@certrev/cert-block 1.0.3 → 1.1.1

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 (88) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +25 -6
  3. package/dist/builder/cert-chrome-data.d.ts +26 -1
  4. package/dist/builder/cert-chrome-data.d.ts.map +1 -1
  5. package/dist/builder/cert-chrome-data.js +1 -0
  6. package/dist/builder/cert-chrome-data.js.map +1 -1
  7. package/dist/builder/cert-review-card.d.ts +35 -2
  8. package/dist/builder/cert-review-card.d.ts.map +1 -1
  9. package/dist/builder/cert-review-card.js +112 -2
  10. package/dist/builder/cert-review-card.js.map +1 -1
  11. package/dist/builder/delivery-runtime.d.ts +23 -0
  12. package/dist/builder/delivery-runtime.d.ts.map +1 -0
  13. package/dist/builder/delivery-runtime.js +38 -0
  14. package/dist/builder/delivery-runtime.js.map +1 -0
  15. package/dist/builder/envelope-face.d.ts +63 -0
  16. package/dist/builder/envelope-face.d.ts.map +1 -0
  17. package/dist/builder/envelope-face.js +151 -0
  18. package/dist/builder/envelope-face.js.map +1 -0
  19. package/dist/builder/index.d.ts +15 -3
  20. package/dist/builder/index.d.ts.map +1 -1
  21. package/dist/builder/index.js +14 -2
  22. package/dist/builder/index.js.map +1 -1
  23. package/dist/builder/issuer-key.d.ts +12 -0
  24. package/dist/builder/issuer-key.d.ts.map +1 -0
  25. package/dist/builder/issuer-key.js +14 -0
  26. package/dist/builder/issuer-key.js.map +1 -0
  27. package/dist/components/memo-toggle.d.ts +42 -0
  28. package/dist/components/memo-toggle.d.ts.map +1 -0
  29. package/dist/components/memo-toggle.js +60 -0
  30. package/dist/components/memo-toggle.js.map +1 -0
  31. package/dist/components/render-cert-block.d.ts +9 -0
  32. package/dist/components/render-cert-block.d.ts.map +1 -1
  33. package/dist/components/render-cert-block.js +83 -25
  34. package/dist/components/render-cert-block.js.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/modal/certrev-cert-modal.d.ts +34 -1
  40. package/dist/modal/certrev-cert-modal.d.ts.map +1 -1
  41. package/dist/modal/certrev-cert-modal.js +40 -2
  42. package/dist/modal/certrev-cert-modal.js.map +1 -1
  43. package/dist/modal/certrev-cert.entry.d.ts.map +1 -1
  44. package/dist/modal/certrev-cert.entry.js +2 -4
  45. package/dist/modal/certrev-cert.entry.js.map +1 -1
  46. package/dist/modal/certrev-cert.js +4 -4
  47. package/dist/modal/deprecated.d.ts +15 -0
  48. package/dist/modal/deprecated.d.ts.map +1 -0
  49. package/dist/modal/deprecated.js +22 -0
  50. package/dist/modal/deprecated.js.map +1 -0
  51. package/dist/modal/ftc-disclosure.d.ts +7 -3
  52. package/dist/modal/ftc-disclosure.d.ts.map +1 -1
  53. package/dist/modal/ftc-disclosure.js +7 -3
  54. package/dist/modal/ftc-disclosure.js.map +1 -1
  55. package/dist/modal/index.d.ts +4 -3
  56. package/dist/modal/index.d.ts.map +1 -1
  57. package/dist/modal/index.js +4 -3
  58. package/dist/modal/index.js.map +1 -1
  59. package/dist/modal/interactions.d.ts +20 -4
  60. package/dist/modal/interactions.d.ts.map +1 -1
  61. package/dist/modal/interactions.js +34 -7
  62. package/dist/modal/interactions.js.map +1 -1
  63. package/dist/verify/get-verified-delivery.d.ts +91 -0
  64. package/dist/verify/get-verified-delivery.d.ts.map +1 -0
  65. package/dist/verify/get-verified-delivery.js +157 -0
  66. package/dist/verify/get-verified-delivery.js.map +1 -0
  67. package/package.json +5 -1
  68. package/src/builder/cert-chrome-data.ts +27 -0
  69. package/src/builder/cert-review-card.tsx +143 -3
  70. package/src/builder/delivery-runtime.ts +54 -0
  71. package/src/builder/envelope-face.ts +173 -0
  72. package/src/builder/index.tsx +23 -1
  73. package/src/builder/issuer-key.ts +13 -0
  74. package/src/components/memo-toggle.ts +63 -0
  75. package/src/components/render-cert-block.ts +124 -28
  76. package/src/index.ts +28 -0
  77. package/src/modal/certrev-cert-modal.ts +45 -2
  78. package/src/modal/certrev-cert.entry.ts +2 -4
  79. package/src/modal/deprecated.ts +25 -0
  80. package/src/modal/ftc-disclosure.ts +7 -3
  81. package/src/modal/index.ts +4 -3
  82. package/src/modal/interactions.ts +37 -7
  83. package/src/verify/get-verified-delivery.ts +211 -0
  84. package/dist/modal/ftc-guard.d.ts +0 -85
  85. package/dist/modal/ftc-guard.d.ts.map +0 -1
  86. package/dist/modal/ftc-guard.js +0 -150
  87. package/dist/modal/ftc-guard.js.map +0 -1
  88. package/src/modal/ftc-guard.ts +0 -174
@@ -0,0 +1,157 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * getVerifiedDelivery — the envelope PLUS the brand's render def, from the Delivery API
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * 1.1.0. The Delivery API answers a Builder placement with the signed artifact and, beside it,
7
+ * the brand's resolved render-def projection as a top-level `renderDef` sibling (the same shape a
8
+ * Shopify store carries in its `render_def` metafield). The envelope's signature covers `payload`
9
+ * only, so the sibling rides the response without touching the signed bytes, and a tombstone
10
+ * carries none. `getVerifiedEnvelope` keeps discarding it; this reads it.
11
+ *
12
+ * The same fetch, deadline, kernel and fail-closed rules as `getVerifiedEnvelope` (every sourcing
13
+ * failure reports the reason it actually is; nothing throws into the render path). Two things are
14
+ * different, and both are the point:
15
+ *
16
+ * 1. WHAT IS CACHED is the cryptographically verified PAYLOAD, not the verdict. The lifecycle
17
+ * policy (revoked, expired) and the subject match are re-run by `renderVerdict` on EVERY call,
18
+ * with the caller's clock. So a payload that was valid when fetched and expires while cached
19
+ * stops rendering at its `expiresAt`, not at the next refetch; and a refetch after the TTL
20
+ * picks up a tombstone (revocation) or a changed def with no write on the brand's side.
21
+ * 2. The CACHE KEY is the Delivery URL plus the rendering placement, so a staging and a
22
+ * production origin never share an entry, and neither do two placements.
23
+ *
24
+ * `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
25
+ * `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
26
+ * keeps a streaming SSR render from suspending forever on a fresh promise every retry).
27
+ */
28
+ import { renderVerdict, verifyArtifact } from '../contract/kernel.js';
29
+ import { TtlCache } from './cache.js';
30
+ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js';
31
+ /** Shared per-process cache: 60s for a verified read, 5s for a suppressed one. */
32
+ const sharedDeliveryCache = new TtlCache({ ttlMs: 60_000, negativeTtlMs: 5_000 });
33
+ function suppressed(reason) {
34
+ return { kind: 'suppressed', reason };
35
+ }
36
+ export function deliveryUrl(baseUrl, platform, externalId) {
37
+ const base = baseUrl.replace(/\/+$/, '');
38
+ return `${base}/api/cert/v1/delivery/${encodeURIComponent(platform)}/${encodeURIComponent(externalId)}`;
39
+ }
40
+ /** Cache key: the URL (origin included) × the placement rendering it. No clock: the TTL bounds time. */
41
+ export function deliveryCacheKey(opts) {
42
+ return `delivery:${deliveryUrl(opts.baseUrl, opts.platform, opts.externalId)}|${encodeURIComponent(opts.context.platform)}:${encodeURIComponent(opts.context.externalId)}`;
43
+ }
44
+ const isString = (v) => typeof v === 'string';
45
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
46
+ /**
47
+ * The def sibling, shape-checked. Anything that is not the metafield shape is null, which paints
48
+ * the preset default (the same thing an absent def paints): a def is presentation, never a claim,
49
+ * so a malformed one degrades the styling and nothing else. The TOKEN VALUES are not judged here;
50
+ * `resolveBlockTheme` re-validates each at paint time and drops what is not a safe value.
51
+ */
52
+ export function parseRenderDef(value) {
53
+ if (!isRecord(value))
54
+ return null;
55
+ const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value;
56
+ if (typeof v !== 'number' || !Number.isFinite(v))
57
+ return null;
58
+ if (!isString(preset) || !isString(rung) || !isRecord(tokens))
59
+ return null;
60
+ if (!Array.isArray(hidden) || !hidden.every(isString))
61
+ return null;
62
+ if (layout !== undefined && !isString(layout))
63
+ return null;
64
+ let face;
65
+ if (customFace !== undefined) {
66
+ if (!isRecord(customFace) || !Array.isArray(customFace.placedFields) || !customFace.placedFields.every(isString)) {
67
+ return null;
68
+ }
69
+ face = { placedFields: [...customFace.placedFields] };
70
+ }
71
+ const tokenOut = {};
72
+ for (const key of ['accentColor', 'surface', 'cornerRadius', 'fontSlot', 'barInk', 'inkColor']) {
73
+ if (isString(tokens[key]))
74
+ tokenOut[key] = tokens[key];
75
+ }
76
+ return {
77
+ v,
78
+ preset,
79
+ rung,
80
+ tokens: tokenOut,
81
+ ...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
82
+ hidden: [...hidden],
83
+ ...(layout !== undefined ? { layout } : {}),
84
+ ...(face ? { customFace: face } : {}),
85
+ };
86
+ }
87
+ /** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
88
+ async function loadDelivery(opts) {
89
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
90
+ let res;
91
+ try {
92
+ res = await fetchWithDeadline(fetchImpl, deliveryUrl(opts.baseUrl, opts.platform, opts.externalId), { headers: { accept: 'application/json' } }, opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS);
93
+ }
94
+ catch {
95
+ // Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
96
+ return suppressed('unsupported_contract_version');
97
+ }
98
+ // Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
99
+ // tombstone, so a 4xx here genuinely means "no credential for this subject".
100
+ if (res.status === 404 || res.status === 410)
101
+ return suppressed('subject_mismatch');
102
+ if (!res.ok)
103
+ return suppressed('unsupported_contract_version');
104
+ let body;
105
+ try {
106
+ body = await res.json();
107
+ }
108
+ catch {
109
+ return suppressed('unsupported_contract_version');
110
+ }
111
+ if (!isRecord(body))
112
+ return suppressed('unsupported_contract_version');
113
+ let verdict;
114
+ try {
115
+ // `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
116
+ // `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
117
+ // part of what is verified, and is never read unless the artifact verified.
118
+ verdict = await verifyArtifact(body, opts.resolveKid, { ...opts.context });
119
+ }
120
+ catch {
121
+ return suppressed('unknown_key');
122
+ }
123
+ if (verdict.decision !== 'render')
124
+ return suppressed(verdict.reason);
125
+ return { kind: 'verified', payload: verdict.payload, renderDef: parseRenderDef(body.renderDef) };
126
+ }
127
+ /** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
128
+ export function settleDelivery(cached, context) {
129
+ if (cached.kind === 'suppressed')
130
+ return { verdict: { decision: 'suppress', reason: cached.reason }, renderDef: null };
131
+ const verdict = renderVerdict(cached.payload, context);
132
+ return { verdict, renderDef: verdict.decision === 'render' ? cached.renderDef : null };
133
+ }
134
+ /**
135
+ * Fetch (if needed), verify, cache, and settle one Delivery read for one placement. FAIL-CLOSED:
136
+ * the verdict is `render` only when the artifact verified against `resolveKid` AND, at
137
+ * `context.now`, the payload is unrevoked, unexpired and for this placement.
138
+ */
139
+ export async function getVerifiedDelivery(opts) {
140
+ const cache = opts.cache ?? sharedDeliveryCache;
141
+ const cached = await cache.getOrLoad(deliveryCacheKey(opts), () => loadDelivery(opts), (v) => v.kind === 'suppressed', opts.ttlMs);
142
+ return settleDelivery(cached, opts.context);
143
+ }
144
+ /**
145
+ * The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
146
+ * caller then awaits `getVerifiedDelivery`). Never fetches.
147
+ */
148
+ export function peekVerifiedDelivery(opts) {
149
+ const cached = (opts.cache ?? sharedDeliveryCache).peek(deliveryCacheKey(opts));
150
+ return cached === undefined ? undefined : settleDelivery(cached, opts.context);
151
+ }
152
+ /** Drop every cached read for one placement (e.g. from a revocation webhook). */
153
+ export function invalidateDelivery(opts, cache = sharedDeliveryCache) {
154
+ cache.delete(deliveryCacheKey(opts));
155
+ }
156
+ export { sharedDeliveryCache };
157
+ //# sourceMappingURL=get-verified-delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"get-verified-delivery.js","sourceRoot":"","sources":["../../src/verify/get-verified-delivery.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@certrev/cert-block",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "description": "Headless render edge for the CertREV CertDeliveryEnvelope: SSR-safe React cert components, a framework-agnostic <certrev-badge> Web Component, a schema.org JSON-LD projector, and a fail-closed verify layer over the shared VerdictKernel.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,6 +23,10 @@
23
23
  "types": "./dist/builder/index.d.ts",
24
24
  "default": "./dist/builder/index.js"
25
25
  },
26
+ "./builder/face": {
27
+ "types": "./dist/builder/envelope-face.d.ts",
28
+ "default": "./dist/builder/envelope-face.js"
29
+ },
26
30
  "./webcomponent": {
27
31
  "types": "./dist/webcomponent/certrev-badge.d.ts",
28
32
  "default": "./dist/webcomponent/certrev-badge.js"
@@ -60,6 +60,25 @@ export interface CertChromeRenderDef {
60
60
  readonly customFace?: { readonly placedFields: readonly string[] }
61
61
  }
62
62
 
63
+ /**
64
+ * The DELIVERY MARKER (1.1.0): the exporter's statement that this block's cert is served,
65
+ * signed, by the CertREV Delivery API at `baseUrl`. When it is present the card no longer
66
+ * paints the other option keys: it takes its `externalId` from its own Builder entry id,
67
+ * fetches `GET {baseUrl}/api/cert/v1/delivery/builder/{externalId}`, verifies the envelope
68
+ * against the baked issuer key, and paints from the verified facts plus the brand's render
69
+ * def served beside them. A revoked, expired or unverifiable cert renders nothing.
70
+ *
71
+ * `baseUrl` is honoured ONLY when it is `https:` on `certrev.com` or a subdomain of it. The
72
+ * marker is an option like any other, so an editor can type into it; a foreign origin would
73
+ * let a page answer its own revocation check, and so renders nothing (never the options).
74
+ */
75
+ export interface CertDeliveryMarker {
76
+ /** Marker shape version. Only `1` is understood; anything else renders nothing. */
77
+ readonly v: 1
78
+ /** The CertREV app origin that serves the Delivery API (e.g. `https://portal.certrev.com`). */
79
+ readonly baseUrl: string
80
+ }
81
+
63
82
  /**
64
83
  * The OPTIONS a brand's native cert component receives. Delivered per certified
65
84
  * article; until the brand registers the component the block renders nothing.
@@ -131,6 +150,13 @@ export interface BuilderCertChromeData {
131
150
  renderDef: CertChromeRenderDef | null
132
151
  /** Version of the delivered string set (`data-certrev-strings-version` for the crawl monitor). */
133
152
  stringsVersion: string
153
+ /**
154
+ * 1.1.0: where the signed envelope for this block is served, or null for an options-only
155
+ * block. Present ⇒ the card paints from the verified envelope + def and ignores the keys
156
+ * above; they stay on the wire so installs older than 1.1.0 keep painting during the
157
+ * transition. See `CertDeliveryMarker`.
158
+ */
159
+ delivery: CertDeliveryMarker | null
134
160
  }
135
161
 
136
162
  /**
@@ -160,6 +186,7 @@ export const BUILDER_CERT_CHROME_KEYS = [
160
186
  'display',
161
187
  'renderDef',
162
188
  'stringsVersion',
189
+ 'delivery',
163
190
  ] as const
164
191
 
165
192
  export type BuilderCertChromeKey = (typeof BUILDER_CERT_CHROME_KEYS)[number]
@@ -18,10 +18,16 @@
18
18
  * - honors `display.showCredentials`.
19
19
  */
20
20
 
21
+ import * as React from 'react'
22
+ import { Suspense } from 'react'
23
+ import { memoToggleFor, toggleMemo } from '../components/memo-toggle.js'
21
24
  import { type CertBlockMode, renderCertBlock } from '../components/render-cert-block.js'
22
- import type { ResolvedBlockTheme } from '../components/render-def.js'
25
+ import { CERT_STRINGS_VERSION, type ResolvedBlockTheme } from '../components/render-def.js'
26
+ import { getVerifiedDelivery, peekVerifiedDelivery, type VerifiedDelivery } from '../verify/get-verified-delivery.js'
23
27
  import type { BuilderCertChromeData } from './cert-chrome-data.js'
24
28
  import { BUILDER_CERT_CHROME_KEYS } from './cert-chrome-data.js'
29
+ import { cardDeliveryRuntime } from './delivery-runtime.js'
30
+ import { envelopeCardInput } from './envelope-face.js'
25
31
 
26
32
  /**
27
33
  * The canonical Builder block name. A brand's registration + the portal
@@ -49,6 +55,13 @@ export interface CertReviewCardProps extends Partial<BuilderCertChromeData> {
49
55
  * string-splitting shim is gone — `renderCertBlock` composes each part directly.
50
56
  */
51
57
  part?: 'full' | 'header' | 'memo' | null
58
+ /**
59
+ * 1.1.0: what `@builder.io/sdk-react` passes because the registration sets
60
+ * `shouldReceiveBuilderProps: { builderContext: true }`. Only `content.id` is read: it is the
61
+ * Builder entry this block renders inside, which IS the Delivery placement's `externalId`, so a
62
+ * block copied onto another entry asks for that entry's cert and never shows this one's.
63
+ */
64
+ builderContext?: { readonly content?: { readonly id?: unknown } | null } | null
52
65
  }
53
66
 
54
67
  /**
@@ -87,7 +100,121 @@ function fusedCredential(pair: BuilderCertChromeData['credentialVerification'] |
87
100
  return credential && verifiedAt ? { credential, verifiedAt } : null
88
101
  }
89
102
 
103
+ /**
104
+ * The delivery marker, accepted only as `{ v: 1, baseUrl }` with `baseUrl` an `https:` ORIGIN on
105
+ * `certrev.com` or one of its subdomains (no path, query, credentials or port). Anything else is
106
+ * null, and a block carrying a marker that is not accepted renders NOTHING: falling back to the
107
+ * options would let an edit to the marker switch revocation off.
108
+ */
109
+ export function acceptedDeliveryMarker(value: unknown): { readonly baseUrl: string } | null {
110
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
111
+ const { v, baseUrl } = value as { v?: unknown; baseUrl?: unknown }
112
+ if (v !== 1 || typeof baseUrl !== 'string') return null
113
+ let url: URL
114
+ try {
115
+ url = new URL(baseUrl)
116
+ } catch {
117
+ return null
118
+ }
119
+ if (url.protocol !== 'https:' || url.username || url.password || url.port) return null
120
+ if (url.pathname !== '/' || url.search || url.hash) return null
121
+ const host = url.hostname.toLowerCase()
122
+ if (host !== 'certrev.com' && !host.endsWith('.certrev.com')) return null
123
+ return { baseUrl: url.origin }
124
+ }
125
+
126
+ /**
127
+ * Suspend on a Delivery read. React 19 gets `use` with ONE promise per placement while it is in
128
+ * flight; React 18 (no `use`) gets the promise thrown, the Suspense protocol it supports. Either
129
+ * way the retry finds the settled read in the cache through `peekVerifiedDelivery`, so it never
130
+ * suspends on a second promise.
131
+ */
132
+ const inflightReads = new Map<string, Promise<VerifiedDelivery>>()
133
+ function suspendOn(key: string, start: () => Promise<VerifiedDelivery>): VerifiedDelivery {
134
+ let pending = inflightReads.get(key)
135
+ if (!pending) {
136
+ pending = start().finally(() => inflightReads.delete(key))
137
+ inflightReads.set(key, pending)
138
+ }
139
+ const use = (React as unknown as { use?: <T>(p: Promise<T>) => T }).use
140
+ if (typeof use === 'function') return use(pending)
141
+ throw pending
142
+ }
143
+
144
+ /**
145
+ * The painted envelope face, with the phone memo toggle bound. One native click listener on the
146
+ * wrapper (delegated, so it survives a re-render of the HTML) flips the memo box the button sits
147
+ * in; the button is native, so Enter and Space reach it as clicks. Bound after hydration only: the
148
+ * server HTML is the collapsed face, which is also what a reader without JS keeps (the whole memo
149
+ * is still in the text for a screen reader, and the desktop never clamps it).
150
+ */
151
+ function EnvelopeFace(props: { readonly html: string; readonly defVersion: number | undefined }) {
152
+ const ref = React.useRef<HTMLDivElement>(null)
153
+ React.useEffect(() => {
154
+ const el = ref.current
155
+ if (!el) return
156
+ const onClick = (e: MouseEvent) => {
157
+ const button = memoToggleFor(e.target)
158
+ if (button && el.contains(button)) toggleMemo(button)
159
+ }
160
+ el.addEventListener('click', onClick)
161
+ return () => el.removeEventListener('click', onClick)
162
+ }, [])
163
+ return (
164
+ <div
165
+ ref={ref}
166
+ data-certrev-cert-chrome=""
167
+ // Marks a face painted from the verified envelope (the migration dry-run and crawl monitor
168
+ // tell the two faces apart by it). The strings are the package's locked constants here.
169
+ data-certrev-delivery=""
170
+ data-certrev-strings-version={CERT_STRINGS_VERSION}
171
+ data-certrev-def-version={props.defVersion}
172
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: renderCertBlock is the escaping-safe SSR string renderer
173
+ dangerouslySetInnerHTML={{ __html: props.html }}
174
+ />
175
+ )
176
+ }
177
+
178
+ function EnvelopeCertReviewCard(props: {
179
+ readonly baseUrl: string
180
+ readonly externalId: string
181
+ readonly mode: unknown
182
+ readonly part: unknown
183
+ }) {
184
+ const rt = cardDeliveryRuntime()
185
+ // The clock is read per render, so the lifecycle is judged NOW, not when the envelope was fetched.
186
+ const context = { platform: 'builder', externalId: props.externalId, now: new Date(rt.now()) }
187
+ const read = { baseUrl: props.baseUrl, platform: 'builder', externalId: props.externalId, context, cache: rt.cache }
188
+ const settled =
189
+ peekVerifiedDelivery(read) ??
190
+ suspendOn(`${props.baseUrl}|${props.externalId}`, () =>
191
+ getVerifiedDelivery({ ...read, resolveKid: rt.resolveKid, fetchImpl: rt.fetchImpl(), timeoutMs: rt.timeoutMs }),
192
+ )
193
+ // Revoked, expired, unverifiable, for another entry, or unreachable: nothing at all.
194
+ if (settled.verdict.decision !== 'render') return null
195
+ const input = envelopeCardInput(settled.verdict.payload.content, settled.renderDef, {
196
+ mode: props.mode,
197
+ part: props.part,
198
+ })
199
+ if (!input) return null
200
+ return <EnvelopeFace html={renderCertBlock(input)} defVersion={settled.renderDef ? settled.renderDef.v : undefined} />
201
+ }
202
+
90
203
  export function CertReviewCard(props: CertReviewCardProps) {
204
+ // 1.1.0: a block that carries the delivery marker paints from the signed envelope and the
205
+ // brand's def, never from the options beside it. No marker (every entry exported before 1.1.0)
206
+ // takes the options path below, unchanged from 1.0.3.
207
+ if (props.delivery != null) {
208
+ const marker = acceptedDeliveryMarker(props.delivery)
209
+ const entryId = text(props.builderContext?.content?.id)
210
+ if (!marker || !entryId) return null
211
+ return (
212
+ <Suspense fallback={null}>
213
+ <EnvelopeCertReviewCard baseUrl={marker.baseUrl} externalId={entryId} mode={props.mode} part={props.part} />
214
+ </Suspense>
215
+ )
216
+ }
217
+
91
218
  const reviewerName = text(props.reviewerName)
92
219
  const verifyUrl = text(props.verifyUrl)
93
220
  if (!reviewerName || !verifyUrl) return null // no chrome pre-cert (or an unreadable delivery)
@@ -119,7 +246,7 @@ export function CertReviewCard(props: CertReviewCardProps) {
119
246
  // explicit `mode` and never reaches this line.
120
247
  //
121
248
  // The problem is the entries that already exist. `mode` is a PLACEMENT input, not one of
122
- // the 19 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
249
+ // the 20 wire keys (`BUILDER_CERT_CHROME_KEYS`), and the exporter emits only the wire
123
250
  // projection (`options: { ...certData }`), so every entry exported to date has NO `mode`
124
251
  // key at all. Through 0.5.5 this line read `?? 'banner'` and those entries rendered the
125
252
  // banner face. Removing it in 1.0.0 did not give them the renderer's default in the
@@ -224,6 +351,7 @@ const WIRE_INPUT_TYPE: Record<(typeof BUILDER_CERT_CHROME_KEYS)[number], Builder
224
351
  display: 'object',
225
352
  renderDef: 'object',
226
353
  stringsVersion: 'string',
354
+ delivery: 'object',
227
355
  }
228
356
 
229
357
  /**
@@ -241,10 +369,12 @@ const WIRE_INPUT_HELPER_TEXT: Partial<Record<(typeof BUILDER_CERT_CHROME_KEYS)[n
241
369
  'System-populated by CertREV. Only its PRESENCE is read (absent = a pro-bono reviewer, so no cue renders); the cue itself is a locked constant, so editing this text changes nothing on the page, and clearing it does not remove the disclosure.',
242
370
  scopeLine:
243
371
  'System-populated by CertREV. The FTC scope line renders byte-verbatim from a locked constant: editing this text changes nothing on the page.',
372
+ delivery:
373
+ 'System-populated by CertREV. When set, the card shows the certificate CertREV serves for this entry, verified, and ignores the other fields; if CertREV revokes it or it expires, the card shows nothing. Do not edit.',
244
374
  }
245
375
 
246
376
  /**
247
- * The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the 19-field
377
+ * The wire-type inputs — DERIVED from `BUILDER_CERT_CHROME_KEYS`, so the 20-field
248
378
  * option set can never drift from the type (the W3 lock asserts name-parity). This
249
379
  * kills the demo's hand-maintained 17-entry array.
250
380
  */
@@ -299,6 +429,8 @@ export interface CertBlockBuilderRegistration {
299
429
  readonly name: string
300
430
  readonly component: typeof CertReviewCard
301
431
  readonly inputs: readonly BuilderInput[]
432
+ /** 1.1.0: asks the SDK for `builderContext`, whose `content.id` the delivery path reads. */
433
+ readonly shouldReceiveBuilderProps: { readonly builderContext: true }
302
434
  }
303
435
 
304
436
  /**
@@ -313,6 +445,7 @@ export const BUILDER_REGISTRATION: CertBlockBuilderRegistration = {
313
445
  name: CERT_COMPONENT_NAME,
314
446
  component: CertReviewCard,
315
447
  inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
448
+ shouldReceiveBuilderProps: { builderContext: true },
316
449
  }
317
450
 
318
451
  /**
@@ -326,6 +459,12 @@ export interface CertReviewCardRegisteredComponent {
326
459
  name: string
327
460
  description?: string
328
461
  inputs: BuilderInput[]
462
+ /**
463
+ * 1.1.0: `@builder.io/sdk-react`'s `RegisteredComponent.shouldReceiveBuilderProps`. The SDK then
464
+ * passes the `builderContext` prop, and the delivery path takes its placement from
465
+ * `builderContext.content.id`.
466
+ */
467
+ shouldReceiveBuilderProps: { builderContext: boolean }
329
468
  }
330
469
 
331
470
  /**
@@ -340,4 +479,5 @@ export const certRevCertComponent: CertReviewCardRegisteredComponent = {
340
479
  description:
341
480
  'CertREV expert-review cert card. Options are populated by the CertREV exporter; editors choose placement (mode/part) only.',
342
481
  inputs: [...PLACEMENT_INPUTS, ...WIRE_INPUTS],
482
+ shouldReceiveBuilderProps: { builderContext: true },
343
483
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The Builder card's Delivery runtime (1.1.0): the trust root, fetch, cache and clock the
3
+ * envelope-backed `CertReviewCard` reads with.
4
+ *
5
+ * INTERNAL. This module is deliberately NOT on the package `exports` map, so a consumer cannot
6
+ * swap the trust root through the published API: the card verifies against the baked
7
+ * `cert-issuer-1` key, the same key the storefront badge bundle ships. The package's own tests
8
+ * reach the setter by relative import to run the real kernel against fixture-signed envelopes.
9
+ */
10
+
11
+ import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
12
+ import { TtlCache } from '../verify/cache.js'
13
+ import type { CachedDelivery } from '../verify/get-verified-delivery.js'
14
+ import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
15
+
16
+ export interface CardDeliveryRuntime {
17
+ readonly resolveKid: ResolvePublicKeyByKid
18
+ readonly fetchImpl: () => typeof fetch
19
+ readonly cache: TtlCache<CachedDelivery>
20
+ readonly now: () => number
21
+ readonly timeoutMs?: number
22
+ }
23
+
24
+ const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
25
+ kid === CERT_ISSUER_KID ? { format: 'pem', pem: CERT_ISSUER_PUBLIC_KEY_PEM } : null
26
+
27
+ function defaultRuntime(): CardDeliveryRuntime {
28
+ return {
29
+ resolveKid: bakedResolveKid,
30
+ fetchImpl: () => globalThis.fetch,
31
+ cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 }),
32
+ now: Date.now,
33
+ }
34
+ }
35
+
36
+ let runtime: CardDeliveryRuntime = defaultRuntime()
37
+
38
+ export function cardDeliveryRuntime(): CardDeliveryRuntime {
39
+ return runtime
40
+ }
41
+
42
+ /** Test seam: replace parts of the runtime (a fresh cache unless one is given). Returns a restore. */
43
+ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
44
+ const previous = runtime
45
+ const now = overrides.now ?? previous.now
46
+ runtime = {
47
+ ...previous,
48
+ cache: new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000, now }),
49
+ ...overrides,
50
+ }
51
+ return () => {
52
+ runtime = previous
53
+ }
54
+ }