@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,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* getVerifiedEnvelope — the server-side fetch + verify + cache entry point
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The headless edge calls this ONCE per render in its server loader (Hydrogen loader,
|
|
7
|
+
* Next server component / route handler, Builder server fetch). It:
|
|
8
|
+
* 1. SOURCES the signed `CertDeliveryEnvelope` from EITHER a Shopify metafield (already
|
|
9
|
+
* fetched via the Storefront API) OR the public Delivery API
|
|
10
|
+
* (`GET /api/cert/v1/delivery/{platform}/{externalId}`).
|
|
11
|
+
* 2. Runs the shared VerdictKernel (signature + subject/lifecycle/drift), FAIL-CLOSED.
|
|
12
|
+
* 3. Caches the resulting verdict per-instance (TTL + single-flight) so concurrent SSR
|
|
13
|
+
* renders don't stampede the origin.
|
|
14
|
+
*
|
|
15
|
+
* It returns a `CertVerdict`: `{ decision: 'render', payload }` → render the badge +
|
|
16
|
+
* JSON-LD; `{ decision: 'suppress', reason }` → render NOTHING. Any error (network,
|
|
17
|
+
* malformed JSON, throwing resolver) collapses to a `suppress` verdict — never throws
|
|
18
|
+
* into the render path, never renders an unverified credential.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CertDeliveryEnvelope, CertVerdict, RenderContext, ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
22
|
+
import { verifyEnvelope } from '../contract/kernel.js'
|
|
23
|
+
import { TtlCache } from './cache.js'
|
|
24
|
+
|
|
25
|
+
/** Where the signed envelope comes from. Exactly one of the two source shapes. */
|
|
26
|
+
export type EnvelopeSource =
|
|
27
|
+
/** PULL: the public, CDN-cacheable Delivery API. The helper fetches it. */
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'delivery_api'
|
|
30
|
+
/** Base URL of the portal Delivery API, e.g. 'https://portal.certrev.com'. */
|
|
31
|
+
readonly baseUrl: string
|
|
32
|
+
readonly platform: string
|
|
33
|
+
readonly externalId: string
|
|
34
|
+
}
|
|
35
|
+
/** PUSH/native: an envelope already read from a Shopify app-owned metafield (or any
|
|
36
|
+
* native store). The caller fetched it via the Storefront API; we just verify it. The
|
|
37
|
+
* value may be the parsed envelope or its JSON string (metafields store strings). */
|
|
38
|
+
| {
|
|
39
|
+
readonly kind: 'metafield'
|
|
40
|
+
readonly value: CertDeliveryEnvelope | string | null | undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GetVerifiedEnvelopeOptions {
|
|
44
|
+
readonly source: EnvelopeSource
|
|
45
|
+
/** kid → public-key resolver (see ./resolve-kid). Required: no verify without keys. */
|
|
46
|
+
readonly resolveKid: ResolvePublicKeyByKid
|
|
47
|
+
/** Render context: the platform + externalId this edge IS, plus optional live hash. */
|
|
48
|
+
readonly context: Omit<RenderContext, 'now'> & { readonly now?: Date }
|
|
49
|
+
/** Injectable fetch for tests / non-global-fetch runtimes. */
|
|
50
|
+
readonly fetchImpl?: typeof fetch
|
|
51
|
+
/** Cache override (per-instance). Defaults to the module-level shared cache. */
|
|
52
|
+
readonly cache?: TtlCache<CertVerdict>
|
|
53
|
+
/** Positive verdict TTL ms (default 60_000). */
|
|
54
|
+
readonly ttlMs?: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Module-level shared cache so all calls in a process coordinate by default. */
|
|
58
|
+
const sharedVerdictCache = new TtlCache<CertVerdict>({ ttlMs: 60_000, negativeTtlMs: 5_000 })
|
|
59
|
+
|
|
60
|
+
function suppress(
|
|
61
|
+
reason: CertVerdict extends { decision: 'suppress' }
|
|
62
|
+
? never
|
|
63
|
+
: Extract<CertVerdict, { decision: 'suppress' }>['reason'],
|
|
64
|
+
): CertVerdict {
|
|
65
|
+
return { decision: 'suppress', reason }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseEnvelope(value: CertDeliveryEnvelope | string | null | undefined): CertDeliveryEnvelope | null {
|
|
69
|
+
if (value == null) return null
|
|
70
|
+
if (typeof value !== 'string') return value
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(value) as CertDeliveryEnvelope
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deliveryApiUrl(s: Extract<EnvelopeSource, { kind: 'delivery_api' }>): string {
|
|
79
|
+
const base = s.baseUrl.replace(/\/+$/, '')
|
|
80
|
+
return `${base}/api/cert/v1/delivery/${encodeURIComponent(s.platform)}/${encodeURIComponent(s.externalId)}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stable cache key per (source identity × render externalId). */
|
|
84
|
+
function cacheKey(source: EnvelopeSource, ctx: RenderContext): string {
|
|
85
|
+
if (source.kind === 'delivery_api') {
|
|
86
|
+
return `api:${source.platform}:${source.externalId}`
|
|
87
|
+
}
|
|
88
|
+
// Metafield: key on the rendering identity (the value is already in hand; the verdict
|
|
89
|
+
// still depends on context). Include a short hash of the value to bust on push update.
|
|
90
|
+
return `mf:${ctx.platform}:${ctx.externalId}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchFromDeliveryApi(
|
|
94
|
+
s: Extract<EnvelopeSource, { kind: 'delivery_api' }>,
|
|
95
|
+
fetchImpl: typeof fetch,
|
|
96
|
+
): Promise<CertDeliveryEnvelope | null> {
|
|
97
|
+
const res = await fetchImpl(deliveryApiUrl(s), { headers: { accept: 'application/json' } })
|
|
98
|
+
if (!res.ok) return null // 404 / 410 (revoked, no longer served) → no envelope → suppress
|
|
99
|
+
try {
|
|
100
|
+
return (await res.json()) as CertDeliveryEnvelope
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fetch (if needed), verify, and cache the certification verdict for one placement.
|
|
108
|
+
* FAIL-CLOSED on every error path. Safe to call on every SSR render — the cache +
|
|
109
|
+
* single-flight keep the origin load bounded.
|
|
110
|
+
*/
|
|
111
|
+
export async function getVerifiedEnvelope(opts: GetVerifiedEnvelopeOptions): Promise<CertVerdict> {
|
|
112
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
113
|
+
const cache = opts.cache ?? sharedVerdictCache
|
|
114
|
+
const ctx: RenderContext = { ...opts.context }
|
|
115
|
+
const key = cacheKey(opts.source, ctx)
|
|
116
|
+
|
|
117
|
+
const isSuppress = (v: CertVerdict) => v.decision !== 'render'
|
|
118
|
+
|
|
119
|
+
return cache.getOrLoad(
|
|
120
|
+
key,
|
|
121
|
+
async () => {
|
|
122
|
+
let envelope: CertDeliveryEnvelope | null
|
|
123
|
+
try {
|
|
124
|
+
envelope =
|
|
125
|
+
opts.source.kind === 'delivery_api'
|
|
126
|
+
? await fetchFromDeliveryApi(opts.source, fetchImpl)
|
|
127
|
+
: parseEnvelope(opts.source.value)
|
|
128
|
+
} catch {
|
|
129
|
+
// Network/JSON failure → fail closed (do NOT render an unverified credential).
|
|
130
|
+
return suppress('unsupported_contract_version')
|
|
131
|
+
}
|
|
132
|
+
if (!envelope) {
|
|
133
|
+
// No envelope present (never certified / revoked + cleared / 404) → suppress.
|
|
134
|
+
return suppress('unsupported_contract_version')
|
|
135
|
+
}
|
|
136
|
+
// The kernel itself never throws (it fails closed), but guard the resolver too.
|
|
137
|
+
try {
|
|
138
|
+
return await verifyEnvelope(envelope, opts.resolveKid, ctx)
|
|
139
|
+
} catch {
|
|
140
|
+
return suppress('unknown_key')
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
isSuppress,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Expose the shared cache so a revocation webhook handler can invalidate proactively. */
|
|
148
|
+
export function invalidateVerdict(
|
|
149
|
+
source: EnvelopeSource,
|
|
150
|
+
context: RenderContext,
|
|
151
|
+
cache: TtlCache<CertVerdict> = sharedVerdictCache,
|
|
152
|
+
): void {
|
|
153
|
+
cache.delete(cacheKey(source, context))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { sharedVerdictCache }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public-key resolution for the kernel's signature-verification step.
|
|
3
|
+
*
|
|
4
|
+
* The kernel needs a `kid → Ed25519 public key` resolver. CertREV publishes its public
|
|
5
|
+
* key set (a JWKS-like document) so any edge can verify without a shared secret; rotation
|
|
6
|
+
* is "publish N+1 keys, start signing with the new kid, retire the old once edges refresh".
|
|
7
|
+
*
|
|
8
|
+
* This module gives two resolvers:
|
|
9
|
+
* • `staticKidResolver(keys)` — for a key set baked into the deploy (the headless edge
|
|
10
|
+
* ships CertREV's current public keys as config; zero network at render time).
|
|
11
|
+
* • `fetchingKidResolver({ jwksUrl })` — fetches + caches the published key set, so a
|
|
12
|
+
* newly-rotated kid resolves without a redeploy. Cached with a long TTL (keys rotate
|
|
13
|
+
* rarely) + single-flight (no thundering herd on the JWKS endpoint).
|
|
14
|
+
*
|
|
15
|
+
* Both return the `Ed25519PublicKeyInput` shape the kernel accepts. The fetching resolver
|
|
16
|
+
* understands the two encodings CertREV may publish: a PEM string, or a JWK with an
|
|
17
|
+
* Ed25519 `x` (base64url raw key).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Ed25519PublicKeyInput, ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
21
|
+
import { TtlCache } from './cache.js'
|
|
22
|
+
|
|
23
|
+
/** A static map of kid → public key (PEM string or an explicit input). */
|
|
24
|
+
export type StaticKeySet = Readonly<Record<string, string | Ed25519PublicKeyInput>>
|
|
25
|
+
|
|
26
|
+
function asInput(v: string | Ed25519PublicKeyInput): Ed25519PublicKeyInput {
|
|
27
|
+
// A bare string is treated as PEM (the common deploy-config form).
|
|
28
|
+
return typeof v === 'string' ? { format: 'pem', pem: v } : v
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolver over a key set baked into the deploy. No network at render time. */
|
|
32
|
+
export function staticKidResolver(keys: StaticKeySet): ResolvePublicKeyByKid {
|
|
33
|
+
return (kid) => {
|
|
34
|
+
const k = keys[kid]
|
|
35
|
+
return k ? asInput(k) : null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── JWKS-style published key set ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface PublishedJwk {
|
|
42
|
+
readonly kid: string
|
|
43
|
+
readonly kty?: string
|
|
44
|
+
readonly crv?: string
|
|
45
|
+
/** base64url raw 32-byte Ed25519 public key (OKP/Ed25519). */
|
|
46
|
+
readonly x?: string
|
|
47
|
+
/** Alternatively, a PEM the publisher chose to ship. */
|
|
48
|
+
readonly pem?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface PublishedKeySetDoc {
|
|
52
|
+
readonly keys: ReadonlyArray<PublishedJwk>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function base64urlToBytes(b64url: string): Uint8Array {
|
|
56
|
+
return new Uint8Array(Buffer.from(b64url, 'base64url'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function jwkToInput(jwk: PublishedJwk): Ed25519PublicKeyInput | null {
|
|
60
|
+
if (jwk.pem) return { format: 'pem', pem: jwk.pem }
|
|
61
|
+
if (jwk.x) {
|
|
62
|
+
const bytes = base64urlToBytes(jwk.x)
|
|
63
|
+
if (bytes.length === 32) return { format: 'raw', bytes }
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FetchingKidResolverOptions {
|
|
69
|
+
/** URL of CertREV's published key set (JWKS-like). */
|
|
70
|
+
readonly jwksUrl: string
|
|
71
|
+
/** Key-set cache TTL in ms (default 1h — keys rotate rarely). */
|
|
72
|
+
readonly ttlMs?: number
|
|
73
|
+
/** Injectable fetch for tests / non-global-fetch runtimes. */
|
|
74
|
+
readonly fetchImpl?: typeof fetch
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolver that fetches + caches CertREV's published key set. A single fetch populates
|
|
79
|
+
* all kids; concurrent misses single-flight through the cache. A kid absent from the
|
|
80
|
+
* fetched set resolves to null (→ kernel suppresses 'unknown_key' — fail-closed).
|
|
81
|
+
*/
|
|
82
|
+
export function fetchingKidResolver(opts: FetchingKidResolverOptions): ResolvePublicKeyByKid {
|
|
83
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
84
|
+
const cache = new TtlCache<Map<string, Ed25519PublicKeyInput>>({ ttlMs: opts.ttlMs ?? 3_600_000 })
|
|
85
|
+
const CACHE_KEY = opts.jwksUrl
|
|
86
|
+
|
|
87
|
+
async function loadKeySet(): Promise<Map<string, Ed25519PublicKeyInput>> {
|
|
88
|
+
const map = new Map<string, Ed25519PublicKeyInput>()
|
|
89
|
+
const res = await fetchImpl(opts.jwksUrl, { headers: { accept: 'application/json' } })
|
|
90
|
+
if (!res.ok) return map // empty → every kid resolves null → fail closed
|
|
91
|
+
const doc = (await res.json()) as PublishedKeySetDoc
|
|
92
|
+
for (const jwk of doc.keys ?? []) {
|
|
93
|
+
const input = jwkToInput(jwk)
|
|
94
|
+
if (input && jwk.kid) map.set(jwk.kid, input)
|
|
95
|
+
}
|
|
96
|
+
return map
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return async (kid) => {
|
|
100
|
+
const keySet = await cache.getOrLoad(CACHE_KEY, loadKeySet, (m) => m.size === 0)
|
|
101
|
+
return keySet.get(kid) ?? null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* <certrev-badge> — the framework-agnostic universal-embed Web Component
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The `universal_embed` surface class: a custom element any site can drop in, fed by the
|
|
7
|
+
* Delivery API, that renders the SAME badge as <CertBadge> (it shares
|
|
8
|
+
* `renderBadgeHtml`). Two usage modes, both crawler-friendly:
|
|
9
|
+
*
|
|
10
|
+
* 1. SSR + hydrate (PREFERRED, crawlable). A server-side include emits the badge HTML
|
|
11
|
+
* (via `renderBadgeHtml`) INSIDE the element, plus the element tag. The component
|
|
12
|
+
* sees existing light-DOM children and leaves them; it only re-renders if asked to
|
|
13
|
+
* refresh. The crawler reads the server HTML; the component is progressive
|
|
14
|
+
* enhancement.
|
|
15
|
+
*
|
|
16
|
+
* 2. Client fetch (fallback, NOT crawlable — for already-client-only contexts). Given a
|
|
17
|
+
* `delivery-api` + `platform` + `external-id`, it fetches the envelope, runs the
|
|
18
|
+
* verdict via the kernel (WebCrypto path lives in cert-contract; here we accept a
|
|
19
|
+
* pre-supplied `resolveKid`), and renders on `render`. Documented as the weaker mode.
|
|
20
|
+
*
|
|
21
|
+
* The element NEVER renders an unverified credential: client-mode renders only on a
|
|
22
|
+
* `render` verdict; on any suppress/error it renders nothing (fail-closed).
|
|
23
|
+
*
|
|
24
|
+
* Registration is side-effect-free until you call `defineCertRevBadge()` (so importing
|
|
25
|
+
* the module in SSR doesn't touch `customElements`, which doesn't exist server-side).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { CertVerdict, ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
29
|
+
import { getVerifiedEnvelope } from '../verify/get-verified-envelope.js'
|
|
30
|
+
import { renderBadgeHtml } from './render-badge-html.js'
|
|
31
|
+
|
|
32
|
+
export const CERTREV_BADGE_TAG = 'certrev-badge'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A resolver registry the page sets once (the published CertREV keys) so the element can
|
|
36
|
+
* verify in client-fetch mode without each tag carrying key config. SSR mode never needs
|
|
37
|
+
* this (verification happened server-side).
|
|
38
|
+
*/
|
|
39
|
+
let globalResolveKid: ResolvePublicKeyByKid | null = null
|
|
40
|
+
export function setCertRevKidResolver(resolver: ResolvePublicKeyByKid): void {
|
|
41
|
+
globalResolveKid = resolver
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CertRevBadgeElement extends HTMLElement {
|
|
45
|
+
static get observedAttributes(): string[] {
|
|
46
|
+
return ['delivery-api', 'platform', 'external-id', 'accent-color', 'badge-style', 'content-hash']
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
connectedCallback(): void {
|
|
50
|
+
// SSR/light-DOM-present mode: server already rendered the badge inside us. Leave it.
|
|
51
|
+
// Only client-fetch when there are no rendered children AND we have a source.
|
|
52
|
+
if (this.querySelector('.certrev-badge')) return
|
|
53
|
+
void this.clientFetchAndRender()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void {
|
|
57
|
+
if (oldValue === newValue) return
|
|
58
|
+
// Re-fetch only in client mode (no server-rendered child present).
|
|
59
|
+
if (this.isConnected && !this.querySelector('.certrev-badge')) {
|
|
60
|
+
void this.clientFetchAndRender()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async clientFetchAndRender(): Promise<void> {
|
|
65
|
+
const baseUrl = this.getAttribute('delivery-api')
|
|
66
|
+
const platform = this.getAttribute('platform')
|
|
67
|
+
const externalId = this.getAttribute('external-id')
|
|
68
|
+
if (!baseUrl || !platform || !externalId) return // nothing to fetch → render nothing
|
|
69
|
+
if (!globalResolveKid) {
|
|
70
|
+
// No keys configured → cannot verify → fail closed (render nothing).
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
let verdict: CertVerdict
|
|
74
|
+
try {
|
|
75
|
+
verdict = await getVerifiedEnvelope({
|
|
76
|
+
source: { kind: 'delivery_api', baseUrl, platform, externalId },
|
|
77
|
+
resolveKid: globalResolveKid,
|
|
78
|
+
context: {
|
|
79
|
+
platform,
|
|
80
|
+
externalId,
|
|
81
|
+
liveContentHash: this.getAttribute('content-hash'),
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
} catch {
|
|
85
|
+
return // fail closed
|
|
86
|
+
}
|
|
87
|
+
if (verdict.decision !== 'render') return
|
|
88
|
+
const accentColor = this.getAttribute('accent-color') ?? undefined
|
|
89
|
+
const badgeStyle = (this.getAttribute('badge-style') as 'full' | 'compact' | null) ?? undefined
|
|
90
|
+
this.innerHTML = renderBadgeHtml(verdict.payload, { accentColor, badgeStyle })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Register the element. Idempotent + safe to call only in a browser/customElements env. */
|
|
95
|
+
export function defineCertRevBadge(): void {
|
|
96
|
+
if (typeof customElements === 'undefined') return
|
|
97
|
+
if (!customElements.get(CERTREV_BADGE_TAG)) {
|
|
98
|
+
customElements.define(CERTREV_BADGE_TAG, CertRevBadgeElement)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic badge HTML renderer.
|
|
3
|
+
*
|
|
4
|
+
* The single source of badge markup for BOTH the Web Component (client upgrade) and any
|
|
5
|
+
* server-side include that wants to emit the badge as a string without React. It mirrors
|
|
6
|
+
* the <CertBadge> JSX one-for-one — same classes, same structure, same accessibility —
|
|
7
|
+
* but builds an HTML string, so EVERY interpolated field is escaped by hand
|
|
8
|
+
* (`escapeHtml` for content, `escapeAttribute` for attributes, `safeHttpUrl` for hrefs).
|
|
9
|
+
*
|
|
10
|
+
* It is pure + DOM-free, so it runs server-side (the SSR string the crawler sees) and is
|
|
11
|
+
* reused client-side by the Web Component. Crawlability requirement from the contract:
|
|
12
|
+
* the badge must be in the server HTML, never client-only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { escapeAttribute, escapeHtml, safeCssColor, safeHttpUrl } from '../components/escape.js'
|
|
16
|
+
import { credentialSuffix, formatDate, resolveDisplay } from '../components/format.js'
|
|
17
|
+
import type { CertPayload } from '../contract/kernel.js'
|
|
18
|
+
|
|
19
|
+
export interface RenderBadgeOptions {
|
|
20
|
+
readonly accentColor?: string
|
|
21
|
+
readonly badgeStyle?: 'full' | 'compact'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const C = 'certrev-badge'
|
|
25
|
+
|
|
26
|
+
function attr(name: string, value: string | null | undefined): string {
|
|
27
|
+
if (value == null || value === '') return ''
|
|
28
|
+
return ` ${name}="${escapeAttribute(value)}"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Render the certified-badge markup for a verified payload as an HTML string. Returns ''
|
|
33
|
+
* when there is nothing safe to link to AND nothing to show (defensive; the kernel has
|
|
34
|
+
* already decided to render by the time this is called).
|
|
35
|
+
*/
|
|
36
|
+
export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions = {}): string {
|
|
37
|
+
const content = payload.content
|
|
38
|
+
const display = resolveDisplay(content.display, opts.accentColor)
|
|
39
|
+
const style = opts.badgeStyle ?? display.badgeStyle
|
|
40
|
+
const accent = safeCssColor(display.accentColor) ?? '#0f766e'
|
|
41
|
+
const verifyUrl = safeHttpUrl(content.verifyUrl)
|
|
42
|
+
const profileUrl = safeHttpUrl(content.expert.profileUrl)
|
|
43
|
+
const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(content.expert.photoUrl) : null
|
|
44
|
+
const suffix = credentialSuffix(content)
|
|
45
|
+
const certified = formatDate(content.certifiedAt)
|
|
46
|
+
const updated = formatDate(content.contentModifiedAt)
|
|
47
|
+
|
|
48
|
+
const name = `${escapeHtml(content.expert.displayName)}${
|
|
49
|
+
suffix ? `<span class="${C}__credentials">, ${escapeHtml(suffix)}</span>` : ''
|
|
50
|
+
}`
|
|
51
|
+
const nameNode = profileUrl
|
|
52
|
+
? `<a class="${C}__expert-link" href="${escapeAttribute(profileUrl)}" rel="noopener">${name}</a>`
|
|
53
|
+
: name
|
|
54
|
+
|
|
55
|
+
const mark =
|
|
56
|
+
`<svg class="${C}__mark" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">` +
|
|
57
|
+
`<circle cx="12" cy="12" r="11" fill="${escapeAttribute(accent)}"></circle>` +
|
|
58
|
+
`<path d="M7 12.5l3.2 3.2L17 9" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>`
|
|
59
|
+
|
|
60
|
+
const photo = photoUrl
|
|
61
|
+
? `<img class="${C}__photo" src="${escapeAttribute(photoUrl)}" alt="Photo of ${escapeAttribute(content.expert.displayName)}" width="40" height="40" loading="lazy" decoding="async" />`
|
|
62
|
+
: ''
|
|
63
|
+
|
|
64
|
+
const header =
|
|
65
|
+
`<div class="${C}__header">${mark}${photo}` +
|
|
66
|
+
`<div class="${C}__heading">` +
|
|
67
|
+
`<span class="${C}__eyebrow">Expert reviewed</span>` +
|
|
68
|
+
`<span class="${C}__byline">Reviewed by ${nameNode}</span>` +
|
|
69
|
+
`</div></div>`
|
|
70
|
+
|
|
71
|
+
let body = ''
|
|
72
|
+
if (style === 'full') {
|
|
73
|
+
if (display.showMemo && content.memo) {
|
|
74
|
+
body += `<p class="${C}__memo">${escapeHtml(content.memo)}</p>`
|
|
75
|
+
}
|
|
76
|
+
if (display.showAuthor && content.author.name && content.author.name !== content.expert.displayName) {
|
|
77
|
+
const title = content.author.title ? `, ${escapeHtml(content.author.title)}` : ''
|
|
78
|
+
body += `<p class="${C}__author">Written by ${escapeHtml(content.author.name)}${title}</p>`
|
|
79
|
+
}
|
|
80
|
+
const dates: string[] = []
|
|
81
|
+
if (certified) {
|
|
82
|
+
dates.push(
|
|
83
|
+
`<div class="${C}__date"><dt>Certified</dt><dd><time datetime="${escapeAttribute(content.certifiedAt)}">${escapeHtml(certified)}</time></dd></div>`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (updated && content.contentModifiedAt) {
|
|
87
|
+
dates.push(
|
|
88
|
+
`<div class="${C}__date"><dt>Last updated</dt><dd><time datetime="${escapeAttribute(content.contentModifiedAt)}">${escapeHtml(updated)}</time></dd></div>`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if (dates.length) body += `<dl class="${C}__dates">${dates.join('')}</dl>`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const verify = verifyUrl
|
|
95
|
+
? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="noopener" aria-label="Verify this certification on CertREV">Verify on CertREV</a>`
|
|
96
|
+
: ''
|
|
97
|
+
|
|
98
|
+
const ariaLabel = `Content reviewed by ${content.expert.displayName}${suffix ? `, ${suffix}` : ''}`
|
|
99
|
+
const rootStyle = `--certrev-accent:${accent};border-inline-start-color:${accent}`
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
`<section class="${C} ${C}--${escapeAttribute(style)}" style="${escapeAttribute(rootStyle)}"` +
|
|
103
|
+
attr('data-certrev-cert-id', payload.certId) +
|
|
104
|
+
` aria-label="${escapeAttribute(ariaLabel)}">${header}${body}${verify}</section>`
|
|
105
|
+
)
|
|
106
|
+
}
|