From 379710b075a5e95277ebb72d319ef548412ff655 Mon Sep 17 00:00:00 2001 From: codekongdev <349702077+codekongdev@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:58:16 +0900 Subject: [PATCH] feat: publish versioned receipt JSON Schema --- README.md | 35 ++ examples/article/receipt.json | 120 +++++++ examples/job/receipt.json | 152 ++++++++ examples/product/receipt.json | 154 ++++++++ package-lock.json | 1 + packages/grounded-json/README.md | 13 + packages/grounded-json/package.json | 5 +- .../schemas/v1/receipt.schema.json | 334 ++++++++++++++++++ .../grounded-json/test/receipt-schema.test.ts | 246 +++++++++++++ scripts/pack-smoke.mjs | 6 + 10 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 examples/article/receipt.json create mode 100644 examples/job/receipt.json create mode 100644 examples/product/receipt.json create mode 100644 packages/grounded-json/schemas/v1/receipt.schema.json create mode 100644 packages/grounded-json/test/receipt-schema.test.ts diff --git a/README.md b/README.md index 5724631..5dfcb7d 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,41 @@ The last command creates a self-contained, script-free review report. Try the co The complete receipt also contains its format version, creation time, content-addressed source metadata, schema digest, structured issues, and a digest over the receipt payload. +## Receipt format + +Receipt format version 1 is described by the published +[JSON Schema](packages/grounded-json/schemas/v1/receipt.schema.json) +(draft 2020-12). The same file ships in the npm package as +`grounded-json/receipt.schema.json`; its `$id` and `manifest.version: 1` +identify the supported receipt format independently of the npm package version. + +External tools can resolve the schema locally without fetching it: + +```js +import { readFileSync } from 'node:fs'; + +const receiptSchema = JSON.parse( + readFileSync( + new URL(import.meta.resolve('grounded-json/receipt.schema.json')), + 'utf8', + ), +); +``` + +Use a draft 2020-12 validator to check receipt structure. This is distinct from +`receipt.schema.document`, which describes the extracted **data**. The receipt +schema checks required fields, supported versions, enums, and structural bounds; +it does not replace `parseReceipt` or `verifyReceipt`. Timestamp parsing, UTF-8 +byte limits, cross-field consistency, safe extraction-schema validation, hashes, +and evidence replay still require the runtime checks. Schema string lengths count +Unicode code points; runtime limits may additionally count UTF-16 code units or +UTF-8 bytes. + +The [product](examples/product/receipt.json), [job](examples/job/receipt.json), and +[article](examples/article/receipt.json) examples include deterministic version 1 +receipts. Tests validate every example receipt against this schema and replay +these fixtures against their local source pages. + ## Why grounded-json? | Typical extraction | grounded-json | diff --git a/examples/article/receipt.json b/examples/article/receipt.json new file mode 100644 index 0000000..689a001 --- /dev/null +++ b/examples/article/receipt.json @@ -0,0 +1,120 @@ +{ + "manifest": { + "format": "grounded-json/receipt", + "version": 1, + "createdAt": "2026-01-01T00:00:00.000Z", + "receiptHash": "sha256:fa319d7cc020b889044b3730bf4cf264082c6cab7ba7f114601ce1fbb7e55924" + }, + "data": { + "author": { + "name": "Ada Brooks" + }, + "datePublished": "2026-09-01", + "headline": "Why evidence belongs beside data", + "wordCount": 1842 + }, + "schema": { + "dialect": "https://json-schema.org/draft/2020-12/schema", + "subset": "grounded-json/safe-v1", + "document": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "datePublished": { + "type": "string" + }, + "headline": { + "type": "string" + }, + "wordCount": { + "type": "integer" + } + }, + "required": ["headline", "datePublished", "wordCount", "author"], + "title": "Article evidence receipt", + "type": "object" + }, + "contentHash": "sha256:a6453ad46d0c702787f9af873e72ecb064fe1a78d1403ec76398c093acc2fab2" + }, + "sources": [ + { + "id": "source:1150f4282453", + "mediaType": "text/html", + "contentHash": "sha256:1150f4282453f0884bd60e3c14a3038ecb5a5f3ce2e0ae97a57be42e88fc04f9", + "bytes": 577 + } + ], + "evidence": { + "/author/name": [ + { + "pointer": "/author/name", + "quote": "\"Ada Brooks\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:1150f4282453", + "sourceHash": "sha256:1150f4282453f0884bd60e3c14a3038ecb5a5f3ce2e0ae97a57be42e88fc04f9", + "status": "grounded", + "observedValue": "Ada Brooks" + } + ], + "/datePublished": [ + { + "pointer": "/datePublished", + "quote": "\"2026-09-01\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:1150f4282453", + "sourceHash": "sha256:1150f4282453f0884bd60e3c14a3038ecb5a5f3ce2e0ae97a57be42e88fc04f9", + "status": "grounded", + "observedValue": "2026-09-01" + } + ], + "/headline": [ + { + "pointer": "/headline", + "quote": "\"Why evidence belongs beside data\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:1150f4282453", + "sourceHash": "sha256:1150f4282453f0884bd60e3c14a3038ecb5a5f3ce2e0ae97a57be42e88fc04f9", + "status": "grounded", + "observedValue": "Why evidence belongs beside data" + } + ], + "/wordCount": [ + { + "pointer": "/wordCount", + "quote": "1842", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:1150f4282453", + "sourceHash": "sha256:1150f4282453f0884bd60e3c14a3038ecb5a5f3ce2e0ae97a57be42e88fc04f9", + "status": "grounded", + "observedValue": 1842 + } + ] + }, + "issues": [], + "coverage": { + "grounded": 4, + "total": 4, + "missing": 0, + "ambiguous": 0, + "invalid": 0, + "ratio": 1 + } +} diff --git a/examples/job/receipt.json b/examples/job/receipt.json new file mode 100644 index 0000000..a614a4c --- /dev/null +++ b/examples/job/receipt.json @@ -0,0 +1,152 @@ +{ + "manifest": { + "format": "grounded-json/receipt", + "version": 1, + "createdAt": "2026-01-01T00:00:00.000Z", + "receiptHash": "sha256:2577484b9d3ca3fda3f51373f8aa753375dfed080ea35ec36f3c195d53b0aa46" + }, + "data": { + "datePosted": "2026-08-28", + "employmentType": "FULL_TIME", + "hiringOrganization": { + "name": "Northwind Systems" + }, + "jobLocation": { + "address": "Berlin, Germany" + }, + "title": "Senior TypeScript Engineer" + }, + "schema": { + "dialect": "https://json-schema.org/draft/2020-12/schema", + "subset": "grounded-json/safe-v1", + "document": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "datePosted": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "hiringOrganization": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "jobLocation": { + "additionalProperties": false, + "properties": { + "address": { + "type": "string" + } + }, + "required": ["address"], + "type": "object" + }, + "title": { + "type": "string" + } + }, + "required": [ + "title", + "datePosted", + "employmentType", + "hiringOrganization", + "jobLocation" + ], + "title": "Job evidence receipt", + "type": "object" + }, + "contentHash": "sha256:64e34deb977887fad4d0078b6790f5211ded9e23347990c142bc88f9464fd694" + }, + "sources": [ + { + "id": "source:85ad42bfdc58", + "mediaType": "text/html", + "contentHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "bytes": 690 + } + ], + "evidence": { + "/datePosted": [ + { + "pointer": "/datePosted", + "quote": "\"2026-08-28\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:85ad42bfdc58", + "sourceHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "status": "grounded", + "observedValue": "2026-08-28" + } + ], + "/employmentType": [ + { + "pointer": "/employmentType", + "quote": "\"FULL_TIME\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:85ad42bfdc58", + "sourceHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "status": "grounded", + "observedValue": "FULL_TIME" + } + ], + "/hiringOrganization/name": [ + { + "pointer": "/hiringOrganization/name", + "quote": "\"Northwind Systems\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:85ad42bfdc58", + "sourceHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "status": "grounded", + "observedValue": "Northwind Systems" + } + ], + "/jobLocation/address": [ + { + "pointer": "/jobLocation/address", + "quote": "\"Berlin, Germany\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:85ad42bfdc58", + "sourceHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "status": "grounded", + "observedValue": "Berlin, Germany" + } + ], + "/title": [ + { + "pointer": "/title", + "quote": "\"Senior TypeScript Engineer\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:85ad42bfdc58", + "sourceHash": "sha256:85ad42bfdc58e6743d7e5d470d2c066fe2de7ff74972223eba951954496c48df", + "status": "grounded", + "observedValue": "Senior TypeScript Engineer" + } + ] + }, + "issues": [], + "coverage": { + "grounded": 5, + "total": 5, + "missing": 0, + "ambiguous": 0, + "invalid": 0, + "ratio": 1 + } +} diff --git a/examples/product/receipt.json b/examples/product/receipt.json new file mode 100644 index 0000000..8292dbc --- /dev/null +++ b/examples/product/receipt.json @@ -0,0 +1,154 @@ +{ + "manifest": { + "format": "grounded-json/receipt", + "version": 1, + "createdAt": "2026-01-01T00:00:00.000Z", + "receiptHash": "sha256:91ea10fc45e212f61f1976df5b8c4356ce2ba744c8440caea63bab0d19c44c1c" + }, + "data": { + "description": "Weatherproof daypack for focused travel.", + "name": "Northstar Pack 24L", + "offers": { + "availability": "https://schema.org/InStock", + "price": 149.95, + "priceCurrency": "USD" + }, + "sku": "NSP-24-INK" + }, + "schema": { + "dialect": "https://json-schema.org/draft/2020-12/schema", + "subset": "grounded-json/safe-v1", + "document": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "offers": { + "additionalProperties": false, + "properties": { + "availability": { + "type": "string" + }, + "price": { + "type": "number" + }, + "priceCurrency": { + "type": "string" + } + }, + "required": ["price", "priceCurrency", "availability"], + "type": "object" + }, + "sku": { + "type": "string" + } + }, + "required": ["name", "sku", "description", "offers"], + "title": "Product evidence receipt", + "type": "object" + }, + "contentHash": "sha256:20cdc7b79c86db90df0e1b4535173b618a1de8ebb6aad7b32667e6aafe011abf" + }, + "sources": [ + { + "id": "source:118b7330bd6d", + "mediaType": "text/html", + "contentHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "bytes": 705 + } + ], + "evidence": { + "/description": [ + { + "pointer": "/description", + "quote": "\"Weatherproof daypack for focused travel.\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": "Weatherproof daypack for focused travel." + } + ], + "/name": [ + { + "pointer": "/name", + "quote": "\"Northstar Pack 24L\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": "Northstar Pack 24L" + } + ], + "/offers/availability": [ + { + "pointer": "/offers/availability", + "quote": "\"https://schema.org/InStock\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": "https://schema.org/InStock" + } + ], + "/offers/price": [ + { + "pointer": "/offers/price", + "quote": "149.95", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": 149.95 + } + ], + "/offers/priceCurrency": [ + { + "pointer": "/offers/priceCurrency", + "quote": "\"USD\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": "USD" + } + ], + "/sku": [ + { + "pointer": "/sku", + "quote": "\"NSP-24-INK\"", + "selector": "script[type=\"application/ld+json\"]", + "selectorIndex": 0, + "transforms": ["json-decode"], + "sourceId": "source:118b7330bd6d", + "sourceHash": "sha256:118b7330bd6d1c55fddf71f3ff1c3424535fe9e0be554c1b917f51d592ff5bad", + "status": "grounded", + "observedValue": "NSP-24-INK" + } + ] + }, + "issues": [], + "coverage": { + "grounded": 6, + "total": 6, + "missing": 0, + "ambiguous": 0, + "invalid": 0, + "ratio": 1 + } +} diff --git a/package-lock.json b/package-lock.json index fd2dfe0..56fb06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9604,6 +9604,7 @@ }, "devDependencies": { "@types/node": "^22.19.19", + "ajv": "^8.20.0", "typescript": "^5.9.3", "vitest": "^5.0.0" }, diff --git a/packages/grounded-json/README.md b/packages/grounded-json/README.md index d1cc58c..c3bdbd3 100644 --- a/packages/grounded-json/README.md +++ b/packages/grounded-json/README.md @@ -32,6 +32,19 @@ const receipt = fromSelectors({ Key exports include `ground`, `fromSelectors`, `fromJsonLd`, `verifyReceipt`, `parseReceipt`, `renderReceiptHtml`, and the safe JSON Schema helpers. +## Receipt format + +The package includes the draft 2020-12 JSON Schema for receipt format version 1 +at `grounded-json/receipt.schema.json`. Resolve it with +`import.meta.resolve('grounded-json/receipt.schema.json')` and load it as JSON in +an external validator. The schema `$id` includes `v1`, matching +`manifest.version: 1`; this is independent of the npm package version. + +Schema validation checks structure. Use `parseReceipt` and `verifyReceipt` for +semantic consistency, timestamp and byte limits, safe extraction schemas, hashes, +and replay. See the [receipt format documentation](https://github.com/msertdev/grounded-json#receipt-format) +and [committed examples](https://github.com/msertdev/grounded-json/tree/main/examples). + Evidence supports provenance, not factual truth. Read the [full documentation](https://github.com/msertdev/grounded-json#readme), [security policy](https://github.com/msertdev/grounded-json/blob/main/SECURITY.md), and [examples](https://github.com/msertdev/grounded-json/tree/main/examples) on GitHub. MIT © 2026 Murat Sert diff --git a/packages/grounded-json/package.json b/packages/grounded-json/package.json index a697780..f1e5138 100644 --- a/packages/grounded-json/package.json +++ b/packages/grounded-json/package.json @@ -34,12 +34,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - } + }, + "./receipt.schema.json": "./schemas/v1/receipt.schema.json" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist", + "schemas", "README.md", "LICENSE" ], @@ -58,6 +60,7 @@ }, "devDependencies": { "@types/node": "^22.19.19", + "ajv": "^8.20.0", "typescript": "^5.9.3", "vitest": "^5.0.0" } diff --git a/packages/grounded-json/schemas/v1/receipt.schema.json b/packages/grounded-json/schemas/v1/receipt.schema.json new file mode 100644 index 0000000..a156709 --- /dev/null +++ b/packages/grounded-json/schemas/v1/receipt.schema.json @@ -0,0 +1,334 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/msertdev/grounded-json/main/packages/grounded-json/schemas/v1/receipt.schema.json", + "title": "grounded-json receipt version 1", + "description": "Structural validation of grounded-json/receipt version 1. Use parseReceipt and verifyReceipt for semantic checks, byte limits, hashes, schema safety, coverage consistency, and evidence replay.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "data", "sources", "evidence", "issues", "coverage"], + "properties": { + "manifest": { + "$ref": "#/$defs/manifest" + }, + "data": { + "$ref": "#/$defs/jsonValue" + }, + "schema": { + "$ref": "#/$defs/schemaSnapshot" + }, + "sources": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": { + "$ref": "#/$defs/source" + } + }, + "evidence": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/pointer" + }, + "additionalProperties": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/$defs/evidenceRecord" + } + } + }, + "issues": { + "type": "array", + "maxItems": 10000, + "items": { + "$ref": "#/$defs/issue" + } + }, + "coverage": { + "$ref": "#/$defs/coverage" + } + }, + "$defs": { + "hash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "not": { + "pattern": "[\u0000-\u001f\u007f]" + } + }, + "timestamp": { + "type": "string", + "maxLength": 64, + "description": "A timestamp accepted by Date.parse. Validity is checked by parseReceipt, rather than imposing a narrower RFC 3339 format." + }, + "pointer": { + "type": "string", + "maxLength": 4096, + "pattern": "^(?:/(?!(?:__proto__|prototype|constructor)(?:/|(?![\\s\\S])))(?:[^~/]|~[01])*){0,256}(?![\\s\\S])", + "description": "RFC 6901 JSON Pointer, including the empty root pointer; prototype-pollution segments are forbidden." + }, + "jsonValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/jsonValue" + } + }, + { + "type": "object", + "propertyNames": { + "not": { + "enum": ["__proto__", "prototype", "constructor"] + } + }, + "additionalProperties": { + "$ref": "#/$defs/jsonValue" + } + } + ] + }, + "manifest": { + "type": "object", + "additionalProperties": false, + "required": ["format", "version", "createdAt", "receiptHash"], + "properties": { + "format": { + "const": "grounded-json/receipt" + }, + "version": { + "const": 1 + }, + "createdAt": { + "$ref": "#/$defs/timestamp" + }, + "receiptHash": { + "$ref": "#/$defs/hash" + } + } + }, + "schemaSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["dialect", "subset", "document", "contentHash"], + "properties": { + "dialect": { + "const": "https://json-schema.org/draft/2020-12/schema" + }, + "subset": { + "const": "grounded-json/safe-v1" + }, + "document": { + "$ref": "#/$defs/jsonValue" + }, + "contentHash": { + "$ref": "#/$defs/hash" + } + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["id", "mediaType", "contentHash", "bytes"], + "properties": { + "id": { + "$ref": "#/$defs/sourceId" + }, + "mediaType": { + "enum": ["text/html", "text/plain"] + }, + "contentHash": { + "$ref": "#/$defs/hash" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 5242880 + }, + "url": { + "type": "string", + "maxLength": 2048 + }, + "retrievedAt": { + "$ref": "#/$defs/timestamp" + }, + "content": { + "type": "string", + "description": "Optional embedded source; UTF-8 byte length is checked by parseReceipt." + } + } + }, + "evidenceRecord": { + "type": "object", + "additionalProperties": false, + "required": ["pointer", "quote", "sourceId", "sourceHash", "status"], + "properties": { + "pointer": { + "$ref": "#/$defs/pointer" + }, + "quote": { + "type": "string", + "maxLength": 16384 + }, + "match": { + "type": "string", + "maxLength": 16384 + }, + "selector": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "selectorIndex": { + "type": "integer", + "minimum": 0 + }, + "attribute": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "transforms": { + "type": "array", + "maxItems": 16, + "items": { + "enum": [ + "trim", + "lowercase", + "parse-number", + "currency-code", + "iso-date", + "boolean", + "json-decode" + ] + } + }, + "sourceId": { + "$ref": "#/$defs/sourceId" + }, + "sourceHash": { + "$ref": "#/$defs/hash" + }, + "status": { + "enum": ["grounded", "missing", "ambiguous", "invalid"] + }, + "observedValue": { + "$ref": "#/$defs/jsonValue" + }, + "reason": { + "type": "string", + "maxLength": 4096 + } + }, + "if": { + "properties": { + "status": { + "const": "grounded" + } + }, + "required": ["status"] + }, + "then": { + "required": ["observedValue"], + "properties": { + "observedValue": { + "$ref": "#/$defs/jsonValue" + } + } + } + }, + "issue": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { + "enum": [ + "missing-evidence", + "pointer-not-found", + "source-not-found", + "quote-not-found", + "ambiguous-anchor", + "selector-invalid", + "selector-not-found", + "selector-index-out-of-range", + "attribute-not-found", + "match-not-in-quote", + "normalization-failed", + "value-mismatch", + "source-hash-mismatch", + "receipt-hash-mismatch", + "verification-mismatch", + "schema-invalid" + ] + }, + "pointer": { + "$ref": "#/$defs/pointer" + }, + "sourceId": { + "$ref": "#/$defs/sourceId" + }, + "message": { + "type": "string", + "maxLength": 4096 + } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": [ + "grounded", + "total", + "missing", + "ambiguous", + "invalid", + "ratio" + ], + "properties": { + "grounded": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "missing": { + "type": "integer", + "minimum": 0 + }, + "ambiguous": { + "type": "integer", + "minimum": 0 + }, + "invalid": { + "type": "integer", + "minimum": 0 + }, + "ratio": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } +} diff --git a/packages/grounded-json/test/receipt-schema.test.ts b/packages/grounded-json/test/receipt-schema.test.ts new file mode 100644 index 0000000..01e565a --- /dev/null +++ b/packages/grounded-json/test/receipt-schema.test.ts @@ -0,0 +1,246 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; + +import { + fromJsonLd, + ground, + parseReceipt, + verifyReceipt, +} from '../src/index.js'; +import type { GroundedReceipt, JsonValue } from '../src/index.js'; + +const packageRoot = new URL('../', import.meta.url); +const schema = JSON.parse( + readFileSync(new URL('schemas/v1/receipt.schema.json', packageRoot), 'utf8'), +); +const validate = new Ajv2020({ strict: true, allErrors: true }).compile(schema); +const examplesRoot = fileURLToPath(new URL('../../examples/', packageRoot)); +const exampleReceipts = readdirSync(examplesRoot, { + recursive: true, + encoding: 'utf8', +}) + .filter((path) => path.endsWith('.json')) + .map((path) => ({ + path, + receipt: JSON.parse(readFileSync(resolve(examplesRoot, path), 'utf8')), + })) + .filter( + ({ path, receipt }) => + path.endsWith('receipt.json') || + receipt?.manifest?.format === 'grounded-json/receipt', + ); + +function makeReceipt() { + return ground({ + data: { ok: true }, + sources: [{ mediaType: 'text/plain', content: 'true' }], + evidence: [{ pointer: '/ok', quote: 'true', transforms: ['boolean'] }], + }); +} + +describe('receipt version 1 JSON Schema', () => { + it('has committed example receipts to validate', () => { + expect(exampleReceipts.length).toBeGreaterThan(0); + }); + + it.each(exampleReceipts)( + 'validates the committed example $path', + ({ receipt }) => { + expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true); + expect(() => parseReceipt(receipt)).not.toThrow(); + }, + ); + + it.each([ + ['product', 'Product'], + ['job', 'JobPosting'], + ['article', 'Article'], + ])('keeps the %s example reproducible', (directory, type) => { + const fixture = new URL(`../../examples/${directory}/`, packageRoot); + const html = readFileSync(new URL('page.html', fixture), 'utf8'); + const receipt = JSON.parse( + readFileSync(new URL('receipt.json', fixture), 'utf8'), + ); + const regenerated = fromJsonLd({ + html, + type, + jsonSchema: JSON.parse( + readFileSync(new URL('schema.json', fixture), 'utf8'), + ), + options: { now: () => new Date(receipt.manifest.createdAt) }, + }); + expect(regenerated).toEqual(receipt); + expect( + verifyReceipt(receipt, [{ mediaType: 'text/html', content: html }]).ok, + ).toBe(true); + }); + + it.each([null, false, 0, '', [], {}, [1, { ok: true }]])( + 'accepts JSON terminal and container values: %j', + (data) => { + const quote = JSON.stringify(data); + for (const embedSources of [false, true]) { + const receipt = ground({ + data, + sources: [{ mediaType: 'text/plain', content: quote }], + evidence: [{ pointer: '', quote, transforms: ['json-decode'] }], + options: { embedSources }, + }); + expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true); + } + }, + ); + + it.each(['missing', 'ambiguous', 'invalid'] as const)( + 'accepts %s evidence without an observed value', + (status) => { + const receipt = makeReceipt(); + const record = receipt.evidence['/ok']![0]!; + record.status = status; + delete record.observedValue; + expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true); + }, + ); + + it('accepts escaped pointer segments and optional evidence fields', () => { + const receipt = ground({ + data: { 'a/b~c': true }, + sources: [ + { mediaType: 'text/html', content: '' }, + ], + evidence: [ + { + pointer: '/a~1b~0c', + quote: 'yes', + match: 'yes', + selector: 'data', + selectorIndex: 0, + attribute: 'value', + transforms: ['boolean'], + }, + ], + }); + expect(validate(receipt), JSON.stringify(validate.errors)).toBe(true); + }); + + it('requires every top-level field except the optional schema snapshot', () => { + for (const key of [ + 'manifest', + 'data', + 'sources', + 'evidence', + 'issues', + 'coverage', + ]) { + const receipt = makeReceipt() as unknown as Record; + delete receipt[key]; + expect(validate(receipt), key).toBe(false); + } + }); + + it.each([ + { name: 'unknown receipt field', path: ['extra'], value: true }, + { + name: 'unknown manifest field', + path: ['manifest', 'extra'], + value: true, + }, + { name: 'unsupported version', path: ['manifest', 'version'], value: 2 }, + { name: 'wrong format', path: ['manifest', 'format'], value: 'other' }, + { + name: 'malformed hash', + path: ['manifest', 'receiptHash'], + value: 'sha256:abc', + }, + { name: 'no sources', path: ['sources'], value: [] }, + { + name: 'unsupported media type', + path: ['sources', '0', 'mediaType'], + value: 'text/javascript', + }, + { name: 'negative bytes', path: ['sources', '0', 'bytes'], value: -1 }, + { + name: 'oversized source', + path: ['sources', '0', 'bytes'], + value: 5242881, + }, + { + name: 'control character in source id', + path: ['sources', '0', 'id'], + value: 'bad\n', + }, + { + name: 'unknown transform', + path: ['evidence', '/ok', '0', 'transforms'], + value: ['eval'], + }, + { + name: 'too many transforms', + path: ['evidence', '/ok', '0', 'transforms'], + value: Array(17).fill('trim'), + }, + { + name: 'unknown evidence status', + path: ['evidence', '/ok', '0', 'status'], + value: 'trusted', + }, + { + name: 'fractional selector index', + path: ['evidence', '/ok', '0', 'selectorIndex'], + value: 0.5, + }, + { + name: 'invalid pointer escape', + path: ['evidence', '/ok', '0', 'pointer'], + value: '/bad~2', + }, + { + name: 'non-pointer evidence key', + path: ['evidence'], + value: { invalid: [] }, + }, + { + name: 'unsafe pointer segment', + path: ['evidence'], + value: { '/constructor': [] }, + }, + { + name: 'unsafe nested data key', + path: ['data'], + value: JSON.parse('{"nested":{"__proto__":true}}'), + }, + { + name: 'unknown issue code', + path: ['issues'], + value: [{ code: 'unknown', message: '' }], + }, + { name: 'negative coverage', path: ['coverage', 'missing'], value: -1 }, + { name: 'fractional count', path: ['coverage', 'total'], value: 1.5 }, + { name: 'ratio above one', path: ['coverage', 'ratio'], value: 1.1 }, + ])('rejects $name', ({ path, value }) => { + const receipt = makeReceipt(); + let target = receipt as unknown as Record; + for (const key of path.slice(0, -1)) { + target = target[key] as Record; + } + target[path.at(-1)!] = value; + expect(validate(receipt), JSON.stringify(validate.errors)).toBe(false); + }); + + it('requires observedValue for grounded evidence', () => { + const receipt = makeReceipt(); + delete receipt.evidence['/ok']![0]!.observedValue; + expect(validate(receipt)).toBe(false); + }); + + it('leaves cross-field and cryptographic checks to the runtime verifier', () => { + const receipt: GroundedReceipt = makeReceipt(); + receipt.coverage.total = 2; + expect(validate(receipt)).toBe(true); + expect(() => parseReceipt(receipt)).toThrow(/Coverage/u); + }); +}); diff --git a/scripts/pack-smoke.mjs b/scripts/pack-smoke.mjs index efcc0af..057d87e 100644 --- a/scripts/pack-smoke.mjs +++ b/scripts/pack-smoke.mjs @@ -53,7 +53,13 @@ try { await writeFile( join(packageDir, 'check.mjs'), ` +import { readFileSync } from 'node:fs'; import { fromSelectors, verifyReceipt } from 'grounded-json'; +const receiptSchema = JSON.parse(readFileSync( + new URL(import.meta.resolve('grounded-json/receipt.schema.json')), 'utf8' +)); +if (receiptSchema.$defs.manifest.properties.version.const !== 1) process.exit(3); +console.log('packaged receipt schema: PASS'); const receipt = fromSelectors({ html: '

Packaged API

', selectors: {