@certrev/cert-block 2.0.0 → 2.0.2
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 +110 -0
- package/README.md +50 -10
- package/dist/builder/card-beacon.d.ts +73 -0
- package/dist/builder/card-beacon.d.ts.map +1 -0
- package/dist/builder/card-beacon.js +178 -0
- package/dist/builder/card-beacon.js.map +1 -0
- package/dist/builder/card-contract.d.ts +10 -2
- package/dist/builder/card-contract.d.ts.map +1 -1
- package/dist/builder/card-contract.js +27 -3
- package/dist/builder/card-contract.js.map +1 -1
- package/dist/builder/card-placement.d.ts +45 -0
- package/dist/builder/card-placement.d.ts.map +1 -0
- package/dist/builder/card-placement.js +193 -0
- package/dist/builder/card-placement.js.map +1 -0
- package/dist/builder/card-report.d.ts +26 -14
- package/dist/builder/card-report.d.ts.map +1 -1
- package/dist/builder/card-report.js +68 -14
- package/dist/builder/card-report.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +6 -4
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +172 -199
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/contract-card.d.ts +18 -1
- package/dist/builder/contract-card.d.ts.map +1 -1
- package/dist/builder/contract-card.js +60 -10
- package/dist/builder/contract-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +38 -4
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +43 -5
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/index.d.ts +4 -4
- package/dist/builder/index.js +4 -4
- package/dist/builder/ssr-seed.d.ts +5 -4
- package/dist/builder/ssr-seed.d.ts.map +1 -1
- package/dist/builder/ssr-seed.js +4 -3
- package/dist/builder/ssr-seed.js.map +1 -1
- package/dist/builder/version.d.ts +3 -0
- package/dist/builder/version.d.ts.map +1 -0
- package/dist/builder/version.js +3 -0
- package/dist/builder/version.js.map +1 -0
- package/dist/verify/cache.d.ts +24 -0
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +78 -6
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +18 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +30 -3
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +1 -1
- package/src/builder/card-beacon.ts +201 -0
- package/src/builder/card-contract.ts +25 -3
- package/src/builder/card-placement.ts +208 -0
- package/src/builder/card-report.ts +79 -14
- package/src/builder/cert-review-card.tsx +208 -238
- package/src/builder/contract-card.tsx +65 -8
- package/src/builder/delivery-runtime.ts +45 -5
- package/src/builder/index.tsx +4 -4
- package/src/builder/ssr-seed.ts +5 -4
- package/src/builder/version.ts +2 -0
- package/src/verify/cache.ts +78 -6
- package/src/verify/get-verified-delivery.ts +40 -3
|
@@ -21,7 +21,18 @@
|
|
|
21
21
|
* injected. Only the block that prints the runtime's tag loads it (`cardCarriesAssets`). The server's HTML carries
|
|
22
22
|
* the same tag in that block's wrapper, which the parser runs; the runtime's init is idempotent, so it starts once.
|
|
23
23
|
* Both run in a layout effect, before the browser paints the inserted card, so a client-mounted card never shows an
|
|
24
|
-
* empty frame.
|
|
24
|
+
* empty frame; the roots are checked after every commit of the block, whatever React wrote. React writes the card only
|
|
25
|
+
* when its HTML changes: the `dangerouslySetInnerHTML` object is kept per string, since React 19 rewrites `innerHTML`
|
|
26
|
+
* for every new object (2.0.0 on React 19 rebuilt the card on each re-render, and the hosted runtime re-attached it).
|
|
27
|
+
*
|
|
28
|
+
* NO CONSOLE NOISE (2.0.1). In a browser that parses declarative roots, the HTML React itself writes names each
|
|
29
|
+
* template's mode `data-certrev-shadowrootmode` instead of `shadowrootmode` (`clientCardHtml`): Chrome warns ("Found
|
|
30
|
+
* declarative shadowrootmode attribute on a template, but declarative Shadow DOM is not being parsed") for every such
|
|
31
|
+
* template `innerHTML` meets, and the layout effect re-parses the card's exact bytes anyway. The wrapper carries
|
|
32
|
+
* `suppressHydrationWarning` (React never prints it): the server's wrapper holds attached roots where React expects
|
|
33
|
+
* templates, which React's development build reported as a hydration mismatch on every page. React does not patch a
|
|
34
|
+
* hydrated element's HTML, so the server's card stays as the parser built it. A server, and a DOM without declarative
|
|
35
|
+
* roots (jsdom, an old browser), print the card's bytes unchanged.
|
|
25
36
|
*
|
|
26
37
|
* ONE BUILDER RULE, for the sidebar card only. The card's rail is a right float that the article's copy flows around
|
|
27
38
|
* (the Appearance preview's layout, and every other CMS's). On a Builder page the block sits in Builder's own wrapper,
|
|
@@ -34,7 +45,9 @@
|
|
|
34
45
|
*/
|
|
35
46
|
|
|
36
47
|
import * as React from 'react'
|
|
48
|
+
import { maybeSendBrowserHealth } from './card-beacon.js'
|
|
37
49
|
import { type CardDocumentV1, type CardPartName, cardCarriesAssets, cardHtml } from './card-contract.js'
|
|
50
|
+
import { afterPageSettles, applyCardPlacement } from './card-placement.js'
|
|
38
51
|
import { SEED_ATTRIBUTE } from './ssr-seed.js'
|
|
39
52
|
|
|
40
53
|
/** The global the hosted runtime sets once it has started (`Symbol.for('certrev.card-runtime')` on `document`). */
|
|
@@ -45,18 +58,36 @@ const INJECTED_RUNTIME_ATTRIBUTE = 'data-certrev-card-runtime'
|
|
|
45
58
|
/** A layout effect in a browser; nothing on a server (where React would only warn about it). */
|
|
46
59
|
const useBrowserLayoutEffect = typeof document === 'undefined' ? () => {} : React.useLayoutEffect
|
|
47
60
|
|
|
61
|
+
/** What a client render prints in place of a template's `shadowrootmode` (the header comment). */
|
|
62
|
+
export const INERT_MODE_ATTRIBUTE = 'data-certrev-shadowrootmode'
|
|
63
|
+
|
|
64
|
+
/** Whether this DOM parses declarative shadow roots (a browser does; a server and jsdom do not). */
|
|
65
|
+
function parsesDeclarativeRoots(): boolean {
|
|
66
|
+
return typeof HTMLTemplateElement === 'function' && 'shadowRootMode' in HTMLTemplateElement.prototype
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The card's HTML as React writes it in such a browser: each template's mode under `INERT_MODE_ATTRIBUTE`. */
|
|
70
|
+
export function clientCardHtml(html: string): string {
|
|
71
|
+
return html.replace(/<template\b([^>]*?)\sshadowrootmode=/gi, `<template$1 ${INERT_MODE_ATTRIBUTE}=`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The template's declared mode, under either name. */
|
|
75
|
+
function declaredMode(template: Element): string | null {
|
|
76
|
+
return template.getAttribute('shadowrootmode') ?? template.getAttribute(INERT_MODE_ATTRIBUTE)
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
/** Whether this host still holds an unattached declarative root. */
|
|
49
80
|
function pendingTemplate(host: Element): HTMLTemplateElement | null {
|
|
50
81
|
if ((host as HTMLElement).shadowRoot) return null
|
|
51
82
|
for (const child of Array.from(host.children)) {
|
|
52
|
-
if (child instanceof HTMLTemplateElement && child
|
|
83
|
+
if (child instanceof HTMLTemplateElement && declaredMode(child) !== null) return child
|
|
53
84
|
}
|
|
54
85
|
return null
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
/** Attach one host's root from its template, as the parser would have: its mode, its options, its content. */
|
|
58
89
|
function attachFromTemplate(host: HTMLElement, template: HTMLTemplateElement): void {
|
|
59
|
-
const mode = template
|
|
90
|
+
const mode = declaredMode(template) === 'closed' ? 'closed' : 'open'
|
|
60
91
|
const init: ShadowRootInit & { clonable?: boolean; serializable?: boolean } = {
|
|
61
92
|
mode,
|
|
62
93
|
delegatesFocus: template.hasAttribute('shadowrootdelegatesfocus'),
|
|
@@ -119,22 +150,47 @@ export const BUILDER_CARD_FLOW_CSS =
|
|
|
119
150
|
'.builder-blocks > .builder-block > [data-certrev-face="card"]:has(> certrev-card[data-certrev-part="rail"]){display:block !important}' +
|
|
120
151
|
'}'
|
|
121
152
|
|
|
153
|
+
/** One object for the flow rule's element, so React 19 never rewrites it on a re-render. */
|
|
154
|
+
const BUILDER_CARD_FLOW_HTML = { __html: BUILDER_CARD_FLOW_CSS }
|
|
155
|
+
|
|
122
156
|
export function ContractCardFace(props: {
|
|
123
157
|
readonly card: CardDocumentV1
|
|
124
158
|
readonly names: readonly CardPartName[]
|
|
125
159
|
/** The record that names this card (`ssr-seed.ts`): the browser repaints it from here with no read. */
|
|
126
160
|
readonly seed: string
|
|
161
|
+
/** 2.0.1: the delivery origin the block read, where its sampled health beacon goes (`card-beacon.ts`). */
|
|
162
|
+
readonly origin?: string
|
|
127
163
|
}) {
|
|
128
164
|
const ref = React.useRef<HTMLDivElement>(null)
|
|
129
165
|
const html = cardHtml(props.card, props.names)
|
|
166
|
+
const printed = typeof document !== 'undefined' && parsesDeclarativeRoots() ? clientCardHtml(html) : html
|
|
167
|
+
// One object per printed string: React 19 rewrites `innerHTML` whenever this prop is a new object (React 18 compared
|
|
168
|
+
// the strings), which would replace the attached card on every re-render.
|
|
169
|
+
const inner = React.useMemo(() => ({ __html: printed }), [printed])
|
|
130
170
|
const { card } = props
|
|
131
171
|
const carriesAssets = cardCarriesAssets(card, props.names)
|
|
172
|
+
// After every commit of this block (a no-op while every root is attached): whatever React wrote, the card has its roots
|
|
173
|
+
// before the browser paints.
|
|
174
|
+
useBrowserLayoutEffect(() => {
|
|
175
|
+
if (ref.current) attachCardRoots(ref.current, html)
|
|
176
|
+
})
|
|
132
177
|
useBrowserLayoutEffect(() => {
|
|
178
|
+
if (ref.current && carriesAssets) injectCardRuntime(card)
|
|
179
|
+
}, [card, carriesAssets])
|
|
180
|
+
const { origin } = props
|
|
181
|
+
React.useEffect(() => {
|
|
182
|
+
maybeSendBrowserHealth(origin, card.subject.externalId)
|
|
183
|
+
}, [origin, card])
|
|
184
|
+
// 2.0.1: the document's placement hints, once the page has settled (`card-placement.ts`). Keyed on the printed HTML,
|
|
185
|
+
// which the document and the parts decide, so a re-render that prints the same card moves nothing again.
|
|
186
|
+
const placing = React.useRef({ card, names: props.names })
|
|
187
|
+
placing.current = { card, names: props.names }
|
|
188
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `html` is the trigger on purpose; the document and parts are read from the ref
|
|
189
|
+
React.useEffect(() => {
|
|
133
190
|
const wrapper = ref.current
|
|
134
191
|
if (!wrapper) return
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}, [html, card, carriesAssets])
|
|
192
|
+
return afterPageSettles(() => applyCardPlacement(wrapper, placing.current.card, placing.current.names))
|
|
193
|
+
}, [html])
|
|
138
194
|
const wrapper = (
|
|
139
195
|
<div
|
|
140
196
|
ref={ref}
|
|
@@ -144,8 +200,9 @@ export function ContractCardFace(props: {
|
|
|
144
200
|
data-certrev-contract={String(props.card.contract)}
|
|
145
201
|
data-certrev-def-version={props.card.defVersion}
|
|
146
202
|
{...{ [SEED_ATTRIBUTE]: props.seed }}
|
|
203
|
+
suppressHydrationWarning
|
|
147
204
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: CertREV's card document, printed only after it hashes to the digest the verified envelope signs
|
|
148
|
-
dangerouslySetInnerHTML={
|
|
205
|
+
dangerouslySetInnerHTML={inner}
|
|
149
206
|
/>
|
|
150
207
|
)
|
|
151
208
|
if (!props.names.includes('rail')) return wrapper
|
|
@@ -153,7 +210,7 @@ export function ContractCardFace(props: {
|
|
|
153
210
|
<>
|
|
154
211
|
{wrapper}
|
|
155
212
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: a package constant; React would escape the `>` and `"` of its selectors */}
|
|
156
|
-
<style data-certrev-builder-flow="" dangerouslySetInnerHTML={
|
|
213
|
+
<style data-certrev-builder-flow="" dangerouslySetInnerHTML={BUILDER_CARD_FLOW_HTML} />
|
|
157
214
|
</>
|
|
158
215
|
)
|
|
159
216
|
}
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* A revocation still lands on the first render after its refresh does. The tests' runtime gets
|
|
15
15
|
* the same cache shape, so they exercise what production runs.
|
|
16
16
|
*
|
|
17
|
+
* 2.0.2: a server render waits at most `CARD_SERVER_WAIT_MS` (300 ms) for a cold read, never joins another request's read
|
|
18
|
+
* in flight, and a failure is remembered `CARD_DELIVERY_FAILURE_MS` (60 s) per placement, so a hanging CertREV costs a
|
|
19
|
+
* brand's page at most one short wait per placement per minute (2.0.1: 8 s, every 5 s).
|
|
20
|
+
*
|
|
17
21
|
* 2.0.0: the trust root is a key SET (`verify/key-set.ts`). `resolveKid` is still the baked set (today
|
|
18
22
|
* `cert-issuer-1`), and the card verifies with `resolveKidFor(baseUrl)`: a baked kid resolves as before, any other kid
|
|
19
23
|
* makes one read of the delivery origin's published key set, accepted only when a baked kid signs it. A rotation then
|
|
@@ -24,16 +28,44 @@ import type { ResolvePublicKeyByKid } from '../contract/kernel.js'
|
|
|
24
28
|
import { TtlCache } from '../verify/cache.js'
|
|
25
29
|
import type { CachedDelivery } from '../verify/get-verified-delivery.js'
|
|
26
30
|
import { forgetKeySetsForTests, keySetResolver } from '../verify/key-set.js'
|
|
31
|
+
import { setCardBeaconForTests } from './card-beacon.js'
|
|
27
32
|
import { forgetCardDigestsForTests } from './card-contract.js'
|
|
28
33
|
import { forgetCardReportsForTests } from './card-report.js'
|
|
29
34
|
import { forgetFaceHashesForTests } from './delivered-face.js'
|
|
30
35
|
import { CERT_ISSUER_KID, CERT_ISSUER_PUBLIC_KEY_PEM } from './issuer-key.js'
|
|
31
36
|
import { forgetPageSeedsForTests } from './ssr-seed.js'
|
|
32
37
|
|
|
33
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The card's fetch deadline: how long a read of the Delivery API may run (2.0.2: in the background, past the render; a
|
|
40
|
+
* browser read under `clientRead`). Not how long a server render waits: that is `CARD_SERVER_WAIT_MS`.
|
|
41
|
+
*/
|
|
34
42
|
export const CARD_DELIVERY_TIMEOUT_MS = 8_000
|
|
35
|
-
/**
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 2.0.2: the longest a SERVER render waits for a placement's read (2.0.1 waited the whole `CARD_DELIVERY_TIMEOUT_MS`,
|
|
45
|
+
* 8 s). Past it the render prints nothing (`delivery_unreachable`), the placement's failure is remembered
|
|
46
|
+
* (`CARD_DELIVERY_FAILURE_MS`), and the read runs on: when it lands it is cached for the next render. 300 ms covers the
|
|
47
|
+
* Delivery API's p90 function time (221 ms over 30 days of production, n = 21,368 CDN misses; 93% <= 300 ms) and a
|
|
48
|
+
* cached edge answer (~50 ms) plus a round trip; see the CHANGELOG.
|
|
49
|
+
*/
|
|
50
|
+
export const CARD_SERVER_WAIT_MS = 300
|
|
51
|
+
/**
|
|
52
|
+
* 2.0.2: how long a placement's failure (a timeout, a network error, a 5xx, a render that stopped waiting) is
|
|
53
|
+
* remembered per process. Inside it, no read of the placement starts: a render with no cached card prints nothing at
|
|
54
|
+
* once, and a stale card paints without a refresh. So an ongoing outage costs at most one short wait per placement per
|
|
55
|
+
* minute. (2.0.1: a failure was remembered 5 s.)
|
|
56
|
+
*/
|
|
57
|
+
export const CARD_DELIVERY_FAILURE_MS = 60_000
|
|
58
|
+
/**
|
|
59
|
+
* 2.0.1: how long a verified answer is fresh. With the stale window below, a card adds at most one minute to the Delivery
|
|
60
|
+
* API's own cache (`max-age=60, stale-while-revalidate=240`), so a revoked card is gone within ADR 0001 §7's five to
|
|
61
|
+
* six minutes (2.0.0's 60 s + 300 s here stacked to about twelve).
|
|
62
|
+
*/
|
|
63
|
+
export const CARD_DELIVERY_TTL_MS = 30_000
|
|
64
|
+
/**
|
|
65
|
+
* How long past its TTL a certificate may still paint while it is refreshed or CertREV is down (2.0.1: 30 s; 2.0.0:
|
|
66
|
+
* 300 s). Never longer: past it, a read that cannot reach CertREV prints nothing.
|
|
67
|
+
*/
|
|
68
|
+
export const CARD_DELIVERY_STALE_MS = 30_000
|
|
37
69
|
|
|
38
70
|
export interface CardDeliveryRuntime {
|
|
39
71
|
/** The baked kids: the package's own trust root (and the only keys that may sign a published key set). */
|
|
@@ -47,6 +79,8 @@ export interface CardDeliveryRuntime {
|
|
|
47
79
|
readonly cache: TtlCache<CachedDelivery>
|
|
48
80
|
readonly now: () => number
|
|
49
81
|
readonly timeoutMs?: number
|
|
82
|
+
/** 2.0.2: the longest a server render waits for a read (`CARD_SERVER_WAIT_MS`). */
|
|
83
|
+
readonly serverWaitMs?: number
|
|
50
84
|
}
|
|
51
85
|
|
|
52
86
|
const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
|
|
@@ -54,9 +88,11 @@ const bakedResolveKid: ResolvePublicKeyByKid = (kid) =>
|
|
|
54
88
|
|
|
55
89
|
function newCardCache(now?: () => number): TtlCache<CachedDelivery> {
|
|
56
90
|
return new TtlCache<CachedDelivery>({
|
|
57
|
-
ttlMs:
|
|
91
|
+
ttlMs: CARD_DELIVERY_TTL_MS,
|
|
58
92
|
negativeTtlMs: 5_000,
|
|
59
93
|
staleMs: CARD_DELIVERY_STALE_MS,
|
|
94
|
+
// 2.0.2: on a server only. A browser read (`clientRead`) holds no page, and keeps 2.0.1's 5 s (break-it B1).
|
|
95
|
+
...(typeof document === 'undefined' ? { failureTtlMs: CARD_DELIVERY_FAILURE_MS } : {}),
|
|
60
96
|
// A refresh the host runtime abandoned (workerd cancels a finished request's I/O) never
|
|
61
97
|
// settles; after the deadline plus a margin, the next render starts a new one.
|
|
62
98
|
inflightMaxMs: CARD_DELIVERY_TIMEOUT_MS + 2_000,
|
|
@@ -71,6 +107,7 @@ function defaultRuntime(): CardDeliveryRuntime {
|
|
|
71
107
|
cache: newCardCache(),
|
|
72
108
|
now: Date.now,
|
|
73
109
|
timeoutMs: CARD_DELIVERY_TIMEOUT_MS,
|
|
110
|
+
serverWaitMs: CARD_SERVER_WAIT_MS,
|
|
74
111
|
}
|
|
75
112
|
}
|
|
76
113
|
|
|
@@ -94,7 +131,8 @@ export function cardResolveKidFor(rt: CardDeliveryRuntime, baseUrl: string): Res
|
|
|
94
131
|
/**
|
|
95
132
|
* Test seam: replace parts of the runtime (a fresh cache unless one is given) and forget the page's envelope seeds
|
|
96
133
|
* (a fresh runtime is a fresh page), (1.3.0) every delivered face's memoized hashes and (2.0.0) every card
|
|
97
|
-
* document's digest and every published key set.
|
|
134
|
+
* document's digest and every published key set. 2.0.1: beacons go nowhere (a test that wants them sets its own
|
|
135
|
+
* transport with `setCardBeaconForTests` after this). Returns a restore.
|
|
98
136
|
*/
|
|
99
137
|
export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRuntime>): () => void {
|
|
100
138
|
forgetPageSeedsForTests()
|
|
@@ -102,6 +140,7 @@ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRu
|
|
|
102
140
|
forgetCardDigestsForTests()
|
|
103
141
|
forgetKeySetsForTests()
|
|
104
142
|
forgetCardReportsForTests()
|
|
143
|
+
const restoreBeacon = setCardBeaconForTests()
|
|
105
144
|
const previous = runtime
|
|
106
145
|
const now = overrides.now ?? previous.now
|
|
107
146
|
runtime = {
|
|
@@ -111,5 +150,6 @@ export function setCardDeliveryRuntimeForTests(overrides: Partial<CardDeliveryRu
|
|
|
111
150
|
}
|
|
112
151
|
return () => {
|
|
113
152
|
runtime = previous
|
|
153
|
+
restoreBeacon()
|
|
114
154
|
}
|
|
115
155
|
}
|
package/src/builder/index.tsx
CHANGED
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
* (`face: { html, css }`, unsigned) and the verified envelope signs that face's sha256 digests for the
|
|
32
32
|
* def version served (`content.faceDigest`), an unpinned whole-card block paints that face instead;
|
|
33
33
|
* markup that does not hash to the signed digest is never painted. A revoked, expired, unverifiable or unreachable cert, a marker that
|
|
34
|
-
* is not exactly that shape, or a missing entry id renders nothing.
|
|
35
|
-
* (every entry exported before 1.1.0) renders
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* is not exactly that shape, or a missing entry id renders nothing. 2.0.1: a block WITHOUT the marker
|
|
35
|
+
* (every entry exported before 1.1.0) renders nothing too, and reports `no_marker`: through 2.0.0 it
|
|
36
|
+
* painted its options as 1.0.3 did, unsigned export-time claims an editor could change. So deleting the
|
|
37
|
+
* marker no longer brings any face back.
|
|
38
38
|
*
|
|
39
39
|
* Three things the card DOES enforce, and they are the limit of it: no chrome at all without a
|
|
40
40
|
* `reviewerName` and a `verifyUrl`; a credential renders only welded to its dated verification
|
package/src/builder/ssr-seed.ts
CHANGED
|
@@ -41,9 +41,10 @@
|
|
|
41
41
|
* page carried for another version.
|
|
42
42
|
*
|
|
43
43
|
* WHEN THE SERVER HAD NO ENVELOPE TO PAINT (1.2.0) the same attribute carries a fallback record instead of a seed:
|
|
44
|
-
* `{ v: 1, baseUrl, externalId, design?, face }`, where `face` is `'
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* `{ v: 1, baseUrl, externalId, design?, face, reason? }`, where `face` is `'none'` (the server painted nothing:
|
|
45
|
+
* revoked, expired, unverifiable, for another entry, or, 2.0.1, CertREV not reached, with `reason` naming why) or, on a
|
|
46
|
+
* page a 2.0.0 server rendered, `'options'` (CertREV could not be reached and that server painted the block's options
|
|
47
|
+
* face; 2.0.1 prints nothing for it). A browser that renders the card again (hydration, or React's client re-render after a hydration
|
|
47
48
|
* error elsewhere on the page) paints what the record says, so it neither reads the Delivery API nor shows a card the
|
|
48
49
|
* server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
|
|
49
50
|
* `hidden` element with nothing in it.
|
|
@@ -114,7 +115,7 @@ export interface PageSeed {
|
|
|
114
115
|
readonly raw: string
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
/** What the server painted with no verified envelope:
|
|
118
|
+
/** What the server painted with no verified envelope: nothing (2.0.1 never writes `'options'`; a 2.0.0 page may hold one). */
|
|
118
119
|
export type FallbackFace = 'options' | 'none'
|
|
119
120
|
|
|
120
121
|
/** The record a server writes where it had no envelope to paint (the file header). */
|
package/src/verify/cache.ts
CHANGED
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* down costs the page nothing it already had. A definitive answer (a revocation, say)
|
|
19
19
|
* replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
|
|
20
20
|
* expired entry is gone, exactly as before 1.2.0.
|
|
21
|
+
* 5. (2.0.2, opt-in through `failureTtlMs`) FAILURE MEMORY: a transient result (`isTransient`: the origin was not
|
|
22
|
+
* reached) is remembered in a slot of its own for `failureTtlMs`, beside a positive entry and never over it. While
|
|
23
|
+
* it is remembered nothing loads for the key: a read with no servable entry gets the failure at once, and a stale
|
|
24
|
+
* entry is served without starting a refresh. A definitive answer clears it. So an ongoing outage costs a caller at
|
|
25
|
+
* most one load per key per failure window. `markFailed` lets a caller that stopped waiting (a render deadline)
|
|
26
|
+
* remember one before the load itself settles. Without `failureTtlMs` the cache behaves exactly as 2.0.1's.
|
|
21
27
|
*
|
|
22
28
|
* It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
|
|
23
29
|
* runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
|
|
@@ -44,6 +50,11 @@ export interface TtlCacheOptions {
|
|
|
44
50
|
* it and none would ever refresh again.
|
|
45
51
|
*/
|
|
46
52
|
readonly inflightMaxMs?: number
|
|
53
|
+
/**
|
|
54
|
+
* 2.0.2: how long a transient result (`isTransient`) is remembered, in a slot of its own (default: none, and a transient
|
|
55
|
+
* result is cached as a negative entry as before). See the module header, point 5.
|
|
56
|
+
*/
|
|
57
|
+
readonly failureTtlMs?: number
|
|
47
58
|
/** Injectable clock for deterministic tests. */
|
|
48
59
|
readonly now?: () => number
|
|
49
60
|
}
|
|
@@ -57,16 +68,19 @@ interface Entry<V> {
|
|
|
57
68
|
interface Inflight<V> {
|
|
58
69
|
readonly promise: Promise<V>
|
|
59
70
|
readonly startedAt: number
|
|
71
|
+
readonly token: object
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
export class TtlCache<V> {
|
|
63
75
|
private readonly store = new Map<string, Entry<V>>()
|
|
64
76
|
private readonly inflight = new Map<string, Inflight<V>>()
|
|
77
|
+
private readonly failures = new Map<string, { readonly value: V; readonly until: number }>()
|
|
65
78
|
private readonly ttlMs: number
|
|
66
79
|
private readonly negativeTtlMs: number
|
|
67
80
|
private readonly maxEntries: number
|
|
68
81
|
private readonly staleMs: number
|
|
69
82
|
private readonly inflightMaxMs: number
|
|
83
|
+
private readonly failureTtlMs: number
|
|
70
84
|
private readonly now: () => number
|
|
71
85
|
|
|
72
86
|
constructor(opts: TtlCacheOptions = {}) {
|
|
@@ -75,6 +89,7 @@ export class TtlCache<V> {
|
|
|
75
89
|
this.maxEntries = opts.maxEntries ?? 1000
|
|
76
90
|
this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
|
|
77
91
|
this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
|
|
92
|
+
this.failureTtlMs = opts.failureTtlMs !== undefined && opts.failureTtlMs > 0 ? opts.failureTtlMs : 0
|
|
78
93
|
this.now = opts.now ?? Date.now
|
|
79
94
|
}
|
|
80
95
|
|
|
@@ -89,10 +104,20 @@ export class TtlCache<V> {
|
|
|
89
104
|
return undefined
|
|
90
105
|
}
|
|
91
106
|
|
|
107
|
+
/** 2.0.2: the remembered failure while it lasts (only with `failureTtlMs`); prunes it past that. */
|
|
108
|
+
private failure(key: string): { readonly value: V } | undefined {
|
|
109
|
+
const f = this.failures.get(key)
|
|
110
|
+
if (!f) return undefined
|
|
111
|
+
if (f.until > this.now()) return f
|
|
112
|
+
this.failures.delete(key)
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
/** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
|
|
93
117
|
peek(key: string): V | undefined {
|
|
94
118
|
const e = this.servable(key)
|
|
95
|
-
|
|
119
|
+
if (e && e.expiresAt > this.now()) return e.value
|
|
120
|
+
return this.failure(key)?.value
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
/**
|
|
@@ -109,8 +134,10 @@ export class TtlCache<V> {
|
|
|
109
134
|
isTransient?: (v: V) => boolean,
|
|
110
135
|
): V | undefined {
|
|
111
136
|
const e = this.servable(key)
|
|
112
|
-
|
|
113
|
-
if (e
|
|
137
|
+
const failed = this.failure(key)
|
|
138
|
+
if (!e) return failed?.value
|
|
139
|
+
// 2.0.2: no refresh while a failure is remembered; the stale value is still served.
|
|
140
|
+
if (e.expiresAt <= this.now() && !failed) {
|
|
114
141
|
this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
|
|
115
142
|
}
|
|
116
143
|
return e.value
|
|
@@ -158,9 +185,20 @@ export class TtlCache<V> {
|
|
|
158
185
|
const existing = this.inflight.get(key)
|
|
159
186
|
if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
|
|
160
187
|
|
|
188
|
+
const token = {}
|
|
161
189
|
const promise: Promise<V> = (async () => {
|
|
162
190
|
const value = await loader()
|
|
163
|
-
|
|
191
|
+
// 2.0.2: a load that was replaced (abandoned past `inflightMaxMs`) never writes: its answer may be older than
|
|
192
|
+
// the replacement's, and a late verified read must never overwrite a newer revocation.
|
|
193
|
+
if (this.inflight.get(key)?.token !== token) return value
|
|
194
|
+
if (this.failureTtlMs > 0) {
|
|
195
|
+
if (isTransient?.(value)) {
|
|
196
|
+
this.markFailed(key, value)
|
|
197
|
+
const kept = this.servable(key)
|
|
198
|
+
return kept && !kept.negative ? kept.value : value
|
|
199
|
+
}
|
|
200
|
+
this.failures.delete(key)
|
|
201
|
+
} else if (isTransient?.(value)) {
|
|
164
202
|
const kept = this.servable(key)
|
|
165
203
|
if (kept && !kept.negative) return kept.value
|
|
166
204
|
}
|
|
@@ -169,10 +207,10 @@ export class TtlCache<V> {
|
|
|
169
207
|
return value
|
|
170
208
|
})().finally(() => {
|
|
171
209
|
// Only this load's own slot: a newer load that replaced an abandoned one keeps its place.
|
|
172
|
-
if (this.inflight.get(key)?.
|
|
210
|
+
if (this.inflight.get(key)?.token === token) this.inflight.delete(key)
|
|
173
211
|
})
|
|
174
212
|
|
|
175
|
-
this.inflight.set(key, { promise, startedAt: this.now() })
|
|
213
|
+
this.inflight.set(key, { promise, startedAt: this.now(), token })
|
|
176
214
|
return promise
|
|
177
215
|
}
|
|
178
216
|
|
|
@@ -189,9 +227,39 @@ export class TtlCache<V> {
|
|
|
189
227
|
this.store.set(key, { value, expiresAt: this.now() + ttl, negative })
|
|
190
228
|
}
|
|
191
229
|
|
|
230
|
+
/** 2.0.2: whether a load for `key` is running (and still joinable, younger than `inflightMaxMs`). */
|
|
231
|
+
loading(key: string): boolean {
|
|
232
|
+
const existing = this.inflight.get(key)
|
|
233
|
+
return existing !== undefined && this.now() - existing.startedAt < this.inflightMaxMs
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 2.0.2: remember a transient failure for `key` for `failureTtlMs` from now (a caller that stopped waiting for a load, or
|
|
238
|
+
* a load that settled on one), beside the key's entry: it never touches a positive entry. Without `failureTtlMs` it is
|
|
239
|
+
* a negative entry for the negative TTL, as a transient result was before 2.0.2, and like any `set` it replaces the
|
|
240
|
+
* key's entry.
|
|
241
|
+
*/
|
|
242
|
+
markFailed(key: string, value: V): void {
|
|
243
|
+
if (this.failureTtlMs <= 0) {
|
|
244
|
+
this.set(key, value, true)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
// Bounded like the store (break-it B3): past `maxEntries`, drop the expired failures, then the oldest.
|
|
248
|
+
if (this.failures.size >= this.maxEntries && !this.failures.has(key)) {
|
|
249
|
+
const now = this.now()
|
|
250
|
+
for (const [k, f] of this.failures) if (f.until <= now) this.failures.delete(k)
|
|
251
|
+
if (this.failures.size >= this.maxEntries) {
|
|
252
|
+
const oldest = this.failures.keys().next().value
|
|
253
|
+
if (oldest !== undefined) this.failures.delete(oldest)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.failures.set(key, { value, until: this.now() + this.failureTtlMs })
|
|
257
|
+
}
|
|
258
|
+
|
|
192
259
|
/** Drop a key (e.g. on a known revocation push). */
|
|
193
260
|
delete(key: string): void {
|
|
194
261
|
this.store.delete(key)
|
|
262
|
+
this.failures.delete(key)
|
|
195
263
|
}
|
|
196
264
|
|
|
197
265
|
/**
|
|
@@ -205,11 +273,15 @@ export class TtlCache<V> {
|
|
|
205
273
|
for (const key of this.store.keys()) {
|
|
206
274
|
if (key.startsWith(prefix)) this.store.delete(key)
|
|
207
275
|
}
|
|
276
|
+
for (const key of this.failures.keys()) {
|
|
277
|
+
if (key.startsWith(prefix)) this.failures.delete(key)
|
|
278
|
+
}
|
|
208
279
|
}
|
|
209
280
|
|
|
210
281
|
clear(): void {
|
|
211
282
|
this.store.clear()
|
|
212
283
|
this.inflight.clear()
|
|
284
|
+
this.failures.clear()
|
|
213
285
|
}
|
|
214
286
|
|
|
215
287
|
get size(): number {
|
|
@@ -106,6 +106,8 @@ export type CachedDelivery =
|
|
|
106
106
|
readonly reason: CertSuppressReason
|
|
107
107
|
/** 1.2.0: true when CertREV was not reached (no answer about the credential); see the module header. */
|
|
108
108
|
readonly unreachable?: true
|
|
109
|
+
/** 2.0.1: the HTTP status, when CertREV answered with a non-OK status other than 404/410. */
|
|
110
|
+
readonly status?: number
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
/** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
|
|
@@ -117,6 +119,8 @@ export interface VerifiedDelivery {
|
|
|
117
119
|
readonly signature?: CertSignature
|
|
118
120
|
/** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
|
|
119
121
|
readonly unreachable?: true
|
|
122
|
+
/** 2.0.1: with `unreachable`, the HTTP status when CertREV answered with one (a 5xx, a 429, ...); else undefined. */
|
|
123
|
+
readonly status?: number
|
|
120
124
|
/**
|
|
121
125
|
* 1.3.0: on a `render` verdict, the server-rendered face served beside the artifact, when the response carried a
|
|
122
126
|
* well-formed one; else undefined (never on a suppress verdict). UNVERIFIED: see `DeliveredFace`.
|
|
@@ -175,8 +179,13 @@ function suppressed(reason: CertSuppressReason): CachedDelivery {
|
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
/** A read that did not reach CertREV: the same least-wrong reason as before 1.2.0, marked `unreachable`. */
|
|
178
|
-
function unreachable(): CachedDelivery {
|
|
179
|
-
return {
|
|
182
|
+
function unreachable(status?: number): CachedDelivery {
|
|
183
|
+
return {
|
|
184
|
+
kind: 'suppressed',
|
|
185
|
+
reason: 'unsupported_contract_version',
|
|
186
|
+
unreachable: true,
|
|
187
|
+
...(status === undefined ? {} : { status }),
|
|
188
|
+
}
|
|
180
189
|
}
|
|
181
190
|
|
|
182
191
|
const isUnreachable = (v: CachedDelivery): boolean => v.kind === 'suppressed' && v.unreachable === true
|
|
@@ -312,7 +321,7 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
|
|
|
312
321
|
// Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
|
|
313
322
|
// tombstone, so a 4xx here genuinely means "no credential for this subject".
|
|
314
323
|
if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
|
|
315
|
-
if (!res.ok) return unreachable()
|
|
324
|
+
if (!res.ok) return unreachable(res.status)
|
|
316
325
|
let body: unknown
|
|
317
326
|
try {
|
|
318
327
|
body = await res.json()
|
|
@@ -356,6 +365,7 @@ export function settleDelivery(cached: CachedDelivery, context: RenderContext):
|
|
|
356
365
|
verdict: { decision: 'suppress', reason: cached.reason },
|
|
357
366
|
renderDef: null,
|
|
358
367
|
...(cached.unreachable ? { unreachable: true as const } : {}),
|
|
368
|
+
...(cached.unreachable && cached.status !== undefined ? { status: cached.status } : {}),
|
|
359
369
|
}
|
|
360
370
|
}
|
|
361
371
|
const verdict = renderVerdict(cached.payload, context)
|
|
@@ -417,6 +427,33 @@ export function peekVerifiedDelivery(
|
|
|
417
427
|
return cached === undefined ? undefined : settleDelivery(cached, opts.context)
|
|
418
428
|
}
|
|
419
429
|
|
|
430
|
+
/**
|
|
431
|
+
* 2.0.2: the settled answer for "CertREV was not reached in time": what a server render prints when it stops waiting
|
|
432
|
+
* (its deadline passed, or another request's read of the placement is in flight). Nothing, reported as
|
|
433
|
+
* `delivery_unreachable`, exactly as for a read that failed.
|
|
434
|
+
*/
|
|
435
|
+
export function unreachableDelivery(): VerifiedDelivery {
|
|
436
|
+
return settleDelivery(unreachable(), { platform: 'builder', externalId: '', now: new Date(0) })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 2.0.2: remember that a read of one placement did not reach CertREV in time (a render stopped waiting for it), so the
|
|
441
|
+
* cache answers the placement at once for its failure window. A no-op on a cache with no failure window beyond 2.0.1's
|
|
442
|
+
* negative entry. Never touches a verified entry.
|
|
443
|
+
*/
|
|
444
|
+
export function rememberDeliveryUnreachable(
|
|
445
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
|
|
446
|
+
): void {
|
|
447
|
+
;(opts.cache ?? sharedDeliveryCache).markFailed(deliveryCacheKey(opts), unreachable())
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 2.0.2: whether a read of one placement is already running in `cache` (a render's, or a background refresh). */
|
|
451
|
+
export function deliveryLoading(
|
|
452
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache' | 'design'>,
|
|
453
|
+
): boolean {
|
|
454
|
+
return (opts.cache ?? sharedDeliveryCache).loading(deliveryCacheKey(opts))
|
|
455
|
+
}
|
|
456
|
+
|
|
420
457
|
/** Drop the cached read for one placement (e.g. from a revocation webhook); with `design`, that pinned read. */
|
|
421
458
|
export function invalidateDelivery(
|
|
422
459
|
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'design'>,
|