From 1f10b9d184e0285ac4fba93227b691469f5dc153 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 06:45:23 +0100 Subject: [PATCH 1/6] feat: add NetworkConsistencyChecker for managed publisher networks (#536) Validates adagents.json deployment health across managed domains by detecting orphaned/stale/missing pointers, schema errors, and unreachable agent endpoints. Includes SSRF protection, response size limits, and a CLI command (adcp check-network). Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/network-consistency-checker.md | 5 + bin/adcp.js | 137 +++++ .../discovery/network-consistency-checker.ts | 543 ++++++++++++++++++ src/lib/index.ts | 12 + test/lib/network-consistency-checker.test.js | 382 ++++++++++++ 5 files changed, 1079 insertions(+) create mode 100644 .changeset/network-consistency-checker.md create mode 100644 src/lib/discovery/network-consistency-checker.ts create mode 100644 test/lib/network-consistency-checker.test.js diff --git a/.changeset/network-consistency-checker.md b/.changeset/network-consistency-checker.md new file mode 100644 index 00000000..38fd40f6 --- /dev/null +++ b/.changeset/network-consistency-checker.md @@ -0,0 +1,5 @@ +--- +"@adcp/client": minor +--- + +Add NetworkConsistencyChecker for validating managed publisher network deployments. Detects orphaned pointers, stale pointers, missing pointers, schema errors, and unreachable agent endpoints. Available as both a library import and CLI command (`adcp check-network`). diff --git a/bin/adcp.js b/bin/adcp.js index 6828d7ec..b03b36f6 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -704,6 +704,7 @@ USAGE: COMMANDS: storyboard Test agent flows (run, list, show, step) + check-network Validate managed publisher network deployment comply [options] DEPRECATED — use "storyboard run" instead test [scenario] Run individual test scenarios (legacy) registry Brand/property registry lookups @@ -1264,6 +1265,137 @@ async function handleStoryboardStepCmd(args) { process.exit(result.passed ? 0 : 3); } +async function handleCheckNetworkCommand(args) { + if (args.includes('--help') || args.includes('-h')) { + console.log(` +Validate a managed publisher network deployment. + +USAGE: + adcp check-network --url [options] + adcp check-network --domains [options] + +OPTIONS: + --url URL URL of the authoritative adagents.json + --domains LIST Comma-separated domains to check + --concurrency N Max parallel fetches (default: 10) + --timeout MS Per-request timeout in ms (default: 10000) + --json Output raw JSON + --help, -h Show this help + +EXAMPLES: + adcp check-network --url https://network.example.com/adagents/v2/adagents.json + adcp check-network --url https://network.example.com/adagents.json --domains extra1.com,extra2.com + adcp check-network --domains cookingdaily.com,gardenweekly.com +`); + return; + } + + const urlIndex = args.indexOf('--url'); + const domainsIndex = args.indexOf('--domains'); + const concurrencyIndex = args.indexOf('--concurrency'); + const timeoutIndex = args.indexOf('--timeout'); + const jsonOutput = args.includes('--json'); + + const url = urlIndex !== -1 ? args[urlIndex + 1] : undefined; + const domainsStr = domainsIndex !== -1 ? args[domainsIndex + 1] : undefined; + const concurrency = concurrencyIndex !== -1 ? parseInt(args[concurrencyIndex + 1], 10) : undefined; + const timeout = timeoutIndex !== -1 ? parseInt(args[timeoutIndex + 1], 10) : undefined; + + if (concurrency !== undefined && (isNaN(concurrency) || concurrency < 1)) { + console.error('ERROR: --concurrency must be a positive integer'); + process.exit(2); + } + if (timeout !== undefined && (isNaN(timeout) || timeout < 1)) { + console.error('ERROR: --timeout must be a positive integer'); + process.exit(2); + } + + if (!url && !domainsStr) { + console.error('ERROR: --url or --domains is required\n'); + console.error('Run "adcp check-network --help" for usage'); + process.exit(2); + } + + const domains = domainsStr ? domainsStr.split(',').map(d => d.trim()).filter(Boolean) : undefined; + + const { NetworkConsistencyChecker } = require('../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: url, + domains, + concurrency, + timeoutMs: timeout, + logLevel: 'warn', + }); + + try { + const report = await checker.check(); + + const totalIssues = report.schemaErrors.length + report.missingPointers.length + + report.stalePointers.length + report.orphanedPointers.length + + report.agentHealth.filter(a => !a.reachable).length; + + if (jsonOutput) { + console.log(JSON.stringify(report, null, 2)); + process.exit(totalIssues > 0 ? 1 : 0); + return; + } + + // Pretty-print report + console.log(`\nNetwork Consistency Report`); + console.log(`${'='.repeat(50)}`); + console.log(`Authoritative URL: ${report.authoritativeUrl}`); + console.log(`Coverage: ${(report.coverage * 100).toFixed(1)}%`); + + if (report.schemaErrors.length > 0) { + console.log(`\nSchema Errors (${report.schemaErrors.length}):`); + for (const err of report.schemaErrors) { + console.log(` - ${err.field}: ${err.message}`); + } + } + + if (report.agentHealth.length > 0) { + console.log(`\nAgent Health:`); + for (const agent of report.agentHealth) { + const status = agent.reachable ? 'OK' : 'UNREACHABLE'; + const detail = agent.error ? ` (${agent.error})` : agent.statusCode ? ` (HTTP ${agent.statusCode})` : ''; + console.log(` ${status} ${agent.url}${detail}`); + } + } + + if (report.missingPointers.length > 0) { + console.log(`\nMissing Pointers (${report.missingPointers.length}):`); + for (const p of report.missingPointers) { + console.log(` - ${p.domain}: ${p.error}`); + } + } + + if (report.stalePointers.length > 0) { + console.log(`\nStale Pointers (${report.stalePointers.length}):`); + for (const p of report.stalePointers) { + console.log(` - ${p.domain}: points to ${p.pointerUrl}, expected ${p.expectedUrl}`); + } + } + + if (report.orphanedPointers.length > 0) { + console.log(`\nOrphaned Pointers (${report.orphanedPointers.length}):`); + for (const p of report.orphanedPointers) { + console.log(` - ${p.domain}: points to ${p.pointerUrl} but not listed in properties`); + } + } + + if (totalIssues === 0) { + console.log(`\nAll checks passed.`); + } else { + console.log(`\n${totalIssues} issue(s) found.`); + } + + process.exit(totalIssues > 0 ? 1 : 0); + } catch (error) { + console.error(`ERROR: ${error.message}`); + process.exit(2); + } +} + async function main() { const args = process.argv.slice(2); @@ -1288,6 +1420,11 @@ async function main() { return; } + if (args[0] === 'check-network') { + await handleCheckNetworkCommand(args.slice(1)); + return; + } + // Handle help if (args.includes('--help') || args.includes('-h') || args.length === 0) { printUsage(); diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts new file mode 100644 index 00000000..4c075dcc --- /dev/null +++ b/src/lib/discovery/network-consistency-checker.ts @@ -0,0 +1,543 @@ +/** + * Network Consistency Checker for AdCP + * + * Validates managed publisher network deployments by checking that: + * 1. The authoritative adagents.json is well-formed + * 2. Each property domain has a valid pointer file + * 3. No orphaned or stale pointer files exist + * 4. All authorized agent endpoints are reachable + */ + +import { createLogger, type LogLevel } from '../utils/logger'; +import { LIBRARY_VERSION } from '../version'; +import { validateUserAgent } from '../utils/validate-user-agent'; +import { validateAgentUrl } from '../validation'; +import type { AdAgentsJson, AuthorizedAgent, Property } from './types'; + +// ====== Configuration ====== + +export interface NetworkConsistencyCheckerConfig { + /** URL of the authoritative adagents.json file */ + authoritativeUrl?: string; + /** Domains to check (if no authoritativeUrl, fetches pointer from first domain) */ + domains?: string[]; + /** Max parallel fetches (default 10, max 50) */ + concurrency?: number; + /** Per-request timeout in ms (default 10000) */ + timeoutMs?: number; + logLevel?: LogLevel; + userAgent?: string; +} + +// ====== Report types ====== + +export interface OrphanedPointer { + domain: string; + pointerUrl: string; +} + +export interface StalePointer { + domain: string; + pointerUrl: string; + expectedUrl: string; +} + +export interface MissingPointer { + domain: string; + error: string; +} + +export interface SchemaError { + field: string; + message: string; +} + +export interface AgentHealthResult { + url: string; + reachable: boolean; + statusCode?: number; + error?: string; +} + +export type DomainStatus = 'ok' | 'missing_pointer' | 'stale_pointer' | 'orphaned_pointer' | 'error'; + +export interface DomainDetail { + domain: string; + status: DomainStatus; + pointerUrl?: string; + errors: string[]; +} + +export interface NetworkCheckReport { + authoritativeUrl: string; + coverage: number; + orphanedPointers: OrphanedPointer[]; + stalePointers: StalePointer[]; + missingPointers: MissingPointer[]; + schemaErrors: SchemaError[]; + agentHealth: AgentHealthResult[]; + domains: DomainDetail[]; +} + +// ====== Implementation ====== + +const DEFAULT_CONCURRENCY = 10; +const MAX_CONCURRENCY = 50; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 1_048_576; // 1 MB + +const FETCH_HEADERS = { + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', +}; + +export class NetworkConsistencyChecker { + private readonly authoritativeUrl?: string; + private readonly domains: string[]; + private readonly concurrency: number; + private readonly timeoutMs: number; + private readonly logger: ReturnType; + private readonly userAgentHeader: string; + private readonly fromHeader: string; + + constructor(config: NetworkConsistencyCheckerConfig) { + if (!config.authoritativeUrl && (!config.domains || config.domains.length === 0)) { + throw new Error('Either authoritativeUrl or domains must be provided'); + } + if (config.userAgent) { + validateUserAgent(config.userAgent); + } + + this.authoritativeUrl = config.authoritativeUrl; + this.domains = config.domains ?? []; + this.concurrency = Math.min(config.concurrency ?? DEFAULT_CONCURRENCY, MAX_CONCURRENCY); + this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (this.concurrency < 1) throw new Error('concurrency must be >= 1'); + if (this.timeoutMs < 1) throw new Error('timeoutMs must be >= 1'); + this.logger = createLogger({ level: config.logLevel ?? 'warn' }).child('NetworkConsistencyChecker'); + this.userAgentHeader = `adcp-network-checker/${LIBRARY_VERSION} (+https://adcontextprotocol.org)`; + this.fromHeader = config.userAgent + ? `adcp-network-checker@adcontextprotocol.org (${config.userAgent}; v${LIBRARY_VERSION})` + : `adcp-network-checker@adcontextprotocol.org (v${LIBRARY_VERSION})`; + } + + async check(): Promise { + const report: NetworkCheckReport = { + authoritativeUrl: '', + coverage: 0, + orphanedPointers: [], + stalePointers: [], + missingPointers: [], + schemaErrors: [], + agentHealth: [], + domains: [], + }; + + // Step 1: Resolve and fetch the authoritative file + const { url: resolvedUrl, data: authData } = await this.fetchAuthoritative(report); + report.authoritativeUrl = resolvedUrl; + + if (!authData) { + return report; + } + + // Step 2: Validate authoritative file schema + this.validateSchema(authData, report); + + // Step 3: Extract domains from authoritative properties + const authoritativeDomains = this.extractDomains(authData.properties ?? []); + + // Step 4: Check agent endpoint health + if (authData.authorized_agents && authData.authorized_agents.length > 0) { + report.agentHealth = await this.checkAgentHealth(authData.authorized_agents); + } + + // Step 5: Check pointer files on authoritative domains + await this.checkPointers(resolvedUrl, authoritativeDomains, report); + + // Step 6: Check for orphaned pointers (domains not in authoritative file) + const extraDomains = this.domains.filter(d => !authoritativeDomains.has(d)); + if (extraDomains.length > 0) { + await this.checkOrphanedPointers(resolvedUrl, extraDomains, report); + } + + // Step 7: Compute coverage + const total = authoritativeDomains.size; + const valid = report.domains.filter(d => d.status === 'ok').length; + report.coverage = total > 0 ? valid / total : 0; + + return report; + } + + // ---- Internal methods ---- + + private async fetchAuthoritative( + report: NetworkCheckReport + ): Promise<{ url: string; data: AdAgentsJson | null }> { + let url = this.authoritativeUrl; + + // If no authoritative URL, discover it from the first domain's pointer + if (!url) { + const firstDomain = this.domains[0]; + try { + const pointerData = await this.fetchJson( + `https://${firstDomain}/.well-known/adagents.json` + ); + if (pointerData.authoritative_location) { + url = pointerData.authoritative_location; + } else { + return { url: `https://${firstDomain}/.well-known/adagents.json`, data: pointerData }; + } + } catch (error) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: `Failed to discover authoritative URL from ${firstDomain}: ${this.sanitizeError(error)}`, + }); + return { url: '', data: null }; + } + } + + try { + const data = await this.fetchJson(url); + + // Follow at most one authoritative_location redirect + if (data.authoritative_location && !data.authorized_agents) { + const redirectUrl = data.authoritative_location; + if (!redirectUrl.startsWith('https://')) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: `authoritative_location must use HTTPS: ${redirectUrl}`, + }); + return { url, data: null }; + } + if (redirectUrl === url) { + report.schemaErrors.push({ + field: 'authoritative_location', + message: 'authoritative_location points to itself', + }); + return { url, data: null }; + } + const redirectData = await this.fetchJson(redirectUrl); + // Do not follow further redirects from the redirect target + return { url: redirectUrl, data: redirectData }; + } + + return { url, data }; + } catch (error) { + report.schemaErrors.push({ + field: '$root', + message: `Failed to fetch authoritative file: ${this.sanitizeError(error)}`, + }); + return { url: url ?? '', data: null }; + } + } + + private validateSchema(data: AdAgentsJson, report: NetworkCheckReport): void { + if (!data.authorized_agents || !Array.isArray(data.authorized_agents)) { + report.schemaErrors.push({ + field: 'authorized_agents', + message: 'Missing or invalid authorized_agents array', + }); + } else { + data.authorized_agents.forEach((agent, i) => { + if (!agent.url) { + report.schemaErrors.push({ + field: `authorized_agents[${i}].url`, + message: 'Missing required url field', + }); + } + if (!agent.authorized_for) { + report.schemaErrors.push({ + field: `authorized_agents[${i}].authorized_for`, + message: 'Missing required authorized_for field', + }); + } + }); + } + + if (data.properties && Array.isArray(data.properties)) { + data.properties.forEach((prop, i) => { + if (!prop.name) { + report.schemaErrors.push({ + field: `properties[${i}].name`, + message: 'Missing required name field', + }); + } + if (!prop.property_type) { + report.schemaErrors.push({ + field: `properties[${i}].property_type`, + message: 'Missing required property_type field', + }); + } + if (!prop.identifiers || !Array.isArray(prop.identifiers) || prop.identifiers.length === 0) { + report.schemaErrors.push({ + field: `properties[${i}].identifiers`, + message: 'Missing or empty identifiers array', + }); + } + }); + } + } + + private extractDomains(properties: Property[]): Set { + const domains = new Set(); + for (const prop of properties) { + for (const id of prop.identifiers ?? []) { + if (id.type === 'domain' || id.type === 'subdomain') { + domains.add(id.value.toLowerCase()); + } + } + if (prop.publisher_domain) { + domains.add(prop.publisher_domain.toLowerCase()); + } + } + return domains; + } + + private async checkAgentHealth(agents: AuthorizedAgent[]): Promise { + return this.runConcurrent(agents, async (agent) => { + try { + validateAgentUrl(agent.url); + } catch (error) { + return { + url: agent.url, + reachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(agent.url, { + method: 'HEAD', + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + return { + url: agent.url, + reachable: response.ok || response.status === 405, + statusCode: response.status, + }; + } finally { + clearTimeout(timeout); + } + } catch (error) { + return { + url: agent.url, + reachable: false, + error: this.sanitizeError(error), + }; + } + }); + } + + private async checkPointers( + authoritativeUrl: string, + authoritativeDomains: Set, + report: NetworkCheckReport + ): Promise { + const domains = Array.from(authoritativeDomains); + + const results = await this.runConcurrent(domains, async (domain) => { + return this.checkDomainPointer(domain, authoritativeUrl); + }); + + for (const result of results) { + report.domains.push(result.detail); + if (result.missing) report.missingPointers.push(result.missing); + if (result.stale) report.stalePointers.push(result.stale); + } + } + + private async checkDomainPointer( + domain: string, + authoritativeUrl: string + ): Promise<{ + detail: DomainDetail; + missing?: MissingPointer; + stale?: StalePointer; + }> { + const url = `https://${domain}/.well-known/adagents.json`; + + try { + const data = await this.fetchJson(url); + + if (data.authoritative_location) { + if (data.authoritative_location === authoritativeUrl) { + return { + detail: { + domain, + status: 'ok', + pointerUrl: data.authoritative_location, + errors: [], + }, + }; + } else { + return { + detail: { + domain, + status: 'stale_pointer', + pointerUrl: data.authoritative_location, + errors: [`Pointer references ${data.authoritative_location}, expected ${authoritativeUrl}`], + }, + stale: { + domain, + pointerUrl: data.authoritative_location, + expectedUrl: authoritativeUrl, + }, + }; + } + } + + // No authoritative_location — the domain hosts its own file + if (url === authoritativeUrl) { + return { + detail: { domain, status: 'ok', errors: [] }, + }; + } + + return { + detail: { + domain, + status: 'stale_pointer', + errors: [`adagents.json exists but has no authoritative_location pointing to ${authoritativeUrl}`], + }, + stale: { + domain, + pointerUrl: url, + expectedUrl: authoritativeUrl, + }, + }; + } catch (error) { + const msg = this.sanitizeError(error); + this.logger.debug(`Failed to fetch pointer from ${domain}: ${msg}`); + return { + detail: { domain, status: 'missing_pointer', errors: [msg] }, + missing: { domain, error: msg }, + }; + } + } + + private async checkOrphanedPointers( + authoritativeUrl: string, + extraDomains: string[], + report: NetworkCheckReport + ): Promise { + const results = await this.runConcurrent(extraDomains, async (domain): Promise<{ + domain: string; + orphaned: boolean; + pointerUrl: string; + } | null> => { + try { + const data = await this.fetchJson( + `https://${domain}/.well-known/adagents.json` + ); + if (data.authoritative_location === authoritativeUrl) { + return { domain, orphaned: true, pointerUrl: data.authoritative_location }; + } + return null; + } catch { + return null; + } + }); + + for (const result of results) { + if (result?.orphaned) { + report.orphanedPointers.push({ + domain: result.domain, + pointerUrl: result.pointerUrl, + }); + report.domains.push({ + domain: result.domain, + status: 'orphaned_pointer', + pointerUrl: result.pointerUrl, + errors: ['Domain has pointer file but is not listed in authoritative properties'], + }); + } + } + } + + private async fetchJson(url: string): Promise { + validateAgentUrl(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await fetch(url, { + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const text = await response.text(); + if (text.length > MAX_RESPONSE_BYTES) { + throw new Error('Response too large'); + } + return JSON.parse(text) as T; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`Request timed out after ${this.timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + } + + private sanitizeError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'AbortError') return 'Request timed out'; + if (error.message.startsWith('HTTP ')) return error.message; + if (error.message.startsWith('Request timed out')) return error.message; + if (error.message.includes('ECONNREFUSED')) return 'Connection refused'; + if (error.message.includes('ENOTFOUND')) return 'DNS resolution failed'; + if (error.message.includes('certificate')) return 'TLS error'; + if (error.message.includes('Response too large')) return 'Response too large'; + if (error.message.includes('not allowed')) return error.message; + if (error.message.includes('must use HTTPS')) return error.message; + } + return 'Request failed'; + } + + /** + * Run async operations with concurrency limit, preserving input order. + */ + private async runConcurrent(items: T[], fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let active = 0; + let nextIdx = 0; + + return new Promise((resolve, reject) => { + if (items.length === 0) return resolve([]); + + const next = (): void => { + while (active < this.concurrency && nextIdx < items.length) { + const i = nextIdx++; + active++; + fn(items[i]!).then(r => { + results[i] = r; + active--; + if (nextIdx >= items.length && active === 0) { + resolve(results); + } else { + next(); + } + }).catch(reject); + } + }; + next(); + }); + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 621a056e..f9d0f220 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -69,6 +69,18 @@ export { type CrawlResult, type PropertyCrawlerConfig, } from './discovery/property-crawler'; +export { + NetworkConsistencyChecker, + type NetworkConsistencyCheckerConfig, + type NetworkCheckReport, + type OrphanedPointer, + type StalePointer, + type MissingPointer, + type SchemaError, + type AgentHealthResult, + type DomainDetail, + type DomainStatus, +} from './discovery/network-consistency-checker'; export type { Property, PropertyIdentifier, diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js new file mode 100644 index 00000000..ccde2b4a --- /dev/null +++ b/test/lib/network-consistency-checker.test.js @@ -0,0 +1,382 @@ +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); + +let originalFetch; + +beforeEach(() => { + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +/** + * Helper: build a mock fetch that dispatches by URL pattern. + * @param {Record} routes - URL substring → response config or handler + */ +function routedFetch(routes) { + global.fetch = async (url, options) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + for (const [pattern, handler] of Object.entries(routes)) { + if (urlStr.includes(pattern)) { + const config = typeof handler === 'function' ? handler(urlStr, options) : handler; + const status = config.status || 200; + const body = JSON.stringify(config.data); + return { + ok: status >= 200 && status < 300, + status, + statusText: config.statusText || 'OK', + headers: new Map([['content-length', String(body.length)]]), + json: async () => config.data, + text: async () => body, + }; + } + } + // Default: 404 + return { + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Map(), + json: async () => { throw new Error('Not Found'); }, + text: async () => 'Not Found', + }; + }; +} + +function makeAuthoritativeFile(properties, agents) { + return { + $schema: 'https://adcontextprotocol.org/schemas/v1/adagents.json', + authorized_agents: agents || [ + { url: 'https://seller.example.com/mcp', authorized_for: 'Programmatic sales' }, + ], + properties: properties || [ + { + property_type: 'website', + name: 'cookingdaily.com', + identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], + publisher_domain: 'cookingdaily.com', + }, + { + property_type: 'website', + name: 'gardenweekly.com', + identifiers: [{ type: 'domain', value: 'gardenweekly.com' }], + publisher_domain: 'gardenweekly.com', + }, + ], + }; +} + +function makePointer(authoritativeUrl) { + return { + authoritative_location: authoritativeUrl, + }; +} + +describe('NetworkConsistencyChecker', () => { + const AUTH_URL = 'https://network.example.com/adagents.json'; + + test('clean network — all pointers valid, 100% coverage', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.orphanedPointers.length, 0); + assert.strictEqual(report.stalePointers.length, 0); + assert.strictEqual(report.missingPointers.length, 0); + assert.strictEqual(report.schemaErrors.length, 0); + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + assert.strictEqual(report.domains.length, 2); + assert.ok(report.domains.every(d => d.status === 'ok')); + }); + + test('orphaned pointer — domain points here but not in properties', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'cookingdaily.com', + identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'orphan.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + domains: ['cookingdaily.com', 'orphan.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.orphanedPointers.length, 1); + assert.strictEqual(report.orphanedPointers[0].domain, 'orphan.com'); + assert.strictEqual(report.orphanedPointers[0].pointerUrl, AUTH_URL); + }); + + test('stale pointer — domain points to different authoritative URL', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { + data: makePointer('https://old-network.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.stalePointers[0].pointerUrl, 'https://old-network.example.com/adagents.json'); + assert.strictEqual(report.coverage, 0.5); + }); + + test('missing pointer — domain returns 404', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + // gardenweekly.com not routed → 404 + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.missingPointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.coverage, 0.5); + }); + + test('schema errors — authoritative file missing required fields', async () => { + const badAuthFile = { + properties: [ + { + identifiers: [{ type: 'domain', value: 'example.com' }], + }, + ], + }; + + routedFetch({ + 'network.example.com/adagents.json': { data: badAuthFile }, + 'example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.ok(report.schemaErrors.length >= 2, `Expected at least 2 schema errors, got ${report.schemaErrors.length}`); + const fields = report.schemaErrors.map(e => e.field); + assert.ok(fields.some(f => f === 'authorized_agents')); + assert.ok(fields.some(f => f.includes('name') || f.includes('property_type'))); + }); + + test('unreachable agent — endpoint returns 500', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://healthy.example.com/mcp', authorized_for: 'Sales' }, + { url: 'https://broken.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'healthy.example.com/mcp': { data: {} }, + 'broken.example.com/mcp': { status: 500, statusText: 'Internal Server Error', data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 2); + const healthy = report.agentHealth.find(a => a.url.includes('healthy')); + const broken = report.agentHealth.find(a => a.url.includes('broken')); + assert.strictEqual(healthy.reachable, true); + assert.strictEqual(broken.reachable, false); + assert.strictEqual(broken.statusCode, 500); + }); + + test('mixed results — combination of issues', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'good.com', + identifiers: [{ type: 'domain', value: 'good.com' }], + }, + { + property_type: 'website', + name: 'stale.com', + identifiers: [{ type: 'domain', value: 'stale.com' }], + }, + { + property_type: 'website', + name: 'missing.com', + identifiers: [{ type: 'domain', value: 'missing.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'good.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'stale.com/.well-known/adagents.json': { + data: makePointer('https://other.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.stalePointers.length, 1); + assert.ok(Math.abs(report.coverage - 1 / 3) < 0.01, `Expected ~33% coverage, got ${report.coverage}`); + }); + + test('domains-only mode — discovers authoritative URL from first domain', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'site-a.com', + identifiers: [{ type: 'domain', value: 'site-a.com' }], + }, + { + property_type: 'website', + name: 'site-b.com', + identifiers: [{ type: 'domain', value: 'site-b.com' }], + }, + ], [ + { url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'site-a.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'network.example.com/adagents.json': { data: authFile }, + 'site-b.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + domains: ['site-a.com', 'site-b.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.strictEqual(report.coverage, 1); + }); + + test('constructor throws if neither authoritativeUrl nor domains provided', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ logLevel: 'silent' }); + }, /Either authoritativeUrl or domains must be provided/); + }); + + test('authoritative URL fetch failure returns early with schema error', async () => { + global.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 0); + assert.ok(report.schemaErrors.length >= 1); + assert.ok(report.schemaErrors.some(e => e.field === '$root')); + assert.strictEqual(report.domains.length, 0); + }); + + test('domain without authoritative_location is stale', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + // standalone.com has an adagents.json but no authoritative_location + 'standalone.com/.well-known/adagents.json': { + data: { + authorized_agents: [{ url: 'https://other.example.com/mcp', authorized_for: 'Sales' }], + properties: [{ property_type: 'website', name: 'standalone.com', identifiers: [{ type: 'domain', value: 'standalone.com' }] }], + }, + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'standalone.com'); + assert.strictEqual(report.coverage, 0); + }); +}); From 44b02d738b389b35e1fb073e6b814cd97fcb0d75 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 06:47:44 +0100 Subject: [PATCH 2/6] style: format with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/adcp.js | 14 +++- .../discovery/network-consistency-checker.ts | 65 ++++++++++--------- test/lib/network-consistency-checker.test.js | 45 +++++++------ 3 files changed, 70 insertions(+), 54 deletions(-) diff --git a/bin/adcp.js b/bin/adcp.js index b03b36f6..1d4f6760 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -1316,7 +1316,12 @@ EXAMPLES: process.exit(2); } - const domains = domainsStr ? domainsStr.split(',').map(d => d.trim()).filter(Boolean) : undefined; + const domains = domainsStr + ? domainsStr + .split(',') + .map(d => d.trim()) + .filter(Boolean) + : undefined; const { NetworkConsistencyChecker } = require('../dist/lib/index.js'); const checker = new NetworkConsistencyChecker({ @@ -1330,8 +1335,11 @@ EXAMPLES: try { const report = await checker.check(); - const totalIssues = report.schemaErrors.length + report.missingPointers.length + - report.stalePointers.length + report.orphanedPointers.length + + const totalIssues = + report.schemaErrors.length + + report.missingPointers.length + + report.stalePointers.length + + report.orphanedPointers.length + report.agentHealth.filter(a => !a.reachable).length; if (jsonOutput) { diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts index 4c075dcc..d4bdbcb8 100644 --- a/src/lib/discovery/network-consistency-checker.ts +++ b/src/lib/discovery/network-consistency-checker.ts @@ -172,18 +172,14 @@ export class NetworkConsistencyChecker { // ---- Internal methods ---- - private async fetchAuthoritative( - report: NetworkCheckReport - ): Promise<{ url: string; data: AdAgentsJson | null }> { + private async fetchAuthoritative(report: NetworkCheckReport): Promise<{ url: string; data: AdAgentsJson | null }> { let url = this.authoritativeUrl; // If no authoritative URL, discover it from the first domain's pointer if (!url) { const firstDomain = this.domains[0]; try { - const pointerData = await this.fetchJson( - `https://${firstDomain}/.well-known/adagents.json` - ); + const pointerData = await this.fetchJson(`https://${firstDomain}/.well-known/adagents.json`); if (pointerData.authoritative_location) { url = pointerData.authoritative_location; } else { @@ -296,7 +292,7 @@ export class NetworkConsistencyChecker { } private async checkAgentHealth(agents: AuthorizedAgent[]): Promise { - return this.runConcurrent(agents, async (agent) => { + return this.runConcurrent(agents, async agent => { try { validateAgentUrl(agent.url); } catch (error) { @@ -345,7 +341,7 @@ export class NetworkConsistencyChecker { ): Promise { const domains = Array.from(authoritativeDomains); - const results = await this.runConcurrent(domains, async (domain) => { + const results = await this.runConcurrent(domains, async domain => { return this.checkDomainPointer(domain, authoritativeUrl); }); @@ -430,23 +426,26 @@ export class NetworkConsistencyChecker { extraDomains: string[], report: NetworkCheckReport ): Promise { - const results = await this.runConcurrent(extraDomains, async (domain): Promise<{ - domain: string; - orphaned: boolean; - pointerUrl: string; - } | null> => { - try { - const data = await this.fetchJson( - `https://${domain}/.well-known/adagents.json` - ); - if (data.authoritative_location === authoritativeUrl) { - return { domain, orphaned: true, pointerUrl: data.authoritative_location }; + const results = await this.runConcurrent( + extraDomains, + async ( + domain + ): Promise<{ + domain: string; + orphaned: boolean; + pointerUrl: string; + } | null> => { + try { + const data = await this.fetchJson(`https://${domain}/.well-known/adagents.json`); + if (data.authoritative_location === authoritativeUrl) { + return { domain, orphaned: true, pointerUrl: data.authoritative_location }; + } + return null; + } catch { + return null; } - return null; - } catch { - return null; } - }); + ); for (const result of results) { if (result?.orphaned) { @@ -526,15 +525,17 @@ export class NetworkConsistencyChecker { while (active < this.concurrency && nextIdx < items.length) { const i = nextIdx++; active++; - fn(items[i]!).then(r => { - results[i] = r; - active--; - if (nextIdx >= items.length && active === 0) { - resolve(results); - } else { - next(); - } - }).catch(reject); + fn(items[i]!) + .then(r => { + results[i] = r; + active--; + if (nextIdx >= items.length && active === 0) { + resolve(results); + } else { + next(); + } + }) + .catch(reject); } }; next(); diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js index ccde2b4a..51369e04 100644 --- a/test/lib/network-consistency-checker.test.js +++ b/test/lib/network-consistency-checker.test.js @@ -39,7 +39,9 @@ function routedFetch(routes) { status: 404, statusText: 'Not Found', headers: new Map(), - json: async () => { throw new Error('Not Found'); }, + json: async () => { + throw new Error('Not Found'); + }, text: async () => 'Not Found', }; }; @@ -48,9 +50,7 @@ function routedFetch(routes) { function makeAuthoritativeFile(properties, agents) { return { $schema: 'https://adcontextprotocol.org/schemas/v1/adagents.json', - authorized_agents: agents || [ - { url: 'https://seller.example.com/mcp', authorized_for: 'Programmatic sales' }, - ], + authorized_agents: agents || [{ url: 'https://seller.example.com/mcp', authorized_for: 'Programmatic sales' }], properties: properties || [ { property_type: 'website', @@ -286,20 +286,21 @@ describe('NetworkConsistencyChecker', () => { }); test('domains-only mode — discovers authoritative URL from first domain', async () => { - const authFile = makeAuthoritativeFile([ - { - property_type: 'website', - name: 'site-a.com', - identifiers: [{ type: 'domain', value: 'site-a.com' }], - }, - { - property_type: 'website', - name: 'site-b.com', - identifiers: [{ type: 'domain', value: 'site-b.com' }], - }, - ], [ - { url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }, - ]); + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'site-a.com', + identifiers: [{ type: 'domain', value: 'site-a.com' }], + }, + { + property_type: 'website', + name: 'site-b.com', + identifiers: [{ type: 'domain', value: 'site-b.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); routedFetch({ 'site-a.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, @@ -361,7 +362,13 @@ describe('NetworkConsistencyChecker', () => { 'standalone.com/.well-known/adagents.json': { data: { authorized_agents: [{ url: 'https://other.example.com/mcp', authorized_for: 'Sales' }], - properties: [{ property_type: 'website', name: 'standalone.com', identifiers: [{ type: 'domain', value: 'standalone.com' }] }], + properties: [ + { + property_type: 'website', + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], + }, + ], }, }, 'seller.example.com/mcp': { data: {} }, From 59bbbe658868508626a97132d33d8565db3c9842 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 07:00:15 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20byte-accurate=20size=20check,=20expanded=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use Buffer.byteLength instead of text.length for response size limit - Add 8 new tests: redirect edge cases (self-referential, non-HTTPS, one-hop follow), constructor validation, domains-only fallback, subdomain extraction, 405 agent health, tightened assertions - Organize tests into nested describe blocks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../discovery/network-consistency-checker.ts | 4 +- test/lib/network-consistency-checker.test.js | 700 +++++++++++------- 2 files changed, 451 insertions(+), 253 deletions(-) diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts index d4bdbcb8..d5fa5897 100644 --- a/src/lib/discovery/network-consistency-checker.ts +++ b/src/lib/discovery/network-consistency-checker.ts @@ -318,7 +318,7 @@ export class NetworkConsistencyChecker { }); return { url: agent.url, - reachable: response.ok || response.status === 405, + reachable: response.ok || response.status === 405, // 405 = HEAD rejected but server is alive statusCode: response.status, }; } finally { @@ -481,7 +481,7 @@ export class NetworkConsistencyChecker { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const text = await response.text(); - if (text.length > MAX_RESPONSE_BYTES) { + if (Buffer.byteLength(text, 'utf-8') > MAX_RESPONSE_BYTES) { throw new Error('Response too large'); } return JSON.parse(text) as T; diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js index 51369e04..0451b8c5 100644 --- a/test/lib/network-consistency-checker.test.js +++ b/test/lib/network-consistency-checker.test.js @@ -77,313 +77,511 @@ function makePointer(authoritativeUrl) { describe('NetworkConsistencyChecker', () => { const AUTH_URL = 'https://network.example.com/adagents.json'; - test('clean network — all pointers valid, 100% coverage', async () => { - const authFile = makeAuthoritativeFile(); - - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'seller.example.com/mcp': { data: {} }, + describe('core checks', () => { + test('clean network — all pointers valid, 100% coverage', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.orphanedPointers.length, 0); + assert.strictEqual(report.stalePointers.length, 0); + assert.strictEqual(report.missingPointers.length, 0); + assert.strictEqual(report.schemaErrors.length, 0); + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + assert.strictEqual(report.domains.length, 2); + assert.ok(report.domains.every(d => d.status === 'ok')); + assert.ok(report.domains.every(d => d.errors.length === 0)); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + test('orphaned pointer — domain points here but not in properties', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'cookingdaily.com', + identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'orphan.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + domains: ['cookingdaily.com', 'orphan.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.orphanedPointers.length, 1); + assert.strictEqual(report.orphanedPointers[0].domain, 'orphan.com'); + assert.strictEqual(report.orphanedPointers[0].pointerUrl, AUTH_URL); + // Coverage is based on authoritative domains only (cookingdaily.com), not orphans + assert.strictEqual(report.coverage, 1); + const orphanDetail = report.domains.find(d => d.domain === 'orphan.com'); + assert.strictEqual(orphanDetail.status, 'orphaned_pointer'); }); - const report = await checker.check(); - - assert.strictEqual(report.authoritativeUrl, AUTH_URL); - assert.strictEqual(report.coverage, 1); - assert.strictEqual(report.orphanedPointers.length, 0); - assert.strictEqual(report.stalePointers.length, 0); - assert.strictEqual(report.missingPointers.length, 0); - assert.strictEqual(report.schemaErrors.length, 0); - assert.strictEqual(report.agentHealth.length, 1); - assert.strictEqual(report.agentHealth[0].reachable, true); - assert.strictEqual(report.domains.length, 2); - assert.ok(report.domains.every(d => d.status === 'ok')); - }); - - test('orphaned pointer — domain points here but not in properties', async () => { - const authFile = makeAuthoritativeFile([ - { - property_type: 'website', - name: 'cookingdaily.com', - identifiers: [{ type: 'domain', value: 'cookingdaily.com' }], - }, - ]); + test('stale pointer — domain points to different authoritative URL', async () => { + const authFile = makeAuthoritativeFile(); - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'orphan.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'seller.example.com/mcp': { data: {} }, + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { + data: makePointer('https://old-network.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.stalePointers[0].pointerUrl, 'https://old-network.example.com/adagents.json'); + assert.strictEqual(report.stalePointers[0].expectedUrl, AUTH_URL); + assert.strictEqual(report.coverage, 0.5); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - domains: ['cookingdaily.com', 'orphan.com'], - logLevel: 'silent', - }); + test('missing pointer — domain returns 404', async () => { + const authFile = makeAuthoritativeFile(); - const report = await checker.check(); + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); - assert.strictEqual(report.orphanedPointers.length, 1); - assert.strictEqual(report.orphanedPointers[0].domain, 'orphan.com'); - assert.strictEqual(report.orphanedPointers[0].pointerUrl, AUTH_URL); - }); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - test('stale pointer — domain points to different authoritative URL', async () => { - const authFile = makeAuthoritativeFile(); + const report = await checker.check(); - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'gardenweekly.com/.well-known/adagents.json': { - data: makePointer('https://old-network.example.com/adagents.json'), - }, - 'seller.example.com/mcp': { data: {} }, + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.missingPointers[0].domain, 'gardenweekly.com'); + assert.strictEqual(report.coverage, 0.5); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + test('schema errors — authoritative file missing required fields', async () => { + const badAuthFile = { + properties: [ + { + identifiers: [{ type: 'domain', value: 'example.com' }], + }, + ], + }; + + routedFetch({ + 'network.example.com/adagents.json': { data: badAuthFile }, + 'example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.schemaErrors.length, 3); + const fields = report.schemaErrors.map(e => e.field); + assert.ok(fields.includes('authorized_agents')); + assert.ok(fields.includes('properties[0].name')); + assert.ok(fields.includes('properties[0].property_type')); }); - const report = await checker.check(); + test('mixed results — combination of issues', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'good.com', + identifiers: [{ type: 'domain', value: 'good.com' }], + }, + { + property_type: 'website', + name: 'stale.com', + identifiers: [{ type: 'domain', value: 'stale.com' }], + }, + { + property_type: 'website', + name: 'missing.com', + identifiers: [{ type: 'domain', value: 'missing.com' }], + }, + ]); - assert.strictEqual(report.stalePointers.length, 1); - assert.strictEqual(report.stalePointers[0].domain, 'gardenweekly.com'); - assert.strictEqual(report.stalePointers[0].pointerUrl, 'https://old-network.example.com/adagents.json'); - assert.strictEqual(report.coverage, 0.5); - }); + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'good.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'stale.com/.well-known/adagents.json': { + data: makePointer('https://other.example.com/adagents.json'), + }, + 'seller.example.com/mcp': { data: {} }, + }); - test('missing pointer — domain returns 404', async () => { - const authFile = makeAuthoritativeFile(); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - // gardenweekly.com not routed → 404 - 'seller.example.com/mcp': { data: {} }, - }); + const report = await checker.check(); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.stalePointers.length, 1); + assert.ok(Math.abs(report.coverage - 1 / 3) < 0.01, `Expected ~33% coverage, got ${report.coverage}`); }); + }); - const report = await checker.check(); + describe('agent health', () => { + test('unreachable agent — endpoint returns 500', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://healthy.example.com/mcp', authorized_for: 'Sales' }, + { url: 'https://broken.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'healthy.example.com/mcp': { data: {} }, + 'broken.example.com/mcp': { status: 500, statusText: 'Internal Server Error', data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 2); + const healthy = report.agentHealth.find(a => a.url.includes('healthy')); + const broken = report.agentHealth.find(a => a.url.includes('broken')); + assert.strictEqual(healthy.reachable, true); + assert.strictEqual(healthy.error, undefined); + assert.strictEqual(broken.reachable, false); + assert.strictEqual(broken.statusCode, 500); + }); - assert.strictEqual(report.missingPointers.length, 1); - assert.strictEqual(report.missingPointers[0].domain, 'gardenweekly.com'); - assert.strictEqual(report.coverage, 0.5); + test('agent returning 405 is treated as reachable', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://no-head.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'no-head.example.com/mcp': { status: 405, statusText: 'Method Not Allowed', data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + assert.strictEqual(report.agentHealth[0].statusCode, 405); + }); }); - test('schema errors — authoritative file missing required fields', async () => { - const badAuthFile = { - properties: [ - { - identifiers: [{ type: 'domain', value: 'example.com' }], - }, - ], - }; - - routedFetch({ - 'network.example.com/adagents.json': { data: badAuthFile }, - 'example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + describe('authoritative file resolution', () => { + test('domains-only mode — discovers authoritative URL from first domain pointer', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'site-a.com', + identifiers: [{ type: 'domain', value: 'site-a.com' }], + }, + { + property_type: 'website', + name: 'site-b.com', + identifiers: [{ type: 'domain', value: 'site-b.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'site-a.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'network.example.com/adagents.json': { data: authFile }, + 'site-b.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + domains: ['site-a.com', 'site-b.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.strictEqual(report.coverage, 1); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + test('domains-only mode — first domain serves full file directly', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'primary.com', + identifiers: [{ type: 'domain', value: 'primary.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'primary.com/.well-known/adagents.json': { data: authFile }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + domains: ['primary.com'], + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, 'https://primary.com/.well-known/adagents.json'); + assert.strictEqual(report.schemaErrors.length, 0); }); - const report = await checker.check(); + test('authoritative URL fetch failure returns early with schema error', async () => { + global.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; - assert.ok(report.schemaErrors.length >= 2, `Expected at least 2 schema errors, got ${report.schemaErrors.length}`); - const fields = report.schemaErrors.map(e => e.field); - assert.ok(fields.some(f => f === 'authorized_agents')); - assert.ok(fields.some(f => f.includes('name') || f.includes('property_type'))); - }); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - test('unreachable agent — endpoint returns 500', async () => { - const authFile = makeAuthoritativeFile(undefined, [ - { url: 'https://healthy.example.com/mcp', authorized_for: 'Sales' }, - { url: 'https://broken.example.com/mcp', authorized_for: 'Sales' }, - ]); - - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'healthy.example.com/mcp': { data: {} }, - 'broken.example.com/mcp': { status: 500, statusText: 'Internal Server Error', data: {} }, - }); + const report = await checker.check(); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + assert.strictEqual(report.coverage, 0); + assert.ok(report.schemaErrors.length >= 1); + assert.ok(report.schemaErrors.some(e => e.field === '$root')); + assert.strictEqual(report.domains.length, 0); }); - const report = await checker.check(); + test('self-referential authoritative_location is reported as schema error', async () => { + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: AUTH_URL }, + }, + }); - assert.strictEqual(report.agentHealth.length, 2); - const healthy = report.agentHealth.find(a => a.url.includes('healthy')); - const broken = report.agentHealth.find(a => a.url.includes('broken')); - assert.strictEqual(healthy.reachable, true); - assert.strictEqual(broken.reachable, false); - assert.strictEqual(broken.statusCode, 500); - }); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - test('mixed results — combination of issues', async () => { - const authFile = makeAuthoritativeFile([ - { - property_type: 'website', - name: 'good.com', - identifiers: [{ type: 'domain', value: 'good.com' }], - }, - { - property_type: 'website', - name: 'stale.com', - identifiers: [{ type: 'domain', value: 'stale.com' }], - }, - { - property_type: 'website', - name: 'missing.com', - identifiers: [{ type: 'domain', value: 'missing.com' }], - }, - ]); + const report = await checker.check(); - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - 'good.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'stale.com/.well-known/adagents.json': { - data: makePointer('https://other.example.com/adagents.json'), - }, - 'seller.example.com/mcp': { data: {} }, + assert.ok(report.schemaErrors.some(e => e.message.includes('points to itself'))); + assert.strictEqual(report.coverage, 0); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', - }); + test('non-HTTPS authoritative_location redirect is rejected', async () => { + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: 'http://insecure.example.com/adagents.json' }, + }, + }); - const report = await checker.check(); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - assert.strictEqual(report.missingPointers.length, 1); - assert.strictEqual(report.stalePointers.length, 1); - assert.ok(Math.abs(report.coverage - 1 / 3) < 0.01, `Expected ~33% coverage, got ${report.coverage}`); + const report = await checker.check(); + + assert.ok(report.schemaErrors.some(e => e.message.includes('must use HTTPS'))); + assert.strictEqual(report.coverage, 0); + }); + + test('authoritative_location redirect is followed one hop', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'pub.com', + identifiers: [{ type: 'domain', value: 'pub.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + const redirectUrl = 'https://canonical.example.com/adagents.json'; + + routedFetch({ + 'network.example.com/adagents.json': { + data: { authoritative_location: redirectUrl }, + }, + 'canonical.example.com/adagents.json': { data: authFile }, + 'pub.com/.well-known/adagents.json': { data: makePointer(redirectUrl) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.authoritativeUrl, redirectUrl); + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.schemaErrors.length, 0); + }); }); - test('domains-only mode — discovers authoritative URL from first domain', async () => { - const authFile = makeAuthoritativeFile( - [ + describe('domain pointer edge cases', () => { + test('domain without authoritative_location is stale', async () => { + const authFile = makeAuthoritativeFile([ { property_type: 'website', - name: 'site-a.com', - identifiers: [{ type: 'domain', value: 'site-a.com' }], + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], }, - { - property_type: 'website', - name: 'site-b.com', - identifiers: [{ type: 'domain', value: 'site-b.com' }], + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'standalone.com/.well-known/adagents.json': { + data: { + authorized_agents: [{ url: 'https://other.example.com/mcp', authorized_for: 'Sales' }], + properties: [ + { + property_type: 'website', + name: 'standalone.com', + identifiers: [{ type: 'domain', value: 'standalone.com' }], + }, + ], + }, }, - ], - [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] - ); - - routedFetch({ - 'site-a.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'network.example.com/adagents.json': { data: authFile }, - 'site-b.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, - 'seller.example.com/mcp': { data: {} }, - }); + 'seller.example.com/mcp': { data: {} }, + }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - domains: ['site-a.com', 'site-b.com'], - logLevel: 'silent', - }); + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); - const report = await checker.check(); - - assert.strictEqual(report.authoritativeUrl, AUTH_URL); - assert.strictEqual(report.coverage, 1); - }); - - test('constructor throws if neither authoritativeUrl nor domains provided', () => { - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - assert.throws(() => { - new NetworkConsistencyChecker({ logLevel: 'silent' }); - }, /Either authoritativeUrl or domains must be provided/); - }); - - test('authoritative URL fetch failure returns early with schema error', async () => { - global.fetch = async () => { - throw new Error('ECONNREFUSED'); - }; + const report = await checker.check(); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + assert.strictEqual(report.stalePointers.length, 1); + assert.strictEqual(report.stalePointers[0].domain, 'standalone.com'); + assert.strictEqual(report.stalePointers[0].expectedUrl, AUTH_URL); + assert.strictEqual(report.coverage, 0); }); - const report = await checker.check(); - - assert.strictEqual(report.coverage, 0); - assert.ok(report.schemaErrors.length >= 1); - assert.ok(report.schemaErrors.some(e => e.field === '$root')); - assert.strictEqual(report.domains.length, 0); + test('subdomain identifier type is extracted for pointer checks', async () => { + const authFile = makeAuthoritativeFile( + [ + { + property_type: 'website', + name: 'Blog', + identifiers: [{ type: 'subdomain', value: 'blog.example.com' }], + }, + ], + [{ url: 'https://seller.example.com/mcp', authorized_for: 'Sales' }] + ); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'blog.example.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.domains.length, 1); + assert.strictEqual(report.domains[0].status, 'ok'); + }); }); - test('domain without authoritative_location is stale', async () => { - const authFile = makeAuthoritativeFile([ - { - property_type: 'website', - name: 'standalone.com', - identifiers: [{ type: 'domain', value: 'standalone.com' }], - }, - ]); - - routedFetch({ - 'network.example.com/adagents.json': { data: authFile }, - // standalone.com has an adagents.json but no authoritative_location - 'standalone.com/.well-known/adagents.json': { - data: { - authorized_agents: [{ url: 'https://other.example.com/mcp', authorized_for: 'Sales' }], - properties: [ - { - property_type: 'website', - name: 'standalone.com', - identifiers: [{ type: 'domain', value: 'standalone.com' }], - }, - ], - }, - }, - 'seller.example.com/mcp': { data: {} }, + describe('constructor validation', () => { + test('throws if neither authoritativeUrl nor domains provided', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ logLevel: 'silent' }); + }, /Either authoritativeUrl or domains must be provided/); }); - const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); - const checker = new NetworkConsistencyChecker({ - authoritativeUrl: AUTH_URL, - logLevel: 'silent', + test('throws if concurrency is less than 1', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + concurrency: 0, + logLevel: 'silent', + }); + }, /concurrency must be >= 1/); }); - const report = await checker.check(); - - assert.strictEqual(report.stalePointers.length, 1); - assert.strictEqual(report.stalePointers[0].domain, 'standalone.com'); - assert.strictEqual(report.coverage, 0); + test('throws if timeoutMs is less than 1', () => { + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + assert.throws(() => { + new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + timeoutMs: 0, + logLevel: 'silent', + }); + }, /timeoutMs must be >= 1/); + }); }); }); From 8712a7d25f655f0662237a11ef05c910b413f5ab Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 07:15:21 +0100 Subject: [PATCH 4/6] feat: add checkedAt, summary, Content-Length check, and onProgress - Add checkedAt (ISO 8601) timestamp to NetworkCheckReport - Add summary object with pre-computed counts (totalIssues, etc.) - Check Content-Length header before reading response body to prevent OOM - Add onProgress callback for long-running checks across many domains - Wire progress into CLI (stderr output during check phases) - CLI now uses report.summary instead of recomputing counts Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/adcp.js | 29 ++-- .../discovery/network-consistency-checker.ts | 158 +++++++++++++----- src/lib/index.ts | 2 + test/lib/network-consistency-checker.test.js | 42 +++++ 4 files changed, 179 insertions(+), 52 deletions(-) diff --git a/bin/adcp.js b/bin/adcp.js index 1d4f6760..ee6e5581 100755 --- a/bin/adcp.js +++ b/bin/adcp.js @@ -1324,35 +1324,40 @@ EXAMPLES: : undefined; const { NetworkConsistencyChecker } = require('../dist/lib/index.js'); + + const progressHandler = jsonOutput + ? undefined + : ({ phase, completed, total }) => { + process.stderr.write(`\r ${phase}: ${completed}/${total}`); + if (completed === total) process.stderr.write('\n'); + }; + const checker = new NetworkConsistencyChecker({ authoritativeUrl: url, domains, concurrency, timeoutMs: timeout, logLevel: 'warn', + onProgress: progressHandler, }); try { const report = await checker.check(); - const totalIssues = - report.schemaErrors.length + - report.missingPointers.length + - report.stalePointers.length + - report.orphanedPointers.length + - report.agentHealth.filter(a => !a.reachable).length; - if (jsonOutput) { console.log(JSON.stringify(report, null, 2)); - process.exit(totalIssues > 0 ? 1 : 0); + process.exit(report.summary.totalIssues > 0 ? 1 : 0); return; } // Pretty-print report console.log(`\nNetwork Consistency Report`); console.log(`${'='.repeat(50)}`); + console.log(`Checked at: ${report.checkedAt}`); console.log(`Authoritative URL: ${report.authoritativeUrl}`); - console.log(`Coverage: ${(report.coverage * 100).toFixed(1)}%`); + console.log( + `Coverage: ${(report.coverage * 100).toFixed(1)}% (${report.summary.validPointers}/${report.summary.totalDomains})` + ); if (report.schemaErrors.length > 0) { console.log(`\nSchema Errors (${report.schemaErrors.length}):`); @@ -1391,13 +1396,13 @@ EXAMPLES: } } - if (totalIssues === 0) { + if (report.summary.totalIssues === 0) { console.log(`\nAll checks passed.`); } else { - console.log(`\n${totalIssues} issue(s) found.`); + console.log(`\n${report.summary.totalIssues} issue(s) found.`); } - process.exit(totalIssues > 0 ? 1 : 0); + process.exit(report.summary.totalIssues > 0 ? 1 : 0); } catch (error) { console.error(`ERROR: ${error.message}`); process.exit(2); diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts index d5fa5897..534b8366 100644 --- a/src/lib/discovery/network-consistency-checker.ts +++ b/src/lib/discovery/network-consistency-checker.ts @@ -16,6 +16,14 @@ import type { AdAgentsJson, AuthorizedAgent, Property } from './types'; // ====== Configuration ====== +/** Progress update emitted after each domain or agent check completes. */ +export interface CheckProgress { + phase: 'pointers' | 'orphans' | 'agents'; + completed: number; + total: number; + domain?: string; +} + export interface NetworkConsistencyCheckerConfig { /** URL of the authoritative adagents.json file */ authoritativeUrl?: string; @@ -27,6 +35,8 @@ export interface NetworkConsistencyCheckerConfig { timeoutMs?: number; logLevel?: LogLevel; userAgent?: string; + /** Called after each domain/agent check completes. */ + onProgress?: (progress: CheckProgress) => void; } // ====== Report types ====== @@ -68,9 +78,22 @@ export interface DomainDetail { errors: string[]; } +export interface CheckSummary { + totalDomains: number; + validPointers: number; + orphanedPointers: number; + stalePointers: number; + missingPointers: number; + schemaErrors: number; + unreachableAgents: number; + totalIssues: number; +} + export interface NetworkCheckReport { + checkedAt: string; authoritativeUrl: string; coverage: number; + summary: CheckSummary; orphanedPointers: OrphanedPointer[]; stalePointers: StalePointer[]; missingPointers: MissingPointer[]; @@ -100,6 +123,7 @@ export class NetworkConsistencyChecker { private readonly logger: ReturnType; private readonly userAgentHeader: string; private readonly fromHeader: string; + private readonly onProgress?: (progress: CheckProgress) => void; constructor(config: NetworkConsistencyCheckerConfig) { if (!config.authoritativeUrl && (!config.domains || config.domains.length === 0)) { @@ -120,12 +144,24 @@ export class NetworkConsistencyChecker { this.fromHeader = config.userAgent ? `adcp-network-checker@adcontextprotocol.org (${config.userAgent}; v${LIBRARY_VERSION})` : `adcp-network-checker@adcontextprotocol.org (v${LIBRARY_VERSION})`; + this.onProgress = config.onProgress; } async check(): Promise { const report: NetworkCheckReport = { + checkedAt: new Date().toISOString(), authoritativeUrl: '', coverage: 0, + summary: { + totalDomains: 0, + validPointers: 0, + orphanedPointers: 0, + stalePointers: 0, + missingPointers: 0, + schemaErrors: 0, + unreachableAgents: 0, + totalIssues: 0, + }, orphanedPointers: [], stalePointers: [], missingPointers: [], @@ -139,6 +175,7 @@ export class NetworkConsistencyChecker { report.authoritativeUrl = resolvedUrl; if (!authData) { + this.computeSummary(report, 0); return report; } @@ -162,14 +199,34 @@ export class NetworkConsistencyChecker { await this.checkOrphanedPointers(resolvedUrl, extraDomains, report); } - // Step 7: Compute coverage + // Step 7: Compute coverage and summary const total = authoritativeDomains.size; const valid = report.domains.filter(d => d.status === 'ok').length; report.coverage = total > 0 ? valid / total : 0; + this.computeSummary(report, total); return report; } + private computeSummary(report: NetworkCheckReport, totalDomains: number): void { + const unreachableAgents = report.agentHealth.filter(a => !a.reachable).length; + report.summary = { + totalDomains, + validPointers: report.domains.filter(d => d.status === 'ok').length, + orphanedPointers: report.orphanedPointers.length, + stalePointers: report.stalePointers.length, + missingPointers: report.missingPointers.length, + schemaErrors: report.schemaErrors.length, + unreachableAgents, + totalIssues: + report.schemaErrors.length + + report.missingPointers.length + + report.stalePointers.length + + report.orphanedPointers.length + + unreachableAgents, + }; + } + // ---- Internal methods ---- private async fetchAuthoritative(report: NetworkCheckReport): Promise<{ url: string; data: AdAgentsJson | null }> { @@ -292,46 +349,54 @@ export class NetworkConsistencyChecker { } private async checkAgentHealth(agents: AuthorizedAgent[]): Promise { + let completed = 0; return this.runConcurrent(agents, async agent => { + const result = await this.probeAgent(agent); + completed++; + this.onProgress?.({ phase: 'agents', completed, total: agents.length }); + return result; + }); + } + + private async probeAgent(agent: AuthorizedAgent): Promise { + try { + validateAgentUrl(agent.url); + } catch (error) { + return { + url: agent.url, + reachable: false, + error: error instanceof Error ? error.message : String(error), + }; + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { - validateAgentUrl(agent.url); - } catch (error) { - return { - url: agent.url, - reachable: false, - error: error instanceof Error ? error.message : String(error), - }; - } - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.timeoutMs); - try { - const response = await fetch(agent.url, { - method: 'HEAD', - signal: controller.signal, - redirect: 'error', - headers: { - ...FETCH_HEADERS, - 'User-Agent': this.userAgentHeader, - From: this.fromHeader, - }, - }); - return { - url: agent.url, - reachable: response.ok || response.status === 405, // 405 = HEAD rejected but server is alive - statusCode: response.status, - }; - } finally { - clearTimeout(timeout); - } - } catch (error) { + const response = await fetch(agent.url, { + method: 'HEAD', + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); return { url: agent.url, - reachable: false, - error: this.sanitizeError(error), + reachable: response.ok || response.status === 405, // 405 = HEAD rejected but server is alive + statusCode: response.status, }; + } finally { + clearTimeout(timeout); } - }); + } catch (error) { + return { + url: agent.url, + reachable: false, + error: this.sanitizeError(error), + }; + } } private async checkPointers( @@ -341,8 +406,12 @@ export class NetworkConsistencyChecker { ): Promise { const domains = Array.from(authoritativeDomains); + let completed = 0; const results = await this.runConcurrent(domains, async domain => { - return this.checkDomainPointer(domain, authoritativeUrl); + const result = await this.checkDomainPointer(domain, authoritativeUrl); + completed++; + this.onProgress?.({ phase: 'pointers', completed, total: domains.length, domain }); + return result; }); for (const result of results) { @@ -426,6 +495,7 @@ export class NetworkConsistencyChecker { extraDomains: string[], report: NetworkCheckReport ): Promise { + let completed = 0; const results = await this.runConcurrent( extraDomains, async ( @@ -437,12 +507,16 @@ export class NetworkConsistencyChecker { } | null> => { try { const data = await this.fetchJson(`https://${domain}/.well-known/adagents.json`); - if (data.authoritative_location === authoritativeUrl) { - return { domain, orphaned: true, pointerUrl: data.authoritative_location }; - } - return null; + const result = + data.authoritative_location === authoritativeUrl + ? { domain, orphaned: true, pointerUrl: data.authoritative_location } + : null; + return result; } catch { return null; + } finally { + completed++; + this.onProgress?.({ phase: 'orphans', completed, total: extraDomains.length, domain }); } } ); @@ -480,6 +554,10 @@ export class NetworkConsistencyChecker { if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } + const contentLength = response.headers.get?.('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) { + throw new Error('Response too large'); + } const text = await response.text(); if (Buffer.byteLength(text, 'utf-8') > MAX_RESPONSE_BYTES) { throw new Error('Response too large'); diff --git a/src/lib/index.ts b/src/lib/index.ts index f9d0f220..c33960a6 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -73,6 +73,8 @@ export { NetworkConsistencyChecker, type NetworkConsistencyCheckerConfig, type NetworkCheckReport, + type CheckSummary, + type CheckProgress, type OrphanedPointer, type StalePointer, type MissingPointer, diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js index 0451b8c5..7e3d8a8f 100644 --- a/test/lib/network-consistency-checker.test.js +++ b/test/lib/network-consistency-checker.test.js @@ -97,6 +97,8 @@ describe('NetworkConsistencyChecker', () => { const report = await checker.check(); assert.strictEqual(report.authoritativeUrl, AUTH_URL); + assert.ok(report.checkedAt, 'checkedAt should be set'); + assert.ok(!isNaN(Date.parse(report.checkedAt)), 'checkedAt should be valid ISO 8601'); assert.strictEqual(report.coverage, 1); assert.strictEqual(report.orphanedPointers.length, 0); assert.strictEqual(report.stalePointers.length, 0); @@ -107,6 +109,10 @@ describe('NetworkConsistencyChecker', () => { assert.strictEqual(report.domains.length, 2); assert.ok(report.domains.every(d => d.status === 'ok')); assert.ok(report.domains.every(d => d.errors.length === 0)); + // Summary + assert.strictEqual(report.summary.totalDomains, 2); + assert.strictEqual(report.summary.validPointers, 2); + assert.strictEqual(report.summary.totalIssues, 0); }); test('orphaned pointer — domain points here but not in properties', async () => { @@ -260,6 +266,11 @@ describe('NetworkConsistencyChecker', () => { assert.strictEqual(report.missingPointers.length, 1); assert.strictEqual(report.stalePointers.length, 1); assert.ok(Math.abs(report.coverage - 1 / 3) < 0.01, `Expected ~33% coverage, got ${report.coverage}`); + assert.strictEqual(report.summary.totalDomains, 3); + assert.strictEqual(report.summary.validPointers, 1); + assert.strictEqual(report.summary.missingPointers, 1); + assert.strictEqual(report.summary.stalePointers, 1); + assert.strictEqual(report.summary.totalIssues, 2); }); }); @@ -554,6 +565,37 @@ describe('NetworkConsistencyChecker', () => { }); }); + describe('progress callback', () => { + test('onProgress is called for each domain check', async () => { + const authFile = makeAuthoritativeFile(); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'seller.example.com/mcp': { data: {} }, + }); + + const events = []; + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + onProgress: progress => events.push(progress), + }); + + await checker.check(); + + const pointerEvents = events.filter(e => e.phase === 'pointers'); + const agentEvents = events.filter(e => e.phase === 'agents'); + assert.strictEqual(pointerEvents.length, 2); + assert.strictEqual(agentEvents.length, 1); + assert.ok(pointerEvents.every(e => e.total === 2)); + assert.ok(pointerEvents.some(e => e.completed === 1)); + assert.ok(pointerEvents.some(e => e.completed === 2)); + }); + }); + describe('constructor validation', () => { test('throws if neither authoritativeUrl nor domains provided', () => { const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); From 2c8f439e9c5d1dd9725d138a39d3b1ef323f2bec Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 07:29:34 +0100 Subject: [PATCH 5/6] feat: follow one HTTP redirect with SSRF validation on each hop Replace redirect: 'error' with redirect: 'manual' + validated one-hop following in fetchJson and probeAgent. CDN 301 redirects (e.g. bare domain to www) no longer cause false missing_pointer results. Security: redirect targets are validated by validateAgentUrl() and must use HTTPS. Missing Location headers produce explicit errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../discovery/network-consistency-checker.ts | 56 ++++++++- test/lib/network-consistency-checker.test.js | 106 ++++++++++++++++++ 2 files changed, 158 insertions(+), 4 deletions(-) diff --git a/src/lib/discovery/network-consistency-checker.ts b/src/lib/discovery/network-consistency-checker.ts index 534b8366..19028660 100644 --- a/src/lib/discovery/network-consistency-checker.ts +++ b/src/lib/discovery/network-consistency-checker.ts @@ -372,16 +372,40 @@ export class NetworkConsistencyChecker { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { - const response = await fetch(agent.url, { + let response = await fetch(agent.url, { method: 'HEAD', signal: controller.signal, - redirect: 'error', + redirect: 'manual', headers: { ...FETCH_HEADERS, 'User-Agent': this.userAgentHeader, From: this.fromHeader, }, }); + + // Follow one redirect with SSRF validation + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get?.('location'); + if (!location) { + return { url: agent.url, reachable: false, error: 'Redirect with no Location header' }; + } + const redirectUrl = new URL(location, agent.url).toString(); + if (!redirectUrl.startsWith('https://')) { + return { url: agent.url, reachable: false, error: 'Redirect to non-HTTPS URL' }; + } + validateAgentUrl(redirectUrl); + response = await fetch(redirectUrl, { + method: 'HEAD', + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + } + return { url: agent.url, reachable: response.ok || response.status === 405, // 405 = HEAD rejected but server is alive @@ -542,15 +566,38 @@ export class NetworkConsistencyChecker { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { - const response = await fetch(url, { + let response = await fetch(url, { signal: controller.signal, - redirect: 'error', + redirect: 'manual', headers: { ...FETCH_HEADERS, 'User-Agent': this.userAgentHeader, From: this.fromHeader, }, }); + + // Follow one HTTP redirect with SSRF validation + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get?.('location'); + if (!location) { + throw new Error(`HTTP ${response.status} redirect with no Location header`); + } + const redirectUrl = new URL(location, url).toString(); + if (!redirectUrl.startsWith('https://')) { + throw new Error('Redirect to non-HTTPS URL not allowed'); + } + validateAgentUrl(redirectUrl); + response = await fetch(redirectUrl, { + signal: controller.signal, + redirect: 'error', + headers: { + ...FETCH_HEADERS, + 'User-Agent': this.userAgentHeader, + From: this.fromHeader, + }, + }); + } + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } @@ -584,6 +631,7 @@ export class NetworkConsistencyChecker { if (error.message.includes('Response too large')) return 'Response too large'; if (error.message.includes('not allowed')) return error.message; if (error.message.includes('must use HTTPS')) return error.message; + if (error.message.includes('no Location header')) return error.message; } return 'Request failed'; } diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js index 7e3d8a8f..9bf4bd04 100644 --- a/test/lib/network-consistency-checker.test.js +++ b/test/lib/network-consistency-checker.test.js @@ -22,6 +22,17 @@ function routedFetch(routes) { if (urlStr.includes(pattern)) { const config = typeof handler === 'function' ? handler(urlStr, options) : handler; const status = config.status || 200; + // Support redirect responses (301/302 with location header) + if (config.location) { + return { + ok: false, + status, + statusText: config.statusText || 'Moved', + headers: new Map([['location', config.location]]), + json: async () => null, + text: async () => '', + }; + } const body = JSON.stringify(config.data); return { ok: status >= 200 && status < 300, @@ -565,6 +576,101 @@ describe('NetworkConsistencyChecker', () => { }); }); + describe('HTTP redirect following', () => { + test('follows one redirect on pointer fetch (CDN www redirect)', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'example.com', + identifiers: [{ type: 'domain', value: 'example.com' }], + }, + ]); + + // Use a function handler to disambiguate bare domain vs www + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'example.com/.well-known/adagents.json': urlStr => { + if (urlStr.includes('www.example.com')) { + return { data: makePointer(AUTH_URL) }; + } + return { status: 301, location: 'https://www.example.com/.well-known/adagents.json' }; + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.coverage, 1); + assert.strictEqual(report.missingPointers.length, 0); + assert.strictEqual(report.domains[0].status, 'ok'); + }); + + test('follows one redirect on agent health check', async () => { + const authFile = makeAuthoritativeFile(undefined, [ + { url: 'https://agent.example.com/mcp', authorized_for: 'Sales' }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'cookingdaily.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + 'gardenweekly.com/.well-known/adagents.json': { data: makePointer(AUTH_URL) }, + // Agent endpoint redirects + 'agent.example.com/mcp': { + status: 301, + location: 'https://agent-v2.example.com/mcp', + }, + 'agent-v2.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.agentHealth.length, 1); + assert.strictEqual(report.agentHealth[0].reachable, true); + }); + + test('rejects redirect to non-HTTPS URL on pointer fetch', async () => { + const authFile = makeAuthoritativeFile([ + { + property_type: 'website', + name: 'insecure.com', + identifiers: [{ type: 'domain', value: 'insecure.com' }], + }, + ]); + + routedFetch({ + 'network.example.com/adagents.json': { data: authFile }, + 'insecure.com/.well-known/adagents.json': { + status: 301, + location: 'http://insecure.com/.well-known/adagents.json', + }, + 'seller.example.com/mcp': { data: {} }, + }); + + const { NetworkConsistencyChecker } = require('../../dist/lib/index.js'); + const checker = new NetworkConsistencyChecker({ + authoritativeUrl: AUTH_URL, + logLevel: 'silent', + }); + + const report = await checker.check(); + + assert.strictEqual(report.missingPointers.length, 1); + assert.strictEqual(report.coverage, 0); + }); + }); + describe('progress callback', () => { test('onProgress is called for each domain check', async () => { const authFile = makeAuthoritativeFile(); From 92927837501507f7da7f67d50ecdfd13427f35af Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 15 Apr 2026 07:46:56 +0100 Subject: [PATCH 6/6] fix: use exact URL match in redirect test to satisfy CodeQL CodeQL flagged urlStr.includes('www.example.com') as an incomplete URL substring sanitization. Switched to exact equality check since this is a test mock dispatching on known URLs. Co-Authored-By: Claude Opus 4.6 (1M context) --- test/lib/network-consistency-checker.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lib/network-consistency-checker.test.js b/test/lib/network-consistency-checker.test.js index 9bf4bd04..6db29ff8 100644 --- a/test/lib/network-consistency-checker.test.js +++ b/test/lib/network-consistency-checker.test.js @@ -590,7 +590,7 @@ describe('NetworkConsistencyChecker', () => { routedFetch({ 'network.example.com/adagents.json': { data: authFile }, 'example.com/.well-known/adagents.json': urlStr => { - if (urlStr.includes('www.example.com')) { + if (urlStr === 'https://www.example.com/.well-known/adagents.json') { return { data: makePointer(AUTH_URL) }; } return { status: 301, location: 'https://www.example.com/.well-known/adagents.json' };