@growth-labs/conformance 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.
package/src/policy.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { Audience, HeaderMap, RenderMode } from './schema.js'
2
+
3
+ export const REQUIRED_SECURITY_HEADERS = [
4
+ 'content-security-policy',
5
+ 'strict-transport-security',
6
+ 'x-frame-options',
7
+ 'x-content-type-options',
8
+ 'referrer-policy',
9
+ 'permissions-policy',
10
+ ] as const
11
+
12
+ export interface HeaderPolicyOptions {
13
+ renderMode: RenderMode
14
+ audience: Audience
15
+ nonce?: string
16
+ markdownTwinUrl?: string
17
+ }
18
+
19
+ export interface CachePolicyOptions {
20
+ renderMode: RenderMode
21
+ audience: Audience
22
+ }
23
+
24
+ export function buildSharedPublicationHeaders(
25
+ options: HeaderPolicyOptions,
26
+ ): Record<string, string> {
27
+ const headers: Record<string, string> = {
28
+ 'Content-Security-Policy': buildContentSecurityPolicy(options.nonce),
29
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
30
+ 'X-Frame-Options': 'DENY',
31
+ 'X-Content-Type-Options': 'nosniff',
32
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
33
+ 'Permissions-Policy':
34
+ 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()',
35
+ 'Cache-Control': buildCacheControl(options),
36
+ }
37
+
38
+ if (options.markdownTwinUrl) {
39
+ headers.Link = `<${options.markdownTwinUrl}>; rel="alternate"; type="text/markdown"`
40
+ }
41
+
42
+ return headers
43
+ }
44
+
45
+ export function buildContentSecurityPolicy(nonce?: string): string {
46
+ const scriptSources = ["'self'"]
47
+ const styleSources = ["'self'"]
48
+ if (nonce) {
49
+ scriptSources.push(`'nonce-${nonce}'`)
50
+ styleSources.push(`'nonce-${nonce}'`)
51
+ }
52
+
53
+ return [
54
+ "default-src 'self'",
55
+ `script-src ${scriptSources.join(' ')}`,
56
+ `style-src ${styleSources.join(' ')}`,
57
+ "img-src 'self' data: https:",
58
+ "font-src 'self' data:",
59
+ "connect-src 'self' https:",
60
+ "object-src 'none'",
61
+ "base-uri 'self'",
62
+ "frame-ancestors 'none'",
63
+ 'upgrade-insecure-requests',
64
+ ].join('; ')
65
+ }
66
+
67
+ export function buildCacheControl({ renderMode, audience }: CachePolicyOptions): string {
68
+ if (isPrivateAudience(audience)) return 'private, no-store'
69
+ if (renderMode === 'static') {
70
+ return 'public, max-age=300, s-maxage=86400, stale-while-revalidate=604800'
71
+ }
72
+ return 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400'
73
+ }
74
+
75
+ export function normalizeHeaders(headers: HeaderMap | undefined): Record<string, string> {
76
+ const normalized: Record<string, string> = {}
77
+ if (!headers) return normalized
78
+
79
+ for (const [name, value] of Object.entries(headers)) {
80
+ if (typeof value === 'string') {
81
+ normalized[name.toLowerCase()] = value
82
+ } else if (Array.isArray(value)) {
83
+ normalized[name.toLowerCase()] = value.join(', ')
84
+ }
85
+ }
86
+
87
+ return normalized
88
+ }
89
+
90
+ export function isPrivateAudience(audience: Audience): boolean {
91
+ return (
92
+ audience === 'subscriber' ||
93
+ audience === 'member' ||
94
+ audience === 'admin' ||
95
+ audience === 'private'
96
+ )
97
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,477 @@
1
+ import { extractHtmlSignals, getSchemaTypes, type HtmlSignals, nodeHasSchemaType } from './html.js'
2
+ import { isPrivateAudience, normalizeHeaders, REQUIRED_SECURITY_HEADERS } from './policy.js'
3
+ import {
4
+ type Audience,
5
+ type ConformanceCheckReceipt,
6
+ type ConformanceFinding,
7
+ type ConformanceFixture,
8
+ type ConformanceFixtureInput,
9
+ conformanceFixtureSchema,
10
+ EVIDENCE_SCHEMA_VERSION,
11
+ type FleetEvidenceBundle,
12
+ fleetEvidenceBundleSchema,
13
+ type SiteEvidenceBundle,
14
+ siteEvidenceBundleSchema,
15
+ } from './schema.js'
16
+
17
+ const REQUIRED_PUBLICATION_SURFACES = ['sitemap', 'robots', 'feed'] as const
18
+ const DISCOVER_HERO_MIN_WIDTH = 1200
19
+
20
+ export interface RunConformanceOptions {
21
+ now?: () => string
22
+ }
23
+
24
+ export function runSiteConformance(
25
+ input: ConformanceFixtureInput,
26
+ options: RunConformanceOptions = {},
27
+ ): SiteEvidenceBundle {
28
+ const fixture = conformanceFixtureSchema.parse(input)
29
+ const generatedAt = options.now?.() ?? new Date().toISOString()
30
+ const htmlSignals = extractHtmlSignals(fixture.response.html ?? fixture.response.body ?? '')
31
+ const checks = [
32
+ checkSecurityHeaders(fixture),
33
+ checkMetadata(fixture, htmlSignals),
34
+ checkStructuredData(fixture, htmlSignals),
35
+ checkImages(fixture, htmlSignals),
36
+ checkPublicationSurfaces(fixture),
37
+ checkCache(fixture),
38
+ checkBuildEvidence(fixture),
39
+ ]
40
+ const findings = checks.flatMap((check) => check.findings)
41
+ const summary = summarizeChecks(checks)
42
+ const bundle: SiteEvidenceBundle = {
43
+ schemaVersion: EVIDENCE_SCHEMA_VERSION,
44
+ generatedAt,
45
+ fixtureId: fixture.id,
46
+ site: fixture.site,
47
+ environment: fixture.environment,
48
+ url: fixture.url,
49
+ status: findings.some((finding) => finding.severity === 'error') ? 'fail' : 'pass',
50
+ summary,
51
+ checks,
52
+ findings,
53
+ }
54
+
55
+ return siteEvidenceBundleSchema.parse(bundle)
56
+ }
57
+
58
+ export function runFleetConformance(
59
+ inputs: ConformanceFixtureInput[],
60
+ options: RunConformanceOptions = {},
61
+ ): FleetEvidenceBundle {
62
+ const generatedAt = options.now?.() ?? new Date().toISOString()
63
+ const sites = inputs.map((input) => runSiteConformance(input, { now: () => generatedAt }))
64
+ const summary = sites.reduce(
65
+ (acc, site) => {
66
+ acc.pass += site.summary.pass
67
+ acc.fail += site.summary.fail
68
+ acc.na += site.summary.na
69
+ acc.findings += site.summary.findings
70
+ return acc
71
+ },
72
+ { pass: 0, fail: 0, na: 0, findings: 0, sites: sites.length },
73
+ )
74
+
75
+ const bundle: FleetEvidenceBundle = {
76
+ schemaVersion: EVIDENCE_SCHEMA_VERSION,
77
+ generatedAt,
78
+ status: sites.some((site) => site.status === 'fail') ? 'fail' : 'pass',
79
+ summary,
80
+ sites,
81
+ }
82
+
83
+ return fleetEvidenceBundleSchema.parse(bundle)
84
+ }
85
+
86
+ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckReceipt {
87
+ const headers = normalizeHeaders(fixture.response.headers)
88
+ const findings: ConformanceFinding[] = []
89
+ const missingSecurityHeaders = REQUIRED_SECURITY_HEADERS.filter((name) => !headers[name])
90
+
91
+ if (
92
+ fixture.renderMode === 'ssr' &&
93
+ fixture.buildEvidence?.staticHeaderPolicy === true &&
94
+ fixture.buildEvidence.ssrHeaderPolicy !== true &&
95
+ missingSecurityHeaders.length > 0
96
+ ) {
97
+ findings.push({
98
+ code: 'security.headers.static_only_for_ssr',
99
+ severity: 'error',
100
+ message:
101
+ 'SSR response is missing runtime security headers while build evidence only declares static header policy.',
102
+ path: 'response.headers',
103
+ evidence: `missing=${missingSecurityHeaders.join(',')}`,
104
+ remediation:
105
+ 'Apply the shared header policy in the Worker response path as well as static output.',
106
+ })
107
+ }
108
+
109
+ for (const header of missingSecurityHeaders) {
110
+ findings.push({
111
+ code: 'security.header.missing',
112
+ severity: 'error',
113
+ message: `Missing required security header: ${header}.`,
114
+ path: `response.headers.${header}`,
115
+ })
116
+ }
117
+
118
+ const csp = headers['content-security-policy']
119
+ if (csp?.toLowerCase().includes('unsafe-inline')) {
120
+ findings.push({
121
+ code: 'security.csp.unsafe_inline',
122
+ severity: 'error',
123
+ message: 'Content-Security-Policy must not allow unsafe-inline script or style execution.',
124
+ path: 'response.headers.content-security-policy',
125
+ evidence: csp,
126
+ remediation: 'Use nonces or hashes in the shared CSP policy instead of unsafe-inline.',
127
+ })
128
+ }
129
+
130
+ const xContentType = headers['x-content-type-options']
131
+ if (xContentType && xContentType.toLowerCase() !== 'nosniff') {
132
+ findings.push({
133
+ code: 'security.header.invalid',
134
+ severity: 'error',
135
+ message: 'X-Content-Type-Options must be nosniff.',
136
+ path: 'response.headers.x-content-type-options',
137
+ evidence: xContentType,
138
+ })
139
+ }
140
+
141
+ const hsts = headers['strict-transport-security']
142
+ if (hsts && !/max-age=\d+/i.test(hsts)) {
143
+ findings.push({
144
+ code: 'security.header.invalid',
145
+ severity: 'error',
146
+ message: 'Strict-Transport-Security must declare max-age.',
147
+ path: 'response.headers.strict-transport-security',
148
+ evidence: hsts,
149
+ })
150
+ }
151
+
152
+ const cacheControl = headers['cache-control']
153
+ if (!cacheControl) {
154
+ findings.push({
155
+ code: 'cache.header.missing',
156
+ severity: 'error',
157
+ message: 'HTML responses must declare Cache-Control semantics.',
158
+ path: 'response.headers.cache-control',
159
+ })
160
+ } else if (isPrivateAudience(fixture.audience) && /\bpublic\b/i.test(cacheControl)) {
161
+ findings.push({
162
+ code: 'cache.private_audience_public',
163
+ severity: 'error',
164
+ message: 'Private or entitled audience responses must not use public cache semantics.',
165
+ path: 'response.headers.cache-control',
166
+ evidence: cacheControl,
167
+ })
168
+ }
169
+
170
+ return receipt('security-headers', findings)
171
+ }
172
+
173
+ function checkMetadata(
174
+ fixture: ConformanceFixture,
175
+ htmlSignals: HtmlSignals,
176
+ ): ConformanceCheckReceipt {
177
+ const findings: ConformanceFinding[] = []
178
+
179
+ if (!htmlSignals.canonicalUrl) {
180
+ findings.push({
181
+ code: 'metadata.canonical.missing',
182
+ severity: 'error',
183
+ message: 'Renderable HTML must include a canonical link.',
184
+ path: 'html.head.link[rel=canonical]',
185
+ })
186
+ } else if (
187
+ fixture.expected.canonicalUrl &&
188
+ htmlSignals.canonicalUrl !== fixture.expected.canonicalUrl
189
+ ) {
190
+ findings.push({
191
+ code: 'metadata.canonical.runtime_override_not_applied',
192
+ severity: 'error',
193
+ message: 'Rendered canonical URL does not match the runtime canonical override.',
194
+ path: 'html.head.link[rel=canonical].href',
195
+ evidence: `expected=${fixture.expected.canonicalUrl}; actual=${htmlSignals.canonicalUrl}`,
196
+ remediation: 'Apply runtime canonical composition before rendering metadata.',
197
+ })
198
+ }
199
+
200
+ const robots = htmlSignals.robotsContent
201
+ const robotsDirectives =
202
+ robots
203
+ ?.toLowerCase()
204
+ .split(',')
205
+ .map((part) => part.trim()) ?? []
206
+ if (!robotsDirectives.includes('max-image-preview:large')) {
207
+ findings.push({
208
+ code: 'metadata.robots.missing_max_image_preview_large',
209
+ severity: 'error',
210
+ message: 'Robots metadata must include max-image-preview:large for Discover eligibility.',
211
+ path: 'html.head.meta[name=robots].content',
212
+ evidence: robots ?? 'missing',
213
+ remediation: 'Compose robots metadata globally with max-image-preview:large.',
214
+ })
215
+ }
216
+
217
+ return receipt('metadata', findings)
218
+ }
219
+
220
+ function checkStructuredData(
221
+ fixture: ConformanceFixture,
222
+ htmlSignals: HtmlSignals,
223
+ ): ConformanceCheckReceipt {
224
+ const findings: ConformanceFinding[] = htmlSignals.jsonLdParseErrors.map((error) => ({
225
+ code: 'structured_data.json_ld.invalid',
226
+ severity: 'error',
227
+ message: `Invalid JSON-LD: ${error.message}`,
228
+ path: error.path,
229
+ }))
230
+
231
+ const allTypes = new Set(htmlSignals.jsonLdNodes.flatMap((node) => getSchemaTypes(node)))
232
+ for (const expectedType of fixture.expected.schemaTypes) {
233
+ if (!allTypes.has(expectedType)) {
234
+ findings.push({
235
+ code: 'structured_data.schema_type.missing',
236
+ severity: 'error',
237
+ message: `Expected JSON-LD schema type ${expectedType} was not rendered.`,
238
+ path: 'jsonLd.@type',
239
+ })
240
+ }
241
+ }
242
+
243
+ htmlSignals.jsonLdNodes.forEach((node, index) => {
244
+ if (
245
+ (nodeHasSchemaType(node, 'Article') ||
246
+ nodeHasSchemaType(node, 'NewsArticle') ||
247
+ nodeHasSchemaType(node, 'VideoObject')) &&
248
+ typeof node.isAccessibleForFree === 'string'
249
+ ) {
250
+ findings.push({
251
+ code: 'structured_data.is_accessible_for_free.stringified',
252
+ severity: 'error',
253
+ message: 'isAccessibleForFree must be a boolean, not a string.',
254
+ path: `jsonLd[${index}].isAccessibleForFree`,
255
+ evidence: node.isAccessibleForFree,
256
+ remediation: 'Return structured data objects with boolean values before serialization.',
257
+ })
258
+ }
259
+
260
+ if (nodeHasSchemaType(node, 'VideoObject') && isDateOnlyString(node.uploadDate)) {
261
+ findings.push({
262
+ code: 'structured_data.video_object.upload_date.date_only',
263
+ severity: 'error',
264
+ message: 'VideoObject uploadDate must include a full ISO date-time with timezone.',
265
+ path: `jsonLd[${index}].uploadDate`,
266
+ evidence: String(node.uploadDate),
267
+ remediation: 'Use an ISO 8601 date-time such as 2026-07-15T14:30:00Z.',
268
+ })
269
+ }
270
+ })
271
+
272
+ return receipt('structured-data', findings)
273
+ }
274
+
275
+ function checkImages(
276
+ fixture: ConformanceFixture,
277
+ htmlSignals: HtmlSignals,
278
+ ): ConformanceCheckReceipt {
279
+ const findings: ConformanceFinding[] = []
280
+ const images = htmlSignals.jsonLdImages
281
+
282
+ if (fixture.expected.discoverEligible && images.length === 0) {
283
+ findings.push({
284
+ code: 'images.discover_image.missing',
285
+ severity: 'error',
286
+ message: 'Discover-eligible pages must expose structured image data.',
287
+ path: 'jsonLd.image',
288
+ })
289
+ }
290
+
291
+ for (const image of images) {
292
+ if (image.width === undefined || image.height === undefined) {
293
+ findings.push({
294
+ code: 'images.dimensions.missing',
295
+ severity: 'error',
296
+ message: 'Structured image objects must include width and height.',
297
+ path: image.path,
298
+ evidence: image.url,
299
+ remediation: 'Return JSON-LD image objects with url, width, and height.',
300
+ })
301
+ }
302
+ }
303
+
304
+ const measuredWidths = images
305
+ .map((image) => image.width)
306
+ .filter((width): width is number => typeof width === 'number')
307
+ if (
308
+ fixture.expected.discoverEligible &&
309
+ measuredWidths.length > 0 &&
310
+ Math.max(...measuredWidths) < DISCOVER_HERO_MIN_WIDTH
311
+ ) {
312
+ findings.push({
313
+ code: 'images.hero.under_1200',
314
+ severity: 'error',
315
+ message: 'Discover-eligible hero image width must be at least 1200px.',
316
+ path: 'jsonLd.image',
317
+ evidence: `maxWidth=${Math.max(...measuredWidths)}`,
318
+ remediation: 'Provide a 1200px or larger image variant in JSON-LD and social metadata.',
319
+ })
320
+ }
321
+
322
+ return receipt('images', findings)
323
+ }
324
+
325
+ function checkPublicationSurfaces(fixture: ConformanceFixture): ConformanceCheckReceipt {
326
+ const findings: ConformanceFinding[] = []
327
+ const surfacesByName = new Map(
328
+ fixture.publicationSurfaces.map((surface) => [surface.name, surface]),
329
+ )
330
+
331
+ for (const required of REQUIRED_PUBLICATION_SURFACES) {
332
+ if (!surfacesByName.has(required)) {
333
+ findings.push({
334
+ code: 'publication.surface.missing',
335
+ severity: 'error',
336
+ message: `Publication surface ${required} must be present or explicitly declared NA.`,
337
+ path: `publicationSurfaces.${required}`,
338
+ })
339
+ }
340
+ }
341
+
342
+ fixture.publicationSurfaces.forEach((surface, index) => {
343
+ const path = `publicationSurfaces[${index}]`
344
+ if (surface.status === 'na') {
345
+ if (!surface.capabilityBoundary || !surface.evidence) {
346
+ findings.push({
347
+ code: 'publication.na.undeclared',
348
+ severity: 'error',
349
+ message: 'NA publication surfaces require a capability boundary and evidence.',
350
+ path,
351
+ evidence: surface.name,
352
+ remediation:
353
+ 'Declare why the site cannot provide the surface and attach supporting evidence.',
354
+ })
355
+ }
356
+ return
357
+ }
358
+
359
+ if (surface.status === 'missing') {
360
+ findings.push({
361
+ code: 'publication.surface.missing',
362
+ severity: 'error',
363
+ message: `Publication surface ${surface.name} is missing.`,
364
+ path,
365
+ })
366
+ return
367
+ }
368
+
369
+ if (surface.httpStatus !== undefined && surface.httpStatus >= 400) {
370
+ findings.push({
371
+ code: 'publication.surface.http_error',
372
+ severity: 'error',
373
+ message: `Publication surface ${surface.name} returned HTTP ${surface.httpStatus}.`,
374
+ path: `${path}.httpStatus`,
375
+ })
376
+ }
377
+
378
+ if (surface.bodyBytes !== undefined && surface.bodyBytes === 0) {
379
+ findings.push({
380
+ code: 'publication.surface.empty',
381
+ severity: 'error',
382
+ message: `Publication surface ${surface.name} has an empty body.`,
383
+ path: `${path}.bodyBytes`,
384
+ })
385
+ }
386
+ })
387
+
388
+ return receipt('publication-surfaces', findings)
389
+ }
390
+
391
+ function checkCache(fixture: ConformanceFixture): ConformanceCheckReceipt {
392
+ const findings: ConformanceFinding[] = []
393
+ const variants = fixture.cacheVariants
394
+
395
+ for (let leftIndex = 0; leftIndex < variants.length; leftIndex += 1) {
396
+ for (let rightIndex = leftIndex + 1; rightIndex < variants.length; rightIndex += 1) {
397
+ const left = variants[leftIndex]
398
+ const right = variants[rightIndex]
399
+ if (!left || !right || left.cacheKey !== right.cacheKey) continue
400
+ if (isCrawlerPublicCollision(left.audience, right.audience)) {
401
+ findings.push({
402
+ code: 'cache.crawler_public_key_collision',
403
+ severity: 'error',
404
+ message: 'Crawler and public HTML variants must not share a cache key.',
405
+ path: `cacheVariants[${leftIndex}],cacheVariants[${rightIndex}]`,
406
+ evidence: left.cacheKey,
407
+ remediation:
408
+ 'Include audience/crawler class in cache keys when content or headers differ.',
409
+ })
410
+ }
411
+ }
412
+ }
413
+
414
+ return receipt('cache', findings)
415
+ }
416
+
417
+ function checkBuildEvidence(fixture: ConformanceFixture): ConformanceCheckReceipt {
418
+ const findings: ConformanceFinding[] = []
419
+ const buildEvidence = fixture.buildEvidence
420
+
421
+ if (!buildEvidence) {
422
+ findings.push({
423
+ code: 'build.evidence.missing',
424
+ severity: 'error',
425
+ message: 'Conformance fixtures must include build evidence.',
426
+ path: 'buildEvidence',
427
+ })
428
+ return receipt('build-evidence', findings)
429
+ }
430
+
431
+ if (!buildEvidence.commitSha && !buildEvidence.artifactId) {
432
+ findings.push({
433
+ code: 'build.evidence.identity_missing',
434
+ severity: 'error',
435
+ message: 'Build evidence must include a commit SHA or artifact identifier.',
436
+ path: 'buildEvidence',
437
+ })
438
+ }
439
+
440
+ if (Object.keys(buildEvidence.packageVersions).length === 0) {
441
+ findings.push({
442
+ code: 'build.evidence.package_versions_missing',
443
+ severity: 'error',
444
+ message: 'Build evidence must include package versions used by the site.',
445
+ path: 'buildEvidence.packageVersions',
446
+ })
447
+ }
448
+
449
+ return receipt('build-evidence', findings)
450
+ }
451
+
452
+ function summarizeChecks(checks: ConformanceCheckReceipt[]) {
453
+ return checks.reduce(
454
+ (acc, check) => {
455
+ acc[check.status] += 1
456
+ acc.findings += check.findings.length
457
+ return acc
458
+ },
459
+ { pass: 0, fail: 0, na: 0, findings: 0 },
460
+ )
461
+ }
462
+
463
+ function receipt(name: string, findings: ConformanceFinding[]): ConformanceCheckReceipt {
464
+ return {
465
+ name,
466
+ status: findings.some((finding) => finding.severity === 'error') ? 'fail' : 'pass',
467
+ findings,
468
+ }
469
+ }
470
+
471
+ function isDateOnlyString(value: unknown): boolean {
472
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
473
+ }
474
+
475
+ function isCrawlerPublicCollision(left: Audience, right: Audience): boolean {
476
+ return (left === 'crawler' && right === 'public') || (left === 'public' && right === 'crawler')
477
+ }