@certrev/cert-block 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +125 -9
  3. package/dist/builder/card-contract.d.ts +143 -0
  4. package/dist/builder/card-contract.d.ts.map +1 -0
  5. package/dist/builder/card-contract.js +262 -0
  6. package/dist/builder/card-contract.js.map +1 -0
  7. package/dist/builder/card-report.d.ts +37 -0
  8. package/dist/builder/card-report.d.ts.map +1 -0
  9. package/dist/builder/card-report.js +43 -0
  10. package/dist/builder/card-report.js.map +1 -0
  11. package/dist/builder/cert-review-card.d.ts +7 -5
  12. package/dist/builder/cert-review-card.d.ts.map +1 -1
  13. package/dist/builder/cert-review-card.js +163 -19
  14. package/dist/builder/cert-review-card.js.map +1 -1
  15. package/dist/builder/contract-card.d.ts +53 -0
  16. package/dist/builder/contract-card.d.ts.map +1 -0
  17. package/dist/builder/contract-card.js +141 -0
  18. package/dist/builder/contract-card.js.map +1 -0
  19. package/dist/builder/delivered-face.d.ts +9 -0
  20. package/dist/builder/delivered-face.d.ts.map +1 -1
  21. package/dist/builder/delivered-face.js +18 -5
  22. package/dist/builder/delivered-face.js.map +1 -1
  23. package/dist/builder/delivery-runtime.d.ts +15 -1
  24. package/dist/builder/delivery-runtime.d.ts.map +1 -1
  25. package/dist/builder/delivery-runtime.js +24 -1
  26. package/dist/builder/delivery-runtime.js.map +1 -1
  27. package/dist/builder/index.d.ts +3 -0
  28. package/dist/builder/index.d.ts.map +1 -1
  29. package/dist/builder/index.js +4 -0
  30. package/dist/builder/index.js.map +1 -1
  31. package/dist/builder/ssr-seed.d.ts +18 -0
  32. package/dist/builder/ssr-seed.d.ts.map +1 -1
  33. package/dist/builder/ssr-seed.js +25 -3
  34. package/dist/builder/ssr-seed.js.map +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/reader/reader-css.generated.d.ts +1 -1
  40. package/dist/reader/reader-css.generated.d.ts.map +1 -1
  41. package/dist/reader/reader-css.generated.js +1 -1
  42. package/dist/reader/reader-css.generated.js.map +1 -1
  43. package/dist/reader/render.d.ts.map +1 -1
  44. package/dist/reader/render.js +71 -34
  45. package/dist/reader/render.js.map +1 -1
  46. package/dist/reader/tokens.d.ts +2 -0
  47. package/dist/reader/tokens.d.ts.map +1 -1
  48. package/dist/reader/tokens.js +28 -6
  49. package/dist/reader/tokens.js.map +1 -1
  50. package/dist/verify/get-verified-delivery.d.ts +28 -0
  51. package/dist/verify/get-verified-delivery.d.ts.map +1 -1
  52. package/dist/verify/get-verified-delivery.js +21 -2
  53. package/dist/verify/get-verified-delivery.js.map +1 -1
  54. package/dist/verify/key-set.d.ts +67 -0
  55. package/dist/verify/key-set.d.ts.map +1 -0
  56. package/dist/verify/key-set.js +190 -0
  57. package/dist/verify/key-set.js.map +1 -0
  58. package/package.json +115 -112
  59. package/src/builder/card-contract.ts +314 -0
  60. package/src/builder/card-report.ts +61 -0
  61. package/src/builder/cert-review-card.tsx +239 -25
  62. package/src/builder/contract-card.tsx +159 -0
  63. package/src/builder/delivered-face.ts +18 -4
  64. package/src/builder/delivery-runtime.ts +30 -1
  65. package/src/builder/index.tsx +12 -0
  66. package/src/builder/ssr-seed.ts +32 -3
  67. package/src/index.ts +9 -0
  68. package/src/reader/reader-css.generated.ts +1 -1
  69. package/src/reader/reader.css +297 -2
  70. package/src/reader/render.ts +87 -38
  71. package/src/reader/tokens.ts +29 -6
  72. package/src/verify/get-verified-delivery.ts +41 -2
  73. package/src/verify/key-set.ts +225 -0
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * ─────────────────────────────────────────────────────────────────────────────
3
- * `renderReaderFace` — the certrev.com "reader" face, as the Builder card paints it (1.2.0)
3
+ * `renderReaderFace` — the certrev.com "reader" face, as the Builder card paints it (1.2.0; caught up to the portal's
4
+ * one face in 1.3.1)
4
5
  * ─────────────────────────────────────────────────────────────────────────────
5
6
  *
6
7
  * The same markup, byte for byte, as the Shopify `certrev-reader-{banner,memo,sidebar,pill}` snippets, which are the
@@ -12,6 +13,17 @@
12
13
  * It is a NEW face, selected only by a delivered render def that says `face: 'reader'` (see `envelopeCardHtml`).
13
14
  * Nothing in the legacy `renderCertBlock` is read or changed here.
14
15
  *
16
+ * Since 1.3.1 it also follows the portal's ONE FACE, as the Shopify snippets and the WordPress plugin paint it:
17
+ * - the def's `rung` (credentialed and named: no memo, bio or photo, so the memo card is not printed and the banner
18
+ * and sidebar are the memo-less card; named: no credential either; minimal: the band and the links only; any other
19
+ * value is the memo rung);
20
+ * - the sidebar's phone fold: the bio and the memo are printed twice, once as the card's own pair and once inside a
21
+ * closed `<details class="certrev-r-more">` (the stylesheet shows one or the other: 640px and under, the fold);
22
+ * - a TONED def (an ink, or a surface that is not white) adds `certrev-r-toned` to the root and `--cr-sf` / `--cr-ink`
23
+ * to its style (`tokens.ts`); a def with no design paints the memo-rung defaults;
24
+ * - the banner's author takes the same 42px face as the reviewer, and the credential date never breaks
25
+ * (`<span class="certrev-r-nw">`).
26
+ *
15
27
  * The facts are read exactly as the snippets read the same envelope:
16
28
  * - the card renders only with a reviewer name (the memo and sidebar too); the pill needs only a verify URL;
17
29
  * - the credential is each credential's `cardLabel` (else `abbreviation`), joined; the "Credential verified by CertREV"
@@ -55,7 +67,7 @@ import {
55
67
  textOr,
56
68
  } from './helpers.js'
57
69
  import { readerLogo } from './logo.js'
58
- import { readerTokens } from './tokens.js'
70
+ import { isTonedStyle, readerTokens } from './tokens.js'
59
71
 
60
72
  export type ReaderLayout = 'banner' | 'sidebar' | 'floating'
61
73
 
@@ -85,6 +97,8 @@ export interface ReaderFaceInput {
85
97
  */
86
98
 
87
99
  /** What every section of one render shares: the facts read once from the envelope and the def. */
100
+ type Rung = 'memo' | 'credentialed' | 'named' | 'minimal'
101
+
88
102
  interface Facts {
89
103
  readonly content: Rec
90
104
  readonly expert: Rec
@@ -92,13 +106,20 @@ interface Facts {
92
106
  readonly def: unknown
93
107
  readonly certId: string
94
108
  readonly ename: string
95
- /** Whether a photo may be painted: neither `reviewerPhoto` hidden nor `showExpertPhoto: false`. */
109
+ /** The def's rung: `memo` unless it says `minimal`, `named` or `credentialed` (any other value is the memo rung). */
110
+ readonly rung: Rung
111
+ /** The minimal rung: the band and the links, nothing else. */
112
+ readonly minimal: boolean
113
+ /** Whether a photo may be painted: neither `reviewerPhoto` hidden nor `showExpertPhoto: false`, and the memo rung. */
96
114
  readonly showPhoto: boolean
97
- /** The joined credential labels, '' when there are none. */
115
+ /** The joined credential labels, '' when there are none or the rung shows none (named, minimal). */
98
116
  readonly creds: string
99
117
  readonly hasCue: boolean
100
118
  readonly illus: boolean
101
- readonly noMemo: boolean
119
+ /** Whether the memo is printed: there is one, and the rung is the memo rung. */
120
+ readonly hasMemo: boolean
121
+ /** Whether the root carries `certrev-r-toned` (the token style says so). */
122
+ readonly toned: boolean
102
123
  readonly purl: string
103
124
  readonly vurl: string
104
125
  readonly style: string
@@ -112,7 +133,9 @@ function readFacts(input: ReaderFaceInput): Facts {
112
133
  const def = input.def
113
134
  const rd = rec(def)
114
135
 
115
- const showPhoto = disp.showExpertPhoto !== false && !contains(rd.hidden, 'reviewerPhoto')
136
+ const rung: Rung = rd.rung === 'minimal' || rd.rung === 'named' || rd.rung === 'credentialed' ? rd.rung : 'memo'
137
+ const minimal = rung === 'minimal'
138
+ const showPhoto = disp.showExpertPhoto !== false && !contains(rd.hidden, 'reviewerPhoto') && rung === 'memo'
116
139
 
117
140
  // POR-9738: each credential's cardLabel over its bare abbreviation, joined with ', ' (labels are not trimmed).
118
141
  const labels: string[] = []
@@ -133,11 +156,14 @@ function readFacts(input: ReaderFaceInput): Facts {
133
156
  def,
134
157
  certId: asText(input.certId),
135
158
  ename: textOr(expert.displayName),
159
+ rung,
160
+ minimal,
136
161
  showPhoto,
137
- creds: labels.join(', '),
162
+ creds: rung === 'named' || minimal ? '' : labels.join(', '),
138
163
  hasCue,
139
164
  illus: input.illustrative === true && !hasCue,
140
- noMemo: isBlank(content.memo),
165
+ hasMemo: !isBlank(content.memo) && rung === 'memo',
166
+ toned: isTonedStyle(style),
141
167
  purl: cleaned(safeUrl(expert.profileUrl)),
142
168
  vurl: cleaned(safeUrl(content.verifyUrl)),
143
169
  style,
@@ -147,7 +173,7 @@ function readFacts(input: ReaderFaceInput): Facts {
147
173
 
148
174
  /** The `<section>` open tag every reader section shares. `badge` is the `.certrev-badge` hook the revalidation reads. */
149
175
  function sectionOpen(f: Facts, badge: boolean, aria: string): string {
150
- const cls = badge ? 'certrev-reader certrev-badge' : 'certrev-reader'
176
+ const cls = `certrev-reader${badge ? ' certrev-badge' : ''}${f.toned ? ' certrev-r-toned' : ''}`
151
177
  // `{% if rd %}`: any def that is not nil/false stamps its version.
152
178
  const version = isTruthy(f.def) ? ` data-certrev-def-version="${esc(rec(f.def).v)}"` : ''
153
179
  return (
@@ -165,7 +191,7 @@ const anchor = (href: string, modal: string, label: string): string =>
165
191
  * name the wording stays one leaf.
166
192
  */
167
193
  function profileLabel(f: Facts): string {
168
- const first = cleaned(firstName(f.ename))
194
+ const first = f.minimal ? '' : cleaned(firstName(f.ename))
169
195
  return first !== '' ? `View <!-- -->${esc(first)}<!-- -->&rsquo;s profile` : 'View profile'
170
196
  }
171
197
 
@@ -194,15 +220,15 @@ const SCOPE = CERT_SCOPE_LINE
194
220
  * The muted credential line, empty when the reviewer has no displayed credential (so no "verified" claim prints).
195
221
  *
196
222
  * The banner's is the demo's three leaves in one `<em>`: the credential, " · ", and the attribution, which reads
197
- * "Credential verified by CertREV on {date}" or, with no date, just "Credential verified by CertREV". The spans carry
198
- * no style of their own. The sidebar names the credential in its bold line already, so its `<em>` is the attribution
199
- * alone.
223
+ * "Credential verified by CertREV on {date}" or, with no date, just "Credential verified by CertREV". The banner's date
224
+ * sits in its own `<span class="certrev-r-nw">`, which the stylesheet never lets break. The sidebar names the credential
225
+ * in its bold line already, so its `<em>` is the attribution alone, and its date is plain text.
200
226
  */
201
227
  function verifiedLine(f: Facts, withCredentials: boolean): string {
202
228
  if (f.creds === '') return ''
203
229
  const date = cleaned(isBlank(f.expert.credentialVerifiedAt) ? '' : longDate(f.expert.credentialVerifiedAt))
204
- const on = date !== '' ? ` on ${esc(date)}` : ''
205
- if (!withCredentials) return `<em>Credential verified by CertREV${on}</em>`
230
+ if (!withCredentials) return `<em>Credential verified by CertREV${date !== '' ? ` on ${esc(date)}` : ''}</em>`
231
+ const on = date !== '' ? ` on <span class="certrev-r-nw">${esc(date)}</span>` : ''
206
232
  return `<em><span>${esc(f.creds)}</span> · <span>Credential verified by CertREV${on}</span></em>`
207
233
  }
208
234
 
@@ -215,24 +241,34 @@ function bannerCard(f: Facts): string {
215
241
  const author = rec(f.content.author)
216
242
  const authorName = textOr(author.name)
217
243
  const authorTitle = textOr(author.title)
218
- const hasAuthor = f.disp.showAuthor !== false && authorName !== '' && authorName !== f.ename
219
- const avatar = (photo: unknown, name: string, size: 'sm' | ''): string =>
220
- readerFace({ photo, show: f.showPhoto, name, size, width: 84 })
244
+ const hasAuthor = f.disp.showAuthor !== false && authorName !== '' && authorName !== f.ename && !f.minimal
245
+ // Both bylines take the same 42px face, photo or initials, at every width.
246
+ const avatar = (photo: unknown, name: string): string =>
247
+ readerFace({ photo, show: f.showPhoto, name, size: '', width: 84 })
221
248
 
222
249
  const authorBy = hasAuthor
223
- ? `<div class="certrev-r-by">${avatar(author.photoUrl, authorName, 'sm')}<div>\n <b>${esc(authorName)}</b>` +
250
+ ? `<div class="certrev-r-by">${avatar(author.photoUrl, authorName)}<div>\n <b>${esc(authorName)}</b>` +
224
251
  `${authorTitle !== '' ? `<em>${esc(authorTitle)}</em>` : ''}<span class="certrev-r-role">Written by</span>\n </div>\n </div>`
225
252
  : ''
226
253
  const expertBy =
227
- `<div class="certrev-r-by">${avatar(f.expert.photoUrl, f.ename, '')}<div>\n <b>${esc(f.ename)}</b>` +
254
+ `<div class="certrev-r-by">${avatar(f.expert.photoUrl, f.ename)}<div>\n <b>${esc(f.ename)}</b>` +
228
255
  `${verifiedLine(f, true)}<span class="certrev-r-role on">Reviewed by</span>\n </div>\n </div>`
256
+ // The minimal rung is the band and the links: no bylines, no date, no cue, no scope line.
257
+ const bys = f.minimal
258
+ ? ''
259
+ : `<div class="certrev-r-bys${hasAuthor ? '' : ' solo'}">${authorBy}${expertBy}\n </div>`
229
260
 
230
261
  const certifiedAt = f.content.certifiedAt
231
- const certified = isBlank(certifiedAt) ? '' : cleaned(longDate(certifiedAt))
262
+ const certified = f.minimal || isBlank(certifiedAt) ? '' : cleaned(longDate(certifiedAt))
263
+ const hasCue = f.hasCue && !f.minimal
264
+ const illus = f.illus && !f.minimal
265
+ // Without a printed memo (none, or a rung that prints none) this card is the sole claim face and carries the scope line.
266
+ const noMemo = !f.hasMemo
267
+ const scopeOn = noMemo && !f.minimal
232
268
 
233
269
  // A memo-less article gets the profile and certificate row, so the certificate stays reachable.
234
270
  let links = ''
235
- if (f.noMemo && (f.purl !== '' || f.vurl !== '')) {
271
+ if (noMemo && (f.purl !== '' || f.vurl !== '')) {
236
272
  links =
237
273
  '<div class="certrev-r-links">' +
238
274
  (f.purl !== '' ? anchor(f.purl, '="expert"', profileLabel(f)) : '') +
@@ -240,8 +276,8 @@ function bannerCard(f: Facts): string {
240
276
  '</div>'
241
277
  }
242
278
 
243
- const hasFoot = certified !== '' || f.hasCue || f.illus
244
- const rule = hasFoot || f.noMemo ? '<div class="certrev-r-rule"></div>' : ''
279
+ const hasFoot = certified !== '' || hasCue || illus
280
+ const rule = hasFoot || scopeOn ? '<div class="certrev-r-rule"></div>' : ''
245
281
 
246
282
  let foot = ''
247
283
  if (hasFoot) {
@@ -249,29 +285,29 @@ function bannerCard(f: Facts): string {
249
285
  // span paints the mono cue and the date's own face.
250
286
  const when = certified !== '' ? `<span><em>Certified</em>${esc(certified)}</span>` : '<span></span>'
251
287
  let cue = ''
252
- if (f.hasCue) {
288
+ if (hasCue) {
253
289
  // The locked cue in plain words, as the demo's foot (judge round 6, E1): no link, so nothing underlines
254
290
  // "expert"; the reviewer's profile is the card's or the memo's "View … profile" link.
255
291
  cue = `<span>${COMPENSATED_EXPERT_CUE}</span>`
256
- } else if (f.illus) {
292
+ } else if (illus) {
257
293
  cue = '<span>Illustrative example</span>'
258
294
  }
259
295
  foot = `<div class="certrev-r-foot">${when}${cue}</div>`
260
296
  }
261
297
 
262
298
  // POR-9317: the memo card carries the scope line when there is a memo; with none this card is the sole claim face.
263
- const scope = f.noMemo ? `<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${SCOPE}</div>` : ''
299
+ const scope = scopeOn ? `<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${SCOPE}</div>` : ''
264
300
 
265
301
  return (
266
- `${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-card">\n` +
267
- ` <div class="certrev-r-cap">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed &amp; certified</div>\n` +
268
- ` <div class="certrev-r-cap-body">\n <div class="certrev-r-bys${hasAuthor ? '' : ' solo'}">${authorBy}${expertBy}\n </div>` +
302
+ `${sectionOpen(f, true, f.minimal ? 'Expert reviewed' : credentialAria(f))}\n <div class="certrev-r-card">\n` +
303
+ ` <div class="certrev-r-cap">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed${f.minimal ? '' : ' &amp; certified'}</div>\n` +
304
+ ` <div class="certrev-r-cap-body">\n ${bys}` +
269
305
  `${links}${rule}${foot}${scope}</div>\n </div>\n</section>`
270
306
  )
271
307
  }
272
308
 
273
309
  function bannerMemo(f: Facts): string {
274
- if (f.ename === '' || isBlank(f.content.memo)) return ''
310
+ if (f.ename === '' || !f.hasMemo) return ''
275
311
  const links =
276
312
  f.purl !== '' || f.vurl !== ''
277
313
  ? '<div class="certrev-r-links">' +
@@ -298,8 +334,6 @@ function bannerMemo(f: Facts): string {
298
334
 
299
335
  function sidebar(f: Facts): string {
300
336
  if (f.ename === '') return ''
301
- const bio = f.disp.showBio !== false && !isBlank(f.expert.bio) ? asText(f.expert.bio) : ''
302
- const avatar = readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, name: f.ename, size: 'lg', width: 112 })
303
337
  const both = f.purl !== '' && f.vurl !== ''
304
338
  const links =
305
339
  f.purl !== '' || f.vurl !== ''
@@ -308,13 +342,28 @@ function sidebar(f: Facts): string {
308
342
  (f.vurl !== '' ? anchor(f.vurl, '', 'View Certificate') : '') +
309
343
  '</div>'
310
344
  : ''
345
+ const open =
346
+ `${sectionOpen(f, true, f.minimal ? 'Expert reviewed' : credentialAria(f))}\n <div class="certrev-r-side">\n` +
347
+ ` <div class="certrev-r-cap center">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed</div>\n `
348
+ // The minimal rung is the band and the links: no body at all.
349
+ if (f.minimal) return `${open}${links}</div>\n</section>`
350
+
351
+ const bio = f.disp.showBio !== false && !isBlank(f.expert.bio) && f.rung === 'memo' ? asText(f.expert.bio) : ''
352
+ const avatar = readerFace({ photo: f.expert.photoUrl, show: f.showPhoto, name: f.ename, size: 'lg', width: 112 })
353
+ // The bio and the memo are printed twice: as the card's own pair, and again inside a closed <details> the stylesheet
354
+ // shows instead on a phone (640px and under; owner decision 3d0838a4, plugin 1.8.3). The disclosure and both links
355
+ // stay outside the fold. With no memo the toggle says "About the expert"; with neither bio nor memo there is no fold.
356
+ const fold =
357
+ `${bio !== '' ? `<p>${esc(bio)}</p>` : ''}` +
358
+ `${f.hasMemo ? `<blockquote>${esc(f.content.memo)}</blockquote>` : ''}`
359
+ const more =
360
+ fold !== ''
361
+ ? `<details class="certrev-r-more"><summary>${f.hasMemo ? 'Read the expert memo' : 'About the expert'}</summary>${fold}</details>`
362
+ : ''
311
363
  return (
312
- `${sectionOpen(f, true, credentialAria(f))}\n <div class="certrev-r-side">\n` +
313
- ` <div class="certrev-r-cap center">${readerLogo(f.fg === '#ffffff' ? 'white' : 'mono', 18, 'CertREV')}Expert reviewed</div>\n` +
314
- ` <div class="certrev-r-side-body">\n <div class="certrev-r-by">${avatar}<div>\n` +
364
+ `${open}<div class="certrev-r-side-body">\n <div class="certrev-r-by">${avatar}<div>\n` +
315
365
  ` <b>${esc(f.ename)}${f.creds !== '' ? `<!-- -->, <!-- -->${esc(f.creds)}` : ''}</b>${verifiedLine(f, false)}</div>\n </div>` +
316
- `${bio !== '' ? `<p>${esc(bio)}</p>` : ''}` +
317
- `${isBlank(f.content.memo) ? '' : `<blockquote>${esc(f.content.memo)}</blockquote>`}` +
366
+ `${fold}${more}` +
318
367
  `<div class="certrev-r-scope" data-ftc-disclosure data-ftc-line>${scopeLead(f)}${SCOPE}</div>\n </div>` +
319
368
  `${links}</div>\n</section>`
320
369
  )
@@ -1,24 +1,32 @@
1
1
  /**
2
- * `certrev-reader-tokens`: the four CSS custom properties a reader section carries on its root, from the render def.
2
+ * `certrev-reader-tokens`: the CSS custom properties a reader section carries on its root, from the render def.
3
3
  *
4
4
  * --cr-ba the accent the card cap and pill paint: the def's `barInk`, else its `accentColor`, else navy
5
5
  * --cr-ba-fg the ink on that accent: white, or `#141414` when the def baked it for the colour that painted
6
6
  * --cr-br the corner radius
7
7
  * --cr-bf the font stack for the brand's `fontSlot`
8
+ * --cr-sf (toned face only) the card surface, the def's `surface`
9
+ * --cr-ink (toned face only) the card ink, the def's `inkColor`
8
10
  *
9
11
  * The values land inside a double-quoted `style` attribute, and they are held to the allow-list the Liquid snippet and
10
12
  * the PHP twin's def parser hold them to: a colour is `#RGB` or `#RRGGBB`, a radius one non-negative px / rem / em / %
11
- * length or a bare `0`, and anything else paints the default (navy, 14px). A denylist of characters is not enough: a
13
+ * length or a bare `0`, and anything else paints the default (navy `#0a1b3f`, 12px: the design guide's memo-rung
14
+ * defaults, as the plugin and the hosted embed paint a face with no design). A denylist of characters is not enough: a
12
15
  * quote, a backslash, a comment opener or a bracket left in a value hides the `;` that ends it and swallows the
13
16
  * declarations after it. The variable names are the reader stylesheet's (`reader.css`), which `.certrev-reader`
14
17
  * declares defaults for.
18
+ *
19
+ * A face is TONED when the def sets an ink, or a surface that is not white (`#fff` / `#ffffff`, either case): only
20
+ * then are `--cr-sf` / `--cr-ink` written (each only when valid), and the section root gains `certrev-r-toned`
21
+ * (`isTonedStyle`, which reads the style string as the snippets do).
15
22
  */
16
23
  import { asText } from '../components/escape.js'
17
24
  import { esc, rec, textOr } from './helpers.js'
18
25
 
19
- /** The accent when the def has none: CertREV navy. */
20
- const DEFAULT_ACCENT = '#0A1B3F'
21
- const DEFAULT_RADIUS = '14px'
26
+ /** The accent when the def has none: the design guide's memo-rung default (CertREV navy). */
27
+ const DEFAULT_ACCENT = '#0a1b3f'
28
+ /** The corner when the def has none: the design guide's memo-rung default. */
29
+ const DEFAULT_RADIUS = '12px'
22
30
  /** The foreground the def bakes for a light accent; anything else is the white default. */
23
31
  const DARK_INK = '#141414'
24
32
 
@@ -71,6 +79,9 @@ export interface ReaderTokens {
71
79
  readonly fg: string
72
80
  }
73
81
 
82
+ /** Whether a root `style` is a toned face's: the snippets' `cr_style contains ';--cr-sf:' or ';--cr-ink:'`. */
83
+ export const isTonedStyle = (style: string): boolean => style.includes(';--cr-sf:') || style.includes(';--cr-ink:')
84
+
74
85
  export function readerTokens(def: unknown): ReaderTokens {
75
86
  const rd = rec(def)
76
87
  const tokens = rec(rd.tokens)
@@ -90,5 +101,17 @@ export function readerTokens(def: unknown): ReaderTokens {
90
101
 
91
102
  const br = radius(tokens.cornerRadius) || DEFAULT_RADIUS
92
103
  const bf = fontStack(asText(tokens.fontSlot))
93
- return { style: `--cr-ba:${esc(ba)};--cr-ba-fg:${fg};--cr-br:${esc(br)};--cr-bf:${bf}`, fg }
104
+
105
+ // A toned face: an ink, or a surface that is not white. `downcase` in the snippet; the digits are ASCII.
106
+ const surface = hexColour(tokens.surface)
107
+ const ink = hexColour(tokens.inkColor)
108
+ const surfaceLc = surface.toLowerCase()
109
+ const toned = ink !== '' || (surface !== '' && surfaceLc !== '#fff' && surfaceLc !== '#ffffff')
110
+
111
+ let style = `--cr-ba:${esc(ba)};--cr-ba-fg:${fg};--cr-br:${esc(br)};--cr-bf:${bf}`
112
+ if (toned) {
113
+ if (surface !== '') style += `;--cr-sf:${esc(surface)}`
114
+ if (ink !== '') style += `;--cr-ink:${esc(ink)}`
115
+ }
116
+ return { style, fg }
94
117
  }
@@ -42,9 +42,18 @@
42
42
  * beside it, and `settleDelivery` hands it out only on a `render` verdict. NOTHING HERE VOUCHES FOR IT: the signature
43
43
  * does not cover it. What binds it is the signed `payload.content.faceDigest` (cert-contract 0.6.0), and a caller
44
44
  * paints it only after hashing it against that digest, as the Builder card does (`src/builder/delivered-face.ts`).
45
+ *
46
+ * 2.0.0: the response may also carry the contract-1 CARD DOCUMENT as a third UNSIGNED sibling, `card` (CertREV's portal
47
+ * serves it only for a brand on contract 1). It is kept exactly as served when it is a JSON object (any other value,
48
+ * `null` included, is no card), cached beside the payload, and handed out only on a `render` verdict. Like `face`,
49
+ * NOTHING HERE VOUCHES FOR IT: the signed `payload.content.faceDigest` binds it (`contract: 1`, `doc`), and the Builder
50
+ * card prints it only after its shape check and digest check pass (`src/builder/card-contract.ts`). A read with no
51
+ * card is the 1.3.1 object exactly. The read is made with `credentials: 'omit'`: it never sends a cookie.
45
52
  */
46
53
 
54
+ import { cardDocumentDigest } from '../builder/card-contract.js'
47
55
  import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
56
+ import { type DeliveredFaceHashes, hashDeliveredFace } from '../builder/delivered-face.js'
48
57
  import type {
49
58
  CertDeliveryArtifact,
50
59
  CertPayload,
@@ -81,6 +90,16 @@ export type CachedDelivery =
81
90
  readonly signature?: CertSignature
82
91
  /** 1.3.0: the unsigned face sibling, when the response carried a well-formed one; else absent. */
83
92
  readonly face?: DeliveredFace
93
+ /** 2.0.0: the unsigned card-document sibling as served, when it was a JSON object; else absent. */
94
+ readonly card?: Readonly<Record<string, unknown>>
95
+ /**
96
+ * 2.0.0: the digest of `card` (lowercase hex sha256 of its RFC 8785 bytes; null when it cannot be computed),
97
+ * computed once by the read that cached it; present whenever `card` is. UNVERIFIED like the card: the caller
98
+ * compares it with the signed binding.
99
+ */
100
+ readonly cardDigest?: string | null
101
+ /** 2.0.0: the two hashes of `face`, computed once by the read; present whenever `face` is (null: no WebCrypto). */
102
+ readonly faceHashes?: DeliveredFaceHashes | null
84
103
  }
85
104
  | {
86
105
  readonly kind: 'suppressed'
@@ -103,6 +122,16 @@ export interface VerifiedDelivery {
103
122
  * well-formed one; else undefined (never on a suppress verdict). UNVERIFIED: see `DeliveredFace`.
104
123
  */
105
124
  readonly face?: DeliveredFace
125
+ /**
126
+ * 2.0.0: on a `render` verdict, the card document served beside the artifact (a JSON object, exactly as served);
127
+ * else undefined. UNVERIFIED: print it only after `parseCardDocument` and the digest check of
128
+ * `src/builder/card-contract.ts`, against the verified payload's `faceDigest`.
129
+ */
130
+ readonly card?: Readonly<Record<string, unknown>>
131
+ /** 2.0.0: `card`'s digest as the read computed it, beside `card`; see `CachedDelivery`. */
132
+ readonly cardDigest?: string | null
133
+ /** 2.0.0: `face`'s hashes as the read computed them, beside `face`; see `CachedDelivery`. */
134
+ readonly faceHashes?: DeliveredFaceHashes | null
106
135
  }
107
136
 
108
137
  export interface GetVerifiedDeliveryOptions {
@@ -273,7 +302,7 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
273
302
  res = await fetchWithDeadline(
274
303
  fetchImpl,
275
304
  deliveryUrl(opts.baseUrl, opts.platform, opts.externalId, opts.design),
276
- { headers: { accept: 'application/json' } },
305
+ { headers: { accept: 'application/json' }, credentials: 'omit' },
277
306
  opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
278
307
  )
279
308
  } catch {
@@ -304,12 +333,19 @@ async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDel
304
333
  // A `render` verdict came from an envelope (a tombstone never renders), so `signature` is the one just verified.
305
334
  const signature = isRecord(body.signature) ? (body.signature as unknown as CertSignature) : undefined
306
335
  const face = parseDeliveredFace(body.face)
336
+ const card = isRecord(body.card) ? body.card : undefined
337
+ // 2.0.0: the digests a render compares with the signed binding, computed here, once per read, so a render that waited
338
+ // for this read never waits a second time. Under React 19, `use` matches promises by their order in a render: a
339
+ // second suspension (on a digest) after the first (on this read) was handed this read's result, and the card failed.
340
+ const cardDigest = card ? await cardDocumentDigest(card) : undefined
341
+ const faceHashes = face ? await hashDeliveredFace(face) : undefined
307
342
  return {
308
343
  kind: 'verified',
309
344
  payload: verdict.payload,
310
345
  renderDef: parseRenderDef(body.renderDef),
311
346
  ...(signature ? { signature } : {}),
312
- ...(face ? { face } : {}),
347
+ ...(face ? { face, faceHashes } : {}),
348
+ ...(card ? { card, cardDigest } : {}),
313
349
  }
314
350
  }
315
351
 
@@ -329,6 +365,9 @@ export function settleDelivery(cached: CachedDelivery, context: RenderContext):
329
365
  renderDef: cached.renderDef,
330
366
  ...(cached.signature ? { signature: cached.signature } : {}),
331
367
  ...(cached.face ? { face: cached.face } : {}),
368
+ ...(cached.faceHashes !== undefined ? { faceHashes: cached.faceHashes } : {}),
369
+ ...(cached.card ? { card: cached.card } : {}),
370
+ ...(cached.cardDigest !== undefined ? { cardDigest: cached.cardDigest } : {}),
332
371
  }
333
372
  }
334
373
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * The issuer key SET a card trusts (2.0.0): baked keys first, then CertREV's signed published set
4
+ * ─────────────────────────────────────────────────────────────────────────────
5
+ *
6
+ * Through 1.3.x the Builder card trusted exactly one baked key (`cert-issuer-1`), so a key rotation would have needed
7
+ * every brand to install a new package. 2.0 trusts a SET without adding a trust root:
8
+ *
9
+ * 1. A kid the package bakes resolves to the baked key, always (a published set never overrides a baked key).
10
+ * 2. Any other kid makes ONE read of the delivery origin's published key set, `GET <origin>/api/cert/v1/keys`
11
+ * (`credentials: 'omit'`, the card's deadline): a browser reads it at most once per page and origin, a server at
12
+ * most once per origin per hour (single-flight, a failure remembered for ten minutes).
13
+ * 3. The set is accepted only when it is signed by a BAKED kid, over the RFC 8785 bytes of exactly this object:
14
+ *
15
+ * { kind: 'certrev.keyset', v: 1, issuedAt, keys: [{ kid, crv: 'Ed25519', x }] } (x: base64url, 32 bytes)
16
+ *
17
+ * answered as `{ keyset, signature: { alg: 'ed25519', kid, sig, signedAt? } }`. Refused: another `kind` or `v`
18
+ * (an envelope, a tombstone or a release record can never verify as a key set: their signed bytes are other
19
+ * objects), a set whose `issuedAt` is older than the one already accepted from that origin (no rollback), a set
20
+ * whose keys do not hold its signer's kid with the same key (it may not drop or replace the key that vouches for
21
+ * it), a malformed key, a signature that does not verify.
22
+ * 4. Anything else (no route, a network error, a refused set) leaves the kid unknown: the kernel suppresses the
23
+ * envelope with `unknown_key`, so the card renders nothing and reports, exactly as for an unknown kid in 1.3.x.
24
+ *
25
+ * The route is the portal's (ADR 0001 section 3 addendum). Today every envelope is signed by the baked
26
+ * `cert-issuer-1`, so step 2 never runs and nothing is fetched.
27
+ */
28
+
29
+ import {
30
+ base64urlDecode,
31
+ canonicalBytes,
32
+ type Ed25519PublicKeyInput,
33
+ importEd25519PublicKey,
34
+ type ResolvePublicKeyByKid,
35
+ } from '../contract/kernel.js'
36
+ import { TtlCache } from './cache.js'
37
+ import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
38
+
39
+ /** Where an origin publishes its key set. */
40
+ export const KEY_SET_PATH = '/api/cert/v1/keys'
41
+
42
+ /** The signed object, exactly as the portal canonicalizes and signs it. */
43
+ export interface PublishedKeySet {
44
+ readonly kind: 'certrev.keyset'
45
+ readonly v: 1
46
+ readonly issuedAt: string
47
+ readonly keys: ReadonlyArray<{ readonly kid: string; readonly crv: 'Ed25519'; readonly x: string }>
48
+ }
49
+
50
+ /** An accepted set: the keys it publishes, and when it was issued (for the no-rollback rule). */
51
+ export interface AcceptedKeySet {
52
+ readonly issuedAt: number
53
+ readonly keys: ReadonlyMap<string, Ed25519PublicKeyInput>
54
+ }
55
+
56
+ const ED25519 = { name: 'Ed25519' } as const
57
+
58
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
59
+ const isString = (v: unknown): v is string => typeof v === 'string'
60
+
61
+ function rawKey(x: unknown): Uint8Array | null {
62
+ if (!isString(x) || !/^[A-Za-z0-9_-]+$/.test(x)) return null
63
+ try {
64
+ const bytes = base64urlDecode(x)
65
+ return bytes.length === 32 ? bytes : null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Ed25519 verify under a declared key or an imported one; any failure (a bad key, no WebCrypto Ed25519) is false. */
72
+ async function verifies(key: Ed25519PublicKeyInput | CryptoKey, sig: Uint8Array, bytes: Uint8Array): Promise<boolean> {
73
+ try {
74
+ const imported = 'format' in (key as object) ? await importEd25519PublicKey(key as Ed25519PublicKeyInput) : key
75
+ // Fresh ArrayBuffer-backed copies: WebCrypto's BufferSource excludes a SharedArrayBuffer view.
76
+ return await crypto.subtle.verify(ED25519, imported as CryptoKey, new Uint8Array(sig), new Uint8Array(bytes))
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Judge one key-set response body against the baked keys (and the set already accepted from the same origin, for
84
+ * the no-rollback rule). Returns the accepted set, or null. Never throws.
85
+ */
86
+ export async function verifyKeySet(
87
+ body: unknown,
88
+ bakedResolveKid: ResolvePublicKeyByKid,
89
+ previous?: AcceptedKeySet,
90
+ ): Promise<AcceptedKeySet | null> {
91
+ if (!isRecord(body) || !isRecord(body.keyset) || !isRecord(body.signature)) return null
92
+ const { keyset, signature } = body
93
+ if (keyset.kind !== 'certrev.keyset' || keyset.v !== 1) return null
94
+ if (!isString(keyset.issuedAt)) return null
95
+ const issuedAt = Date.parse(keyset.issuedAt)
96
+ if (!Number.isFinite(issuedAt)) return null
97
+ if (previous && issuedAt < previous.issuedAt) return null
98
+ if (!Array.isArray(keyset.keys) || keyset.keys.length === 0) return null
99
+ if (signature.alg !== 'ed25519' || !isString(signature.kid) || !isString(signature.sig)) return null
100
+
101
+ // The signer must be a kid this package bakes.
102
+ let baked: Ed25519PublicKeyInput | CryptoKey | null | undefined
103
+ try {
104
+ baked = await bakedResolveKid(signature.kid)
105
+ } catch {
106
+ return null
107
+ }
108
+ if (!baked) return null
109
+
110
+ const keys = new Map<string, Ed25519PublicKeyInput>()
111
+ for (const entry of keyset.keys) {
112
+ if (!isRecord(entry) || !isString(entry.kid) || entry.kid === '' || entry.crv !== 'Ed25519') return null
113
+ const bytes = rawKey(entry.x)
114
+ if (!bytes || keys.has(entry.kid)) return null
115
+ keys.set(entry.kid, { format: 'raw', bytes })
116
+ }
117
+ // The set must keep the key that signed it.
118
+ const signerEntry = keys.get(signature.kid)
119
+ if (!signerEntry) return null
120
+
121
+ let sig: Uint8Array
122
+ let bytes: Uint8Array
123
+ try {
124
+ sig = base64urlDecode(signature.sig)
125
+ bytes = canonicalBytes(keyset)
126
+ } catch {
127
+ return null
128
+ }
129
+ if (!(await verifies(baked, sig, bytes))) return null
130
+ // ... and publish it as the same key: the signature must verify under the published entry too.
131
+ if (!(await verifies(signerEntry, sig, bytes))) return null
132
+ return { issuedAt, keys }
133
+ }
134
+
135
+ export interface KeySetResolverOptions {
136
+ /** The delivery origin the card reads (the marker's `baseUrl`). */
137
+ readonly baseUrl: string
138
+ /** The baked kids (the package's own trust root). */
139
+ readonly bakedResolveKid: ResolvePublicKeyByKid
140
+ readonly fetchImpl: typeof fetch
141
+ readonly timeoutMs?: number
142
+ }
143
+
144
+ /** What one origin's published set settled to: accepted, or nothing (unknown kid). */
145
+ type Loaded = AcceptedKeySet | null
146
+
147
+ /** Server: one read per origin per hour, a failure remembered for ten minutes (single-flight across renders). */
148
+ const serverSets = new TtlCache<Loaded>({ ttlMs: 3_600_000, negativeTtlMs: 600_000, maxEntries: 50 })
149
+ /** Browser: one read per origin for the page's lifetime, whatever it settled to. */
150
+ const pageSets = new Map<string, Promise<Loaded>>()
151
+ /** The newest set accepted per origin (both runtimes), for the no-rollback rule. */
152
+ const acceptedSets = new Map<string, AcceptedKeySet>()
153
+
154
+ function originOf(baseUrl: string): string | null {
155
+ try {
156
+ return new URL(baseUrl).origin
157
+ } catch {
158
+ return null
159
+ }
160
+ }
161
+
162
+ async function loadKeySet(origin: string, opts: KeySetResolverOptions): Promise<Loaded> {
163
+ let res: Response
164
+ try {
165
+ res = await fetchWithDeadline(
166
+ opts.fetchImpl,
167
+ `${origin}${KEY_SET_PATH}`,
168
+ { headers: { accept: 'application/json' }, credentials: 'omit' },
169
+ opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
170
+ )
171
+ } catch {
172
+ return acceptedSets.get(origin) ?? null
173
+ }
174
+ if (!res.ok) return acceptedSets.get(origin) ?? null
175
+ let body: unknown
176
+ try {
177
+ body = await res.json()
178
+ } catch {
179
+ return acceptedSets.get(origin) ?? null
180
+ }
181
+ const previous = acceptedSets.get(origin)
182
+ const accepted = await verifyKeySet(body, opts.bakedResolveKid, previous)
183
+ // A refused set (older, unsigned, malformed) never replaces one this origin already proved.
184
+ if (!accepted) return previous ?? null
185
+ acceptedSets.set(origin, accepted)
186
+ return accepted
187
+ }
188
+
189
+ function publishedSet(origin: string, opts: KeySetResolverOptions): Promise<Loaded> {
190
+ if (typeof document !== 'undefined') {
191
+ let pending = pageSets.get(origin)
192
+ if (!pending) {
193
+ pending = loadKeySet(origin, opts)
194
+ pageSets.set(origin, pending)
195
+ }
196
+ return pending
197
+ }
198
+ return serverSets.getOrLoad(
199
+ origin,
200
+ () => loadKeySet(origin, opts),
201
+ (v) => v === null,
202
+ )
203
+ }
204
+
205
+ /**
206
+ * The resolver a card verifies with: the baked kids, then (for any other kid) the origin's published, signed set.
207
+ * Never throws; an unresolved kid is null (`unknown_key`).
208
+ */
209
+ export function keySetResolver(opts: KeySetResolverOptions): ResolvePublicKeyByKid {
210
+ return async (kid) => {
211
+ const baked = await opts.bakedResolveKid(kid)
212
+ if (baked) return baked
213
+ const origin = originOf(opts.baseUrl)
214
+ if (!origin) return null
215
+ const set = await publishedSet(origin, opts)
216
+ return set?.keys.get(kid) ?? null
217
+ }
218
+ }
219
+
220
+ /** Test seam: forget every published set (a new page and process). */
221
+ export function forgetKeySetsForTests(): void {
222
+ serverSets.clear()
223
+ pageSets.clear()
224
+ acceptedSets.clear()
225
+ }