@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.
- package/README.md +154 -0
- package/dist/components/CertBadge.d.ts +29 -0
- package/dist/components/CertBadge.d.ts.map +1 -0
- package/dist/components/CertBadge.js +36 -0
- package/dist/components/CertBadge.js.map +1 -0
- package/dist/components/CertJsonLd.d.ts +23 -0
- package/dist/components/CertJsonLd.d.ts.map +1 -0
- package/dist/components/CertJsonLd.js +10 -0
- package/dist/components/CertJsonLd.js.map +1 -0
- package/dist/components/CertRevBacklink.d.ts +18 -0
- package/dist/components/CertRevBacklink.d.ts.map +1 -0
- package/dist/components/CertRevBacklink.js +16 -0
- package/dist/components/CertRevBacklink.js.map +1 -0
- package/dist/components/CertReview.d.ts +23 -0
- package/dist/components/CertReview.d.ts.map +1 -0
- package/dist/components/CertReview.js +11 -0
- package/dist/components/CertReview.js.map +1 -0
- package/dist/components/ExpertBio.d.ts +17 -0
- package/dist/components/ExpertBio.d.ts.map +1 -0
- package/dist/components/ExpertBio.js +17 -0
- package/dist/components/ExpertBio.js.map +1 -0
- package/dist/components/escape.d.ts +36 -0
- package/dist/components/escape.d.ts.map +1 -0
- package/dist/components/escape.js +76 -0
- package/dist/components/escape.js.map +1 -0
- package/dist/components/format.d.ts +22 -0
- package/dist/components/format.d.ts.map +1 -0
- package/dist/components/format.js +42 -0
- package/dist/components/format.js.map +1 -0
- package/dist/contract/fixtures.d.ts +36 -0
- package/dist/contract/fixtures.d.ts.map +1 -0
- package/dist/contract/fixtures.js +87 -0
- package/dist/contract/fixtures.js.map +1 -0
- package/dist/contract/kernel-contract.d.ts +154 -0
- package/dist/contract/kernel-contract.d.ts.map +1 -0
- package/dist/contract/kernel-contract.js +35 -0
- package/dist/contract/kernel-contract.js.map +1 -0
- package/dist/contract/kernel-stub.d.ts +44 -0
- package/dist/contract/kernel-stub.d.ts.map +1 -0
- package/dist/contract/kernel-stub.js +163 -0
- package/dist/contract/kernel-stub.js.map +1 -0
- package/dist/contract/kernel.d.ts +20 -0
- package/dist/contract/kernel.d.ts.map +1 -0
- package/dist/contract/kernel.js +19 -0
- package/dist/contract/kernel.js.map +1 -0
- package/dist/contract/verdict-kernel.d.ts +34 -0
- package/dist/contract/verdict-kernel.d.ts.map +1 -0
- package/dist/contract/verdict-kernel.js +13 -0
- package/dist/contract/verdict-kernel.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonld/project.d.ts +71 -0
- package/dist/jsonld/project.d.ts.map +1 -0
- package/dist/jsonld/project.js +183 -0
- package/dist/jsonld/project.js.map +1 -0
- package/dist/verify/cache.d.ts +56 -0
- package/dist/verify/cache.d.ts.map +1 -0
- package/dist/verify/cache.js +93 -0
- package/dist/verify/cache.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +65 -0
- package/dist/verify/get-verified-envelope.d.ts.map +1 -0
- package/dist/verify/get-verified-envelope.js +104 -0
- package/dist/verify/get-verified-envelope.js.map +1 -0
- package/dist/verify/resolve-kid.d.ts +38 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -0
- package/dist/verify/resolve-kid.js +71 -0
- package/dist/verify/resolve-kid.js.map +1 -0
- package/dist/webcomponent/certrev-badge.d.ts +38 -0
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -0
- package/dist/webcomponent/certrev-badge.js +98 -0
- package/dist/webcomponent/certrev-badge.js.map +1 -0
- package/dist/webcomponent/render-badge-html.d.ts +25 -0
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -0
- package/dist/webcomponent/render-badge-html.js +81 -0
- package/dist/webcomponent/render-badge-html.js.map +1 -0
- package/package.json +70 -0
- package/src/__tests__/components.test.tsx +191 -0
- package/src/__tests__/project.test.ts +128 -0
- package/src/__tests__/verify.test.ts +203 -0
- package/src/__tests__/webcomponent.test.tsx +106 -0
- package/src/components/CertBadge.tsx +164 -0
- package/src/components/CertJsonLd.tsx +36 -0
- package/src/components/CertRevBacklink.tsx +63 -0
- package/src/components/CertReview.tsx +42 -0
- package/src/components/ExpertBio.tsx +77 -0
- package/src/components/escape.ts +72 -0
- package/src/components/format.ts +55 -0
- package/src/contract/fixtures.ts +107 -0
- package/src/contract/kernel.ts +20 -0
- package/src/contract/verdict-kernel.ts +47 -0
- package/src/index.ts +85 -0
- package/src/jsonld/project.ts +206 -0
- package/src/verify/cache.ts +116 -0
- package/src/verify/get-verified-envelope.ts +156 -0
- package/src/verify/resolve-kid.ts +103 -0
- package/src/webcomponent/certrev-badge.ts +100 -0
- 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
|
+
}
|