@certrev/cert-block 0.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.
Files changed (99) hide show
  1. package/README.md +154 -0
  2. package/dist/components/CertBadge.d.ts +29 -0
  3. package/dist/components/CertBadge.d.ts.map +1 -0
  4. package/dist/components/CertBadge.js +36 -0
  5. package/dist/components/CertBadge.js.map +1 -0
  6. package/dist/components/CertJsonLd.d.ts +23 -0
  7. package/dist/components/CertJsonLd.d.ts.map +1 -0
  8. package/dist/components/CertJsonLd.js +10 -0
  9. package/dist/components/CertJsonLd.js.map +1 -0
  10. package/dist/components/CertRevBacklink.d.ts +18 -0
  11. package/dist/components/CertRevBacklink.d.ts.map +1 -0
  12. package/dist/components/CertRevBacklink.js +16 -0
  13. package/dist/components/CertRevBacklink.js.map +1 -0
  14. package/dist/components/CertReview.d.ts +23 -0
  15. package/dist/components/CertReview.d.ts.map +1 -0
  16. package/dist/components/CertReview.js +11 -0
  17. package/dist/components/CertReview.js.map +1 -0
  18. package/dist/components/ExpertBio.d.ts +17 -0
  19. package/dist/components/ExpertBio.d.ts.map +1 -0
  20. package/dist/components/ExpertBio.js +17 -0
  21. package/dist/components/ExpertBio.js.map +1 -0
  22. package/dist/components/escape.d.ts +36 -0
  23. package/dist/components/escape.d.ts.map +1 -0
  24. package/dist/components/escape.js +76 -0
  25. package/dist/components/escape.js.map +1 -0
  26. package/dist/components/format.d.ts +22 -0
  27. package/dist/components/format.d.ts.map +1 -0
  28. package/dist/components/format.js +42 -0
  29. package/dist/components/format.js.map +1 -0
  30. package/dist/contract/fixtures.d.ts +36 -0
  31. package/dist/contract/fixtures.d.ts.map +1 -0
  32. package/dist/contract/fixtures.js +87 -0
  33. package/dist/contract/fixtures.js.map +1 -0
  34. package/dist/contract/kernel-contract.d.ts +154 -0
  35. package/dist/contract/kernel-contract.d.ts.map +1 -0
  36. package/dist/contract/kernel-contract.js +35 -0
  37. package/dist/contract/kernel-contract.js.map +1 -0
  38. package/dist/contract/kernel-stub.d.ts +44 -0
  39. package/dist/contract/kernel-stub.d.ts.map +1 -0
  40. package/dist/contract/kernel-stub.js +163 -0
  41. package/dist/contract/kernel-stub.js.map +1 -0
  42. package/dist/contract/kernel.d.ts +20 -0
  43. package/dist/contract/kernel.d.ts.map +1 -0
  44. package/dist/contract/kernel.js +19 -0
  45. package/dist/contract/kernel.js.map +1 -0
  46. package/dist/contract/verdict-kernel.d.ts +34 -0
  47. package/dist/contract/verdict-kernel.d.ts.map +1 -0
  48. package/dist/contract/verdict-kernel.js +13 -0
  49. package/dist/contract/verdict-kernel.js.map +1 -0
  50. package/dist/index.d.ts +31 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +38 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/jsonld/project.d.ts +71 -0
  55. package/dist/jsonld/project.d.ts.map +1 -0
  56. package/dist/jsonld/project.js +183 -0
  57. package/dist/jsonld/project.js.map +1 -0
  58. package/dist/verify/cache.d.ts +56 -0
  59. package/dist/verify/cache.d.ts.map +1 -0
  60. package/dist/verify/cache.js +93 -0
  61. package/dist/verify/cache.js.map +1 -0
  62. package/dist/verify/get-verified-envelope.d.ts +65 -0
  63. package/dist/verify/get-verified-envelope.d.ts.map +1 -0
  64. package/dist/verify/get-verified-envelope.js +104 -0
  65. package/dist/verify/get-verified-envelope.js.map +1 -0
  66. package/dist/verify/resolve-kid.d.ts +38 -0
  67. package/dist/verify/resolve-kid.d.ts.map +1 -0
  68. package/dist/verify/resolve-kid.js +71 -0
  69. package/dist/verify/resolve-kid.js.map +1 -0
  70. package/dist/webcomponent/certrev-badge.d.ts +38 -0
  71. package/dist/webcomponent/certrev-badge.d.ts.map +1 -0
  72. package/dist/webcomponent/certrev-badge.js +98 -0
  73. package/dist/webcomponent/certrev-badge.js.map +1 -0
  74. package/dist/webcomponent/render-badge-html.d.ts +25 -0
  75. package/dist/webcomponent/render-badge-html.d.ts.map +1 -0
  76. package/dist/webcomponent/render-badge-html.js +81 -0
  77. package/dist/webcomponent/render-badge-html.js.map +1 -0
  78. package/package.json +70 -0
  79. package/src/__tests__/components.test.tsx +191 -0
  80. package/src/__tests__/project.test.ts +128 -0
  81. package/src/__tests__/verify.test.ts +203 -0
  82. package/src/__tests__/webcomponent.test.tsx +106 -0
  83. package/src/components/CertBadge.tsx +164 -0
  84. package/src/components/CertJsonLd.tsx +36 -0
  85. package/src/components/CertRevBacklink.tsx +63 -0
  86. package/src/components/CertReview.tsx +42 -0
  87. package/src/components/ExpertBio.tsx +77 -0
  88. package/src/components/escape.ts +72 -0
  89. package/src/components/format.ts +55 -0
  90. package/src/contract/fixtures.ts +107 -0
  91. package/src/contract/kernel.ts +20 -0
  92. package/src/contract/verdict-kernel.ts +47 -0
  93. package/src/index.ts +85 -0
  94. package/src/jsonld/project.ts +206 -0
  95. package/src/verify/cache.ts +116 -0
  96. package/src/verify/get-verified-envelope.ts +156 -0
  97. package/src/verify/resolve-kid.ts +103 -0
  98. package/src/webcomponent/certrev-badge.ts +100 -0
  99. package/src/webcomponent/render-badge-html.ts +106 -0
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@certrev/cert-block",
3
+ "version": "0.1.0",
4
+ "description": "Headless / crypto_verify render edge for the CertREV CertDeliveryEnvelope. SSR-safe React components (<CertBadge>/<ExpertBio>/<CertRevBacklink>), a deterministic schema.org JSON-LD projector, a fail-closed verify layer over the shared VerdictKernel, and a framework-agnostic <certrev-badge> Web Component. Sign once (portal), render everywhere (Hydrogen / Next / Builder / universal embed).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./webcomponent": {
14
+ "types": "./dist/webcomponent/certrev-badge.d.ts",
15
+ "default": "./dist/webcomponent/certrev-badge.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "registry": "https://registry.npmjs.org",
25
+ "access": "public"
26
+ },
27
+ "publishTargets": [
28
+ "npmjs"
29
+ ],
30
+ "peerDependencies": {
31
+ "@certrev/cert-contract": ">=0.1.1",
32
+ "react": ">=18.0.0"
33
+ },
34
+ "peerDependenciesMeta": {
35
+ "react": {
36
+ "optional": false
37
+ }
38
+ },
39
+ "devDependencies": {
40
+ "@testing-library/react": "^16.1.0",
41
+ "@types/node": "^20.0.0",
42
+ "@types/react": "^18.3.0",
43
+ "@types/react-dom": "^18.3.0",
44
+ "jsdom": "^25.0.0",
45
+ "react": "^18.3.1",
46
+ "react-dom": "^18.3.1",
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.5",
49
+ "@certrev/cert-contract": "0.1.1"
50
+ },
51
+ "keywords": [
52
+ "certrev",
53
+ "certification",
54
+ "react",
55
+ "server-components",
56
+ "hydrogen",
57
+ "shopify",
58
+ "json-ld",
59
+ "schema-org",
60
+ "web-component",
61
+ "ed25519",
62
+ "ssr"
63
+ ],
64
+ "license": "MIT",
65
+ "scripts": {
66
+ "build": "tsc",
67
+ "typecheck": "tsc --noEmit",
68
+ "test": "vitest run"
69
+ }
70
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * SSR-render tests for the React components, exercised through `react-dom/server`'s
3
+ * `renderToStaticMarkup` — the SAME path Hydrogen / Next use to produce the crawlable
4
+ * HTML. Rendering server-side here proves three contract properties at once:
5
+ * • SSR-safe: a component that touched `useState`/`useEffect`/`window` would throw under
6
+ * `renderToStaticMarkup`; passing means the components are render-pure.
7
+ * • Escaping: React escapes text + attributes, and our `safeHttpUrl` strips dangerous
8
+ * schemes — both asserted against hostile mock facts.
9
+ * • Fail-closed: a suppressed verdict / unsafe URL renders nothing.
10
+ *
11
+ * Facts are mocked via `makeMockPayload` (no crypto, no network — pure render under test).
12
+ */
13
+
14
+ import { renderToStaticMarkup } from 'react-dom/server'
15
+ import { describe, expect, it } from 'vitest'
16
+ import { CertBadge } from '../components/CertBadge.js'
17
+ import { CertJsonLd } from '../components/CertJsonLd.js'
18
+ import { CertRevBacklink } from '../components/CertRevBacklink.js'
19
+ import { CertReview } from '../components/CertReview.js'
20
+ import { ExpertBio } from '../components/ExpertBio.js'
21
+ import { makeMockPayload } from '../contract/fixtures.js'
22
+
23
+ const payload = makeMockPayload()
24
+
25
+ describe('<CertBadge>', () => {
26
+ it('renders the expert name, credentials, certified date, memo, and verify link (full style)', () => {
27
+ const html = renderToStaticMarkup(<CertBadge payload={payload} />)
28
+ expect(html).toContain('Dr. Jane Doe')
29
+ expect(html).toContain('MD, FAAD')
30
+ expect(html).toContain('Jun 21, 2026') // certifiedAt, deterministic UTC formatting
31
+ expect(html).toContain('retinol claims') // memo
32
+ expect(html).toContain('Verify on CertREV')
33
+ expect(html).toContain('href="https://certrev.com/verify/cert_fixture_001"')
34
+ })
35
+
36
+ it('carries an accessible name + the cert id data attribute', () => {
37
+ const html = renderToStaticMarkup(<CertBadge payload={payload} />)
38
+ expect(html).toContain('aria-label="Content reviewed by Dr. Jane Doe, MD, FAAD"')
39
+ expect(html).toContain('data-certrev-cert-id="cert_fixture_001"')
40
+ expect(html).toMatch(/^<section/)
41
+ })
42
+
43
+ it('applies the signed accent color as a CSS custom property + accent border', () => {
44
+ const html = renderToStaticMarkup(<CertBadge payload={payload} />)
45
+ expect(html).toContain('--certrev-accent:#7c3aed')
46
+ expect(html).toContain('border-inline-start-color:#7c3aed')
47
+ })
48
+
49
+ it('compact style collapses to one line — no memo, no dates, no photo', () => {
50
+ const html = renderToStaticMarkup(<CertBadge payload={payload} badgeStyle="compact" />)
51
+ expect(html).toContain('certrev-badge--compact')
52
+ expect(html).not.toContain('retinol claims')
53
+ expect(html).not.toContain('Certified')
54
+ expect(html).not.toContain('__photo')
55
+ })
56
+
57
+ it('honors display flags: showMemo=false hides the memo; showExpertPhoto=false hides the photo', () => {
58
+ const p = makeMockPayload({
59
+ content: { ...payload.content, display: { ...payload.content.display, showMemo: false, showExpertPhoto: false } },
60
+ })
61
+ const html = renderToStaticMarkup(<CertBadge payload={p} />)
62
+ expect(html).not.toContain('retinol claims')
63
+ expect(html).not.toContain('__photo')
64
+ })
65
+
66
+ it('ESCAPES a hostile display name (React text-escaping; no raw HTML injected)', () => {
67
+ const p = makeMockPayload({
68
+ content: {
69
+ ...payload.content,
70
+ expert: { ...payload.content.expert, displayName: '<img src=x onerror=alert(1)>' },
71
+ memo: null,
72
+ },
73
+ })
74
+ const html = renderToStaticMarkup(<CertBadge payload={p} />)
75
+ expect(html).not.toContain('<img src=x onerror=alert(1)>')
76
+ expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;')
77
+ })
78
+
79
+ it('DROPS a javascript: profile URL (safeHttpUrl) — the name renders unlinked, no href', () => {
80
+ const p = makeMockPayload({
81
+ content: {
82
+ ...payload.content,
83
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: literal javascript: scheme string for the XSS-drop assertion
84
+ expert: { ...payload.content.expert, profileUrl: 'javascript:alert(document.cookie)' },
85
+ },
86
+ })
87
+ const html = renderToStaticMarkup(<CertBadge payload={p} />)
88
+ expect(html).not.toContain('javascript:')
89
+ expect(html).not.toContain('__expert-link') // no anchor when the URL is unsafe
90
+ expect(html).toContain('Dr. Jane Doe') // name still rendered
91
+ })
92
+
93
+ it('renders the author byline only when the author is present + distinct from the expert', () => {
94
+ const distinct = renderToStaticMarkup(<CertBadge payload={payload} />)
95
+ expect(distinct).toContain('Written by Sam Writer, Senior Content Editor')
96
+
97
+ const same = makeMockPayload({
98
+ content: { ...payload.content, author: { name: 'Dr. Jane Doe', title: null } },
99
+ })
100
+ expect(renderToStaticMarkup(<CertBadge payload={same} />)).not.toContain('Written by')
101
+ })
102
+ })
103
+
104
+ describe('<ExpertBio>', () => {
105
+ it('renders the expert identity block with credentials expanded (abbr + full name)', () => {
106
+ const html = renderToStaticMarkup(<ExpertBio payload={payload} />)
107
+ expect(html).toMatch(/^<aside/)
108
+ expect(html).toContain('Dr. Jane Doe')
109
+ expect(html).toContain('<abbr title="Doctor of Medicine">MD</abbr>')
110
+ expect(html).toContain('Fellow of the American Academy of Dermatology')
111
+ expect(html).toContain('Verified expert reviewer')
112
+ })
113
+
114
+ it('respects the heading level prop for page-outline slotting', () => {
115
+ expect(renderToStaticMarkup(<ExpertBio payload={payload} headingLevel="h2" />)).toContain('<h2')
116
+ expect(renderToStaticMarkup(<ExpertBio payload={payload} />)).toContain('<h3') // default
117
+ })
118
+
119
+ it('omits the photo when showExpertPhoto is false', () => {
120
+ const p = makeMockPayload({
121
+ content: { ...payload.content, display: { ...payload.content.display, showExpertPhoto: false } },
122
+ })
123
+ expect(renderToStaticMarkup(<ExpertBio payload={p} />)).not.toContain('__photo')
124
+ })
125
+ })
126
+
127
+ describe('<CertRevBacklink>', () => {
128
+ it('renders the live verify link with an accessible label', () => {
129
+ const html = renderToStaticMarkup(<CertRevBacklink payload={payload} />)
130
+ expect(html).toMatch(/^<a/)
131
+ expect(html).toContain('href="https://certrev.com/verify/cert_fixture_001"')
132
+ expect(html).toContain('aria-label="Verify this certification on CertREV"')
133
+ expect(html).toContain('Verify on CertREV')
134
+ })
135
+
136
+ it('accepts a custom label', () => {
137
+ expect(renderToStaticMarkup(<CertRevBacklink payload={payload} label="Check the certificate" />)).toContain(
138
+ 'Check the certificate',
139
+ )
140
+ })
141
+
142
+ it('FAIL-CLOSED: renders nothing when the verify URL is unsafe/absent', () => {
143
+ const p = makeMockPayload({ content: { ...payload.content, verifyUrl: 'javascript:alert(1)' } })
144
+ expect(renderToStaticMarkup(<CertRevBacklink payload={p} />)).toBe('')
145
+ })
146
+ })
147
+
148
+ describe('<CertJsonLd>', () => {
149
+ it('emits an application/ld+json <script> with the projected graph', () => {
150
+ const html = renderToStaticMarkup(<CertJsonLd payload={payload} />)
151
+ expect(html).toContain('<script type="application/ld+json">')
152
+ expect(html).toContain('"@type":"Article"')
153
+ expect(html).toContain('"@type":"Review"')
154
+ expect(html).toContain('"reviewedBy"')
155
+ expect(html).toContain('Dr. Jane Doe')
156
+ })
157
+
158
+ it('neutralizes < in the serialized body so a hostile memo cannot break out of the tag', () => {
159
+ const p = makeMockPayload({
160
+ content: { ...payload.content, memo: 'safe </script><script>alert(1)</script>' },
161
+ })
162
+ const html = renderToStaticMarkup(<CertJsonLd payload={p} />)
163
+ // The only literal </script> in the output is the script CLOSE tag, not from the memo.
164
+ expect(html.match(/<\/script>/g)?.length).toBe(1)
165
+ expect(html).toContain('\\u003c/script')
166
+ })
167
+
168
+ it('threads pageUrl into the Article @id for host-graph merge', () => {
169
+ const html = renderToStaticMarkup(<CertJsonLd payload={payload} pageUrl="https://shop.example.com/a/b" />)
170
+ expect(html).toContain('"@id":"https://shop.example.com/a/b#article"')
171
+ })
172
+ })
173
+
174
+ describe('<CertReview> (composite, fail-closed at the component boundary)', () => {
175
+ it('renders badge + JSON-LD on a render verdict', () => {
176
+ const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'render', payload }} />)
177
+ expect(html).toContain('certrev-badge')
178
+ expect(html).toContain('application/ld+json')
179
+ })
180
+
181
+ it('renders NOTHING on a suppress verdict', () => {
182
+ const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'suppress', reason: 'revoked' }} />)
183
+ expect(html).toBe('')
184
+ })
185
+
186
+ it('omitJsonLd drops the structured-data script but keeps the badge', () => {
187
+ const html = renderToStaticMarkup(<CertReview verdict={{ decision: 'render', payload }} omitJsonLd />)
188
+ expect(html).toContain('certrev-badge')
189
+ expect(html).not.toContain('application/ld+json')
190
+ })
191
+ })
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { makeMockPayload } from '../contract/fixtures.js'
3
+ import {
4
+ type JsonLdValue,
5
+ projectCertJsonLd,
6
+ projectCertJsonLdString,
7
+ serializeJsonLdForScript,
8
+ } from '../jsonld/project.js'
9
+
10
+ function graphOf(value: JsonLdValue | JsonLdValue[]): JsonLdValue[] {
11
+ if (Array.isArray(value)) return value
12
+ return value['@graph'] as JsonLdValue[]
13
+ }
14
+ function nodeOfType(value: JsonLdValue | JsonLdValue[], type: string): JsonLdValue {
15
+ const n = graphOf(value).find((x) => x['@type'] === type)
16
+ if (!n) throw new Error(`no ${type} node`)
17
+ return n
18
+ }
19
+
20
+ describe('projectCertJsonLd', () => {
21
+ it('emits a wrapped @graph with Article, Review, Person(expert), Organization', () => {
22
+ const g = projectCertJsonLd(makeMockPayload())
23
+ expect(Array.isArray(g)).toBe(false)
24
+ const graph = graphOf(g)
25
+ const types = graph.map((n) => n['@type']).sort()
26
+ expect(types).toEqual(['Article', 'Organization', 'Person', 'Review'])
27
+ expect((g as JsonLdValue)['@context']).toBe('https://schema.org')
28
+ })
29
+
30
+ it('Article @id aligns to the page URL (#article) so it MERGES into the host/Yoast node', () => {
31
+ const g = projectCertJsonLd(makeMockPayload(), {
32
+ pageUrl: 'https://brand.example.com/blogs/skincare/retinol-guide?utm=x',
33
+ })
34
+ const article = nodeOfType(g, 'Article')
35
+ // Fragment + query stripped, #article appended — the Yoast convention.
36
+ expect(article['@id']).toBe('https://brand.example.com/blogs/skincare/retinol-guide#article')
37
+ })
38
+
39
+ it('falls back to subject.canonicalUrls[0] for the Article @id when no pageUrl given', () => {
40
+ const article = nodeOfType(projectCertJsonLd(makeMockPayload()), 'Article')
41
+ expect(article['@id']).toBe('https://brand.example.com/blogs/skincare/retinol-guide#article')
42
+ })
43
+
44
+ it('attaches the EXPERT as reviewedBy (the E-E-A-T signal), not as author', () => {
45
+ const g = projectCertJsonLd(makeMockPayload())
46
+ const article = nodeOfType(g, 'Article')
47
+ const person = nodeOfType(g, 'Person')
48
+ expect(person.name).toBe('Dr. Jane Doe')
49
+ expect((article.reviewedBy as JsonLdValue)['@id']).toBe(person['@id'])
50
+ // Distinct author present + attached separately.
51
+ expect((article.author as JsonLdValue).name).toBe('Sam Writer')
52
+ })
53
+
54
+ it('projects credentials as hasCredential + honorificSuffix', () => {
55
+ const person = nodeOfType(projectCertJsonLd(makeMockPayload()), 'Person')
56
+ expect(person.honorificSuffix).toBe('MD, FAAD')
57
+ expect((person.hasCredential as JsonLdValue[]).map((c) => c.alternateName)).toEqual(['MD', 'FAAD'])
58
+ })
59
+
60
+ it('Review node binds itemReviewed→Article, author→expert, publisher→CertREV org', () => {
61
+ const g = projectCertJsonLd(makeMockPayload())
62
+ const review = nodeOfType(g, 'Review')
63
+ const article = nodeOfType(g, 'Article')
64
+ const person = nodeOfType(g, 'Person')
65
+ const org = nodeOfType(g, 'Organization')
66
+ expect((review.itemReviewed as JsonLdValue)['@id']).toBe(article['@id'])
67
+ expect((review.author as JsonLdValue)['@id']).toBe(person['@id'])
68
+ expect((review.publisher as JsonLdValue)['@id']).toBe(org['@id'])
69
+ expect(review.reviewBody).toContain('retinol claims')
70
+ })
71
+
72
+ it('CertREV node @ids are namespaced under certrev.com so they cannot collide with host nodes', () => {
73
+ const g = projectCertJsonLd(makeMockPayload())
74
+ const person = nodeOfType(g, 'Person')
75
+ const org = nodeOfType(g, 'Organization')
76
+ const review = nodeOfType(g, 'Review')
77
+ for (const id of [person['@id'], org['@id'], review['@id']]) {
78
+ expect(String(id).startsWith('https://certrev.com/')).toBe(true)
79
+ }
80
+ })
81
+
82
+ it('omits dateModified when contentModifiedAt is null', () => {
83
+ const article = nodeOfType(
84
+ projectCertJsonLd(makeMockPayload({ content: { ...makeMockPayload().content, contentModifiedAt: null } })),
85
+ 'Article',
86
+ )
87
+ expect('dateModified' in article).toBe(false)
88
+ })
89
+
90
+ it('does not double-attribute when author === expert', () => {
91
+ const p = makeMockPayload()
92
+ const sameAuthor = makeMockPayload({
93
+ content: { ...p.content, author: { name: p.content.expert.displayName, title: null } },
94
+ })
95
+ const article = nodeOfType(projectCertJsonLd(sameAuthor), 'Article')
96
+ expect('author' in article).toBe(false)
97
+ })
98
+
99
+ it('is DETERMINISTIC — same facts → byte-identical serialization', () => {
100
+ const a = projectCertJsonLdString(makeMockPayload(), { pageUrl: 'https://x.com/p' })
101
+ const b = projectCertJsonLdString(makeMockPayload(), { pageUrl: 'https://x.com/p' })
102
+ expect(a).toBe(b)
103
+ })
104
+
105
+ it('wrapGraph:false returns a bare node array for merge-into-existing-graph callers', () => {
106
+ const arr = projectCertJsonLd(makeMockPayload(), { wrapGraph: false })
107
+ expect(Array.isArray(arr)).toBe(true)
108
+ })
109
+ })
110
+
111
+ describe('serializeJsonLdForScript', () => {
112
+ it('neutralizes </script> so the body cannot break out of the <script> tag', () => {
113
+ const evilPayload = makeMockPayload({
114
+ content: { ...makeMockPayload().content, memo: 'totally safe </script><script>alert(1)</script>' },
115
+ })
116
+ const out = projectCertJsonLdString(evilPayload)
117
+ expect(out).not.toContain('</script>')
118
+ expect(out).toContain('\\u003c/script')
119
+ // Still valid JSON that round-trips to the original string.
120
+ const parsed = JSON.parse(out.replace(/\\u003c/g, '<'))
121
+ const review = (parsed['@graph'] as JsonLdValue[]).find((n) => n['@type'] === 'Review')
122
+ expect(review?.reviewBody).toContain('</script>')
123
+ })
124
+
125
+ it('escapes every < (HTML-comment opener defense)', () => {
126
+ expect(serializeJsonLdForScript({ a: '<!-- x -->' })).toBe('{"a":"\\u003c!-- x --\\u003e"}')
127
+ })
128
+ })
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { makeSignedEnvelope } from '../contract/fixtures.js'
3
+ import type { CertDeliveryEnvelope } from '../contract/kernel.js'
4
+ import { verifyEnvelope } from '../contract/kernel.js'
5
+ import { TtlCache } from '../verify/cache.js'
6
+ import { getVerifiedEnvelope } from '../verify/get-verified-envelope.js'
7
+ import { staticKidResolver } from '../verify/resolve-kid.js'
8
+
9
+ const RENDER_CTX = { platform: 'shopify', externalId: 'gid://shopify/Article/123456789' }
10
+
11
+ describe('kernel via the SDK binding (real Ed25519 over JCS)', () => {
12
+ it('a freshly signed fixture envelope verifies + renders, carrying the payload', async () => {
13
+ const { envelope, resolveKid } = makeSignedEnvelope()
14
+ const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') })
15
+ expect(verdict.decision).toBe('render')
16
+ if (verdict.decision === 'render') expect(verdict.payload.certId).toBe('cert_fixture_001')
17
+ })
18
+
19
+ it('a tampered payload fails the signature (fail-closed suppress)', async () => {
20
+ const { envelope, resolveKid } = makeSignedEnvelope()
21
+ const tampered: CertDeliveryEnvelope = { ...envelope, payload: { ...envelope.payload, certId: 'cert_FORGED' } }
22
+ expect(await verifyEnvelope(tampered, resolveKid, RENDER_CTX)).toEqual({
23
+ decision: 'suppress',
24
+ reason: 'invalid_signature',
25
+ })
26
+ })
27
+
28
+ it('an unknown kid suppresses unknown_key', async () => {
29
+ const { envelope } = makeSignedEnvelope()
30
+ const verdict = await verifyEnvelope(envelope, () => null, RENDER_CTX)
31
+ expect(verdict).toEqual({ decision: 'suppress', reason: 'unknown_key' })
32
+ })
33
+
34
+ it('platform mismatch suppresses', async () => {
35
+ const { envelope, resolveKid } = makeSignedEnvelope()
36
+ expect(await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, platform: 'wordpress' })).toEqual({
37
+ decision: 'suppress',
38
+ reason: 'platform_mismatch',
39
+ })
40
+ })
41
+
42
+ it('revoked suppresses', async () => {
43
+ const { envelope, resolveKid } = makeSignedEnvelope({
44
+ lifecycle: {
45
+ issuedAt: '2026-06-21T00:00:00Z',
46
+ expiresAt: '2099-01-01T00:00:00Z',
47
+ revokedAt: '2026-06-21T12:00:00Z',
48
+ revision: 2,
49
+ },
50
+ })
51
+ expect((await verifyEnvelope(envelope, resolveKid, RENDER_CTX)).decision).toBe('suppress')
52
+ })
53
+
54
+ it('expired suppresses', async () => {
55
+ const { envelope, resolveKid } = makeSignedEnvelope({
56
+ lifecycle: { issuedAt: '2026-01-01T00:00:00Z', expiresAt: '2026-02-01T00:00:00Z', revokedAt: null, revision: 1 },
57
+ })
58
+ const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, now: new Date('2026-03-01T00:00:00Z') })
59
+ expect(verdict).toEqual({ decision: 'suppress', reason: 'expired' })
60
+ })
61
+
62
+ it('content drift (live hash != subject.contentDigest) suppresses', async () => {
63
+ const { envelope, resolveKid } = makeSignedEnvelope()
64
+ const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, liveContentHash: 'b'.repeat(64) })
65
+ expect(verdict).toEqual({ decision: 'suppress', reason: 'content_drift' })
66
+ })
67
+
68
+ it('matching live hash renders', async () => {
69
+ const digest = 'c'.repeat(64)
70
+ const { envelope, resolveKid } = makeSignedEnvelope({
71
+ subject: { ...makeSignedEnvelope().envelope.payload.subject, contentDigest: digest },
72
+ })
73
+ const verdict = await verifyEnvelope(envelope, resolveKid, { ...RENDER_CTX, liveContentHash: digest })
74
+ expect(verdict.decision).toBe('render')
75
+ })
76
+ })
77
+
78
+ describe('getVerifiedEnvelope — metafield source', () => {
79
+ it('verifies an envelope read from a metafield (object form)', async () => {
80
+ const { envelope, resolveKid } = makeSignedEnvelope()
81
+ const verdict = await getVerifiedEnvelope({
82
+ source: { kind: 'metafield', value: envelope },
83
+ resolveKid,
84
+ context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
85
+ cache: new TtlCache(),
86
+ })
87
+ expect(verdict.decision).toBe('render')
88
+ })
89
+
90
+ it('verifies an envelope read from a metafield (JSON-string form)', async () => {
91
+ const { envelope, resolveKid } = makeSignedEnvelope()
92
+ const verdict = await getVerifiedEnvelope({
93
+ source: { kind: 'metafield', value: JSON.stringify(envelope) },
94
+ resolveKid,
95
+ context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
96
+ cache: new TtlCache(),
97
+ })
98
+ expect(verdict.decision).toBe('render')
99
+ })
100
+
101
+ it('a missing metafield value fails closed to suppress', async () => {
102
+ const { resolveKid } = makeSignedEnvelope()
103
+ const verdict = await getVerifiedEnvelope({
104
+ source: { kind: 'metafield', value: null },
105
+ resolveKid,
106
+ context: RENDER_CTX,
107
+ cache: new TtlCache(),
108
+ })
109
+ expect(verdict.decision).toBe('suppress')
110
+ })
111
+
112
+ it('malformed JSON in a metafield fails closed (no throw)', async () => {
113
+ const { resolveKid } = makeSignedEnvelope()
114
+ const verdict = await getVerifiedEnvelope({
115
+ source: { kind: 'metafield', value: '{not json' },
116
+ resolveKid,
117
+ context: RENDER_CTX,
118
+ cache: new TtlCache(),
119
+ })
120
+ expect(verdict.decision).toBe('suppress')
121
+ })
122
+ })
123
+
124
+ describe('getVerifiedEnvelope — Delivery API source', () => {
125
+ it('fetches from GET /api/cert/v1/delivery/{platform}/{externalId} and verifies', async () => {
126
+ const { envelope, resolveKid } = makeSignedEnvelope()
127
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify(envelope), { status: 200 }))
128
+ const verdict = await getVerifiedEnvelope({
129
+ source: {
130
+ kind: 'delivery_api',
131
+ baseUrl: 'https://portal.certrev.com',
132
+ platform: 'shopify',
133
+ externalId: 'gid://shopify/Article/123456789',
134
+ },
135
+ resolveKid,
136
+ context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
137
+ fetchImpl: fetchImpl as unknown as typeof fetch,
138
+ cache: new TtlCache(),
139
+ })
140
+ expect(verdict.decision).toBe('render')
141
+ expect(fetchImpl).toHaveBeenCalledOnce()
142
+ const calledUrl = (fetchImpl.mock.calls[0] as unknown[])[0] as string
143
+ expect(calledUrl).toBe(
144
+ 'https://portal.certrev.com/api/cert/v1/delivery/shopify/gid%3A%2F%2Fshopify%2FArticle%2F123456789',
145
+ )
146
+ })
147
+
148
+ it('a 404 / 410 from the Delivery API fails closed to suppress', async () => {
149
+ const { resolveKid } = makeSignedEnvelope()
150
+ const fetchImpl = vi.fn(async () => new Response('', { status: 410 }))
151
+ const verdict = await getVerifiedEnvelope({
152
+ source: { kind: 'delivery_api', baseUrl: 'https://portal.certrev.com', platform: 'shopify', externalId: 'x' },
153
+ resolveKid,
154
+ context: RENDER_CTX,
155
+ fetchImpl: fetchImpl as unknown as typeof fetch,
156
+ cache: new TtlCache(),
157
+ })
158
+ expect(verdict.decision).toBe('suppress')
159
+ })
160
+
161
+ it('SINGLE-FLIGHTS concurrent SSR renders — N renders → ONE fetch (thundering-herd guard)', async () => {
162
+ const { envelope, resolveKid } = makeSignedEnvelope()
163
+ let calls = 0
164
+ const fetchImpl = vi.fn(async () => {
165
+ calls++
166
+ await new Promise((r) => setTimeout(r, 10))
167
+ return new Response(JSON.stringify(envelope), { status: 200 })
168
+ })
169
+ const cache = new TtlCache<never>() as unknown as TtlCache<import('../contract/kernel.js').CertVerdict>
170
+ const opts = {
171
+ source: {
172
+ kind: 'delivery_api' as const,
173
+ baseUrl: 'https://portal.certrev.com',
174
+ platform: 'shopify',
175
+ externalId: 'gid://shopify/Article/123456789',
176
+ },
177
+ resolveKid,
178
+ context: { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') },
179
+ fetchImpl: fetchImpl as unknown as typeof fetch,
180
+ cache,
181
+ }
182
+ const verdicts = await Promise.all([
183
+ getVerifiedEnvelope(opts),
184
+ getVerifiedEnvelope(opts),
185
+ getVerifiedEnvelope(opts),
186
+ getVerifiedEnvelope(opts),
187
+ getVerifiedEnvelope(opts),
188
+ ])
189
+ expect(verdicts.every((v) => v.decision === 'render')).toBe(true)
190
+ expect(calls).toBe(1)
191
+ })
192
+ })
193
+
194
+ describe('staticKidResolver', () => {
195
+ it('resolves a configured kid and returns null for an unknown one', async () => {
196
+ const { envelope, publicKey, kid } = makeSignedEnvelope()
197
+ const pem = publicKey.export({ format: 'pem', type: 'spki' }).toString()
198
+ const resolver = staticKidResolver({ [kid]: pem })
199
+ const verdict = await verifyEnvelope(envelope, resolver, { ...RENDER_CTX, now: new Date('2026-06-22T00:00:00Z') })
200
+ expect(verdict.decision).toBe('render')
201
+ expect(await resolver('nope')).toBeNull()
202
+ })
203
+ })