@certrev/cert-block 1.3.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +235 -0
- package/README.md +162 -9
- 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 +151 -0
- package/dist/builder/card-contract.d.ts.map +1 -0
- package/dist/builder/card-contract.js +286 -0
- package/dist/builder/card-contract.js.map +1 -0
- 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 +49 -0
- package/dist/builder/card-report.d.ts.map +1 -0
- package/dist/builder/card-report.js +97 -0
- package/dist/builder/card-report.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +7 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +202 -170
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/contract-card.d.ts +70 -0
- package/dist/builder/contract-card.d.ts.map +1 -0
- package/dist/builder/contract-card.js +191 -0
- package/dist/builder/contract-card.js.map +1 -0
- package/dist/builder/delivered-face.d.ts +9 -0
- package/dist/builder/delivered-face.d.ts.map +1 -1
- package/dist/builder/delivered-face.js +18 -5
- package/dist/builder/delivered-face.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +27 -3
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +40 -4
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/index.d.ts +7 -4
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +8 -4
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/ssr-seed.d.ts +23 -4
- package/dist/builder/ssr-seed.d.ts.map +1 -1
- package/dist/builder/ssr-seed.js +29 -6
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/reader/reader-css.generated.d.ts +1 -1
- package/dist/reader/reader-css.generated.d.ts.map +1 -1
- package/dist/reader/reader-css.generated.js +1 -1
- package/dist/reader/reader-css.generated.js.map +1 -1
- package/dist/reader/render.d.ts.map +1 -1
- package/dist/reader/render.js +71 -34
- package/dist/reader/render.js.map +1 -1
- package/dist/reader/tokens.d.ts +2 -0
- package/dist/reader/tokens.d.ts.map +1 -1
- package/dist/reader/tokens.js +28 -6
- package/dist/reader/tokens.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +32 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +30 -5
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/dist/verify/key-set.d.ts +67 -0
- package/dist/verify/key-set.d.ts.map +1 -0
- package/dist/verify/key-set.js +190 -0
- package/dist/verify/key-set.js.map +1 -0
- package/package.json +115 -112
- package/src/builder/card-beacon.ts +201 -0
- package/src/builder/card-contract.ts +336 -0
- package/src/builder/card-placement.ts +208 -0
- package/src/builder/card-report.ts +126 -0
- package/src/builder/cert-review-card.tsx +285 -201
- package/src/builder/contract-card.tsx +216 -0
- package/src/builder/delivered-face.ts +18 -4
- package/src/builder/delivery-runtime.ts +46 -4
- package/src/builder/index.tsx +16 -4
- package/src/builder/ssr-seed.ts +37 -7
- package/src/builder/version.ts +2 -0
- package/src/index.ts +9 -0
- package/src/reader/reader-css.generated.ts +1 -1
- package/src/reader/reader.css +297 -2
- package/src/reader/render.ts +87 -38
- package/src/reader/tokens.ts +29 -6
- package/src/verify/get-verified-delivery.ts +54 -5
- package/src/verify/key-set.ts +225 -0
|
@@ -22,7 +22,7 @@ import * as React from 'react'
|
|
|
22
22
|
import { Suspense } from 'react'
|
|
23
23
|
import { memoToggleFor, toggleMemo } from '../components/memo-toggle.js'
|
|
24
24
|
import { type CertBlockMode, renderCertBlock } from '../components/render-cert-block.js'
|
|
25
|
-
import { CERT_STRINGS_VERSION
|
|
25
|
+
import { CERT_STRINGS_VERSION } from '../components/render-def.js'
|
|
26
26
|
import { type CertPayload, renderVerdict } from '../contract/kernel.js'
|
|
27
27
|
import {
|
|
28
28
|
type DeliveredFace,
|
|
@@ -32,9 +32,28 @@ import {
|
|
|
32
32
|
readVerifiedDelivery,
|
|
33
33
|
type VerifiedDelivery,
|
|
34
34
|
} from '../verify/get-verified-delivery.js'
|
|
35
|
+
import {
|
|
36
|
+
acceptedCardBinding,
|
|
37
|
+
type CardBinding,
|
|
38
|
+
type CardDocumentV1,
|
|
39
|
+
type CardPartName,
|
|
40
|
+
cardDigestFor,
|
|
41
|
+
cardDigestMatches,
|
|
42
|
+
cardMatchesPlacement,
|
|
43
|
+
cardPartsFor,
|
|
44
|
+
parseCardDocument,
|
|
45
|
+
} from './card-contract.js'
|
|
35
46
|
import { CARD_READER_CSS } from './card-reader-css.js'
|
|
47
|
+
import {
|
|
48
|
+
CARD_FALLBACK_REASONS,
|
|
49
|
+
type CardFallbackDetail,
|
|
50
|
+
type CardFallbackReason,
|
|
51
|
+
reportCardFallbackInBrowser,
|
|
52
|
+
reportCardFallbackOnServer,
|
|
53
|
+
} from './card-report.js'
|
|
36
54
|
import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
|
|
37
55
|
import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
|
|
56
|
+
import { ContractCardFace } from './contract-card.js'
|
|
38
57
|
import {
|
|
39
58
|
type DeliveredFaceHashes,
|
|
40
59
|
deliveredFaceDigest,
|
|
@@ -43,10 +62,9 @@ import {
|
|
|
43
62
|
faceHashesFor,
|
|
44
63
|
showsWholeCard,
|
|
45
64
|
} from './delivered-face.js'
|
|
46
|
-
import { cardDeliveryRuntime } from './delivery-runtime.js'
|
|
65
|
+
import { cardDeliveryRuntime, cardResolveKidFor } from './delivery-runtime.js'
|
|
47
66
|
import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
|
|
48
67
|
import {
|
|
49
|
-
type FallbackFace,
|
|
50
68
|
type PageRecord,
|
|
51
69
|
pageHoldsServerHtml,
|
|
52
70
|
pageRecordFor,
|
|
@@ -100,11 +118,13 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
|
100
118
|
readonly context?: { readonly certrevDesignPin?: unknown } | null
|
|
101
119
|
} | null
|
|
102
120
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
121
|
+
* A Delivery read FROM THE BROWSER for a card the server did not render (a client-side navigation, Builder's editor).
|
|
122
|
+
* 2.0.0: ON by default (absent or `true`): it is the only way CertREV's server-rendered card exists after an in-app
|
|
123
|
+
* navigation, and it needs the Delivery API's origin (`https://portal.certrev.com`) in the page's `connect-src`, one
|
|
124
|
+
* of the four CSP sources 2.0 asks for. A read the policy blocks is a read that did not reach CertREV: nothing, and a
|
|
125
|
+
* reported `delivery_unreachable` (2.0.1; 2.0.0 painted the block's options face). `false`: no request from the
|
|
126
|
+
* browser, and such a card prints nothing and reports `client_read_off`. Not a Builder input: a brand sets it in code,
|
|
127
|
+
* on a wrapper of this component.
|
|
108
128
|
*/
|
|
109
129
|
clientRead?: boolean
|
|
110
130
|
}
|
|
@@ -123,28 +143,6 @@ function text(value: unknown): string {
|
|
|
123
143
|
return typeof value === 'string' ? value : ''
|
|
124
144
|
}
|
|
125
145
|
|
|
126
|
-
/** The same rule where ABSENT and EMPTY are different renders (`authorTitle`, `bio`). */
|
|
127
|
-
function optionalText(value: unknown): string | undefined {
|
|
128
|
-
return text(value) || undefined
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The fused credential pair — accepted ONLY when both halves are present AND both are strings.
|
|
133
|
-
*
|
|
134
|
-
* The R1 gate ("a credential never displays without its dated verification") is THIS FUNCTION,
|
|
135
|
-
* not the type: the pair arrives as untyped CMS JSON, and the old truthiness check keyed the
|
|
136
|
-
* whole pair off the OBJECT, so `{credential:'MD'}` forwarded `verifiedAt: undefined` into the
|
|
137
|
-
* renderer and crashed. Anything malformed — a half pair, a bare string, an array, a number
|
|
138
|
-
* where a date belongs — degrades to the name-only face, which is the honest render of a
|
|
139
|
-
* delivery whose verification cannot be read.
|
|
140
|
-
*/
|
|
141
|
-
function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] | undefined) {
|
|
142
|
-
if (!pair || typeof pair !== 'object') return null
|
|
143
|
-
const credential = text(pair.credential)
|
|
144
|
-
const verifiedAt = text(pair.verifiedAt)
|
|
145
|
-
return credential && verifiedAt ? { credential, verifiedAt } : null
|
|
146
|
-
}
|
|
147
|
-
|
|
148
146
|
/**
|
|
149
147
|
* The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
|
|
150
148
|
* `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
|
|
@@ -308,9 +306,13 @@ function EnvelopeFace(props: {
|
|
|
308
306
|
|
|
309
307
|
type PhotoPerson = 'expert' | 'author'
|
|
310
308
|
|
|
311
|
-
/**
|
|
309
|
+
/**
|
|
310
|
+
* Whose avatar a broken photo is. The banner's two bylines take the same face (1.3.1; the author's was the small
|
|
311
|
+
* `.certrev-r-face.sm` before), so the person is read from the byline: the author's carries the plain "Written by"
|
|
312
|
+
* role, the reviewer's "Reviewed by" (`.on`), and the sidebar's and the memo's reviewer has no role at all.
|
|
313
|
+
*/
|
|
312
314
|
function photoPerson(img: HTMLImageElement): PhotoPerson {
|
|
313
|
-
return img.
|
|
315
|
+
return img.closest('.certrev-r-by')?.querySelector('.certrev-r-role:not(.on)') ? 'author' : 'expert'
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
interface EnvelopeCardProps {
|
|
@@ -379,11 +381,14 @@ function deliveredFaceFor(
|
|
|
379
381
|
renderDef: CertChromeRenderDef | null,
|
|
380
382
|
face: DeliveredFace | undefined,
|
|
381
383
|
props: EnvelopeCardProps,
|
|
384
|
+
/** 2.0.0: the hashes the read computed (`VerifiedDelivery.faceHashes`): then nothing suspends here. */
|
|
385
|
+
hashed?: DeliveredFaceHashes | null,
|
|
382
386
|
): DeliveredFace | undefined {
|
|
383
387
|
if (!face || props.design || !showsWholeCard(props.part)) return undefined
|
|
384
388
|
const digest = deliveredFaceDigest(payload.content, renderDef, face)
|
|
385
389
|
if (!digest) return undefined
|
|
386
|
-
|
|
390
|
+
const hashes = hashed !== undefined ? hashed : suspendOnFaceHashes(face)
|
|
391
|
+
return deliveredFaceMatches(hashes, digest) ? face : undefined
|
|
387
392
|
}
|
|
388
393
|
|
|
389
394
|
/**
|
|
@@ -433,64 +438,155 @@ function paintEnvelope(
|
|
|
433
438
|
return <EnvelopeFace html={renderCertBlock(input)} defVersion={defVersion} seed={seed} />
|
|
434
439
|
}
|
|
435
440
|
|
|
436
|
-
|
|
441
|
+
/** Where the server painted no face, an empty hidden element carries the record that says so. */
|
|
442
|
+
function NoFace(props: { readonly record: string }) {
|
|
443
|
+
return <div hidden {...{ [SEED_ATTRIBUTE]: props.record }} />
|
|
444
|
+
}
|
|
437
445
|
|
|
438
446
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* record names the pin and says `'options'`, so a capture can never take it for the pinned design.
|
|
447
|
+
* 2.0.0: reports one fallback (`card-report.ts`) and renders nothing (no element, no marker: it changes no byte of the
|
|
448
|
+
* page). On a server it reports as it renders; in a browser, once it has mounted. 2.0.1: the report is also a beacon to
|
|
449
|
+
* `origin`, the delivery origin the block reads (none: CertREV's production origin).
|
|
443
450
|
*/
|
|
444
|
-
function
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
451
|
+
function FallbackReport(props: {
|
|
452
|
+
readonly detail: CardFallbackDetail
|
|
453
|
+
readonly server: boolean
|
|
454
|
+
readonly origin?: string
|
|
455
|
+
}) {
|
|
456
|
+
const { detail, server, origin } = props
|
|
457
|
+
if (server) reportCardFallbackOnServer(detail, origin)
|
|
458
|
+
React.useEffect(() => {
|
|
459
|
+
reportCardFallbackInBrowser(detail, origin)
|
|
460
|
+
}, [detail, origin])
|
|
461
|
+
return null
|
|
454
462
|
}
|
|
455
463
|
|
|
456
|
-
/**
|
|
457
|
-
function
|
|
464
|
+
/** A face with its fallback report beside it. */
|
|
465
|
+
function withReport(
|
|
466
|
+
face: React.ReactElement | null,
|
|
467
|
+
reason: CardFallbackReason,
|
|
468
|
+
props: EnvelopeCardProps,
|
|
469
|
+
server: boolean,
|
|
470
|
+
certId?: string,
|
|
471
|
+
status?: number,
|
|
472
|
+
): React.ReactElement {
|
|
473
|
+
const detail: CardFallbackDetail = {
|
|
474
|
+
reason,
|
|
475
|
+
platform: 'builder',
|
|
476
|
+
externalId: props.externalId,
|
|
477
|
+
...(certId === undefined ? {} : { certId }),
|
|
478
|
+
...(status === undefined ? {} : { status }),
|
|
479
|
+
}
|
|
458
480
|
return (
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// stamping "[object Object]" as a version.
|
|
464
|
-
data-certrev-strings-version={optionalText(props.block.stringsVersion)}
|
|
465
|
-
data-certrev-def-version={typeof props.block.renderDef?.v === 'number' ? props.block.renderDef.v : undefined}
|
|
466
|
-
{...(props.record === undefined ? {} : { [SEED_ATTRIBUTE]: props.record })}
|
|
467
|
-
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
468
|
-
dangerouslySetInnerHTML={{ __html: props.html }}
|
|
469
|
-
/>
|
|
481
|
+
<>
|
|
482
|
+
{face}
|
|
483
|
+
<FallbackReport detail={detail} server={server} origin={props.baseUrl} />
|
|
484
|
+
</>
|
|
470
485
|
)
|
|
471
486
|
}
|
|
472
487
|
|
|
473
|
-
/**
|
|
474
|
-
|
|
475
|
-
|
|
488
|
+
/**
|
|
489
|
+
* 2.0.0: what a contract-1 (or unsupported-contract) placement prints, decided by the SIGNED binding (`card-contract.ts`):
|
|
490
|
+
* the card when the served document hashes to the signed digest and names this design version, certificate and
|
|
491
|
+
* placement; else nothing, and the reason. Suspends once while WebCrypto hashes a document it has not seen.
|
|
492
|
+
*/
|
|
493
|
+
type ContractOutcome =
|
|
494
|
+
| { readonly kind: 'card'; readonly card: CardDocumentV1; readonly names: CardPartName[] }
|
|
495
|
+
| { readonly kind: 'none'; readonly reason?: CardFallbackReason }
|
|
496
|
+
|
|
497
|
+
function contractOutcome(
|
|
498
|
+
binding: CardBinding | 'unsupported',
|
|
499
|
+
rawCard: unknown,
|
|
500
|
+
payload: CertPayload,
|
|
501
|
+
props: EnvelopeCardProps,
|
|
502
|
+
/** The digest the read computed (`VerifiedDelivery.cardDigest`): then nothing suspends here. */
|
|
503
|
+
digested?: string | null,
|
|
504
|
+
): ContractOutcome {
|
|
505
|
+
if (binding === 'unsupported') return { kind: 'none', reason: 'card_unsupported_contract' }
|
|
506
|
+
// CertREV's replica pin: the signed card was painted for one design version; another pin gets nothing.
|
|
507
|
+
if (props.design && props.design.version !== binding.defVersion) return { kind: 'none', reason: 'card_pin_mismatch' }
|
|
508
|
+
if (rawCard === undefined || rawCard === null) return { kind: 'none', reason: 'card_missing' }
|
|
509
|
+
const card = parseCardDocument(rawCard)
|
|
510
|
+
if (!card || !cardMatchesPlacement(card, binding, payload, props.externalId)) {
|
|
511
|
+
return { kind: 'none', reason: 'card_verify_failed' }
|
|
512
|
+
}
|
|
513
|
+
let digest: string | null
|
|
514
|
+
if (digested !== undefined) {
|
|
515
|
+
digest = digested
|
|
516
|
+
} else {
|
|
517
|
+
// A page's seed: the one suspension of this render (a render that read waits on nothing but its read).
|
|
518
|
+
const memo = cardDigestFor(card)
|
|
519
|
+
digest = memo.settled ? memo.settled.digest : suspendUntil(memo.pending)
|
|
520
|
+
}
|
|
521
|
+
if (!cardDigestMatches(digest, binding)) return { kind: 'none', reason: 'card_verify_failed' }
|
|
522
|
+
// A block with nothing of this design to print (a memo block of a memo-less card, or of a sidebar design) prints
|
|
523
|
+
// nothing, by design: not a fallback.
|
|
524
|
+
const names = cardPartsFor(card, props.part)
|
|
525
|
+
return names.length === 0 ? { kind: 'none' } : { kind: 'card', card, names }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The `'none'` record of a contract-1 placement, with its reason when it is a fallback. */
|
|
529
|
+
function contractNone(
|
|
530
|
+
props: EnvelopeCardProps,
|
|
531
|
+
reason: CardFallbackReason | undefined,
|
|
532
|
+
server: boolean,
|
|
533
|
+
certId: string,
|
|
534
|
+
): React.ReactElement {
|
|
535
|
+
const record = serializeFallback({
|
|
536
|
+
baseUrl: props.baseUrl,
|
|
537
|
+
externalId: props.externalId,
|
|
538
|
+
...(props.design ? { design: props.design } : {}),
|
|
539
|
+
face: 'none',
|
|
540
|
+
...(reason ? { reason } : {}),
|
|
541
|
+
})
|
|
542
|
+
const face = <NoFace record={record} />
|
|
543
|
+
return reason ? withReport(face, reason, props, server, certId) : face
|
|
476
544
|
}
|
|
477
545
|
|
|
478
|
-
/**
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
546
|
+
/**
|
|
547
|
+
* What a card with no verified envelope paints: nothing, and a `'none'` record. 2.0.1: CertREV not reached prints
|
|
548
|
+
* nothing too (it painted the block's export-time options through 2.0.0), and the record names `delivery_unreachable`
|
|
549
|
+
* so the browser reports it as well (with CertREV's HTTP status when it answered with one); an unknown kid is reported as
|
|
550
|
+
* `unknown_key`, and (2.0.1) an envelope that does not verify as `envelope_verify_failed`. A definitive no (revoked,
|
|
551
|
+
* expired, for another entry, not found) prints nothing, by design, unreported.
|
|
552
|
+
*/
|
|
553
|
+
function settleWithoutEnvelope(
|
|
554
|
+
props: EnvelopeCardProps,
|
|
555
|
+
verdict: { readonly unreachable: boolean; readonly reason?: string; readonly status?: number },
|
|
556
|
+
server: boolean,
|
|
557
|
+
): React.ReactElement {
|
|
558
|
+
const reason: CardFallbackReason | undefined = verdict.unreachable
|
|
559
|
+
? 'delivery_unreachable'
|
|
560
|
+
: verdict.reason === 'unknown_key'
|
|
561
|
+
? 'unknown_key'
|
|
562
|
+
: verdict.reason !== undefined && ENVELOPE_VERIFY_FAILURES.has(verdict.reason)
|
|
563
|
+
? 'envelope_verify_failed'
|
|
564
|
+
: undefined
|
|
482
565
|
const record = serializeFallback({
|
|
483
566
|
baseUrl: props.baseUrl,
|
|
484
567
|
externalId: props.externalId,
|
|
485
568
|
...(props.design ? { design: props.design } : {}),
|
|
486
|
-
face,
|
|
569
|
+
face: 'none',
|
|
570
|
+
...(reason ? { reason } : {}),
|
|
487
571
|
})
|
|
488
|
-
|
|
572
|
+
const none = <NoFace record={record} />
|
|
573
|
+
return reason
|
|
574
|
+
? withReport(none, reason, props, server, undefined, verdict.unreachable ? verdict.status : undefined)
|
|
575
|
+
: none
|
|
489
576
|
}
|
|
490
577
|
|
|
578
|
+
/** 2.0.1: the kernel's suppress reasons that mean the envelope itself did not verify (reported, never silent). */
|
|
579
|
+
const ENVELOPE_VERIFY_FAILURES: ReadonlySet<string> = new Set([
|
|
580
|
+
'invalid_signature',
|
|
581
|
+
'unsupported_alg',
|
|
582
|
+
'platform_mismatch',
|
|
583
|
+
'content_drift',
|
|
584
|
+
'unsupported_contract_version',
|
|
585
|
+
])
|
|
586
|
+
|
|
491
587
|
/**
|
|
492
588
|
* Paint what the page's record for this placement says: a seed its envelope (judged at the browser's clock first when
|
|
493
|
-
* `judge`),
|
|
589
|
+
* `judge`), a `'none'` record nothing, and (2.0.1) a 2.0.0 server's `'options'` record nothing too.
|
|
494
590
|
*/
|
|
495
591
|
function paintRecord(
|
|
496
592
|
record: PageRecord,
|
|
@@ -506,9 +602,24 @@ function paintRecord(
|
|
|
506
602
|
return null
|
|
507
603
|
}
|
|
508
604
|
}
|
|
605
|
+
const { seed } = record
|
|
606
|
+
// 2.0.0: a seed whose signed binding names a contract prints the card it carries, only after the browser has
|
|
607
|
+
// checked it against that binding itself; otherwise nothing. Never a 1.3.1 face for such a placement.
|
|
608
|
+
const binding = acceptedCardBinding(seed.payload.content)
|
|
609
|
+
if (binding !== null) {
|
|
610
|
+
const outcome = contractOutcome(binding, seed.card, seed.payload, props)
|
|
611
|
+
if (outcome.kind === 'card')
|
|
612
|
+
return <ContractCardFace card={outcome.card} names={outcome.names} seed={record.raw} origin={props.baseUrl} />
|
|
613
|
+
// Hydrating over the card the server printed, which the browser could not confirm (no WebCrypto): adopt the
|
|
614
|
+
// server's element; the render right after hydration prints nothing in its place.
|
|
615
|
+
if (!judge && outcome.reason === 'card_verify_failed' && seed.card !== undefined)
|
|
616
|
+
return <ServerFaceUntilHydrated />
|
|
617
|
+
// Nothing of this design for this block (a memo block of a sidebar card): nothing, and no record of its own.
|
|
618
|
+
if (outcome.reason === undefined) return null
|
|
619
|
+
return contractNone(props, outcome.reason, false, seed.payload.certId)
|
|
620
|
+
}
|
|
509
621
|
// 1.3.0: a seed that names a delivered face repaints it only after the browser hashes it against the seed
|
|
510
622
|
// payload's own digest; otherwise the seed paints what 1.2.1 paints for it.
|
|
511
|
-
const { seed } = record
|
|
512
623
|
const delivered = deliveredFaceFor(seed.payload, seed.renderDef, seed.face, props)
|
|
513
624
|
if (delivered && seed.renderDef) {
|
|
514
625
|
return <DeliveredEnvelopeFace face={delivered} defVersion={seed.renderDef.v} seed={record.raw} />
|
|
@@ -517,18 +628,21 @@ function paintRecord(
|
|
|
517
628
|
if (seed.face && !judge) return <ServerFaceUntilHydrated />
|
|
518
629
|
return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
|
|
519
630
|
}
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
)
|
|
631
|
+
const none = <NoFace record={record.raw} />
|
|
632
|
+
// 2.0.1: an `'options'` record (a page a 2.0.0 server rendered while CertREV was not reached) prints nothing too.
|
|
633
|
+
if (record.fallback.face === 'options') return withReport(none, 'delivery_unreachable', props, false)
|
|
634
|
+
// 2.0.0: a contract-1 placement's record names why it printed nothing; the browser reports it too.
|
|
635
|
+
const reason = record.fallback.reason
|
|
636
|
+
return isFallbackReason(reason) ? withReport(none, reason, props, false) : none
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function isFallbackReason(value: unknown): value is CardFallbackReason {
|
|
640
|
+
return typeof value === 'string' && (CARD_FALLBACK_REASONS as readonly string[]).includes(value)
|
|
526
641
|
}
|
|
527
642
|
|
|
528
|
-
/**
|
|
529
|
-
function clientMountFace(props: EnvelopeCardProps): React.ReactElement
|
|
530
|
-
|
|
531
|
-
return html === null ? null : <OptionsFace block={props.block} html={html} />
|
|
643
|
+
/** 2.0.1: a card the client mounts with no record and `clientRead` off prints nothing (2.0.0: the options face). */
|
|
644
|
+
function clientMountFace(props: EnvelopeCardProps): React.ReactElement {
|
|
645
|
+
return withReport(null, 'client_read_off', props, false)
|
|
532
646
|
}
|
|
533
647
|
|
|
534
648
|
const pendingRecord = () => 'pending' as const
|
|
@@ -549,8 +663,9 @@ function StreamedRecordCard(props: EnvelopeCardProps) {
|
|
|
549
663
|
pendingRecord,
|
|
550
664
|
)
|
|
551
665
|
if (record === 'pending') return null
|
|
552
|
-
if (record === 'done') return clientMountFace(props)
|
|
553
666
|
const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
|
|
667
|
+
// 2.0.0: the page loaded with no record for this placement: with `clientRead` (the default), the one read.
|
|
668
|
+
if (record === 'done') return props.clientRead ? readAndPaint(props, context, true) : clientMountFace(props)
|
|
554
669
|
return paintRecord(record, props, context, true)
|
|
555
670
|
}
|
|
556
671
|
|
|
@@ -581,23 +696,24 @@ function useHydrating(): boolean {
|
|
|
581
696
|
* as through 1.1.1, and write a record of what it painted (`ssr-seed.ts`):
|
|
582
697
|
* - a verified envelope: its face and the envelope seed (less the fields the def hides, and with no signature if
|
|
583
698
|
* it hid any);
|
|
584
|
-
* - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON)
|
|
585
|
-
*
|
|
699
|
+
* - CertREV not reached (a timeout, a network error, a 5xx, a body that is not JSON) with no verified answer inside
|
|
700
|
+
* the stale window: nothing, and a `'none'` record naming `delivery_unreachable` (2.0.1; through 2.0.0 the
|
|
701
|
+
* block's own options face and an `'options'` record);
|
|
586
702
|
* - a definitive no (revoked, expired, unverifiable, for another entry, not found): nothing, and a `'none'` record
|
|
587
703
|
* on an empty hidden element.
|
|
588
704
|
* - Browser, the page carries this placement's record: paint what it says. A seed paints its envelope; while
|
|
589
705
|
* hydrating the server's verdict stands (the tree is the server's, byte for byte), and once hydrated the lifecycle
|
|
590
|
-
* and subject are judged at the browser's clock, so a card that expired since the server render is taken down.
|
|
591
|
-
* `'
|
|
706
|
+
* and subject are judged at the browser's clock, so a card that expired since the server render is taken down. A
|
|
707
|
+
* `'none'` record, and (2.0.1) a 2.0.0 server's `'options'` record, print nothing. This holds when React renders the card
|
|
592
708
|
* again on the client after a hydration error elsewhere on the page: the record is still in the document (or in
|
|
593
709
|
* the page's memory of it), so the browser repaints the server's answer and reads nothing.
|
|
594
710
|
* - Browser, hydrating with no record: server HTML from before 1.2.0 painted nothing here, so nothing.
|
|
595
711
|
* - Browser, a card mounted on the client with no record (a client-side navigation to an article this page never
|
|
596
|
-
* server-rendered, Builder's editor):
|
|
597
|
-
*
|
|
712
|
+
* server-rendered, Builder's editor): with `clientRead` (the default), the one read, settled exactly as on the
|
|
713
|
+
* server; with `clientRead={false}`, nothing (`client_read_off`; 2.0.0: the block's options face).
|
|
598
714
|
* - Browser, the same while the document is still loading (1.2.1; React rendered the card on the client before the
|
|
599
|
-
* server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; the
|
|
600
|
-
*
|
|
715
|
+
* server's streamed record arrived, `ssr-seed.ts`): nothing until the record arrives, then what it says; if the page
|
|
716
|
+
* loads without one, what a client mount does (`StreamedRecordCard`). `clientRead` reads as above.
|
|
601
717
|
*
|
|
602
718
|
* WHICH OF THE TWO FIRST CASES THIS RENDER IS (server or browser) is decided from what the render can observe, with
|
|
603
719
|
* no switch a test could set. A server render and a hydration are both "hydrating" to React and both see a `document`
|
|
@@ -631,8 +747,9 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
631
747
|
// 1.2.1, latched at mount: a client mount with no record and no `clientRead` while the page is still loading waits
|
|
632
748
|
// for a record the server may still be streaming, for the card's whole life (so it never swaps components when the
|
|
633
749
|
// record lands).
|
|
750
|
+
// 2.0.0: whatever `clientRead` says: a card that read instead of waiting would miss the record the server streams.
|
|
634
751
|
const [awaitsStream] = React.useState(
|
|
635
|
-
() => inBrowser && !hydrating && fromPage === undefined &&
|
|
752
|
+
() => inBrowser && !hydrating && fromPage === undefined && document.readyState === 'loading',
|
|
636
753
|
)
|
|
637
754
|
if (awaitsStream) return <StreamedRecordCard {...props} />
|
|
638
755
|
if (inBrowser) {
|
|
@@ -640,6 +757,19 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
640
757
|
if (hydratedMount) return null
|
|
641
758
|
if (!props.clientRead) return clientMountFace(props)
|
|
642
759
|
}
|
|
760
|
+
return readAndPaint(props, context, inBrowser)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The Delivery read and its paint: a server render's, or a browser's (`clientRead`). Suspends on the read, and (1.3.0)
|
|
765
|
+
* on a delivered face's hashes or (2.0.0) on a card document's digest.
|
|
766
|
+
*/
|
|
767
|
+
function readAndPaint(
|
|
768
|
+
props: EnvelopeCardProps,
|
|
769
|
+
context: Parameters<typeof renderVerdict>[1],
|
|
770
|
+
inBrowser: boolean,
|
|
771
|
+
): React.ReactElement | null {
|
|
772
|
+
const rt = cardDeliveryRuntime()
|
|
643
773
|
const { design } = props
|
|
644
774
|
const read = {
|
|
645
775
|
baseUrl: props.baseUrl,
|
|
@@ -648,7 +778,7 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
648
778
|
context,
|
|
649
779
|
cache: rt.cache,
|
|
650
780
|
design,
|
|
651
|
-
resolveKid: rt.
|
|
781
|
+
resolveKid: cardResolveKidFor(rt, props.baseUrl),
|
|
652
782
|
fetchImpl: rt.fetchImpl(),
|
|
653
783
|
timeoutMs: rt.timeoutMs,
|
|
654
784
|
}
|
|
@@ -656,7 +786,17 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
656
786
|
? `${props.baseUrl}|${props.externalId}|design=${design.version}|${design.brand ?? ''}`
|
|
657
787
|
: `${props.baseUrl}|${props.externalId}`
|
|
658
788
|
const settled = readVerifiedDelivery(read) ?? suspendOn(key, () => getVerifiedDelivery(read))
|
|
659
|
-
if (settled.verdict.decision !== 'render')
|
|
789
|
+
if (settled.verdict.decision !== 'render') {
|
|
790
|
+
return settleWithoutEnvelope(
|
|
791
|
+
props,
|
|
792
|
+
{
|
|
793
|
+
unreachable: settled.unreachable === true,
|
|
794
|
+
reason: settled.verdict.reason,
|
|
795
|
+
...(settled.status === undefined ? {} : { status: settled.status }),
|
|
796
|
+
},
|
|
797
|
+
!inBrowser,
|
|
798
|
+
)
|
|
799
|
+
}
|
|
660
800
|
// The face is painted from the seed's payload (what the def hides is already out of it), so the server's HTML and
|
|
661
801
|
// the browser's, which paints from the seed, are the same bytes and neither holds a hidden field.
|
|
662
802
|
const seed = seedFor({
|
|
@@ -667,9 +807,29 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
667
807
|
signature: settled.signature,
|
|
668
808
|
renderDef: settled.renderDef,
|
|
669
809
|
})
|
|
810
|
+
// 2.0.0: a placement whose signed binding names a contract prints CertREV's card, or nothing; never a face of its own.
|
|
811
|
+
const binding = acceptedCardBinding(seed.payload.content)
|
|
812
|
+
if (binding !== null) {
|
|
813
|
+
const outcome = contractOutcome(binding, settled.card, seed.payload, props, settled.cardDigest)
|
|
814
|
+
if (outcome.kind === 'card') {
|
|
815
|
+
return (
|
|
816
|
+
<ContractCardFace
|
|
817
|
+
card={outcome.card}
|
|
818
|
+
names={outcome.names}
|
|
819
|
+
seed={serializeSeed({ ...seed, card: outcome.card as unknown as Readonly<Record<string, unknown>> })}
|
|
820
|
+
origin={props.baseUrl}
|
|
821
|
+
/>
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
// Nothing of this design for this block, by design: nothing at all, and NO record. A record here would sit under the
|
|
825
|
+
// same placement as the block that printed the card (a split header and memo pair) and, read last on hydration,
|
|
826
|
+
// would take that block's card down (break-it B2).
|
|
827
|
+
if (outcome.reason === undefined) return null
|
|
828
|
+
return contractNone(props, outcome.reason, !inBrowser, seed.payload.certId)
|
|
829
|
+
}
|
|
670
830
|
// 1.3.0: the server-rendered face the Delivery API served beside the envelope, when its bytes hash to the digest the
|
|
671
831
|
// verified payload signs; the seed then carries it, so the browser can repaint (and re-check) the same face.
|
|
672
|
-
const delivered = deliveredFaceFor(seed.payload, seed.renderDef, settled.face, props)
|
|
832
|
+
const delivered = deliveredFaceFor(seed.payload, seed.renderDef, settled.face, props, settled.faceHashes)
|
|
673
833
|
if (delivered && seed.renderDef) {
|
|
674
834
|
return (
|
|
675
835
|
<DeliveredEnvelopeFace
|
|
@@ -683,116 +843,40 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
683
843
|
}
|
|
684
844
|
|
|
685
845
|
export function CertReviewCard(props: CertReviewCardProps) {
|
|
686
|
-
// 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
// 1.2.0:
|
|
692
|
-
// with the marker paints this options face too, pinned or not: see `EnvelopeCertReviewCard`.
|
|
846
|
+
// 1.1.0: a block that carries the delivery marker paints from the signed envelope and the brand's def, never from
|
|
847
|
+
// the options beside it. 2.0.1: a block WITHOUT the marker (an entry exported before 1.1.0, a block dropped in by
|
|
848
|
+
// hand) prints nothing at all and reports `no_marker`; through 2.0.0 it painted its export-time options (a reviewer
|
|
849
|
+
// name, a credential, a memo, a verify link nobody had signed). A marker this client does not accept, or a block
|
|
850
|
+
// with no Builder entry to read for, prints nothing and reports `marker_invalid`.
|
|
851
|
+
// 1.2.0-canary.5: a design pin (CertREV's canary replica only) that cannot be read renders nothing.
|
|
693
852
|
const design = parseDesignPin(props.builderContext?.context?.certrevDesignPin)
|
|
694
853
|
if (design === 'invalid') return null
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
if (design) return null
|
|
714
|
-
|
|
715
|
-
const html = optionsFaceHtml(props, 'banner')
|
|
716
|
-
if (html === null) return null // no chrome pre-cert (or an unreadable delivery)
|
|
717
|
-
return <OptionsFace block={props} html={html} />
|
|
854
|
+
const entryId = text(props.builderContext?.content?.id)
|
|
855
|
+
if (props.delivery == null) return <BlockReport reason="no_marker" externalId={entryId} />
|
|
856
|
+
const marker = acceptedDeliveryMarker(props.delivery)
|
|
857
|
+
if (!marker || !entryId) return <BlockReport reason="marker_invalid" externalId={entryId} />
|
|
858
|
+
return (
|
|
859
|
+
<Suspense fallback={null}>
|
|
860
|
+
<EnvelopeCertReviewCard
|
|
861
|
+
baseUrl={marker.baseUrl}
|
|
862
|
+
externalId={entryId}
|
|
863
|
+
mode={props.mode}
|
|
864
|
+
part={props.part}
|
|
865
|
+
design={design}
|
|
866
|
+
block={props}
|
|
867
|
+
clientRead={props.clientRead !== false}
|
|
868
|
+
/>
|
|
869
|
+
</Suspense>
|
|
870
|
+
)
|
|
718
871
|
}
|
|
719
872
|
|
|
720
|
-
/**
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const verifyUrl = text(props.verifyUrl)
|
|
728
|
-
if (!reviewerName || !verifyUrl) return null
|
|
729
|
-
|
|
730
|
-
// Name-only face when the pair is unreadable, or when the brand set showCredentials:false.
|
|
731
|
-
const showCred = props.display?.showCredentials !== false
|
|
732
|
-
const withCred = showCred ? fusedCredential(props.credentialVerification) : null
|
|
733
|
-
|
|
734
|
-
const tokens = props.renderDef?.tokens
|
|
735
|
-
const theme: Partial<ResolvedBlockTheme> | undefined = tokens
|
|
736
|
-
? {
|
|
737
|
-
accentColor: optionalText(tokens.accentColor),
|
|
738
|
-
surface: optionalText(tokens.surface),
|
|
739
|
-
cornerRadius: optionalText(tokens.cornerRadius),
|
|
740
|
-
fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
|
|
741
|
-
// v2 brand-ink (post-demo): the bar + free-content ink follow the def.
|
|
742
|
-
barInk: optionalText(tokens.barInk),
|
|
743
|
-
inkColor: optionalText(tokens.inkColor),
|
|
744
|
-
}
|
|
745
|
-
: undefined
|
|
746
|
-
|
|
747
|
-
return renderCertBlock({
|
|
748
|
-
// COMPATIBILITY BRIDGE, NOT THE DESIGN. Delete this fallback once the exporter emits an
|
|
749
|
-
// explicit `mode` and existing entries have been backfilled; until then it must stay.
|
|
750
|
-
//
|
|
751
|
-
// `renderCertBlock` owns the ONE placement default (absent or unrecognised ⇒ sidebar) and
|
|
752
|
-
// that decision STANDS: it is what a direct caller gets, and `sidebar` is the registered
|
|
753
|
-
// input's `defaultValue`, so every block created in the editor from now on carries an
|
|
754
|
-
// explicit `mode` and never reaches this line.
|
|
755
|
-
//
|
|
756
|
-
// The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
|
|
757
|
-
// the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
|
|
758
|
-
// projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
|
|
759
|
-
// key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
|
|
760
|
-
// banner face. Removing it in 1.0.0 did not give them the renderer's default in the
|
|
761
|
-
// abstract; it silently repainted live pages from banner to sidebar, with no entry edit
|
|
762
|
-
// and no version pin to protect them, because their install line is unpinned.
|
|
763
|
-
//
|
|
764
|
-
// So this is a bridge for pre-existing entries, NOT a reversal of the sidebar default.
|
|
765
|
-
// The three-copies-of-a-default problem that motivated removing it is still real and is
|
|
766
|
-
// still fixed: the helper text and the wire type's doc comment no longer name a default,
|
|
767
|
-
// and this line names one only for input the renderer can no longer distinguish from a
|
|
768
|
-
// deliberate choice. The exporter writing `mode` is what retires it.
|
|
769
|
-
mode: props.mode ?? bridge ?? 'banner',
|
|
770
|
-
theme,
|
|
771
|
-
// First-class banner memo split — renderCertBlock composes the requested
|
|
772
|
-
// part directly; no string-splitting. Ignored outside banner mode, and an unrecognised
|
|
773
|
-
// value is normalised to 'full' there, same as `mode` — not here.
|
|
774
|
-
...(props.part ? { part: props.part } : {}),
|
|
775
|
-
facts: {
|
|
776
|
-
authorName: optionalText(props.authorName) ?? 'Editorial',
|
|
777
|
-
authorTitle: optionalText(props.authorTitle),
|
|
778
|
-
reviewerName,
|
|
779
|
-
credential: withCred?.credential ?? '',
|
|
780
|
-
credentialVerifiedAt: withCred?.verifiedAt ?? '',
|
|
781
|
-
certifiedAt: text(props.certifiedAt),
|
|
782
|
-
memo: text(props.memo),
|
|
783
|
-
bio: optionalText(props.bio),
|
|
784
|
-
profileUrl: text(props.reviewerProfileUrl),
|
|
785
|
-
certificateUrl: verifyUrl,
|
|
786
|
-
// The compliance disclosures are CONSTANTS the renderer owns — a delivered cue is
|
|
787
|
-
// IGNORED (an editor cannot retype the FTC copy into something friendlier), and the
|
|
788
|
-
// scope line is not forwarded at all. The ONE thing honored is an explicit `null`:
|
|
789
|
-
// the pro-bono reviewer has no compensation to disclose, so the cue is omitted
|
|
790
|
-
// rather than replaced by the compensated constant (the modal already gets this
|
|
791
|
-
// right — a card claiming "Compensated expert" over a volunteer is an FTC-facing
|
|
792
|
-
// falsehood, not a cosmetic default).
|
|
793
|
-
compensationCue: props.compensationCue === null ? null : undefined,
|
|
794
|
-
},
|
|
795
|
-
})
|
|
873
|
+
/** 2.0.1: a block with nothing to read reports why, to CertREV's production origin (it has no delivery origin). */
|
|
874
|
+
function BlockReport(props: { readonly reason: CardFallbackReason; readonly externalId: string }) {
|
|
875
|
+
const detail = React.useMemo<CardFallbackDetail>(
|
|
876
|
+
() => ({ reason: props.reason, platform: 'builder', externalId: props.externalId }),
|
|
877
|
+
[props.reason, props.externalId],
|
|
878
|
+
)
|
|
879
|
+
return <FallbackReport detail={detail} server={typeof document === 'undefined'} />
|
|
796
880
|
}
|
|
797
881
|
|
|
798
882
|
// ─────────────────────────────────────────────────────────────────────────────
|