@certrev/cert-block 0.5.5 → 1.0.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 +164 -26
- package/LICENSE +21 -0
- package/README.md +415 -54
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +5 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/readme-version.test.ts +0 -51
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
package/README.md
CHANGED
|
@@ -6,14 +6,14 @@ This is the SDK the brand SSRs into a Hydrogen loader, a Next server component,
|
|
|
6
6
|
|
|
7
7
|
## What this is
|
|
8
8
|
|
|
9
|
-
CertREV's durable asset is a portable, forgery-proof "this content was reviewed by a credentialed expert" credential
|
|
9
|
+
CertREV's durable asset is a portable, forgery-proof "this content was reviewed by a credentialed expert" credential: a single Ed25519-signed `CertDeliveryEnvelope`. Each delivery surface renders the visible UI + JSON-LD from that envelope and enforces its validity. The WordPress plugin does it in PHP; the Shopify Liquid extension does it in Liquid; **this package does it in TS/JS for any React or vanilla-JS surface that can run Node/WebCrypto.**
|
|
10
10
|
|
|
11
11
|
The package gives a brand four things:
|
|
12
12
|
|
|
13
|
-
1. **React components
|
|
14
|
-
2. **A deterministic JSON-LD projector
|
|
15
|
-
3. **A thin verify layer
|
|
16
|
-
4. **A Web Component
|
|
13
|
+
1. **React components**: `<CertBadge>`, `<ExpertBio>`, `<CertRevBacklink>`, `<CertJsonLd>`, and the `<CertReview>` composite. SSR-safe (render-pure; no `useState`/`useEffect`/browser globals), theme-light, accessible, and they escape every field. Presentation is driven by the signed `content.display` config (`badgeStyle`, `accentColor`, `showExpertPhoto`, `showMemo`, `showAuthor`), which is deprecated on the wire from v0.2 (no current issuer writes it) and still read at the render edge, so a v0.1.x envelope renders exactly as it was signed.
|
|
14
|
+
2. **A deterministic JSON-LD projector**: `projectCertJsonLd(facts)` → a schema.org `@graph` of `WebPage` + `Article` + `reviewedBy(Person)` + `EducationalOccupationalCredential` + `Review` + `Organization`, designed to **merge by `@id`** into the host page's existing WebPage/Article graph (won't collide with Yoast / the theme's structured data). The JSON-LD is **projected from the facts at render time, never stored in the envelope**, and every field mirroring something the card hides is gated by the same `resolveDisplay` the card uses; the graph never asserts what the reader cannot see.
|
|
15
|
+
3. **A thin verify layer**: `getVerifiedEnvelope(source)` fetches the signed envelope from **either a Shopify metafield OR the Delivery API** (`GET /api/cert/v1/delivery/{platform}/{externalId}`), runs the fail-closed `VerdictKernel`, and caches the verdict (TTL + single-flight) so concurrent SSR renders don't stampede the origin.
|
|
16
|
+
4. **A Web Component**: `<certrev-badge>`, the `universal_embed` surface for sites with no native integration, wrapping the same render as `<CertBadge>` via a shared string renderer.
|
|
17
17
|
|
|
18
18
|
## The contract shape (settled)
|
|
19
19
|
|
|
@@ -24,14 +24,15 @@ payload = {
|
|
|
24
24
|
contractVersion: 1,
|
|
25
25
|
certId,
|
|
26
26
|
subject: { platform, externalId, logicalArticleId, canonicalUrls[], installationId, contentDigest },
|
|
27
|
-
content: { expert{displayName,credentials[],profileUrl,photoUrl
|
|
28
|
-
memo, certifiedAt, contentModifiedAt, verifyUrl,
|
|
29
|
-
|
|
27
|
+
content: { expert{displayName,credentials[],profileUrl,photoUrl,bio?,background?,compensationCue?},
|
|
28
|
+
author{name,title,photoUrl?,bio?}, memo, certifiedAt, contentModifiedAt, verifyUrl,
|
|
29
|
+
articleTitle?, displayCertId?, // v0.5, signed
|
|
30
|
+
display? }, // deprecated v0.2: accept-on-read only
|
|
30
31
|
lifecycle: { issuedAt, expiresAt, revokedAt|null, revision },
|
|
31
32
|
}
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
`content` is **structured FACTS**, not rendered JSON-LD
|
|
35
|
+
`content` is **structured FACTS**, not rendered JSON-LD; the JSON-LD is projected from these facts at render, never stored. The `VerdictKernel` (shared) verifies the signature, then checks subject (`platform`/`externalId`), lifecycle (`revokedAt`/`expiresAt`), and content drift (`contentDigest` vs the live hash), failing closed at every step.
|
|
35
36
|
|
|
36
37
|
## Public API
|
|
37
38
|
|
|
@@ -50,21 +51,44 @@ import { staticKidResolver, fetchingKidResolver } from '@certrev/cert-block'
|
|
|
50
51
|
import type { CertDeliveryEnvelope, CertPayload, CertVerdict, RenderContext } from '@certrev/cert-block'
|
|
51
52
|
import { verifyEnvelope, renderVerdict, verifySignatureOnly } from '@certrev/cert-block'
|
|
52
53
|
|
|
53
|
-
//
|
|
54
|
+
// Link qualification: the `rel` every anchor this package emits carries (read-only)
|
|
55
|
+
import { CERTREV_LINK_REL } from '@certrev/cert-block'
|
|
56
|
+
|
|
57
|
+
// Web Component (separate subpath: does NOT touch customElements on import)
|
|
54
58
|
import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'
|
|
55
59
|
|
|
56
60
|
// Builder.io registration (separate subpath; no Builder dependency)
|
|
57
61
|
import { certRevCertComponent, BUILDER_REGISTRATION, CERT_COMPONENT_NAME } from '@certrev/cert-block/builder'
|
|
62
|
+
|
|
63
|
+
// Certificate modal: the ONE SIDE-EFFECTING entry in the package. Importing it registers
|
|
64
|
+
// <certrev-cert-modal> and, in a browser, starts four DOM passes (placement, interactions,
|
|
65
|
+
// FTC guard, badge revalidation). Browser only. Never import it from server code. Most
|
|
66
|
+
// integrations should load the prebuilt IIFE as an external <script> instead (see below).
|
|
67
|
+
import '@certrev/cert-block/modal'
|
|
68
|
+
import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM, CERT_MODAL_TAG } from '@certrev/cert-block/modal'
|
|
69
|
+
import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
|
|
70
|
+
|
|
71
|
+
// The placement engine on its own: pure functions + selector constants, no registration,
|
|
72
|
+
// no DOM pass on import. This is the subpath to reach for if you only want the anchors.
|
|
73
|
+
import { placeCert, initCertPlacement, PLACEMENT_ANCHORS } from '@certrev/cert-block/modal/placement'
|
|
74
|
+
|
|
75
|
+
// Test/dev fixtures: Node-only (they sign with node:crypto), which is why they are NOT on
|
|
76
|
+
// the main entry: importing `@certrev/cert-block` must never pull a Node builtin into an edge bundle.
|
|
77
|
+
import { makeMockPayload, makeSignedEnvelope, FIXTURE_KID } from '@certrev/cert-block/fixtures'
|
|
58
78
|
```
|
|
59
79
|
|
|
60
|
-
## Usage
|
|
80
|
+
## Usage: headless React (Hydrogen / Next server component)
|
|
61
81
|
|
|
62
|
-
Fetch + verify in the **server** runtime (it can run Ed25519 over JCS
|
|
82
|
+
Fetch + verify in the **server** runtime (it can run Ed25519 over JCS: full `crypto_verify` parity), then render the badge + JSON-LD only on a `render` verdict.
|
|
63
83
|
|
|
64
84
|
```tsx
|
|
65
|
-
import { getVerifiedEnvelope,
|
|
85
|
+
import { getVerifiedEnvelope, fetchingKidResolver, CertReview } from '@certrev/cert-block'
|
|
66
86
|
|
|
67
|
-
|
|
87
|
+
// Reach for this one first: it reads CertREV's published key set, so a key rotation resolves
|
|
88
|
+
// without a redeploy. One fetch populates every kid; concurrent misses single-flight; the set
|
|
89
|
+
// is cached for an hour by default (`ttlMs`) and the fetch itself has a 3s deadline
|
|
90
|
+
// (`timeoutMs`), because this runs on the render path.
|
|
91
|
+
const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
|
|
68
92
|
|
|
69
93
|
// In a Hydrogen loader / Next server component:
|
|
70
94
|
const verdict = await getVerifiedEnvelope({
|
|
@@ -78,6 +102,32 @@ const verdict = await getVerifiedEnvelope({
|
|
|
78
102
|
return <CertReview verdict={verdict} pageUrl={canonicalUrl} />
|
|
79
103
|
```
|
|
80
104
|
|
|
105
|
+
### Picking a key resolver
|
|
106
|
+
|
|
107
|
+
Two resolvers ship, and the difference matters at rotation time:
|
|
108
|
+
|
|
109
|
+
- **`fetchingKidResolver({ jwksUrl, ttlMs?, timeoutMs?, fetchImpl? })`**: fetches CertREV's
|
|
110
|
+
published key set and caches it. Right for anything long-running: a rotated kid resolves on
|
|
111
|
+
the next cache miss with no redeploy. Costs one cross-origin fetch per TTL window.
|
|
112
|
+
- **`staticKidResolver({ [kid]: pem })`**: a key set baked into the deploy. Zero network at
|
|
113
|
+
render time, which is why it is right for an edge/Workers runtime that must not make an
|
|
114
|
+
outbound call, or for a build whose key you pin deliberately. The cost is that a rotation
|
|
115
|
+
needs a redeploy. Today CertREV publishes exactly one key, `cert-issuer-1`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { staticKidResolver } from '@certrev/cert-block'
|
|
119
|
+
const resolveKid = staticKidResolver({ 'cert-issuer-1': process.env.CERTREV_PUBKEY_PEM! })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Get the PEM from `https://certrev.com/.well-known/jwks.json` (the `x` value is the base64url
|
|
123
|
+
raw Ed25519 key; the resolver also accepts a JWK directly).
|
|
124
|
+
|
|
125
|
+
**When the kid is wrong the failure is silent.** A resolver that returns `null` for the kid on
|
|
126
|
+
the envelope (a typo'd key name, a stale bake after a rotation) produces
|
|
127
|
+
`{ decision: 'suppress', reason: 'unknown_key' }`, and `<CertReview>` renders nothing. This
|
|
128
|
+
package writes nothing to the console on any path, so `verdict.reason` is the only signal you
|
|
129
|
+
get. Log it while integrating.
|
|
130
|
+
|
|
81
131
|
For finer control, render the pieces independently from `verdict.payload`:
|
|
82
132
|
|
|
83
133
|
```tsx
|
|
@@ -91,7 +141,36 @@ For finer control, render the pieces independently from `verdict.payload`:
|
|
|
91
141
|
)}
|
|
92
142
|
```
|
|
93
143
|
|
|
94
|
-
|
|
144
|
+
### Caching + deadlines
|
|
145
|
+
|
|
146
|
+
`getVerifiedEnvelope` is safe to call on every render. What bounds the origin load:
|
|
147
|
+
|
|
148
|
+
- **A process-local `TtlCache`.** Default: 60s for a `render` verdict, 5s for a `suppress`, 1000
|
|
149
|
+
entries, and single-flight. N concurrent renders of the same page do one fetch, not N.
|
|
150
|
+
- **`ttlMs`** raises or lowers the TTL for a `render` verdict written by that call. It
|
|
151
|
+
deliberately does **not** apply to a `suppress`, which keeps the short negative TTL, so a
|
|
152
|
+
longer `ttlMs` can never make a transient origin failure sticky.
|
|
153
|
+
- **`timeoutMs`** (default 3s) is the deadline on the Delivery-API fetch. On expiry the request
|
|
154
|
+
is aborted and the call fails closed; a hung origin never holds a render open. The metafield
|
|
155
|
+
source does no I/O and ignores it. `fetchingKidResolver` carries the same deadline for its own
|
|
156
|
+
JWKS fetch.
|
|
157
|
+
- **`cache`** takes your own `TtlCache` instance instead of the module-level
|
|
158
|
+
`sharedVerdictCache`: the seam a test or a multi-tenant server uses to avoid one shared map.
|
|
159
|
+
- **`invalidateVerdict(source, context, cache?)`** drops every cached verdict for one placement,
|
|
160
|
+
whatever live content hash each was cached under. Call it from a revocation webhook: the
|
|
161
|
+
webhook knows the article, never the body hash a given render observed.
|
|
162
|
+
|
|
163
|
+
The cache key covers the source URL (or a hash of the metafield value), the platform/externalId,
|
|
164
|
+
and the live content hash. So staging and production do not share an entry, and a verdict
|
|
165
|
+
computed before the article drifted is not served after it.
|
|
166
|
+
|
|
167
|
+
This cache is **per instance, and nothing more**. On a multi-instance deploy each instance keeps
|
|
168
|
+
its own copy. A shared L2 (a CDN in front of the Delivery API, cacheable on
|
|
169
|
+
`lifecycle.revision`, or a KV store) is the caller's to add, and this package has no hook for
|
|
170
|
+
one. If you do add one, bound it: a positive verdict held past a revocation is exactly the
|
|
171
|
+
failure the verify layer exists to prevent.
|
|
172
|
+
|
|
173
|
+
## Usage: JSON-LD merge (don't collide with Yoast)
|
|
95
174
|
|
|
96
175
|
The Article node carries the SAME `@id` the host page's primary Article uses (`{pageUrl}#article`, query + fragment stripped), so a consumer **merges** our `reviewedBy` / `dateModified` into the existing node instead of emitting a competing primary entity. Pass `wrapGraph: false` to get a bare node array to splice into a `@graph` you already own.
|
|
97
176
|
|
|
@@ -100,9 +179,29 @@ const graph = projectCertJsonLd(payload, { pageUrl: canonicalUrl }) //
|
|
|
100
179
|
const nodes = projectCertJsonLd(payload, { pageUrl: canonicalUrl, wrapGraph: false }) // bare node[]
|
|
101
180
|
```
|
|
102
181
|
|
|
103
|
-
|
|
182
|
+
Three `@id` conventions the merge depends on. Any surface emitting a CertREV graph (this package,
|
|
183
|
+
Portal, the WordPress plugin, the Liquid extension) has to agree on all three, or the halves land
|
|
184
|
+
as sibling nodes instead of merging:
|
|
104
185
|
|
|
105
|
-
|
|
186
|
+
- **WebPage** → the canonical page URL, query and fragment stripped.
|
|
187
|
+
- **Article** → that same URL + `#article` (the Yoast convention). A page reached with a `?utm=…`
|
|
188
|
+
param must still derive the canonical `@id`, which is why the query is stripped.
|
|
189
|
+
- **The CertREV `Organization`** → the hard literal `https://certrev.com/#certrev-organization`,
|
|
190
|
+
never derived from whichever host served the verify link. CertREV is one real-world
|
|
191
|
+
organization; a staging origin, a vanity verify domain or a future regional host must not mint a
|
|
192
|
+
second org node that never merges with the first. Emit the literal.
|
|
193
|
+
|
|
194
|
+
Our own nodes (`Review`, the expert `Person`, their credentials) are keyed to the certification:
|
|
195
|
+
the verify URL, the expert profile, that fixed organization id. They never sit under the host
|
|
196
|
+
page's `@id` namespace, so they cannot clash with anything the host emits.
|
|
197
|
+
|
|
198
|
+
Pass `renderDef` alongside `pageUrl` when the brand hides fields on the card: the projector gates
|
|
199
|
+
the memo, the author byline and the reviewer photo through the same `resolveDisplay` the card
|
|
200
|
+
uses, so a hidden field is absent from the graph too.
|
|
201
|
+
|
|
202
|
+
## Usage: universal embed (Web Component)
|
|
203
|
+
|
|
204
|
+
Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the badge HTML (via `renderBadgeHtml`) inside the element; the component leaves the server-rendered child alone. Client-fetch mode (not crawlable) is a documented fallback and is **fail-closed**; it renders nothing without a configured key resolver.
|
|
106
205
|
|
|
107
206
|
```html
|
|
108
207
|
<!-- SSR: server emits the badge markup inside the tag; crawler reads it -->
|
|
@@ -115,21 +214,27 @@ Preferred mode is **SSR + hydrate** (crawlable): a server-side include emits the
|
|
|
115
214
|
</script>
|
|
116
215
|
```
|
|
117
216
|
|
|
118
|
-
## Usage
|
|
217
|
+
## Usage: Builder.io (the cert component)
|
|
119
218
|
|
|
120
219
|
Two ways to place the visible cert on a Builder.io page. **PUSH is the primary story** for
|
|
121
220
|
exporter-driven brands (the cert block arrives with the CertREV-drafted entry); the PULL anchor
|
|
122
|
-
is the editor-placed alternative
|
|
221
|
+
is the editor-placed alternative.
|
|
222
|
+
|
|
223
|
+
**They do not have the same trust model, and that should drive the choice more than layout does.**
|
|
224
|
+
PULL renders from a verdict your own loader verified and passed down through `CertRevProvider`:
|
|
225
|
+
the editor picks the spot, the system picks the credential, and a `suppress` verdict renders
|
|
226
|
+
nothing. PUSH renders from strings the exporter wrote into the CMS entry, and verifies nothing.
|
|
227
|
+
Read [PUSH trust model](#push-trust-model) before choosing it.
|
|
123
228
|
|
|
124
|
-
### PUSH
|
|
229
|
+
### PUSH: the registered cert block
|
|
125
230
|
|
|
126
231
|
CertREV's exporter emits a cert block whose options are the `BuilderCertChromeData` projection.
|
|
127
|
-
Register the ready component once, from the `./builder` subpath
|
|
232
|
+
Register the ready component once, from the `./builder` subpath: one import, one entry in the
|
|
128
233
|
`customComponents` array your app already passes to the gen2 SDK's `<Content>` (the same array
|
|
129
234
|
your other custom components render from). No hand-mapped options, no hand-maintained inputs:
|
|
130
235
|
|
|
131
236
|
```tsx
|
|
132
|
-
// gen2 SDKs (@builder.io/sdk-react and friends
|
|
237
|
+
// gen2 SDKs (@builder.io/sdk-react and friends: Hydrogen/Remix, Next, Gatsby)
|
|
133
238
|
import { certRevCertComponent } from '@certrev/cert-block/builder'
|
|
134
239
|
|
|
135
240
|
<Content
|
|
@@ -141,7 +246,7 @@ import { certRevCertComponent } from '@certrev/cert-block/builder'
|
|
|
141
246
|
```
|
|
142
247
|
|
|
143
248
|
On `0.5.2` and earlier (before `certRevCertComponent` existed), build the entry from the
|
|
144
|
-
canonical readonly view
|
|
249
|
+
canonical readonly view; the spread sheds the `readonly` typing, which the gen2 SDK's mutable
|
|
145
250
|
`inputs[]` type won't accept directly:
|
|
146
251
|
|
|
147
252
|
```ts
|
|
@@ -159,18 +264,64 @@ Gen1 SDK (`@builder.io/react`) consumers register the same data imperatively:
|
|
|
159
264
|
|
|
160
265
|
`CERT_COMPONENT_NAME` (`"CertREV Cert"`) is the canonical block name the portal's
|
|
161
266
|
`certComponent` connection field defaults to; the inputs are single-sourced from the
|
|
162
|
-
`BuilderCertChromeData` wire type (drift-locked
|
|
267
|
+
`BuilderCertChromeData` wire type (drift-locked: the exporter option keys, the registered
|
|
163
268
|
inputs, and the type can never diverge silently). The 19 exporter-populated wire inputs are
|
|
164
269
|
marked `advanced`, so the editor's Options tab leads with the two placement choices an editor
|
|
165
270
|
actually makes (`mode`, `part`). The component enforces the fused-credential rule by
|
|
166
271
|
construction (a credential is unrenderable without its dated verification) and themes to the
|
|
167
272
|
brand's render-def (accent / surface / corners / font + v2 bar-ink / body-ink).
|
|
168
273
|
|
|
274
|
+
### PUSH trust model
|
|
275
|
+
|
|
276
|
+
Read this before choosing PUSH. The registered card is **chrome, not a verifier**, and it is a
|
|
277
|
+
different trust model from the headless-React path above.
|
|
278
|
+
|
|
279
|
+
**Nothing cryptographic runs at card render.** The card draws entirely from the block options the
|
|
280
|
+
CertREV exporter wrote into the CMS entry: display strings that were projected from the signed
|
|
281
|
+
envelope at export time. Its whole guard is that `reviewerName` and `verifyUrl` are non-empty
|
|
282
|
+
strings; anything malformed degrades to the name-only face. There is no signature check, no
|
|
283
|
+
subject check, and `revokedAt` / `expiresAt` / `contentDigest` are not fields on
|
|
284
|
+
`BuilderCertChromeData` at all; they are not on the wire, so the card could not check them.
|
|
285
|
+
|
|
286
|
+
**What follows from that:** anyone who can write to the CMS entry can render a fully-branded cert
|
|
287
|
+
card, because to the card a hand-typed option and an exporter-written option are the same string.
|
|
288
|
+
The trust boundary on this path is **write access to the Builder space**, not a signature.
|
|
289
|
+
Treat the space's editor permissions and the exporter's API key accordingly. If you need the
|
|
290
|
+
credential itself verified at render, use the headless-React path (`getVerifiedEnvelope` +
|
|
291
|
+
`<CertReview>`), where a bad signature, wrong subject, revocation, expiry or content drift each
|
|
292
|
+
collapse to `suppress`.
|
|
293
|
+
|
|
294
|
+
**Revocation, precisely.** A CMS snapshot cannot self-invalidate: a revocation upstream does not
|
|
295
|
+
reach the entry, and the card keeps rendering the chrome until something rewrites or removes that
|
|
296
|
+
entry. One narrower thing does happen, and only this. When a visitor **clicks** a
|
|
297
|
+
`data-certrev-modal-open` control (the "View certificate" / reviewer links the card emits) on a
|
|
298
|
+
page that loaded the modal script and carries `data-delivery-api` + `data-platform` +
|
|
299
|
+
`data-external-id`, the modal fetches the Delivery API live. If that returns a revocation
|
|
300
|
+
tombstone, or an envelope with `revokedAt` set or `expiresAt` passed, the modal refuses to open
|
|
301
|
+
and calls `markSuppressed()`: it stamps `data-certrev-suppressed` on itself and sets `hidden` +
|
|
302
|
+
`aria-hidden` on **every** `[data-certrev-modal-open]` control in the document. So the certificate
|
|
303
|
+
links disappear on that page view. The card body (the reviewer's name, the credential, the memo,
|
|
304
|
+
the "Expert reviewed" chrome) is not touched, and nothing happens at all for a visitor who never
|
|
305
|
+
clicks, for a page without the modal script, or for a crawler. One more way to lose even that: the
|
|
306
|
+
modal prefers a synchronous envelope source, so if the page inlines one (a light-DOM
|
|
307
|
+
`<script data-certrev-envelope>` child or a `data-envelope` attribute) it opens from that snapshot
|
|
308
|
+
and never fetches. Leave the modal on the routing attributes if you want the live check.
|
|
309
|
+
|
|
310
|
+
**How a revoked cert actually stops rendering on this path is a CMS-side question**: who rewrites
|
|
311
|
+
or unpublishes the entry, and how fast. This package does not implement it and does not assume it.
|
|
312
|
+
|
|
313
|
+
**The card emits no structured data.** By design: a drag-droppable page-builder block must not own
|
|
314
|
+
JSON-LD, or an editor can duplicate it, move it out of `<head>`, or delete it. So the card renders
|
|
315
|
+
chrome only, and the **article route** emits the JSON-LD server-side via `projectCertJsonLd` from
|
|
316
|
+
the verified payload. See [The article route example](#the-article-route-example). It cannot come
|
|
317
|
+
from the block options: `projectCertJsonLd` takes a `CertPayload`, and the CMS hands the card
|
|
318
|
+
display strings.
|
|
319
|
+
|
|
169
320
|
### The certificate modal + CSP
|
|
170
321
|
|
|
171
322
|
The "View certificate" affordance opens an envelope-verified modal. The browser bundle **ships
|
|
172
323
|
in-package**: a prebuilt, dependency-free, minified IIFE at `@certrev/cert-block/modal/certrev-cert.js`.
|
|
173
|
-
Serve it as an external `<script
|
|
324
|
+
Serve it as an external `<script>`. Do **not** side-effect-import it into server code (it
|
|
174
325
|
registers `customElements` and touches `window`; keeping it an external script keeps it out of
|
|
175
326
|
the SSR bundle entirely):
|
|
176
327
|
|
|
@@ -187,50 +338,225 @@ import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
|
|
|
187
338
|
<script src={certScriptUrl} defer />
|
|
188
339
|
```
|
|
189
340
|
|
|
190
|
-
|
|
191
|
-
|
|
341
|
+
CSP allowances for the headless host. The **registered card** needs none; it fetches no font,
|
|
342
|
+
loads no image (the avatar is rendered initials) and inlines its logo as SVG. These are what the
|
|
343
|
+
**modal** needs:
|
|
192
344
|
|
|
193
345
|
- `connect-src` → the CertREV Delivery origin the element fetches the signed envelope from
|
|
194
346
|
(production `https://portal.certrev.com`; staging `https://staging.portal.certrev.com`).
|
|
195
|
-
- `img-src` → `https://
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
347
|
+
- `img-src` → `https://assets.certrev.com` (where CertREV headshots live, and where the modal's
|
|
348
|
+
`/cdn-cgi/image/` display-sized derivative is served from) plus any other origin the envelope's
|
|
349
|
+
`photoUrl` points at. `<CertBadge>` and `renderBadgeHtml` need the same allowance on the
|
|
350
|
+
headless-React and universal-embed paths, since both emit the expert photo as an `<img>`.
|
|
351
|
+
- `font-src` → `https://assets.certrev.com`, unless you set `data-fonts="host"`. This one is easy
|
|
352
|
+
to miss: Hydrogen's default `font-src` falls back to `default-src`, which does not include that
|
|
353
|
+
host, so the modal's font faces fail to load and it renders in the fallback stack.
|
|
354
|
+
|
|
355
|
+
**`data-fonts` is the switch for that last one.** On first open the modal injects a `<style>` into
|
|
356
|
+
`document.head` declaring the five self-hosted families it uses: DM Sans, Playfair Display,
|
|
357
|
+
JetBrains Mono, Plus Jakarta Sans and Allura (12 `@font-face` rules, all pointing at
|
|
358
|
+
`https://assets.certrev.com/fonts/`), plus a crossorigin `preconnect` to that origin. Font faces
|
|
359
|
+
do not scope into a shadow root, which is why the injection is at document level. Set
|
|
360
|
+
`data-fonts="host"` to suppress it entirely; then the host page must self-host those five families
|
|
361
|
+
itself, and the modal adds no `font-src` origin to its CSP. Omitting the attribute is the default
|
|
362
|
+
and is fine; it just means the origin has to be in `font-src`.
|
|
363
|
+
|
|
364
|
+
`data-delivery-api` is the **origin only**: the element appends
|
|
365
|
+
`/api/cert/v1/delivery/{platform}/{externalId}`.
|
|
366
|
+
|
|
367
|
+
### PULL: the editor-placed anchor (alternative)
|
|
203
368
|
|
|
204
369
|
`CertRevProvider` + the zero-input `CertRevAnchor` (`@certrev/cert-block/builder`) let an editor
|
|
205
|
-
drop the cert wherever they want it
|
|
206
|
-
rather than exporter-driven. *(The anchor is preset-default only today
|
|
370
|
+
drop the cert wherever they want it. Use this when placement is editorial
|
|
371
|
+
rather than exporter-driven. *(The anchor is preset-default only today; render-def theming on
|
|
207
372
|
the anchor path is a follow-up.)*
|
|
208
373
|
|
|
209
|
-
##
|
|
374
|
+
## The article route example
|
|
210
375
|
|
|
211
|
-
The
|
|
376
|
+
The route is where the two halves meet: `<Content>` renders the CMS-driven card, and the route
|
|
377
|
+
itself (server-side, from the verified envelope) emits the JSON-LD the card deliberately does
|
|
378
|
+
not own. This is the example `src/builder/index.tsx` refers to.
|
|
379
|
+
|
|
380
|
+
**Hydrogen / Remix** (`app/routes/blog.$handle.tsx`):
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
import { useLoaderData } from '@remix-run/react'
|
|
384
|
+
import { Content, fetchOneEntry, isPreviewing } from '@builder.io/sdk-react'
|
|
385
|
+
import { certRevCertComponent } from '@certrev/cert-block/builder'
|
|
386
|
+
import { CertJsonLd, fetchingKidResolver, getVerifiedEnvelope } from '@certrev/cert-block'
|
|
387
|
+
import certScriptUrl from '@certrev/cert-block/modal/certrev-cert.js?url'
|
|
388
|
+
|
|
389
|
+
const MODEL = 'blog-article'
|
|
390
|
+
const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
|
|
391
|
+
|
|
392
|
+
export async function loader({ params, request, context }) {
|
|
393
|
+
const urlPath = new URL(request.url).pathname
|
|
394
|
+
const apiKey = context.env.BUILDER_PUBLIC_API_KEY
|
|
395
|
+
|
|
396
|
+
const content = await fetchOneEntry({ model: MODEL, apiKey, userAttributes: { urlPath } })
|
|
397
|
+
if (!content) throw new Response('Not found', { status: 404 })
|
|
398
|
+
|
|
399
|
+
// The externalId the cert was issued against: the Builder entry id for a Builder placement.
|
|
400
|
+
const externalId = content.id
|
|
401
|
+
const canonicalUrl = new URL(urlPath, 'https://brand.example.com').toString()
|
|
402
|
+
|
|
403
|
+
// Verify per request. Do NOT long-cache this HTML: a revoked credential sitting in a stale
|
|
404
|
+
// edge cache defeats fail-closed. The in-process TTL cache below already bounds origin load.
|
|
405
|
+
const verdict = await getVerifiedEnvelope({
|
|
406
|
+
source: {
|
|
407
|
+
kind: 'delivery_api',
|
|
408
|
+
baseUrl: 'https://portal.certrev.com',
|
|
409
|
+
platform: 'builder',
|
|
410
|
+
externalId,
|
|
411
|
+
},
|
|
412
|
+
resolveKid,
|
|
413
|
+
context: { platform: 'builder', externalId },
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
return { content, apiKey, externalId, canonicalUrl, verdict }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export default function ArticleRoute() {
|
|
420
|
+
const { content, apiKey, externalId, canonicalUrl, verdict } = useLoaderData<typeof loader>()
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<>
|
|
424
|
+
{/* The STRUCTURED DATA: route-owned, from the verified payload. The card emits none. */}
|
|
425
|
+
{verdict.decision === 'render' && (
|
|
426
|
+
<CertJsonLd payload={verdict.payload} pageUrl={canonicalUrl} />
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{/* The VISIBLE CARD: placed by the CMS, rendered from the exporter's block options. */}
|
|
430
|
+
<Content
|
|
431
|
+
model={MODEL}
|
|
432
|
+
content={content}
|
|
433
|
+
apiKey={apiKey}
|
|
434
|
+
customComponents={[certRevCertComponent]}
|
|
435
|
+
/>
|
|
436
|
+
|
|
437
|
+
{/* The certificate modal the card's "View certificate" link opens. */}
|
|
438
|
+
<certrev-cert-modal
|
|
439
|
+
data-delivery-api="https://portal.certrev.com"
|
|
440
|
+
data-platform="builder"
|
|
441
|
+
data-external-id={externalId}
|
|
442
|
+
/>
|
|
443
|
+
<script src={certScriptUrl} defer />
|
|
444
|
+
</>
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The **Next.js App Router** shape is the same two halves in one async server component:
|
|
450
|
+
|
|
451
|
+
```tsx
|
|
452
|
+
import { Content, fetchOneEntry } from '@builder.io/sdk-react'
|
|
453
|
+
import { certRevCertComponent } from '@certrev/cert-block/builder'
|
|
454
|
+
import { CertJsonLd, fetchingKidResolver, getVerifiedEnvelope } from '@certrev/cert-block'
|
|
455
|
+
|
|
456
|
+
const resolveKid = fetchingKidResolver({ jwksUrl: 'https://certrev.com/.well-known/jwks.json' })
|
|
457
|
+
|
|
458
|
+
export default async function Page({ params }: { params: { slug: string[] } }) {
|
|
459
|
+
const urlPath = `/${params.slug.join('/')}`
|
|
460
|
+
const apiKey = process.env.NEXT_PUBLIC_BUILDER_API_KEY!
|
|
461
|
+
const content = await fetchOneEntry({ model: 'blog-article', apiKey, userAttributes: { urlPath } })
|
|
462
|
+
if (!content) return null
|
|
463
|
+
|
|
464
|
+
const verdict = await getVerifiedEnvelope({
|
|
465
|
+
source: {
|
|
466
|
+
kind: 'delivery_api',
|
|
467
|
+
baseUrl: 'https://portal.certrev.com',
|
|
468
|
+
platform: 'builder',
|
|
469
|
+
externalId: content.id,
|
|
470
|
+
},
|
|
471
|
+
resolveKid,
|
|
472
|
+
context: { platform: 'builder', externalId: content.id },
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
<>
|
|
477
|
+
{verdict.decision === 'render' && (
|
|
478
|
+
<CertJsonLd payload={verdict.payload} pageUrl={`https://brand.example.com${urlPath}`} />
|
|
479
|
+
)}
|
|
480
|
+
<Content
|
|
481
|
+
model="blog-article"
|
|
482
|
+
content={content}
|
|
483
|
+
apiKey={apiKey}
|
|
484
|
+
customComponents={[certRevCertComponent]}
|
|
485
|
+
/>
|
|
486
|
+
</>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Four things about this shape that are easy to get wrong:
|
|
492
|
+
|
|
493
|
+
- **`<CertJsonLd>` is the route's, not the block's.** It wraps `projectCertJsonLd` +
|
|
494
|
+
`serializeJsonLdForScript`; call those two directly if you are not rendering through React. Put
|
|
495
|
+
it where your framework hoists `<script>` into `<head>` (Next: a layout/head; Hydrogen: React 19
|
|
496
|
+
hoists it).
|
|
497
|
+
- **The two halves can disagree, and the failure is one-directional.** The card comes from the CMS
|
|
498
|
+
and the JSON-LD comes from the verify layer, so on a `suppress` verdict the JSON-LD is correctly
|
|
499
|
+
omitted while the card **still renders**. See [PUSH trust model](#push-trust-model). Only the
|
|
500
|
+
structured data is fail-closed here.
|
|
501
|
+
- **Verify per request.** Treat the loader as a dynamic edge subrequest and do not long-cache the
|
|
502
|
+
rendered HTML. `getVerifiedEnvelope`'s own TTL + single-flight cache is what keeps origin load
|
|
503
|
+
bounded; see [Caching + deadlines](#caching--deadlines).
|
|
504
|
+
- **`externalId` must be the id the cert was issued against.** For a Builder placement that is the
|
|
505
|
+
entry id the portal recorded at publish. A mismatch is not a soft failure: the kernel returns
|
|
506
|
+
`suppress` with `reason: 'subject_mismatch'`, and nothing is logged.
|
|
507
|
+
|
|
508
|
+
## Fail-closed, where the verifying happens
|
|
509
|
+
|
|
510
|
+
**The verify layer never yields a credential it could not verify.** Every error path through
|
|
511
|
+
`getVerifiedEnvelope` / the `VerdictKernel` (bad signature, unknown kid, wrong post, revoked,
|
|
512
|
+
expired, content drift, network failure, fetch deadline, malformed JSON, a throwing resolver)
|
|
513
|
+
collapses to a `suppress` verdict, never an exception that a caller could swallow into a render.
|
|
514
|
+
`<CertReview>` and `<CertRevBacklink>` fail closed at the component boundary too (suppress /
|
|
515
|
+
unsafe URL → render nothing). The verify path is the one to reach for when the guarantee has to
|
|
516
|
+
be cryptographic.
|
|
517
|
+
|
|
518
|
+
**Scope it deliberately: this is a property of the verify layer, not of every entry point.** The
|
|
519
|
+
registered Builder card takes CMS block options, not an envelope, so it verifies nothing. See
|
|
520
|
+
[PUSH trust model](#push-trust-model). `renderCertBlock` and `renderBadgeHtml` are string
|
|
521
|
+
renderers over facts a caller supplies; whether those facts were verified is the caller's
|
|
522
|
+
business. A component that never receives a signature cannot check one, and this section used to
|
|
523
|
+
read as though it did.
|
|
524
|
+
|
|
525
|
+
What IS true everywhere, including those paths: nothing here throws into a render, and every
|
|
526
|
+
field is escaped. URLs pass through `safeHttpUrl` (drops `javascript:`/`data:`); accent colors
|
|
527
|
+
through `safeCssColor`; a value of the wrong type degrades to an empty render instead of crashing
|
|
528
|
+
the route; the JSON-LD body neutralizes `<`/`>` so a hostile memo can't break out of the
|
|
529
|
+
`<script>` tag; and every anchor the package emits carries `CERTREV_LINK_REL`
|
|
530
|
+
(`nofollow sponsored noopener`).
|
|
212
531
|
|
|
213
532
|
## Server-safe guarantee
|
|
214
533
|
|
|
215
|
-
- The main entry never touches `customElements` / `window
|
|
216
|
-
- The React components are render-pure (verified by `react-dom/server` SSR tests) so they work as Server Components and as client components that SSR identically
|
|
534
|
+
- The main entry never touches `customElements` / `window`; the Web Component ships from the `./webcomponent` subpath so importing `@certrev/cert-block` in an RSC/Node loader is side-effect-free. Registration only happens when you call `defineCertRevBadge()` in a browser.
|
|
535
|
+
- The React components are render-pure (verified by `react-dom/server` SSR tests) so they work as Server Components and as client components that SSR identically: same crawlable output either way.
|
|
217
536
|
- Date formatting is deterministic UTC (fixed English month abbreviations, not `toLocaleDateString`) so SSR output is byte-stable and never causes a hydration mismatch.
|
|
218
537
|
|
|
219
538
|
## The contract dependency
|
|
220
539
|
|
|
221
540
|
The shared contract types + the fail-closed `VerdictKernel` live in
|
|
222
541
|
[`@certrev/cert-contract`](https://www.npmjs.com/package/@certrev/cert-contract) (published, MIT).
|
|
223
|
-
Since `0.5.3` it is a **regular dependency
|
|
542
|
+
Since `0.5.3` it is a **regular dependency**: `npm i @certrev/cert-block` brings it along; a
|
|
224
543
|
brand integration never imports it directly. Its one runtime dependency is `canonicalize` (the
|
|
225
544
|
RFC-8785 JCS implementation used for signature verification). To be clear about that word:
|
|
226
|
-
**nothing cryptographic runs at card render
|
|
545
|
+
**nothing cryptographic runs at card render**. The cert card draws purely from block options;
|
|
227
546
|
verification enters only with the modal / the `getVerifiedEnvelope` verify layer.
|
|
228
547
|
|
|
229
|
-
React is the one **peer** dependency left
|
|
548
|
+
React is the one **peer** dependency left: **`react >=18.0.0`**, declared non-optional, so a
|
|
230
549
|
peer-enforcing resolver (npm 7+) fails the install on React 17 with `ERESOLVE` rather than
|
|
231
550
|
warning. Only the React entries need it: `./webcomponent` and `./modal` build React-free (no
|
|
232
|
-
`react/jsx-runtime`) and there is no `react-dom` peer.
|
|
233
|
-
|
|
551
|
+
`react/jsx-runtime`) and there is no `react-dom` peer.
|
|
552
|
+
|
|
553
|
+
**What CI actually proves about that range: React 18.3 only.** One job, Node 20, installing the
|
|
554
|
+
`react@^18.3.1` devDependency: it builds, typechecks and runs the whole suite including the
|
|
555
|
+
`react-dom/server` SSR tests, but there is no version matrix and no React 19 job. `>=18.0.0` is
|
|
556
|
+
the range `package.json` declares, not a range CI exercises. Nothing in the package imports a
|
|
557
|
+
React-19-only API (the package's only `react` import is `createContext` / `useContext` in the
|
|
558
|
+
PULL anchor, and every other component is render-pure with no hooks), so 19 is expected to work.
|
|
559
|
+
That is expectation, not evidence; if you run 19 you are the first to find out.
|
|
234
560
|
|
|
235
561
|
## Known issues (upstream)
|
|
236
562
|
|
|
@@ -244,25 +570,60 @@ React 18.3; the range admits 19.
|
|
|
244
570
|
- Brand-owned Hydrogen / Next / Remix storefronts (`headless_react`), Builder.io spaces
|
|
245
571
|
(`headless_visual_cms`), and any site dropping in `<certrev-badge>` (`universal_embed`).
|
|
246
572
|
|
|
573
|
+
## Current consumers
|
|
574
|
+
|
|
575
|
+
| Consumer | Status |
|
|
576
|
+
|---|---|
|
|
577
|
+
| Portal | **Not importing yet.** Portal is where the vendored halves came FROM, and it still holds its own copies: `BuilderCertChromeData` is duplicated on the Portal side, and `src/modal/font-face.ts` + `src/modal/avatar-resize.ts` here are byte-faithful copies of Portal's. Portal also owns the exporter that writes the block options, and its `certComponent` connection field defaults to `CERT_COMPONENT_NAME`. The migration (Portal importing this package instead of hand-syncing) is what the package headers call "gated on publishing". Ping on any change to `BuilderCertChromeData`, `renderCertBlock`, the modal bundle, or `CERT_ISSUER_KID` / `CERT_ISSUER_PUBLIC_KEY_PEM`. |
|
|
578
|
+
| Brand headless storefronts | **Live, via npmjs, and NOT enumerable from this repo.** `@certrev/cert-block` publishes publicly, so anyone can `npm i` it. Nothing in this repo records which brands have. |
|
|
579
|
+
| AgOS | None. No AgOS-side import is referenced anywhere in this repo. |
|
|
580
|
+
| This monorepo | None. No other package here declares a dependency on `@certrev/cert-block` (it depends on `@certrev/cert-contract`, not the reverse). |
|
|
581
|
+
|
|
582
|
+
**The edge this list admits: the public npm consumers are unknown to it.** A version bump here can
|
|
583
|
+
reach a brand storefront nobody in this repo can name, which is the difference between this package
|
|
584
|
+
and the `file:`-dep packages alongside it. Treat the CHANGELOG's semver contract (patch and minor
|
|
585
|
+
never change the rendered cert output and never break a compiling integration) as the actual
|
|
586
|
+
notification channel, because for those consumers it is the only one.
|
|
587
|
+
|
|
247
588
|
## Tests
|
|
248
589
|
|
|
249
|
-
`pnpm test` (Vitest).
|
|
590
|
+
`pnpm test` (Vitest) runs all 17 test files. They sit next to what they cover: `src/__tests__/`
|
|
591
|
+
for the cross-cutting suites, `src/builder/__tests__/` for the Builder kit, and `src/modal/*.test.ts`
|
|
592
|
+
alongside each modal module. The load-bearing ones:
|
|
250
593
|
|
|
251
|
-
- **`__tests__/verify.test.ts`**
|
|
252
|
-
- **`__tests__/project.test.ts`**
|
|
253
|
-
- **`__tests__/components.test.tsx`**
|
|
254
|
-
- **`__tests__/
|
|
594
|
+
- **`__tests__/verify.test.ts`** exercises the kernel via the SDK binding with **real Ed25519 over JCS** (freshly-signed fixture envelopes, no mocked crypto): render, tamper → `invalid_signature`, unknown kid, platform/subject mismatch, revoked, expired, content drift; `getVerifiedEnvelope` over metafield (object + JSON-string) and Delivery API sources, 404/410 fail-closed, fetch deadlines (a hung origin and a hung JWKS endpoint both abort), cache-key identity (base URL, placement, live content hash), and the **single-flight** thundering-herd guard (N concurrent renders → one fetch).
|
|
595
|
+
- **`__tests__/project.test.ts`** covers the JSON-LD projector: `@graph` shape, WebPage / Article `@id` host-merge alignment (query + fragment stripped), expert-as-`reviewedBy`, credentials → `hasCredential` + `honorificSuffix`, CertREV-namespaced node `@id`s, visibility gating through `resolveDisplay`, determinism, and `</script>` / HTML-comment neutralization.
|
|
596
|
+
- **`__tests__/components.test.tsx`** renders every React component through `react-dom/server` with mock facts: structure, accessibility (`aria-label`, heading levels), accent theming, display-flag honoring, hostile-input escaping, `javascript:`-URL dropping, link qualification, and fail-closed (`suppress` verdict / unsafe URL → nothing).
|
|
597
|
+
- **`__tests__/render-cert-block.test.ts`** + **`__tests__/render-def.test.tsx`**: the locked 3-mode string renderer (placement default, banner `part` split, locked compliance copy) and render-def theming / subtractive visibility.
|
|
598
|
+
- **`__tests__/escape-family.test.ts`** puts both escape families (`components/` and `modal/`) against wrong-typed input, not just null: a half-filled CMS object must degrade, never throw.
|
|
599
|
+
- **`__tests__/webcomponent.test.tsx`**: the shared `renderBadgeHtml` string renderer (hand-escaping, unsafe-URL/color dropping, compact style) and the `<certrev-badge>` custom element (idempotent registration, SSR light-DOM preservation, client-mode fail-closed without a resolver).
|
|
600
|
+
- **`builder/__tests__/builder.test.tsx`** covers the PUSH card and its registration: the wire-type ⇄ inputs parity lock, the fused-credential gate, and the mistyped-option degradation.
|
|
601
|
+
- **`modal/*.test.ts`**, per module: the modal element (tombstone → `markSuppressed`), the dialog view, interactions, placement, badge revalidation, the FTC guard, and the Delivery-API verify client.
|
|
602
|
+
- **`__tests__/readme-version.test.ts`** + **`__tests__/readme-contract.test.ts`** pin the claims in this file that rot silently: the version stamp against `package.json`, the key id, the route-example heading, and every `exports` subpath appearing under Public API.
|
|
255
603
|
|
|
256
|
-
Facts are mocked via `makeMockPayload` / `makeSignedEnvelope` (`src/contract/fixtures.ts
|
|
604
|
+
Facts are mocked via `makeMockPayload` / `makeSignedEnvelope` (`src/contract/fixtures.ts`, also
|
|
605
|
+
published at `@certrev/cert-block/fixtures`).
|
|
257
606
|
|
|
258
607
|
## Version
|
|
259
608
|
|
|
260
|
-
`0.
|
|
609
|
+
`1.0.0`: see [CHANGELOG.md](./CHANGELOG.md) for the release history and the semver contract
|
|
261
610
|
(patch/minor never change the rendered cert output or break a compiling integration). Publishes
|
|
262
611
|
**publicly** to npm as `@certrev/cert-block` (`publishConfig.access: public`) via GitHub Actions
|
|
263
612
|
trusted publishing (OIDC); the internal `@certrev` GitHub-Packages channel mirrors it.
|
|
264
613
|
|
|
265
|
-
Source is public at [`CertREV/cert-kit`](https://github.com/CertREV/cert-kit) (MIT)
|
|
614
|
+
Source is public at [`CertREV/cert-kit`](https://github.com/CertREV/cert-kit) (MIT). This
|
|
266
615
|
package is `packages/cert-block/`; report a vulnerability privately through that repo's
|
|
267
616
|
`SECURITY.md`. The tarball also ships `src/` alongside `dist/`, so a security review reads the
|
|
268
617
|
exact code for the version it installed.
|
|
618
|
+
|
|
619
|
+
**cert-kit is a published-source mirror, not the build repo, so don't go looking for the CI in
|
|
620
|
+
it.** It is a flat snapshot: source and config, one commit per release, no `.github/`. The
|
|
621
|
+
workflows referenced above (build, typecheck, test, biome; and the OIDC trusted publish) live in
|
|
622
|
+
CertREV's private release repo, which is what npm records as this package's `gitHead`. Two
|
|
623
|
+
consequences worth naming rather than leaving a reviewer to discover: those `gitHead` commits 404
|
|
624
|
+
against the public mirror, and the publishes carry **no provenance attestation**: npm generates
|
|
625
|
+
provenance under trusted publishing only when the OIDC claim says the source repo is public, and
|
|
626
|
+
this one is private. Moving the publish job into the mirror is the only real fix; adding
|
|
627
|
+
`--provenance` to a private-repo publish changes nothing. What you CAN check today is the code
|
|
628
|
+
itself: the mirror is gated to rebuild every published `dist/` file byte for byte from the `src/`
|
|
629
|
+
in the same tag.
|