@certrev/cert-block 0.5.4 → 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.
Files changed (189) hide show
  1. package/CHANGELOG.md +195 -12
  2. package/LICENSE +21 -0
  3. package/README.md +422 -50
  4. package/dist/builder/cert-chrome-data.d.ts +26 -8
  5. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  6. package/dist/builder/cert-chrome-data.js +5 -5
  7. package/dist/builder/cert-chrome-data.js.map +1 -1
  8. package/dist/builder/cert-review-card.d.ts +35 -5
  9. package/dist/builder/cert-review-card.d.ts.map +1 -1
  10. package/dist/builder/cert-review-card.js +115 -34
  11. package/dist/builder/cert-review-card.js.map +1 -1
  12. package/dist/builder/index.d.ts +33 -16
  13. package/dist/builder/index.d.ts.map +1 -1
  14. package/dist/builder/index.js +33 -16
  15. package/dist/builder/index.js.map +1 -1
  16. package/dist/components/CertBadge.d.ts +2 -2
  17. package/dist/components/CertBadge.d.ts.map +1 -1
  18. package/dist/components/CertBadge.js +11 -5
  19. package/dist/components/CertBadge.js.map +1 -1
  20. package/dist/components/CertJsonLd.d.ts.map +1 -1
  21. package/dist/components/CertJsonLd.js.map +1 -1
  22. package/dist/components/CertRevBacklink.d.ts.map +1 -1
  23. package/dist/components/CertRevBacklink.js +3 -2
  24. package/dist/components/CertRevBacklink.js.map +1 -1
  25. package/dist/components/CertReview.d.ts +1 -1
  26. package/dist/components/ExpertBio.d.ts.map +1 -1
  27. package/dist/components/ExpertBio.js +12 -4
  28. package/dist/components/ExpertBio.js.map +1 -1
  29. package/dist/components/escape.d.ts +28 -1
  30. package/dist/components/escape.d.ts.map +1 -1
  31. package/dist/components/escape.js +37 -8
  32. package/dist/components/escape.js.map +1 -1
  33. package/dist/components/format.d.ts +51 -2
  34. package/dist/components/format.d.ts.map +1 -1
  35. package/dist/components/format.js +103 -6
  36. package/dist/components/format.js.map +1 -1
  37. package/dist/components/preview-block.d.ts +1 -1
  38. package/dist/components/preview-block.js +3 -3
  39. package/dist/components/preview-block.js.map +1 -1
  40. package/dist/components/rel.d.ts +24 -0
  41. package/dist/components/rel.d.ts.map +1 -0
  42. package/dist/components/rel.js +24 -0
  43. package/dist/components/rel.js.map +1 -0
  44. package/dist/components/render-cert-block.d.ts +50 -22
  45. package/dist/components/render-cert-block.d.ts.map +1 -1
  46. package/dist/components/render-cert-block.js +146 -80
  47. package/dist/components/render-cert-block.js.map +1 -1
  48. package/dist/components/render-def.d.ts +13 -14
  49. package/dist/components/render-def.d.ts.map +1 -1
  50. package/dist/components/render-def.js +14 -15
  51. package/dist/components/render-def.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/jsonld/project.d.ts +46 -13
  57. package/dist/jsonld/project.d.ts.map +1 -1
  58. package/dist/jsonld/project.js +139 -56
  59. package/dist/jsonld/project.js.map +1 -1
  60. package/dist/modal/avatar-resize.d.ts +10 -10
  61. package/dist/modal/avatar-resize.js +14 -14
  62. package/dist/modal/avatar-resize.js.map +1 -1
  63. package/dist/modal/cert-modal-view.d.ts +27 -7
  64. package/dist/modal/cert-modal-view.d.ts.map +1 -1
  65. package/dist/modal/cert-modal-view.js +49 -25
  66. package/dist/modal/cert-modal-view.js.map +1 -1
  67. package/dist/modal/certrev-cert-modal.d.ts +5 -5
  68. package/dist/modal/certrev-cert-modal.js +9 -9
  69. package/dist/modal/certrev-cert-modal.js.map +1 -1
  70. package/dist/modal/certrev-cert-modal.styles.d.ts +6 -6
  71. package/dist/modal/certrev-cert-modal.styles.d.ts.map +1 -1
  72. package/dist/modal/certrev-cert-modal.styles.js +8 -8
  73. package/dist/modal/certrev-cert-modal.styles.js.map +1 -1
  74. package/dist/modal/certrev-cert.entry.d.ts +8 -6
  75. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  76. package/dist/modal/certrev-cert.entry.js +9 -7
  77. package/dist/modal/certrev-cert.entry.js.map +1 -1
  78. package/dist/modal/certrev-cert.js +6 -6
  79. package/dist/modal/display-strings.d.ts +25 -5
  80. package/dist/modal/display-strings.d.ts.map +1 -1
  81. package/dist/modal/display-strings.js +27 -5
  82. package/dist/modal/display-strings.js.map +1 -1
  83. package/dist/modal/escape.d.ts +26 -3
  84. package/dist/modal/escape.d.ts.map +1 -1
  85. package/dist/modal/escape.js +32 -3
  86. package/dist/modal/escape.js.map +1 -1
  87. package/dist/modal/font-face.d.ts.map +1 -1
  88. package/dist/modal/font-face.js +8 -11
  89. package/dist/modal/font-face.js.map +1 -1
  90. package/dist/modal/ftc-disclosure.d.ts +2 -2
  91. package/dist/modal/ftc-disclosure.js +2 -2
  92. package/dist/modal/ftc-guard.d.ts +44 -9
  93. package/dist/modal/ftc-guard.d.ts.map +1 -1
  94. package/dist/modal/ftc-guard.js +50 -17
  95. package/dist/modal/ftc-guard.js.map +1 -1
  96. package/dist/modal/index.d.ts +1 -1
  97. package/dist/modal/index.js +1 -1
  98. package/dist/modal/interactions.d.ts +2 -2
  99. package/dist/modal/interactions.js +2 -2
  100. package/dist/modal/logo-paths.js +2 -2
  101. package/dist/modal/logo-paths.js.map +1 -1
  102. package/dist/modal/placement.d.ts +4 -3
  103. package/dist/modal/placement.d.ts.map +1 -1
  104. package/dist/modal/placement.js +7 -6
  105. package/dist/modal/placement.js.map +1 -1
  106. package/dist/modal/revalidate.d.ts +2 -3
  107. package/dist/modal/revalidate.d.ts.map +1 -1
  108. package/dist/modal/revalidate.js +2 -3
  109. package/dist/modal/revalidate.js.map +1 -1
  110. package/dist/modal/verify-client.d.ts +2 -2
  111. package/dist/modal/verify-client.js +2 -2
  112. package/dist/verify/cache.d.ts +21 -6
  113. package/dist/verify/cache.d.ts.map +1 -1
  114. package/dist/verify/cache.js +29 -8
  115. package/dist/verify/cache.js.map +1 -1
  116. package/dist/verify/fetch-with-deadline.d.ts +35 -0
  117. package/dist/verify/fetch-with-deadline.d.ts.map +1 -0
  118. package/dist/verify/fetch-with-deadline.js +55 -0
  119. package/dist/verify/fetch-with-deadline.js.map +1 -0
  120. package/dist/verify/get-verified-envelope.d.ts +23 -9
  121. package/dist/verify/get-verified-envelope.d.ts.map +1 -1
  122. package/dist/verify/get-verified-envelope.js +89 -40
  123. package/dist/verify/get-verified-envelope.js.map +1 -1
  124. package/dist/verify/resolve-kid.d.ts +3 -0
  125. package/dist/verify/resolve-kid.d.ts.map +1 -1
  126. package/dist/verify/resolve-kid.js +17 -8
  127. package/dist/verify/resolve-kid.js.map +1 -1
  128. package/dist/webcomponent/certrev-badge.d.ts.map +1 -1
  129. package/dist/webcomponent/certrev-badge.js +24 -2
  130. package/dist/webcomponent/certrev-badge.js.map +1 -1
  131. package/dist/webcomponent/render-badge-html.d.ts +1 -1
  132. package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
  133. package/dist/webcomponent/render-badge-html.js +36 -17
  134. package/dist/webcomponent/render-badge-html.js.map +1 -1
  135. package/package.json +14 -2
  136. package/src/builder/cert-chrome-data.ts +26 -8
  137. package/src/builder/cert-review-card.tsx +147 -38
  138. package/src/builder/index.tsx +34 -17
  139. package/src/components/CertBadge.tsx +14 -9
  140. package/src/components/CertJsonLd.tsx +1 -1
  141. package/src/components/CertRevBacklink.tsx +10 -3
  142. package/src/components/CertReview.tsx +1 -1
  143. package/src/components/ExpertBio.tsx +12 -7
  144. package/src/components/escape.ts +37 -7
  145. package/src/components/format.ts +102 -6
  146. package/src/components/preview-block.ts +3 -3
  147. package/src/components/rel.ts +23 -0
  148. package/src/components/render-cert-block.ts +192 -109
  149. package/src/components/render-def.ts +14 -15
  150. package/src/index.ts +4 -2
  151. package/src/jsonld/project.ts +155 -59
  152. package/src/modal/avatar-resize.ts +14 -14
  153. package/src/modal/cert-modal-view.ts +52 -27
  154. package/src/modal/certrev-cert-modal.styles.ts +8 -8
  155. package/src/modal/certrev-cert-modal.ts +9 -9
  156. package/src/modal/certrev-cert.entry.ts +9 -7
  157. package/src/modal/display-strings.ts +26 -5
  158. package/src/modal/escape.ts +32 -4
  159. package/src/modal/font-face.ts +8 -11
  160. package/src/modal/ftc-disclosure.ts +2 -2
  161. package/src/modal/ftc-guard.ts +73 -18
  162. package/src/modal/index.ts +1 -1
  163. package/src/modal/interactions.ts +2 -2
  164. package/src/modal/logo-paths.ts +2 -2
  165. package/src/modal/placement.ts +7 -6
  166. package/src/modal/revalidate.ts +2 -3
  167. package/src/modal/verify-client.ts +2 -2
  168. package/src/verify/cache.ts +34 -8
  169. package/src/verify/fetch-with-deadline.ts +60 -0
  170. package/src/verify/get-verified-envelope.ts +131 -48
  171. package/src/verify/resolve-kid.ts +22 -6
  172. package/src/webcomponent/certrev-badge.ts +24 -2
  173. package/src/webcomponent/render-badge-html.ts +66 -19
  174. package/src/__tests__/__snapshots__/render-cert-block.test.ts.snap +0 -17
  175. package/src/__tests__/components.test.tsx +0 -206
  176. package/src/__tests__/preview-block.test.ts +0 -84
  177. package/src/__tests__/project.test.ts +0 -128
  178. package/src/__tests__/render-cert-block.test.ts +0 -657
  179. package/src/__tests__/render-def.test.tsx +0 -295
  180. package/src/__tests__/verify.test.ts +0 -239
  181. package/src/__tests__/webcomponent.test.tsx +0 -116
  182. package/src/builder/__tests__/builder.test.tsx +0 -158
  183. package/src/modal/cert-modal-view.test.ts +0 -175
  184. package/src/modal/certrev-cert-modal.test.ts +0 -125
  185. package/src/modal/ftc-guard.test.ts +0 -153
  186. package/src/modal/interactions.test.ts +0 -250
  187. package/src/modal/placement.test.ts +0 -398
  188. package/src/modal/revalidate.test.ts +0 -111
  189. 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 { CertDeliveryEnvelope, CertVerdict, RenderContext, ResolvePublicKeyByKid } from '../contract/kernel.js'
22
- import { verifyEnvelope } from '../contract/kernel.js'
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 envelope already read from a Shopify app-owned metafield (or any
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 envelope or its JSON string (metafields store strings). */
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: CertDeliveryEnvelope | string | null | undefined
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: Omit<RenderContext, 'now'> & { readonly now?: Date }
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
- /** Positive verdict TTL ms (default 60_000). */
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
- function parseEnvelope(value: CertDeliveryEnvelope | string | null | undefined): CertDeliveryEnvelope | null {
69
- if (value == null) return null
70
- if (typeof value !== 'string') return value
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 CertDeliveryEnvelope
110
+ return { ok: true, artifact: JSON.parse(value) as CertDeliveryArtifact }
73
111
  } catch {
74
- return null
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: CertDeliveryEnvelope | string | null | undefined): string {
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
- /** Stable cache key per (source identity × render externalId × envelope value). */
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
- if (source.kind === 'delivery_api') {
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
- ): Promise<CertDeliveryEnvelope | null> {
122
- const res = await fetchImpl(deliveryApiUrl(s), { headers: { accept: 'application/json' } })
123
- if (!res.ok) return null // 404 / 410 (revoked, no longer served) → no envelope → suppress
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 CertDeliveryEnvelope
205
+ return { ok: true, artifact: (await res.json()) as CertDeliveryArtifact }
126
206
  } catch {
127
- return null
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
- let envelope: CertDeliveryEnvelope | null
148
- try {
149
- envelope =
150
- opts.source.kind === 'delivery_api'
151
- ? await fetchFromDeliveryApi(opts.source, fetchImpl)
152
- : parseEnvelope(opts.source.value)
153
- } catch {
154
- // Network/JSON failure → fail closed (do NOT render an unverified credential).
155
- return suppress('unsupported_contract_version')
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 verifyEnvelope(envelope, opts.resolveKid, ctx)
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
- /** Expose the shared cache so a revocation webhook handler can invalidate proactively. */
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.delete(cacheKey(source, context))
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
- const res = await fetchImpl(opts.jwksUrl, { headers: { accept: 'application/json' } })
92
- if (!res.ok) return map // empty → every kid resolves null → fail closed
93
- const doc = (await res.json()) as PublishedKeySetDoc
94
- for (const jwk of doc.keys ?? []) {
95
- const input = jwkToInput(jwk)
96
- if (input && jwk.kid) map.set(jwk.kid, input)
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') as 'full' | 'compact' | null) ?? undefined
90
- this.innerHTML = renderBadgeHtml(verdict.payload, { accentColor, badgeStyle })
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 { credentialSuffix, formatDate, resolveDisplay } from '../components/format.js'
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 (WS6). ABSENT → the badge renders the CertREV preset
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.content
52
- const display = resolveDisplay(content.display, opts.accentColor, opts.renderDef)
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(content.expert.profileUrl)
58
- const photoUrl = style === 'full' && display.showExpertPhoto ? safeHttpUrl(content.expert.photoUrl) : null
59
- const suffix = credentialSuffix(content)
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(content.expert.displayName)}${
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="noopener">${name}</a>`
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="Photo of ${escapeAttribute(content.expert.displayName)}" width="40" height="40" loading="lazy" decoding="async" />`
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.name && content.author.name !== content.expert.displayName) {
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="noopener">expert</a>`
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="noopener" aria-label="Verify this certification on CertREV">Verify on CertREV</a>`
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
- const ariaLabel = `Content reviewed by ${content.expert.displayName}${suffix ? `, ${suffix}` : ''}`
130
- // Accent LEADS so the un-themed output is byte-identical to the pre-WS6 single-var
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
- `<section class="${C} ${C}--${escapeAttribute(style)}" style="${rootStyle}"` +
143
- attr('data-certrev-cert-id', payload.certId) +
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>`