@certrev/cert-block 1.2.0-canary.7 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -63
- package/README.md +116 -21
- package/dist/builder/card-reader-css.d.ts +14 -0
- package/dist/builder/card-reader-css.d.ts.map +1 -0
- package/dist/builder/card-reader-css.js +17 -0
- package/dist/builder/card-reader-css.js.map +1 -0
- package/dist/builder/cert-review-card.d.ts +8 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +234 -41
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +15 -1
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +31 -3
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/ssr-seed.d.ts +155 -0
- package/dist/builder/ssr-seed.d.ts.map +1 -0
- package/dist/builder/ssr-seed.js +278 -0
- package/dist/builder/ssr-seed.js.map +1 -0
- package/dist/verify/cache.d.ts +39 -2
- package/dist/verify/cache.d.ts.map +1 -1
- package/dist/verify/cache.js +61 -12
- package/dist/verify/cache.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +28 -3
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +46 -9
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/package.json +112 -112
- package/src/builder/card-reader-css.ts +19 -0
- package/src/builder/cert-review-card.tsx +308 -56
- package/src/builder/delivery-runtime.ts +33 -3
- package/src/builder/ssr-seed.ts +343 -0
- package/src/verify/cache.ts +87 -11
- package/src/verify/get-verified-delivery.ts +77 -11
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server render's envelope seed (1.2.0): what lets a hydrated delivery card paint with NO browser read.
|
|
3
|
+
*
|
|
4
|
+
* Through 1.1.1 the browser re-read the Delivery API for every card it hydrated, because nothing handed the server's
|
|
5
|
+
* read to it. A storefront whose Content-Security-Policy `connect-src` does not name the CertREV origin (e.l.f.'s
|
|
6
|
+
* Hydrogen store is one) blocked that read, the read settled as a suppress, and the card the server had painted
|
|
7
|
+
* vanished. So the painted face now carries the envelope it was painted from, as one attribute on its wrapper:
|
|
8
|
+
* `data-certrev-envelope` = `{ v: 1, baseUrl, externalId, design?, payload, signature?, renderDef }`. `payload` is the
|
|
9
|
+
* signed envelope's payload as the Delivery API served it, less the fields the def hides (below); `renderDef` is the
|
|
10
|
+
* def the face was painted with; `design` is the design pin the read was made with (the canary replica's `?design=N`),
|
|
11
|
+
* present only on a pinned read. React escapes the attribute like any other, so no `<script>` and no CSP `script-src`
|
|
12
|
+
* are involved.
|
|
13
|
+
*
|
|
14
|
+
* HIDDEN FIELDS ARE ABSENT FROM THE PAGE, NOT JUST FROM THE FACE. A def's `hidden` set (`reviewerPhoto`, `memo`) is the
|
|
15
|
+
* brand's statement that the page does not show those fields; a seed that carried them would put the reviewer's photo
|
|
16
|
+
* URL or the memo text in the page source all the same. So `withoutHiddenContent` removes them from the payload
|
|
17
|
+
* BEFORE the card paints and before it serializes, and both the server's face and the browser's are painted from the
|
|
18
|
+
* stripped payload (so they are identical, and a hidden memo is not on the page in either place):
|
|
19
|
+
* - `reviewerPhoto` hides `content.expert.photoUrl` and the author's `content.author.photoUrl` (the reader face paints
|
|
20
|
+
* the author's photo under the same switch);
|
|
21
|
+
* - `memo` hides `content.memo`.
|
|
22
|
+
* `display.showExpertPhoto: false` is the payload's own switch for the same photos and is stripped the same way (the
|
|
23
|
+
* portal never writes the URL for it either): both photo URLs go, as for `reviewerPhoto`, whatever the def hides.
|
|
24
|
+
* `content.expert.compensationCue` is NEVER touched: the cue is a locked disclosure no def can hide, and the face
|
|
25
|
+
* paints "Compensated expert" from it in the banner, the memo and the sidebar (the floating pill's own markup has
|
|
26
|
+
* no disclosure line, as in the portal's snippets; its modal paints the cue from the same field).
|
|
27
|
+
*
|
|
28
|
+
* THE SIGNATURE IS LEFT OUT WHENEVER ANYTHING WAS STRIPPED. The signature signs the full payload, so a payload with a
|
|
29
|
+
* field removed no longer verifies against it, and a seed that carried both would hand a verifier a pair that fails.
|
|
30
|
+
* Leaving it out costs nothing: the browser never re-checks a seed's signature (next paragraph), it re-runs only
|
|
31
|
+
* `renderVerdict` (lifecycle and subject) at its own clock, and neither reads a field the strip removes. A seed whose
|
|
32
|
+
* payload was not stripped keeps the signature, so a verifier can still check the page's HTML against the issuer key.
|
|
33
|
+
*
|
|
34
|
+
* The browser trusts a seed as far as it trusts the server HTML the seed sits in: whoever can write that HTML can
|
|
35
|
+
* already write any card they like, so the signature is NOT re-verified here (which would make hydration async, and
|
|
36
|
+
* Ed25519 in WebCrypto is not in every browser). The client still re-runs the lifecycle and subject policy
|
|
37
|
+
* (`renderVerdict`) at its own clock once hydration is done; `cert-review-card.tsx` does that.
|
|
38
|
+
*
|
|
39
|
+
* A PINNED CARD TAKES ONLY A SEED WITH THE SAME PIN (and an unpinned card only an unpinned one): the pin is part of
|
|
40
|
+
* the seed's key, as it is part of the read's, so the canary replica's `?design=7` card never paints the design the
|
|
41
|
+
* page carried for another version.
|
|
42
|
+
*
|
|
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 `'options'` (CertREV could not be reached, so the
|
|
45
|
+
* server painted the block's own options face) or `'none'` (the server painted nothing: revoked, expired, unverifiable,
|
|
46
|
+
* for another entry). A browser that renders the card again (hydration, or React's client re-render after a hydration
|
|
47
|
+
* error elsewhere on the page) paints what the record says, so it neither reads the Delivery API nor shows a card the
|
|
48
|
+
* server withheld. A record holds no payload, so `parseSeed` never takes one for a seed. A `'none'` record sits on a
|
|
49
|
+
* `hidden` element with nothing in it.
|
|
50
|
+
*
|
|
51
|
+
* React-free and browser-only in effect: with no `document` (a server) `pageSeedFor` finds nothing, so a server never
|
|
52
|
+
* reads or keeps a seed and a multi-tenant render process holds nothing per page.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { HIDEABLE_FIELDS } from '../components/render-def.js'
|
|
56
|
+
import type { CertContent, CertPayload, CertSignature } from '../contract/kernel.js'
|
|
57
|
+
import { type DesignPin, parseDesignPin, parseRenderDef } from '../verify/get-verified-delivery.js'
|
|
58
|
+
import type { CertChromeRenderDef } from './cert-chrome-data.js'
|
|
59
|
+
|
|
60
|
+
export const SEED_ATTRIBUTE = 'data-certrev-envelope'
|
|
61
|
+
|
|
62
|
+
export interface DeliverySeed {
|
|
63
|
+
readonly baseUrl: string
|
|
64
|
+
readonly externalId: string
|
|
65
|
+
/** The design pin the read was made with; absent on an unpinned read (every production brand). */
|
|
66
|
+
readonly design?: DesignPin
|
|
67
|
+
/** The payload as painted: with every field the def hides removed (`withoutHiddenContent`). */
|
|
68
|
+
readonly payload: CertPayload
|
|
69
|
+
/**
|
|
70
|
+
* The envelope's signature as served; absent when the payload was stripped (it no longer verifies), and when a
|
|
71
|
+
* cache was filled by hand (a test, a custom cache).
|
|
72
|
+
*/
|
|
73
|
+
readonly signature?: CertSignature
|
|
74
|
+
readonly renderDef: CertChromeRenderDef | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A seed as read from the page: the parsed seed and the attribute's exact text (re-rendered verbatim on hydration). */
|
|
78
|
+
export interface PageSeed {
|
|
79
|
+
readonly seed: DeliverySeed
|
|
80
|
+
readonly raw: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What the server painted with no verified envelope: the block's options face, or nothing. */
|
|
84
|
+
export type FallbackFace = 'options' | 'none'
|
|
85
|
+
|
|
86
|
+
/** The record a server writes where it had no envelope to paint (the file header). */
|
|
87
|
+
export interface DeliveryFallback {
|
|
88
|
+
readonly baseUrl: string
|
|
89
|
+
readonly externalId: string
|
|
90
|
+
readonly design?: DesignPin
|
|
91
|
+
readonly face: FallbackFace
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A fallback record as read from the page, with the attribute's exact text. */
|
|
95
|
+
export interface PageFallback {
|
|
96
|
+
readonly fallback: DeliveryFallback
|
|
97
|
+
readonly raw: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whatever the server wrote for one placement: a seed or a fallback record. */
|
|
101
|
+
export type PageRecord = PageSeed | PageFallback
|
|
102
|
+
|
|
103
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
104
|
+
|
|
105
|
+
/** A value worth hiding: anything but nothing (absent, null, the empty string). */
|
|
106
|
+
const hasValue = (v: unknown): boolean => v !== undefined && v !== null && v !== ''
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The payload with the named people's photo URLs removed (`null`); the same object when neither had one. The card uses
|
|
110
|
+
* it for a photo the browser could not load (a brand's CSP `img-src`, a 404), so the face repaints with the initials.
|
|
111
|
+
*/
|
|
112
|
+
export function withoutPhotos(payload: CertPayload, people: readonly ('expert' | 'author')[]): CertPayload {
|
|
113
|
+
let content: Record<string, unknown> = { ...payload.content }
|
|
114
|
+
let stripped = false
|
|
115
|
+
for (const person of people) {
|
|
116
|
+
const fields = content[person]
|
|
117
|
+
if (isRecord(fields) && hasValue(fields.photoUrl)) {
|
|
118
|
+
content = { ...content, [person]: { ...fields, photoUrl: null } }
|
|
119
|
+
stripped = true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return stripped ? { ...payload, content: content as unknown as CertContent } : payload
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The payload with every content field the def's `hidden` set controls removed, and whether anything was; the two
|
|
127
|
+
* photo URLs go as well when the payload's own `display.showExpertPhoto` is `false` (exactly `false`, as the reader
|
|
128
|
+
* face reads it: absent or `null` shows the photo).
|
|
129
|
+
*
|
|
130
|
+
* Only the ids in `HIDEABLE_FIELDS` count (a def may list others; the faces ignore them, so they hide nothing). A
|
|
131
|
+
* removed text field becomes `null`, so the content keeps the shape the contract types give it and every face reads it
|
|
132
|
+
* as absent. Nothing but those fields changes: the subject and lifecycle (what `renderVerdict` reads), the cue, the
|
|
133
|
+
* dates, the links and the names are the payload's own. Never throws on a hostile shape: a payload whose `expert` or
|
|
134
|
+
* `author` is not an object has no photo to strip.
|
|
135
|
+
*/
|
|
136
|
+
export function withoutHiddenContent(
|
|
137
|
+
payload: CertPayload,
|
|
138
|
+
renderDef: CertChromeRenderDef | null,
|
|
139
|
+
): { readonly payload: CertPayload; readonly stripped: boolean } {
|
|
140
|
+
const hidden = (renderDef?.hidden ?? []).filter((id) => (HIDEABLE_FIELDS as readonly string[]).includes(id))
|
|
141
|
+
const display: unknown = (payload.content as unknown as Record<string, unknown> | undefined)?.display
|
|
142
|
+
const photoOff = isRecord(display) && display.showExpertPhoto === false
|
|
143
|
+
if (hidden.length === 0 && !photoOff) return { payload, stripped: false }
|
|
144
|
+
let next = photoOff || hidden.includes('reviewerPhoto') ? withoutPhotos(payload, ['expert', 'author']) : payload
|
|
145
|
+
if (hidden.includes('memo') && hasValue(next.content.memo)) {
|
|
146
|
+
next = { ...next, content: { ...next.content, memo: null } as unknown as CertContent }
|
|
147
|
+
}
|
|
148
|
+
return { payload: next, stripped: next !== payload }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The seed for a verified read: the payload stripped of what the def hides, and the signature only when nothing was
|
|
153
|
+
* stripped. The card paints from this seed's payload, on the server and in the browser alike.
|
|
154
|
+
*/
|
|
155
|
+
export function seedFor(read: {
|
|
156
|
+
readonly baseUrl: string
|
|
157
|
+
readonly externalId: string
|
|
158
|
+
readonly design?: DesignPin
|
|
159
|
+
readonly payload: CertPayload
|
|
160
|
+
readonly signature?: CertSignature
|
|
161
|
+
readonly renderDef: CertChromeRenderDef | null
|
|
162
|
+
}): DeliverySeed {
|
|
163
|
+
const { payload, stripped } = withoutHiddenContent(read.payload, read.renderDef)
|
|
164
|
+
return {
|
|
165
|
+
baseUrl: read.baseUrl,
|
|
166
|
+
externalId: read.externalId,
|
|
167
|
+
...(read.design ? { design: read.design } : {}),
|
|
168
|
+
payload,
|
|
169
|
+
...(read.signature && !stripped ? { signature: read.signature } : {}),
|
|
170
|
+
renderDef: read.renderDef,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const serializedPin = (design: DesignPin | undefined) =>
|
|
175
|
+
design ? { design: { version: design.version, ...(design.brand === undefined ? {} : { brand: design.brand }) } } : {}
|
|
176
|
+
|
|
177
|
+
export function serializeSeed(seed: DeliverySeed): string {
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
v: 1,
|
|
180
|
+
baseUrl: seed.baseUrl,
|
|
181
|
+
externalId: seed.externalId,
|
|
182
|
+
...serializedPin(seed.design),
|
|
183
|
+
payload: seed.payload,
|
|
184
|
+
...(seed.signature ? { signature: seed.signature } : {}),
|
|
185
|
+
renderDef: seed.renderDef,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function serializeFallback(fallback: DeliveryFallback): string {
|
|
190
|
+
return JSON.stringify({
|
|
191
|
+
v: 1,
|
|
192
|
+
baseUrl: fallback.baseUrl,
|
|
193
|
+
externalId: fallback.externalId,
|
|
194
|
+
...serializedPin(fallback.design),
|
|
195
|
+
face: fallback.face,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A pin a record names, judged by the same grammar the card's own pin is: undefined when absent, null when bad. */
|
|
200
|
+
function parsedPin(design: unknown): DesignPin | undefined | null {
|
|
201
|
+
if (design === undefined) return undefined
|
|
202
|
+
const judged = isRecord(design)
|
|
203
|
+
? parseDesignPin({ design: design.version, ...(design.brand === undefined ? {} : { designBrand: design.brand }) })
|
|
204
|
+
: 'invalid'
|
|
205
|
+
return judged === 'invalid' || judged === undefined ? null : judged
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The seed in one attribute value, or null for anything that is not one (never throws). */
|
|
209
|
+
export function parseSeed(raw: string): DeliverySeed | null {
|
|
210
|
+
let value: unknown
|
|
211
|
+
try {
|
|
212
|
+
value = JSON.parse(raw)
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
if (!isRecord(value) || value.v !== 1) return null
|
|
217
|
+
const { baseUrl, externalId, design, payload, signature, renderDef } = value
|
|
218
|
+
if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
|
|
219
|
+
if (!isRecord(payload) || !isRecord(payload.subject) || !isRecord(payload.lifecycle) || !isRecord(payload.content)) {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
// A pin the seed names is judged by the same grammar the card's own pin is; one that fails it is not a seed.
|
|
223
|
+
const pin = parsedPin(design)
|
|
224
|
+
if (pin === null) return null
|
|
225
|
+
return {
|
|
226
|
+
baseUrl,
|
|
227
|
+
externalId,
|
|
228
|
+
...(pin ? { design: pin } : {}),
|
|
229
|
+
payload: payload as unknown as CertPayload,
|
|
230
|
+
...(isRecord(signature) ? { signature: signature as unknown as CertSignature } : {}),
|
|
231
|
+
renderDef: parseRenderDef(renderDef),
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A fallback record in one attribute value, or null for anything that is not one (a seed included; never throws). */
|
|
236
|
+
export function parseFallback(raw: string): DeliveryFallback | null {
|
|
237
|
+
let value: unknown
|
|
238
|
+
try {
|
|
239
|
+
value = JSON.parse(raw)
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
if (!isRecord(value) || value.v !== 1 || 'payload' in value) return null
|
|
244
|
+
const { baseUrl, externalId, design, face } = value
|
|
245
|
+
if (typeof baseUrl !== 'string' || typeof externalId !== 'string') return null
|
|
246
|
+
if (face !== 'options' && face !== 'none') return null
|
|
247
|
+
const pin = parsedPin(design)
|
|
248
|
+
if (pin === null) return null
|
|
249
|
+
return { baseUrl, externalId, ...(pin ? { design: pin } : {}), face }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** One placement's key: the origin, the entry and the pin (the read's own key, so a pin never shares a seed). */
|
|
253
|
+
function seedKey(baseUrl: string, externalId: string, design: DesignPin | undefined): string {
|
|
254
|
+
return design ? `${baseUrl}|${externalId}|design=${design.version}|${design.brand ?? ''}` : `${baseUrl}|${externalId}`
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The seeds (and fallback records) this page's server HTML carried, by placement, for the page's lifetime: a card
|
|
259
|
+
* mounted later by a client-side render (an in-app navigation back to a server-rendered article, or React's client
|
|
260
|
+
* re-render after a hydration error) paints what the server painted, as the hydrated card it replaces would have.
|
|
261
|
+
*/
|
|
262
|
+
const pageSeeds = new Map<string, PageRecord>()
|
|
263
|
+
|
|
264
|
+
const recordKey = (record: PageRecord): string => {
|
|
265
|
+
const r = 'seed' in record ? record.seed : record.fallback
|
|
266
|
+
return seedKey(r.baseUrl, r.externalId, r.design)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Every seed and fallback record the live document carries, in document order. */
|
|
270
|
+
function scanDocument(): PageRecord[] {
|
|
271
|
+
const found: PageRecord[] = []
|
|
272
|
+
for (const el of Array.from(document.querySelectorAll(`[${SEED_ATTRIBUTE}]`))) {
|
|
273
|
+
const raw = el.getAttribute(SEED_ATTRIBUTE)
|
|
274
|
+
if (!raw) continue
|
|
275
|
+
const seed = parseSeed(raw)
|
|
276
|
+
const fallback = seed ? null : parseFallback(raw)
|
|
277
|
+
if (seed) found.push({ seed, raw })
|
|
278
|
+
else if (fallback) found.push({ fallback, raw })
|
|
279
|
+
}
|
|
280
|
+
return found
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The record the server wrote for this placement and pin: its seed, or its fallback record. By default: from the map,
|
|
285
|
+
* else from one scan of the document (which also records every other one on the page). With `live`, the map is not
|
|
286
|
+
* consulted: the document is scanned and decides, so what a hydrating card learns is what the server HTML in front of
|
|
287
|
+
* it carries, not what an earlier page in this process left behind. `undefined` when the page carries none, and always
|
|
288
|
+
* on a server.
|
|
289
|
+
*/
|
|
290
|
+
export function pageRecordFor(
|
|
291
|
+
baseUrl: string,
|
|
292
|
+
externalId: string,
|
|
293
|
+
design?: DesignPin,
|
|
294
|
+
options: { readonly live?: boolean } = {},
|
|
295
|
+
): PageRecord | undefined {
|
|
296
|
+
const key = seedKey(baseUrl, externalId, design)
|
|
297
|
+
if (typeof document === 'undefined') return undefined
|
|
298
|
+
const known = options.live ? undefined : pageSeeds.get(key)
|
|
299
|
+
if (known) return known
|
|
300
|
+
let inDocument = false
|
|
301
|
+
for (const found of scanDocument()) {
|
|
302
|
+
const k = recordKey(found)
|
|
303
|
+
if (k === key) inDocument = true
|
|
304
|
+
if (options.live || !pageSeeds.has(k)) pageSeeds.set(k, found)
|
|
305
|
+
}
|
|
306
|
+
// A live read answers for the document alone: a record an earlier page left in the map is not one this page carries.
|
|
307
|
+
return options.live && !inDocument ? undefined : pageSeeds.get(key)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `pageRecordFor`, seeds only: the seed the server painted for this placement and pin. */
|
|
311
|
+
export function pageSeedFor(
|
|
312
|
+
baseUrl: string,
|
|
313
|
+
externalId: string,
|
|
314
|
+
design?: DesignPin,
|
|
315
|
+
options: { readonly live?: boolean } = {},
|
|
316
|
+
): PageSeed | undefined {
|
|
317
|
+
const record = pageRecordFor(baseUrl, externalId, design, options)
|
|
318
|
+
return record && 'seed' in record ? record : undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A Suspense boundary's comment marker as React's server render writes it: `$` (done), `$?` (pending), `$!`, `$~`. */
|
|
322
|
+
const BOUNDARY_MARKER = /^\$[?!~]?$/
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Whether the live document holds server-rendered React HTML: any Suspense boundary marker. The delivery card sits in
|
|
326
|
+
* a `<Suspense>` of its own, so a server render of one always leaves a marker, a card the server painted nothing for
|
|
327
|
+
* (revoked, expired, unreachable) included, and a client render never does. This is how a card that is hydrating tells
|
|
328
|
+
* a browser hydrating server HTML (the document holds it) from a server render that merely runs where a `document`
|
|
329
|
+
* exists (a test's jsdom, a shim): there the document does not hold the HTML being produced.
|
|
330
|
+
*/
|
|
331
|
+
export function pageHoldsServerHtml(): boolean {
|
|
332
|
+
if (typeof document === 'undefined' || !document.documentElement) return false
|
|
333
|
+
const walker = document.createTreeWalker(document.documentElement, 128 /* NodeFilter.SHOW_COMMENT */)
|
|
334
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
335
|
+
if (BOUNDARY_MARKER.test((node as Comment).data)) return true
|
|
336
|
+
}
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Test seam: forget every seed (a new page). */
|
|
341
|
+
export function forgetPageSeedsForTests(): void {
|
|
342
|
+
pageSeeds.clear()
|
|
343
|
+
}
|
package/src/verify/cache.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* ONE fetch, not N.
|
|
12
12
|
* 3. Negative-caches failures briefly so a hard-down origin doesn't get hammered, while
|
|
13
13
|
* still recovering quickly (short negative TTL).
|
|
14
|
+
* 4. (1.2.0, opt-in through `staleMs`) STALE-WHILE-REVALIDATE and STALE-IF-ERROR: a positive
|
|
15
|
+
* entry past its TTL but inside the stale window is served at once while ONE background
|
|
16
|
+
* refresh replaces it; a refresh that only failed to reach the origin (`isTransient`)
|
|
17
|
+
* keeps the stale entry instead of overwriting it. So an origin that is slow or briefly
|
|
18
|
+
* down costs the page nothing it already had. A definitive answer (a revocation, say)
|
|
19
|
+
* replaces the entry the moment the refresh lands. With the default `staleMs` of 0 an
|
|
20
|
+
* expired entry is gone, exactly as before 1.2.0.
|
|
14
21
|
*
|
|
15
22
|
* It is deliberately tiny + dependency-free (a Map + timestamps) so it runs in any server
|
|
16
23
|
* runtime (Node, edge, workerd). For multi-instance deployments this is per-instance L1 and
|
|
@@ -25,6 +32,18 @@ export interface TtlCacheOptions {
|
|
|
25
32
|
readonly negativeTtlMs?: number
|
|
26
33
|
/** Max entries before LRU-ish eviction of the oldest insert (default 1000). */
|
|
27
34
|
readonly maxEntries?: number
|
|
35
|
+
/**
|
|
36
|
+
* 1.2.0: how long past its TTL a POSITIVE entry may still be served while a refresh runs (default 0: never). A
|
|
37
|
+
* negative entry is never served stale.
|
|
38
|
+
*/
|
|
39
|
+
readonly staleMs?: number
|
|
40
|
+
/**
|
|
41
|
+
* 1.2.0: how long an unsettled load may be joined by later callers (default: no limit). Past it, the next caller
|
|
42
|
+
* starts a fresh load. WHY: a runtime may cancel the I/O of a request that has finished (Cloudflare workerd does),
|
|
43
|
+
* which can leave a background refresh's promise pending forever; without a limit every later caller would join
|
|
44
|
+
* it and none would ever refresh again.
|
|
45
|
+
*/
|
|
46
|
+
readonly inflightMaxMs?: number
|
|
28
47
|
/** Injectable clock for deterministic tests. */
|
|
29
48
|
readonly now?: () => number
|
|
30
49
|
}
|
|
@@ -35,28 +54,64 @@ interface Entry<V> {
|
|
|
35
54
|
readonly negative: boolean
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
interface Inflight<V> {
|
|
58
|
+
readonly promise: Promise<V>
|
|
59
|
+
readonly startedAt: number
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
export class TtlCache<V> {
|
|
39
63
|
private readonly store = new Map<string, Entry<V>>()
|
|
40
|
-
private readonly inflight = new Map<string,
|
|
64
|
+
private readonly inflight = new Map<string, Inflight<V>>()
|
|
41
65
|
private readonly ttlMs: number
|
|
42
66
|
private readonly negativeTtlMs: number
|
|
43
67
|
private readonly maxEntries: number
|
|
68
|
+
private readonly staleMs: number
|
|
69
|
+
private readonly inflightMaxMs: number
|
|
44
70
|
private readonly now: () => number
|
|
45
71
|
|
|
46
72
|
constructor(opts: TtlCacheOptions = {}) {
|
|
47
73
|
this.ttlMs = opts.ttlMs ?? 60_000
|
|
48
74
|
this.negativeTtlMs = opts.negativeTtlMs ?? 5_000
|
|
49
75
|
this.maxEntries = opts.maxEntries ?? 1000
|
|
76
|
+
this.staleMs = opts.staleMs !== undefined && opts.staleMs > 0 ? opts.staleMs : 0
|
|
77
|
+
this.inflightMaxMs = opts.inflightMaxMs ?? Number.POSITIVE_INFINITY
|
|
50
78
|
this.now = opts.now ?? Date.now
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
81
|
+
/** The entry while it may still be served (fresh, or positive and inside the stale window); prunes it past that. */
|
|
82
|
+
private servable(key: string): Entry<V> | undefined {
|
|
55
83
|
const e = this.store.get(key)
|
|
56
84
|
if (!e) return undefined
|
|
85
|
+
const now = this.now()
|
|
86
|
+
if (e.expiresAt > now) return e
|
|
87
|
+
if (!e.negative && now < e.expiresAt + this.staleMs) return e
|
|
88
|
+
this.store.delete(key)
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read a live (non-expired) entry, or undefined. Never serves a stale one; prunes an entry past its stale window. */
|
|
93
|
+
peek(key: string): V | undefined {
|
|
94
|
+
const e = this.servable(key)
|
|
95
|
+
return e && e.expiresAt > this.now() ? e.value : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The synchronous stale-while-revalidate read: a fresh value; or a positive entry past its TTL but inside the stale
|
|
100
|
+
* window, returned at once while this starts (or joins) ONE background refresh through `loader`; else undefined,
|
|
101
|
+
* and the caller loads (`getOrLoad`). The refresh caches as `getOrLoad` does, with the same arguments; its failure
|
|
102
|
+
* is swallowed (the stale value was already served, and a thrown loader is never cached).
|
|
103
|
+
*/
|
|
104
|
+
peekOrRefresh(
|
|
105
|
+
key: string,
|
|
106
|
+
loader: () => Promise<V>,
|
|
107
|
+
isNegative?: (v: V) => boolean,
|
|
108
|
+
positiveTtlMs?: number,
|
|
109
|
+
isTransient?: (v: V) => boolean,
|
|
110
|
+
): V | undefined {
|
|
111
|
+
const e = this.servable(key)
|
|
112
|
+
if (!e) return undefined
|
|
57
113
|
if (e.expiresAt <= this.now()) {
|
|
58
|
-
this.
|
|
59
|
-
return undefined
|
|
114
|
+
this.load(key, loader, isNegative, positiveTtlMs, isTransient).catch(() => undefined)
|
|
60
115
|
}
|
|
61
116
|
return e.value
|
|
62
117
|
}
|
|
@@ -74,29 +129,50 @@ export class TtlCache<V> {
|
|
|
74
129
|
* It deliberately does NOT apply to a negative result: a caller asking for a 10-minute
|
|
75
130
|
* positive TTL is asking for fewer origin reads, not for a transient failure to be
|
|
76
131
|
* remembered for ten minutes (see the negative-TTL rationale in the module header).
|
|
132
|
+
*
|
|
133
|
+
* 1.2.0: inside the stale window (`staleMs`) an expired positive entry is returned at once and refreshed in the
|
|
134
|
+
* background (`peekOrRefresh`). `isTransient(value)` marks a result that says only "the origin was not reached"
|
|
135
|
+
* (a timeout, a 5xx): when a servable positive entry exists, such a result is NOT cached over it and the caller
|
|
136
|
+
* gets that entry's value instead (stale-if-error). With no entry to keep, it is cached as `isNegative` says.
|
|
77
137
|
*/
|
|
78
138
|
async getOrLoad(
|
|
79
139
|
key: string,
|
|
80
140
|
loader: () => Promise<V>,
|
|
81
141
|
isNegative?: (v: V) => boolean,
|
|
82
142
|
positiveTtlMs?: number,
|
|
143
|
+
isTransient?: (v: V) => boolean,
|
|
83
144
|
): Promise<V> {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
145
|
+
const served = this.peekOrRefresh(key, loader, isNegative, positiveTtlMs, isTransient)
|
|
146
|
+
if (served !== undefined) return served
|
|
147
|
+
return this.load(key, loader, isNegative, positiveTtlMs, isTransient)
|
|
148
|
+
}
|
|
86
149
|
|
|
150
|
+
/** Single-flight load and cache (joins an unsettled load younger than `inflightMaxMs`). */
|
|
151
|
+
private load(
|
|
152
|
+
key: string,
|
|
153
|
+
loader: () => Promise<V>,
|
|
154
|
+
isNegative: ((v: V) => boolean) | undefined,
|
|
155
|
+
positiveTtlMs: number | undefined,
|
|
156
|
+
isTransient: ((v: V) => boolean) | undefined,
|
|
157
|
+
): Promise<V> {
|
|
87
158
|
const existing = this.inflight.get(key)
|
|
88
|
-
if (existing) return existing
|
|
159
|
+
if (existing && this.now() - existing.startedAt < this.inflightMaxMs) return existing.promise
|
|
89
160
|
|
|
90
|
-
const promise = (async () => {
|
|
161
|
+
const promise: Promise<V> = (async () => {
|
|
91
162
|
const value = await loader()
|
|
163
|
+
if (isTransient?.(value)) {
|
|
164
|
+
const kept = this.servable(key)
|
|
165
|
+
if (kept && !kept.negative) return kept.value
|
|
166
|
+
}
|
|
92
167
|
const negative = isNegative ? isNegative(value) : false
|
|
93
168
|
this.set(key, value, negative, negative ? undefined : positiveTtlMs)
|
|
94
169
|
return value
|
|
95
170
|
})().finally(() => {
|
|
96
|
-
this.
|
|
171
|
+
// Only this load's own slot: a newer load that replaced an abandoned one keeps its place.
|
|
172
|
+
if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key)
|
|
97
173
|
})
|
|
98
174
|
|
|
99
|
-
this.inflight.set(key, promise)
|
|
175
|
+
this.inflight.set(key, { promise, startedAt: this.now() })
|
|
100
176
|
return promise
|
|
101
177
|
}
|
|
102
178
|
|
|
@@ -29,12 +29,20 @@
|
|
|
29
29
|
* `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
|
|
30
30
|
* `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
|
|
31
31
|
* keeps a streaming SSR render from suspending forever on a fresh promise every retry).
|
|
32
|
+
*
|
|
33
|
+
* 1.2.0: a suppressed read that only failed to REACH CertREV (the fetch threw or hit its deadline, a 5xx or other
|
|
34
|
+
* non-OK status but 404/410, a body that is not JSON or not an object) is marked `unreachable`. It says nothing
|
|
35
|
+
* about the credential, so a cache with a stale window keeps a verified entry over it (stale-if-error), and the
|
|
36
|
+
* card paints its options face instead of nothing. A definitive answer (404/410, a key or signature that does not
|
|
37
|
+
* verify, a revoked, expired or mismatched artifact) is never `unreachable`. `readVerifiedDelivery` is the
|
|
38
|
+
* synchronous read that also serves a stale entry while refreshing it.
|
|
32
39
|
*/
|
|
33
40
|
|
|
34
41
|
import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
|
|
35
42
|
import type {
|
|
36
43
|
CertDeliveryArtifact,
|
|
37
44
|
CertPayload,
|
|
45
|
+
CertSignature,
|
|
38
46
|
CertSuppressReason,
|
|
39
47
|
CertVerdict,
|
|
40
48
|
RenderContext,
|
|
@@ -44,16 +52,34 @@ import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
|
|
|
44
52
|
import { TtlCache } from './cache.js'
|
|
45
53
|
import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
|
|
46
54
|
|
|
47
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* What one Delivery read settles to, as cached: a verified payload + its def (and, from 1.2.0, the signature it
|
|
57
|
+
* verified with, so a server render can hand the signed envelope to the browser), or the reason not.
|
|
58
|
+
*/
|
|
48
59
|
export type CachedDelivery =
|
|
49
|
-
| {
|
|
50
|
-
|
|
60
|
+
| {
|
|
61
|
+
readonly kind: 'verified'
|
|
62
|
+
readonly payload: CertPayload
|
|
63
|
+
readonly renderDef: CertChromeRenderDef | null
|
|
64
|
+
/** Absent only in a cache filled by hand (a test, a custom cache); a read always keeps it. */
|
|
65
|
+
readonly signature?: CertSignature
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
readonly kind: 'suppressed'
|
|
69
|
+
readonly reason: CertSuppressReason
|
|
70
|
+
/** 1.2.0: true when CertREV was not reached (no answer about the credential); see the module header. */
|
|
71
|
+
readonly unreachable?: true
|
|
72
|
+
}
|
|
51
73
|
|
|
52
74
|
/** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
|
|
53
75
|
export interface VerifiedDelivery {
|
|
54
76
|
readonly verdict: CertVerdict
|
|
55
77
|
/** The brand's render def, or null (no def resolved, a malformed one, or a suppress verdict). */
|
|
56
78
|
readonly renderDef: CertChromeRenderDef | null
|
|
79
|
+
/** The verified envelope's signature, on a `render` verdict whose read kept it; else undefined. */
|
|
80
|
+
readonly signature?: CertSignature
|
|
81
|
+
/** 1.2.0: on a suppress verdict, true when CertREV was not reached; else undefined. */
|
|
82
|
+
readonly unreachable?: true
|
|
57
83
|
}
|
|
58
84
|
|
|
59
85
|
export interface GetVerifiedDeliveryOptions {
|
|
@@ -70,7 +96,7 @@ export interface GetVerifiedDeliveryOptions {
|
|
|
70
96
|
readonly cache?: TtlCache<CachedDelivery>
|
|
71
97
|
/** TTL in ms for a VERIFIED read (default: the cache's positive TTL, 60_000 on the shared one). */
|
|
72
98
|
readonly ttlMs?: number
|
|
73
|
-
/** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). */
|
|
99
|
+
/** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s; the Builder card's runtime passes 8s). */
|
|
74
100
|
readonly timeoutMs?: number
|
|
75
101
|
/**
|
|
76
102
|
* 1.2.0-canary.5: read one saved design version instead of the brand's live def. CertREV's own canary
|
|
@@ -96,6 +122,13 @@ function suppressed(reason: CertSuppressReason): CachedDelivery {
|
|
|
96
122
|
return { kind: 'suppressed', reason }
|
|
97
123
|
}
|
|
98
124
|
|
|
125
|
+
/** A read that did not reach CertREV: the same least-wrong reason as before 1.2.0, marked `unreachable`. */
|
|
126
|
+
function unreachable(): CachedDelivery {
|
|
127
|
+
return { kind: 'suppressed', reason: 'unsupported_contract_version', unreachable: true }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isUnreachable = (v: CachedDelivery): boolean => v.kind === 'suppressed' && v.unreachable === true
|
|
131
|
+
|
|
99
132
|
/** The Delivery route; a design pin adds `?design=<v>` and, with a brand, `&designBrand=<brand>`. */
|
|
100
133
|
export function deliveryUrl(baseUrl: string, platform: string, externalId: string, design?: DesignPin): string {
|
|
101
134
|
const base = baseUrl.replace(/\/+$/, '')
|
|
@@ -210,19 +243,19 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
|
|
|
210
243
|
)
|
|
211
244
|
} catch {
|
|
212
245
|
// Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
|
|
213
|
-
return
|
|
246
|
+
return unreachable()
|
|
214
247
|
}
|
|
215
248
|
// Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
|
|
216
249
|
// tombstone, so a 4xx here genuinely means "no credential for this subject".
|
|
217
250
|
if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
|
|
218
|
-
if (!res.ok) return
|
|
251
|
+
if (!res.ok) return unreachable()
|
|
219
252
|
let body: unknown
|
|
220
253
|
try {
|
|
221
254
|
body = await res.json()
|
|
222
255
|
} catch {
|
|
223
|
-
return
|
|
256
|
+
return unreachable()
|
|
224
257
|
}
|
|
225
|
-
if (!isRecord(body)) return
|
|
258
|
+
if (!isRecord(body)) return unreachable()
|
|
226
259
|
let verdict: CertVerdict
|
|
227
260
|
try {
|
|
228
261
|
// `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
|
|
@@ -233,14 +266,28 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
|
|
|
233
266
|
return suppressed('unknown_key')
|
|
234
267
|
}
|
|
235
268
|
if (verdict.decision !== 'render') return suppressed(verdict.reason)
|
|
236
|
-
|
|
269
|
+
// A `render` verdict came from an envelope (a tombstone never renders), so `signature` is the one just verified.
|
|
270
|
+
const signature = isRecord(body.signature) ? (body.signature as unknown as CertSignature) : undefined
|
|
271
|
+
return {
|
|
272
|
+
kind: 'verified',
|
|
273
|
+
payload: verdict.payload,
|
|
274
|
+
renderDef: parseRenderDef(body.renderDef),
|
|
275
|
+
...(signature ? { signature } : {}),
|
|
276
|
+
}
|
|
237
277
|
}
|
|
238
278
|
|
|
239
279
|
/** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
|
|
240
280
|
export function settleDelivery(cached: CachedDelivery, context: RenderContext): VerifiedDelivery {
|
|
241
|
-
if (cached.kind === 'suppressed')
|
|
281
|
+
if (cached.kind === 'suppressed') {
|
|
282
|
+
return {
|
|
283
|
+
verdict: { decision: 'suppress', reason: cached.reason },
|
|
284
|
+
renderDef: null,
|
|
285
|
+
...(cached.unreachable ? { unreachable: true as const } : {}),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
242
288
|
const verdict = renderVerdict(cached.payload, context)
|
|
243
|
-
|
|
289
|
+
if (verdict.decision !== 'render') return { verdict, renderDef: null }
|
|
290
|
+
return { verdict, renderDef: cached.renderDef, ...(cached.signature ? { signature: cached.signature } : {}) }
|
|
244
291
|
}
|
|
245
292
|
|
|
246
293
|
/**
|
|
@@ -255,10 +302,29 @@ export async function getVerifiedDelivery(opts: GetVerifiedDeliveryOptions): Pro
|
|
|
255
302
|
() => loadDelivery(opts),
|
|
256
303
|
(v) => v.kind === 'suppressed',
|
|
257
304
|
opts.ttlMs,
|
|
305
|
+
isUnreachable,
|
|
258
306
|
)
|
|
259
307
|
return settleDelivery(cached, opts.context)
|
|
260
308
|
}
|
|
261
309
|
|
|
310
|
+
/**
|
|
311
|
+
* 1.2.0, the synchronous read a render uses: the settled answer for a fresh cached read; or, inside the cache's
|
|
312
|
+
* stale window, the stale read's answer at once while ONE background refresh (the same fetch `getVerifiedDelivery`
|
|
313
|
+
* makes) replaces it; else `undefined`, and the caller awaits `getVerifiedDelivery`. The verdict is still settled
|
|
314
|
+
* at `context.now`, so a stale payload that has expired or been revoked in its own fields still suppresses.
|
|
315
|
+
*/
|
|
316
|
+
export function readVerifiedDelivery(opts: GetVerifiedDeliveryOptions): VerifiedDelivery | undefined {
|
|
317
|
+
const cache = opts.cache ?? sharedDeliveryCache
|
|
318
|
+
const cached = cache.peekOrRefresh(
|
|
319
|
+
deliveryCacheKey(opts),
|
|
320
|
+
() => loadDelivery(opts),
|
|
321
|
+
(v) => v.kind === 'suppressed',
|
|
322
|
+
opts.ttlMs,
|
|
323
|
+
isUnreachable,
|
|
324
|
+
)
|
|
325
|
+
return cached === undefined ? undefined : settleDelivery(cached, opts.context)
|
|
326
|
+
}
|
|
327
|
+
|
|
262
328
|
/**
|
|
263
329
|
* The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
|
|
264
330
|
* caller then awaits `getVerifiedDelivery`). Never fetches.
|