@certrev/cert-block 1.0.3 → 1.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 +60 -0
- package/README.md +6 -1
- package/dist/builder/cert-chrome-data.d.ts +26 -1
- package/dist/builder/cert-chrome-data.d.ts.map +1 -1
- package/dist/builder/cert-chrome-data.js +1 -0
- package/dist/builder/cert-chrome-data.js.map +1 -1
- package/dist/builder/cert-review-card.d.ts +35 -2
- package/dist/builder/cert-review-card.d.ts.map +1 -1
- package/dist/builder/cert-review-card.js +112 -2
- package/dist/builder/cert-review-card.js.map +1 -1
- package/dist/builder/delivery-runtime.d.ts +23 -0
- package/dist/builder/delivery-runtime.d.ts.map +1 -0
- package/dist/builder/delivery-runtime.js +38 -0
- package/dist/builder/delivery-runtime.js.map +1 -0
- package/dist/builder/envelope-face.d.ts +63 -0
- package/dist/builder/envelope-face.d.ts.map +1 -0
- package/dist/builder/envelope-face.js +151 -0
- package/dist/builder/envelope-face.js.map +1 -0
- package/dist/builder/index.d.ts +15 -3
- package/dist/builder/index.d.ts.map +1 -1
- package/dist/builder/index.js +14 -2
- package/dist/builder/index.js.map +1 -1
- package/dist/builder/issuer-key.d.ts +12 -0
- package/dist/builder/issuer-key.d.ts.map +1 -0
- package/dist/builder/issuer-key.js +14 -0
- package/dist/builder/issuer-key.js.map +1 -0
- package/dist/components/memo-toggle.d.ts +42 -0
- package/dist/components/memo-toggle.d.ts.map +1 -0
- package/dist/components/memo-toggle.js +60 -0
- package/dist/components/memo-toggle.js.map +1 -0
- package/dist/components/render-cert-block.d.ts +9 -0
- package/dist/components/render-cert-block.d.ts.map +1 -1
- package/dist/components/render-cert-block.js +80 -19
- package/dist/components/render-cert-block.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/verify/get-verified-delivery.d.ts +91 -0
- package/dist/verify/get-verified-delivery.d.ts.map +1 -0
- package/dist/verify/get-verified-delivery.js +157 -0
- package/dist/verify/get-verified-delivery.js.map +1 -0
- package/package.json +5 -1
- package/src/builder/cert-chrome-data.ts +27 -0
- package/src/builder/cert-review-card.tsx +143 -3
- package/src/builder/delivery-runtime.ts +54 -0
- package/src/builder/envelope-face.ts +173 -0
- package/src/builder/index.tsx +23 -1
- package/src/builder/issuer-key.ts +13 -0
- package/src/components/memo-toggle.ts +63 -0
- package/src/components/render-cert-block.ts +121 -22
- package/src/index.ts +28 -0
- package/src/verify/get-verified-delivery.ts +211 -0
|
@@ -66,6 +66,15 @@
|
|
|
66
66
|
|
|
67
67
|
import { escapeAttribute, escapeHtml, safeHttpUrl } from './escape.js'
|
|
68
68
|
import { dedupeCredential, formatDate } from './format.js'
|
|
69
|
+
import {
|
|
70
|
+
MEMO_ATTR,
|
|
71
|
+
MEMO_COLLAPSED_LINES,
|
|
72
|
+
MEMO_EXPAND_LABEL,
|
|
73
|
+
MEMO_TEXT_ATTR,
|
|
74
|
+
MEMO_TOGGLE_ATTR,
|
|
75
|
+
memoCollapses,
|
|
76
|
+
NARROW_VIEWPORT,
|
|
77
|
+
} from './memo-toggle.js'
|
|
69
78
|
import { CERTREV_LINK_REL } from './rel.js'
|
|
70
79
|
import {
|
|
71
80
|
CERT_SCOPE_LINE,
|
|
@@ -165,6 +174,15 @@ export interface RenderCertBlockInput {
|
|
|
165
174
|
* Ignored outside banner mode.
|
|
166
175
|
*/
|
|
167
176
|
readonly part?: 'full' | 'header' | 'memo'
|
|
177
|
+
/**
|
|
178
|
+
* 1.1.0: the PHONE face. ABSENT ⇒ byte-identical to 1.0.3. `true` ⇒ at `NARROW_VIEWPORT` or
|
|
179
|
+
* narrower the banner header stacks its author and reviewer columns, and a long memo
|
|
180
|
+
* (`memoCollapses`) is clamped to `MEMO_COLLAPSED_LINES` lines behind a native toggle
|
|
181
|
+
* (`memo-toggle.ts`) on every face that paints one. Wider viewports paint the same card as
|
|
182
|
+
* without it. The Builder card's envelope face sets it; the options path and every other
|
|
183
|
+
* surface leave it off.
|
|
184
|
+
*/
|
|
185
|
+
readonly responsive?: boolean
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -267,6 +285,77 @@ function showsCompensationCue(facts: CertBlockFacts, placed: PlacedTest): boolea
|
|
|
267
285
|
const STAMP_LINK_STYLE =
|
|
268
286
|
'font-family:var(--font-mono);font-size:10.5px;font-weight:500;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);text-decoration:none;cursor:pointer;'
|
|
269
287
|
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
// The PHONE face (1.1.0, `responsive`): one scoped stylesheet per root, and the
|
|
290
|
+
// collapsible memo markup `memo-toggle.ts` flips
|
|
291
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The phone rules, scoped to a responsive root so a host page's other markup is never touched.
|
|
295
|
+
* Every rule lives inside the media query: a desktop viewport, or a host that strips the
|
|
296
|
+
* `<style>`, paints exactly the 1.0.3 card (the toggle stays hidden by its inline `display:none`,
|
|
297
|
+
* which only the `!important` rule here overrides).
|
|
298
|
+
*/
|
|
299
|
+
const RESPONSIVE_STYLE =
|
|
300
|
+
`<style>@media ${NARROW_VIEWPORT}{` +
|
|
301
|
+
`.certrev-cert[data-certrev-responsive] [data-certrev-stack]{grid-template-columns:1fr!important}` +
|
|
302
|
+
`.certrev-cert[data-certrev-responsive] [${MEMO_ATTR}="collapsed"] [${MEMO_TEXT_ATTR}]{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:${MEMO_COLLAPSED_LINES};line-clamp:${MEMO_COLLAPSED_LINES};overflow:hidden}` +
|
|
303
|
+
`.certrev-cert[data-certrev-responsive] [${MEMO_TOGGLE_ATTR}]{display:inline-flex!important}` +
|
|
304
|
+
`}</style>`
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The memo toggle: a native button (keyboard and screen reader for free), a 44px touch target,
|
|
308
|
+
* styled as the memo footer's stamp links. Hidden INLINE rather than with `hidden`, so a host
|
|
309
|
+
* stylesheet's `[hidden]{display:none!important}` cannot pin it shut on a phone.
|
|
310
|
+
*/
|
|
311
|
+
const MEMO_TOGGLE_STYLE =
|
|
312
|
+
'display:none;align-items:center;min-height:44px;padding:0;margin:0;border:0;background:none;cursor:pointer;font-family:var(--font-mono);font-size:10.5px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);text-decoration:underline;text-underline-offset:3px;'
|
|
313
|
+
|
|
314
|
+
/** A stable short id for the memo text (the toggle's `aria-controls`); FNV-1a, base 36. */
|
|
315
|
+
function memoId(seed: string): string {
|
|
316
|
+
let h = 0x811c9dc5
|
|
317
|
+
for (let i = 0; i < seed.length; i++) {
|
|
318
|
+
h ^= seed.charCodeAt(i)
|
|
319
|
+
h = Math.imul(h, 0x01000193)
|
|
320
|
+
}
|
|
321
|
+
return `certrev-memo-${(h >>> 0).toString(36)}`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The attributes and toggle a collapsible memo adds; all empty when it does not collapse. */
|
|
325
|
+
interface MemoCollapse {
|
|
326
|
+
/** On the box that holds the memo text and its toggle: the state. */
|
|
327
|
+
readonly box: string
|
|
328
|
+
/** On the memo text element: its id and the clamp hook. */
|
|
329
|
+
readonly text: string
|
|
330
|
+
readonly toggle: string
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const NO_MEMO_COLLAPSE: MemoCollapse = { box: '', text: '', toggle: '' }
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Collapsed by default: the phone rule clamps the text, and the desktop never reads the state.
|
|
337
|
+
* The clamp is visual only, so a screen reader reads the whole memo in either state and hears
|
|
338
|
+
* the toggle as "Read the full memo, button, collapsed".
|
|
339
|
+
*/
|
|
340
|
+
function memoCollapse(facts: CertBlockFacts, responsive: boolean, mode: CertBlockLayout): MemoCollapse {
|
|
341
|
+
if (!responsive || !memoCollapses(facts.memo)) return NO_MEMO_COLLAPSE
|
|
342
|
+
const id = memoId(`${mode}\n${facts.certificateUrl}\n${facts.memo}`)
|
|
343
|
+
return {
|
|
344
|
+
box: ` ${MEMO_ATTR}="collapsed"`,
|
|
345
|
+
text: ` id="${id}" ${MEMO_TEXT_ATTR}`,
|
|
346
|
+
toggle:
|
|
347
|
+
`<button type="button" ${MEMO_TOGGLE_ATTR} aria-expanded="false" aria-controls="${id}" ` +
|
|
348
|
+
`style="${MEMO_TOGGLE_STYLE}">${MEMO_EXPAND_LABEL}</button>`,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The sidebar/custom memo quote, collapsible on a phone. */
|
|
353
|
+
function memoQuote(facts: CertBlockFacts, responsive: boolean, mode: CertBlockLayout): string {
|
|
354
|
+
const collapse = memoCollapse(facts, responsive, mode)
|
|
355
|
+
const quote = `<blockquote${collapse.text} style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`
|
|
356
|
+
return collapse.toggle ? `<div${collapse.box}>${quote}${collapse.toggle}</div>` : quote
|
|
357
|
+
}
|
|
358
|
+
|
|
270
359
|
/** A round avatar tile with the given initials. */
|
|
271
360
|
function avatar(text: string, size: number, fontSize: number): string {
|
|
272
361
|
return (
|
|
@@ -394,13 +483,22 @@ function rootStyle(theme: ResolvedBlockTheme): string {
|
|
|
394
483
|
].join(';')
|
|
395
484
|
}
|
|
396
485
|
|
|
397
|
-
function root(
|
|
486
|
+
function root(
|
|
487
|
+
mode: CertBlockLayout,
|
|
488
|
+
theme: ResolvedBlockTheme,
|
|
489
|
+
inner: string,
|
|
490
|
+
face?: CertBlockFace,
|
|
491
|
+
responsive = false,
|
|
492
|
+
): string {
|
|
398
493
|
// The rung is an OBSERVABILITY stamp for the crawl monitor — only emitted when a
|
|
399
494
|
// face is present, so the grandfathered (face-absent) output stays byte-identical.
|
|
400
495
|
const rungAttr = face ? ` data-certrev-rung="${escapeAttribute(face.rung)}"` : ''
|
|
496
|
+
// The phone face (1.1.0): the scope its rules key on, and the rules, only when asked for.
|
|
497
|
+
const responsiveAttr = responsive ? ' data-certrev-responsive' : ''
|
|
498
|
+
const style = responsive ? RESPONSIVE_STYLE : ''
|
|
401
499
|
return (
|
|
402
500
|
`<div class="certrev-cert certrev-cert--${mode}" data-certrev-mode="${mode}" ` +
|
|
403
|
-
`data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr} style="${rootStyle(theme)}">${inner}</div>`
|
|
501
|
+
`data-certrev-strings-version="${CERT_STRINGS_VERSION}"${rungAttr}${responsiveAttr} style="${rootStyle(theme)}">${style}${inner}</div>`
|
|
404
502
|
)
|
|
405
503
|
}
|
|
406
504
|
|
|
@@ -408,7 +506,7 @@ function root(mode: CertBlockLayout, theme: ResolvedBlockTheme, inner: string, f
|
|
|
408
506
|
// Face — BANNER: a certification header card + a separate expert-memo card
|
|
409
507
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
410
508
|
|
|
411
|
-
function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
509
|
+
function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
|
|
412
510
|
const certified = formatDate(facts.certifiedAt)
|
|
413
511
|
const subtitle =
|
|
414
512
|
placed('authorTitle') && facts.authorTitle
|
|
@@ -473,7 +571,8 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
473
571
|
`<div style="border:1px solid var(--navy-10);border-radius:calc(var(--br,14px) * 0.7);overflow:hidden;font-family:var(--cr-bf);">` +
|
|
474
572
|
header +
|
|
475
573
|
`<div style="padding:22px 24px 18px;">` +
|
|
476
|
-
|
|
574
|
+
// On a phone (responsive) the two columns stack: the stylesheet keys on `data-certrev-stack`.
|
|
575
|
+
`<div${responsive ? ' data-certrev-stack' : ''} style="display:grid;grid-template-columns:${gridCols};gap:22px;">` +
|
|
477
576
|
authorColumn +
|
|
478
577
|
`<div>` +
|
|
479
578
|
`<div style="font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--navy);margin-bottom:11px;">Reviewed by</div>` +
|
|
@@ -487,7 +586,7 @@ function bannerHeaderCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
487
586
|
)
|
|
488
587
|
}
|
|
489
588
|
|
|
490
|
-
function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
589
|
+
function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
|
|
491
590
|
// The memo card composes `<name><, credential>` as two spans rather than through
|
|
492
591
|
// `nameWithCredential`, so it needs the SAME duplicate guard — the doubled "MD, MD" was
|
|
493
592
|
// visible on this card too. Gated on what SURVIVES the dedupe, not on the raw field: a
|
|
@@ -500,15 +599,18 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
500
599
|
// placement (the pattern `bio` already follows): placement alone painted an "Expert
|
|
501
600
|
// memo" heading over an empty `<p>` for a contract-legal empty memo.
|
|
502
601
|
const memoAvatar = placed('reviewerPhoto') ? avatar(initials(facts.reviewerName), 46, 13) : ''
|
|
602
|
+
// On a phone a long memo collapses; the name row stays visible above it (the box is the column).
|
|
603
|
+
const collapse = memoCollapse(facts, responsive, 'banner')
|
|
503
604
|
const memoSection =
|
|
504
605
|
placed('memo') && facts.memo
|
|
505
606
|
? `<div style="font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--navy-55);margin-bottom:14px;">Expert memo</div>` +
|
|
506
607
|
`<div style="display:flex;gap:15px;">${memoAvatar}` +
|
|
507
|
-
`<div style="flex:1;min-width:0;">` +
|
|
608
|
+
`<div${collapse.box} style="flex:1;min-width:0;">` +
|
|
508
609
|
`<div style="font-size:16px;line-height:1.2;color:var(--cr-ink);"><span style="font-weight:700;">${escapeHtml(facts.reviewerName)}</span>${credTail}</div>` +
|
|
509
610
|
// Readable, UPRIGHT DM Sans quote (the display-serif italic hurt legibility);
|
|
510
611
|
// pre-line preserves the reviewer's paragraph breaks. No left accent bar (matches the guide).
|
|
511
|
-
`<p style="margin:11px 0 14px;font-size:15.5px;line-height:1.6;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</p>` +
|
|
612
|
+
`<p${collapse.text} style="margin:11px 0 14px;font-size:15.5px;line-height:1.6;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</p>` +
|
|
613
|
+
collapse.toggle +
|
|
512
614
|
`</div>` +
|
|
513
615
|
`</div>`
|
|
514
616
|
: ''
|
|
@@ -547,7 +649,7 @@ function bannerMemoCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
547
649
|
// Face — SIDEBAR (the DEFAULT): a reviewer card pinned beside the article body
|
|
548
650
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
549
651
|
|
|
550
|
-
function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
652
|
+
function sidebarCard(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
|
|
551
653
|
const header = placed('label')
|
|
552
654
|
? `<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:12px;background:var(--certrev-bar-bg,#0a1b3f);color:var(--certrev-bar-fg,#fff);font-family:var(--font-mono);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;">${logo(18)} Expert reviewed</div>`
|
|
553
655
|
: ''
|
|
@@ -558,10 +660,7 @@ function sidebarCard(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
558
660
|
: ''
|
|
559
661
|
// Gated on CONTENT as well as placement (like `bioPara` above) — placement alone rendered
|
|
560
662
|
// an empty accent-bordered blockquote for a contract-legal empty memo.
|
|
561
|
-
const blockquote =
|
|
562
|
-
placed('memo') && facts.memo
|
|
563
|
-
? `<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`
|
|
564
|
-
: ''
|
|
663
|
+
const blockquote = placed('memo') && facts.memo ? memoQuote(facts, responsive, 'sidebar') : ''
|
|
565
664
|
// Disclosure row: cue + ` · ` + scope; the separator only when BOTH land, the row only when either does.
|
|
566
665
|
const cueEl = showsCompensationCue(facts, placed)
|
|
567
666
|
? `<span style="font-family:var(--font-mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;">${escapeHtml(COMPENSATED_EXPERT_CUE)}</span>`
|
|
@@ -662,7 +761,7 @@ function floatingPill(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
662
761
|
// (which surfaces the switch covers) — not something to settle by quietly adding hooks.
|
|
663
762
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
664
763
|
|
|
665
|
-
function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
764
|
+
function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest, responsive = false): string {
|
|
666
765
|
const parts: string[] = []
|
|
667
766
|
|
|
668
767
|
// 1. label — the navy "Expert reviewed" header bar (sidebar's header markup)
|
|
@@ -703,9 +802,7 @@ function renderCustomFace(facts: CertBlockFacts, placed: PlacedTest): string {
|
|
|
703
802
|
|
|
704
803
|
// 5. memo (sidebar's blockquote) — placement AND content, like `bio` directly above
|
|
705
804
|
if (placed('memo') && facts.memo) {
|
|
706
|
-
parts.push(
|
|
707
|
-
`<blockquote style="margin:16px 0 0;padding-left:16px;border-left:3px solid var(--ba,#0a1b3f);font-size:15px;line-height:1.5;color:var(--cr-ink-soft);white-space:pre-line;">${escapeHtml(facts.memo)}</blockquote>`,
|
|
708
|
-
)
|
|
805
|
+
parts.push(memoQuote(facts, responsive, 'custom'))
|
|
709
806
|
}
|
|
710
807
|
|
|
711
808
|
// 6. disclosures (sidebar's cue · scope row; separator only when BOTH land)
|
|
@@ -755,13 +852,14 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
|
|
|
755
852
|
const theme = resolveBlockTheme(input.theme)
|
|
756
853
|
const { facts, face } = input
|
|
757
854
|
const placed = placedTester(face)
|
|
855
|
+
const responsive = input.responsive === true
|
|
758
856
|
switch (input.mode) {
|
|
759
857
|
case 'banner': {
|
|
760
858
|
// Suppress the memo card entirely when the engine placed none of its fields
|
|
761
859
|
// (avoids an empty bordered shell). Grandfather: all placed ⇒ card renders.
|
|
762
860
|
const memoCard =
|
|
763
861
|
placed('memo') || placed('profileLink') || placed('certificateLink') || placed('scopeLine')
|
|
764
|
-
? bannerMemoCard(facts, placed)
|
|
862
|
+
? bannerMemoCard(facts, placed, responsive)
|
|
765
863
|
: ''
|
|
766
864
|
// First-class memo split: default 'full' is byte-identical to the prior
|
|
767
865
|
// header+memo render; 'header'/'memo' emit only that card so the exporter can place
|
|
@@ -770,16 +868,17 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
|
|
|
770
868
|
const part = input.part === 'header' || input.part === 'memo' ? input.part : 'full'
|
|
771
869
|
const inner =
|
|
772
870
|
part === 'header'
|
|
773
|
-
? bannerHeaderCard(facts, placed)
|
|
871
|
+
? bannerHeaderCard(facts, placed, responsive)
|
|
774
872
|
: part === 'memo'
|
|
775
873
|
? memoCard
|
|
776
|
-
: bannerHeaderCard(facts, placed) + memoCard
|
|
777
|
-
return root('banner', theme, inner, face)
|
|
874
|
+
: bannerHeaderCard(facts, placed, responsive) + memoCard
|
|
875
|
+
return root('banner', theme, inner, face, responsive)
|
|
778
876
|
}
|
|
779
877
|
case 'floating':
|
|
878
|
+
// The pill has no memo and no columns: nothing on it changes on a phone.
|
|
780
879
|
return root('floating', theme, floatingPill(facts, placed), face)
|
|
781
880
|
case 'custom':
|
|
782
|
-
return root('custom', theme, renderCustomFace(facts, placed), face)
|
|
881
|
+
return root('custom', theme, renderCustomFace(facts, placed, responsive), face, responsive)
|
|
783
882
|
// THE default placement, named here and nowhere else: the declared default and the
|
|
784
883
|
// runtime fallback are the same line, so they cannot drift apart. Naming `sidebar` as a
|
|
785
884
|
// real case also keeps the exhaustiveness read over CertBlockLayout honest — a reader
|
|
@@ -787,6 +886,6 @@ export function renderCertBlock(input: RenderCertBlockInput): string {
|
|
|
787
886
|
// biome-ignore lint/complexity/noUselessSwitchCase: the redundancy IS the mechanism (see above)
|
|
788
887
|
case 'sidebar':
|
|
789
888
|
default:
|
|
790
|
-
return root('sidebar', theme, sidebarCard(facts, placed), face)
|
|
889
|
+
return root('sidebar', theme, sidebarCard(facts, placed, responsive), face, responsive)
|
|
791
890
|
}
|
|
792
891
|
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,21 @@ export {
|
|
|
36
36
|
type ResolvedDisplay,
|
|
37
37
|
resolveDisplay,
|
|
38
38
|
} from './components/format.js'
|
|
39
|
+
// ── The PHONE face's collapsible memo (1.1.0): the markup contract + the one toggle ──
|
|
40
|
+
export {
|
|
41
|
+
MEMO_ATTR,
|
|
42
|
+
MEMO_COLLAPSE_LABEL,
|
|
43
|
+
MEMO_COLLAPSE_MIN_BREAKS,
|
|
44
|
+
MEMO_COLLAPSE_MIN_CHARS,
|
|
45
|
+
MEMO_COLLAPSED_LINES,
|
|
46
|
+
MEMO_EXPAND_LABEL,
|
|
47
|
+
MEMO_TEXT_ATTR,
|
|
48
|
+
MEMO_TOGGLE_ATTR,
|
|
49
|
+
memoCollapses,
|
|
50
|
+
memoToggleFor,
|
|
51
|
+
NARROW_VIEWPORT,
|
|
52
|
+
toggleMemo,
|
|
53
|
+
} from './components/memo-toggle.js'
|
|
39
54
|
// ── Pre-sales PREVIEW block — static, non-delivery, hard-bounded ──
|
|
40
55
|
export {
|
|
41
56
|
type PreviewBlockOptions,
|
|
@@ -118,6 +133,19 @@ export {
|
|
|
118
133
|
serializeJsonLdForScript,
|
|
119
134
|
} from './jsonld/project.js'
|
|
120
135
|
export { TtlCache, type TtlCacheOptions } from './verify/cache.js'
|
|
136
|
+
export {
|
|
137
|
+
type CachedDelivery,
|
|
138
|
+
deliveryCacheKey,
|
|
139
|
+
deliveryUrl,
|
|
140
|
+
type GetVerifiedDeliveryOptions,
|
|
141
|
+
getVerifiedDelivery,
|
|
142
|
+
invalidateDelivery,
|
|
143
|
+
parseRenderDef,
|
|
144
|
+
peekVerifiedDelivery,
|
|
145
|
+
settleDelivery,
|
|
146
|
+
sharedDeliveryCache,
|
|
147
|
+
type VerifiedDelivery,
|
|
148
|
+
} from './verify/get-verified-delivery.js'
|
|
121
149
|
// ── Verify layer ──────────────────────────────────────────────────────────────────
|
|
122
150
|
export {
|
|
123
151
|
type EnvelopeSource,
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* getVerifiedDelivery — the envelope PLUS the brand's render def, from the Delivery API
|
|
4
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 1.1.0. The Delivery API answers a Builder placement with the signed artifact and, beside it,
|
|
7
|
+
* the brand's resolved render-def projection as a top-level `renderDef` sibling (the same shape a
|
|
8
|
+
* Shopify store carries in its `render_def` metafield). The envelope's signature covers `payload`
|
|
9
|
+
* only, so the sibling rides the response without touching the signed bytes, and a tombstone
|
|
10
|
+
* carries none. `getVerifiedEnvelope` keeps discarding it; this reads it.
|
|
11
|
+
*
|
|
12
|
+
* The same fetch, deadline, kernel and fail-closed rules as `getVerifiedEnvelope` (every sourcing
|
|
13
|
+
* failure reports the reason it actually is; nothing throws into the render path). Two things are
|
|
14
|
+
* different, and both are the point:
|
|
15
|
+
*
|
|
16
|
+
* 1. WHAT IS CACHED is the cryptographically verified PAYLOAD, not the verdict. The lifecycle
|
|
17
|
+
* policy (revoked, expired) and the subject match are re-run by `renderVerdict` on EVERY call,
|
|
18
|
+
* with the caller's clock. So a payload that was valid when fetched and expires while cached
|
|
19
|
+
* stops rendering at its `expiresAt`, not at the next refetch; and a refetch after the TTL
|
|
20
|
+
* picks up a tombstone (revocation) or a changed def with no write on the brand's side.
|
|
21
|
+
* 2. The CACHE KEY is the Delivery URL plus the rendering placement, so a staging and a
|
|
22
|
+
* production origin never share an entry, and neither do two placements.
|
|
23
|
+
*
|
|
24
|
+
* `peekVerifiedDelivery` is the synchronous read of the same cache: a React component suspends on
|
|
25
|
+
* `getVerifiedDelivery` once, then reads the settled value without a second promise (which is what
|
|
26
|
+
* keeps a streaming SSR render from suspending forever on a fresh promise every retry).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CertChromeRenderDef } from '../builder/cert-chrome-data.js'
|
|
30
|
+
import type {
|
|
31
|
+
CertDeliveryArtifact,
|
|
32
|
+
CertPayload,
|
|
33
|
+
CertSuppressReason,
|
|
34
|
+
CertVerdict,
|
|
35
|
+
RenderContext,
|
|
36
|
+
ResolvePublicKeyByKid,
|
|
37
|
+
} from '../contract/kernel.js'
|
|
38
|
+
import { renderVerdict, verifyArtifact } from '../contract/kernel.js'
|
|
39
|
+
import { TtlCache } from './cache.js'
|
|
40
|
+
import { DEFAULT_FETCH_TIMEOUT_MS, fetchWithDeadline } from './fetch-with-deadline.js'
|
|
41
|
+
|
|
42
|
+
/** What one Delivery read settles to, as cached: a verified payload + its def, or the reason not. */
|
|
43
|
+
export type CachedDelivery =
|
|
44
|
+
| { readonly kind: 'verified'; readonly payload: CertPayload; readonly renderDef: CertChromeRenderDef | null }
|
|
45
|
+
| { readonly kind: 'suppressed'; readonly reason: CertSuppressReason }
|
|
46
|
+
|
|
47
|
+
/** The answer for one render: the verdict AT THIS RENDER'S CLOCK, and the def to paint it with. */
|
|
48
|
+
export interface VerifiedDelivery {
|
|
49
|
+
readonly verdict: CertVerdict
|
|
50
|
+
/** The brand's render def, or null (no def resolved, a malformed one, or a suppress verdict). */
|
|
51
|
+
readonly renderDef: CertChromeRenderDef | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface GetVerifiedDeliveryOptions {
|
|
55
|
+
/** Base URL of the CertREV app that serves the Delivery API, e.g. 'https://portal.certrev.com'. */
|
|
56
|
+
readonly baseUrl: string
|
|
57
|
+
readonly platform: string
|
|
58
|
+
readonly externalId: string
|
|
59
|
+
/** kid → public-key resolver. Required: no verify without keys. */
|
|
60
|
+
readonly resolveKid: ResolvePublicKeyByKid
|
|
61
|
+
/** Render context: the platform + externalId this edge IS, and the render's clock (`now`). */
|
|
62
|
+
readonly context: RenderContext
|
|
63
|
+
readonly fetchImpl?: typeof fetch
|
|
64
|
+
/** Cache override. Defaults to the module-level shared delivery cache. */
|
|
65
|
+
readonly cache?: TtlCache<CachedDelivery>
|
|
66
|
+
/** TTL in ms for a VERIFIED read (default: the cache's positive TTL, 60_000 on the shared one). */
|
|
67
|
+
readonly ttlMs?: number
|
|
68
|
+
/** Deadline in ms for the fetch (default `DEFAULT_FETCH_TIMEOUT_MS`, 3s). */
|
|
69
|
+
readonly timeoutMs?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Shared per-process cache: 60s for a verified read, 5s for a suppressed one. */
|
|
73
|
+
const sharedDeliveryCache = new TtlCache<CachedDelivery>({ ttlMs: 60_000, negativeTtlMs: 5_000 })
|
|
74
|
+
|
|
75
|
+
function suppressed(reason: CertSuppressReason): CachedDelivery {
|
|
76
|
+
return { kind: 'suppressed', reason }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function deliveryUrl(baseUrl: string, platform: string, externalId: string): string {
|
|
80
|
+
const base = baseUrl.replace(/\/+$/, '')
|
|
81
|
+
return `${base}/api/cert/v1/delivery/${encodeURIComponent(platform)}/${encodeURIComponent(externalId)}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cache key: the URL (origin included) × the placement rendering it. No clock: the TTL bounds time. */
|
|
85
|
+
export function deliveryCacheKey(
|
|
86
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
|
|
87
|
+
): string {
|
|
88
|
+
return `delivery:${deliveryUrl(opts.baseUrl, opts.platform, opts.externalId)}|${encodeURIComponent(opts.context.platform)}:${encodeURIComponent(opts.context.externalId)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isString = (v: unknown): v is string => typeof v === 'string'
|
|
92
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The def sibling, shape-checked. Anything that is not the metafield shape is null, which paints
|
|
96
|
+
* the preset default (the same thing an absent def paints): a def is presentation, never a claim,
|
|
97
|
+
* so a malformed one degrades the styling and nothing else. The TOKEN VALUES are not judged here;
|
|
98
|
+
* `resolveBlockTheme` re-validates each at paint time and drops what is not a safe value.
|
|
99
|
+
*/
|
|
100
|
+
export function parseRenderDef(value: unknown): CertChromeRenderDef | null {
|
|
101
|
+
if (!isRecord(value)) return null
|
|
102
|
+
const { v, preset, rung, tokens, hidden, barInkFg, layout, customFace } = value
|
|
103
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return null
|
|
104
|
+
if (!isString(preset) || !isString(rung) || !isRecord(tokens)) return null
|
|
105
|
+
if (!Array.isArray(hidden) || !hidden.every(isString)) return null
|
|
106
|
+
if (layout !== undefined && !isString(layout)) return null
|
|
107
|
+
let face: CertChromeRenderDef['customFace']
|
|
108
|
+
if (customFace !== undefined) {
|
|
109
|
+
if (!isRecord(customFace) || !Array.isArray(customFace.placedFields) || !customFace.placedFields.every(isString)) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
face = { placedFields: [...customFace.placedFields] }
|
|
113
|
+
}
|
|
114
|
+
const tokenOut: Record<string, string> = {}
|
|
115
|
+
for (const key of ['accentColor', 'surface', 'cornerRadius', 'fontSlot', 'barInk', 'inkColor'] as const) {
|
|
116
|
+
if (isString(tokens[key])) tokenOut[key] = tokens[key]
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
v,
|
|
120
|
+
preset,
|
|
121
|
+
rung,
|
|
122
|
+
tokens: tokenOut,
|
|
123
|
+
...(barInkFg === '#141414' || barInkFg === '#ffffff' ? { barInkFg } : {}),
|
|
124
|
+
hidden: [...hidden],
|
|
125
|
+
...(layout !== undefined ? { layout } : {}),
|
|
126
|
+
...(face ? { customFace: face } : {}),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fetch, parse and cryptographically verify one Delivery response. Never throws. */
|
|
131
|
+
async function loadDelivery(opts: GetVerifiedDeliveryOptions): Promise<CachedDelivery> {
|
|
132
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch
|
|
133
|
+
let res: Response
|
|
134
|
+
try {
|
|
135
|
+
res = await fetchWithDeadline(
|
|
136
|
+
fetchImpl,
|
|
137
|
+
deliveryUrl(opts.baseUrl, opts.platform, opts.externalId),
|
|
138
|
+
{ headers: { accept: 'application/json' } },
|
|
139
|
+
opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
|
|
140
|
+
)
|
|
141
|
+
} catch {
|
|
142
|
+
// Unreachable, DNS/TLS, or the deadline. Same least-wrong member as getVerifiedEnvelope.
|
|
143
|
+
return suppressed('unsupported_contract_version')
|
|
144
|
+
}
|
|
145
|
+
// Nothing served for this placement: never certified, or withdrawn. A LIVE revocation is a 200
|
|
146
|
+
// tombstone, so a 4xx here genuinely means "no credential for this subject".
|
|
147
|
+
if (res.status === 404 || res.status === 410) return suppressed('subject_mismatch')
|
|
148
|
+
if (!res.ok) return suppressed('unsupported_contract_version')
|
|
149
|
+
let body: unknown
|
|
150
|
+
try {
|
|
151
|
+
body = await res.json()
|
|
152
|
+
} catch {
|
|
153
|
+
return suppressed('unsupported_contract_version')
|
|
154
|
+
}
|
|
155
|
+
if (!isRecord(body)) return suppressed('unsupported_contract_version')
|
|
156
|
+
let verdict: CertVerdict
|
|
157
|
+
try {
|
|
158
|
+
// `verifyArtifact`, so a tombstone verifies as itself and reports 'revoked'. The kernel reads
|
|
159
|
+
// `payload` + `signature` (or the tombstone's own fields); the `renderDef` sibling is not
|
|
160
|
+
// part of what is verified, and is never read unless the artifact verified.
|
|
161
|
+
verdict = await verifyArtifact(body as unknown as CertDeliveryArtifact, opts.resolveKid, { ...opts.context })
|
|
162
|
+
} catch {
|
|
163
|
+
return suppressed('unknown_key')
|
|
164
|
+
}
|
|
165
|
+
if (verdict.decision !== 'render') return suppressed(verdict.reason)
|
|
166
|
+
return { kind: 'verified', payload: verdict.payload, renderDef: parseRenderDef(body.renderDef) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Apply the render-time policy to a cached read: lifecycle + subject at THIS render's clock. */
|
|
170
|
+
export function settleDelivery(cached: CachedDelivery, context: RenderContext): VerifiedDelivery {
|
|
171
|
+
if (cached.kind === 'suppressed') return { verdict: { decision: 'suppress', reason: cached.reason }, renderDef: null }
|
|
172
|
+
const verdict = renderVerdict(cached.payload, context)
|
|
173
|
+
return { verdict, renderDef: verdict.decision === 'render' ? cached.renderDef : null }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fetch (if needed), verify, cache, and settle one Delivery read for one placement. FAIL-CLOSED:
|
|
178
|
+
* the verdict is `render` only when the artifact verified against `resolveKid` AND, at
|
|
179
|
+
* `context.now`, the payload is unrevoked, unexpired and for this placement.
|
|
180
|
+
*/
|
|
181
|
+
export async function getVerifiedDelivery(opts: GetVerifiedDeliveryOptions): Promise<VerifiedDelivery> {
|
|
182
|
+
const cache = opts.cache ?? sharedDeliveryCache
|
|
183
|
+
const cached = await cache.getOrLoad(
|
|
184
|
+
deliveryCacheKey(opts),
|
|
185
|
+
() => loadDelivery(opts),
|
|
186
|
+
(v) => v.kind === 'suppressed',
|
|
187
|
+
opts.ttlMs,
|
|
188
|
+
)
|
|
189
|
+
return settleDelivery(cached, opts.context)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The synchronous read: the settled answer when a fresh read is cached, else `undefined` (the
|
|
194
|
+
* caller then awaits `getVerifiedDelivery`). Never fetches.
|
|
195
|
+
*/
|
|
196
|
+
export function peekVerifiedDelivery(
|
|
197
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context' | 'cache'>,
|
|
198
|
+
): VerifiedDelivery | undefined {
|
|
199
|
+
const cached = (opts.cache ?? sharedDeliveryCache).peek(deliveryCacheKey(opts))
|
|
200
|
+
return cached === undefined ? undefined : settleDelivery(cached, opts.context)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Drop every cached read for one placement (e.g. from a revocation webhook). */
|
|
204
|
+
export function invalidateDelivery(
|
|
205
|
+
opts: Pick<GetVerifiedDeliveryOptions, 'baseUrl' | 'platform' | 'externalId' | 'context'>,
|
|
206
|
+
cache: TtlCache<CachedDelivery> = sharedDeliveryCache,
|
|
207
|
+
): void {
|
|
208
|
+
cache.delete(deliveryCacheKey(opts))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export { sharedDeliveryCache }
|