@certrev/cert-block 1.2.1-canary.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/README.md +12 -1
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +68 -3
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivered-face.d.ts +74 -0
- package/dist/builder/delivered-face.d.ts.map +1 -0
- package/dist/builder/delivered-face.js +122 -0
- package/dist/builder/delivered-face.js.map +1 -0
- package/dist/builder/delivery-runtime.d.ts +1 -1
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +3 -1
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/index.d.ts +4 -1
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +4 -1
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/ssr-seed.d.ts +13 -1
- package/dist/builder/ssr-seed.d.ts.map +1 -1
- package/dist/builder/ssr-seed.js +14 -2
- package/dist/builder/ssr-seed.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +28 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +29 -3
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +112 -112
- package/src/builder/cert-review-card.tsx +96 -4
- package/src/builder/delivered-face.ts +142 -0
- package/src/builder/delivery-runtime.ts +3 -1
- package/src/builder/index.tsx +4 -1
- package/src/builder/ssr-seed.ts +25 -2
- package/src/index.ts +3 -0
- package/src/verify/get-verified-delivery.ts +45 -3
package/package.json
CHANGED
|
@@ -1,113 +1,113 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
2
|
+
"name": "@certrev/cert-block",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Headless render edge for the CertREV CertDeliveryEnvelope: SSR-safe React cert components, a framework-agnostic <certrev-badge> Web Component, a schema.org JSON-LD projector, and a fail-closed verify layer over the shared VerdictKernel.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/CertREV/cert-kit.git",
|
|
8
|
+
"directory": "packages/cert-block"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/CertREV/cert-kit/tree/HEAD/packages/cert-block#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/CertREV/cert-kit/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./builder": {
|
|
23
|
+
"types": "./dist/builder/index.d.ts",
|
|
24
|
+
"default": "./dist/builder/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./builder/face": {
|
|
27
|
+
"types": "./dist/builder/envelope-face.d.ts",
|
|
28
|
+
"default": "./dist/builder/envelope-face.js"
|
|
29
|
+
},
|
|
30
|
+
"./reader": {
|
|
31
|
+
"types": "./dist/reader/index.d.ts",
|
|
32
|
+
"default": "./dist/reader/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./reader.css": "./src/reader/reader.css",
|
|
35
|
+
"./webcomponent": {
|
|
36
|
+
"types": "./dist/webcomponent/certrev-badge.d.ts",
|
|
37
|
+
"default": "./dist/webcomponent/certrev-badge.js"
|
|
38
|
+
},
|
|
39
|
+
"./fixtures": {
|
|
40
|
+
"types": "./dist/contract/fixtures.d.ts",
|
|
41
|
+
"default": "./dist/contract/fixtures.js"
|
|
42
|
+
},
|
|
43
|
+
"./modal": {
|
|
44
|
+
"types": "./dist/modal/index.d.ts",
|
|
45
|
+
"default": "./dist/modal/index.js"
|
|
46
|
+
},
|
|
47
|
+
"./modal/placement": {
|
|
48
|
+
"types": "./dist/modal/placement.d.ts",
|
|
49
|
+
"default": "./dist/modal/placement.js"
|
|
50
|
+
},
|
|
51
|
+
"./modal/certrev-cert.js": "./dist/modal/certrev-cert.js"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"dist",
|
|
55
|
+
"src",
|
|
56
|
+
"!src/**/__tests__",
|
|
57
|
+
"!src/**/*.test.ts",
|
|
58
|
+
"!src/**/*.test.tsx",
|
|
59
|
+
"README.md",
|
|
60
|
+
"CHANGELOG.md"
|
|
61
|
+
],
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"registry": "https://registry.npmjs.org",
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"publishTargets": [
|
|
67
|
+
"npmjs",
|
|
68
|
+
"github-packages"
|
|
69
|
+
],
|
|
70
|
+
"scripts": {
|
|
71
|
+
"build": "tsc && pnpm build:modal-bundle",
|
|
72
|
+
"build:modal-bundle": "pnpm dlx esbuild@0.27.7 src/modal/certrev-cert.entry.ts --bundle --minify --target=es2020 --format=iife --legal-comments=none --tsconfig-raw='{\"compilerOptions\":{\"target\":\"ES2017\",\"module\":\"esnext\",\"strict\":true}}' --banner:js='/* biome-ignore-all format: generated cert-badge bundle, see @certrev/cert-block src/modal/ */ /* biome-ignore-all lint: generated cert-badge bundle, see @certrev/cert-block src/modal/ */' --outfile=dist/modal/certrev-cert.js",
|
|
73
|
+
"refresh:reader": "node scripts/refresh-reader.mjs",
|
|
74
|
+
"typecheck": "tsc --noEmit",
|
|
75
|
+
"test": "vitest run"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"@certrev/cert-contract": "^0.6.0"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"react": ">=18.0.0"
|
|
82
|
+
},
|
|
83
|
+
"peerDependenciesMeta": {
|
|
84
|
+
"react": {
|
|
85
|
+
"optional": false
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@testing-library/react": "^16.1.0",
|
|
90
|
+
"@types/node": "^20.0.0",
|
|
91
|
+
"@types/react": "^18.3.0",
|
|
92
|
+
"@types/react-dom": "^18.3.0",
|
|
93
|
+
"jsdom": "^25.0.0",
|
|
94
|
+
"react": "^18.3.1",
|
|
95
|
+
"react-dom": "^18.3.1",
|
|
96
|
+
"typescript": "^6.0.3",
|
|
97
|
+
"vitest": "^4.1.5"
|
|
98
|
+
},
|
|
99
|
+
"keywords": [
|
|
100
|
+
"certrev",
|
|
101
|
+
"certification",
|
|
102
|
+
"react",
|
|
103
|
+
"server-components",
|
|
104
|
+
"hydrogen",
|
|
105
|
+
"shopify",
|
|
106
|
+
"json-ld",
|
|
107
|
+
"schema-org",
|
|
108
|
+
"web-component",
|
|
109
|
+
"ed25519",
|
|
110
|
+
"ssr"
|
|
111
|
+
],
|
|
112
|
+
"license": "MIT"
|
|
113
|
+
}
|
|
@@ -25,6 +25,7 @@ import { type CertBlockMode, renderCertBlock } from '../components/render-cert-b
|
|
|
25
25
|
import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
|
|
26
26
|
import { type CertPayload, renderVerdict } from '../contract/kernel.js'
|
|
27
27
|
import {
|
|
28
|
+
type DeliveredFace,
|
|
28
29
|
type DesignPin,
|
|
29
30
|
getVerifiedDelivery,
|
|
30
31
|
parseDesignPin,
|
|
@@ -34,6 +35,14 @@ import {
|
|
|
34
35
|
import { CARD_READER_CSS } from './card-reader-css.js'
|
|
35
36
|
import type { BuilderCertChromeData, CertChromeRenderDef } from './cert-chrome-data.js'
|
|
36
37
|
import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
|
|
38
|
+
import {
|
|
39
|
+
type DeliveredFaceHashes,
|
|
40
|
+
deliveredFaceDigest,
|
|
41
|
+
deliveredFaceHtml,
|
|
42
|
+
deliveredFaceMatches,
|
|
43
|
+
faceHashesFor,
|
|
44
|
+
showsWholeCard,
|
|
45
|
+
} from './delivered-face.js'
|
|
37
46
|
import { cardDeliveryRuntime } from './delivery-runtime.js'
|
|
38
47
|
import { envelopeCardHtml, envelopeCardInput, isReaderFaceDef } from './envelope-face.js'
|
|
39
48
|
import {
|
|
@@ -172,11 +181,25 @@ function suspendOn(key: string, start: () => Promise<VerifiedDelivery>): Verifie
|
|
|
172
181
|
pending = start().finally(() => inflightReads.delete(key))
|
|
173
182
|
inflightReads.set(key, pending)
|
|
174
183
|
}
|
|
175
|
-
|
|
184
|
+
return suspendUntil(pending)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `use` where React has it (19), else the thrown promise (18). */
|
|
188
|
+
function suspendUntil<T>(pending: Promise<T>): T {
|
|
189
|
+
const use = (React as unknown as { use?: <U>(p: Promise<U>) => U }).use
|
|
176
190
|
if (typeof use === 'function') return use(pending)
|
|
177
191
|
throw pending
|
|
178
192
|
}
|
|
179
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 1.3.0: a delivered face's two digests, suspending once while WebCrypto computes them (`faceHashesFor` memoizes them
|
|
196
|
+
* by the face's content, so every later render of the same face, server or browser, reads them at once).
|
|
197
|
+
*/
|
|
198
|
+
function suspendOnFaceHashes(face: DeliveredFace): DeliveredFaceHashes | null {
|
|
199
|
+
const memo = faceHashesFor(face)
|
|
200
|
+
return memo.settled ? memo.settled.hashes : suspendUntil(memo.pending)
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
/**
|
|
181
204
|
* 1.2.0-canary.6: the reader sidebar card is a right float that the article's copy flows around, as it does on a
|
|
182
205
|
* WordPress or Shopify page. On a Builder page the card sits in its own block, a flex item of the flex column that holds
|
|
@@ -225,6 +248,8 @@ function EnvelopeFace(props: {
|
|
|
225
248
|
readonly html: string
|
|
226
249
|
readonly defVersion: number | undefined
|
|
227
250
|
readonly reader?: boolean
|
|
251
|
+
/** 1.3.0: the server-rendered face the signed digest binds (`DeliveredEnvelopeFace`); never with `reader`. */
|
|
252
|
+
readonly delivered?: boolean
|
|
228
253
|
/** The envelope seed (`ssr-seed.ts`): what a browser hydrating this face paints from, with no read of its own. */
|
|
229
254
|
readonly seed: string
|
|
230
255
|
/** 1.2.0, reader faces: told which person's photo the browser could not load (`ReaderEnvelopeFace`). */
|
|
@@ -270,8 +295,12 @@ function EnvelopeFace(props: {
|
|
|
270
295
|
data-certrev-strings-version={CERT_STRINGS_VERSION}
|
|
271
296
|
data-certrev-def-version={props.defVersion}
|
|
272
297
|
{...{ [SEED_ATTRIBUTE]: props.seed }}
|
|
273
|
-
{...(props.reader
|
|
274
|
-
|
|
298
|
+
{...(props.reader
|
|
299
|
+
? { 'data-certrev-face': 'reader', style: { display: 'flow-root' } }
|
|
300
|
+
: props.delivered
|
|
301
|
+
? { 'data-certrev-face': 'delivered' }
|
|
302
|
+
: {})}
|
|
303
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer; a delivered face paints only when its bytes hash to the signed faceDigest
|
|
275
304
|
dangerouslySetInnerHTML={{ __html: props.html }}
|
|
276
305
|
/>,
|
|
277
306
|
)
|
|
@@ -340,6 +369,48 @@ function ReaderEnvelopeFace(props: {
|
|
|
340
369
|
)
|
|
341
370
|
}
|
|
342
371
|
|
|
372
|
+
/**
|
|
373
|
+
* 1.3.0: the delivered face this paint may use, or undefined (paint what 1.2.1 paints). Only for an unpinned card on a
|
|
374
|
+
* block that shows the whole card, a digest the verified content signs for the def's version, and a face whose UTF-8
|
|
375
|
+
* bytes hash to it (`delivered-face.ts`). Suspends once while WebCrypto hashes a face it has not seen.
|
|
376
|
+
*/
|
|
377
|
+
function deliveredFaceFor(
|
|
378
|
+
payload: CertPayload,
|
|
379
|
+
renderDef: CertChromeRenderDef | null,
|
|
380
|
+
face: DeliveredFace | undefined,
|
|
381
|
+
props: EnvelopeCardProps,
|
|
382
|
+
): DeliveredFace | undefined {
|
|
383
|
+
if (!face || props.design || !showsWholeCard(props.part)) return undefined
|
|
384
|
+
const digest = deliveredFaceDigest(payload.content, renderDef, face)
|
|
385
|
+
if (!digest) return undefined
|
|
386
|
+
return deliveredFaceMatches(suspendOnFaceHashes(face), digest) ? face : undefined
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 1.3.0: the delivered face, in the card's own wrapper (the same attributes as any envelope face, and
|
|
391
|
+
* `data-certrev-face="delivered"`): the server's stylesheet, then its markup. `seed` is the record that names it.
|
|
392
|
+
*/
|
|
393
|
+
function DeliveredEnvelopeFace(props: {
|
|
394
|
+
readonly face: DeliveredFace
|
|
395
|
+
readonly defVersion: number
|
|
396
|
+
readonly seed: string
|
|
397
|
+
}) {
|
|
398
|
+
return <EnvelopeFace delivered html={deliveredFaceHtml(props.face)} defVersion={props.defVersion} seed={props.seed} />
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 1.3.0: what a card hydrating a seed that names a delivered face renders when the browser's own check of that face
|
|
403
|
+
* fails (no WebCrypto: an insecure context; or bytes that do not hash to the seed's digest). The server painted the
|
|
404
|
+
* delivered face there, and the browser must not render that markup, so this adopts the server's element as it stands
|
|
405
|
+
* (no attribute or innerHTML compared: `suppressHydrationWarning`; no children hydrated: `dangerouslySetInnerHTML`)
|
|
406
|
+
* and paints nothing of its own. The render right after hydration (at the browser's clock) is another component, so
|
|
407
|
+
* React replaces this element with the face 1.2.1 paints for the seed.
|
|
408
|
+
*/
|
|
409
|
+
function ServerFaceUntilHydrated() {
|
|
410
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: an empty string; it only stops React hydrating the server's children
|
|
411
|
+
return <div suppressHydrationWarning dangerouslySetInnerHTML={{ __html: '' }} />
|
|
412
|
+
}
|
|
413
|
+
|
|
343
414
|
/** Paint a verified payload with its def; null when the def and content give no face. */
|
|
344
415
|
function paintEnvelope(
|
|
345
416
|
payload: CertPayload,
|
|
@@ -435,7 +506,16 @@ function paintRecord(
|
|
|
435
506
|
return null
|
|
436
507
|
}
|
|
437
508
|
}
|
|
438
|
-
|
|
509
|
+
// 1.3.0: a seed that names a delivered face repaints it only after the browser hashes it against the seed
|
|
510
|
+
// payload's own digest; otherwise the seed paints what 1.2.1 paints for it.
|
|
511
|
+
const { seed } = record
|
|
512
|
+
const delivered = deliveredFaceFor(seed.payload, seed.renderDef, seed.face, props)
|
|
513
|
+
if (delivered && seed.renderDef) {
|
|
514
|
+
return <DeliveredEnvelopeFace face={delivered} defVersion={seed.renderDef.v} seed={record.raw} />
|
|
515
|
+
}
|
|
516
|
+
// Hydrating (not `judge`) over the delivered face the server painted, which the browser could not confirm.
|
|
517
|
+
if (seed.face && !judge) return <ServerFaceUntilHydrated />
|
|
518
|
+
return paintEnvelope(seed.payload, seed.renderDef, props, record.raw)
|
|
439
519
|
}
|
|
440
520
|
const html = record.fallback.face === 'options' ? fallbackFace(props) : null
|
|
441
521
|
return html === null ? (
|
|
@@ -587,6 +667,18 @@ function EnvelopeCertReviewCard(props: EnvelopeCardProps) {
|
|
|
587
667
|
signature: settled.signature,
|
|
588
668
|
renderDef: settled.renderDef,
|
|
589
669
|
})
|
|
670
|
+
// 1.3.0: the server-rendered face the Delivery API served beside the envelope, when its bytes hash to the digest the
|
|
671
|
+
// 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)
|
|
673
|
+
if (delivered && seed.renderDef) {
|
|
674
|
+
return (
|
|
675
|
+
<DeliveredEnvelopeFace
|
|
676
|
+
face={delivered}
|
|
677
|
+
defVersion={seed.renderDef.v}
|
|
678
|
+
seed={serializeSeed({ ...seed, face: delivered })}
|
|
679
|
+
/>
|
|
680
|
+
)
|
|
681
|
+
}
|
|
590
682
|
return paintEnvelope(seed.payload, seed.renderDef, props, serializeSeed(seed))
|
|
591
683
|
}
|
|
592
684
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* The delivered face gate: when the Builder card may paint the server's markup (1.3.0)
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The Delivery API may serve a server-rendered face (`face: { html, css }`) beside the signed artifact. That sibling
|
|
7
|
+
* is UNSIGNED. What binds it is the signed `payload.content.faceDigest` (cert-contract 0.6.0): the lowercase hex sha256
|
|
8
|
+
* of the markup's and the stylesheet's UTF-8 bytes, for one saved design version. The card paints the delivered face
|
|
9
|
+
* only when ALL of these hold:
|
|
10
|
+
*
|
|
11
|
+
* - the verdict is `render` (the caller has already checked that);
|
|
12
|
+
* - the card is not pinned (a design pin keeps 1.2.1's pinned face; the caller checks that);
|
|
13
|
+
* - the block shows the whole card: `part` is not `'header'` or `'memo'`, so a split banner never paints the face
|
|
14
|
+
* twice (`showsWholeCard`);
|
|
15
|
+
* - the verified content carries a digest it understands (`v: 1`, two 64-char lowercase hex digests, a numeric
|
|
16
|
+
* `defVersion`);
|
|
17
|
+
* - a well-formed face was served;
|
|
18
|
+
* - a def was served and its `v` IS the digest's `defVersion` (markup rendered for another design version is not
|
|
19
|
+
* this design);
|
|
20
|
+
* - sha256(html) and sha256(css) equal the signed values.
|
|
21
|
+
*
|
|
22
|
+
* In every other case the card paints exactly what 1.2.1 paints. Markup whose bytes do not hash to the signed digest
|
|
23
|
+
* is never painted, and a WebCrypto failure counts as a mismatch, never as a pass.
|
|
24
|
+
*
|
|
25
|
+
* Hashing uses WebCrypto (`crypto.subtle.digest`), the global the kernel verifies with, so it runs wherever the card
|
|
26
|
+
* already verifies (Node SSR, edge runtimes, a browser in a secure context). It is react-free; the card owns the
|
|
27
|
+
* suspension around the async digest.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { CertContent, CertFaceDigest } from '../contract/kernel.js'
|
|
31
|
+
import type { DeliveredFace } from '../verify/get-verified-delivery.js'
|
|
32
|
+
import type { CertChromeRenderDef } from './cert-chrome-data.js'
|
|
33
|
+
|
|
34
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether a block shows the whole card, by `renderCertBlock`'s own rule: only `'header'` and `'memo'` split the
|
|
38
|
+
* banner; absent, `'full'`, null or anything unrecognised is the full card. A split block keeps its own part even
|
|
39
|
+
* when a delivered face matches, because the delivered face is a whole design.
|
|
40
|
+
*/
|
|
41
|
+
export function showsWholeCard(part: unknown): boolean {
|
|
42
|
+
return part !== 'header' && part !== 'memo'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The signed digest, accepted only in the shape cert-contract 0.6.0 defines; anything else is null. */
|
|
46
|
+
export function acceptedFaceDigest(content: CertContent | null | undefined): CertFaceDigest | null {
|
|
47
|
+
const digest: unknown = (content as { faceDigest?: unknown } | null | undefined)?.faceDigest
|
|
48
|
+
if (typeof digest !== 'object' || digest === null || Array.isArray(digest)) return null
|
|
49
|
+
const { v, defVersion, html, css } = digest as Record<string, unknown>
|
|
50
|
+
if (v !== 1 || typeof defVersion !== 'number' || !Number.isFinite(defVersion)) return null
|
|
51
|
+
if (typeof html !== 'string' || !SHA256_HEX.test(html)) return null
|
|
52
|
+
if (typeof css !== 'string' || !SHA256_HEX.test(css)) return null
|
|
53
|
+
return { v: 1, defVersion, html, css }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The synchronous half of the gate: the digest to hash the face against, or null when the delivered face cannot
|
|
58
|
+
* paint whatever its bytes are (no digest, no face, no def, or a def of another version). The card hashes only when
|
|
59
|
+
* this is non-null, so a delivery without a face never suspends a second time.
|
|
60
|
+
*/
|
|
61
|
+
export function deliveredFaceDigest(
|
|
62
|
+
content: CertContent | null | undefined,
|
|
63
|
+
renderDef: CertChromeRenderDef | null,
|
|
64
|
+
face: DeliveredFace | null | undefined,
|
|
65
|
+
): CertFaceDigest | null {
|
|
66
|
+
if (!face) return null
|
|
67
|
+
const digest = acceptedFaceDigest(content)
|
|
68
|
+
if (!digest || !renderDef || renderDef.v !== digest.defVersion) return null
|
|
69
|
+
return digest
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Lowercase hex sha256 of a string's UTF-8 bytes, via WebCrypto. */
|
|
73
|
+
export async function sha256HexUtf8(text: string): Promise<string> {
|
|
74
|
+
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
|
|
75
|
+
let hex = ''
|
|
76
|
+
for (const byte of digest) hex += byte.toString(16).padStart(2, '0')
|
|
77
|
+
return hex
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The two digests of a delivered face. */
|
|
81
|
+
export interface DeliveredFaceHashes {
|
|
82
|
+
readonly html: string
|
|
83
|
+
readonly css: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Hash both halves. Never rejects: a WebCrypto failure (none in an insecure context) is null, which never matches. */
|
|
87
|
+
export async function hashDeliveredFace(face: DeliveredFace): Promise<DeliveredFaceHashes | null> {
|
|
88
|
+
try {
|
|
89
|
+
const [html, css] = await Promise.all([sha256HexUtf8(face.html), sha256HexUtf8(face.css)])
|
|
90
|
+
return { html, css }
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The asynchronous half's verdict: both halves hash to the signed values. */
|
|
97
|
+
export function deliveredFaceMatches(hashes: DeliveredFaceHashes | null, digest: CertFaceDigest): boolean {
|
|
98
|
+
return hashes !== null && hashes.html === digest.html && hashes.css === digest.css
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The markup the card paints for a matching face: the stylesheet first, then the face. */
|
|
102
|
+
export function deliveredFaceHtml(face: DeliveredFace): string {
|
|
103
|
+
return `<style>${face.css}</style>${face.html}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One face's hashing: the pending digest, and its answer once settled. */
|
|
107
|
+
export interface FaceHashMemo {
|
|
108
|
+
readonly pending: Promise<DeliveredFaceHashes | null>
|
|
109
|
+
settled?: { readonly hashes: DeliveredFaceHashes | null }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** How many faces' hashes are kept (insertion order; the oldest goes first). A page or a brand has one or a few. */
|
|
113
|
+
const FACE_HASH_MEMO_MAX = 64
|
|
114
|
+
const faceHashMemo = new Map<string, FaceHashMemo>()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The memoized hashing of one face, keyed by its CONTENT (both strings), not by the object: the browser parses a seed
|
|
118
|
+
* from the page afresh on each render, and a server's cached read and a seed of the same face share one entry. A
|
|
119
|
+
* failure settles to null and stays settled (a context with no WebCrypto does not gain one).
|
|
120
|
+
*/
|
|
121
|
+
export function faceHashesFor(face: DeliveredFace): FaceHashMemo {
|
|
122
|
+
const key = `${face.html.length}:${face.html}${face.css}`
|
|
123
|
+
const known = faceHashMemo.get(key)
|
|
124
|
+
if (known) return known
|
|
125
|
+
const fresh: FaceHashMemo = {
|
|
126
|
+
pending: hashDeliveredFace(face).then((hashes) => {
|
|
127
|
+
fresh.settled = { hashes }
|
|
128
|
+
return hashes
|
|
129
|
+
}),
|
|
130
|
+
}
|
|
131
|
+
if (faceHashMemo.size >= FACE_HASH_MEMO_MAX) {
|
|
132
|
+
const oldest = faceHashMemo.keys().next().value
|
|
133
|
+
if (oldest !== undefined) faceHashMemo.delete(oldest)
|
|
134
|
+
}
|
|
135
|
+
faceHashMemo.set(key, fresh)
|
|
136
|
+
return fresh
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Test seam: forget every face's hashes (a new process). */
|
|
140
|
+
export function forgetFaceHashesForTests(): void {
|
|
141
|
+
faceHashMemo.clear()
|
|
142
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
19
19
|
import { TtlCache } from '../verify/cache.js'
|
|
20
20
|
import type { CachedDelivery } from '../verify/get-verified-delivery.js'
|
|
21
|
+
import { forgetFaceHashesForTests } from './delivered-face.js'
|
|
21
22
|
import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
|
|
22
23
|
import { forgetPageSeedsForTests } from './ssr-seed.js'
|
|
23
24
|
|
|
@@ -67,10 +68,11 @@ export function cardDeliveryRuntime(): CardDeliveryRuntime {
|
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
|
|
70
|
-
* (a fresh runtime is a fresh page). Returns a restore.
|
|
71
|
+
* (a fresh runtime is a fresh page) and (1.3.0) every delivered face's memoized hashes. Returns a restore.
|
|
71
72
|
*/
|
|
72
73
|
export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
|
|
73
74
|
forgetPageSeedsForTests()
|
|
75
|
+
forgetFaceHashesForTests()
|
|
74
76
|
const previous = runtime
|
|
75
77
|
const now = overrides.now ?? previous.now
|
|
76
78
|
runtime = {
|
package/src/builder/index.tsx
CHANGED
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* for the entry it renders inside (`builderContext.content.id`) from the CertREV Delivery API on
|
|
28
28
|
* `baseUrl` (an https certrev.com origin, nothing else), verifies it against the baked
|
|
29
29
|
* `cert-issuer-1` key, re-judges revocation and expiry at every render, and paints the brand's
|
|
30
|
-
* render def served beside it.
|
|
30
|
+
* render def served beside it. 1.3.0: when the Delivery API also serves a server-rendered face
|
|
31
|
+
* (`face: { html, css }`, unsigned) and the verified envelope signs that face's sha256 digests for the
|
|
32
|
+
* def version served (`content.faceDigest`), an unpinned whole-card block paints that face instead;
|
|
33
|
+
* markup that does not hash to the signed digest is never painted. A revoked, expired, unverifiable or unreachable cert, a marker that
|
|
31
34
|
* is not exactly that shape, or a missing entry id renders nothing. A block WITHOUT the marker
|
|
32
35
|
* (every entry exported before 1.1.0) renders from its options exactly as 1.0.3 did, so the
|
|
33
36
|
* paragraph above still describes it: an editor who deletes the marker gets the options face back,
|
package/src/builder/ssr-seed.ts
CHANGED
|
@@ -48,13 +48,26 @@
|
|
|
48
48
|
* server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
|
|
49
49
|
* `hidden` element with nothing in it.
|
|
50
50
|
*
|
|
51
|
+
* A DELIVERED FACE RIDES THE SEED (1.3.0), and only when the server painted it: the seed then ends with
|
|
52
|
+
* `face: { html, css }`, the server-rendered markup and stylesheet the Delivery API served beside the envelope, whose
|
|
53
|
+
* hashes the signed `payload.content.faceDigest` binds. A browser repaints that face from the seed only after hashing
|
|
54
|
+
* it against the seed payload's digest itself (`cert-review-card.tsx`); a seed with no `face`, a malformed one, or one
|
|
55
|
+
* whose hashes fail paints what 1.2.1 paints for it. A seed with no face is byte for byte the 1.2.1 seed. The face is
|
|
56
|
+
* on the page twice (painted, and in the seed), which is its page-weight cost.
|
|
57
|
+
*
|
|
51
58
|
* React-free and browser-only in effect: with no `document` (a server) `pageSeedFor` finds nothing, so a server never
|
|
52
59
|
* reads or keeps a seed and a multi-tenant render process holds nothing per page.
|
|
53
60
|
*/
|
|
54
61
|
|
|
55
62
|
import { HIDEABLE_FIELDS } from '../components/render-def.js'
|
|
56
63
|
import type { CertContent, CertPayload, CertSignature } from '../contract/kernel.js'
|
|
57
|
-
import {
|
|
64
|
+
import {
|
|
65
|
+
type DeliveredFace,
|
|
66
|
+
type DesignPin,
|
|
67
|
+
parseDeliveredFace,
|
|
68
|
+
parseDesignPin,
|
|
69
|
+
parseRenderDef,
|
|
70
|
+
} from '../verify/get-verified-delivery.js'
|
|
58
71
|
import type { CertChromeRenderDef } from './cert-chrome-data.js'
|
|
59
72
|
|
|
60
73
|
export const SEED_ATTRIBUTE = 'data-certrev-envelope'
|
|
@@ -72,6 +85,11 @@ export interface DeliverySeed {
|
|
|
72
85
|
*/
|
|
73
86
|
readonly signature?: CertSignature
|
|
74
87
|
readonly renderDef: CertChromeRenderDef | null
|
|
88
|
+
/**
|
|
89
|
+
* 1.3.0: the delivered face the server painted (the file header); absent whenever it painted anything else. On a
|
|
90
|
+
* seed read from the page it is UNVERIFIED until hashed against `payload.content.faceDigest`.
|
|
91
|
+
*/
|
|
92
|
+
readonly face?: DeliveredFace
|
|
75
93
|
}
|
|
76
94
|
|
|
77
95
|
/** A seed as read from the page: the parsed seed and the attribute's exact text (re-rendered verbatim on hydration). */
|
|
@@ -183,6 +201,8 @@ export function serializeSeed(seed: DeliverySeed): string {
|
|
|
183
201
|
payload: seed.payload,
|
|
184
202
|
...(seed.signature ? { signature: seed.signature } : {}),
|
|
185
203
|
renderDef: seed.renderDef,
|
|
204
|
+
// 1.3.0: last, and only with a delivered face, so a seed without one is the 1.2.1 bytes.
|
|
205
|
+
...(seed.face ? { face: { html: seed.face.html, css: seed.face.css } } : {}),
|
|
186
206
|
})
|
|
187
207
|
}
|
|
188
208
|
|
|
@@ -214,7 +234,7 @@ export function parseSeed(raw: string): DeliverySeed | null {
|
|
|
214
234
|
return null
|
|
215
235
|
}
|
|
216
236
|
if (!isRecord(value) || value.v !== 1) return null
|
|
217
|
-
const { baseUrl, externalId, design, payload, signature, renderDef } = value
|
|
237
|
+
const { baseUrl, externalId, design, payload, signature, renderDef, face } = value
|
|
218
238
|
if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
|
|
219
239
|
if (!isRecord(payload) || !isRecord(payload.subject) || !isRecord(payload.lifecycle) || !isRecord(payload.content)) {
|
|
220
240
|
return null
|
|
@@ -222,6 +242,8 @@ export function parseSeed(raw: string): DeliverySeed | null {
|
|
|
222
242
|
// A pin the seed names is judged by the same grammar the card's own pin is; one that fails it is not a seed.
|
|
223
243
|
const pin = parsedPin(design)
|
|
224
244
|
if (pin === null) return null
|
|
245
|
+
// 1.3.0: a face that is not two strings is no face; the seed is still a seed (and paints as 1.2.1 paints it).
|
|
246
|
+
const delivered = parseDeliveredFace(face)
|
|
225
247
|
return {
|
|
226
248
|
baseUrl,
|
|
227
249
|
externalId,
|
|
@@ -229,6 +251,7 @@ export function parseSeed(raw: string): DeliverySeed | null {
|
|
|
229
251
|
payload: payload as unknown as CertPayload,
|
|
230
252
|
...(isRecord(signature) ? { signature: signature as unknown as CertSignature } : {}),
|
|
231
253
|
renderDef: parseRenderDef(renderDef),
|
|
254
|
+
...(delivered ? { face: delivered } : {}),
|
|
232
255
|
}
|
|
233
256
|
}
|
|
234
257
|
|