@certrev/cert-block 1.0.3 → 1.1.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 +60 -0
- package/README.md +6 -1
- package/dist/builder/cert-chrome-data.d.ts +26 -1
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +1 -0
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -2
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +112 -2
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +23 -0
- package/dist/builder/delivery-runtime.d.ts.map +1 -0
- package/dist/builder/delivery-runtime.js +38 -0
- package/dist/builder/delivery-runtime.js.map +1 -0
- package/dist/builder/envelope-face.d.ts +63 -0
- package/dist/builder/envelope-face.d.ts.map +1 -0
- package/dist/builder/envelope-face.js +151 -0
- package/dist/builder/envelope-face.js.map +1 -0
- package/dist/builder/index.d.ts +15 -3
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +14 -2
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/issuer-key.d.ts +12 -0
- package/dist/builder/issuer-key.d.ts.map +1 -0
- package/dist/builder/issuer-key.js +14 -0
- package/dist/builder/issuer-key.js.map +1 -0
- package/dist/components/memo-toggle.d.ts +42 -0
- package/dist/components/memo-toggle.d.ts.map +1 -0
- package/dist/components/memo-toggle.js +60 -0
- package/dist/components/memo-toggle.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +9 -0
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +80 -19
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +91 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -0
- package/dist/verify/get-verified-delivery.js +157 -0
- package/dist/verify/get-verified-delivery.js.map +1 -0
- package/package.json +5 -1
- package/src/builder/cert-chrome-data.ts +27 -0
- package/src/builder/cert-review-card.tsx +143 -3
- package/src/builder/delivery-runtime.ts +54 -0
- package/src/builder/envelope-face.ts +173 -0
- package/src/builder/index.tsx +23 -1
- package/src/builder/issuer-key.ts +13 -0
- package/src/components/memo-toggle.ts +63 -0
- package/src/components/render-cert-block.ts +121 -22
- package/src/index.ts +28 -0
- package/src/verify/get-verified-delivery.ts +211 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get-verified-delivery.js","sourceRoot":"","sources":["../../src/verify/get-verified-delivery.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@certrev/cert-block",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
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
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"types": "./dist/builder/index.d.ts",
|
|
24
24
|
"default": "./dist/builder/index.js"
|
|
25
25
|
},
|
|
26
|
+
"./builder/face": {
|
|
27
|
+
"types": "./dist/builder/envelope-face.d.ts",
|
|
28
|
+
"default": "./dist/builder/envelope-face.js"
|
|
29
|
+
},
|
|
26
30
|
"./webcomponent": {
|
|
27
31
|
"types": "./dist/webcomponent/certrev-badge.d.ts",
|
|
28
32
|
"default": "./dist/webcomponent/certrev-badge.js"
|
|
@@ -60,6 +60,25 @@ export interface CertChromeRenderDef {
|
|
|
60
60
|
readonly customFace?: { readonly placedFields: readonly string[] }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The DELIVERY MARKER (1.1.0): the exporter's statement that this block's cert is served,
|
|
65
|
+
* signed, by the CertREV Delivery API at `baseUrl`. When it is present the card no longer
|
|
66
|
+
* paints the other option keys: it takes its `externalId` from its own Builder entry id,
|
|
67
|
+
* fetches `GET {baseUrl}/api/cert/v1/delivery/builder/{externalId}`, verifies the envelope
|
|
68
|
+
* against the baked issuer key, and paints from the verified facts plus the brand's render
|
|
69
|
+
* def served beside them. A revoked, expired or unverifiable cert renders nothing.
|
|
70
|
+
*
|
|
71
|
+
* `baseUrl` is honoured ONLY when it is `https:` on `certrev.com` or a subdomain of it. The
|
|
72
|
+
* marker is an option like any other, so an editor can type into it; a foreign origin would
|
|
73
|
+
* let a page answer its own revocation check, and so renders nothing (never the options).
|
|
74
|
+
*/
|
|
75
|
+
export interface CertDeliveryMarker {
|
|
76
|
+
/** Marker shape version. Only `1` is understood; anything else renders nothing. */
|
|
77
|
+
readonly v: 1
|
|
78
|
+
/** The CertREV app origin that serves the Delivery API (e.g. `https://portal.certrev.com`). */
|
|
79
|
+
readonly baseUrl: string
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
/**
|
|
64
83
|
* The OPTIONS a brand's native cert component receives. Delivered per certified
|
|
65
84
|
* article; until the brand registers the component the block renders nothing.
|
|
@@ -131,6 +150,13 @@ export interface BuilderCertChromeData {
|
|
|
131
150
|
renderDef: CertChromeRenderDef | null
|
|
132
151
|
/** Version of the delivered string set (`data-certrev-strings-version` for the crawl monitor). */
|
|
133
152
|
stringsVersion: string
|
|
153
|
+
/**
|
|
154
|
+
* 1.1.0: where the signed envelope for this block is served, or null for an options-only
|
|
155
|
+
* block. Present ⇒ the card paints from the verified envelope + def and ignores the keys
|
|
156
|
+
* above; they stay on the wire so installs older than 1.1.0 keep painting during the
|
|
157
|
+
* transition. See `CertDeliveryMarker`.
|
|
158
|
+
*/
|
|
159
|
+
delivery: CertDeliveryMarker | null
|
|
134
160
|
}
|
|
135
161
|
|
|
136
162
|
/**
|
|
@@ -160,6 +186,7 @@ export const BUILDER_CERT_CHROME_KEYS = [
|
|
|
160
186
|
'display',
|
|
161
187
|
'renderDef',
|
|
162
188
|
'stringsVersion',
|
|
189
|
+
'delivery',
|
|
163
190
|
] as const
|
|
164
191
|
|
|
165
192
|
export type BuilderCertChromeKey = (typeof BUILDER_CERT_CHROME_KEYS)[number]
|
|
@@ -18,10 +18,16 @@
|
|
|
18
18
|
* - honors `display.showCredentials`.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
import { Suspense } from 'react'
|
|
23
|
+
import { memoToggleFor, toggleMemo } from '../components/memo-toggle.js'
|
|
21
24
|
import { type CertBlockMode, renderCertBlock } from '../components/render-cert-block.js'
|
|
22
|
-
import type
|
|
25
|
+
import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
|
|
26
|
+
import { getVerifiedDelivery, peekVerifiedDelivery, type VerifiedDelivery } from '../verify/get-verified-delivery.js'
|
|
23
27
|
import type { BuilderCertChromeData } from './cert-chrome-data.js'
|
|
24
28
|
import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
|
|
29
|
+
import { cardDeliveryRuntime } from './delivery-runtime.js'
|
|
30
|
+
import { envelopeCardInput } from './envelope-face.js'
|
|
25
31
|
|
|
26
32
|
/**
|
|
27
33
|
* The canonical Builder block name. A brand's registration + the portal
|
|
@@ -49,6 +55,13 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
|
|
|
49
55
|
* string-splitting shim is gone — `renderCertBlock` composes each part directly.
|
|
50
56
|
*/
|
|
51
57
|
part?: 'full' | 'header' | 'memo' | null
|
|
58
|
+
/**
|
|
59
|
+
* 1.1.0: what `@builder.io/sdk-react` passes because the registration sets
|
|
60
|
+
* `shouldReceiveBuilderProps: { builderContext: true }`. Only `content.id` is read: it is the
|
|
61
|
+
* Builder entry this block renders inside, which IS the Delivery placement's `externalId`, so a
|
|
62
|
+
* block copied onto another entry asks for that entry's cert and never shows this one's.
|
|
63
|
+
*/
|
|
64
|
+
builderContext?: { readonly content?: { readonly id?: unknown } | null } | null
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
/**
|
|
@@ -87,7 +100,121 @@ function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] |
|
|
|
87
100
|
return credential && verifiedAt ? { credential, verifiedAt } : null
|
|
88
101
|
}
|
|
89
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
|
|
105
|
+
* `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
|
|
106
|
+
* null, and a block carrying a marker that is not accepted renders NOTHING: falling back to the
|
|
107
|
+
* options would let an edit to the marker switch revocation off.
|
|
108
|
+
*/
|
|
109
|
+
export function acceptedDeliveryMarker(value: unknown): { readonly baseUrl: string } | null {
|
|
110
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
|
111
|
+
const { v, baseUrl } = value as { v?: unknown; baseUrl?: unknown }
|
|
112
|
+
if (v !== 1 || typeof baseUrl !== 'string') return null
|
|
113
|
+
let url: URL
|
|
114
|
+
try {
|
|
115
|
+
url = new URL(baseUrl)
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null
|
|
120
|
+
if (url.pathname !== '/' || url.search || url.hash) return null
|
|
121
|
+
const host = url.hostname.toLowerCase()
|
|
122
|
+
if (host !== 'certrev.com' && !host.endsWith('.certrev.com')) return null
|
|
123
|
+
return { baseUrl: url.origin }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Suspend on a Delivery read. React 19 gets `use` with ONE promise per placement while it is in
|
|
128
|
+
* flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
|
|
129
|
+
* way the retry finds the settled read in the cache through `peekVerifiedDelivery`, so it never
|
|
130
|
+
* suspends on a second promise.
|
|
131
|
+
*/
|
|
132
|
+
const inflightReads = new Map<string, Promise<VerifiedDelivery>>()
|
|
133
|
+
function suspendOn(key: string, start: () => Promise<VerifiedDelivery>): VerifiedDelivery {
|
|
134
|
+
let pending = inflightReads.get(key)
|
|
135
|
+
if (!pending) {
|
|
136
|
+
pending = start().finally(() => inflightReads.delete(key))
|
|
137
|
+
inflightReads.set(key, pending)
|
|
138
|
+
}
|
|
139
|
+
const use = (React as unknown as { use?: <T>(p: Promise<T>) => T }).use
|
|
140
|
+
if (typeof use === 'function') return use(pending)
|
|
141
|
+
throw pending
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The painted envelope face, with the phone memo toggle bound. One native click listener on the
|
|
146
|
+
* wrapper (delegated, so it survives a re-render of the HTML) flips the memo box the button sits
|
|
147
|
+
* in; the button is native, so Enter and Space reach it as clicks. Bound after hydration only: the
|
|
148
|
+
* server HTML is the collapsed face, which is also what a reader without JS keeps (the whole memo
|
|
149
|
+
* is still in the text for a screen reader, and the desktop never clamps it).
|
|
150
|
+
*/
|
|
151
|
+
function EnvelopeFace(props: { readonly html: string; readonly defVersion: number | undefined }) {
|
|
152
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
const el = ref.current
|
|
155
|
+
if (!el) return
|
|
156
|
+
const onClick = (e: MouseEvent) => {
|
|
157
|
+
const button = memoToggleFor(e.target)
|
|
158
|
+
if (button && el.contains(button)) toggleMemo(button)
|
|
159
|
+
}
|
|
160
|
+
el.addEventListener('click', onClick)
|
|
161
|
+
return () => el.removeEventListener('click', onClick)
|
|
162
|
+
}, [])
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
ref={ref}
|
|
166
|
+
data-certrev-cert-chrome=""
|
|
167
|
+
// Marks a face painted from the verified envelope (the migration dry-run and crawl monitor
|
|
168
|
+
// tell the two faces apart by it). The strings are the package's locked constants here.
|
|
169
|
+
data-certrev-delivery=""
|
|
170
|
+
data-certrev-strings-version={CERT_STRINGS_VERSION}
|
|
171
|
+
data-certrev-def-version={props.defVersion}
|
|
172
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
|
|
173
|
+
dangerouslySetInnerHTML={{ __html: props.html }}
|
|
174
|
+
/>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function EnvelopeCertReviewCard(props: {
|
|
179
|
+
readonly baseUrl: string
|
|
180
|
+
readonly externalId: string
|
|
181
|
+
readonly mode: unknown
|
|
182
|
+
readonly part: unknown
|
|
183
|
+
}) {
|
|
184
|
+
const rt = cardDeliveryRuntime()
|
|
185
|
+
// The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
|
|
186
|
+
const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
|
|
187
|
+
const read = { baseUrl: props.baseUrl, platform: 'builder', externalId: props.externalId, context, cache: rt.cache }
|
|
188
|
+
const settled =
|
|
189
|
+
peekVerifiedDelivery(read) ??
|
|
190
|
+
suspendOn(`${props.baseUrl}|${props.externalId}`, () =>
|
|
191
|
+
getVerifiedDelivery({ ...read, resolveKid: rt.resolveKid, fetchImpl: rt.fetchImpl(), timeoutMs: rt.timeoutMs }),
|
|
192
|
+
)
|
|
193
|
+
// Revoked, expired, unverifiable, for another entry, or unreachable: nothing at all.
|
|
194
|
+
if (settled.verdict.decision !== 'render') return null
|
|
195
|
+
const input = envelopeCardInput(settled.verdict.payload.content, settled.renderDef, {
|
|
196
|
+
mode: props.mode,
|
|
197
|
+
part: props.part,
|
|
198
|
+
})
|
|
199
|
+
if (!input) return null
|
|
200
|
+
return <EnvelopeFace html={renderCertBlock(input)} defVersion={settled.renderDef ? settled.renderDef.v : undefined} />
|
|
201
|
+
}
|
|
202
|
+
|
|
90
203
|
export function CertReviewCard(props: CertReviewCardProps) {
|
|
204
|
+
// 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
|
|
205
|
+
// brand's def, never from the options beside it. No marker (every entry exported before 1.1.0)
|
|
206
|
+
// takes the options path below, unchanged from 1.0.3.
|
|
207
|
+
if (props.delivery != null) {
|
|
208
|
+
const marker = acceptedDeliveryMarker(props.delivery)
|
|
209
|
+
const entryId = text(props.builderContext?.content?.id)
|
|
210
|
+
if (!marker || !entryId) return null
|
|
211
|
+
return (
|
|
212
|
+
<Suspense fallback={null}>
|
|
213
|
+
<EnvelopeCertReviewCard baseUrl={marker.baseUrl} externalId={entryId} mode={props.mode} part={props.part} />
|
|
214
|
+
</Suspense>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
91
218
|
const reviewerName = text(props.reviewerName)
|
|
92
219
|
const verifyUrl = text(props.verifyUrl)
|
|
93
220
|
if (!reviewerName || !verifyUrl) return null // no chrome pre-cert (or an unreadable delivery)
|
|
@@ -119,7 +246,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
|
|
|
119
246
|
// explicit `mode` and never reaches this line.
|
|
120
247
|
//
|
|
121
248
|
// The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
|
|
122
|
-
// the
|
|
249
|
+
// the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
|
|
123
250
|
// projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
|
|
124
251
|
// key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
|
|
125
252
|
// banner face. Removing it in 1.0.0 did not give them the renderer's default in the
|
|
@@ -224,6 +351,7 @@ const WIRE_INPUT_TYPE: Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], Builder
|
|
|
224
351
|
display: 'object',
|
|
225
352
|
renderDef: 'object',
|
|
226
353
|
stringsVersion: 'string',
|
|
354
|
+
delivery: 'object',
|
|
227
355
|
}
|
|
228
356
|
|
|
229
357
|
/**
|
|
@@ -241,10 +369,12 @@ const WIRE_INPUT_HELPER_TEXT: Partial<Record<(typeof BUILDER_CERT_CHROME_KEYS)[n
|
|
|
241
369
|
'System-populated by CertREV. Only its PRESENCE is read (absent = a pro-bono reviewer, so no cue renders); the cue itself is a locked constant, so editing this text changes nothing on the page, and clearing it does not remove the disclosure.',
|
|
242
370
|
scopeLine:
|
|
243
371
|
'System-populated by CertREV. The FTC scope line renders byte-verbatim from a locked constant: editing this text changes nothing on the page.',
|
|
372
|
+
delivery:
|
|
373
|
+
'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified, and ignores the other fields; if CertREV revokes it or it expires, the card shows nothing. Do not edit.',
|
|
244
374
|
}
|
|
245
375
|
|
|
246
376
|
/**
|
|
247
|
-
* The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the
|
|
377
|
+
* The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the 20-field
|
|
248
378
|
* option set can never drift from the type (the W3 lock asserts name-parity). This
|
|
249
379
|
* kills the demo's hand-maintained 17-entry array.
|
|
250
380
|
*/
|
|
@@ -299,6 +429,8 @@ export interface CertBlockBuilderRegistration {
|
|
|
299
429
|
readonly name: string
|
|
300
430
|
readonly component: typeof CertReviewCard
|
|
301
431
|
readonly inputs: readonly BuilderInput[]
|
|
432
|
+
/** 1.1.0: asks the SDK for `builderContext`, whose `content.id` the delivery path reads. */
|
|
433
|
+
readonly shouldReceiveBuilderProps: { readonly builderContext: true }
|
|
302
434
|
}
|
|
303
435
|
|
|
304
436
|
/**
|
|
@@ -313,6 +445,7 @@ export const BUILDER_REGISTRATION: CertBlockBuilderRegistration = {
|
|
|
313
445
|
name: CERT_COMPONENT_NAME,
|
|
314
446
|
component: CertReviewCard,
|
|
315
447
|
inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
|
|
448
|
+
shouldReceiveBuilderProps: { builderContext: true },
|
|
316
449
|
}
|
|
317
450
|
|
|
318
451
|
/**
|
|
@@ -326,6 +459,12 @@ export interface CertReviewCardRegisteredComponent {
|
|
|
326
459
|
name: string
|
|
327
460
|
description?: string
|
|
328
461
|
inputs: BuilderInput[]
|
|
462
|
+
/**
|
|
463
|
+
* 1.1.0: `@builder.io/sdk-react`'s `RegisteredComponent.shouldReceiveBuilderProps`. The SDK then
|
|
464
|
+
* passes the `builderContext` prop, and the delivery path takes its placement from
|
|
465
|
+
* `builderContext.content.id`.
|
|
466
|
+
*/
|
|
467
|
+
shouldReceiveBuilderProps: { builderContext: boolean }
|
|
329
468
|
}
|
|
330
469
|
|
|
331
470
|
/**
|
|
@@ -340,4 +479,5 @@ export const certRevCertComponent: CertReviewCardRegisteredComponent = {
|
|
|
340
479
|
description:
|
|
341
480
|
'CertREV expert-review cert card. Options are populated by the CertREV exporter; editors choose placement (mode/part) only.',
|
|
342
481
|
inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
|
|
482
|
+
shouldReceiveBuilderProps: { builderContext: true },
|
|
343
483
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Builder card's Delivery runtime (1.1.0): the trust root, fetch, cache and clock the
|
|
3
|
+
* envelope-backed `CertReviewCard` reads with.
|
|
4
|
+
*
|
|
5
|
+
* INTERNAL. This module is deliberately NOT on the package `exports` map, so a consumer cannot
|
|
6
|
+
* swap the trust root through the published API: the card verifies against the baked
|
|
7
|
+
* `cert-issuer-1` key, the same key the storefront badge bundle ships. The package's own tests
|
|
8
|
+
* reach the setter by relative import to run the real kernel against fixture-signed envelopes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
12
|
+
import { TtlCache } from '../verify/cache.js'
|
|
13
|
+
import type { CachedDelivery } from '../verify/get-verified-delivery.js'
|
|
14
|
+
import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
|
|
15
|
+
|
|
16
|
+
export interface CardDeliveryRuntime {
|
|
17
|
+
readonly resolveKid: ResolvePublicKeyByKid
|
|
18
|
+
readonly fetchImpl: () => typeof fetch
|
|
19
|
+
readonly cache: TtlCache<CachedDelivery>
|
|
20
|
+
readonly now: () => number
|
|
21
|
+
readonly timeoutMs?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
|
|
25
|
+
kid === CERT_ISSUER_KID ? { format: 'pem', pem: CERT_ISSUER_PUBLIC_KEY_PEM } : null
|
|
26
|
+
|
|
27
|
+
function defaultRuntime(): CardDeliveryRuntime {
|
|
28
|
+
return {
|
|
29
|
+
resolveKid: bakedResolveKid,
|
|
30
|
+
fetchImpl: () => globalThis.fetch,
|
|
31
|
+
cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 }),
|
|
32
|
+
now: Date.now,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let runtime: CardDeliveryRuntime = defaultRuntime()
|
|
37
|
+
|
|
38
|
+
export function cardDeliveryRuntime(): CardDeliveryRuntime {
|
|
39
|
+
return runtime
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test seam: replace parts of the runtime (a fresh cache unless one is given). Returns a restore. */
|
|
43
|
+
export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
|
|
44
|
+
const previous = runtime
|
|
45
|
+
const now = overrides.now ?? previous.now
|
|
46
|
+
runtime = {
|
|
47
|
+
...previous,
|
|
48
|
+
cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000, now }),
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
runtime = previous
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* `envelopeCardInput` — the Builder card's face, from VERIFIED facts + the brand's def (1.1.0)
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The one mapping from a verified payload's `content` and the Delivery API's `renderDef` sibling
|
|
7
|
+
* to the `renderCertBlock` input the registered `CertReviewCard` paints. It is its own module,
|
|
8
|
+
* react-free, and exported on the `./builder/face` subpath so the portal's server bundle (which
|
|
9
|
+
* cannot load `./builder`: that entry creates a React context at import) renders its preview
|
|
10
|
+
* through THIS function instead of a copy of it. A preview and the live card that share the
|
|
11
|
+
* mapping cannot disagree about it.
|
|
12
|
+
*
|
|
13
|
+
* The facts are read exactly as the Shopify app embed reads the same envelope:
|
|
14
|
+
* - the credential is each verified credential's `cardLabel` (else `abbreviation`), joined, and
|
|
15
|
+
* only with the dated verification beside it; no date, no credential (the name-only face);
|
|
16
|
+
* - the compensation cue's PRESENCE is the only thing read; the words are the locked constant,
|
|
17
|
+
* and an absent or null cue is the pro-bono face with no cue;
|
|
18
|
+
* - the def's `hidden` set removes only what is hideable (`HIDEABLE_FIELDS`); a def cannot hide
|
|
19
|
+
* the scope line, the credential verification or the certificate link.
|
|
20
|
+
*
|
|
21
|
+
* The face is the PHONE-aware one (`responsive`): on a narrow viewport the banner header stacks and
|
|
22
|
+
* a long memo collapses behind a toggle the card binds (`memo-toggle.ts`); a wider viewport paints
|
|
23
|
+
* exactly what it would without it.
|
|
24
|
+
*
|
|
25
|
+
* The caller has already run the kernel. This function never decides WHETHER to render a cert;
|
|
26
|
+
* it only returns null when the verified content cannot make a face (no reviewer, no verify URL).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
CertBlockFace,
|
|
31
|
+
CertBlockLayout,
|
|
32
|
+
CertBlockMode,
|
|
33
|
+
RenderCertBlockInput,
|
|
34
|
+
} from '../components/render-cert-block.js'
|
|
35
|
+
import { accentFg, HIDEABLE_FIELDS, isOpaqueHexColor, type ResolvedBlockTheme } from '../components/render-def.js'
|
|
36
|
+
import type { CertContent } from '../contract/kernel.js'
|
|
37
|
+
import type { CertChromeRenderDef } from './cert-chrome-data.js'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every field id `renderCertBlock` places, in its canonical order. A def-driven face places all of
|
|
41
|
+
* them except what the def hides; `renderCertBlock` still gates each on its fact being present.
|
|
42
|
+
*/
|
|
43
|
+
export const CERT_FACE_FIELDS = [
|
|
44
|
+
'label',
|
|
45
|
+
'authorName',
|
|
46
|
+
'authorTitle',
|
|
47
|
+
'reviewerPhoto',
|
|
48
|
+
'bylineCredentialed',
|
|
49
|
+
'bylinePlain',
|
|
50
|
+
'bio',
|
|
51
|
+
'memo',
|
|
52
|
+
'profileLink',
|
|
53
|
+
'certificateLink',
|
|
54
|
+
'scopeLine',
|
|
55
|
+
'compensationCue',
|
|
56
|
+
] as const
|
|
57
|
+
|
|
58
|
+
const MODES: readonly CertBlockMode[] = ['banner', 'sidebar', 'floating']
|
|
59
|
+
|
|
60
|
+
function isMode(v: unknown): v is CertBlockMode {
|
|
61
|
+
return typeof v === 'string' && (MODES as readonly string[]).includes(v)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function text(v: unknown): string {
|
|
65
|
+
return typeof v === 'string' ? v : ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function optionalText(v: unknown): string | undefined {
|
|
69
|
+
return text(v).trim() !== '' ? (v as string) : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface EnvelopeCardOptions {
|
|
73
|
+
/** The placement the entry/editor chose. A recognised mode wins over the def's layout. */
|
|
74
|
+
readonly mode?: unknown
|
|
75
|
+
/** Banner memo placement, passed through as on the options path. */
|
|
76
|
+
readonly part?: unknown
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The def's theme tokens as `renderCertBlock` theme input, with the accent ink derived. */
|
|
80
|
+
export function themeFromDef(def: CertChromeRenderDef | null): Partial<ResolvedBlockTheme> | undefined {
|
|
81
|
+
const tokens = def?.tokens
|
|
82
|
+
if (!tokens) return undefined
|
|
83
|
+
const accentColor = optionalText(tokens.accentColor)
|
|
84
|
+
return {
|
|
85
|
+
accentColor,
|
|
86
|
+
surface: optionalText(tokens.surface),
|
|
87
|
+
cornerRadius: optionalText(tokens.cornerRadius),
|
|
88
|
+
fontSlot: optionalText(tokens.fontSlot) as ResolvedBlockTheme['fontSlot'] | undefined,
|
|
89
|
+
// The ink painted over the accent fill, derived the way the portal derives it, so a black
|
|
90
|
+
// accent carries white ink. Omitted for an accent `resolveBlockTheme` would reject anyway.
|
|
91
|
+
...(accentColor && isOpaqueHexColor(accentColor) ? { accentFg: accentFg(accentColor) } : {}),
|
|
92
|
+
barInk: optionalText(tokens.barInk),
|
|
93
|
+
inkColor: optionalText(tokens.inkColor),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The face a def asks for: its custom composition, or every field minus the hideable ones it hides. */
|
|
98
|
+
export function faceFromDef(def: CertChromeRenderDef | null, layout: CertBlockLayout): CertBlockFace | undefined {
|
|
99
|
+
if (!def) return undefined
|
|
100
|
+
if (layout === 'custom') return { rung: def.rung, placedFields: [...(def.customFace?.placedFields ?? [])] }
|
|
101
|
+
const hidden = new Set(def.hidden.filter((id) => (HIDEABLE_FIELDS as readonly string[]).includes(id)))
|
|
102
|
+
return { rung: def.rung, placedFields: CERT_FACE_FIELDS.filter((id) => !hidden.has(id)) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The layout: an editor-chosen mode, else the def's own layout (a custom face only when the def
|
|
107
|
+
* carries one), else banner, which is what the Shopify embed paints for a def with no layout and
|
|
108
|
+
* what every exported Builder entry has painted since 0.5.
|
|
109
|
+
*/
|
|
110
|
+
export function layoutFor(def: CertChromeRenderDef | null, mode: unknown): CertBlockLayout {
|
|
111
|
+
if (isMode(mode)) return mode
|
|
112
|
+
if (def?.layout === 'custom' && def.customFace) return 'custom'
|
|
113
|
+
if (isMode(def?.layout)) return def.layout
|
|
114
|
+
return 'banner'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The fused credential label the envelope's verified credentials stand for, or '' (name-only). */
|
|
118
|
+
export function credentialLabel(expert: Record<string, unknown>): { credential: string; verifiedAt: string } | null {
|
|
119
|
+
const verifiedAt = optionalText(expert.credentialVerifiedAt)
|
|
120
|
+
if (!verifiedAt) return null
|
|
121
|
+
const creds = Array.isArray(expert.credentials) ? expert.credentials : []
|
|
122
|
+
const credential = creds
|
|
123
|
+
.map((c) => {
|
|
124
|
+
const rec = (typeof c === 'object' && c !== null ? c : {}) as Record<string, unknown>
|
|
125
|
+
return optionalText(rec.cardLabel) ?? optionalText(rec.abbreviation)
|
|
126
|
+
})
|
|
127
|
+
.filter((l): l is string => l !== undefined)
|
|
128
|
+
.join(', ')
|
|
129
|
+
return credential ? { credential, verifiedAt } : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The exact `renderCertBlock` input for a verified envelope's content and the brand's def, or
|
|
134
|
+
* null when the content cannot make a face.
|
|
135
|
+
*/
|
|
136
|
+
export function envelopeCardInput(
|
|
137
|
+
content: CertContent,
|
|
138
|
+
renderDef: CertChromeRenderDef | null,
|
|
139
|
+
options: EnvelopeCardOptions = {},
|
|
140
|
+
): RenderCertBlockInput | null {
|
|
141
|
+
const expert = (content?.expert ?? {}) as unknown as Record<string, unknown>
|
|
142
|
+
const author = (content?.author ?? {}) as unknown as Record<string, unknown>
|
|
143
|
+
const reviewerName = text(expert.displayName)
|
|
144
|
+
const verifyUrl = text(content?.verifyUrl)
|
|
145
|
+
if (!reviewerName || !verifyUrl) return null
|
|
146
|
+
const cred = credentialLabel(expert)
|
|
147
|
+
const layout = layoutFor(renderDef, options.mode)
|
|
148
|
+
const face = faceFromDef(renderDef, layout)
|
|
149
|
+
const theme = themeFromDef(renderDef)
|
|
150
|
+
const part =
|
|
151
|
+
options.part === 'full' || options.part === 'header' || options.part === 'memo' ? options.part : undefined
|
|
152
|
+
return {
|
|
153
|
+
mode: layout,
|
|
154
|
+
...(theme ? { theme } : {}),
|
|
155
|
+
...(face ? { face } : {}),
|
|
156
|
+
...(part ? { part } : {}),
|
|
157
|
+
responsive: true,
|
|
158
|
+
facts: {
|
|
159
|
+
authorName: optionalText(author.name) ?? 'Editorial',
|
|
160
|
+
authorTitle: optionalText(author.title),
|
|
161
|
+
reviewerName,
|
|
162
|
+
credential: cred?.credential ?? '',
|
|
163
|
+
credentialVerifiedAt: cred?.verifiedAt ?? '',
|
|
164
|
+
certifiedAt: text(content.certifiedAt),
|
|
165
|
+
memo: text(content.memo),
|
|
166
|
+
bio: optionalText(expert.bio),
|
|
167
|
+
profileUrl: text(expert.profileUrl),
|
|
168
|
+
certificateUrl: verifyUrl,
|
|
169
|
+
// Presence only: a cue string renders the locked constant; absent or null is pro-bono.
|
|
170
|
+
compensationCue: optionalText(expert.compensationCue) ? undefined : null,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/builder/index.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* another article, and there is no `placementId` input to forge or mis-select.
|
|
13
13
|
*
|
|
14
14
|
* THE PUSH KIT DOES NOT INHERIT THAT PROPERTY, and this module exports it too. `CertReviewCard` /
|
|
15
|
-
* `certRevCertComponent` register the
|
|
15
|
+
* `certRevCertComponent` register the 20 wire-type fields — `reviewerName`,
|
|
16
16
|
* `credentialVerification`, `verifyUrl`, `memo` — as Builder INPUTS, and the card renders what
|
|
17
17
|
* those inputs hold; it runs no signature check and consults no verdict. `advanced: true` folds
|
|
18
18
|
* them out of the editor's default view, which is a UI reveal and not a lock, and `safeHttpUrl`
|
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* narrows it. See "PUSH trust model" in the package README for the full statement — including
|
|
23
23
|
* what a revocation upstream does and does not reach.
|
|
24
24
|
*
|
|
25
|
+
* 1.1.0 NARROWS THAT FOR A BLOCK CARRYING THE `delivery` MARKER (`{ v: 1, baseUrl }`, which the
|
|
26
|
+
* CertREV exporter writes). Such a block ignores every other option: it reads the signed envelope
|
|
27
|
+
* for the entry it renders inside (`builderContext.content.id`) from the CertREV Delivery API on
|
|
28
|
+
* `baseUrl` (an https certrev.com origin, nothing else), verifies it against the baked
|
|
29
|
+
* `cert-issuer-1` key, re-judges revocation and expiry at every render, and paints the brand's
|
|
30
|
+
* render def served beside it. A revoked, expired, unverifiable or unreachable cert, a marker that
|
|
31
|
+
* is not exactly that shape, or a missing entry id renders nothing. A block WITHOUT the marker
|
|
32
|
+
* (every entry exported before 1.1.0) renders from its options exactly as 1.0.3 did, so the
|
|
33
|
+
* paragraph above still describes it: an editor who deletes the marker gets the options face back,
|
|
34
|
+
* which is the 1.0.3 trust boundary, not a new one.
|
|
35
|
+
*
|
|
25
36
|
* Three things the card DOES enforce, and they are the limit of it: no chrome at all without a
|
|
26
37
|
* `reviewerName` and a `verifyUrl`; a credential renders only welded to its dated verification
|
|
27
38
|
* (the fused pair); and the FTC disclosures render from locked constants, so rewriting the
|
|
@@ -144,8 +155,10 @@ export {
|
|
|
144
155
|
type BuilderCertChromeData,
|
|
145
156
|
type BuilderCertChromeKey,
|
|
146
157
|
type CertChromeRenderDef,
|
|
158
|
+
type CertDeliveryMarker,
|
|
147
159
|
} from './cert-chrome-data.js'
|
|
148
160
|
export {
|
|
161
|
+
acceptedDeliveryMarker,
|
|
149
162
|
BUILDER_REGISTRATION,
|
|
150
163
|
type BuilderInput,
|
|
151
164
|
CERT_COMPONENT_NAME,
|
|
@@ -157,3 +170,12 @@ export {
|
|
|
157
170
|
PLACEMENT_INPUTS,
|
|
158
171
|
WIRE_INPUTS,
|
|
159
172
|
} from './cert-review-card.js'
|
|
173
|
+
export {
|
|
174
|
+
CERT_FACE_FIELDS,
|
|
175
|
+
credentialLabel,
|
|
176
|
+
type EnvelopeCardOptions,
|
|
177
|
+
envelopeCardInput,
|
|
178
|
+
faceFromDef,
|
|
179
|
+
layoutFor,
|
|
180
|
+
themeFromDef,
|
|
181
|
+
} from './envelope-face.js'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CertREV issuer PUBLIC key the envelope-backed Builder card verifies against (1.1.0).
|
|
3
|
+
*
|
|
4
|
+
* A copy of `CERT_ISSUER_KID` / `CERT_ISSUER_PUBLIC_KEY_PEM` in `modal/certrev-cert.entry.ts`, not
|
|
5
|
+
* an import of it: that entry registers a custom element and starts the storefront DOM passes at
|
|
6
|
+
* import, which a React server render must not do, and moving the constant out of it would change
|
|
7
|
+
* the storefront bundle the Shopify extension ships byte-for-byte. The two copies are pinned equal by
|
|
8
|
+
* `builder/__tests__/issuer-key.test.ts`, so a rotation that updates one and not the other fails.
|
|
9
|
+
*/
|
|
10
|
+
export const CERT_ISSUER_KID = 'cert-issuer-1'
|
|
11
|
+
export const CERT_ISSUER_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
12
|
+
MCowBQYDK2VwAyEAMWN956IOPjpAq900dL428VzA28TO/pVXnq3brwqUmwM=
|
|
13
|
+
-----END PUBLIC KEY-----`
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PHONE face's collapsible memo (1.1.0): the markup contract `renderCertBlock` emits when its
|
|
3
|
+
* input asks for the responsive face, and the one toggle every host wires to it.
|
|
4
|
+
*
|
|
5
|
+
* On a viewport `NARROW_VIEWPORT` or narrower a long memo is clamped to `MEMO_COLLAPSED_LINES`
|
|
6
|
+
* lines behind a native `<button>` that expands it; wider viewports show the whole memo and no
|
|
7
|
+
* button. The state lives on the memo box (`data-certrev-memo="collapsed" | "open"`), never on a
|
|
8
|
+
* class, so a host stylesheet's `.open` rules cannot reach it; the button mirrors it on
|
|
9
|
+
* `aria-expanded` and in its label.
|
|
10
|
+
*
|
|
11
|
+
* Pure DOM, no React: the Builder card binds it on its wrapper, and any other host that paints the
|
|
12
|
+
* responsive face (a portal preview, a web component) calls the same function.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The memo box: the memo text and its toggle. The value is the state. */
|
|
16
|
+
export const MEMO_ATTR = 'data-certrev-memo'
|
|
17
|
+
/** The clamped text inside the box. */
|
|
18
|
+
export const MEMO_TEXT_ATTR = 'data-certrev-memo-text'
|
|
19
|
+
/** The expand/collapse button inside the box. */
|
|
20
|
+
export const MEMO_TOGGLE_ATTR = 'data-certrev-memo-toggle'
|
|
21
|
+
|
|
22
|
+
export const MEMO_EXPAND_LABEL = 'Read the full memo'
|
|
23
|
+
export const MEMO_COLLAPSE_LABEL = 'Show less'
|
|
24
|
+
|
|
25
|
+
/** The viewport at or under which the phone face applies. */
|
|
26
|
+
export const NARROW_VIEWPORT = '(max-width: 640px)'
|
|
27
|
+
/** Lines a collapsed memo keeps. */
|
|
28
|
+
export const MEMO_COLLAPSED_LINES = 4
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A memo shorter than this, with fewer line breaks than `MEMO_COLLAPSE_MIN_BREAKS`, fits its
|
|
32
|
+
* collapsed lines on a phone (about 33 characters a line at 393px), so it gets no toggle: a button
|
|
33
|
+
* that reveals nothing is noise.
|
|
34
|
+
*/
|
|
35
|
+
export const MEMO_COLLAPSE_MIN_CHARS = 160
|
|
36
|
+
export const MEMO_COLLAPSE_MIN_BREAKS = MEMO_COLLAPSED_LINES
|
|
37
|
+
|
|
38
|
+
/** Is this memo long enough to collapse on a phone? (No memo never does.) */
|
|
39
|
+
export function memoCollapses(memo: string | null | undefined): boolean {
|
|
40
|
+
if (!memo) return false
|
|
41
|
+
return memo.length > MEMO_COLLAPSE_MIN_CHARS || memo.split('\n').length - 1 >= MEMO_COLLAPSE_MIN_BREAKS
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The toggle a click landed on (the button or anything inside it), or null. */
|
|
45
|
+
export function memoToggleFor(target: EventTarget | null): Element | null {
|
|
46
|
+
const el = target as Element | null
|
|
47
|
+
if (!el || typeof el.closest !== 'function') return null
|
|
48
|
+
return el.closest(`[${MEMO_TOGGLE_ATTR}]`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flip the memo box a toggle belongs to, mirroring the state on the button. Returns the new state
|
|
53
|
+
* (true = expanded), or null when the button is not inside a memo box.
|
|
54
|
+
*/
|
|
55
|
+
export function toggleMemo(button: Element): boolean | null {
|
|
56
|
+
const box = button.closest(`[${MEMO_ATTR}]`)
|
|
57
|
+
if (!box) return null
|
|
58
|
+
const open = box.getAttribute(MEMO_ATTR) !== 'open'
|
|
59
|
+
box.setAttribute(MEMO_ATTR, open ? 'open' : 'collapsed')
|
|
60
|
+
button.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
61
|
+
button.textContent = open ? MEMO_COLLAPSE_LABEL : MEMO_EXPAND_LABEL
|
|
62
|
+
return open
|
|
63
|
+
}
|