From 028119e7f086f514f4fb4aa8246f92ad52261fc2 Mon Sep 17 00:00:00 2001 From: Nathan Clevenger <4130910+nathanclevenger@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:04:25 -0500 Subject: [PATCH] =?UTF-8?q?feat(checks):=20digital-link-resolver=20?= =?UTF-8?q?=E2=80=94=20verify=20a=20DECLARED=20Digital=20Link=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An OPTIONAL third interface beside `interfaces.http` and `interfaces.mcp`. The card naming `interfaces.digitalLink` is the whole gate, and it cuts both ways: - OMITTED ⇒ the check SKIPs, the well-known is never fetched, no budget is spent, and the grade is untouched. Nothing that conformed yesterday starts failing. The `apis-ax-standard` pinned spec is NOT touched — it stays at 21 requirements and its digest is unchanged. A target that wants this enforced pins `digital-link-resolver` in its own spec. - DECLARED ⇒ judged strictly. GS1 fixes the discovery indicator (RFC 8615 /.well-known/gs1resolver; "the presence or absence of this file can be used to determine whether or not the URI points to a service conformant to this standard"), so the well-known must be same-origin, answer 2xx with a JSON media type, parse, and validate against GS1's published description-file schema. A machine-readable claim is one a verifier will believe. GS1's schema is VENDORED and digest-pinned (sha256:99d505c7…, the bytes ref.gs1.org served 2026-08-06), never fetched at verification time: a third-party document its publisher can edit would be a sixth input to the determinism invariant, and `observe` refuses off-origin fetches anyway. The runtime constraint is a hand-written MiniSchema translation, audited against the vendored bytes keyword by keyword in BOTH directions — a dropped constraint and an invented one each fail the suite (both proven by mutation). What is deliberately NOT enforced is named in the verdict text, not just in a comment: draft-07 `format: "uri"` is an annotation, and `contact.hasTelephone` sits outside a keyword position in GS1's own file, so GS1's schema does not validate it and neither do we. RFC 9264 linkset behaviour is out of scope — this check verifies the discovery indicator, not resolution. Failing cases shipped, and they are the point: 404, non-2xx, unparseable JSON, non-JSON content-type, missing required member, out-of-vocabulary primary key, resolverRoot naming another origin, card/document resolverRoot disagreement, network failure, cross-origin claim (refused unfetched), private/metadata claim, and four malformed declaration shapes. A present-but-defective declaration FAILs — it is never collapsed to an absence. Verified against reality: https://id.org.ai/.well-known/gs1resolver returns 404 today, so the estate's own resolver would FAIL this check if it declared the interface. That is asserted from the recorded 404, and — under APIQA_LIVE_NET=1 — against the live host. barcoding.dev's live document (which does serve 200) is vendored and asserted to validate, so the validator has seen a real in-the-wild file and not only its own fixtures. 44 new tests. Full suite: 1005 passed, 4 skipped, 36 files. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN.md | 21 + src/checks.ts | 142 +++++ src/discovery.ts | 124 ++++ src/gs1-resolver.ts | 219 +++++++ test/digital-link.test.ts | 590 ++++++++++++++++++ test/fixtures/barcoding-dev-gs1resolver.json | 44 ++ .../fixtures/gs1-description-file-schema.json | 124 ++++ 7 files changed, 1264 insertions(+) create mode 100644 src/gs1-resolver.ts create mode 100644 test/digital-link.test.ts create mode 100644 test/fixtures/barcoding-dev-gs1resolver.json create mode 100644 test/fixtures/gs1-description-file-schema.json diff --git a/DESIGN.md b/DESIGN.md index 4570682..7e73251 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -27,6 +27,27 @@ specific attack. > and its *published contracts*, but changing either changes the evidence > digest, visibly, in the attested report. +### Corollary: third-party constraint documents are vendored, never fetched + +Some checks judge a target against a standard *someone else* publishes — the +first is `digital-link-resolver`, which holds a declared Digital Link interface +to GS1's published resolver description-file schema +(`https://ref.gs1.org/standards/resolver/description-file-schema`). + +Fetching that schema at verification time would add a **sixth input** to the +invariant above — a document its publisher can edit at any moment, which no +replay could reproduce — and `Observer.observe` refuses off-origin fetches +anyway. So the published bytes are **vendored and digest-pinned** +(`src/gs1-resolver.ts` carries the provenance record; `test/fixtures/` carries +the bytes), the runtime constraint is a hand-written `MiniSchema` translation +of them, and a test audits that translation against the vendored bytes keyword +by keyword — in both directions, so neither a dropped constraint nor an +invented one passes silently. Constraints the published schema states but the +translation deliberately does not enforce (draft-07 `format`, which is an +annotation; a member GS1 misplaced outside a keyword position) are enumerated +with reasons and named in the check's own verdict text. A verifier that is +stricter than the standard it cites is lying in the other direction. + ### Attacks and mitigations | # | Attack | Mitigation | Status | diff --git a/src/checks.ts b/src/checks.ts index dc51fd9..7b2b79b 100644 --- a/src/checks.ts +++ b/src/checks.ts @@ -35,6 +35,13 @@ import { } from './discovery.js' import { isPubliclyRoutableSameOrigin, isPublicHttpsOffOriginAllowed } from './http.js' import { validateSchema } from './schema.js' +import { + GS1_DESCRIPTION_FILE_SCHEMA_SOURCE, + GS1_RESOLVER_WELL_KNOWN_PATH, + urlOriginOrUndefined, + renderGs1Violations, + validateGs1DescriptionFile, +} from './gs1-resolver.js' import { contractDiff } from './contract.js' import type { CheckResult, Evidence, EvidenceBundle, MiniSchema, Verdict } from './types.js' @@ -743,6 +750,132 @@ export function runChecks(bundle: EvidenceBundle): CheckResult[] { streamEvidence, judgeUiStreamParity(us))) } + // ── GS1 Digital Link resolver — an OPTIONAL, card-DECLARED interface ────── + // The GS1 resolver standard fixes a discovery indicator: a conformant + // resolver SHALL serve a Resolver Description File at the RFC 8615 + // well-known `/.well-known/gs1resolver`, and "the presence or absence of + // this file can be used to determine whether or not the URI points to a + // service conformant to this standard". + // + // DECLARATION-ARMED, exactly like the uiMessageStream face: the card + // naming `interfaces.digitalLink` is the whole gate. A card that OMITS + // the key SKIPs — no fetch, no budget, no verdict, and nothing that was + // conforming yesterday starts failing. A card that DECLARES it is judged + // STRICTLY, because a machine-readable claim a verifier will believe is + // worse than a prose one: the well-known must be same-origin, answer 2xx + // with JSON, and validate against GS1's PUBLISHED description-file schema + // (vendored + digest-pinned in gs1-resolver.ts — never fetched at + // verification time, which would put a third party inside the determinism + // contract). + // + // SCOPE, stated so the verdict is not read as more than it is: this check + // verifies the DISCOVERY INDICATOR, not resolution behaviour. RFC 9264 + // linkset responses (`Accept: application/linkset+json` / `linkType= + // linkset`), redirect semantics and Accept-Language are NOT covered here. + // axItem is undefined — this is an additive readiness dimension and it + // moves no AX point. + { + const dl = agents.digitalLink + const dlEvidence = [ROLE.agentsJson, ROLE.gs1Resolver] + const problems: string[] = [] + let result: { verdict: Verdict; detail: string } | undefined + if (!dl) { + result = { + verdict: 'skip', + detail: + 'no Digital Link interface declared (agents.json `interfaces.digitalLink` absent) — the interface is OPTIONAL and this card does not claim it, so nothing was fetched and nothing is judged; under a pinned must:pass this fails closed', + } + } else if (dl.malformed) { + result = { + verdict: 'fail', + detail: + `interfaces.digitalLink is present but is not a JSON object (got ${dl.malformedAs}) — the card claims a Digital Link resolver face in a shape no verifier can check. ` + + 'Declare an object (optionally `{ "wellKnown": …, "resolverRoot": … }`), or OMIT the key entirely to declare no Digital Link interface — omitting it is fully conforming.', + } + } else { + const origin = bundle.target + const declaredOrigin = urlOriginOrUndefined(dl.wellKnown) + if (declaredOrigin === undefined) { + problems.push( + `card declares interfaces.digitalLink.wellKnown = ${JSON.stringify(dl.wellKnownRaw ?? dl.wellKnown)}, which does not resolve to a URL — refused without fetching`, + ) + } else if (declaredOrigin !== origin) { + problems.push( + `card declares its Digital Link well-known at ${dl.wellKnown} (origin ${declaredOrigin}), which is NOT the target origin ${origin} — a card must not claim another origin's resolver; api.qa refused to fetch it`, + ) + } else if (!isPubliclyRoutableSameOrigin(dl.wellKnown, origin)) { + problems.push( + `card declares its Digital Link well-known at ${dl.wellKnown}, which is not a publicly-routable target for ${origin} — refused without fetching (SSRF guard)`, + ) + } else { + const ev = findEvidence(bundle, ROLE.gs1Resolver) + if (!ok(ev)) { + problems.push( + `GET ${dl.wellKnown} did not answer 2xx — ${!ev ? 'not fetched' : ev.status === null ? `fetch failed (${ev.error ?? 'unknown'})` : `status ${ev.status}`}. ` + + `The card DECLARES a Digital Link interface, so the GS1 resolver description file (RFC 8615 ${GS1_RESOLVER_WELL_KNOWN_PATH}) must be served`, + ) + } else { + const ct = (ev!.contentType ?? '').toLowerCase() + if (!ct.includes('json')) { + problems.push( + `${dl.wellKnown} answered ${ev!.status} with content-type ${JSON.stringify(ev!.contentType ?? '(none)')} — the resolver description file is a JSON document and must be served with a JSON media type`, + ) + } + const doc = parseJsonBody(ev) + if (doc === undefined) { + problems.push(`${dl.wellKnown} answered ${ev!.status} but its body did not parse as JSON`) + } else { + const violations = validateGs1DescriptionFile(doc) + if (violations.length > 0) { + problems.push( + `description file fails GS1's published description-file schema (${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.$id}) — ${renderGs1Violations(violations)}`, + ) + } else { + // Schema-valid ⇒ resolverRoot is a present string. Two SEMANTIC + // rules beyond the schema, named as such: the served document + // must name the origin it was actually reached on (one Snippet + // on N hostnames still answers with the reached host), and the + // card's own resolverRoot, if it declares one, must agree. + const rr = (doc as Record).resolverRoot as string + const rrOrigin = urlOriginOrUndefined(rr) + if (rrOrigin === undefined) { + problems.push( + `resolverRoot ${JSON.stringify(rr)} is not an absolute URL — GS1 annotates it \`format: "uri"\`, which draft-07 leaves unenforced, but a resolverRoot naming no origin cannot be reconciled with the origin that served it`, + ) + } else if (rrOrigin !== origin) { + problems.push( + `resolverRoot names origin ${rrOrigin} but the description file was served from ${origin} — a resolver must name the host the caller actually reached`, + ) + } + if (dl.resolverRoot !== undefined && trimTrailingSlash(dl.resolverRoot) !== trimTrailingSlash(rr)) { + problems.push( + `card declares interfaces.digitalLink.resolverRoot ${JSON.stringify(dl.resolverRoot)} but the description file declares ${JSON.stringify(rr)} — the two claims disagree`, + ) + } + if (problems.length === 0) { + const keys = (doc as Record).supportedPrimaryKeys as string[] + result = pass( + `interfaces.digitalLink declared; ${dl.wellKnown} → ${ev!.status} ${ev!.contentType}, valid against GS1's published description-file schema ` + + `(${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.$id}, vendored at sha256:${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.sha256.slice(0, 12)}… fetched ${GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.fetchedAt}, never fetched at verification time); ` + + `resolverRoot ${rr} names the serving origin; supportedPrimaryKeys ${JSON.stringify(keys)}. ` + + 'NOT enforced: draft-07 `format: "uri"` (annotation-only) and `contact.hasTelephone` (misplaced in GS1\'s own schema). ' + + 'NOT covered: RFC 9264 linkset responses, redirect semantics — this check verifies the discovery indicator, not resolution behaviour.', + ) + } + } + } + } + } + } + checks.push(check('digital-link-resolver', + 'a DECLARED Digital Link interface serves a schema-valid GS1 resolver description file at its well-known', undefined, + dlEvidence, + result ?? { + verdict: 'fail', + detail: problems.slice(0, 6).join('; ') || 'the declared Digital Link interface could not be verified', + })) + } + // ── AXP structural checks (Clause 3 conneg + Clause 6 cross-linking) ────── // Discriminating checks the AXP pinned spec binds via kind:'check': // machine-legible-home, conneg-accept, conneg-client-class, @@ -3096,6 +3229,15 @@ function looksLikeHtml(body: string): boolean { return /^\s*(]/i.test(body.slice(0, 1024)) } +/** + * Drop a single trailing slash so `https://x.example/` and `https://x.example` + * compare equal. Used only to reconcile two DECLARED resolverRoot strings — + * a trailing slash is not a disagreement worth failing a card over. + */ +function trimTrailingSlash(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url +} + function pass(detail: string): { verdict: Verdict; detail: string } { return { verdict: 'pass', detail } } diff --git a/src/discovery.ts b/src/discovery.ts index 08a662f..7d6471a 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -9,6 +9,7 @@ */ import { Observer, isPubliclyRoutableSameOrigin, isPublicHttpsOffOriginAllowed } from './http.js' +import { GS1_RESOLVER_WELL_KNOWN_PATH } from './gs1-resolver.js' import { canonicalJson, sha256Hex, sampleSeeded } from './digest.js' import type { ClaimedEndpoint, @@ -146,6 +147,16 @@ export const ROLE = { * Same-origin SSRF-gated. Absent ⇒ the parity check SKIPs (no twin to diff). */ uiStreamTwin: 'probe:ui-message-stream-twin', + // ── GS1 Digital Link resolver (OPTIONAL, target-DECLARED) ──────────────── + /** + * GET of the GS1 resolver description file — `/.well-known/gs1resolver` by + * default (RFC 8615, fixed by the GS1 resolver standard), or the location + * the card names in `interfaces.digitalLink.wellKnown`. Same-origin + * SSRF-gated. Recorded ONLY when the card DECLARES the interface: a card + * that omits `interfaces.digitalLink` records nothing, spends no budget, + * and the digital-link-resolver check SKIPs. + */ + gs1Resolver: 'surface:gs1resolver', } as const export function findEvidence(bundle: EvidenceBundle, role: string): Evidence | undefined { @@ -276,6 +287,48 @@ export interface AgentsClaims { * the target exposes no UI-message-stream face and the ui-stream-* checks SKIP. */ uiStream?: { url?: string; twin?: string } + /** + * The target-declared GS1 Digital Link resolver interface + * (`interfaces.digitalLink`) — an OPTIONAL third interface beside + * `interfaces.http` and `interfaces.mcp`. PRESENT ⇒ the card CLAIMS the + * surface is a Digital Link resolver and the digital-link-resolver check is + * ARMED; ABSENT ⇒ the field is undefined, the well-known is never fetched, + * and that check SKIPs. A card that simply omits the key is fully + * conforming — the interface is optional, and declaring it is what invites + * the verification. + */ + digitalLink?: DigitalLinkClaim +} + +/** + * A card's `interfaces.digitalLink` declaration, as parsed off the wire. + * + * `malformed` is the load-bearing distinction: the key being PRESENT but not + * a plain object (a string, `true`, `null`, an array) is a DEFECTIVE claim, + * never an absence. Collapsing it to "not declared" would let a card claim + * the face in a shape no verifier can check and still SKIP — so it is + * surfaced and FAILED. A card meaning "no Digital Link interface" omits the + * key. + */ +export interface DigitalLinkClaim { + /** The `interfaces.digitalLink` key was present on the card. Always true. */ + declared: true + /** Present but not a plain JSON object — a defective declaration. */ + malformed?: boolean + /** `typeof`-style name of the malformed value, for the failure message. */ + malformedAs?: string + /** Well-known location exactly as the card wrote it (relative or absolute). */ + wellKnownRaw?: string + /** + * Well-known location to fetch, absolutized against the target origin. + * Defaults to the RFC 8615 path the GS1 standard fixes. An ABSOLUTE + * off-origin value is preserved verbatim (never rewritten to the target) + * so the same-origin gate can DROP it and the check can FAIL the card for + * claiming another origin's resolver. + */ + wellKnown: string + /** Card-declared `resolverRoot`, absolutized. Optional. */ + resolverRoot?: string } export function parseAgentsJson(doc: unknown, origin: string): AgentsClaims { @@ -333,6 +386,37 @@ export function parseAgentsJson(doc: unknown, origin: string): AgentsClaims { } } + // GS1 Digital Link resolver face (OPTIONAL third interface): + // interfaces.digitalLink.{wellKnown,resolverRoot}. PRESENCE of the key is + // the whole declaration signal — `'digitalLink' in interfaces`, not + // truthiness — so a present-but-defective value is recorded as MALFORMED and + // failed, never silently treated as absent. Both urls are card-derived and + // therefore adversarial: absolutize (a relative "/.well-known/gs1resolver" + // resolves same-origin; an absolute foreign url is PRESERVED so the + // same-origin gate downstream drops it and the check fails the card for + // claiming another origin's resolver), exactly like interfaces.mcp.url. + if (Object.prototype.hasOwnProperty.call(interfaces, 'digitalLink')) { + const dl = interfaces.digitalLink + if (dl !== null && typeof dl === 'object' && !Array.isArray(dl)) { + const d = dl as Record + const wellKnownRaw = str(d.wellKnown) + const resolverRoot = str(d.resolverRoot) + out.digitalLink = { + declared: true, + ...(wellKnownRaw !== undefined && { wellKnownRaw }), + wellKnown: absolutize(wellKnownRaw ?? GS1_RESOLVER_WELL_KNOWN_PATH, origin), + ...(resolverRoot !== undefined && { resolverRoot: absolutize(resolverRoot, origin) }), + } + } else { + out.digitalLink = { + declared: true, + malformed: true, + malformedAs: dl === null ? 'null' : Array.isArray(dl) ? 'array' : typeof dl, + wellKnown: absolutize(GS1_RESOLVER_WELL_KNOWN_PATH, origin), + } + } + } + // Top-level server.json pointer (card-derived; absolutized, SSRF-gated at fetch). const declaredServerJson = str(d.serverJson) ?? str(d.server_json) if (out.serverJsonUrl === undefined && declaredServerJson !== undefined) { @@ -1162,6 +1246,37 @@ async function observeUiStream(origin: string, observer: Observer): Promise { + const items = observer.items + const bundleView: EvidenceBundle = { target: origin, fetchedAt: '', seed: 0, items } + const agentsEv = findEvidence(bundleView, ROLE.agentsJson) + const agents = parseAgentsJson(parseJsonBody(agentsEv), origin) + const dl = agents.digitalLink + if (!dl) return // interface not declared — optional, nothing to verify + if (dl.malformed) return // defective declaration — judged from the card, never fetched + if (!isPubliclyRoutableSameOrigin(dl.wellKnown, origin)) return // off-origin/private — dropped, the check fails it + await observer.observe(ROLE.gs1Resolver, dl.wellKnown, { accept: 'application/json' }) +} + export async function observeTarget(origin: string, observer: Observer, seed: number): Promise { // 1. The fixed surface plan — identical for every target (no fingerprint). const rootAgentEv = await observer.observe(ROLE.rootAgent, `${origin}/`, { accept: '*/*' }) @@ -1437,6 +1552,15 @@ export async function observeTarget(origin: string, observer: Observer, seed: nu // card-derived probe (see observeUiStream). await observeUiStream(origin, observer) + // 4e. GS1 Digital Link resolver (OPTIONAL, target-declared): when the card + // declares `interfaces.digitalLink`, GET the resolver description file so + // the digital-link-resolver judge can hold the claim to GS1's published + // description-file schema. Zero-overhead and zero-budget for the common + // case (no declaration ⇒ no probe). Ordered here, BEFORE the unbounded + // contract-diff enumeration, so a fixed high-value probe is never starved + // by an endpoint-rich target — the same priority rule as 4b/4c/4d. + await observeDigitalLink(origin, observer) + // 5. Contract-diff probing (ax-e6b.28.4): for a FULL OpenAPI<->live diff, // fetch EVERY GET-safe candidate path once — not just the seeded keyless // sample above — so the diff enumerates every declared operation, not a diff --git a/src/gs1-resolver.ts b/src/gs1-resolver.ts new file mode 100644 index 0000000..b0655db --- /dev/null +++ b/src/gs1-resolver.ts @@ -0,0 +1,219 @@ +/** + * GS1 Digital Link resolver description file — the constraint api.qa judges a + * DECLARED Digital Link interface against. + * + * WHAT GS1 SAYS (https://ref.gs1.org/standards/resolver/): a GS1-Conformant + * Resolver SHALL make a Resolver Description File available at + * `/.well-known/gs1resolver` (RFC 8615), and "the presence or absence of this + * file can be used to determine whether or not the URI points to a service + * conformant to this standard". The file validates against a published JSON + * Schema whose required members are exactly `resolverRoot` and + * `supportedPrimaryKeys`. + * + * WHY THE SCHEMA IS NOT FETCHED AT VERIFICATION TIME. Two hard constraints + * forbid it, and neither is negotiable: + * + * 1. A verdict is a pure function of (published contracts, observed + * behavior, pinned spec digest, seed, verifier version) — DESIGN.md. A + * third-party document that its publisher may edit at any moment is a + * sixth input nobody controls, and a replayed bundle would re-judge + * differently. That is the one property api.qa cannot trade away. + * 2. `Observer.observe` refuses off-origin fetches by construction (SSRF). + * A verification run reaches the target's origin and nowhere else. + * + * So the published bytes are VENDORED and DIGEST-PINNED (see + * GS1_DESCRIPTION_FILE_SCHEMA_SOURCE and test/fixtures/), and the constraint + * this file actually evaluates is the hand-written `MiniSchema` translation + * below. `test/digital-link.test.ts` walks the vendored bytes keyword by + * keyword and fails if the translation drifts from them, or if this file + * invents a constraint GS1 did not write — so "validated against GS1's + * published schema" is a tested property, not an adjective. + * + * WHAT WE DELIBERATELY DO NOT ENFORCE, and why it is said out loud: + * + * - `format: "uri"`. In draft-07 `format` is an ANNOTATION by default. A + * validator that rejects a non-URI `resolverRoot` on `format` grounds is + * STRICTER than the schema requires. api.qa does not enforce it here, and + * the check's detail string says so. (The resolver-root ORIGIN rule the + * check applies is a separate, explicitly-named semantic rule — not a + * smuggled `format` enforcement.) + * - `default: false` on `linkTypeDefaultCanBeLinkset`. An annotation; it + * never constrains an instance. + * - `contact.hasTelephone`. In GS1's published file this member sits as a + * SIBLING of `properties` inside the `contact` subschema, which is not a + * JSON Schema keyword position. GS1's own schema therefore does not + * validate `contact.hasTelephone`, and neither do we. Reproducing the + * published behaviour is the honest choice; inventing a stricter rule and + * calling it "the GS1 schema" would be a machine-readable false claim. + * - `additionalProperties` is ABSENT from the published schema, so extra + * members are permitted. A description file carrying vendor extensions + * validates. We do not close the object. + * + * Nothing here reads axp.org.ai. The wire is the input: a card that declares + * the interface, and a document served at the well-known. + */ + +import { validateSchema } from './schema.js' +import type { MiniSchema } from './types.js' +import type { SchemaViolation } from './schema.js' + +/** + * RFC 8615 well-known path the GS1 resolver standard fixes for the + * description file. Origin-relative; the target's own origin is prepended. + */ +export const GS1_RESOLVER_WELL_KNOWN_PATH = '/.well-known/gs1resolver' + +/** + * Provenance of the vendored schema. `sha256` is over the bytes ref.gs1.org + * ACTUALLY SERVED on `fetchedAt` — not over a reformatted copy — so the pin + * proves byte-provenance and not merely semantic equivalence. + * `test/fixtures/gs1-description-file-schema.json` holds those bytes and + * `test/digital-link.test.ts` re-hashes them on every run. + */ +export const GS1_DESCRIPTION_FILE_SCHEMA_SOURCE = { + url: 'https://ref.gs1.org/standards/resolver/description-file-schema', + /** The `$id` inside the served document — the unversioned URL pins to 1.2.0. */ + $id: 'https://ref.gs1.org/standards/resolver/1.2.0/description-file-schema', + dialect: 'http://json-schema.org/draft-07/schema#', + sha256: '99d505c7aa2004cde3277651e8db0e019eab61d2746b2631ddab3fec8a13617e', + bytes: 3593, + fetchedAt: '2026-08-06', +} as const + +/** + * The closed `supportedPrimaryKeys` vocabulary, verbatim from the published + * schema's `items.enum` (21 values, `"all"` first). Order preserved so the + * faithfulness audit can compare it element-for-element. + */ +export const GS1_PRIMARY_KEY_AIS = [ + 'all', '01', '8006', '8013', '8010', '410', '411', '412', '413', '414', + '415', '417', '8017', '8018', '255', '00', '253', '401', '402', '8003', '8004', +] as const + +/** + * The published draft-07 schema, translated into the dependency-free + * `MiniSchema` this repo already validates with. FAITHFUL, not stricter: + * every constraint here exists in the published bytes, and every published + * constraint that is not here is listed in GS1_SCHEMA_KEYWORDS_NOT_ENFORCED + * with a reason. + */ +export const GS1_DESCRIPTION_FILE_SCHEMA: MiniSchema = { + type: 'object', + required: ['resolverRoot', 'supportedPrimaryKeys'], + properties: { + resolverRoot: { type: 'string' }, + supportedPrimaryKeys: { + type: 'array', + items: { type: 'string', enum: [...GS1_PRIMARY_KEY_AIS] }, + }, + name: { type: 'string' }, + supportedLinkType: { + type: 'array', + // GS1's `items` declares NO `type` and NO `required` — an empty object, + // and indeed a non-object, validates. Reproduced deliberately: adding + // `type: 'object'` here would fail documents GS1's own schema accepts. + items: { + properties: { + namespace: { type: 'string' }, + prefix: { type: 'string', pattern: '^[a-zA-Z_][A-Za-z0-9_-]*?:$' }, + }, + }, + }, + linkTypeDefaultCanBeLinkset: { type: 'boolean' }, + supportedContextValuesEnumerated: { type: 'array', items: { type: 'string' } }, + supportedContextValuesExternal: { + type: 'array', + items: { + type: 'object', + properties: { nameOfList: { type: 'string' }, url: { type: 'string' } }, + }, + }, + contact: { + type: 'object', + properties: { + fn: { type: 'string' }, + hasAddress: { + type: 'object', + properties: { + streetAddress: { type: 'string' }, + locality: { type: 'string' }, + region: { type: 'string' }, + 'postal-code': { type: 'string' }, + }, + }, + }, + }, + extensionProfile: { type: 'string' }, + jsonLdContextLocation: { type: 'string' }, + }, +} + +/** + * Draft-07 keywords the translation above CARRIES. The faithfulness audit + * requires every one of these, wherever it appears in the published bytes, to + * survive into `GS1_DESCRIPTION_FILE_SCHEMA` unchanged. + */ +export const GS1_SCHEMA_KEYWORDS_ENFORCED = ['type', 'properties', 'required', 'items', 'enum', 'pattern'] as const + +/** + * Members of the published bytes the translation deliberately DROPS, each + * with the reason. The audit fails if the published schema grows a keyword + * that is neither enforced nor listed here — i.e. silent under-enforcement is + * a test failure, not a shrug. + */ +export const GS1_SCHEMA_KEYWORDS_NOT_ENFORCED: Record = { + $schema: 'dialect declaration — metadata, constrains no instance', + $id: 'schema identity — metadata, constrains no instance', + description: 'annotation', + decription: + "GS1's own typo for `description` (contact.hasAddress.postal-code) — an annotation under either spelling", + format: + 'draft-07 `format` is an ANNOTATION by default; enforcing it would make api.qa stricter than the schema it cites. The check says so in its detail rather than being silently stricter.', + default: 'draft-07 `default` is an annotation; it never constrains an instance', +} + +/** + * Members that appear in the published bytes at a position where they are NOT + * a JSON Schema keyword, keyed by the schema path they sit at. GS1's own + * validator ignores them; so do we, and we name them so nobody mistakes the + * omission for an oversight. + */ +export const GS1_SCHEMA_MISPLACED_MEMBERS: Record = { + '$.contact.hasTelephone': + "sits as a SIBLING of `properties` inside the `contact` subschema in GS1's published file — not a JSON Schema keyword position. GS1's schema does not validate contact.hasTelephone; reproducing that is honest, tightening it silently would not be.", +} + +/** + * Validate a parsed description file against the translated schema. Pure and + * total: any JSON value in, a (possibly empty) violation list out. Never + * throws, never fetches. + */ +export function validateGs1DescriptionFile(doc: unknown): SchemaViolation[] { + return validateSchema(doc, GS1_DESCRIPTION_FILE_SCHEMA) +} + +/** + * Render violations as one actionable clause naming the failing property + * paths — `$.resolverRoot: required property missing` — capped so a badly + * broken document cannot flood a detail string. + */ +export function renderGs1Violations(violations: SchemaViolation[], max = 4): string { + const shown = violations.slice(0, max).map((v) => `${v.path}: ${v.message}`) + const extra = violations.length > max ? ` (+${violations.length - max} more)` : '' + return shown.join('; ') + extra +} + +/** + * Origin of an ABSOLUTE URL string, or `undefined` when it does not parse. + * Distinct from checks.ts's local `originOf`, which returns the input string + * on a parse failure — here the difference between "no origin" and "some + * origin" is exactly what the cross-origin rule turns on, so it must be + * unambiguous. + */ +export function urlOriginOrUndefined(url: string): string | undefined { + try { + return new URL(url).origin + } catch { + return undefined + } +} diff --git a/test/digital-link.test.ts b/test/digital-link.test.ts new file mode 100644 index 0000000..2483cbb --- /dev/null +++ b/test/digital-link.test.ts @@ -0,0 +1,590 @@ +/** + * The OPTIONAL, card-DECLARED GS1 Digital Link interface + * (`interfaces.digitalLink`) and its check, `digital-link-resolver`. + * + * Three properties this file exists to hold: + * + * 1. **Undeclared is not a failure.** A card that omits the key SKIPs, is + * never fetched for, spends no budget, and its grade is untouched. If + * this ever regresses, every conforming surface in the estate starts + * failing for not implementing something optional. + * 2. **Declared is judged strictly**, because a machine-readable claim is + * one a verifier will believe. Every failing case is here: 404, wrong + * content-type, unparseable JSON, schema violation, cross-origin claim, + * malformed declaration, a resolverRoot that names someone else, network + * failure. + * 3. **"Validates against GS1's published schema" is a tested property.** + * The published bytes are vendored under test/fixtures at a pinned + * sha256, and the translated MiniSchema is audited keyword-by-keyword + * against them — including the two defects in GS1's own file, which are + * reproduced rather than silently tightened. + * + * AND AGAINST REALITY: `https://id.org.ai/.well-known/gs1resolver` returns 404 + * (observed 2026-08-06). If id.org.ai declared the interface today it would + * FAIL this check — which is correct, and is exactly why the interface is + * optional and undeclared. That is asserted below, not narrated in a comment: + * deterministically from the recorded 404, and — under APIQA_LIVE_NET=1 — + * against the live host. + */ + +import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { describe, it, expect } from 'vitest' +import { Observer } from '../src/http.js' +import { observeTarget, ROLE, parseAgentsJson } from '../src/discovery.js' +import { runChecks } from '../src/checks.js' +import { axScoreOf, gradeOf } from '../src/grade.js' +import { + GS1_DESCRIPTION_FILE_SCHEMA, + GS1_DESCRIPTION_FILE_SCHEMA_SOURCE, + GS1_PRIMARY_KEY_AIS, + GS1_RESOLVER_WELL_KNOWN_PATH, + GS1_SCHEMA_KEYWORDS_NOT_ENFORCED, + GS1_SCHEMA_MISPLACED_MEMBERS, + validateGs1DescriptionFile, +} from '../src/gs1-resolver.js' +import type { CheckResult, Evidence, EvidenceBundle } from '../src/types.js' +import { GOOD, goodTargetRoutes, makeFetcher, withOverrides, withoutRoutes, type Routes } from './helpers.js' + +// --------------------------------------------------------------------------- +// Plumbing +// --------------------------------------------------------------------------- + +const WK_ROUTE = `GET ${GS1_RESOLVER_WELL_KNOWN_PATH}` + +/** A description file that satisfies the published schema in full. */ +const VALID_DOC = { + resolverRoot: GOOD, + supportedPrimaryKeys: ['01', '00', '414'], + name: 'good.example resolver', + supportedLinkType: [{ namespace: 'https://ref.gs1.org/voc/', prefix: 'gs1:' }], + linkTypeDefaultCanBeLinkset: false, + supportedContextValuesEnumerated: ['us', 'ca'], + contact: { fn: 'good.example ops', hasAddress: { locality: 'Minneapolis', region: 'MN' } }, + jsonLdContextLocation: 'https://ref.gs1.org/voc/data/context.jsonld', +} + +const json = (value: unknown, contentType = 'application/json') => () => ({ + status: 200, + contentType, + body: JSON.stringify(value), +}) + +/** + * goodTargetRoutes with `interfaces.digitalLink` set to `declaration`, plus a + * well-known route. Pass `declaration: OMIT` to leave the key off entirely — + * the conforming-but-silent card. + */ +const OMIT = Symbol('omit') + +function routesFor( + declaration: unknown | typeof OMIT, + wellKnown: Routes[string] | undefined = json(VALID_DOC), + wellKnownRoute: string = WK_ROUTE, +): Routes { + const base = goodTargetRoutes() + const card = JSON.parse( + base['GET /.well-known/agents.json']!({ method: 'GET', accept: 'application/json' }).body!, + ) as Record + if (declaration !== OMIT) card.interfaces.digitalLink = declaration + return withOverrides(base, { + 'GET /.well-known/agents.json': json(card), + ...(wellKnown ? { [wellKnownRoute]: wellKnown } : {}), + }) +} + +async function judge(routes: Routes, origin = GOOD) { + const calls: string[] = [] + const inner = makeFetcher(routes, origin) + const observer = new Observer({ + fetcher: async (url, init) => { + calls.push(url) + return inner(url, init) + }, + delayMs: 0, + }) + const bundle = await observeTarget(origin, observer, 7) + const checks = runChecks(bundle) + const { grade } = gradeOf(axScoreOf(checks), checks) + return { bundle, checks, grade, calls } +} + +const dl = (checks: CheckResult[]) => checks.find((c) => c.id === 'digital-link-resolver')! + +// --------------------------------------------------------------------------- +// 1. The vendored schema — provenance +// --------------------------------------------------------------------------- + +const FIXTURE_PATH = new URL('./fixtures/gs1-description-file-schema.json', import.meta.url) +const FIXTURE_BYTES = readFileSync(FIXTURE_PATH) +const PUBLISHED = JSON.parse(FIXTURE_BYTES.toString('utf8')) as Record + +describe("GS1's published description-file schema is vendored, not fetched", () => { + it('the vendored bytes hash to the pinned sha256 — byte provenance, not merely semantic equivalence', () => { + expect(createHash('sha256').update(FIXTURE_BYTES).digest('hex')).toBe( + GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.sha256, + ) + expect(FIXTURE_BYTES.byteLength).toBe(GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.bytes) + }) + + it('the vendored document identifies itself as the pinned draft-07 schema', () => { + expect(PUBLISHED.$id).toBe(GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.$id) + expect(PUBLISHED.$schema).toBe(GS1_DESCRIPTION_FILE_SCHEMA_SOURCE.dialect) + expect(PUBLISHED.required).toEqual(['resolverRoot', 'supportedPrimaryKeys']) + }) + + it('no source file reaches ref.gs1.org at verification time', () => { + // Determinism (DESIGN.md) and the SSRF gate both forbid it: a verdict may + // not depend on a third-party document its publisher can edit. The only + // permitted mention of the host is in prose/provenance, never a fetch. + for (const file of ['../src/gs1-resolver.ts', '../src/checks.ts', '../src/discovery.ts']) { + const src = readFileSync(new URL(file, import.meta.url), 'utf8') + expect(src).not.toMatch(/(fetch|observe)\s*\([^)]*ref\.gs1\.org/) + } + }) +}) + +// --------------------------------------------------------------------------- +// 2. The translation is faithful to the published bytes — keyword by keyword +// --------------------------------------------------------------------------- + +/** + * Walk the published draft-07 schema against the MiniSchema translation and + * report every divergence in BOTH directions: a published constraint the + * translation drops without an entry in the not-enforced ledger, and a + * constraint the translation invents that GS1 never wrote. + */ +function auditTranslation( + published: Record, + mini: Record | undefined, + path: string, + problems: string[], +): void { + if (mini === undefined) { + problems.push(`${path}: published subschema has no counterpart in the translation`) + return + } + for (const [key, value] of Object.entries(published)) { + const at = `${path}.${key}` + if (key === 'properties') { + const miniProps = (mini.properties ?? {}) as Record + for (const [prop, sub] of Object.entries(value as Record)) { + auditTranslation(sub as Record, miniProps[prop], `${path}.${prop}`, problems) + } + for (const prop of Object.keys(miniProps)) { + if (!(prop in (value as Record))) { + problems.push(`${path}.${prop}: translation declares a property the published schema does not`) + } + } + } else if (key === 'items') { + auditTranslation(value as Record, mini.items, `${path}[]`, problems) + } else if (key === 'type' || key === 'required' || key === 'enum' || key === 'pattern') { + if (JSON.stringify(mini[key]) !== JSON.stringify(value)) { + problems.push(`${at}: translation has ${JSON.stringify(mini[key])}, published has ${JSON.stringify(value)}`) + } + } else if (at in GS1_SCHEMA_MISPLACED_MEMBERS) { + // Present in the published bytes at a NON-keyword position. GS1's own + // validator ignores it; so do we, deliberately and on the record. + if (mini[key] !== undefined) problems.push(`${at}: translation enforces a misplaced member GS1 does not`) + } else if (key in GS1_SCHEMA_KEYWORDS_NOT_ENFORCED) { + if (mini[key] !== undefined) problems.push(`${at}: translation enforces a keyword listed as not-enforced`) + } else { + problems.push(`${at}: published keyword is neither enforced nor listed in GS1_SCHEMA_KEYWORDS_NOT_ENFORCED`) + } + } + for (const key of Object.keys(mini)) { + if (key === 'properties' || key === 'items') continue + if (!(key in published)) { + problems.push(`${path}.${key}: translation invents a constraint the published schema does not carry`) + } + } +} + +describe('the MiniSchema translation is faithful to the published bytes', () => { + it('drops nothing unaccounted-for and invents nothing', () => { + const problems: string[] = [] + auditTranslation(PUBLISHED, GS1_DESCRIPTION_FILE_SCHEMA as unknown as Record, '$', problems) + expect(problems).toEqual([]) + }) + + it('carries the full 21-value supportedPrimaryKeys vocabulary in published order', () => { + expect([...GS1_PRIMARY_KEY_AIS]).toEqual(PUBLISHED.properties.supportedPrimaryKeys.items.enum) + expect(GS1_PRIMARY_KEY_AIS).toHaveLength(21) + }) + + it("reproduces GS1's misplaced `contact.hasTelephone` rather than silently tightening it", () => { + // In the published file hasTelephone sits as a SIBLING of `properties` + // inside the contact subschema — not a keyword position. GS1's schema + // therefore does not validate it, and neither may we. + expect(PUBLISHED.properties.contact.hasTelephone).toBeDefined() + expect(PUBLISHED.properties.contact.properties.hasTelephone).toBeUndefined() + expect(Object.keys(GS1_SCHEMA_MISPLACED_MEMBERS)).toContain('$.contact.hasTelephone') + // …and the practical consequence: a wrong-typed hasTelephone still validates. + expect(validateGs1DescriptionFile({ ...VALID_DOC, contact: { fn: 'x', hasTelephone: 12345 } })).toEqual([]) + }) + + it('permits additional members — the published schema sets no additionalProperties', () => { + expect(PUBLISHED.additionalProperties).toBeUndefined() + expect(validateGs1DescriptionFile({ ...VALID_DOC, extensions: { anything: true }, pins: [] })).toEqual([]) + }) + + it('accepts a supportedLinkType item with no members — published `items` declares no type and no required', () => { + expect(PUBLISHED.properties.supportedLinkType.items.type).toBeUndefined() + expect(PUBLISHED.properties.supportedLinkType.items.required).toBeUndefined() + expect(validateGs1DescriptionFile({ ...VALID_DOC, supportedLinkType: [{}] })).toEqual([]) + }) +}) + +describe('validateGs1DescriptionFile catches what the published schema actually constrains', () => { + it('the minimal document — the two required members and nothing else — is valid', () => { + expect(validateGs1DescriptionFile({ resolverRoot: GOOD, supportedPrimaryKeys: ['01'] })).toEqual([]) + }) + + it('a missing required member is reported at its property path', () => { + const v = validateGs1DescriptionFile({ supportedPrimaryKeys: ['01'] }) + expect(v).toEqual([{ path: '$.resolverRoot', message: 'required property missing' }]) + }) + + it('a supportedPrimaryKeys value outside the closed AI vocabulary is reported at its index', () => { + const v = validateGs1DescriptionFile({ resolverRoot: GOOD, supportedPrimaryKeys: ['01', '99'] }) + expect(v).toHaveLength(1) + expect(v[0]!.path).toBe('$.supportedPrimaryKeys[1]') + expect(v[0]!.message).toMatch(/not in enum/) + }) + + it('a CURIE prefix without its colon fails the published pattern', () => { + const v = validateGs1DescriptionFile({ + ...VALID_DOC, + supportedLinkType: [{ namespace: 'https://ref.gs1.org/voc/', prefix: 'gs1' }], + }) + expect(v).toHaveLength(1) + expect(v[0]!.path).toBe('$.supportedLinkType[0].prefix') + expect(v[0]!.message).toMatch(/does not match pattern/) + }) + + it('a wrong-typed member is reported with the expected and actual type', () => { + const v = validateGs1DescriptionFile({ ...VALID_DOC, linkTypeDefaultCanBeLinkset: 'yes' }) + expect(v).toEqual([{ path: '$.linkTypeDefaultCanBeLinkset', message: 'expected boolean, got string' }]) + }) + + it('a non-object document fails at the root', () => { + expect(validateGs1DescriptionFile([VALID_DOC])[0]!.message).toMatch(/expected object, got array/) + }) + + it('draft-07 `format: "uri"` is NOT enforced — a non-URI resolverRoot is schema-valid', () => { + // Deliberate, and named in GS1_SCHEMA_KEYWORDS_NOT_ENFORCED. The check's + // separate resolverRoot-origin rule is what actually catches this, and it + // says so in its own words — see the semantic tests below. + expect(validateGs1DescriptionFile({ resolverRoot: 'not a url', supportedPrimaryKeys: ['01'] })).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// 3. Undeclared is not a failure — the property everything else rests on +// --------------------------------------------------------------------------- + +describe('a card that does NOT declare the interface', () => { + it('SKIPs, is never fetched for, and its grade is untouched', async () => { + const { checks, calls, grade, bundle } = await judge(routesFor(OMIT, json(VALID_DOC))) + expect(dl(checks).verdict).toBe('skip') + expect(dl(checks).detail).toMatch(/OPTIONAL/) + // The well-known is SERVED here and still never requested: presence on the + // wire is not the trigger, the card's declaration is. + expect(calls.some((u) => u.includes('gs1resolver'))).toBe(false) + expect(bundle.items.some((e) => e.role === ROLE.gs1Resolver)).toBe(false) + expect(grade).toBe('A+') + for (const c of checks) expect(c.verdict, `${c.id}: ${c.detail}`).not.toBe('fail') + }) + + it('is unaffected by the well-known being absent altogether', async () => { + const { checks, grade } = await judge(withoutRoutes(routesFor(OMIT), WK_ROUTE)) + expect(dl(checks).verdict).toBe('skip') + expect(grade).toBe('A+') + }) + + it('parseAgentsJson leaves digitalLink undefined when the key is absent', () => { + expect(parseAgentsJson({ interfaces: { http: {} } }, GOOD).digitalLink).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// 4. Declared and correct +// --------------------------------------------------------------------------- + +describe('a card that declares the interface and serves a valid description file', () => { + it('PASSes, and the detail names what was and was not verified', async () => { + const { checks, calls } = await judge(routesFor({})) + const c = dl(checks) + expect(c.verdict, c.detail).toBe('pass') + expect(c.detail).toMatch(/valid against GS1's published description-file schema/) + expect(c.detail).toMatch(/never fetched at verification time/) + expect(c.detail).toMatch(/NOT enforced: draft-07 `format: "uri"`/) + expect(c.detail).toMatch(/NOT covered: RFC 9264 linkset responses/) + expect(calls.filter((u) => u.includes('gs1resolver'))).toHaveLength(1) + }) + + it('scores no AX point — it is an additive readiness dimension, not one of the ten', async () => { + const { checks } = await judge(routesFor({})) + expect(dl(checks).axItem).toBeUndefined() + }) + + it('is independent of the AXP opt-in — the fixture declares no probe manifest and still PASSes', async () => { + const { checks } = await judge(routesFor({})) + expect(checks.find((c) => c.id === 'card-interfaces-linked')!.verdict).toBe('skip') + expect(dl(checks).verdict).toBe('pass') + }) + + it('honours a card-declared same-origin wellKnown at a non-default path', async () => { + const routes = withoutRoutes( + routesFor({ wellKnown: '/resolver-description' }, json(VALID_DOC), 'GET /resolver-description'), + WK_ROUTE, + ) + const { checks, calls } = await judge(routes) + expect(dl(checks).verdict, dl(checks).detail).toBe('pass') + expect(calls.some((u) => u.endsWith('/resolver-description'))).toBe(true) + }) + + it('accepts a matching card-declared resolverRoot', async () => { + const { checks } = await judge(routesFor({ resolverRoot: `${GOOD}/` })) + expect(dl(checks).verdict, dl(checks).detail).toBe('pass') + }) +}) + +// --------------------------------------------------------------------------- +// 5. Declared and wrong — every failing case, which is the point +// --------------------------------------------------------------------------- + +describe('declared-but-broken FAILs with an actionable message', () => { + it('404 at the well-known → fail naming the status', async () => { + const { checks } = await judge(withoutRoutes(routesFor({}), WK_ROUTE)) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/did not answer 2xx — status 404/) + expect(c.detail).toContain('/.well-known/gs1resolver') + }) + + it('a non-2xx that is not 404 → fail naming that status', async () => { + const { checks } = await judge(routesFor({}, () => ({ status: 503, contentType: 'application/json', body: '{}' }))) + expect(dl(checks).detail).toMatch(/status 503/) + }) + + it('unparseable JSON → fail saying the body did not parse', async () => { + const { checks } = await judge( + routesFor({}, () => ({ status: 200, contentType: 'application/json', body: '{"resolverRoot": ' })), + ) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/body did not parse as JSON/) + }) + + it('a non-JSON content-type → fail naming the served type', async () => { + const { checks } = await judge( + routesFor({}, () => ({ status: 200, contentType: 'text/html', body: JSON.stringify(VALID_DOC) })), + ) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/content-type "text\/html"/) + }) + + it('a missing required member → fail naming the schema property that failed', async () => { + const { checks } = await judge(routesFor({}, json({ supportedPrimaryKeys: ['01'] }))) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/fails GS1's published description-file schema/) + expect(c.detail).toMatch(/\$\.resolverRoot: required property missing/) + }) + + it('a primary key outside the closed vocabulary → fail naming the offending index', async () => { + const { checks } = await judge( + routesFor({}, json({ resolverRoot: GOOD, supportedPrimaryKeys: ['01', 'gtin'] })), + ) + expect(dl(checks).detail).toMatch(/\$\.supportedPrimaryKeys\[1\]: not in enum/) + }) + + it('a resolverRoot naming another origin → fail (the document was copied from someone else)', async () => { + const { checks } = await judge(routesFor({}, json({ ...VALID_DOC, resolverRoot: 'https://other.example' }))) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/resolverRoot names origin https:\/\/other\.example but the description file was served from https:\/\/good\.example/) + }) + + it('a resolverRoot that is not a URL at all → fail, and the message says format is unenforced', async () => { + const { checks } = await judge(routesFor({}, json({ ...VALID_DOC, resolverRoot: 'good.example' }))) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/is not an absolute URL/) + expect(c.detail).toMatch(/draft-07 leaves unenforced/) + }) + + it('a card resolverRoot that disagrees with the document → fail naming both', async () => { + const { checks } = await judge(routesFor({ resolverRoot: 'https://elsewhere.example' })) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/the two claims disagree/) + expect(c.detail).toContain('https://elsewhere.example') + }) + + it('a network failure at the well-known → fail saying the fetch failed, not that the file is invalid', async () => { + const base = routesFor({}) + const inner = makeFetcher(base) + const observer = new Observer({ + fetcher: async (url, init) => { + if (url.includes('gs1resolver')) throw new TypeError('fetch failed: connection reset') + return inner(url, init) + }, + delayMs: 0, + }) + const checks = runChecks(await observeTarget(GOOD, observer, 7)) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/fetch failed \(/) + expect(c.detail).toMatch(/connection reset/) + }) +}) + +describe('a card may not claim another origin\'s resolver', () => { + it('an absolute off-origin wellKnown → fail, and NOTHING is fetched from that origin', async () => { + const { checks, calls } = await judge( + routesFor({ wellKnown: 'https://resolver.evil.example/.well-known/gs1resolver' }), + ) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/must not claim another origin's resolver/) + expect(c.detail).toContain('https://resolver.evil.example') + expect(calls.some((u) => u.includes('evil.example'))).toBe(false) + }) + + it('a private/metadata wellKnown is refused without a byte on the wire', async () => { + const { checks, calls } = await judge(routesFor({ wellKnown: 'http://169.254.169.254/.well-known/gs1resolver' })) + expect(dl(checks).verdict).toBe('fail') + expect(calls.some((u) => u.includes('169.254.169.254'))).toBe(false) + }) + + it('a wellKnown that is not a URL → fail, unfetched', async () => { + // absolutize() leaves un-parseable junk as-is; the check refuses it by + // name rather than fetching something it cannot reason about. + const { checks } = await judge(routesFor({ wellKnown: 'http://' })) + expect(dl(checks).verdict).toBe('fail') + expect(dl(checks).detail).toMatch(/does not resolve to a URL|not a publicly-routable/) + }) +}) + +describe('a present-but-defective declaration is a FAIL, never an absence', () => { + for (const [label, value, shown] of [ + ['a bare true', true, 'boolean'], + ['a string', 'https://good.example/', 'string'], + ['an array', [{ wellKnown: '/x' }], 'array'], + ['an explicit null', null, 'null'], + ] as Array<[string, unknown, string]>) { + it(`${label} → fail, and the message says how to declare nothing instead`, async () => { + const { checks, calls } = await judge(routesFor(value)) + const c = dl(checks) + expect(c.verdict).toBe('fail') + expect(c.detail).toContain(`(got ${shown})`) + expect(c.detail).toMatch(/OMIT the key entirely/) + // A shape no verifier can check is not fetched for. + expect(calls.some((u) => u.includes('gs1resolver'))).toBe(false) + }) + } +}) + +// --------------------------------------------------------------------------- +// 6. Against reality — id.org.ai returns 404 today +// --------------------------------------------------------------------------- + +const ID_ORG_AI = 'https://id.org.ai' + +function bundleWithDeclaredInterface(origin: string, wellKnown: Evidence): EvidenceBundle { + const card = { + name: 'id.org.ai', + interfaces: { http: { root: { method: 'GET', url: `${origin}/` } }, digitalLink: {} }, + } + return { + target: origin, + fetchedAt: '2026-08-06T00:00:00.000Z', + seed: 1, + items: [ + { + role: ROLE.agentsJson, + url: `${origin}/.well-known/agents.json`, + method: 'GET', + status: 200, + contentType: 'application/json', + headers: {}, + body: JSON.stringify(card), + elapsedMs: 1, + }, + wellKnown, + ], + } +} + +describe('reality: the estate\'s own resolver would FAIL if it declared the interface', () => { + it('id.org.ai\'s recorded 404 (2026-08-06) fails a declared Digital Link interface', () => { + // Recorded verbatim 2026-08-06: HTTP/2 404, content-type application/json, + // content-length 81, server cloudflare. + const observed404: Evidence = { + role: ROLE.gs1Resolver, + url: `${ID_ORG_AI}${GS1_RESOLVER_WELL_KNOWN_PATH}`, + method: 'GET', + status: 404, + contentType: 'application/json', + headers: {}, + body: '{"error":"not_found","error_description":"The requested endpoint does not exist"}', + elapsedMs: 42, + } + const c = dl(runChecks(bundleWithDeclaredInterface(ID_ORG_AI, observed404))) + expect(c.verdict).toBe('fail') + expect(c.detail).toMatch(/did not answer 2xx — status 404/) + expect(c.detail).toContain(`${ID_ORG_AI}${GS1_RESOLVER_WELL_KNOWN_PATH}`) + }) + + // Opt-in (APIQA_LIVE_NET=1) because a unit suite must not depend on the + // internet. One request, read-only. It asserts a CONDITIONAL, not a wish: + // whatever id.org.ai answers today, the check's verdict must match it — 2xx + // and schema-valid ⇒ pass, anything else ⇒ fail. + it('a REAL in-the-wild description file validates — barcoding.dev, served 200 on 2026-08-06', () => { + // A validator that has only ever seen its own fixtures is a validator + // nobody has tested. This is the live estate resolver's actual document, + // vendored verbatim: it carries `extensions`, `conneg`, `delegation` and + // `pins` (legal — the published schema sets no additionalProperties) and a + // `profile` member inside supportedLinkType (likewise legal). + const doc = JSON.parse(readFileSync(new URL('./fixtures/barcoding-dev-gs1resolver.json', import.meta.url), 'utf8')) + expect(validateGs1DescriptionFile(doc)).toEqual([]) + expect(doc.supportedLinkType[0].profile).toBeDefined() + expect(doc.extensions).toBeDefined() + }) + + it.skipIf(process.env.APIQA_LIVE_NET !== '1')( + 'LIVE: id.org.ai/.well-known/gs1resolver — the verdict tracks what the host actually answers', + async () => { + const observer = new Observer({ delayMs: 0 }) + const ev = await observer.observe(ROLE.gs1Resolver, `${ID_ORG_AI}${GS1_RESOLVER_WELL_KNOWN_PATH}`, { + accept: 'application/json', + }) + const c = dl(runChecks(bundleWithDeclaredInterface(ID_ORG_AI, { ...ev, role: ROLE.gs1Resolver }))) + const served = ev.status !== null && ev.status >= 200 && ev.status < 300 + const valid = served && validateGs1DescriptionFile(JSON.parse(ev.body ?? 'null')).length === 0 + expect(c.verdict, `live status=${ev.status} ct=${ev.contentType} detail=${c.detail}`).toBe( + valid ? 'pass' : 'fail', + ) + // Recorded 2026-08-06: 404. If this ever stops being a fail, the + // resolver shipped — and the interface may then be declared. + if (!served) expect(c.detail).toMatch(/did not answer 2xx/) + }, + 20_000, + ) + + it.skipIf(process.env.APIQA_LIVE_NET !== '1')( + 'LIVE: barcoding.dev/.well-known/gs1resolver still validates against the pinned schema', + async () => { + const observer = new Observer({ delayMs: 0 }) + const ev = await observer.observe(ROLE.gs1Resolver, `https://barcoding.dev${GS1_RESOLVER_WELL_KNOWN_PATH}`, { + accept: 'application/json', + }) + expect(ev.status, `live status=${ev.status}`).toBe(200) + expect(validateGs1DescriptionFile(JSON.parse(ev.body ?? 'null'))).toEqual([]) + }, + 20_000, + ) +}) diff --git a/test/fixtures/barcoding-dev-gs1resolver.json b/test/fixtures/barcoding-dev-gs1resolver.json new file mode 100644 index 0000000..0cd6e32 --- /dev/null +++ b/test/fixtures/barcoding-dev-gs1resolver.json @@ -0,0 +1,44 @@ +{ + "name": "barcoding.dev — the delegating door", + "resolverRoot": "https://barcoding.dev", + "supportedLinkType": [ + { + "namespace": "https://gs1.org/voc/", + "prefix": "gs1:", + "profile": [ + "gs1:defaultLink" + ] + } + ], + "supportedPrimaryKeys": [ + "01", + "00", + "417" + ], + "extensions": { + "note": "non-GS1 keys served through the same door (P0-5d)", + "keys": [ + "/vin/{vin}", + "/isbn/{isbn13}", + "/ndc/{ndc}", + "/usps-impb/{tracking}", + "/ups-1z/{tracking}" + ] + }, + "conneg": { + "faces": [ + "text/html", + "text/markdown", + "application/json", + "application/ld+json", + "application/linkset+json" + ], + "law": "extension > Accept > client-class defaults (Sec-Fetch for browsers)" + }, + "delegation": "this property reads barcodes and serves the conformant standard surface; resolution — who and what alike — is id.org.ai's (vehicles via auto.dev, the vertical authority)", + "pins": { + "registry_sha256": "597c1c5e14adda3436e3ec14885963b06acad68bbede85fe36e52d58c4fa4571", + "ai_table_sha256": "8e243cf22e905241d1b2e6e1d4be3d31d965fc60e15470fc8e84e05872cbd99f", + "pinned": "2026-07-31" + } +} diff --git a/test/fixtures/gs1-description-file-schema.json b/test/fixtures/gs1-description-file-schema.json new file mode 100644 index 0000000..6a71356 --- /dev/null +++ b/test/fixtures/gs1-description-file-schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://ref.gs1.org/standards/resolver/1.2.0/description-file-schema", + "description": "The JSON schema for a conformant GS1 resolver description file to be placed at /.well-known/gs1resolver", + "type": "object", + "properties": { + "resolverRoot": { + "description": "The root URL of the resolver", + "type": "string", + "format": "uri" + }, + "supportedPrimaryKeys": { + "description": "An array of primary keys supported by the resolver, provided as their numeric AI. The value 'all' indicates that all GS1 primary identification keys are supported", + "type": "array", + "items": { + "type": "string", + "enum": ["all", "01", "8006", "8013", "8010", "410", "411", "412", "413", "414", "415", "417", "8017", "8018", "255", "00", "253", "401", "402", "8003", "8004"] + } + }, + "name": { + "description": "The name of the resolver", + "type": "string" + }, + "supportedLinkType": { + "type": "array", + "items": { + "properties": { + "namespace": { + "description": "A namespace URI recognised by the resolver", + "type": "string", + "format": "uri" + }, + "prefix": { + "description": "The prefix assigned to this URI for use in CURIEs, including the colon", + "type": "string", + "pattern": "^[a-zA-Z_][A-Za-z0-9_-]*?:$" + } + } + } + }, + "linkTypeDefaultCanBeLinkset": { + "description": "Boolean flag stating whether the resolver supports the option to make 'linkset' the default linkType.", + "type": "boolean", + "default": false + }, + "supportedContextValuesEnumerated": { + "description": "Enumerated list of values for context supported by this resolver", + "type": "array", + "items": { + "type": "string" + } + }, + "supportedContextValuesExternal": { + "description": "External lists of supported values for context recognised by this resolver", + "type": "array", + "items": { + "type": "object", + "properties": { + "nameOfList": { + "description": "The name of the list of values", + "type": "string" + }, + "url": { + "description": "The URL of the external list", + "type": "string", + "format": "uri" + } + } + } + }, + "contact": { + "description": "Contact details for the resolver operator, uses VCard", + "type": "object", + "properties": { + "fn": { + "description": "Full name", + "type": "string" + }, + "hasAddress": { + "description": "Address info for the resolver operator", + "type": "object", + "properties": { + "streetAddress": { + "description": "Street address of hte operator", + "type": "string" + }, + "locality": { + "description": "locality of the operator", + "type": "string" + }, + "region": { + "description": "region of the operator", + "type": "string" + }, + "postal-code": { + "decription": "post code of the operator", + "type": "string" + } + } + } + }, + "hasTelephone": { + "description": "A contact telephone number", + "type": "string", + "format": "uri" + } + }, + "extensionProfile": { + "description": "A link to a document that describes any extended key=value pairs supported by the resolver and/or an additional supported compression/decompression capability", + "type": "string", + "format": "uri" + }, + + "jsonLdContextLocation": { + "description": "The location of the JSON-LD context file referenced when serving linksets as JSON", + "type": "string", + "format": "uri" + } + + + }, + "required": ["resolverRoot", "supportedPrimaryKeys"] +} +