@certrev/cert-block 0.5.5 → 1.0.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/CHANGELOG.md +164 -26
- package/LICENSE +21 -0
- package/README.md +415 -54
- package/dist/builder/cert-chrome-data.d.ts +26 -8
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +5 -5
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -5
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +115 -34
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/index.d.ts +33 -16
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +33 -16
- package/dist/builder/index.js.map +1 -1
- package/dist/components/CertBadge.d.ts +2 -2
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +11 -5
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/CertJsonLd.d.ts.map +1 -1
- package/dist/components/CertJsonLd.js.map +1 -1
- package/dist/components/CertRevBacklink.d.ts.map +1 -1
- package/dist/components/CertRevBacklink.js +3 -2
- package/dist/components/CertRevBacklink.js.map +1 -1
- package/dist/components/CertReview.d.ts +1 -1
- package/dist/components/ExpertBio.d.ts.map +1 -1
- package/dist/components/ExpertBio.js +12 -4
- package/dist/components/ExpertBio.js.map +1 -1
- package/dist/components/escape.d.ts +28 -1
- package/dist/components/escape.d.ts.map +1 -1
- package/dist/components/escape.js +37 -8
- package/dist/components/escape.js.map +1 -1
- package/dist/components/format.d.ts +51 -2
- package/dist/components/format.d.ts.map +1 -1
- package/dist/components/format.js +103 -6
- package/dist/components/format.js.map +1 -1
- package/dist/components/preview-block.d.ts +1 -1
- package/dist/components/preview-block.js +3 -3
- package/dist/components/preview-block.js.map +1 -1
- package/dist/components/rel.d.ts +24 -0
- package/dist/components/rel.d.ts.map +1 -0
- package/dist/components/rel.js +24 -0
- package/dist/components/rel.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +50 -22
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +146 -80
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +13 -14
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +14 -15
- package/dist/components/render-def.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/jsonld/project.d.ts +46 -13
- package/dist/jsonld/project.d.ts.map +1 -1
- package/dist/jsonld/project.js +139 -56
- package/dist/jsonld/project.js.map +1 -1
- package/dist/modal/avatar-resize.d.ts +10 -10
- package/dist/modal/avatar-resize.js +14 -14
- package/dist/modal/avatar-resize.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +27 -7
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +49 -25
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert-modal.d.ts +5 -5
- package/dist/modal/certrev-cert-modal.js +9 -9
- package/dist/modal/certrev-cert-modal.js.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
- package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
- package/dist/modal/certrev-cert-modal.styles.js +8 -8
- package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
- package/dist/modal/certrev-cert.entry.d.ts +8 -6
- package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
- package/dist/modal/certrev-cert.entry.js +9 -7
- package/dist/modal/certrev-cert.entry.js.map +1 -1
- package/dist/modal/certrev-cert.js +6 -6
- package/dist/modal/display-strings.d.ts +25 -5
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +27 -5
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/escape.d.ts +26 -3
- package/dist/modal/escape.d.ts.map +1 -1
- package/dist/modal/escape.js +32 -3
- package/dist/modal/escape.js.map +1 -1
- package/dist/modal/font-face.d.ts.map +1 -1
- package/dist/modal/font-face.js +8 -11
- package/dist/modal/font-face.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +2 -2
- package/dist/modal/ftc-disclosure.js +2 -2
- package/dist/modal/ftc-guard.d.ts +44 -9
- package/dist/modal/ftc-guard.d.ts.map +1 -1
- package/dist/modal/ftc-guard.js +50 -17
- package/dist/modal/ftc-guard.js.map +1 -1
- package/dist/modal/index.d.ts +1 -1
- package/dist/modal/index.js +1 -1
- package/dist/modal/interactions.d.ts +2 -2
- package/dist/modal/interactions.js +2 -2
- package/dist/modal/logo-paths.js +2 -2
- package/dist/modal/logo-paths.js.map +1 -1
- package/dist/modal/placement.d.ts +4 -3
- package/dist/modal/placement.d.ts.map +1 -1
- package/dist/modal/placement.js +7 -6
- package/dist/modal/placement.js.map +1 -1
- package/dist/modal/revalidate.d.ts +2 -3
- package/dist/modal/revalidate.d.ts.map +1 -1
- package/dist/modal/revalidate.js +2 -3
- package/dist/modal/revalidate.js.map +1 -1
- package/dist/modal/verify-client.d.ts +2 -2
- package/dist/modal/verify-client.js +2 -2
- package/dist/verify/cache.d.ts +21 -6
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +29 -8
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/fetch-with-deadline.d.ts +35 -0
- package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
- package/dist/verify/fetch-with-deadline.js +55 -0
- package/dist/verify/fetch-with-deadline.js.map +1 -0
- package/dist/verify/get-verified-envelope.d.ts +23 -9
- package/dist/verify/get-verified-envelope.d.ts.map +1 -1
- package/dist/verify/get-verified-envelope.js +89 -40
- package/dist/verify/get-verified-envelope.js.map +1 -1
- package/dist/verify/resolve-kid.d.ts +3 -0
- package/dist/verify/resolve-kid.d.ts.map +1 -1
- package/dist/verify/resolve-kid.js +17 -8
- package/dist/verify/resolve-kid.js.map +1 -1
- package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
- package/dist/webcomponent/certrev-badge.js +24 -2
- package/dist/webcomponent/certrev-badge.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +36 -17
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +5 -2
- package/src/builder/cert-chrome-data.ts +26 -8
- package/src/builder/cert-review-card.tsx +147 -38
- package/src/builder/index.tsx +34 -17
- package/src/components/CertBadge.tsx +14 -9
- package/src/components/CertJsonLd.tsx +1 -1
- package/src/components/CertRevBacklink.tsx +10 -3
- package/src/components/CertReview.tsx +1 -1
- package/src/components/ExpertBio.tsx +12 -7
- package/src/components/escape.ts +37 -7
- package/src/components/format.ts +102 -6
- package/src/components/preview-block.ts +3 -3
- package/src/components/rel.ts +23 -0
- package/src/components/render-cert-block.ts +192 -109
- package/src/components/render-def.ts +14 -15
- package/src/index.ts +4 -2
- package/src/jsonld/project.ts +155 -59
- package/src/modal/avatar-resize.ts +14 -14
- package/src/modal/cert-modal-view.ts +52 -27
- package/src/modal/certrev-cert-modal.styles.ts +8 -8
- package/src/modal/certrev-cert-modal.ts +9 -9
- package/src/modal/certrev-cert.entry.ts +9 -7
- package/src/modal/display-strings.ts +26 -5
- package/src/modal/escape.ts +32 -4
- package/src/modal/font-face.ts +8 -11
- package/src/modal/ftc-disclosure.ts +2 -2
- package/src/modal/ftc-guard.ts +73 -18
- package/src/modal/index.ts +1 -1
- package/src/modal/interactions.ts +2 -2
- package/src/modal/logo-paths.ts +2 -2
- package/src/modal/placement.ts +7 -6
- package/src/modal/revalidate.ts +2 -3
- package/src/modal/verify-client.ts +2 -2
- package/src/verify/cache.ts +34 -8
- package/src/verify/fetch-with-deadline.ts +60 -0
- package/src/verify/get-verified-envelope.ts +131 -48
- package/src/verify/resolve-kid.ts +22 -6
- package/src/webcomponent/certrev-badge.ts +24 -2
- package/src/webcomponent/render-badge-html.ts +66 -19
- package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
- package/src/__tests__/components.test.tsx +0 -206
- package/src/__tests__/preview-block.test.ts +0 -84
- package/src/__tests__/project.test.ts +0 -128
- package/src/__tests__/readme-version.test.ts +0 -51
- package/src/__tests__/render-cert-block.test.ts +0 -657
- package/src/__tests__/render-def.test.tsx +0 -295
- package/src/__tests__/verify.test.ts +0 -239
- package/src/__tests__/webcomponent.test.tsx +0 -116
- package/src/builder/__tests__/builder.test.tsx +0 -158
- package/src/modal/cert-modal-view.test.ts +0 -175
- package/src/modal/certrev-cert-modal.test.ts +0 -125
- package/src/modal/ftc-guard.test.ts +0 -153
- package/src/modal/interactions.test.ts +0 -250
- package/src/modal/placement.test.ts +0 -398
- package/src/modal/revalidate.test.ts +0 -111
- package/src/modal/verify-client.test.ts +0 -164
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetchWithDeadline` — every network read on the render path gets a deadline.
|
|
3
|
+
*
|
|
4
|
+
* WHY: the verify layer runs INSIDE a server render (a Hydrogen loader, a Next server
|
|
5
|
+
* component, a Builder server fetch). A hung origin with no deadline holds that render open
|
|
6
|
+
* for as long as the runtime's socket timeout allows — one confirmed case was still pending
|
|
7
|
+
* at 45 SECONDS, i.e. a page that never answered because a decorative badge could not be
|
|
8
|
+
* verified. A badge is never worth a page: past the deadline the request is ABORTED (the
|
|
9
|
+
* connection goes back) and the caller fails closed, the same safe outcome as any other
|
|
10
|
+
* fetch failure.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate choices:
|
|
13
|
+
* • The deadline is enforced by a RACE, not by the AbortSignal alone. A `fetchImpl` that
|
|
14
|
+
* ignores its signal (a wrapper, a proxy, a non-conforming polyfill) must not be able to
|
|
15
|
+
* hold a render open. The abort still fires, so a conforming implementation also releases
|
|
16
|
+
* the socket rather than merely being abandoned.
|
|
17
|
+
* • `setTimeout` + `AbortController`, not `AbortSignal.timeout()`. The one-liner reads
|
|
18
|
+
* better but its timer is not the one a test's fake clock controls, and a deadline that
|
|
19
|
+
* can only be tested by really waiting is a deadline nobody keeps tested.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default deadline for a render-path fetch. The Delivery API is CDN-cacheable and answers
|
|
24
|
+
* well under a second at p99; three seconds is generous for a cold origin and still bounded
|
|
25
|
+
* by what a page can afford to spend on a decoration whose failure mode is "render nothing".
|
|
26
|
+
* Callers on a tighter TTFB budget pass their own.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 3_000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `fetchImpl(url, init)` with a hard deadline. Rejects when the deadline passes (after
|
|
32
|
+
* aborting the request); every caller on the verify path treats that rejection like any other
|
|
33
|
+
* fetch failure — fail closed. A non-finite or non-positive `timeoutMs` is an explicit opt-out
|
|
34
|
+
* (no deadline), for a caller that owns its own cancellation.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchWithDeadline(
|
|
37
|
+
fetchImpl: typeof fetch,
|
|
38
|
+
url: string,
|
|
39
|
+
init: RequestInit,
|
|
40
|
+
timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return fetchImpl(url, init)
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController()
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
46
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
47
|
+
timer = setTimeout(() => {
|
|
48
|
+
controller.abort()
|
|
49
|
+
reject(new Error(`cert-block: fetch exceeded its ${timeoutMs}ms deadline — ${url}`))
|
|
50
|
+
}, timeoutMs)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Promise.race subscribes to both, so an abort-rejection that lands after the race has
|
|
55
|
+
// settled is still handled — it never surfaces as an unhandled rejection.
|
|
56
|
+
return await Promise.race([fetchImpl(url, { ...init, signal: controller.signal }), deadline])
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -16,11 +16,23 @@
|
|
|
16
16
|
* JSON-LD; `{ decision: 'suppress', reason }` → render NOTHING. Any error (network,
|
|
17
17
|
* malformed JSON, throwing resolver) collapses to a `suppress` verdict — never throws
|
|
18
18
|
* into the render path, never renders an unverified credential.
|
|
19
|
+
*
|
|
20
|
+
* `verdict.reason` is the ONLY signal a consumer gets when a badge does not appear (this
|
|
21
|
+
* package logs nothing), so every failure path here reports what ACTUALLY failed rather than
|
|
22
|
+
* a convenient catch-all. Every fetch also carries a deadline: a hung origin fails closed at
|
|
23
|
+
* `timeoutMs` instead of holding the render open (see ./fetch-with-deadline).
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
|
-
import type {
|
|
22
|
-
|
|
26
|
+
import type {
|
|
27
|
+
CertDeliveryArtifact,
|
|
28
|
+
CertSuppressReason,
|
|
29
|
+
CertVerdict,
|
|
30
|
+
RenderContext,
|
|
31
|
+
ResolvePublicKeyByKid,
|
|
32
|
+
} from '../contract/kernel.js'
|
|
33
|
+
import { verifyArtifact } from '../contract/kernel.js'
|
|
23
34
|
import { TtlCache } from './cache.js'
|
|
35
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
|
|
24
36
|
|
|
25
37
|
/** Where the signed envelope comes from. Exactly one of the two source shapes. */
|
|
26
38
|
export type EnvelopeSource =
|
|
@@ -32,12 +44,13 @@ export type EnvelopeSource =
|
|
|
32
44
|
readonly platform: string
|
|
33
45
|
readonly externalId: string
|
|
34
46
|
}
|
|
35
|
-
/** PUSH/native: an
|
|
47
|
+
/** PUSH/native: an artifact already read from a Shopify app-owned metafield (or any
|
|
36
48
|
* native store). The caller fetched it via the Storefront API; we just verify it. The
|
|
37
|
-
* value may be the parsed
|
|
49
|
+
* value may be the parsed artifact — a full envelope OR the slim revocation tombstone —
|
|
50
|
+
* or its JSON string (metafields store strings). */
|
|
38
51
|
| {
|
|
39
52
|
readonly kind: 'metafield'
|
|
40
|
-
readonly value:
|
|
53
|
+
readonly value: CertDeliveryArtifact | string | null | undefined
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export interface GetVerifiedEnvelopeOptions {
|
|
@@ -45,33 +58,58 @@ export interface GetVerifiedEnvelopeOptions {
|
|
|
45
58
|
/** kid → public-key resolver (see ./resolve-kid). Required: no verify without keys. */
|
|
46
59
|
readonly resolveKid: ResolvePublicKeyByKid
|
|
47
60
|
/** Render context: the platform + externalId this edge IS, plus optional live hash. */
|
|
48
|
-
readonly context:
|
|
61
|
+
readonly context: RenderContext
|
|
49
62
|
/** Injectable fetch for tests / non-global-fetch runtimes. */
|
|
50
63
|
readonly fetchImpl?: typeof fetch
|
|
51
64
|
/** Cache override (per-instance). Defaults to the module-level shared cache. */
|
|
52
65
|
readonly cache?: TtlCache<CertVerdict>
|
|
53
|
-
/**
|
|
66
|
+
/** TTL in ms for a RENDER verdict written by this call (default: the cache's own positive
|
|
67
|
+
* TTL — 60_000 on the shared cache). A suppress verdict keeps the cache's short negative
|
|
68
|
+
* TTL regardless, so a longer `ttlMs` can never make a transient failure sticky. */
|
|
54
69
|
readonly ttlMs?: number
|
|
70
|
+
/** Deadline in ms for the Delivery API fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s).
|
|
71
|
+
* Ignored by the metafield source, which does no I/O. */
|
|
72
|
+
readonly timeoutMs?: number
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
/** Module-level shared cache so all calls in a process coordinate by default. */
|
|
58
76
|
const sharedVerdictCache = new TtlCache<CertVerdict>({ ttlMs: 60_000, negativeTtlMs: 5_000 })
|
|
59
77
|
|
|
60
|
-
function suppress(
|
|
61
|
-
reason: CertVerdict extends { decision: 'suppress' }
|
|
62
|
-
? never
|
|
63
|
-
: Extract<CertVerdict, { decision: 'suppress' }>['reason'],
|
|
64
|
-
): CertVerdict {
|
|
78
|
+
function suppress(reason: CertSuppressReason): CertVerdict {
|
|
65
79
|
return { decision: 'suppress', reason }
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
/**
|
|
83
|
+
* What sourcing produced: an artifact to verify, or the TRUTHFUL reason it produced none.
|
|
84
|
+
*
|
|
85
|
+
* WHY a reason per failure, and why these reasons: the verdict's `reason` is the only thing a
|
|
86
|
+
* consumer sees when a badge silently doesn't render. Every sourcing failure used to collapse
|
|
87
|
+
* to 'unsupported_contract_version', which is FALSE for a 404 and for an unreachable origin —
|
|
88
|
+
* it sends whoever is debugging to inspect a contractVersion that was never in play.
|
|
89
|
+
*
|
|
90
|
+
* `CertSuppressReason` (owned by @certrev/cert-contract) has no member for a TRANSPORT
|
|
91
|
+
* failure, and inventing one here would be a cross-package contract change. So the mapping
|
|
92
|
+
* is: nothing is published for this subject → 'subject_mismatch'; a body arrived that could
|
|
93
|
+
* not be parsed → 'unsupported_contract_version' (true as written); the origin could not be
|
|
94
|
+
* read at all → 'unsupported_contract_version' as the least-wrong member — it asserts nothing
|
|
95
|
+
* about the credential itself, which every other member would.
|
|
96
|
+
*/
|
|
97
|
+
type SourceOutcome =
|
|
98
|
+
| { readonly ok: true; readonly artifact: CertDeliveryArtifact }
|
|
99
|
+
| { readonly ok: false; readonly reason: CertSuppressReason }
|
|
100
|
+
|
|
101
|
+
/** Read the artifact out of a metafield value. Never throws. */
|
|
102
|
+
function parseMetafieldValue(value: CertDeliveryArtifact | string | null | undefined): SourceOutcome {
|
|
103
|
+
// Absent — never certified, or the app cleared the metafield (Shopify writes an empty
|
|
104
|
+
// string rather than deleting it). Nothing is published for this subject.
|
|
105
|
+
if (value == null || (typeof value === 'string' && value.trim() === '')) {
|
|
106
|
+
return { ok: false, reason: 'subject_mismatch' }
|
|
107
|
+
}
|
|
108
|
+
if (typeof value !== 'string') return { ok: true, artifact: value }
|
|
71
109
|
try {
|
|
72
|
-
return JSON.parse(value) as
|
|
110
|
+
return { ok: true, artifact: JSON.parse(value) as CertDeliveryArtifact }
|
|
73
111
|
} catch {
|
|
74
|
-
return
|
|
112
|
+
return { ok: false, reason: 'unsupported_contract_version' }
|
|
75
113
|
}
|
|
76
114
|
}
|
|
77
115
|
|
|
@@ -98,33 +136,76 @@ function fnv1a(s: string): string {
|
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
/** A stable string form of the metafield value for hashing (parsed → canonical-ish JSON). */
|
|
101
|
-
function metafieldValueKey(value:
|
|
139
|
+
function metafieldValueKey(value: CertDeliveryArtifact | string | null | undefined): string {
|
|
102
140
|
if (value == null) return 'null'
|
|
103
141
|
const s = typeof value === 'string' ? value : JSON.stringify(value)
|
|
104
142
|
return fnv1a(s)
|
|
105
143
|
}
|
|
106
144
|
|
|
107
|
-
/**
|
|
145
|
+
/** WHICH artifact is being verified. For the delivery source that is the full URL — the base
|
|
146
|
+
* URL is part of the identity (a staging portal and production serve DIFFERENT envelopes for
|
|
147
|
+
* the same platform/externalId); for a metafield it's a hash of the value the caller passed. */
|
|
148
|
+
function sourceKey(source: EnvelopeSource): string {
|
|
149
|
+
return source.kind === 'delivery_api' ? `api:${deliveryApiUrl(source)}` : `mf:${metafieldValueKey(source.value)}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** WHERE it is being rendered. `now` is deliberately NOT part of the key: elapsed time is what
|
|
153
|
+
* the TTL already bounds, and keying on an injected clock would turn the cache into a
|
|
154
|
+
* permanent miss for every caller that passes one. */
|
|
155
|
+
function placementKey(ctx: RenderContext): string {
|
|
156
|
+
return `${encodeURIComponent(ctx.platform)}:${encodeURIComponent(ctx.externalId)}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Everything in the key ABOVE the live content hash — one placement, all of its hashes. */
|
|
160
|
+
function placementPrefix(source: EnvelopeSource, ctx: RenderContext): string {
|
|
161
|
+
return `${sourceKey(source)}|${placementKey(ctx)}|`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stable cache key per (artifact identity × placement × live content hash) — i.e. every input
|
|
166
|
+
* the verdict actually depends on. Both source branches now key on the same three things: the
|
|
167
|
+
* old delivery key (`api:<platform>:<externalId>`) named neither the base URL nor the render
|
|
168
|
+
* context, so one entry was shared by a staging and a production portal, and — the dangerous
|
|
169
|
+
* one — a verdict computed while the article matched its `contentDigest` was served for a
|
|
170
|
+
* later render whose body had DRIFTED, rendering a credential the drift check would have
|
|
171
|
+
* suppressed. The live hash goes LAST so `invalidateVerdict` can drop a placement by prefix.
|
|
172
|
+
*/
|
|
108
173
|
function cacheKey(source: EnvelopeSource, ctx: RenderContext): string {
|
|
109
|
-
|
|
110
|
-
return `api:${source.platform}:${source.externalId}`
|
|
111
|
-
}
|
|
112
|
-
// Metafield: key on the rendering identity AND a short hash of the envelope value, so a
|
|
113
|
-
// push update (new envelope at the same placement) busts the entry, and verifying
|
|
114
|
-
// different envelopes against one render context never collides on a stale verdict.
|
|
115
|
-
return `mf:${ctx.platform}:${ctx.externalId}:${metafieldValueKey(source.value)}`
|
|
174
|
+
return `${placementPrefix(source, ctx)}${ctx.liveContentHash ?? '-'}`
|
|
116
175
|
}
|
|
117
176
|
|
|
177
|
+
/** Fetch the artifact from the Delivery API. Never throws: every failure comes back as the
|
|
178
|
+
* truthful suppress reason (see `SourceOutcome`). */
|
|
118
179
|
async function fetchFromDeliveryApi(
|
|
119
180
|
s: Extract<EnvelopeSource, { kind: 'delivery_api' }>,
|
|
120
181
|
fetchImpl: typeof fetch,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
182
|
+
timeoutMs: number,
|
|
183
|
+
): Promise<SourceOutcome> {
|
|
184
|
+
let res: Response
|
|
185
|
+
try {
|
|
186
|
+
res = await fetchWithDeadline(fetchImpl, deliveryApiUrl(s), { headers: { accept: 'application/json' } }, timeoutMs)
|
|
187
|
+
} catch {
|
|
188
|
+
// Unreachable origin, DNS/TLS failure, or the deadline above. No transport member
|
|
189
|
+
// exists in the contract enum; this is the least-wrong one.
|
|
190
|
+
return { ok: false, reason: 'unsupported_contract_version' }
|
|
191
|
+
}
|
|
192
|
+
if (res.status === 404 || res.status === 410) {
|
|
193
|
+
// The API serves nothing for this (platform, externalId): never certified, or the
|
|
194
|
+
// placement was withdrawn. Revocation of a LIVE cert comes back 200 with a tombstone
|
|
195
|
+
// so a 4xx here is genuinely "no credential for this subject" — which is
|
|
196
|
+
// also the question the consumer needs to ask (is this the externalId I think it is?).
|
|
197
|
+
return { ok: false, reason: 'subject_mismatch' }
|
|
198
|
+
}
|
|
199
|
+
if (!res.ok) {
|
|
200
|
+
// 5xx / 429 / anything else: the origin is failing, not the credential. Transport gap
|
|
201
|
+
// again — same least-wrong member, and the short negative TTL keeps it recoverable.
|
|
202
|
+
return { ok: false, reason: 'unsupported_contract_version' }
|
|
203
|
+
}
|
|
124
204
|
try {
|
|
125
|
-
return (await res.json()) as
|
|
205
|
+
return { ok: true, artifact: (await res.json()) as CertDeliveryArtifact }
|
|
126
206
|
} catch {
|
|
127
|
-
|
|
207
|
+
// A body arrived and could not be parsed — here the reason is literally true.
|
|
208
|
+
return { ok: false, reason: 'unsupported_contract_version' }
|
|
128
209
|
}
|
|
129
210
|
}
|
|
130
211
|
|
|
@@ -144,38 +225,40 @@ export async function getVerifiedEnvelope(opts: GetVerifiedEnvelopeOptions): Pro
|
|
|
144
225
|
return cache.getOrLoad(
|
|
145
226
|
key,
|
|
146
227
|
async () => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
opts.source.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!envelope) {
|
|
158
|
-
// No envelope present (never certified / revoked + cleared / 404) → suppress.
|
|
159
|
-
return suppress('unsupported_contract_version')
|
|
160
|
-
}
|
|
228
|
+
const sourced =
|
|
229
|
+
opts.source.kind === 'delivery_api'
|
|
230
|
+
? await fetchFromDeliveryApi(opts.source, fetchImpl, opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS)
|
|
231
|
+
: parseMetafieldValue(opts.source.value)
|
|
232
|
+
if (!sourced.ok) return suppress(sourced.reason)
|
|
233
|
+
// `verifyArtifact` — not `verifyEnvelope` — because the Delivery API answers a
|
|
234
|
+
// revoked cert with the slim signed TOMBSTONE. The envelope-only kernel
|
|
235
|
+
// still fails closed on one, but it reports it as an unparseable envelope; the
|
|
236
|
+
// artifact kernel verifies the tombstone's own signature + subject and says
|
|
237
|
+
// 'revoked', which is both true and the thing a consumer needs to hear.
|
|
161
238
|
// The kernel itself never throws (it fails closed), but guard the resolver too.
|
|
162
239
|
try {
|
|
163
|
-
return await
|
|
240
|
+
return await verifyArtifact(sourced.artifact, opts.resolveKid, ctx)
|
|
164
241
|
} catch {
|
|
165
242
|
return suppress('unknown_key')
|
|
166
243
|
}
|
|
167
244
|
},
|
|
168
245
|
isSuppress,
|
|
246
|
+
opts.ttlMs,
|
|
169
247
|
)
|
|
170
248
|
}
|
|
171
249
|
|
|
172
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Expose the shared cache so a revocation webhook handler can invalidate proactively. Drops
|
|
252
|
+
* EVERY entry for this source at this placement, whatever live content hash each was cached
|
|
253
|
+
* under — the webhook knows the article, never the body hash a given render observed, and an
|
|
254
|
+
* invalidation that misses because of a hash mismatch is a revoked cert that keeps rendering.
|
|
255
|
+
*/
|
|
173
256
|
export function invalidateVerdict(
|
|
174
257
|
source: EnvelopeSource,
|
|
175
258
|
context: RenderContext,
|
|
176
259
|
cache: TtlCache<CertVerdict> = sharedVerdictCache,
|
|
177
260
|
): void {
|
|
178
|
-
cache.
|
|
261
|
+
cache.deletePrefix(placementPrefix(source, context))
|
|
179
262
|
}
|
|
180
263
|
|
|
181
264
|
export { sharedVerdictCache }
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { base64urlDecode } from '@certrev/cert-contract'
|
|
21
21
|
import type { Ed25519PublicKeyInput, ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
22
22
|
import { TtlCache } from './cache.js'
|
|
23
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
|
|
23
24
|
|
|
24
25
|
/** A static map of kid → public key (PEM string or an explicit input). */
|
|
25
26
|
export type StaticKeySet = Readonly<Record<string, string | Ed25519PublicKeyInput>>
|
|
@@ -72,6 +73,9 @@ export interface FetchingKidResolverOptions {
|
|
|
72
73
|
readonly jwksUrl: string
|
|
73
74
|
/** Key-set cache TTL in ms (default 1h — keys rotate rarely). */
|
|
74
75
|
readonly ttlMs?: number
|
|
76
|
+
/** Deadline in ms for the JWKS fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). This fetch
|
|
77
|
+
* is on the render path too — a hung key endpoint must not hold a page open. */
|
|
78
|
+
readonly timeoutMs?: number
|
|
75
79
|
/** Injectable fetch for tests / non-global-fetch runtimes. */
|
|
76
80
|
readonly fetchImpl?: typeof fetch
|
|
77
81
|
}
|
|
@@ -88,12 +92,24 @@ export function fetchingKidResolver(opts: FetchingKidResolverOptions): ResolvePu
|
|
|
88
92
|
|
|
89
93
|
async function loadKeySet(): Promise<Map<string, Ed25519PublicKeyInput>> {
|
|
90
94
|
const map = new Map<string, Ed25519PublicKeyInput>()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetchWithDeadline(
|
|
97
|
+
fetchImpl,
|
|
98
|
+
opts.jwksUrl,
|
|
99
|
+
{ headers: { accept: 'application/json' } },
|
|
100
|
+
opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
101
|
+
)
|
|
102
|
+
if (!res.ok) return map // empty → every kid resolves null → fail closed
|
|
103
|
+
const doc = (await res.json()) as PublishedKeySetDoc
|
|
104
|
+
for (const jwk of doc.keys ?? []) {
|
|
105
|
+
const input = jwkToInput(jwk)
|
|
106
|
+
if (input && jwk.kid) map.set(jwk.kid, input)
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Unreachable / deadline / unreadable body → the EMPTY key set below, negative-cached
|
|
110
|
+
// briefly. The resolver itself never throws: a kid that cannot be resolved must come
|
|
111
|
+
// back as null ('unknown_key' — true) rather than as an exception the caller has to
|
|
112
|
+
// remember to catch.
|
|
97
113
|
}
|
|
98
114
|
return map
|
|
99
115
|
}
|
|
@@ -41,6 +41,17 @@ export function setCertRevKidResolver(resolver: ResolvePublicKeyByKid): void {
|
|
|
41
41
|
globalResolveKid = resolver
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* `badge-style` is AUTHOR-typed markup, not signed data — a hand-written attribute on a page
|
|
46
|
+
* we don't control. Only the two real faces are honored; anything else (`"Full"`, a stray
|
|
47
|
+
* value, an empty attribute) reads as ABSENT, so the signed display config decides. The
|
|
48
|
+
* attribute was previously CAST (`as 'full' | 'compact' | null`), which let a typo travel
|
|
49
|
+
* into the renderer as if it were a face.
|
|
50
|
+
*/
|
|
51
|
+
function badgeStyleAttribute(value: string | null): 'full' | 'compact' | undefined {
|
|
52
|
+
return value === 'full' || value === 'compact' ? value : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
export class CertRevBadgeElement extends HTMLElement {
|
|
45
56
|
static get observedAttributes(): string[] {
|
|
46
57
|
return ['delivery-api', 'platform', 'external-id', 'accent-color', 'badge-style', 'content-hash']
|
|
@@ -86,8 +97,19 @@ export class CertRevBadgeElement extends HTMLElement {
|
|
|
86
97
|
}
|
|
87
98
|
if (verdict.decision !== 'render') return
|
|
88
99
|
const accentColor = this.getAttribute('accent-color') ?? undefined
|
|
89
|
-
const badgeStyle = (this.getAttribute('badge-style')
|
|
90
|
-
|
|
100
|
+
const badgeStyle = badgeStyleAttribute(this.getAttribute('badge-style'))
|
|
101
|
+
// The render is INSIDE the fail-closed boundary too. `connectedCallback` `void`s this
|
|
102
|
+
// promise, so a throw from the renderer (a payload shape nothing runtime-validates —
|
|
103
|
+
// the kernel checks the signature, never `content`) would escape as an unhandled
|
|
104
|
+
// rejection instead of rendering nothing, which is the one behaviour this element
|
|
105
|
+
// documents. Compute first, assign after, so a failure leaves the element untouched.
|
|
106
|
+
let html: string
|
|
107
|
+
try {
|
|
108
|
+
html = renderBadgeHtml(verdict.payload, { accentColor, badgeStyle })
|
|
109
|
+
} catch {
|
|
110
|
+
return // fail closed
|
|
111
|
+
}
|
|
112
|
+
this.innerHTML = html
|
|
91
113
|
}
|
|
92
114
|
}
|
|
93
115
|
|
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { escapeAttribute, escapeHtml, safeCssColor, safeHttpUrl } from '../components/escape.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
credentialSuffix,
|
|
18
|
+
dedupeCredential,
|
|
19
|
+
expertNameWithCredentials,
|
|
20
|
+
formatDate,
|
|
21
|
+
resolveDisplay,
|
|
22
|
+
} from '../components/format.js'
|
|
23
|
+
import { CERTREV_LINK_REL } from '../components/rel.js'
|
|
17
24
|
import {
|
|
18
25
|
CERT_SCOPE_LINE,
|
|
19
26
|
CERT_STRINGS_VERSION,
|
|
@@ -21,13 +28,13 @@ import {
|
|
|
21
28
|
resolveRenderTheme,
|
|
22
29
|
themeCssVarDeclarations,
|
|
23
30
|
} from '../components/render-def.js'
|
|
24
|
-
import type { CertPayload } from '../contract/kernel.js'
|
|
31
|
+
import type { CertContent, CertPayload } from '../contract/kernel.js'
|
|
25
32
|
|
|
26
33
|
export interface RenderBadgeOptions {
|
|
27
34
|
readonly accentColor?: string
|
|
28
35
|
readonly badgeStyle?: 'full' | 'compact'
|
|
29
36
|
/**
|
|
30
|
-
* The brand render-def
|
|
37
|
+
* The brand render-def. ABSENT → the badge renders the CertREV preset
|
|
31
38
|
* default (theme output byte-identical to un-themed). PRESENT → validated
|
|
32
39
|
* themeable tokens are emitted as `--certrev-*` CSS vars + subtractive
|
|
33
40
|
* visibility applies. Mirrors `<CertBadge>`'s `renderDef` prop exactly.
|
|
@@ -37,6 +44,37 @@ export interface RenderBadgeOptions {
|
|
|
37
44
|
|
|
38
45
|
const C = 'certrev-badge'
|
|
39
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The payload as it ACTUALLY arrives, not as the contract types promise it.
|
|
49
|
+
*
|
|
50
|
+
* `getVerifiedEnvelope` casts raw JSON (`JSON.parse(value) as CertDeliveryEnvelope`) and
|
|
51
|
+
* `verifyEnvelope` checks the signature, subject, lifecycle and drift — it never asserts the
|
|
52
|
+
* SHAPE of `content`. `renderBadgeHtml` is public API on top of that (the README shows a
|
|
53
|
+
* consumer calling it with a payload of their own), so every field is read as absent-able.
|
|
54
|
+
* The escape/format helpers already coerce a non-string VALUE (`asText`); what no helper can
|
|
55
|
+
* absorb is a missing PARENT object, which is what this view exists for. `cert-modal-view.ts`
|
|
56
|
+
* has read the same data this way since the unified modal landed — two renderers in one package
|
|
57
|
+
* disagreeing
|
|
58
|
+
* about whether the same payload is trusted is itself the defect.
|
|
59
|
+
*
|
|
60
|
+
* The `format.ts` helpers still take the CONTRACT type (`payload?.content`): they are
|
|
61
|
+
* shape-guarded on their own side, so widening them here would only hide that.
|
|
62
|
+
*/
|
|
63
|
+
type Loose<T> = { readonly [K in keyof T]?: T[K] | null }
|
|
64
|
+
type LooseContent = Omit<Loose<CertContent>, 'expert' | 'author'> & {
|
|
65
|
+
readonly expert?: Loose<CertContent['expert']> | null
|
|
66
|
+
readonly author?: Loose<CertContent['author']> | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The badge has exactly two faces, and this is the ONE place that decides which. Anything
|
|
71
|
+
* that is not the compact face renders the full face — so an unvalidated `badgeStyle` (a
|
|
72
|
+
* malformed envelope's number, an untyped caller's typo) can never reach the class name.
|
|
73
|
+
*/
|
|
74
|
+
function badgeFace(style: unknown): 'full' | 'compact' {
|
|
75
|
+
return style === 'compact' ? 'compact' : 'full'
|
|
76
|
+
}
|
|
77
|
+
|
|
40
78
|
function attr(name: string, value: string | null | undefined): string {
|
|
41
79
|
if (value == null || value === '') return ''
|
|
42
80
|
return ` ${name}="${escapeAttribute(value)}"`
|
|
@@ -48,23 +86,26 @@ function attr(name: string, value: string | null | undefined): string {
|
|
|
48
86
|
* already decided to render by the time this is called).
|
|
49
87
|
*/
|
|
50
88
|
export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions = {}): string {
|
|
51
|
-
const content = payload
|
|
52
|
-
const
|
|
89
|
+
const content: LooseContent = payload?.content ?? {}
|
|
90
|
+
const expert = content.expert ?? null
|
|
91
|
+
const display = resolveDisplay(content.display ?? undefined, opts.accentColor, opts.renderDef)
|
|
53
92
|
const theme = resolveRenderTheme(opts.renderDef)
|
|
54
|
-
const style = opts.badgeStyle ?? display.badgeStyle
|
|
93
|
+
const style = badgeFace(opts.badgeStyle ?? display.badgeStyle)
|
|
55
94
|
const accent = safeCssColor(display.accentColor) ?? '#0f766e'
|
|
56
95
|
const verifyUrl = safeHttpUrl(content.verifyUrl)
|
|
57
|
-
const profileUrl = safeHttpUrl(
|
|
58
|
-
const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(
|
|
59
|
-
|
|
96
|
+
const profileUrl = safeHttpUrl(expert?.profileUrl)
|
|
97
|
+
const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(expert?.photoUrl) : null
|
|
98
|
+
// Same composition rule as <CertBadge>: the suffix drops a credential the display name
|
|
99
|
+
// already carries, so the two renderers never disagree about one payload.
|
|
100
|
+
const suffix = dedupeCredential(expert?.displayName, credentialSuffix(payload?.content))
|
|
60
101
|
const certified = formatDate(content.certifiedAt)
|
|
61
102
|
const updated = formatDate(content.contentModifiedAt)
|
|
62
103
|
|
|
63
|
-
const name = `${escapeHtml(
|
|
104
|
+
const name = `${escapeHtml(expert?.displayName)}${
|
|
64
105
|
suffix ? `<span class="${C}__credentials">, ${escapeHtml(suffix)}</span>` : ''
|
|
65
106
|
}`
|
|
66
107
|
const nameNode = profileUrl
|
|
67
|
-
? `<a class="${C}__expert-link" href="${escapeAttribute(profileUrl)}" rel="
|
|
108
|
+
? `<a class="${C}__expert-link" href="${escapeAttribute(profileUrl)}" rel="${CERTREV_LINK_REL}">${name}</a>`
|
|
68
109
|
: name
|
|
69
110
|
|
|
70
111
|
const mark =
|
|
@@ -72,8 +113,10 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
72
113
|
`<circle cx="12" cy="12" r="11" fill="${escapeAttribute(accent)}"></circle>` +
|
|
73
114
|
`<path d="M7 12.5l3.2 3.2L17 9" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></svg>`
|
|
74
115
|
|
|
116
|
+
// alt="" is deliberate: the byline below renders the same name as text, so alt text would make a
|
|
117
|
+
// screen reader announce it twice. Kept in lockstep with <CertBadge> and the modal avatar.
|
|
75
118
|
const photo = photoUrl
|
|
76
|
-
? `<img class="${C}__photo" src="${escapeAttribute(photoUrl)}" alt="
|
|
119
|
+
? `<img class="${C}__photo" src="${escapeAttribute(photoUrl)}" alt="" width="40" height="40" loading="lazy" decoding="async" />`
|
|
77
120
|
: ''
|
|
78
121
|
|
|
79
122
|
const header =
|
|
@@ -88,7 +131,7 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
88
131
|
if (display.showMemo && content.memo) {
|
|
89
132
|
body += `<p class="${C}__memo">${escapeHtml(content.memo)}</p>`
|
|
90
133
|
}
|
|
91
|
-
if (display.showAuthor && content.author
|
|
134
|
+
if (display.showAuthor && content.author?.name && content.author.name !== expert?.displayName) {
|
|
92
135
|
const title = content.author.title ? `, ${escapeHtml(content.author.title)}` : ''
|
|
93
136
|
body += `<p class="${C}__author">Written by ${escapeHtml(content.author.name)}${title}</p>`
|
|
94
137
|
}
|
|
@@ -114,7 +157,7 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
114
157
|
// byte-verbatim COMPENSATED_EXPERT_CUE. The scope line is byte-verbatim
|
|
115
158
|
// CERT_SCOPE_LINE (its curly apostrophe U+2019 survives escapeHtml untouched).
|
|
116
159
|
const cueInner = profileUrl
|
|
117
|
-
? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="
|
|
160
|
+
? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="${CERTREV_LINK_REL}">expert</a>`
|
|
118
161
|
: 'Compensated expert'
|
|
119
162
|
const disclosure =
|
|
120
163
|
`<div class="${C}__disclosure">` +
|
|
@@ -123,11 +166,13 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
123
166
|
`</div>`
|
|
124
167
|
|
|
125
168
|
const verify = verifyUrl
|
|
126
|
-
? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="
|
|
169
|
+
? `<a class="${C}__verify" href="${escapeAttribute(verifyUrl)}" rel="${CERTREV_LINK_REL}" aria-label="Verify this certification on CertREV">Verify on CertREV</a>`
|
|
127
170
|
: ''
|
|
128
171
|
|
|
129
|
-
|
|
130
|
-
//
|
|
172
|
+
// Same derivation <CertBadge> labels with, so the two renderers cannot drift on the one
|
|
173
|
+
// string a screen reader actually announces.
|
|
174
|
+
const ariaLabel = `Content reviewed by ${expertNameWithCredentials(payload?.content)}`
|
|
175
|
+
// Accent LEADS so the un-themed output is byte-identical to the older single-var
|
|
131
176
|
// form. A THEMED brand appends surface/radius/font: the data-bearing tokens ride
|
|
132
177
|
// escaped (a no-op for validated hex/length — defense in depth), the font is a
|
|
133
178
|
// controlled literal stack emitted raw. The whole string is NOT re-escaped
|
|
@@ -139,8 +184,10 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
139
184
|
}
|
|
140
185
|
|
|
141
186
|
return (
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
// `style` is one of two literals by construction (`badgeFace`), so the class name needs
|
|
188
|
+
// no escaping — there is no path by which caller data reaches it.
|
|
189
|
+
`<section class="${C} ${C}--${style}" style="${rootStyle}"` +
|
|
190
|
+
attr('data-certrev-cert-id', payload?.certId) +
|
|
144
191
|
` data-certrev-strings-version="${CERT_STRINGS_VERSION}"` +
|
|
145
192
|
attr('data-certrev-def-version', theme.version != null ? String(theme.version) : null) +
|
|
146
193
|
` aria-label="${escapeAttribute(ariaLabel)}">${header}${body}${disclosure}${verify}</section>`
|