npm.io
0.3.0 • Published 2d ago

@certrev/cert-block

Licence
MIT
Version
0.3.0
Deps
0
Size
442 kB
Vulns
0
Weekly
0

@certrev/cert-block

The headless / crypto_verify render edge for the CertREV CertDeliveryEnvelope. Sign once in the portal, render everywhere: SSR-safe React components, a deterministic schema.org JSON-LD projector, a fail-closed verify layer over the shared VerdictKernel, and a framework-agnostic <certrev-badge> Web Component.

This is the SDK the brand SSRs into a Hydrogen loader, a Next server component, a Builder client component, or a universal server-side include. It is the headless_react + universal_embed surface classes from the cross-platform delivery design.

What this is

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.

The package gives a brand four things:

  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).
  2. A deterministic JSON-LD projectorprojectCertJsonLd(facts) → a schema.org Article + reviewedBy(Person) + Review + Organization @graph, designed to merge by @id into the host page's existing 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.
  3. A thin verify layergetVerifiedEnvelope(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.
  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.

The contract shape (settled)

A CertDeliveryEnvelope = { payload, signature }. The detached signature is Ed25519 over RFC-8785 JCS(payload).

payload = {
  contractVersion: 1,
  certId,
  subject:   { platform, externalId, logicalArticleId, canonicalUrls[], installationId, contentDigest },
  content:   { expert{displayName,credentials[],profileUrl,photoUrl}, author{name,title},
               memo, certifiedAt, contentModifiedAt, verifyUrl,
               display{accentColor,showExpertPhoto,showAuthor,showMemo,badgeStyle} },
  lifecycle: { issuedAt, expiresAt, revokedAt|null, revision },
}

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.

Public API

// React components (SSR-safe)
import { CertBadge, ExpertBio, CertRevBacklink, CertJsonLd, CertReview } from '@certrev/cert-block'

// JSON-LD projector
import { projectCertJsonLd, projectCertJsonLdString, serializeJsonLdForScript } from '@certrev/cert-block'

// Verify layer
import { getVerifiedEnvelope, invalidateVerdict, sharedVerdictCache, TtlCache } from '@certrev/cert-block'
import { staticKidResolver, fetchingKidResolver } from '@certrev/cert-block'

// Contract surface (re-exported from the binding point; → @certrev/cert-contract on publish)
import type { CertDeliveryEnvelope, CertPayload, CertVerdict, RenderContext } from '@certrev/cert-block'
import { verifyEnvelope, renderVerdict, verifySignatureOnly } from '@certrev/cert-block'

// Web Component (separate subpath — does NOT touch customElements on import)
import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'

Usage — headless React (Hydrogen / Next server component)

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.

import { getVerifiedEnvelope, staticKidResolver, CertReview } from '@certrev/cert-block'

const resolveKid = staticKidResolver({ 'certrev-2026-1': process.env.CERTREV_PUBKEY_PEM! })

// In a Hydrogen loader / Next server component:
const verdict = await getVerifiedEnvelope({
  // PULL from the Delivery API (or { kind: 'metafield', value } for a Shopify metafield)
  source: { kind: 'delivery_api', baseUrl: 'https://portal.certrev.com', platform: 'shopify', externalId: articleGid },
  resolveKid,
  context: { platform: 'shopify', externalId: articleGid, liveContentHash },
})

// <CertReview> is fail-closed: renders the badge + projected JSON-LD on `render`, NOTHING on `suppress`.
return <CertReview verdict={verdict} pageUrl={canonicalUrl} />

For finer control, render the pieces independently from verdict.payload:

{verdict.decision === 'render' && (
  <>
    <CertBadge payload={verdict.payload} badgeStyle="compact" />
    <ExpertBio payload={verdict.payload} headingLevel="h2" />
    <CertRevBacklink payload={verdict.payload} />
    <CertJsonLd payload={verdict.payload} pageUrl={canonicalUrl} />
  </>
)}

Usage — JSON-LD merge (don't collide with Yoast)

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.

const graph = projectCertJsonLd(payload, { pageUrl: canonicalUrl })          // { '@context', '@graph' }
const nodes = projectCertJsonLd(payload, { pageUrl: canonicalUrl, wrapGraph: false }) // bare node[]

Usage — universal embed (Web Component)

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.

<!-- SSR: server emits the badge markup inside the tag; crawler reads it -->
<certrev-badge>{{ renderBadgeHtml(payload) }}</certrev-badge>

<script type="module">
  import { defineCertRevBadge, setCertRevKidResolver } from '@certrev/cert-block/webcomponent'
  setCertRevKidResolver(myResolver) // only needed for the client-fetch fallback
  defineCertRevBadge()
</script>

Fail-closed, everywhere

The package never renders an unverified credential. Every error path — bad signature, unknown kid, wrong post, revoked, expired, content drift, network failure, malformed JSON, a throwing resolver — collapses to a suppress verdict (or a null render), never an exception that a caller could swallow into a render. <CertReview> and <CertRevBacklink> also fail closed at the component boundary (suppress / unsafe URL → render nothing). URLs pass through safeHttpUrl (drops javascript:/data:); accent colors through safeCssColor; the JSON-LD body neutralizes </> so a hostile memo can't break out of the <script> tag.

Server-safe guarantee

  • 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.
  • 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.
  • Date formatting is deterministic UTC (fixed English month abbreviations, not toLocaleDateString) so SSR output is byte-stable and never causes a hydration mismatch.

Integration TODOs (wire the real @certrev/cert-contract)

The shared contract types + the VerdictKernel live in the published @certrev/cert-contract package, which is not yet on a registry this workspace installs from. Until it publishes, the SDK binds the contract surface through a local stub so everything builds + tests now. To cut over on publish:

  1. Delete src/contract/kernel-contract.ts (the type mirror) and src/contract/kernel-stub.ts (the faithful kernel impl).
  2. In src/contract/kernel.ts (the single binding point), replace both re-export blocks with one line: export * from '@certrev/cert-contract'. The exported names are deliberately identical, so nothing else in the SDK changes.
  3. In package.json, flip the @certrev/cert-contract peer dependency back to optional: false and remove the root .npmrc auto-install-peers=false line (it exists only so the workspace installs against the stub).
  4. Point src/contract/fixtures.ts at cert-contract's real canonicalPayloadBytes (RFC 8785) so the test fixtures sign bytes the published kernel verifies. (Today the stub + fixtures share a minimal JCS sufficient for the all-string/number/bool envelope shape.)
  5. Confirm verifyEnvelope / renderVerdict / verifySignatureOnly / toEd25519PublicKey resolve from the published package, then re-run pnpm typecheck && pnpm test && pnpm build.

A WebCrypto verify path for the Web Component's client-fetch fallback lives in cert-contract; once published, wire it into certrev-badge.ts so the universal embed can verify in the browser without a pre-supplied resolver.

Current consumers

  • None yet. Target consumers: brand-owned Hydrogen / Next / Remix storefronts (headless_react), Builder.io spaces (headless_visual_cms), and any site dropping in <certrev-badge> (universal_embed). Add each here as it adopts the SDK.

Tests

pnpm test (Vitest). Coverage:

  • __tests__/verify.test.ts — 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, and the single-flight thundering-herd guard (N concurrent renders → one fetch).
  • __tests__/project.test.ts — the JSON-LD projector: @graph shape, Article @id host-merge alignment (query + fragment stripped), expert-as-reviewedBy, credentials → hasCredential + honorificSuffix, CertREV-namespaced node @ids, determinism, and </script> / HTML-comment neutralization.
  • __tests__/components.test.tsx — every React component rendered through react-dom/server with mock facts: structure, accessibility (aria-label, heading levels), accent theming, display-flag honoring, hostile-input escaping, javascript:-URL dropping, and fail-closed (suppress verdict / unsafe URL → nothing).
  • __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).

Facts are mocked via makeMockPayload / makeSignedEnvelope (src/contract/fixtures.ts).

Version

0.1.0 — initial scaffold against the settled contract shape, bound to the local contract stub. Publishes publicly to npm as @certrev/cert-block (publishConfig.access: public).

Keywords