@certrev/cert-block 2.0.1 → 2.1.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 +66 -0
- package/README.md +9 -4
- package/dist/builder/cert-review-card.d.ts +2 -0
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +95 -10
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +25 -1
- package/dist/builder/delivery-runtime.d.ts.map +1 -1
- package/dist/builder/delivery-runtime.js +26 -1
- package/dist/builder/delivery-runtime.js.map +1 -1
- package/dist/builder/envelope-face.d.ts +2 -0
- package/dist/builder/envelope-face.d.ts.map +1 -1
- package/dist/builder/envelope-face.js +9 -1
- package/dist/builder/envelope-face.js.map +1 -1
- package/dist/builder/version.d.ts +1 -1
- package/dist/builder/version.js +1 -1
- package/dist/components/CertBadge.d.ts.map +1 -1
- package/dist/components/CertBadge.js +4 -2
- package/dist/components/CertBadge.js.map +1 -1
- package/dist/components/render-cert-block.d.ts +9 -1
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +4 -4
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/components/render-def.d.ts +18 -0
- package/dist/components/render-def.d.ts.map +1 -1
- package/dist/components/render-def.js +15 -0
- package/dist/components/render-def.js.map +1 -1
- package/dist/modal/cert-modal-view.d.ts +3 -0
- package/dist/modal/cert-modal-view.d.ts.map +1 -1
- package/dist/modal/cert-modal-view.js +4 -3
- package/dist/modal/cert-modal-view.js.map +1 -1
- package/dist/modal/certrev-cert.js +4 -4
- package/dist/modal/display-strings.d.ts +9 -0
- package/dist/modal/display-strings.d.ts.map +1 -1
- package/dist/modal/display-strings.js +12 -1
- package/dist/modal/display-strings.js.map +1 -1
- package/dist/modal/ftc-disclosure.d.ts +6 -0
- package/dist/modal/ftc-disclosure.d.ts.map +1 -1
- package/dist/modal/ftc-disclosure.js +6 -0
- package/dist/modal/ftc-disclosure.js.map +1 -1
- package/dist/reader/render.d.ts.map +1 -1
- package/dist/reader/render.js +5 -10
- package/dist/reader/render.js.map +1 -1
- 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 +14 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -1
- package/dist/verify/get-verified-delivery.js +21 -0
- package/dist/verify/get-verified-delivery.js.map +1 -1
- package/dist/webcomponent/render-badge-html.d.ts.map +1 -1
- package/dist/webcomponent/render-badge-html.js +6 -3
- package/dist/webcomponent/render-badge-html.js.map +1 -1
- package/package.json +1 -1
- package/src/builder/cert-review-card.tsx +108 -8
- package/src/builder/delivery-runtime.ts +28 -1
- package/src/builder/envelope-face.ts +16 -1
- package/src/builder/version.ts +1 -1
- package/src/components/CertBadge.tsx +6 -3
- package/src/components/render-cert-block.ts +13 -4
- package/src/components/render-def.ts +24 -0
- package/src/modal/cert-modal-view.ts +7 -3
- package/src/modal/display-strings.ts +14 -1
- package/src/modal/ftc-disclosure.ts +8 -0
- package/src/reader/render.ts +12 -11
- package/src/verify/cache.ts +78 -6
- package/src/verify/get-verified-delivery.ts +27 -0
- package/src/webcomponent/render-badge-html.ts +6 -3
|
@@ -141,6 +141,30 @@ export const COMPENSATED_EXPERT_CUE = 'Compensated expert'
|
|
|
141
141
|
export const CERT_SCOPE_LINE =
|
|
142
142
|
'Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice.'
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* 2.1.0: the scope line for an article whose review covered its TEXT only (no references or sources section). Byte-verbatim
|
|
146
|
+
* copy of the portal's `FTC_DISCLOSURE_LINE_TEXT_ONLY` (`src/lib/embed/ftc-disclosure.ts`; Owen, Console decision
|
|
147
|
+
* af5b0db5, 2026-10-30: the review line says what the expert actually reviewed). Same curly apostrophe, same contract
|
|
148
|
+
* status as `CERT_SCOPE_LINE`: never re-word here.
|
|
149
|
+
*/
|
|
150
|
+
export const CERT_SCOPE_LINE_TEXT_ONLY =
|
|
151
|
+
'Independent editorial review of this article’s text. Not a product endorsement, nor medical advice.'
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 2.1.0: what the review covered, the portal's signed `content.expert.reviewScope`. ABSENT means `text_and_sources`: every
|
|
155
|
+
* envelope signed before the field existed prints exactly as 2.0.2 printed it.
|
|
156
|
+
*/
|
|
157
|
+
export type CertReviewScope = 'text' | 'text_and_sources'
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The scope line for a review scope, the ONE rule every face of this package prints by: only an exact `'text'` is the
|
|
161
|
+
* text-only line; anything else (absent, null, `'text_and_sources'`, a value this version has never heard of) is the full
|
|
162
|
+
* `CERT_SCOPE_LINE`. The portal's `scopeLineFor` decides the same way.
|
|
163
|
+
*/
|
|
164
|
+
export function certScopeLineFor(reviewScope: unknown): typeof CERT_SCOPE_LINE | typeof CERT_SCOPE_LINE_TEXT_ONLY {
|
|
165
|
+
return reviewScope === 'text' ? CERT_SCOPE_LINE_TEXT_ONLY : CERT_SCOPE_LINE
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
/**
|
|
145
169
|
* The delivered display-string EDITION, stamped as `data-certrev-strings-version`
|
|
146
170
|
* so the crawl monitor can assert which verbatim strings a live face should carry.
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import { asText, escapeHtml } from './escape.js'
|
|
31
31
|
import { resizeAvatarUrl } from './avatar-resize.js'
|
|
32
|
-
import {
|
|
32
|
+
import { formatCertDisplayDate, scopeLineForReview } from './display-strings.js'
|
|
33
33
|
import { checkIcon, chevronMark, verifiedStamp } from './certrev-cert-modal.styles.js'
|
|
34
34
|
|
|
35
35
|
export interface CertModalCredential {
|
|
@@ -48,6 +48,9 @@ export interface CertModalExpert {
|
|
|
48
48
|
/** The pre-composed material-connection label ('Compensated expert') or null/absent for
|
|
49
49
|
* a pro-bono reviewer. Present → the disclosure footer renders the label; null/absent → label omitted. */
|
|
50
50
|
compensationCue?: string | null
|
|
51
|
+
/** 2.1.0: what the review covered (`'text'` | `'text_and_sources'`, absent = text and sources). Picks the locked
|
|
52
|
+
* scope line the footer prints, never its words. */
|
|
53
|
+
reviewScope?: string | null
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/** The subset of the signed `payload.content` the modals read (structural). */
|
|
@@ -171,12 +174,13 @@ function certifiedChip(cls: string): string {
|
|
|
171
174
|
* (`expert.compensationCue`, a FINISHED string off the signed envelope) leads ONLY when the reviewer
|
|
172
175
|
* was compensated; a pro-bono reviewer (null/absent cue) drops the label but keeps the scope line —
|
|
173
176
|
* which makes no compensation claim, so it is true for volunteers too and renders unconditionally.
|
|
174
|
-
* The scope line is the verbatim FTC copy (`CERT_SCOPE_LINE`
|
|
177
|
+
* The scope line is the verbatim FTC copy (`CERT_SCOPE_LINE`, or 2.1.0's `CERT_SCOPE_LINE_TEXT_ONLY` when the signed
|
|
178
|
+
* `expert.reviewScope` is `'text'`), never re-assembled here.
|
|
175
179
|
*/
|
|
176
180
|
function disclosureFooter(expert: CertModalExpert | null | undefined): string {
|
|
177
181
|
const cue = asText(expert?.compensationCue).trim()
|
|
178
182
|
const cueHtml = cue ? `<span class="crm-disclosure__cue">${escapeHtml(cue)}</span> ` : ''
|
|
179
|
-
return `<div class="crm-disclosure">${cueHtml}${escapeHtml(
|
|
183
|
+
return `<div class="crm-disclosure">${cueHtml}${escapeHtml(scopeLineForReview(expert?.reviewScope))}</div>`
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* never legitimately carry renders nothing (see `formatCertDisplayDate`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { FTC_DISCLOSURE_LINE } from "./ftc-disclosure.js";
|
|
17
|
+
import { FTC_DISCLOSURE_LINE, FTC_DISCLOSURE_LINE_TEXT_ONLY } from "./ftc-disclosure.js";
|
|
18
18
|
|
|
19
19
|
/** The compensation cue (guide "line A"), byte-verbatim from portal `COMPENSATED_EXPERT_CUE`. */
|
|
20
20
|
export const COMPENSATED_EXPERT_CUE = "Compensated expert";
|
|
@@ -22,6 +22,19 @@ export const COMPENSATED_EXPERT_CUE = "Compensated expert";
|
|
|
22
22
|
/** The FTC scope disclaimer, re-exported from the FTC SoT (portal `CERT_SCOPE_LINE = FTC_DISCLOSURE_LINE`). */
|
|
23
23
|
export const CERT_SCOPE_LINE = FTC_DISCLOSURE_LINE;
|
|
24
24
|
|
|
25
|
+
/** 2.1.0: the text-only scope line (portal `FTC_DISCLOSURE_LINE_TEXT_ONLY`), for a review that covered the text only. */
|
|
26
|
+
export const CERT_SCOPE_LINE_TEXT_ONLY = FTC_DISCLOSURE_LINE_TEXT_ONLY;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 2.1.0: the scope line for the signed `content.expert.reviewScope`. Only an exact `"text"` is the text-only line; absent,
|
|
30
|
+
* null, `"text_and_sources"` or any other value is `CERT_SCOPE_LINE`, as 2.0.2 printed it. The same rule as
|
|
31
|
+
* `certScopeLineFor` in `../components/render-def.ts`, repeated because `src/modal/` imports nothing from `src/components/`
|
|
32
|
+
* (it is bundled standalone into `certrev-cert.js`).
|
|
33
|
+
*/
|
|
34
|
+
export function scopeLineForReview(reviewScope: unknown): typeof CERT_SCOPE_LINE | typeof CERT_SCOPE_LINE_TEXT_ONLY {
|
|
35
|
+
return reviewScope === "text" ? CERT_SCOPE_LINE_TEXT_ONLY : CERT_SCOPE_LINE;
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* Format a timestamp the way every cert byline renders dates ("Jun 14, 2026", en-US short month,
|
|
27
40
|
* UTC-pinned). Null for an unparseable input — callers treat that as "no date, no byline".
|
|
@@ -15,3 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
export const FTC_DISCLOSURE_LINE =
|
|
17
17
|
"Independent editorial review of this article’s text and sources. Not a product endorsement, nor medical advice." as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 2.1.0: the line for an article whose review covered its TEXT only (no references or sources section): a byte-faithful
|
|
21
|
+
* copy of the portal's `FTC_DISCLOSURE_LINE_TEXT_ONLY` (Owen, Console decision af5b0db5). Same contract status and curly
|
|
22
|
+
* apostrophe as `FTC_DISCLOSURE_LINE`.
|
|
23
|
+
*/
|
|
24
|
+
export const FTC_DISCLOSURE_LINE_TEXT_ONLY =
|
|
25
|
+
"Independent editorial review of this article’s text. Not a product endorsement, nor medical advice." as const;
|
package/src/reader/render.ts
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { asText } from '../components/escape.js'
|
|
51
51
|
import { CERTREV_LINK_REL } from '../components/rel.js'
|
|
52
|
-
import {
|
|
52
|
+
import { CERT_STRINGS_VERSION, COMPENSATED_EXPERT_CUE, certScopeLineFor } from '../components/render-def.js'
|
|
53
53
|
import type { CertContent } from '../contract/kernel.js'
|
|
54
54
|
import { readerFace } from './face.js'
|
|
55
55
|
import {
|
|
@@ -115,6 +115,13 @@ interface Facts {
|
|
|
115
115
|
/** The joined credential labels, '' when there are none or the rung shows none (named, minimal). */
|
|
116
116
|
readonly creds: string
|
|
117
117
|
readonly hasCue: boolean
|
|
118
|
+
/**
|
|
119
|
+
* The scope sentence, by the signed `expert.reviewScope` (2.1.0, `certScopeLineFor`: `'text'` is the text-only line,
|
|
120
|
+
* anything else the full one). It is DIRECT text of the scope element (the demo's `<span class="dg-scope">`), which
|
|
121
|
+
* carries the `data-ftc-line` marker itself: no inner span, so the element's `.certrev-r-scope em` cue and the sentence
|
|
122
|
+
* are leaves of one element, as the demo paints them. The marker is on exactly one element per render, as before.
|
|
123
|
+
*/
|
|
124
|
+
readonly scope: string
|
|
118
125
|
readonly illus: boolean
|
|
119
126
|
/** Whether the memo is printed: there is one, and the rung is the memo rung. */
|
|
120
127
|
readonly hasMemo: boolean
|
|
@@ -161,6 +168,7 @@ function readFacts(input: ReaderFaceInput): Facts {
|
|
|
161
168
|
showPhoto,
|
|
162
169
|
creds: rung === 'named' || minimal ? '' : labels.join(', '),
|
|
163
170
|
hasCue,
|
|
171
|
+
scope: certScopeLineFor(expert.reviewScope),
|
|
164
172
|
illus: input.illustrative === true && !hasCue,
|
|
165
173
|
hasMemo: !isBlank(content.memo) && rung === 'memo',
|
|
166
174
|
toned: isTonedStyle(style),
|
|
@@ -209,13 +217,6 @@ function scopeLead(f: Facts): string {
|
|
|
209
217
|
return ''
|
|
210
218
|
}
|
|
211
219
|
|
|
212
|
-
/**
|
|
213
|
-
* The scope sentence is DIRECT text of the scope element (the demo's `<span class="dg-scope">`), which carries the
|
|
214
|
-
* `data-ftc-line` marker itself: no inner span, so the element's `.certrev-r-scope em` cue and the sentence are leaves of
|
|
215
|
-
* one element, as the demo paints them. The marker is on exactly one element per render, as before.
|
|
216
|
-
*/
|
|
217
|
-
const SCOPE = CERT_SCOPE_LINE
|
|
218
|
-
|
|
219
220
|
/**
|
|
220
221
|
* The muted credential line, empty when the reviewer has no displayed credential (so no "verified" claim prints).
|
|
221
222
|
*
|
|
@@ -296,7 +297,7 @@ function bannerCard(f: Facts): string {
|
|
|
296
297
|
}
|
|
297
298
|
|
|
298
299
|
// POR-9317: the memo card carries the scope line when there is a memo; with none this card is the sole claim face.
|
|
299
|
-
const scope = scopeOn ? `<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${
|
|
300
|
+
const scope = scopeOn ? `<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${f.scope}</div>` : ''
|
|
300
301
|
|
|
301
302
|
return (
|
|
302
303
|
`${sectionOpen(f, true, f.minimal ? 'Expert reviewed' : credentialAria(f))}\n <div class="certrev-r-card">\n` +
|
|
@@ -324,7 +325,7 @@ function bannerMemo(f: Facts): string {
|
|
|
324
325
|
` <blockquote>${esc(f.content.memo)}</blockquote>${links}</div>\n </div>\n` +
|
|
325
326
|
' <div class="certrev-r-rule"></div>\n <div class="certrev-r-vfoot">\n' +
|
|
326
327
|
` <span class="certrev-r-verified">${readerLogo('mono', 17, '')}Verified by <b>CertREV</b></span>\n` +
|
|
327
|
-
` <span class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${scopeLead(f)}${
|
|
328
|
+
` <span class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${scopeLead(f)}${f.scope}</span>\n </div>\n </div>\n</section>`
|
|
328
329
|
)
|
|
329
330
|
}
|
|
330
331
|
|
|
@@ -364,7 +365,7 @@ function sidebar(f: Facts): string {
|
|
|
364
365
|
`${open}<div class="certrev-r-side-body">\n <div class="certrev-r-by">${avatar}<div>\n` +
|
|
365
366
|
` <b>${esc(f.ename)}${f.creds !== '' ? `<!-- -->, <!-- -->${esc(f.creds)}` : ''}</b>${verifiedLine(f, false)}</div>\n </div>` +
|
|
366
367
|
`${fold}${more}` +
|
|
367
|
-
`<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${scopeLead(f)}${
|
|
368
|
+
`<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${scopeLead(f)}${f.scope}</div>\n </div>` +
|
|
368
369
|
`${links}</div>\n</section>`
|
|
369
370
|
)
|
|
370
371
|
}
|
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 {
|
|
@@ -427,6 +427,33 @@ export function peekVerifiedDelivery(
|
|
|
427
427
|
return cached === undefined ? undefined : settleDelivery(cached, opts.context)
|
|
428
428
|
}
|
|
429
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
|
+
|
|
430
457
|
/** Drop the cached read for one placement (e.g. from a revocation webhook); with `design`, that pinned read. */
|
|
431
458
|
export function invalidateDelivery(
|
|
432
459
|
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'design'>,
|
|
@@ -22,9 +22,9 @@ import {
|
|
|
22
22
|
} from '../components/format.js'
|
|
23
23
|
import { CERTREV_LINK_REL } from '../components/rel.js'
|
|
24
24
|
import {
|
|
25
|
-
CERT_SCOPE_LINE,
|
|
26
25
|
CERT_STRINGS_VERSION,
|
|
27
26
|
type CertRenderDef,
|
|
27
|
+
certScopeLineFor,
|
|
28
28
|
resolveRenderTheme,
|
|
29
29
|
themeCssVarDeclarations,
|
|
30
30
|
} from '../components/render-def.js'
|
|
@@ -88,6 +88,8 @@ function attr(name: string, value: string | null | undefined): string {
|
|
|
88
88
|
export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions = {}): string {
|
|
89
89
|
const content: LooseContent = payload?.content ?? {}
|
|
90
90
|
const expert = content.expert ?? null
|
|
91
|
+
// 2.1.0: the locked scope line the signed `expert.reviewScope` picks ('text' is the text-only line).
|
|
92
|
+
const scopeLine = certScopeLineFor((expert as { readonly reviewScope?: unknown } | null)?.reviewScope)
|
|
91
93
|
const display = resolveDisplay(content.display ?? undefined, opts.accentColor, opts.renderDef)
|
|
92
94
|
const theme = resolveRenderTheme(opts.renderDef)
|
|
93
95
|
const style = badgeFace(opts.badgeStyle ?? display.badgeStyle)
|
|
@@ -155,14 +157,15 @@ export function renderBadgeHtml(payload: CertPayload, opts: RenderBadgeOptions =
|
|
|
155
157
|
// regardless of a render-def, matching the Shopify/WordPress engine fix. The
|
|
156
158
|
// cue's "expert" links to the reviewer profile; the whole cue tag-strips to the
|
|
157
159
|
// byte-verbatim COMPENSATED_EXPERT_CUE. The scope line is byte-verbatim
|
|
158
|
-
// CERT_SCOPE_LINE (its curly apostrophe U+2019 survives escapeHtml untouched).
|
|
160
|
+
// CERT_SCOPE_LINE (its curly apostrophe U+2019 survives escapeHtml untouched), or 2.1.0's CERT_SCOPE_LINE_TEXT_ONLY
|
|
161
|
+
// when the signed `expert.reviewScope` is 'text' (`certScopeLineFor`).
|
|
159
162
|
const cueInner = profileUrl
|
|
160
163
|
? `Compensated <a class="${C}__cue-link" href="${escapeAttribute(profileUrl)}" rel="${CERTREV_LINK_REL}">expert</a>`
|
|
161
164
|
: 'Compensated expert'
|
|
162
165
|
const disclosure =
|
|
163
166
|
`<div class="${C}__disclosure">` +
|
|
164
167
|
`<span class="${C}__compensated-cue">${cueInner}</span>` +
|
|
165
|
-
`<p class="${C}__ftc" data-ftc-disclosure><span data-ftc-line>${escapeHtml(
|
|
168
|
+
`<p class="${C}__ftc" data-ftc-disclosure><span data-ftc-line>${escapeHtml(scopeLine)}</span></p>` +
|
|
166
169
|
`</div>`
|
|
167
170
|
|
|
168
171
|
const verify = verifyUrl
|