@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,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
+ }