@certrev/cert-block 0.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.
Files changed (99) hide show
  1. package/README.md +154 -0
  2. package/dist/components/CertBadge.d.ts +29 -0
  3. package/dist/components/CertBadge.d.ts.map +1 -0
  4. package/dist/components/CertBadge.js +36 -0
  5. package/dist/components/CertBadge.js.map +1 -0
  6. package/dist/components/CertJsonLd.d.ts +23 -0
  7. package/dist/components/CertJsonLd.d.ts.map +1 -0
  8. package/dist/components/CertJsonLd.js +10 -0
  9. package/dist/components/CertJsonLd.js.map +1 -0
  10. package/dist/components/CertRevBacklink.d.ts +18 -0
  11. package/dist/components/CertRevBacklink.d.ts.map +1 -0
  12. package/dist/components/CertRevBacklink.js +16 -0
  13. package/dist/components/CertRevBacklink.js.map +1 -0
  14. package/dist/components/CertReview.d.ts +23 -0
  15. package/dist/components/CertReview.d.ts.map +1 -0
  16. package/dist/components/CertReview.js +11 -0
  17. package/dist/components/CertReview.js.map +1 -0
  18. package/dist/components/ExpertBio.d.ts +17 -0
  19. package/dist/components/ExpertBio.d.ts.map +1 -0
  20. package/dist/components/ExpertBio.js +17 -0
  21. package/dist/components/ExpertBio.js.map +1 -0
  22. package/dist/components/escape.d.ts +36 -0
  23. package/dist/components/escape.d.ts.map +1 -0
  24. package/dist/components/escape.js +76 -0
  25. package/dist/components/escape.js.map +1 -0
  26. package/dist/components/format.d.ts +22 -0
  27. package/dist/components/format.d.ts.map +1 -0
  28. package/dist/components/format.js +42 -0
  29. package/dist/components/format.js.map +1 -0
  30. package/dist/contract/fixtures.d.ts +36 -0
  31. package/dist/contract/fixtures.d.ts.map +1 -0
  32. package/dist/contract/fixtures.js +87 -0
  33. package/dist/contract/fixtures.js.map +1 -0
  34. package/dist/contract/kernel-contract.d.ts +154 -0
  35. package/dist/contract/kernel-contract.d.ts.map +1 -0
  36. package/dist/contract/kernel-contract.js +35 -0
  37. package/dist/contract/kernel-contract.js.map +1 -0
  38. package/dist/contract/kernel-stub.d.ts +44 -0
  39. package/dist/contract/kernel-stub.d.ts.map +1 -0
  40. package/dist/contract/kernel-stub.js +163 -0
  41. package/dist/contract/kernel-stub.js.map +1 -0
  42. package/dist/contract/kernel.d.ts +20 -0
  43. package/dist/contract/kernel.d.ts.map +1 -0
  44. package/dist/contract/kernel.js +19 -0
  45. package/dist/contract/kernel.js.map +1 -0
  46. package/dist/contract/verdict-kernel.d.ts +34 -0
  47. package/dist/contract/verdict-kernel.d.ts.map +1 -0
  48. package/dist/contract/verdict-kernel.js +13 -0
  49. package/dist/contract/verdict-kernel.js.map +1 -0
  50. package/dist/index.d.ts +31 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +38 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/jsonld/project.d.ts +71 -0
  55. package/dist/jsonld/project.d.ts.map +1 -0
  56. package/dist/jsonld/project.js +183 -0
  57. package/dist/jsonld/project.js.map +1 -0
  58. package/dist/verify/cache.d.ts +56 -0
  59. package/dist/verify/cache.d.ts.map +1 -0
  60. package/dist/verify/cache.js +93 -0
  61. package/dist/verify/cache.js.map +1 -0
  62. package/dist/verify/get-verified-envelope.d.ts +65 -0
  63. package/dist/verify/get-verified-envelope.d.ts.map +1 -0
  64. package/dist/verify/get-verified-envelope.js +104 -0
  65. package/dist/verify/get-verified-envelope.js.map +1 -0
  66. package/dist/verify/resolve-kid.d.ts +38 -0
  67. package/dist/verify/resolve-kid.d.ts.map +1 -0
  68. package/dist/verify/resolve-kid.js +71 -0
  69. package/dist/verify/resolve-kid.js.map +1 -0
  70. package/dist/webcomponent/certrev-badge.d.ts +38 -0
  71. package/dist/webcomponent/certrev-badge.d.ts.map +1 -0
  72. package/dist/webcomponent/certrev-badge.js +98 -0
  73. package/dist/webcomponent/certrev-badge.js.map +1 -0
  74. package/dist/webcomponent/render-badge-html.d.ts +25 -0
  75. package/dist/webcomponent/render-badge-html.d.ts.map +1 -0
  76. package/dist/webcomponent/render-badge-html.js +81 -0
  77. package/dist/webcomponent/render-badge-html.js.map +1 -0
  78. package/package.json +70 -0
  79. package/src/__tests__/components.test.tsx +191 -0
  80. package/src/__tests__/project.test.ts +128 -0
  81. package/src/__tests__/verify.test.ts +203 -0
  82. package/src/__tests__/webcomponent.test.tsx +106 -0
  83. package/src/components/CertBadge.tsx +164 -0
  84. package/src/components/CertJsonLd.tsx +36 -0
  85. package/src/components/CertRevBacklink.tsx +63 -0
  86. package/src/components/CertReview.tsx +42 -0
  87. package/src/components/ExpertBio.tsx +77 -0
  88. package/src/components/escape.ts +72 -0
  89. package/src/components/format.ts +55 -0
  90. package/src/contract/fixtures.ts +107 -0
  91. package/src/contract/kernel.ts +20 -0
  92. package/src/contract/verdict-kernel.ts +47 -0
  93. package/src/index.ts +85 -0
  94. package/src/jsonld/project.ts +206 -0
  95. package/src/verify/cache.ts +116 -0
  96. package/src/verify/get-verified-envelope.ts +156 -0
  97. package/src/verify/resolve-kid.ts +103 -0
  98. package/src/webcomponent/certrev-badge.ts +100 -0
  99. package/src/webcomponent/render-badge-html.ts +106 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Mock facts + a SIGNED envelope fixture for unit tests + local dev.
3
+ *
4
+ * `makeMockPayload` returns a complete, well-formed `CertPayload` (override any field).
5
+ * `makeSignedEnvelope` generates an ephemeral Ed25519 keypair, signs the payload's RFC-
6
+ * 8785 canonical bytes, and returns `{ envelope, resolveKid, publicKey }` so a test can
7
+ * run the FULL kernel pipeline (real signature verification) against the fixture — no
8
+ * mocking of the crypto.
9
+ *
10
+ * These helpers depend on Node's `crypto` (for keygen + signing on the ISSUER side, which
11
+ * tests stand in for). The SDK's RUNTIME never signs — it only verifies — so this stays
12
+ * test/dev-only and is not part of the public render API.
13
+ *
14
+ * The fixture signs over `canonicalPayloadBytes` from `@certrev/cert-contract` (RFC 8785),
15
+ * the SAME bytes the real kernel recomputes on verify — so a fixture envelope verifies
16
+ * end-to-end under the production `verifyEnvelope`, not a stand-in canonicalizer.
17
+ */
18
+
19
+ import { generateKeyPairSync, type KeyObject, sign as nodeSign } from 'node:crypto'
20
+ import {
21
+ canonicalPayloadBytes,
22
+ type CertDeliveryEnvelope,
23
+ type CertPayload,
24
+ type Ed25519PublicKeyInput,
25
+ type ResolvePublicKeyByKid,
26
+ } from '@certrev/cert-contract'
27
+
28
+ const FIXTURE_KID = 'certrev-fixture-key-1'
29
+
30
+ /** Build a complete mock payload. Pass overrides to exercise specific render branches. */
31
+ export function makeMockPayload(overrides: Partial<CertPayload> = {}): CertPayload {
32
+ const base: CertPayload = {
33
+ contractVersion: 1,
34
+ certId: 'cert_fixture_001',
35
+ subject: {
36
+ platform: 'shopify',
37
+ externalId: 'gid://shopify/Article/123456789',
38
+ logicalArticleId: 'art_logical_abc',
39
+ canonicalUrls: ['https://brand.example.com/blogs/skincare/retinol-guide'],
40
+ installationId: 'inst_shopify_42',
41
+ contentDigest: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2',
42
+ },
43
+ content: {
44
+ expert: {
45
+ displayName: 'Dr. Jane Doe',
46
+ credentials: [
47
+ { abbreviation: 'MD', fullName: 'Doctor of Medicine' },
48
+ { abbreviation: 'FAAD', fullName: 'Fellow of the American Academy of Dermatology' },
49
+ ],
50
+ profileUrl: 'https://certrev.com/experts/jane-doe',
51
+ photoUrl: 'https://cdn.certrev.com/experts/jane-doe.jpg',
52
+ },
53
+ author: { name: 'Sam Writer', title: 'Senior Content Editor' },
54
+ memo: 'I reviewed the retinol claims against current dermatology guidance; the concentrations and usage cadence cited are accurate and safely framed.',
55
+ certifiedAt: '2026-06-21T15:30:00.000Z',
56
+ contentModifiedAt: '2026-06-20T09:00:00.000Z',
57
+ verifyUrl: 'https://certrev.com/verify/cert_fixture_001',
58
+ display: {
59
+ accentColor: '#7c3aed',
60
+ showExpertPhoto: true,
61
+ showAuthor: true,
62
+ showMemo: true,
63
+ badgeStyle: 'full',
64
+ },
65
+ },
66
+ lifecycle: {
67
+ issuedAt: '2026-06-21T15:30:00.000Z',
68
+ expiresAt: '2099-01-01T00:00:00.000Z',
69
+ revokedAt: null,
70
+ revision: 1,
71
+ },
72
+ ...overrides,
73
+ }
74
+ return base
75
+ }
76
+
77
+ export interface SignedEnvelopeFixture {
78
+ readonly envelope: CertDeliveryEnvelope
79
+ readonly resolveKid: ResolvePublicKeyByKid
80
+ readonly publicKey: KeyObject
81
+ readonly kid: string
82
+ }
83
+
84
+ /**
85
+ * Generate a fresh keypair, sign the payload, and return everything a test needs to run
86
+ * the kernel against a genuinely-valid envelope. The returned `resolveKid` resolves only
87
+ * the fixture kid (any other kid → null → 'unknown_key').
88
+ */
89
+ export function makeSignedEnvelope(overrides: Partial<CertPayload> = {}, kid = FIXTURE_KID): SignedEnvelopeFixture {
90
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
91
+ const payload = makeMockPayload(overrides)
92
+ const bytes = canonicalPayloadBytes(payload)
93
+ const sig = nodeSign(null, bytes, privateKey).toString('base64url')
94
+
95
+ const envelope: CertDeliveryEnvelope = {
96
+ payload,
97
+ signature: { alg: 'ed25519', kid, sig, signedAt: payload.lifecycle.issuedAt },
98
+ }
99
+
100
+ const spkiBase64 = publicKey.export({ format: 'der', type: 'spki' }).toString('base64')
101
+ const keyInput: Ed25519PublicKeyInput = { format: 'spki-base64', base64: spkiBase64 }
102
+ const resolveKid: ResolvePublicKeyByKid = (k) => (k === kid ? keyInput : null)
103
+
104
+ return { envelope, resolveKid, publicKey, kid }
105
+ }
106
+
107
+ export { FIXTURE_KID }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * Contract binding point — the SDK's single import of @certrev/cert-contract
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * Every other SDK module imports the contract TYPES and the KERNEL functions from HERE,
7
+ * never directly from `@certrev/cert-contract`, so the whole SDK's dependency on the
8
+ * contract is funnelled through one module. The published package owns the envelope types
9
+ * (`CertDeliveryEnvelope`, `CertPayload`, `CertCredential`, …), the RFC-8785
10
+ * canonicalization (`canonicalPayloadBytes`), and the fail-closed kernel (`verifyEnvelope`,
11
+ * `renderVerdict`, `verifySignatureOnly`, `toEd25519PublicKey`).
12
+ *
13
+ * `VerdictKernel` is the ONE name NOT re-exported from the package: the contract exposes
14
+ * the kernel as free functions, and different edges bundle them with different key-resolver
15
+ * shapes (cert-block uses `Ed25519PublicKeyInput`; the portal Delivery API uses raw
16
+ * `Uint8Array`), so the bundled interface stays edge-local — see ./verdict-kernel.
17
+ */
18
+
19
+ export * from '@certrev/cert-contract'
20
+ export type { VerdictKernel } from './verdict-kernel.js'
@@ -0,0 +1,47 @@
1
+ /**
2
+ * VerdictKernel — cert-block's edge-local view of the kernel function set.
3
+ *
4
+ * The published `@certrev/cert-contract` exposes the kernel as FREE FUNCTIONS
5
+ * (`verifyEnvelope`, `renderVerdict`, `verifySignatureOnly`), NOT as a bundled interface,
6
+ * because different edges bundle them with different key-resolver shapes: cert-block
7
+ * resolves keys as `Ed25519PublicKeyInput`, while the portal Delivery API's kernel
8
+ * resolves raw `Uint8Array`. There is therefore no single canonical `VerdictKernel`
9
+ * interface to live in the contract — each edge declares the bundled shape it depends on.
10
+ * This is cert-block's.
11
+ */
12
+
13
+ import type {
14
+ CertDeliveryEnvelope,
15
+ CertPayload,
16
+ CertSuppressReason,
17
+ CertVerdict,
18
+ RenderContext,
19
+ ResolvePublicKeyByKid,
20
+ } from '@certrev/cert-contract'
21
+
22
+ /** Full kernel: verify signature, then apply subject/lifecycle/drift policy. */
23
+ export type VerifyEnvelope = (
24
+ envelope: CertDeliveryEnvelope,
25
+ resolveKid: ResolvePublicKeyByKid,
26
+ ctx: RenderContext,
27
+ ) => Promise<CertVerdict>
28
+
29
+ /** Phase-2-only policy over an already-verified payload. */
30
+ export type RenderVerdict = (payload: CertPayload, ctx: RenderContext) => CertVerdict
31
+
32
+ /** Phase-1-only cryptographic verification. */
33
+ export type VerifySignatureOnly = (
34
+ envelope: CertDeliveryEnvelope,
35
+ resolveKid: ResolvePublicKeyByKid,
36
+ ) => Promise<{ ok: true } | { ok: false; reason: CertSuppressReason }>
37
+
38
+ /**
39
+ * The kernel function trio as a single named interface — the value-level surface for
40
+ * callers that want them as one object. `@certrev/cert-contract` exposes these as free
41
+ * functions; this is a convenience binding, deliberately kept out of the wire contract.
42
+ */
43
+ export interface VerdictKernel {
44
+ readonly verifyEnvelope: VerifyEnvelope
45
+ readonly renderVerdict: RenderVerdict
46
+ readonly verifySignatureOnly: VerifySignatureOnly
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @certrev/cert-block — the headless / crypto_verify render edge for CertREV.
3
+ *
4
+ * Public API:
5
+ * • React components — <CertBadge>, <ExpertBio>, <CertRevBacklink>, <CertJsonLd>,
6
+ * <CertReview> (composite). SSR-safe, theme-light, accessible, escaping every field.
7
+ * • JSON-LD projector — deterministic facts → schema.org Article+reviewedBy(Person)
8
+ * graph, mergeable by @id (won't collide with Yoast).
9
+ * • Verify layer — `getVerifiedEnvelope` (fetch from metafield OR Delivery API + run
10
+ * the fail-closed VerdictKernel + cache), kid resolvers, the TTL/single-flight cache.
11
+ * • Contract types — re-exported from `@certrev/cert-contract` (stubbed locally until
12
+ * that package publishes; see the contract binding point in ./contract/kernel).
13
+ *
14
+ * The Web Component (`<certrev-badge>`) ships from the './webcomponent' subpath export so
15
+ * importing the main entry doesn't touch `customElements` (which is undefined server-side).
16
+ */
17
+
18
+ // ── React components ──────────────────────────────────────────────────────────────
19
+ export { CertBadge, type CertBadgeProps } from './components/CertBadge.js'
20
+ export { CertJsonLd, type CertJsonLdProps } from './components/CertJsonLd.js'
21
+ export { CertRevBacklink, type CertRevBacklinkProps } from './components/CertRevBacklink.js'
22
+ export { CertReview, type CertReviewProps } from './components/CertReview.js'
23
+ export { ExpertBio, type ExpertBioProps } from './components/ExpertBio.js'
24
+ export { escapeAttribute, escapeHtml, safeCssColor, safeHttpUrl, tidyText } from './components/escape.js'
25
+ // ── Presentation helpers (shared with the Web Component) ────────────────────────────
26
+ export {
27
+ credentialSuffix,
28
+ DEFAULT_ACCENT,
29
+ expertNameWithCredentials,
30
+ formatDate,
31
+ type ResolvedDisplay,
32
+ resolveDisplay,
33
+ } from './components/format.js'
34
+ // ── Test/dev fixtures (mock facts + signed-envelope generator) ─────────────────────
35
+ export { FIXTURE_KID, makeMockPayload, makeSignedEnvelope, type SignedEnvelopeFixture } from './contract/fixtures.js'
36
+ // ── Contract surface (types + kernel) — re-export the binding point ────────────────
37
+ export {
38
+ CANONICALIZATION,
39
+ type CertContent,
40
+ type CertCredential,
41
+ type CertDeliveryEnvelope,
42
+ type CertDisplayConfig,
43
+ type CertLifecycle,
44
+ type CertPayload,
45
+ type CertSignature,
46
+ type CertSubject,
47
+ type CertSuppressReason,
48
+ type CertVerdict,
49
+ CONTRACT_VERSION,
50
+ type ContractVersion,
51
+ type Ed25519PublicKeyInput,
52
+ type RenderContext,
53
+ type ResolvePublicKeyByKid,
54
+ renderVerdict,
55
+ SIGNATURE_ALG,
56
+ type SignatureAlg,
57
+ type VerdictKernel,
58
+ verifyEnvelope,
59
+ verifySignatureOnly,
60
+ } from './contract/kernel.js'
61
+ // ── JSON-LD projector ───────────────────────────────────────────────────────────────
62
+ export {
63
+ type JsonLdValue,
64
+ type ProjectJsonLdOptions,
65
+ projectCertJsonLd,
66
+ projectCertJsonLdString,
67
+ serializeJsonLdForScript,
68
+ } from './jsonld/project.js'
69
+ export { TtlCache, type TtlCacheOptions } from './verify/cache.js'
70
+ // ── Verify layer ──────────────────────────────────────────────────────────────────
71
+ export {
72
+ type EnvelopeSource,
73
+ type GetVerifiedEnvelopeOptions,
74
+ getVerifiedEnvelope,
75
+ invalidateVerdict,
76
+ sharedVerdictCache,
77
+ } from './verify/get-verified-envelope.js'
78
+ export {
79
+ type FetchingKidResolverOptions,
80
+ fetchingKidResolver,
81
+ type StaticKeySet,
82
+ staticKidResolver,
83
+ } from './verify/resolve-kid.js'
84
+ // ── Web Component string renderer (the markup shared by SSR + the custom element) ──
85
+ export { type RenderBadgeOptions, renderBadgeHtml } from './webcomponent/render-badge-html.js'
@@ -0,0 +1,206 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * Deterministic JSON-LD projector — facts → schema.org graph (mergeable by @id)
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * INVARIANT #1 of the contract: the JSON-LD is PROJECTED from `payload.content` facts
7
+ * at render time, never stored in (or signed into) the envelope. This is that projector.
8
+ * It is DETERMINISTIC — same facts in → byte-identical graph out — so two edges (and a
9
+ * server pre-render) produce the same structured data, and a snapshot diff is meaningful.
10
+ *
11
+ * DESIGN GOAL: MERGE, don't collide.
12
+ * A brand page usually already emits an `Article` (or `BlogPosting`) graph — Yoast on
13
+ * WordPress, the theme on Shopify, the framework on headless. If we emit a SECOND
14
+ * top-level `Article`, crawlers see two competing primary entities. Instead we emit a
15
+ * `@graph` whose Article node carries the SAME `@id` the page's primary article uses
16
+ * (the page URL + `#article`, the Yoast convention) so a consumer MERGES our
17
+ * `reviewedBy` / `dateModified` into the existing node by `@id` rather than duplicating
18
+ * it. Our own nodes (the review, the expert Person, the CertREV org) use CertREV-scoped
19
+ * `@id`s that cannot clash with the host page's nodes.
20
+ *
21
+ * The Person we attach is the reviewing EXPERT (schema.org `reviewedBy`), which is the
22
+ * E-E-A-T signal CertREV exists to add — not the author. The author (content byline) is
23
+ * attached as `author` only when present + distinct.
24
+ */
25
+
26
+ import type { CertContent, CertPayload } from '../contract/kernel.js'
27
+
28
+ /** A JSON-LD node/graph value. Loose by design — this is wire JSON, not a TS model. */
29
+ export type JsonLdValue = Record<string, unknown>
30
+
31
+ export interface ProjectJsonLdOptions {
32
+ /**
33
+ * The canonical page URL the article renders on. Used to derive the primary
34
+ * Article node `@id` (`${pageUrl}#article`) so we merge into the host page's graph
35
+ * instead of colliding. When omitted, the first `subject.canonicalUrls` entry is
36
+ * used; when there is none either, a CertREV-scoped article `@id` is derived from
37
+ * `verifyUrl` (still mergeable, just not aligned to a host node).
38
+ */
39
+ readonly pageUrl?: string
40
+ /**
41
+ * When true (default), emit the wrapping `{ "@context": "...", "@graph": [...] }`.
42
+ * When false, return just the array of nodes — for a caller that merges them into an
43
+ * existing `@graph` it already owns.
44
+ */
45
+ readonly wrapGraph?: boolean
46
+ }
47
+
48
+ const SCHEMA_CONTEXT = 'https://schema.org'
49
+
50
+ /**
51
+ * Strip the query AND fragment from a URL for a stable, mergeable @id. The Yoast / host
52
+ * convention keys the primary Article node on `{path}#article` with no query — so a page
53
+ * reached with a `?utm=…` tracking param must derive the SAME `@id` as the canonical URL,
54
+ * else our `reviewedBy` lands on a sibling node instead of merging into the host's Article.
55
+ */
56
+ function baseUrl(url: string): string {
57
+ const cut = Math.min(...[url.indexOf('#'), url.indexOf('?')].filter((i) => i !== -1), url.length)
58
+ return url.slice(0, cut)
59
+ }
60
+
61
+ /** Derive the primary Article `@id`, aligned to the Yoast/host convention when we can. */
62
+ function articleId(payload: CertPayload, opts: ProjectJsonLdOptions): string {
63
+ const page = opts.pageUrl ?? payload.subject.canonicalUrls[0]
64
+ if (page) return `${baseUrl(page)}#article`
65
+ // No page URL known — derive a stable, mergeable id from the verify URL.
66
+ return `${baseUrl(payload.content.verifyUrl)}#article`
67
+ }
68
+
69
+ /** CertREV-scoped node ids — namespaced under the verify URL so they never clash. */
70
+ function reviewId(content: CertContent): string {
71
+ return `${content.verifyUrl}#certrev-review`
72
+ }
73
+ function expertId(content: CertContent): string {
74
+ return content.expert.profileUrl ? `${content.expert.profileUrl}#person` : `${content.verifyUrl}#expert`
75
+ }
76
+ function orgId(content: CertContent): string {
77
+ // One stable CertREV organization node across all pages: derive from the verify
78
+ // URL origin so it dedupes cleanly when a page hosts multiple certified articles.
79
+ try {
80
+ return `${new URL(content.verifyUrl).origin}/#certrev-organization`
81
+ } catch {
82
+ return 'https://certrev.com/#certrev-organization'
83
+ }
84
+ }
85
+
86
+ /** Build the reviewing-expert Person node (the E-E-A-T signal). */
87
+ function expertPersonNode(payload: CertPayload): JsonLdValue {
88
+ const { expert } = payload.content
89
+ const node: JsonLdValue = {
90
+ '@type': 'Person',
91
+ '@id': expertId(payload.content),
92
+ name: expert.displayName,
93
+ }
94
+ if (expert.profileUrl) node.url = expert.profileUrl
95
+ if (expert.photoUrl) node.image = expert.photoUrl
96
+ if (expert.credentials.length > 0) {
97
+ // hasCredential → EducationalOccupationalCredential per credential; also a flat
98
+ // honorificSuffix list for consumers that don't read hasCredential.
99
+ node.hasCredential = expert.credentials.map((c) => ({
100
+ '@type': 'EducationalOccupationalCredential',
101
+ name: c.fullName,
102
+ alternateName: c.abbreviation,
103
+ }))
104
+ node.honorificSuffix = expert.credentials.map((c) => c.abbreviation).join(', ')
105
+ }
106
+ return node
107
+ }
108
+
109
+ /** Build the CertREV organization node (publisher of the review credential). */
110
+ function certRevOrgNode(payload: CertPayload): JsonLdValue {
111
+ let url = 'https://certrev.com'
112
+ try {
113
+ url = new URL(payload.content.verifyUrl).origin
114
+ } catch {
115
+ /* keep default */
116
+ }
117
+ return {
118
+ '@type': 'Organization',
119
+ '@id': orgId(payload.content),
120
+ name: 'CertREV',
121
+ url,
122
+ }
123
+ }
124
+
125
+ /** Build the Review node binding the article to its expert review. */
126
+ function reviewNode(payload: CertPayload): JsonLdValue {
127
+ const node: JsonLdValue = {
128
+ '@type': 'Review',
129
+ '@id': reviewId(payload.content),
130
+ itemReviewed: { '@id': articleId(payload, {}) },
131
+ author: { '@id': expertId(payload.content) },
132
+ publisher: { '@id': orgId(payload.content) },
133
+ datePublished: payload.content.certifiedAt,
134
+ url: payload.content.verifyUrl,
135
+ }
136
+ if (payload.content.memo) node.reviewBody = payload.content.memo
137
+ return node
138
+ }
139
+
140
+ /**
141
+ * The primary Article node — carries the SAME `@id` the host page's article uses so a
142
+ * consumer merges our `reviewedBy` / `dateModified` into the existing node rather than
143
+ * duplicating the primary entity. We deliberately emit ONLY the certification-relevant
144
+ * properties (reviewedBy, dateModified, plus author when distinct) — not headline, body,
145
+ * image, etc. — so we never overwrite the host's richer Article fields on merge.
146
+ */
147
+ function articleNode(payload: CertPayload, opts: ProjectJsonLdOptions): JsonLdValue {
148
+ const { content } = payload
149
+ const node: JsonLdValue = {
150
+ '@type': 'Article',
151
+ '@id': articleId(payload, opts),
152
+ reviewedBy: { '@id': expertId(content) },
153
+ review: { '@id': reviewId(content) },
154
+ }
155
+ if (content.contentModifiedAt) node.dateModified = content.contentModifiedAt
156
+ // Author only when present + distinct from the expert (don't double-attribute).
157
+ if (content.author.name && content.author.name !== content.expert.displayName) {
158
+ const author: JsonLdValue = { '@type': 'Person', name: content.author.name }
159
+ if (content.author.title) author.jobTitle = content.author.title
160
+ node.author = author
161
+ }
162
+ return node
163
+ }
164
+
165
+ /**
166
+ * Project the verified certification facts into a schema.org `@graph`.
167
+ *
168
+ * @param payload The cryptographically-verified payload (the same facts the badge renders).
169
+ * @param opts Page URL for @id alignment + graph-wrapping control.
170
+ * @returns A `{ "@context", "@graph" }` object (wrapGraph !== false) OR a bare
171
+ * array of nodes (wrapGraph === false) for merge-into-existing-graph callers.
172
+ */
173
+ export function projectCertJsonLd(payload: CertPayload, opts: ProjectJsonLdOptions = {}): JsonLdValue | JsonLdValue[] {
174
+ const nodes: JsonLdValue[] = [
175
+ articleNode(payload, opts),
176
+ reviewNode(payload),
177
+ expertPersonNode(payload),
178
+ certRevOrgNode(payload),
179
+ ]
180
+ if (opts.wrapGraph === false) return nodes
181
+ return { '@context': SCHEMA_CONTEXT, '@graph': nodes }
182
+ }
183
+
184
+ /**
185
+ * Serialize the projected graph to the exact JSON string that goes inside a
186
+ * `<script type="application/ld+json">`. We use `JSON.stringify` with no extra
187
+ * whitespace for a compact, deterministic body. The string is then made safe to embed
188
+ * by `serializeJsonLdForScript` (which neutralizes `</script>`).
189
+ */
190
+ export function projectCertJsonLdString(payload: CertPayload, opts: ProjectJsonLdOptions = {}): string {
191
+ return serializeJsonLdForScript(projectCertJsonLd(payload, opts))
192
+ }
193
+
194
+ /**
195
+ * Serialize an arbitrary JSON-LD value for safe inclusion inside a `<script>` element.
196
+ * Inside `application/ld+json`, two HTML sequences can break out of the tag: `</script>`
197
+ * (closes the element early) and HTML-comment markers `<!--` / `-->` (some parsers treat
198
+ * a comment opened inside a script as suspending script-data parsing). A memo or name
199
+ * containing either is attacker-influenced text, so we neutralize BOTH angle brackets to
200
+ * their `\uXXXX` form. This is valid JSON that parses back to the identical string — the
201
+ * structured data is unchanged, but no `<…>`/`-->` sequence can terminate the script.
202
+ * Standard Next.js / Yoast hardening.
203
+ */
204
+ export function serializeJsonLdForScript(value: unknown): string {
205
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
206
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * In-process TTL cache with single-flight de-duplication.
3
+ *
4
+ * WHY: under SSR, a popular product/blog page can render N times concurrently across a
5
+ * server's request workers. Without coordination, each render fires its own fetch to the
6
+ * Shopify metafield / Delivery API → a thundering herd against the issuer on cold cache
7
+ * or cache expiry. This cache:
8
+ * 1. Serves a fresh value from memory within its TTL (no fetch).
9
+ * 2. SINGLE-FLIGHTS concurrent misses for the same key — the first caller's in-flight
10
+ * promise is shared by every other caller for that key, so N concurrent renders do
11
+ * ONE fetch, not N.
12
+ * 3. Negative-caches failures briefly so a hard-down origin doesn't get hammered, while
13
+ * still recovering quickly (short negative TTL).
14
+ *
15
+ * It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
16
+ * runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1;
17
+ * pair with a shared CDN/KV cache (the Delivery API is CDN-cacheable on
18
+ * `lifecycle.revision`) for L2 — see README.
19
+ */
20
+
21
+ export interface TtlCacheOptions {
22
+ /** Positive-result TTL in ms (default 60_000). */
23
+ readonly ttlMs?: number
24
+ /** Negative-result (miss/error) TTL in ms (default 5_000). */
25
+ readonly negativeTtlMs?: number
26
+ /** Max entries before LRU-ish eviction of the oldest insert (default 1000). */
27
+ readonly maxEntries?: number
28
+ /** Injectable clock for deterministic tests. */
29
+ readonly now?: () => number
30
+ }
31
+
32
+ interface Entry<V> {
33
+ readonly value: V
34
+ readonly expiresAt: number
35
+ readonly negative: boolean
36
+ }
37
+
38
+ export class TtlCache<V> {
39
+ private readonly store = new Map<string, Entry<V>>()
40
+ private readonly inflight = new Map<string, Promise<V>>()
41
+ private readonly ttlMs: number
42
+ private readonly negativeTtlMs: number
43
+ private readonly maxEntries: number
44
+ private readonly now: () => number
45
+
46
+ constructor(opts: TtlCacheOptions = {}) {
47
+ this.ttlMs = opts.ttlMs ?? 60_000
48
+ this.negativeTtlMs = opts.negativeTtlMs ?? 5_000
49
+ this.maxEntries = opts.maxEntries ?? 1000
50
+ this.now = opts.now ?? Date.now
51
+ }
52
+
53
+ /** Read a live (non-expired) entry, or undefined. Prunes the entry if expired. */
54
+ peek(key: string): V | undefined {
55
+ const e = this.store.get(key)
56
+ if (!e) return undefined
57
+ if (e.expiresAt <= this.now()) {
58
+ this.store.delete(key)
59
+ return undefined
60
+ }
61
+ return e.value
62
+ }
63
+
64
+ /**
65
+ * Get-or-load with single-flight. If a fresh value is cached, returns it. Otherwise
66
+ * de-duplicates concurrent loads for `key`: the first caller runs `loader`, every
67
+ * concurrent caller awaits the same promise. The result is cached with the positive
68
+ * TTL; if `isNegative(value)` returns true (e.g. a 'suppress' verdict) it's cached
69
+ * with the shorter negative TTL so a transient failure recovers fast. A thrown loader
70
+ * is NOT cached (it rejects all current waiters and the next call retries).
71
+ */
72
+ async getOrLoad(key: string, loader: () => Promise<V>, isNegative?: (v: V) => boolean): Promise<V> {
73
+ const cached = this.peek(key)
74
+ if (cached !== undefined) return cached
75
+
76
+ const existing = this.inflight.get(key)
77
+ if (existing) return existing
78
+
79
+ const promise = (async () => {
80
+ const value = await loader()
81
+ const negative = isNegative ? isNegative(value) : false
82
+ this.set(key, value, negative)
83
+ return value
84
+ })().finally(() => {
85
+ this.inflight.delete(key)
86
+ })
87
+
88
+ this.inflight.set(key, promise)
89
+ return promise
90
+ }
91
+
92
+ /** Insert/overwrite an entry with the appropriate TTL. */
93
+ set(key: string, value: V, negative = false): void {
94
+ if (this.store.size >= this.maxEntries && !this.store.has(key)) {
95
+ // Evict the oldest inserted key (Map preserves insertion order).
96
+ const oldest = this.store.keys().next().value
97
+ if (oldest !== undefined) this.store.delete(oldest)
98
+ }
99
+ const ttl = negative ? this.negativeTtlMs : this.ttlMs
100
+ this.store.set(key, { value, expiresAt: this.now() + ttl, negative })
101
+ }
102
+
103
+ /** Drop a key (e.g. on a known revocation push). */
104
+ delete(key: string): void {
105
+ this.store.delete(key)
106
+ }
107
+
108
+ clear(): void {
109
+ this.store.clear()
110
+ this.inflight.clear()
111
+ }
112
+
113
+ get size(): number {
114
+ return this.store.size
115
+ }
116
+ }