From 13badf4c0d6477f3468f1e84266fb3ca1ca7aadd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:49:55 +0000 Subject: [PATCH 01/15] fix(registry): fail closed on supply paths with canonical shared vectors --- .changeset/fail-closed-supply-path-vectors.md | 5 + .../collections-and-installments.mdx | 8 + server/src/routes/registry-api.ts | 55 +- server/src/services/supply-path-contract.ts | 97 + server/src/services/supply-path-evidence.ts | 22 + server/src/services/supply-path-input.ts | 24 + server/src/services/supply-path-verifier.ts | 710 +- .../services/supply-path-evidence.test.ts | 24 + .../unit/services/supply-path-golden.test.ts | 16 + .../services/supply-path-verifier.test.ts | 5 +- .../source/test-vectors/supply-path/README.md | 21 + .../test-vectors/supply-path/vectors.json | 6014 +++++++++++++++++ static/openapi/registry.yaml | 10 + 13 files changed, 6658 insertions(+), 353 deletions(-) create mode 100644 .changeset/fail-closed-supply-path-vectors.md create mode 100644 server/src/services/supply-path-contract.ts create mode 100644 server/src/services/supply-path-evidence.ts create mode 100644 server/src/services/supply-path-input.ts create mode 100644 server/tests/unit/services/supply-path-evidence.test.ts create mode 100644 server/tests/unit/services/supply-path-golden.test.ts create mode 100644 static/compliance/source/test-vectors/supply-path/README.md create mode 100644 static/compliance/source/test-vectors/supply-path/vectors.json diff --git a/.changeset/fail-closed-supply-path-vectors.md b/.changeset/fail-closed-supply-path-vectors.md new file mode 100644 index 00000000000..d8ae731ad54 --- /dev/null +++ b/.changeset/fail-closed-supply-path-vectors.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Publish canonical supply-path verification golden vectors and an explicit evaluator semantics version. Clarify fail-closed owner-sold carriage verification for affirmative collection constraints, dangling property references, unknown or unevaluated authorization constraints, revocation precedence, agent URL identity and independent domain-level paths. diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index 40b2c800012..8eeafe474d4 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -253,6 +253,14 @@ sales path: it is the machine-readable form of the carriage contract's existing owner-sales clause, and the upgrade path from an `inventorypartnerdomain` line they typically already publish. +### Canonical verification semantics + +The registry supply-path endpoint returns `semantics_version: "1"`. Its evaluator and authoritative SDK implementations consume the same [canonical golden vectors](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/supply-path/vectors.json). Consumers MUST NOT treat results from older evaluator semantics as equivalent to version 1. + +Version 1 requires an affirmative host `collections` selector naming the owner. A broad property grant with no collection constraint does not attest owner-sold carriage. Every claimed host property ID must resolve, and the matching grant must cover all of them. Identifier-only distribution entries remain valid discovery data, but cannot prove a host property binding. Missing or unknown authorization discriminators, malformed selectors, and country, effective-time or placement constraints that cannot be evaluated in the supplied context fail closed. Revocations take precedence over declarations. Agent URL identity follows the protocol canonicalization rules and preserves case-sensitive paths and query values. + +A query omitting `collection_id` returns the strongest complete individual collection path. It never combines one collection's carriage with another collection's authorization. The weaker `host_delegated` state retains its documented owner declarations plus host inventory-partner evidence requirements; it does not require a host `adagents.json`, prove concrete property coverage, or override revocation. + Two verified paths can legitimately overlap. In a revenue-share deal the host's agent and the owner's agent may both offer avails on the same property and collection; in an inventory-split deal they sell disjoint avails. diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 06d2b7396ae..f20cfa20fd6 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -1,3 +1,4 @@ +import { domain as supplyPathDomain, agentIdentity as supplyPathAgentIdentity } from '../services/supply-path-input.js'; import { isAuthoritativeComplianceRun } from '../compliance/run-publication.js'; /** * Public Registry API routes. @@ -72,38 +73,11 @@ import { brandResolveCacheControl, type BrandResolutionOutcome, } from "../services/brand-resolution-cache-policy.js"; -import { verifySupplyPath, parseInventoryPartnerDomains } from "../services/supply-path-verifier.js"; +import { verifySupplyPath } from "../services/supply-path-verifier.js"; import { canonicalizePublisherDomain } from "../services/publisher-domain.js"; -import { AAO_UA_VALIDATOR } from "../config/user-agents.js"; -/** - * Union of inventorypartnerdomain= declarations from the host's - * app-ads.txt and ads.txt. Returns null only when neither file could be - * fetched — distinct from fetched-and-absent (empty array), which the - * verifier treats as an explicit "not declared". - */ -async function fetchHostInventoryPartnerDomains(hostDomain: string): Promise { - const partners = new Set(); - let anyFetched = false; - for (const file of ["app-ads.txt", "ads.txt"]) { - try { - const response = await safeFetchAxiosLike(`https://${hostDomain}/${file}`, { - timeoutMs: 10000, - maxRedirects: 3, - headers: { Accept: "text/plain", "User-Agent": AAO_UA_VALIDATOR }, - }); - if (response.status === 200) { - anyFetched = true; - for (const partner of parseInventoryPartnerDomains(response.data.toString("utf-8"))) { - partners.add(partner); - } - } - } catch { - // Unreachable file — treated as unavailable unless the other resolves. - } - } - return anyFetched ? [...partners] : null; -} +import { fetchHostInventoryPartnerDomains } from '../services/supply-path-evidence.js'; + import { projectPublicComplianceNotices, type PublicComplianceNotice, @@ -2093,7 +2067,7 @@ const VerifySupplyPathRequestSchema = z.object({ owner_domain: z.string().min(1).openapi({ description: "Channel owner's publisher domain — where the canonical collection is declared." }), host_domain: z.string().min(1).openapi({ description: "Host publisher domain — where the carrying property (e.g. CTV app) is declared." }), agent_url: z.string().min(1).openapi({ description: "Sales agent URL the buyer would transact with. Canonicalized server-side." }), - collection_id: z.string().min(1).optional().openapi({ description: "Owner-assigned collection ID. Omit to verify the path at domain level (bulk deals)." }), + collection_id: z.string().min(1).max(256).refine(value => value.trim().length > 0).optional().openapi({ description: "Owner-assigned collection ID. Omit to verify the path at domain level (bulk deals)." }), }); registry.registerPath({ @@ -2121,6 +2095,8 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ + semantics_version: z.literal("1").openapi({ description: "Canonical supply-path evaluator semantics; pinned by the shared supply-path golden vectors." }), + resolved_collection_id: z.string().optional().openapi({ description: "Concrete collection chosen for the complete path, including domain-level inquiries." }), state: z.enum(["verified_owner_sold", "host_delegated", "owner_attested", "unverified"]), legs: z.object({ owner_collection_declared: SupplyPathLegSchema, @@ -10675,16 +10651,16 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { } }); - router.post("/registry/verify/supply-path", async (req, res) => { + router.post("/registry/verify/supply-path", registryReadRateLimiter, async (req, res) => { try { const parsed = VerifySupplyPathRequestSchema.safeParse(req.body ?? {}); if (!parsed.success) { return res.status(400).json({ error: "Invalid request", issues: parsed.error.issues }); } - const ownerDomain = canonicalizePublisherDomain(parsed.data.owner_domain); - const hostDomain = canonicalizePublisherDomain(parsed.data.host_domain); - if (!ownerDomain || !hostDomain) { - return res.status(400).json({ error: "owner_domain and host_domain must be valid publisher domains" }); + const ownerDomain = supplyPathDomain(parsed.data.owner_domain); + const hostDomain = supplyPathDomain(parsed.data.host_domain); + if (!ownerDomain || !hostDomain || !supplyPathAgentIdentity(parsed.data.agent_url)) { + return res.status(400).json({ error: "owner_domain, host_domain, and agent_url must be valid publisher and HTTPS agent identities" }); } const [ownerManifest, hostManifest] = await Promise.all([ @@ -10704,7 +10680,9 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { hostManifest, hostInventoryPartnerDomains: null, }); - if (!verdict.legs.host_authorization.ok) { + if (verdict.legs.host_authorization.ok) { + verdict.legs.inventory_partner_domain = { ok: false, failure: 'not_evaluated' }; + } else { verdict = verifySupplyPath({ ownerDomain, hostDomain, @@ -10712,7 +10690,8 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { collectionId: parsed.data.collection_id, ownerManifest, hostManifest, - hostInventoryPartnerDomains: await fetchHostInventoryPartnerDomains(hostDomain), + hostInventoryPartnerDomains: null, + hostInventoryPartnerDomainsByFile: await fetchHostInventoryPartnerDomains({ ownerDomain, hostDomain, agentUrl: parsed.data.agent_url, collectionId: parsed.data.collection_id, ownerManifest, hostManifest, hostInventoryPartnerDomains: null }), }); } diff --git a/server/src/services/supply-path-contract.ts b/server/src/services/supply-path-contract.ts new file mode 100644 index 00000000000..fbd5d1ca574 --- /dev/null +++ b/server/src/services/supply-path-contract.ts @@ -0,0 +1,97 @@ +/** Untrusted evidence. The evaluator validates every authorization-relevant field. */ +export interface SupplyPathManifest { + authorized_agents?: unknown; + properties?: unknown; + collections?: unknown; + revoked_publisher_domains?: unknown; + [key: string]: unknown; +} + +export type SupplyPathState = 'verified_owner_sold' | 'host_delegated' | 'owner_attested' | 'unverified'; + +export type OwnerCollectionFailure = + | 'manifest_not_found' + | 'no_collections_declared' + | 'collection_not_declared' + | 'publisher_revoked'; + +export type OwnerCarriageFailure = + | 'collection_leg_failed' + | 'no_distribution_for_host' + | 'property_ids_unresolved' + | 'host_manifest_not_found'; + +export type OwnerAgentFailure = 'manifest_not_found' | 'agent_not_declared_by_owner'; + +export type HostAuthorizationFailure = + | 'manifest_not_found' + | 'no_agent_entry' + | 'collection_scope_mismatch' + | 'property_scope_mismatch' + | 'unsupported_constraints'; + +export interface Leg { + ok: boolean; + failure?: F | 'evaluation_limit_exceeded'; + detail?: string; +} + +export interface SupplyPathLegs { + /** Owner's adagents.json declares the collection (kind noted in detail). */ + owner_collection_declared: Leg; + /** The collection's distribution[] names the host, and any property_ids resolve in the host manifest. */ + owner_distribution_carriage: Leg & { + property_ids_matched?: string[]; + property_ids_unmatched?: string[]; + }; + /** Owner's own adagents.json lists the sales agent. */ + owner_agent_declared: Leg; + /** Host adagents.json authorizes the agent for the property, collection-scoped. */ + host_authorization: Leg & { + matched_entry?: { + url: string; + authorization_type?: string; + delegation_type?: string; + collections?: Array<{ publisher_domain: string; collection_ids?: string[] }>; + }; + }; + /** Host ads.txt / app-ads.txt names the owner via inventorypartnerdomain=. */ + inventory_partner_domain: Leg<'not_declared' | 'ads_txt_unavailable' | 'not_evaluated'>; +} + +export interface SupplyPathVerdict { + /** Version of the canonical evaluator semantics and shared vector corpus. */ + semantics_version: '1'; + /** Concrete winning collection, including domain-level inquiries. */ + resolved_collection_id?: string; + state: SupplyPathState; + legs: SupplyPathLegs; +} + +export interface SupplyPathInput { + /** Canonicalized owner (channel publisher) domain. */ + ownerDomain: string; + /** Canonicalized host (carrying property publisher) domain. */ + hostDomain: string; + /** Seller agent URL as the buyer sees it. */ + agentUrl: string; + /** Owner-assigned collection ID. Omit to verify the path at domain level (bulk deals). */ + collectionId?: string; + /** Optional concrete product scope, resolved from the host manifest. Empty means unresolved. */ + requiredHostPropertyIds?: string[]; + ownerManifest: SupplyPathManifest | null; + hostManifest: SupplyPathManifest | null; + /** + * inventorypartnerdomain values from the host's ads.txt/app-ads.txt, + * already canonicalized. null = the files could not be fetched (distinct + * from fetched-and-absent, which is an empty array). + */ + hostInventoryPartnerDomains: string[] | null; + /** Per-file parsed evidence, so bulk inquiries evaluate each collection's applicable surfaces independently. */ + hostInventoryPartnerDomainsByFile?: Partial>; + /** Previously observed authority-scoped revocations, even when a current manifest is unavailable. */ + heldRevocations?: { owner?: string[]; host?: string[] }; + /** False when the caller deliberately skipped IAB evidence after host authorization succeeded. */ + inventoryPartnerDomainEvaluated?: boolean; +} + diff --git a/server/src/services/supply-path-evidence.ts b/server/src/services/supply-path-evidence.ts new file mode 100644 index 00000000000..f71800771e3 --- /dev/null +++ b/server/src/services/supply-path-evidence.ts @@ -0,0 +1,22 @@ +import { safeFetchAxiosLike } from '../utils/url-security.js'; +import { AAO_UA_VALIDATOR } from '../config/user-agents.js'; +import { supplyPathAdsTxtPolicy, parseInventoryPartnerDomains, type SupplyPathInput } from './supply-path-verifier.js'; + +/** Bounded IAB evidence from the applicable host surfaces; redirect delegation is not accepted here. */ +export async function fetchHostInventoryPartnerDomains(input: SupplyPathInput): Promise> { + const policy = supplyPathAdsTxtPolicy(input); + const contents = await Promise.all(policy.files.map(async file => { + try { + const response = await safeFetchAxiosLike(`https://${input.hostDomain}/${file}`, { + timeoutMs: 5000, + maxResponseBytes: 256 * 1024, + maxRedirects: 0, + headers: { Accept: "text/plain", "User-Agent": AAO_UA_VALIDATOR }, + }); + if (response.status === 404) return ""; + const contentType = String(response.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); + return response.status === 200 && contentType === 'text/plain' ? response.data.toString("utf-8") : null; + } catch { return null; } + })); + return Object.fromEntries(policy.files.map((file, index) => [file, contents[index] === null ? null : parseInventoryPartnerDomains(contents[index]!)])); +} diff --git a/server/src/services/supply-path-input.ts b/server/src/services/supply-path-input.ts new file mode 100644 index 00000000000..cac87ac2ebd --- /dev/null +++ b/server/src/services/supply-path-input.ts @@ -0,0 +1,24 @@ +import { isIP } from 'node:net'; +import { canonicalizeAgentUrl } from '@adcp/sdk'; + +export function record(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +export function records(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(record) : []; +} +export function strings(value: unknown): value is string[] { + return Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string' && v.trim().length > 0); +} +/** Domain identity never strips www or conflates sibling publisher namespaces. */ +export function domain(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.toLowerCase().replace(/\.$/, ''); + if (normalized.length > 253 || isIP(normalized) || !normalized.includes('.')) return null; + return normalized.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) ? normalized : null; +} +export function agentIdentity(value: unknown): string | null { + if (typeof value !== 'string' || /[\s\x00-\x1f\x7f]/.test(value)) return null; + const canonical = canonicalizeAgentUrl(value); + return canonical?.startsWith('https://') ? canonical : null; +} diff --git a/server/src/services/supply-path-verifier.ts b/server/src/services/supply-path-verifier.ts index 4fa152e5099..0ecf75afe70 100644 --- a/server/src/services/supply-path-verifier.ts +++ b/server/src/services/supply-path-verifier.ts @@ -1,357 +1,441 @@ /** - * Owner-sold supply-path verifier. - * - * Computes the verification state of one owner-sold carriage path — a - * channel owner's sales agent selling the owner's collection on a host - * property — by joining the owner's and host's adagents.json manifests - * plus the host's ads.txt/app-ads.txt inventorypartnerdomain lines. - * - * The states are the ladder defined in - * docs/media-buy/product-discovery/collections-and-installments.mdx - * ("Verification states"): - * - * verified_owner_sold — the host's adagents.json authorizes the agent - * for the host property, narrowed by the owner's collection selector - * (exact or bulk grant). Enforcement-grade. - * host_delegated — no collection-scoped host entry, but the host's - * ads.txt names the owner via inventorypartnerdomain= and the - * owner's own adagents.json names the agent and the collection. - * owner_attested — only the owner's distribution[] asserts carriage. - * Discovery data; MUST NOT be treated as sales authorization. - * unverified — none of the above. - * - * The verdict is deliberately evidence-bearing: every leg reports what - * was checked and why it failed, so callers can reproduce the conclusion - * from the authoritative files and fix the failing leg (most early - * failures are cross-file typos, not policy). + * Pure, fail-closed implementation of the adcp#6897 verification ladder. + * Inputs are evidence, never seller-supplied verdicts. Each collection is + * evaluated independently so domain-level queries cannot join unrelated legs. */ +import { resolveAgentProperties } from '@adcp/sdk'; +import { + parsePublisherPropertySelector, + expandPublisherPropertySelector, +} from '@adcp/sdk'; +import type { AdAgentsJson, AuthorizedAgent } from '@adcp/sdk'; +import type { SupplyPathInput, SupplyPathLegs, SupplyPathManifest, SupplyPathVerdict } from './supply-path-contract.js'; +import { agentIdentity, domain, record, records, strings } from './supply-path-input.js'; -import { canonicalizePublisherDomain } from './publisher-domain.js'; -import { entryPropertyScope } from './carriage-confirmation.js'; -import { canonicalizeAgentUrl, type AdagentsManifest, type AdagentsAuthorizedAgent } from '../db/publisher-db.js'; +export const SUPPLY_PATH_STATES = ['unverified', 'owner_attested', 'host_delegated', 'verified_owner_sold'] as const; -// ─── Types ─────────────────────────────────────────────────────────────────── - -export type SupplyPathState = - | 'verified_owner_sold' - | 'host_delegated' - | 'owner_attested' - | 'unverified'; - -export type OwnerCollectionFailure = - | 'manifest_not_found' - | 'no_collections_declared' - | 'collection_not_declared'; - -export type OwnerCarriageFailure = - | 'collection_leg_failed' - | 'no_distribution_for_host' - | 'property_ids_unresolved' - | 'host_manifest_not_found'; - -export type OwnerAgentFailure = - | 'manifest_not_found' - | 'agent_not_declared_by_owner'; - -export type HostAuthorizationFailure = - | 'manifest_not_found' - | 'no_agent_entry' - | 'collection_scope_mismatch' - | 'property_scope_mismatch'; - -export interface Leg { - ok: boolean; - failure?: F; - detail?: string; -} - -export interface SupplyPathLegs { - /** Owner's adagents.json declares the collection (kind noted in detail). */ - owner_collection_declared: Leg; - /** The collection's distribution[] names the host, and any property_ids resolve in the host manifest. */ - owner_distribution_carriage: Leg & { - property_ids_matched?: string[]; - property_ids_unmatched?: string[]; - }; - /** Owner's own adagents.json lists the sales agent. */ - owner_agent_declared: Leg; - /** Host adagents.json authorizes the agent for the property, collection-scoped. */ - host_authorization: Leg & { - matched_entry?: { - url: string; - authorization_type?: string; - delegation_type?: string; - collections?: Array<{ publisher_domain: string; collection_ids?: string[] }>; - }; - }; - /** Host ads.txt / app-ads.txt names the owner via inventorypartnerdomain=. */ - inventory_partner_domain: Leg<'not_declared' | 'ads_txt_unavailable'>; +export function parseInventoryPartnerDomains(content: string): string[] { + const partners = new Set(); + for (const line of content.split(/\r?\n/)) { + const match = line.trim().match(/^inventorypartnerdomain\s*=\s*([A-Za-z0-9.-]+)\s*(?:#.*)?$/i); + const value = match ? domain(match[1]) : null; + if (value) partners.add(value); + } + return [...partners]; } -export interface SupplyPathVerdict { - state: SupplyPathState; - legs: SupplyPathLegs; +function revoked(manifest: SupplyPathManifest, publisher: string): boolean { + if (manifest.revoked_publisher_domains === undefined) return false; + if (!Array.isArray(manifest.revoked_publisher_domains)) return true; + return manifest.revoked_publisher_domains.some( + item => + !domain(record(item) ? item.publisher_domain : item) || + domain(record(item) ? item.publisher_domain : item) === publisher + ); } -export interface SupplyPathInput { - /** Canonicalized owner (channel publisher) domain. */ - ownerDomain: string; - /** Canonicalized host (carrying property publisher) domain. */ - hostDomain: string; - /** Seller agent URL as the buyer sees it. */ - agentUrl: string; - /** Owner-assigned collection ID. Omit to verify the path at domain level (bulk deals). */ - collectionId?: string; - ownerManifest: AdagentsManifest | null; - hostManifest: AdagentsManifest | null; - /** - * inventorypartnerdomain values from the host's ads.txt/app-ads.txt, - * already canonicalized. null = the files could not be fetched (distinct - * from fetched-and-absent, which is an empty array). - */ - hostInventoryPartnerDomains: string[] | null; +function coversCollection(entry: Record, owner: string, id: string | undefined): boolean { + // Absent is an unconstrained property grant; present malformed is never broad. + if (entry.collections === undefined) return true; + if (!Array.isArray(entry.collections) || entry.collections.length === 0) return false; + if ( + !entry.collections.every( + selector => + record(selector) && + domain(selector.publisher_domain) && + (selector.collection_ids === undefined || strings(selector.collection_ids)) + ) + ) + return false; + return entry.collections.some( + selector => + domain(selector.publisher_domain) === owner && + (selector.collection_ids === undefined || (id !== undefined && selector.collection_ids.includes(id))) + ); } -// ─── ads.txt parsing ───────────────────────────────────────────────────────── - -/** - * Extract inventorypartnerdomain= values from ads.txt content - * (IAB ads.txt 1.1 §3.2 variable). Comments after '#' are stripped; - * values are canonicalized publisher domains. - */ -export function parseInventoryPartnerDomains(adsTxtContent: string): string[] { - const partners = new Set(); - for (const line of adsTxtContent.split(/\r?\n/)) { - const match = line.trim().match(/^inventorypartnerdomain\s*=\s*([A-Za-z0-9.-]+)\s*(?:#.*)?$/i); - if (match?.[1]) { - const canonical = canonicalizePublisherDomain(match[1]); - if (canonical) partners.add(canonical); +/** Resolve one entry at a time: separate grants are alternatives, never merged across collection scopes. */ +function propertyScope(entry: Record, host: string, manifest: SupplyPathManifest): Set { + if (revoked(manifest, host)) return new Set(); + if (entry.authorization_type === 'publisher_properties') { + if (!Array.isArray(entry.publisher_properties) || entry.publisher_properties.length === 0) return new Set(); + const properties = records(manifest.properties).filter( + p => p.publisher_domain === undefined || domain(p.publisher_domain) === host + ); + const ids = new Set(); + try { + for (const raw of entry.publisher_properties) { + const selector = parsePublisherPropertySelector(raw); + if ('property_ids' in selector && !strings(selector.property_ids)) return new Set(); + if ('property_tags' in selector && !strings(selector.property_tags)) return new Set(); + for (const single of expandPublisherPropertySelector(selector)) { + if (domain(single.publisher_domain) !== host) continue; + for (const property of properties) { + if (typeof property.property_id !== 'string') continue; + if ( + single.selection_type === 'all' || + (single.selection_type === 'by_id' && single.property_ids.includes(property.property_id)) || + (single.selection_type === 'by_tag' && + strings(property.tags) && + property.tags.some(t => single.property_tags.includes(t))) + ) { + ids.add(property.property_id); + } + } + } + } + } catch { + return new Set(); } + return ids; } - return [...partners]; + if (entry.authorization_type === 'property_ids' && !strings(entry.property_ids)) return new Set(); + if (entry.authorization_type === 'property_tags' && !strings(entry.property_tags)) return new Set(); + // Reuse discovery's discriminator, missing-selector, and revocation semantics. + const result = resolveAgentProperties( + { ...manifest, authorized_agents: [entry as unknown as AuthorizedAgent] } as AdAgentsJson, + String(entry.url) + ); + return new Set( + result.properties + .filter(p => p.publisher_domain === undefined || domain(p.publisher_domain) === host) + .map(p => p.property_id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) + ); } -// ─── Verification ──────────────────────────────────────────────────────────── - -interface CollectionRecord extends Record { - collection_id?: unknown; - kind?: unknown; - distribution?: unknown; +export function isUnqualifiedPropertySelector(value: unknown): boolean { + return ( + record(value) && + Object.keys(value).every(key => + ['publisher_domain', 'publisher_domains', 'selection_type', 'property_ids', 'property_tags'].includes(key) + ) + ); } -function ownerCollections(manifest: AdagentsManifest): CollectionRecord[] { - return Array.isArray(manifest.collections) - ? (manifest.collections as CollectionRecord[]).filter((c) => c && typeof c === 'object') - : []; +const UNDERSTOOD_GRANT_FIELDS = new Set([ + 'url', + 'authorized_for', + 'authorization_type', + 'property_ids', + 'property_tags', + 'properties', + 'publisher_properties', + 'collections', + 'delegation_type', + 'exclusive', + 'signing_keys', + 'encryption_keys', + 'last_updated', +]); +function unsupportedConstraints(entry: Record): string[] { + // Unknown extensions may narrow authorization. They need a reviewed semantic + // version before this context-free verifier can safely accept the entry. + const unknown = Object.keys(entry).filter(key => !UNDERSTOOD_GRANT_FIELDS.has(key)); + for (const selector of records(entry.collections)) { + unknown.push( + ...Object.keys(selector) + .filter(key => !['publisher_domain', 'collection_ids'].includes(key)) + .map(key => `collections[].${key}`) + ); + } + for (const selector of records(entry.publisher_properties)) { + unknown.push( + ...Object.keys(selector) + .filter( + key => + !['publisher_domain', 'publisher_domains', 'selection_type', 'property_ids', 'property_tags'].includes(key) + ) + .map(key => `publisher_properties[].${key}`) + ); + } + return unknown; } -function hostPropertyIds(manifest: AdagentsManifest): Set { - const ids = new Set(); - for (const property of Array.isArray(manifest.properties) ? manifest.properties : []) { - const id = (property as { property_id?: unknown } | null)?.property_id; - if (typeof id === 'string' && id.length > 0) ids.add(id); +/** Bound nested input and worst-case selector work before any traversal. */ +function nodeCost(value: unknown): number { + const stack = [value]; + let cost = 0; + while (stack.length) { + const item = stack.pop(); + if (++cost > 32768) return Infinity; + if (typeof item === 'string' && item.length > 8192) return Infinity; + if (Array.isArray(item)) { + if (item.length > 1024) return Infinity; + stack.push(...item); + } else if (record(item)) { + const values = Object.values(item); + if (values.length > 1024) return Infinity; + stack.push(...values); + } } - return ids; + return cost; } -/** Does the entry's collections constraint cover (ownerDomain, collectionId)? */ -function entryCoversCollection( - entry: AdagentsAuthorizedAgent, - ownerDomain: string, - collectionId: string | undefined, -): boolean { - // No constraint = every collection (a plain property grant is broader - // than a collection-narrowed one). - if (!Array.isArray(entry.collections) || entry.collections.length === 0) return true; - return entry.collections.some((selector) => { - if (!selector || typeof selector !== 'object') return false; - const domain = typeof selector.publisher_domain === 'string' - ? canonicalizePublisherDomain(selector.publisher_domain) - : null; - if (domain !== ownerDomain) return false; - if (selector.collection_ids === undefined) return true; // bulk grant - if (!Array.isArray(selector.collection_ids)) return false; - if (collectionId === undefined) return selector.collection_ids.length > 0; - return selector.collection_ids.includes(collectionId); - }); +function limitVerdict(): SupplyPathVerdict { + const failure = { ok: false, failure: 'evaluation_limit_exceeded' as const }; + return { + semantics_version: '1', + state: 'unverified', + legs: { + owner_collection_declared: { ...failure }, + owner_distribution_carriage: { ...failure }, + owner_agent_declared: { ...failure }, + host_authorization: { ...failure }, + inventory_partner_domain: { ...failure }, + }, + }; } export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { - const { ownerDomain, hostDomain, collectionId } = input; - const agentCanonical = canonicalizeAgentUrl(input.agentUrl); - - // ── Leg 1: owner declares the collection ────────────────────────────────── - const ownerCollectionLeg: SupplyPathLegs['owner_collection_declared'] = { ok: false }; - let targetCollections: CollectionRecord[] = []; - if (!input.ownerManifest) { - ownerCollectionLeg.failure = 'manifest_not_found'; - } else { - const declared = ownerCollections(input.ownerManifest); - if (collectionId !== undefined) { - targetCollections = declared.filter((c) => c.collection_id === collectionId); - if (targetCollections.length > 0) { - ownerCollectionLeg.ok = true; - const kind = targetCollections[0].kind; - if (typeof kind === 'string') ownerCollectionLeg.detail = `kind: ${kind}`; - } else { - ownerCollectionLeg.failure = 'collection_not_declared'; - ownerCollectionLeg.detail = - `owner declares ${declared.length} collection(s); none has collection_id "${collectionId}"`; - } - } else if (declared.length > 0) { - targetCollections = declared; - ownerCollectionLeg.ok = true; - ownerCollectionLeg.detail = `owner declares ${declared.length} collection(s)`; - } else { - ownerCollectionLeg.failure = 'no_collections_declared'; + const total = nodeCost(input); + if (!Number.isFinite(total)) return limitVerdict(); + const grants = nodeCost(input.hostManifest?.authorized_agents); + const properties = nodeCost(input.hostManifest?.properties); + const collections = records(input.ownerManifest?.collections).length; + if (!Number.isFinite(total) || grants * properties + Math.max(1, collections) * total * 4 > 4_000_000) { + return limitVerdict(); + } + // Bulk inquiry is existential over complete individual paths, not a union + // of owner collection A's carriage and collection B's host authorization. + if (input.collectionId === undefined) { + const collections = records(input.ownerManifest?.collections).filter( + c => typeof c.collection_id === 'string' && c.collection_id.length > 0 + ); + if (collections.length) { + const verdicts = collections.map(c => verifySupplyPath({ ...input, collectionId: c.collection_id as string })); + return verdicts.reduce((best, next) => + SUPPLY_PATH_STATES.indexOf(next.state) > SUPPLY_PATH_STATES.indexOf(best.state) ? next : best + ); } } - - // ── Leg 2: the collection's distribution names the host ─────────────────── - const carriageLeg: SupplyPathLegs['owner_distribution_carriage'] = { ok: false }; - if (!ownerCollectionLeg.ok) { - carriageLeg.failure = 'collection_leg_failed'; + const owner = domain(input.ownerDomain); + const host = domain(input.hostDomain); + const agent = agentIdentity(input.agentUrl); + const ownerManifest = record(input.ownerManifest) ? input.ownerManifest : null; + const hostManifest = record(input.hostManifest) ? input.hostManifest : null; + const isRevoked = (role: 'owner' | 'host', publisher: string): boolean => { + const manifest = role === 'owner' ? ownerManifest : hostManifest; + return ( + (manifest !== null && revoked(manifest, publisher)) || + (input.heldRevocations?.[role] ?? []).some(value => domain(value) === publisher) + ); + }; + const collectionLeg: SupplyPathLegs['owner_collection_declared'] = { ok: false }; + const declared = records(ownerManifest?.collections); + const targets = declared.filter(c => c.collection_id === input.collectionId && typeof c.collection_id === 'string'); + if (!ownerManifest) collectionLeg.failure = 'manifest_not_found'; + else if (owner && isRevoked('owner', owner)) collectionLeg.failure = 'publisher_revoked'; + else if (!input.collectionId && !declared.length) collectionLeg.failure = 'no_collections_declared'; + else if (!owner || !input.collectionId?.trim() || targets.length !== 1) { + collectionLeg.failure = 'collection_not_declared'; + collectionLeg.detail = `owner declares ${declared.length} collection(s); none has an unambiguous collection_id "${input.collectionId}"`; } else { - const hostEntries = targetCollections.flatMap((collection) => - (Array.isArray(collection.distribution) ? collection.distribution : []).filter( - (entry: unknown) => - !!entry && typeof entry === 'object' - && typeof (entry as { publisher_domain?: unknown }).publisher_domain === 'string' - && canonicalizePublisherDomain((entry as { publisher_domain: string }).publisher_domain) === hostDomain, - ) as Array<{ property_ids?: unknown }>, + collectionLeg.ok = true; + if (typeof targets[0]!.kind === 'string') collectionLeg.detail = `kind: ${targets[0]!.kind}`; + } + + const carriage: SupplyPathLegs['owner_distribution_carriage'] = { ok: false }; + const entries = collectionLeg.ok + ? records(targets[0]!.distribution).filter(e => domain(e.publisher_domain) === host) + : []; + let claimed: string[] = []; + if (!collectionLeg.ok) carriage.failure = 'collection_leg_failed'; + else if (!host || !entries.length) carriage.failure = 'no_distribution_for_host'; + else { + const valid = entries.every( + e => + Object.keys(e).every(key => ['publisher_domain', 'property_ids', 'identifiers'].includes(key)) && + (e.property_ids === undefined || strings(e.property_ids)) && + (e.identifiers === undefined || + (Array.isArray(e.identifiers) && + e.identifiers.length > 0 && + e.identifiers.every( + i => + record(i) && + typeof i.type === 'string' && + i.type.length > 0 && + typeof i.value === 'string' && + i.value.length > 0 + ))) && + (e.property_ids !== undefined || e.identifiers !== undefined) ); - if (hostEntries.length === 0) { - carriageLeg.failure = 'no_distribution_for_host'; + claimed = [...new Set(entries.flatMap(e => (strings(e.property_ids) ? e.property_ids : [])))]; + if (!valid) { + carriage.failure = 'property_ids_unresolved'; + carriage.detail = 'Malformed distribution constraints'; + } else if (!claimed.length) { + carriage.ok = true; + carriage.detail = 'carriage asserted without host property_ids (identifiers only)'; + } else if (!hostManifest) { + carriage.failure = 'host_manifest_not_found'; + carriage.property_ids_unmatched = claimed; } else { - const claimed = [...new Set(hostEntries.flatMap((entry) => - Array.isArray(entry.property_ids) - ? entry.property_ids.filter((id): id is string => typeof id === 'string') - : []))]; - if (claimed.length === 0) { - // Identifier-only carriage: asserted, nothing to cross-resolve. - carriageLeg.ok = true; - carriageLeg.detail = 'carriage asserted without host property_ids (identifiers only)'; - } else if (!input.hostManifest) { - carriageLeg.failure = 'host_manifest_not_found'; - carriageLeg.property_ids_unmatched = claimed; - } else { - const known = hostPropertyIds(input.hostManifest); - carriageLeg.property_ids_matched = claimed.filter((id) => known.has(id)); - carriageLeg.property_ids_unmatched = claimed.filter((id) => !known.has(id)); - if (carriageLeg.property_ids_matched.length > 0) { - carriageLeg.ok = true; - } else { - // Dangling cross-file references MUST be treated as unverified - // carriage (spec: programmed channels and owner-sold carriage). - carriageLeg.failure = 'property_ids_unresolved'; - } - } + const properties = records(hostManifest.properties).filter( + p => p.publisher_domain === undefined || domain(p.publisher_domain) === host + ); + const counts = new Map(); + for (const property of properties) counts.set(property.property_id, (counts.get(property.property_id) ?? 0) + 1); + const known = new Set([...counts].filter(([, count]) => count === 1).map(([id]) => id)); + carriage.property_ids_matched = claimed.filter(id => known.has(id)); + carriage.property_ids_unmatched = claimed.filter(id => !known.has(id)); + carriage.ok = carriage.property_ids_unmatched.length === 0; + if (!carriage.ok) carriage.failure = 'property_ids_unresolved'; } } + const ownerAgent: SupplyPathLegs['owner_agent_declared'] = !ownerManifest + ? { ok: false, failure: 'manifest_not_found' } + : agent && + records(ownerManifest.authorized_agents).some( + e => + agentIdentity(e.url) === agent && + owner && + coversCollection(e, owner, input.collectionId) && + !unsupportedConstraints(e).length + ) + ? { ok: true } + : { ok: false, failure: 'agent_not_declared_by_owner' }; - // ── Leg 3: owner's own file names the sales agent ───────────────────────── - const ownerAgentLeg: SupplyPathLegs['owner_agent_declared'] = { ok: false }; - if (!input.ownerManifest) { - ownerAgentLeg.failure = 'manifest_not_found'; - } else { - const listed = (Array.isArray(input.ownerManifest.authorized_agents) - ? input.ownerManifest.authorized_agents - : [] - ).some((entry) => typeof entry?.url === 'string' && canonicalizeAgentUrl(entry.url) === agentCanonical); - if (listed) ownerAgentLeg.ok = true; - else ownerAgentLeg.failure = 'agent_not_declared_by_owner'; - } - - // ── Leg 4: host authorization (the enforcement-grade leg) ───────────────── const hostLeg: SupplyPathLegs['host_authorization'] = { ok: false }; - if (!input.hostManifest) { - hostLeg.failure = 'manifest_not_found'; - } else { - const agentEntries = (Array.isArray(input.hostManifest.authorized_agents) - ? input.hostManifest.authorized_agents - : [] - ).filter((entry) => typeof entry?.url === 'string' && canonicalizeAgentUrl(entry.url) === agentCanonical); - if (agentEntries.length === 0) { - hostLeg.failure = 'no_agent_entry'; - } else { - const collectionCovered = agentEntries.filter((entry) => - entryCoversCollection(entry, ownerDomain, collectionId)); - if (collectionCovered.length === 0) { - hostLeg.failure = 'collection_scope_mismatch'; - hostLeg.detail = - `${agentEntries.length} entr${agentEntries.length === 1 ? 'y' : 'ies'} for this agent; ` + - `none has a collections selector covering ${ownerDomain}` + - (collectionId !== undefined ? `:${collectionId}` : ''); - } else { - // Property scope: only checkable when the owner's carriage map - // names host property_ids. When it does, at least one claimed - // property must be reachable by the entry's scope. - const claimed = carriageLeg.property_ids_matched ?? []; - const matched = collectionCovered.find((entry) => { - if (claimed.length === 0) return true; - const scope = entryPropertyScope(entry, hostDomain, input.hostManifest!); - if (scope === null) return true; // publisher-wide - return claimed.some((id) => scope.has(id)); + if (!hostManifest) hostLeg.failure = 'manifest_not_found'; + else { + const agents = agent ? records(hostManifest.authorized_agents).filter(e => agentIdentity(e.url) === agent) : []; + const covered = + owner && !isRevoked('host', owner) && !(host && isRevoked('owner', host)) + ? agents.filter(e => e.collections !== undefined && coversCollection(e, owner, input.collectionId)) + : []; + if (!agents.length) hostLeg.failure = 'no_agent_entry'; + else if (!covered.length) hostLeg.failure = 'collection_scope_mismatch'; + else { + const matched = + host && + !isRevoked('host', host) && + carriage.ok && + collectionLeg.ok && + covered.find(e => { + if (unsupportedConstraints(e).length) return false; + const scope = propertyScope(e, host, hostManifest); + const required = input.requiredHostPropertyIds ?? claimed; + if ( + input.requiredHostPropertyIds && + (!required.length || (claimed.length && !required.every(id => claimed.includes(id)))) + ) + return false; + // With identifier-only owner carriage, the host must affirmatively + // bind the collection to its own property scope. + if (!claimed.length) return false; + return required.length ? required.every(id => scope.has(id)) : scope.size > 0; }); - if (matched) { - hostLeg.ok = true; - hostLeg.matched_entry = { - url: matched.url ?? input.agentUrl, - authorization_type: matched.authorization_type, - delegation_type: (matched as { delegation_type?: string }).delegation_type, - collections: Array.isArray(matched.collections) - ? matched.collections.map((selector) => ({ - publisher_domain: String(selector.publisher_domain), - ...(Array.isArray(selector.collection_ids) - ? { collection_ids: selector.collection_ids.filter((id): id is string => typeof id === 'string') } - : {}), - })) - : undefined, - }; - } else { - hostLeg.failure = 'property_scope_mismatch'; - hostLeg.detail = - `collection scope matches, but no entry authorizes the carried host propert` + - `${claimed.length === 1 ? 'y' : 'ies'} [${claimed.join(', ')}]`; - } + if (!matched) { + const constraints = [...new Set(covered.flatMap(unsupportedConstraints))].sort(); + hostLeg.failure = covered.every(e => unsupportedConstraints(e).length) + ? 'unsupported_constraints' + : 'property_scope_mismatch'; + hostLeg.detail = constraints.length + ? `Unevaluated grant fields: ${constraints.join(', ')}` + : 'No grant proves the complete carried property scope'; + } else { + hostLeg.ok = true; + hostLeg.matched_entry = { + url: String(matched.url), + ...(typeof matched.authorization_type === 'string' ? { authorization_type: matched.authorization_type } : {}), + ...(typeof matched.delegation_type === 'string' ? { delegation_type: matched.delegation_type } : {}), + ...(Array.isArray(matched.collections) + ? { + collections: matched.collections.map(s => ({ + publisher_domain: String(s.publisher_domain), + ...(s.collection_ids !== undefined ? { collection_ids: [...s.collection_ids] } : {}), + })), + } + : {}), + }; } } } - - // ── Leg 5: inventorypartnerdomain (interim host-side evidence) ──────────── - const partnerLeg: SupplyPathLegs['inventory_partner_domain'] = { ok: false }; - if (input.hostInventoryPartnerDomains === null) { - partnerLeg.failure = 'ads_txt_unavailable'; - } else if (input.hostInventoryPartnerDomains.includes(ownerDomain)) { - partnerLeg.ok = true; - } else { - partnerLeg.failure = 'not_declared'; - } - - // ── State ladder ─────────────────────────────────────────────────────────── - // verified requires BOTH sides: the host grant's collection selector is a - // reference into the owner's adagents.json, and a dangling reference - // (owner file missing or collection undeclared) fails closed like any - // other cross-file mismatch. - let state: SupplyPathState = 'unverified'; - if (hostLeg.ok && ownerCollectionLeg.ok) { - state = 'verified_owner_sold'; - } else if (partnerLeg.ok && ownerAgentLeg.ok && ownerCollectionLeg.ok) { - state = 'host_delegated'; - } else if (ownerCollectionLeg.ok && (carriageLeg.ok || carriageLeg.failure === 'property_ids_unresolved' || carriageLeg.failure === 'host_manifest_not_found')) { - // The owner asserts carriage on this host, with no host-side - // corroboration. Discovery-only. - state = 'owner_attested'; - } - + const partners = input.hostInventoryPartnerDomainsByFile + ? inventoryPartnersForInput(input) + : input.hostInventoryPartnerDomains; + const partner: SupplyPathLegs['inventory_partner_domain'] = + input.inventoryPartnerDomainEvaluated === false + ? { ok: false, failure: 'not_evaluated' } + : partners === null + ? { ok: false, failure: 'ads_txt_unavailable' } + : owner && partners.some(d => domain(d) === owner) + ? { ok: true } + : { ok: false, failure: 'not_declared' }; + const attested = collectionLeg.ok && entries.length > 0; + const state = + hostLeg.ok && collectionLeg.ok && carriage.ok + ? 'verified_owner_sold' + : partner.ok && + ownerAgent.ok && + collectionLeg.ok && + !((owner && isRevoked('host', owner)) || (host && isRevoked('host', host))) && + !(host && isRevoked('owner', host)) + ? 'host_delegated' + : attested + ? 'owner_attested' + : 'unverified'; return { + semantics_version: '1', + ...(collectionLeg.ok ? { resolved_collection_id: input.collectionId } : {}), state, legs: { - owner_collection_declared: ownerCollectionLeg, - owner_distribution_carriage: carriageLeg, - owner_agent_declared: ownerAgentLeg, + owner_collection_declared: collectionLeg, + owner_distribution_carriage: carriage, + owner_agent_declared: ownerAgent, host_authorization: hostLeg, - inventory_partner_domain: partnerLeg, + inventory_partner_domain: partner, }, }; } + +/** Choose applicable IAB files from the carried host properties, never from catalog channel identifiers. */ +export function supplyPathAdsTxtPolicy(input: SupplyPathInput): { + files: Array<'ads.txt' | 'app-ads.txt'>; + requireAll: boolean; +} { + const collectionIds = new Set( + records(input.ownerManifest?.collections) + .filter(c => input.collectionId === undefined || c.collection_id === input.collectionId) + .flatMap(c => + records(c.distribution) + .filter(d => domain(d.publisher_domain) === domain(input.hostDomain)) + .flatMap(d => (strings(d.property_ids) ? d.property_ids : [])) + ) + ); + const requested = input.requiredHostPropertyIds ? new Set(input.requiredHostPropertyIds) : collectionIds; + const properties = records(input.hostManifest?.properties).filter(p => requested.has(String(p.property_id))); + const files = new Set<'ads.txt' | 'app-ads.txt'>(); + for (const property of properties) { + if (property.property_type === 'website') files.add('ads.txt'); + if (property.property_type === 'mobile_app' || property.property_type === 'ctv_app') files.add('app-ads.txt'); + } + // Unknown surfaces have no applicable typed evidence. Without a host + // manifest, the canonical interim path can only inspect domain-level files. + if (!input.hostManifest || !properties.length) return { files: ['app-ads.txt', 'ads.txt'], requireAll: false }; + return { files: [...files].sort(), requireAll: true }; +} + +export function combineInventoryPartnerDomains(contents: Array, requireAll: boolean): string[] | null { + if (!contents.length || (requireAll && contents.some(c => c === null))) return null; + const fetched = contents.filter((c): c is string => c !== null).map(parseInventoryPartnerDomains); + if (!fetched.length) return null; + return requireAll + ? fetched[0]!.filter(d => fetched.every(domains => domains.includes(d))) + : [...new Set(fetched.flat())]; +} + +/** Select parsed IAB evidence for one complete path; called after bulk inquiry expansion. */ +export function inventoryPartnersForInput(input: SupplyPathInput): string[] | null { + const policy = supplyPathAdsTxtPolicy(input); + const values = policy.files.map(file => input.hostInventoryPartnerDomainsByFile?.[file] ?? null); + if (!values.length || (policy.requireAll && values.some(value => value === null))) return null; + const fetched = values.filter((value): value is string[] => value !== null); + if (!fetched.length) return null; + return policy.requireAll + ? fetched[0]!.filter(domain => fetched.every(value => value.includes(domain))) + : [...new Set(fetched.flat())]; +} + +export type { SupplyPathInput, SupplyPathLegs, SupplyPathManifest, SupplyPathVerdict } from './supply-path-contract.js'; diff --git a/server/tests/unit/services/supply-path-evidence.test.ts b/server/tests/unit/services/supply-path-evidence.test.ts new file mode 100644 index 00000000000..64b27ba7fc1 --- /dev/null +++ b/server/tests/unit/services/supply-path-evidence.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const safeFetch = vi.hoisted(() => vi.fn()); +vi.mock('../../../src/utils/url-security.js', () => ({ safeFetchAxiosLike: safeFetch })); +import { fetchHostInventoryPartnerDomains } from '../../../src/services/supply-path-evidence.js'; +const corpus = JSON.parse(readFileSync(new URL('../../../../static/compliance/source/test-vectors/supply-path/vectors.json', import.meta.url), 'utf8')); +const input = corpus.vectors[0].input; +describe('registry supply-path IAB fetching', () => { + beforeEach(() => safeFetch.mockReset()); + it('fetches only applicable evidence with bounded bytes/time and no redirects', async () => { + safeFetch.mockResolvedValue({ status: 200, data: Buffer.from('inventorypartnerdomain=channel-owner.example'), headers: { 'content-type': 'text/plain; charset=utf-8' } }); + expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': ['channel-owner.example'] }); + expect(safeFetch).toHaveBeenCalledTimes(1); + expect(safeFetch).toHaveBeenCalledWith('https://hoststream.example/app-ads.txt', expect.objectContaining({ maxRedirects: 0, maxResponseBytes: 256 * 1024, timeoutMs: 5000 })); + }); + it('distinguishes missing from failed and refuses mislabeled evidence', async () => { + safeFetch.mockResolvedValueOnce({ status: 404, headers: {}, data: Buffer.from('missing') }); + expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': [] }); + safeFetch.mockRejectedValueOnce(new Error('network unavailable')); + expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': null }); + safeFetch.mockResolvedValueOnce({ status: 200, headers: { 'content-type': 'text/html' }, data: Buffer.from('inventorypartnerdomain=channel-owner.example') }); + expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': null }); + }); +}); diff --git a/server/tests/unit/services/supply-path-golden.test.ts b/server/tests/unit/services/supply-path-golden.test.ts new file mode 100644 index 00000000000..0a7af6d278f --- /dev/null +++ b/server/tests/unit/services/supply-path-golden.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { verifySupplyPath, parseInventoryPartnerDomains, supplyPathAdsTxtPolicy, combineInventoryPartnerDomains } from '../../../src/services/supply-path-verifier.js'; + +const corpus = JSON.parse(readFileSync(new URL('../../../../static/compliance/source/test-vectors/supply-path/vectors.json', import.meta.url), 'utf8')); +// The normative projection includes every leg's boolean and failure code. +// Human diagnostics and ancillary evidence are intentionally additive. +function project(result: ReturnType) { + return { ...(result.resolved_collection_id ? { resolved_collection_id: result.resolved_collection_id } : {}), semantics_version: result.semantics_version, state: result.state, legs: Object.fromEntries(Object.entries(result.legs).map(([key, leg]) => [key, { ok: leg.ok, ...(leg.failure ? { failure: leg.failure } : {}) }])) }; +} +describe('canonical shared supply-path vectors', () => { + for (const vector of corpus.ads_txt_policy_vectors) it(vector.id, () => expect(supplyPathAdsTxtPolicy(vector.input)).toEqual(vector.expected)); + for (const vector of corpus.inventory_partner_combination_vectors) it(vector.id, () => expect(combineInventoryPartnerDomains(vector.contents, vector.requireAll)).toEqual(vector.expected)); + for (const vector of corpus.vectors) it(vector.id, () => expect(project(verifySupplyPath(vector.input))).toEqual(vector.expected)); + for (const vector of corpus.inventory_partner_domain_vectors) it(vector.id, () => expect(parseInventoryPartnerDomains(vector.text)).toEqual(vector.expected)); +}); diff --git a/server/tests/unit/services/supply-path-verifier.test.ts b/server/tests/unit/services/supply-path-verifier.test.ts index ecb5b7f2ab3..2763df62a44 100644 --- a/server/tests/unit/services/supply-path-verifier.test.ts +++ b/server/tests/unit/services/supply-path-verifier.test.ts @@ -76,7 +76,7 @@ describe('verifySupplyPath', () => { expect(verdict.state).toBe('verified_owner_sold'); }); - it('accepts an unconstrained host grant as covering the collection', () => { + it('requires an affirmative owner collection selector on the host grant', () => { const verdict = verifySupplyPath(input({ hostManifest: hostManifest({ authorized_agents: [{ @@ -86,7 +86,8 @@ describe('verifySupplyPath', () => { }], }), })); - expect(verdict.state).toBe('verified_owner_sold'); + expect(verdict.state).toBe('owner_attested'); + expect(verdict.legs.host_authorization.failure).toBe('collection_scope_mismatch'); }); it('diagnoses collection_scope_mismatch when the host names a different owner', () => { diff --git a/static/compliance/source/test-vectors/supply-path/README.md b/static/compliance/source/test-vectors/supply-path/README.md new file mode 100644 index 00000000000..426dda3953c --- /dev/null +++ b/static/compliance/source/test-vectors/supply-path/README.md @@ -0,0 +1,21 @@ +# Supply-path verification semantics version 1 + +`vectors.json` is the canonical shared corpus for `POST /api/registry/verify/supply-path` and authoritative client evaluators. The registry's `server/tests/unit/services/supply-path-golden.test.ts` consumes it directly. SDKs should vendor this file byte-for-byte at a reviewed commit and record its SHA-256; do not rewrite expected verdicts independently. + +Inputs contain owner/host manifests (or `null` for unavailable), publisher domains, the agent URL, an optional collection ID, and parsed host inventory partner domains (`null` means unavailable; `[]` means fetched and absent). `requiredHostPropertyIds`, when present, is an additional authoritative product scope resolved against the host manifest; the registry endpoint does not accept product selectors. + +The `expected` projection is normative: semantics version, state, `resolved_collection_id` when a collection was declared, and all five legs' `ok` and `failure` fields. Human-readable `detail`, matched entries, and ancillary evidence fields are additive and excluded from this projection. The separate parser vectors pin comment, whitespace, case and domain handling for `INVENTORYPARTNERDOMAIN`. + +Semantics version 1 requires an affirmative host collection selector, complete carriage property resolution, and an applicable property authorization discriminator. Omitted `collection_ids` is a bulk authorization grant; an absent `collections` constraint is not an affirmative host statement about an owner's carriage. Identifier-only carriage is discovery evidence and cannot prove a host property binding. Unevaluated country, time and placement qualifiers fail closed. Revocations take precedence over grants. Domain-level queries select the strongest complete individual collection path, never a join across different collections. Agent URL identity preserves case-sensitive paths and query values. + +`host_delegated` retains its weaker documented meaning: host inventory partner evidence plus the owner's agent and collection declarations. It does not require a host adagents manifest, establish concrete property coverage, or override an explicit revocation. Buyers must apply their own policy to this state. + +The endpoint exposes `semantics_version: "1"` so consumers can refuse older implementations that have not adopted these fail-closed rules. This version identifies evaluator semantics, not manifest freshness or protocol version. + +`inventoryPartnerDomainEvaluated: false` produces `not_evaluated`, for a caller that skips IAB fetching once the strongest host leg succeeds. `ads_txt_policy_vectors` pin the applicable files (websites: ads.txt; apps: app-ads.txt; mixed surfaces: both), and `inventory_partner_combination_vectors` require agreement in all applicable files. A missing host surface permits the documented domain-level interim evidence, which never proves product property coverage. Fetchers represent a 404 as an empty document and a transport failure as null. The registry uses cached manifests; `checked_at` does not establish origin freshness. + +Malformed present collection selectors invalidate that grant, including when another selector in the same grant is valid. Independent grants remain alternatives. Unknown authorization fields and unevaluated country/time/placement constraints return `unsupported_constraints`; callers cannot widen them by ignoring an unfamiliar field. The evaluator bounds input at 1,024 entries per array, 8,192 characters per string, 32,768 values total, and a conservative selector-work estimate of four million; every leg returns `evaluation_limit_exceeded` when exceeded. This failure is not evidence of publisher absence. + +Authoritative consumers must retain previously observed revocations per publisher authority for seven days from first observation, including across stale refreshes, and pass the effective revocations to the evaluator. The corpus tests the effective evidence; persistent store choice and fetch provenance belong to the consuming client. Explicit publisher-origin pointers delegate retrieval, not authority to unrelated publisher namespaces. + +`hostInventoryPartnerDomainsByFile` retains parsed evidence separately for ads.txt and app-ads.txt. When supplied, it takes precedence over the legacy combined `hostInventoryPartnerDomains` input. Bulk inquiries select applicable files independently for each collection before choosing the strongest complete path. `heldRevocations` supplies authority-scoped owner/host holds even when a current manifest is null. Nested collection and publisher-property selector qualifiers fail closed, just like top-level qualifiers. diff --git a/static/compliance/source/test-vectors/supply-path/vectors.json b/static/compliance/source/test-vectors/supply-path/vectors.json new file mode 100644 index 00000000000..fed7646f054 --- /dev/null +++ b/static/compliance/source/test-vectors/supply-path/vectors.json @@ -0,0 +1,6014 @@ +{ + "semantics_version": "1", + "description": "Canonical owner-sold supply-path verdicts and machine-readable leg diagnostics. Human-readable detail and ancillary evidence fields are not compared.", + "vectors": [ + { + "id": "exact-owner-collection-and-carried-property", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "bulk-authorization-selector", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example" + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "absent-host-collection-selector", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-1", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": null + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-2", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": {} + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-3", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-4", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": "all" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-5", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": null + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-6", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-collection-selector-7", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news", + 42 + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "wrong-owner-namespace", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "other-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "wrong-collection-id", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "other" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-property-scope-mismatch", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "other" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "partially-dangling-carriage", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv", + "missing" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "property_ids_unresolved" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "entirely-dangling-carriage", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "property_ids_unresolved" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "identifier-only-carriage-is-not-property-proof", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "identifiers": [ + { + "type": "publisher_channel_id", + "value": "retro" + } + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "distribution-with-both-identifiers-and-property-ids", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ], + "identifiers": [ + { + "type": "publisher_channel_id", + "value": "retro" + } + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "distribution-with-neither", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example" + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "property_ids_unresolved" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unevaluated-countries", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "countries": "malformed" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unevaluated-effective_from", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "effective_from": "malformed" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unevaluated-effective_until", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "effective_until": "malformed" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unevaluated-placement_ids", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "placement_ids": "malformed" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unevaluated-placement_tags", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "placement_tags": "malformed" + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unknown-or-non-property-grant-future", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "future", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unknown-or-non-property-grant-signal_ids", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "signal_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unknown-or-non-property-grant-None", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": null, + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-revokes-owner", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ], + "revoked_publisher_domains": [ + "channel-owner.example" + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-manifest-missing", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": null + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "host_manifest_not_found" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "manifest_not_found" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "owner-manifest-missing", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": null, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "manifest_not_found" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "collection_leg_failed" + }, + "owner_agent_declared": { + "ok": false, + "failure": "manifest_not_found" + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + } + } + }, + { + "id": "collection-not-declared", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "unknown", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "collection_not_declared" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "collection_leg_failed" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + } + } + }, + { + "id": "domain-level-complete-path", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "domain-level-disjoint-collection-legs", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + }, + { + "collection_id": "other", + "name": "Other", + "kind": "channel", + "distribution": [] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "other" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-delegated", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "host_delegated", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-delegated-without-host-adagents", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": null + }, + "expected": { + "semantics_version": "1", + "state": "host_delegated", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "host_manifest_not_found" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "manifest_not_found" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "partner-does-not-bind-undeclared-agent", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "absent-inventory-partner", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "not_declared" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "case-sensitive-agent-path", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example/Sales", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example/sales", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "invalid-agent-identities-never-match", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": " ", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": " ", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "concrete-product-property-mismatch", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requiredHostPropertyIds": [ + "other" + ] + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "concrete-product-property-match", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requiredHostPropertyIds": [ + "hoststream_ctv" + ] + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "unknown-narrowing-extension", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "future_scope": {} + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "owner-revokes-host", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ], + "revoked_publisher_domains": [ + "hoststream.example" + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "owner-revokes-owner", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ], + "revoked_publisher_domains": [ + "channel-owner.example" + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "publisher_revoked" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "collection_leg_failed" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + } + } + }, + { + "id": "host-revokes-self", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ], + "revoked_publisher_domains": [ + "hoststream.example" + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-revocation", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ], + "revoked_publisher_domains": [ + {} + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "collection_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "iab-evidence-not-evaluated", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "inventoryPartnerDomainEvaluated": false + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "not_evaluated" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "bounded-nested-evaluation", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {} + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "evaluation_limit_exceeded" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "evaluation_limit_exceeded" + }, + "owner_agent_declared": { + "ok": false, + "failure": "evaluation_limit_exceeded" + }, + "host_authorization": { + "ok": false, + "failure": "evaluation_limit_exceeded" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "evaluation_limit_exceeded" + } + } + } + }, + { + "id": "host-delegated-without-owner-carriage", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "host_delegated", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "no_distribution_for_host" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-self-revocation-blocks-interim-evidence", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [], + "revoked_publisher_domains": [ + "hoststream.example" + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "nested-collection-qualifier", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ], + "countries": [ + "US" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "nested-property-qualifier", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domain": "hoststream.example", + "selection_type": "all", + "countries": [ + "US" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "unsupported_constraints" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "held-revocation-with-host-unavailable", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": null, + "heldRevocations": { + "host": [ + "channel-owner.example" + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "host_manifest_not_found" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "manifest_not_found" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "domain-level-iab-per-complete-collection", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + }, + { + "collection_id": "web_channel", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "web" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + }, + { + "property_id": "web", + "name": "HostStream CTV", + "property_type": "website", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "hostInventoryPartnerDomainsByFile": { + "ads.txt": [ + "channel-owner.example" + ], + "app-ads.txt": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "host_delegated", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "web_channel" + } + } + ], + "ads_txt_policy_vectors": [ + { + "id": "ctv-needs-app-ads", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "files": [ + "app-ads.txt" + ], + "requireAll": true + } + }, + { + "id": "website-needs-ads", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "website", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "files": [ + "ads.txt" + ], + "requireAll": true + } + }, + { + "id": "mixed-surfaces-need-both", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv", + "web" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + }, + { + "property_id": "web", + "name": "HostStream CTV", + "property_type": "website", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "files": [ + "ads.txt", + "app-ads.txt" + ], + "requireAll": true + } + }, + { + "id": "missing-host-fallback-files", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": null + }, + "expected": { + "files": [ + "app-ads.txt", + "ads.txt" + ], + "requireAll": false + } + }, + { + "id": "dooh-has-no-iab-file", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "dooh", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "files": [], + "requireAll": true + } + } + ], + "inventory_partner_combination_vectors": [ + { + "id": "all-applicable-required", + "contents": [ + "inventorypartnerdomain=channel-owner.example", + "" + ], + "requireAll": true, + "expected": [] + }, + { + "id": "all-applicable-unavailable", + "contents": [ + "inventorypartnerdomain=channel-owner.example", + null + ], + "requireAll": true, + "expected": null + }, + { + "id": "unknown-surface-union", + "contents": [ + "inventorypartnerdomain=channel-owner.example", + null + ], + "requireAll": false, + "expected": [ + "channel-owner.example" + ] + }, + { + "id": "all-applicable-agree", + "contents": [ + "inventorypartnerdomain=channel-owner.example", + "INVENTORYPARTNERDOMAIN=channel-owner.example" + ], + "requireAll": true, + "expected": [ + "channel-owner.example" + ] + } + ], + "inventory_partner_domain_vectors": [ + { + "id": "case-whitespace-comments", + "text": "INVENTORYPARTNERDOMAIN = Channel-Owner.example # deal\n# inventorypartnerdomain=commented.example\ninventorypartnerdomain=other.example\ninventorypartnerdomain=not a domain", + "expected": [ + "channel-owner.example", + "other.example" + ] + } + ] +} diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index a8e47a531bc..f6718bfa251 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -8291,6 +8291,7 @@ paths: collection_id: type: string minLength: 1 + maxLength: 256 description: Owner-assigned collection ID. Omit to verify the path at domain level (bulk deals). required: - owner_domain @@ -8304,6 +8305,14 @@ paths: schema: type: object properties: + semantics_version: + type: string + enum: + - "1" + description: Canonical supply-path evaluator semantics; pinned by the shared supply-path golden vectors. + resolved_collection_id: + type: string + description: Concrete collection chosen for the complete path, including domain-level inquiries. state: type: string enum: @@ -8415,6 +8424,7 @@ paths: checked_at: type: string required: + - semantics_version - state - legs - owner_domain From 6f94e9b8a1d35f9c29873c244adcbd8a6d4d0ab0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:55:54 +0000 Subject: [PATCH 02/15] fix(registry): preserve per-collection evidence fallback in bulk inquiries --- server/src/routes/registry-api.ts | 2 +- server/src/services/supply-path-verifier.ts | 9 ++ .../services/supply-path-evidence.test.ts | 8 ++ .../test-vectors/supply-path/vectors.json | 88 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index f20cfa20fd6..742b20c5f24 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -10682,7 +10682,7 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { }); if (verdict.legs.host_authorization.ok) { verdict.legs.inventory_partner_domain = { ok: false, failure: 'not_evaluated' }; - } else { + } else if (verdict.legs.host_authorization.failure !== 'evaluation_limit_exceeded') { verdict = verifySupplyPath({ ownerDomain, hostDomain, diff --git a/server/src/services/supply-path-verifier.ts b/server/src/services/supply-path-verifier.ts index 0ecf75afe70..c11fff9b418 100644 --- a/server/src/services/supply-path-verifier.ts +++ b/server/src/services/supply-path-verifier.ts @@ -395,6 +395,15 @@ export function supplyPathAdsTxtPolicy(input: SupplyPathInput): { files: Array<'ads.txt' | 'app-ads.txt'>; requireAll: boolean; } { + if (input.collectionId === undefined) { + const candidates = records(input.ownerManifest?.collections).filter( + c => typeof c.collection_id === 'string' && c.collection_id.length > 0 + ); + if (candidates.length) { + const policies = candidates.map(c => supplyPathAdsTxtPolicy({ ...input, collectionId: String(c.collection_id) })); + return { files: [...new Set(policies.flatMap(policy => policy.files))].sort(), requireAll: false }; + } + } const collectionIds = new Set( records(input.ownerManifest?.collections) .filter(c => input.collectionId === undefined || c.collection_id === input.collectionId) diff --git a/server/tests/unit/services/supply-path-evidence.test.ts b/server/tests/unit/services/supply-path-evidence.test.ts index 64b27ba7fc1..1ba02ed6c79 100644 --- a/server/tests/unit/services/supply-path-evidence.test.ts +++ b/server/tests/unit/services/supply-path-evidence.test.ts @@ -22,3 +22,11 @@ describe('registry supply-path IAB fetching', () => { expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': null }); }); }); + +it('includes identifier-only fallback files when another bulk candidate is a website', async () => { + const fixture = corpus.ads_txt_policy_vectors.find((v: { id: string }) => v.id === 'bulk-fetch-includes-untyped-collection-fallback').input; + safeFetch.mockReset(); + safeFetch.mockImplementation(async (url: string) => ({ status: 200, headers: { 'content-type': 'text/plain' }, data: Buffer.from(url.endsWith('/app-ads.txt') ? 'inventorypartnerdomain=channel-owner.example' : '') })); + expect(await fetchHostInventoryPartnerDomains(fixture)).toEqual({ 'ads.txt': [], 'app-ads.txt': ['channel-owner.example'] }); + expect(safeFetch).toHaveBeenCalledTimes(2); +}); diff --git a/static/compliance/source/test-vectors/supply-path/vectors.json b/static/compliance/source/test-vectors/supply-path/vectors.json index fed7646f054..02122e9749f 100644 --- a/static/compliance/source/test-vectors/supply-path/vectors.json +++ b/static/compliance/source/test-vectors/supply-path/vectors.json @@ -5957,6 +5957,94 @@ "files": [], "requireAll": true } + }, + { + "id": "bulk-fetch-includes-untyped-collection-fallback", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "identifiers": [ + { + "type": "publisher_channel_id", + "value": "retro" + } + ] + } + ] + }, + { + "collection_id": "web_channel", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "web" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + }, + { + "property_id": "web", + "name": "HostStream CTV", + "property_type": "website", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "hostInventoryPartnerDomainsByFile": { + "ads.txt": [ + "channel-owner.example" + ], + "app-ads.txt": [] + } + }, + "expected": { + "files": [ + "ads.txt", + "app-ads.txt" + ], + "requireAll": false + } } ], "inventory_partner_combination_vectors": [ From 7b2d3945dd1ee46ad6765ffabd18bfa09d81ff25 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 15:43:48 +0000 Subject: [PATCH 03/15] fix(registry): persist supply-path authority and bind shared publisher evidence --- .changeset/fail-closed-supply-path-vectors.md | 4 + docs/governance/property/managed-networks.mdx | 6 +- .../collections-and-installments.mdx | 6 + docs/reference/test-vectors/index.mdx | 1 + .../scripts/approve-supply-path-authority.ts | 21 + .../591_supply_path_manifest_provenance.sql | 8 + server/src/db/publisher-db.ts | 27 +- server/src/routes/registry-api.ts | 51 +- .../services/supply-path-authority-state.ts | 69 + server/src/services/supply-path-contract.ts | 4 + server/src/services/supply-path-evidence.ts | 12 +- server/src/services/supply-path-snapshot.ts | 26 + server/src/services/supply-path-verifier.ts | 44 +- server/src/utils/url-security.ts | 34 +- .../registry-crawler-cache.test.ts | 38 + .../supply-path-authority-state.test.ts | 50 + .../services/supply-path-evidence.test.ts | 13 +- .../services/supply-path-snapshot.test.ts | 32 + .../tests/unit/url-security-redirects.test.ts | 16 + .../source/test-vectors/supply-path/README.md | 2 + .../test-vectors/supply-path/vectors.json | 1946 +++++++++++++++++ static/openapi/registry.yaml | 22 + static/schemas/source/adagents.json | 2 +- static/schemas/source/core/collection.json | 12 +- 24 files changed, 2392 insertions(+), 54 deletions(-) create mode 100644 server/scripts/approve-supply-path-authority.ts create mode 100644 server/src/db/migrations/591_supply_path_manifest_provenance.sql create mode 100644 server/src/services/supply-path-authority-state.ts create mode 100644 server/src/services/supply-path-snapshot.ts create mode 100644 server/tests/unit/services/supply-path-authority-state.test.ts create mode 100644 server/tests/unit/services/supply-path-snapshot.test.ts diff --git a/.changeset/fail-closed-supply-path-vectors.md b/.changeset/fail-closed-supply-path-vectors.md index d8ae731ad54..2c3490e8dbb 100644 --- a/.changeset/fail-closed-supply-path-vectors.md +++ b/.changeset/fail-closed-supply-path-vectors.md @@ -3,3 +3,7 @@ --- Publish canonical supply-path verification golden vectors and an explicit evaluator semantics version. Clarify fail-closed owner-sold carriage verification for affirmative collection constraints, dangling property references, unknown or unevaluated authorization constraints, revocation precedence, agent URL identity and independent domain-level paths. + +Retain authority-scoped revocations and authoritative-location pins in persistent registry state. Refuse stale or non-authoritative cache provenance and require explicit publisher attribution in cross-origin shared catalogs. Expose cache observation times and resolved URLs. + +Add publisher_domain attribution to shared collection declarations and require it for cross-origin owner evidence. Preserve successful-fetch provenance through failed crawls and provide an operator command for independently confirmed authority migrations. diff --git a/docs/governance/property/managed-networks.mdx b/docs/governance/property/managed-networks.mdx index a524be30390..51d71bfcb97 100644 --- a/docs/governance/property/managed-networks.mdx +++ b/docs/governance/property/managed-networks.mdx @@ -640,13 +640,13 @@ Validator behavior: - Validators MUST treat any publisher domain in `revoked_publisher_domains[]` as no-longer-authorized, **regardless** of whether the same domain still appears in any `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain` elsewhere in the file. The revocation list takes precedence — this lets a network ship a revocation without redeploying every selector entry. - Validators with a cached prior version of the file MUST apply the revocation as of the validator's `last_updated` adoption time, not the entry's `revoked_at` (which can be in the past for catch-up entries). -- **Append-only durability on the validator side.** A `publisher_domain` that a validator has ever observed in a `revoked_publisher_domains[]` entry MUST be held as revoked for **7 days from the earliest `revoked_at` the validator has ever observed for that domain**, even if the entry is missing from a subsequent fetch. Durability keys on `publisher_domain` alone — keying on the `(publisher_domain, revoked_at)` tuple would let an attacker re-emit the same domain with a slightly mutated `revoked_at` (one second earlier, say) and present a "fresh" tuple the validator has never observed, bypassing the hold. Use `revoked_at` only to set the clock origin, not to identify the durability key. This places durability on the validator's cached state rather than on the network's retention SHOULD, and closes the rollback gap: an attacker who re-serves a stale file with `revoked_publisher_domains[]` removed and `last_updated` advanced cannot reinstate a previously-revoked publisher inside the validator's 7-day window. New revocations seen for the first time in the current fetch (no prior observation) start the 7-day clock from now. -- **Persistence across restart.** Validators SHOULD persist observed revocation entries (`{publisher_domain, earliest_revoked_at, first_observed_at}`) to durable storage. An in-memory-only validator that restarts inside the 7-day window MAY accept a rolled-back file because it has no record of the prior observation; operators SHOULD treat this as a known limitation and either persist the index or accept the residual risk. The 7-day window is measured from the *validator's* first observation, not from process start. +- **Append-only durability on the validator side.** A `publisher_domain` that a validator has ever observed in a `revoked_publisher_domains[]` entry MUST be held as revoked for **7 days from the validator's first observation of that domain's revocation**, even if the entry is missing from a subsequent fetch. Durability keys on `publisher_domain` within the publisher authority — keying on the `(publisher_domain, revoked_at)` tuple would let an attacker re-emit the same domain with a slightly mutated `revoked_at` (one second earlier, say) and present a "fresh" tuple the validator has never observed, bypassing the hold. Record `revoked_at` for evidence; it neither identifies the durability key nor resets the first-observation clock. This places durability on the validator's cached state rather than on the network's retention SHOULD, and closes the rollback gap: an attacker who re-serves a stale file with `revoked_publisher_domains[]` removed and `last_updated` advanced cannot reinstate a previously-revoked publisher inside the validator's 7-day window. New revocations seen for the first time in the current fetch (no prior observation) start the 7-day clock from now. +- **Persistence across restart.** Validators SHOULD persist observed revocation entries (`{authority, publisher_domain, revoked_at, first_observed_at}`) to durable storage. An in-memory-only validator that restarts inside the 7-day window MAY accept a rolled-back file because it has no record of the prior observation; operators SHOULD treat this as a known limitation and either persist the index or accept the residual risk. The 7-day window is measured from the *validator's* first observation, not from process start. - The network SHOULD retain each `revoked_publisher_domains[]` entry for at least 7 days after `revoked_at` so validators that didn't observe the entry during its first appearance still pick it up on the next refresh. For multi-publisher exits or routine churn under a normal commercial relationship-end, networks SHOULD use `reason: "relationship_ended"` so validator change-detection can suppress alerts on routine revocations and route the rest for review. -**Re-authorizing a previously-revoked publisher.** There is no `revoked_until` field and no un-revoke verb. To re-authorize, the network removes the entry from `revoked_publisher_domains[]` *after* the validator-side 7-day durability window has elapsed since `revoked_at`. Removing the entry sooner is a no-op on validators that observed the original revocation (they hold the revocation locally for the remainder of the window). For time-bounded compliance pulls where same-week reinstatement is operationally required, prefer running the revocation as a `reason: "compliance_violation"` and coordinating reinstatement out of band; the schema deliberately does not give the network a re-authorize-before-7-days back door, which would be the same surface as a rollback attack. +**Re-authorizing a previously-revoked publisher.** There is no `revoked_until` field and no un-revoke verb. To re-authorize, the network removes the entry from `revoked_publisher_domains[]` *after* the validator-side 7-day durability window has elapsed since the validator's first observation. Removing the entry sooner is a no-op on validators that observed the original revocation (they hold the revocation locally for the remainder of the window). For time-bounded compliance pulls where same-week reinstatement is operationally required, prefer running the revocation as a `reason: "compliance_violation"` and coordinating reinstatement out of band; the schema deliberately does not give the network a re-authorize-before-7-days back door, which would be the same surface as a rollback attack. **Extension fields on revocation entries have no normative effect.** `revoked_publisher_domains[]` items use the project-wide `additionalProperties: true` policy, but validators MUST ignore unknown fields on these entries — extensions cannot loosen revocation semantics or carry side-channel reinstatement signals. diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index 8eeafe474d4..5db5c81afe9 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -293,6 +293,12 @@ priority are not discovery or authorization data. Each seller applies those terms when constructing and pricing its products; buyers see the resulting offer, not how the participating parties divide the proceeds. +The registry admits cached manifests for supply-path legs only when their provenance is a direct publisher-origin fetch or a publisher `authoritative_location` delegation, the observation is no older than seven days, and its cache expiry has not elapsed. Manager and community discovery results need additional publisher authority proofs and are not enforcement evidence. Successful-fetch provenance is stored atomically with the manifest, separately from last-attempt diagnostics; cache rows created before this provenance was recorded must refresh before they can establish authority. Registry sources expose `owner_fetched_at`, `host_fetched_at`, and resolved URLs separately from the verdict's `checked_at`. + +Both authoritative verification and the registry retain observed revocations by authority and publisher domain for seven days from first observation; changing `revoked_at` does not restart that clock. The registry uses the existing persistent SDK document store so workers and restarts share the hold and authoritative-location pin. A changed pin rejects until an operator independently confirms the publisher migration; an operator can run `npx tsx server/scripts/approve-supply-path-authority.ts --confirm-publisher-migration` with `DATABASE_URL` after independently confirming the migration. This calls `approveSupplyPathAuthorityChange` while preserving revocations. Never run it from an automatic retry. Properties read from a cross-origin shared authoritative document must explicitly attribute their publisher. IAB evidence allows at most three exact-HTTPS-origin redirects, with bounded bytes and time per fetch. + +Collections in a cross-origin authoritative document must explicitly name their owning `publisher_domain`, just as shared host properties must identify their publisher. A domain pointing at an unrelated public catalog cannot borrow that catalog's collections, including through weaker inventory-partner evidence. Publisher-origin collection declarations may omit the field. Owner agent grants in shared cross-origin documents must also explicitly select that owner through `collections[].publisher_domain`; an absent `collections` constraint cannot authorize every member. Within that owner namespace, omitted `collection_ids` still grants all collections. + ## The installment object An installment is a specific installment of a collection. Not all installments will be known in advance — a weekly podcast may only have next week's installment scheduled, and some installments may be tentative (a playoff Game 7 depends on Game 6). diff --git a/docs/reference/test-vectors/index.mdx b/docs/reference/test-vectors/index.mdx index fa60dbf7a0f..54469cd6049 100644 --- a/docs/reference/test-vectors/index.mdx +++ b/docs/reference/test-vectors/index.mdx @@ -28,6 +28,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t | Set | What it pins | Source | CDN | |---|---|---|---| +| [`supply-path`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/supply-path) | Fail-closed owner-sold supply-path states, leg diagnostics, collection/property scope, revocations and applicable IAB evidence | `static/compliance/source/test-vectors/supply-path/` | `/compliance/latest/test-vectors/supply-path/` | | [`request-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/request-signing) | RFC 9421 request-signing profile: canonical signature base, covered components, signature params, tag namespace, alg allowlist, `adcp_use` discriminator, replay dedup, revocation, content-digest semantics, and URL canonicalization | `static/compliance/source/test-vectors/request-signing/` | `/compliance/latest/test-vectors/request-signing/` | | [`webhook-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/webhook-signing) | RFC 9421 webhook-signing profile: required covered components (content-digest mandatory — no `forbidden` opt-out), `adcp/webhook-signing/v1` tag, webhook-valid `adcp_use` set (`request-signing` plus deprecated `webhook-signing`), `webhook_signature_*` error taxonomy; shares `@target-uri` canonicalization with `request-signing` | `static/compliance/source/test-vectors/webhook-signing/` | `/compliance/latest/test-vectors/webhook-signing/` | | [`plan-hash`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) | JCS canonicalization of the `plan_hash` preimage: required-only baseline, full-optional, bookkeeping-stripped, omitted-vs-explicit-null, array-order sensitivity, `ext.trace_id` distinctness, Unicode non-normalization (RFC 8785 §3.2.5) | `static/compliance/source/test-vectors/plan-hash/` | `/compliance/latest/test-vectors/plan-hash/` | diff --git a/server/scripts/approve-supply-path-authority.ts b/server/scripts/approve-supply-path-authority.ts new file mode 100644 index 00000000000..a91e04f5879 --- /dev/null +++ b/server/scripts/approve-supply-path-authority.ts @@ -0,0 +1,21 @@ +/** Operator-only confirmation after independently verifying a publisher migration. */ +import { initializeDatabase, closeDatabase } from '../src/db/client.js'; +import { domain } from '../src/services/supply-path-input.js'; +import { approveSupplyPathAuthorityChange } from '../src/services/supply-path-authority-state.js'; + +const usage = 'Usage: npx tsx server/scripts/approve-supply-path-authority.ts --confirm-publisher-migration'; +async function main(): Promise { + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === '--help') { console.log(usage); return; } + const publisher = domain(args[0]); + if (args.length !== 3 || !publisher || args[2] !== '--confirm-publisher-migration') throw new Error(usage); + const location = new URL(args[1]!); + if (location.protocol !== 'https:' || location.username || location.password || location.hash || (location.port && location.port !== '443') || location.href.length > 8192) throw new Error('Invalid authoritative location'); + if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required'); + initializeDatabase({ connectionString: process.env.DATABASE_URL }); + try { + await approveSupplyPathAuthorityChange(publisher, location.href); + console.log(`Confirmed authoritative location for ${publisher}; existing revocation holds are preserved.`); + } finally { await closeDatabase(); } +} +main().catch(error => { console.error(error instanceof Error ? error.message : 'Authority confirmation failed'); process.exitCode = 1; }); diff --git a/server/src/db/migrations/591_supply_path_manifest_provenance.sql b/server/src/db/migrations/591_supply_path_manifest_provenance.sql new file mode 100644 index 00000000000..f6629447ccf --- /dev/null +++ b/server/src/db/migrations/591_supply_path_manifest_provenance.sql @@ -0,0 +1,8 @@ +-- Authority evidence must describe the cached manifest, not the latest failed +-- crawl attempt. Successful manifest writes populate this atomically with the +-- complete (untruncated) URL, method, observation time and expiry. Existing rows +-- deliberately remain untrusted until refreshed; last-attempt fields cannot be +-- used to reconstruct historical provenance safely. +ALTER TABLE publishers ADD COLUMN IF NOT EXISTS supply_path_provenance JSONB; +COMMENT ON COLUMN publishers.supply_path_provenance IS + 'Successful manifest fetch provenance for supply-path verification; NULL until a provenance-aware refresh'; diff --git a/server/src/db/publisher-db.ts b/server/src/db/publisher-db.ts index 26b3a11d1fc..a46f9546ad8 100644 --- a/server/src/db/publisher-db.ts +++ b/server/src/db/publisher-db.ts @@ -1230,10 +1230,12 @@ export class PublisherDatabase { `INSERT INTO publishers (domain, adagents_json, source_type, last_validated, expires_at, last_http_status, last_response_bytes, resolved_url, - discovery_method, manager_domain) - VALUES ($1, $2::jsonb, 'adagents_json', NOW(), $3, $4, $5, $6, $7, $8) + discovery_method, manager_domain, supply_path_provenance) + VALUES ($1, $2::jsonb, 'adagents_json', NOW(), $3, $4, $5, $6, $7, $8, + CASE WHEN $9::text IS NOT NULL THEN jsonb_build_object('resolved_url', $9::text, 'discovery_method', $7::text, 'fetched_at', NOW(), 'expires_at', $3::timestamptz) ELSE NULL END) ON CONFLICT (domain) DO UPDATE SET adagents_json = EXCLUDED.adagents_json, + supply_path_provenance = EXCLUDED.supply_path_provenance, source_type = 'adagents_json', last_validated = NOW(), expires_at = EXCLUDED.expires_at, @@ -1254,6 +1256,7 @@ export class PublisherDatabase { truncateResolvedUrl(input.resolvedUrl), input.discoveryMethod ?? null, input.managerDomain ?? null, + typeof input.resolvedUrl === 'string' && input.resolvedUrl.length <= 8192 ? input.resolvedUrl : null, ] ); @@ -1492,6 +1495,26 @@ export class PublisherDatabase { } } + /** Manifest-bound successful-fetch provenance; last-attempt diagnostics are never authority evidence. */ + async getSupplyPathSnapshot(domain: string): Promise { + const result = await query<{ + adagents_json: AdagentsManifest | null; + supply_path_provenance: { resolved_url?: string; discovery_method?: string; fetched_at?: string; expires_at?: string } | null; + }>( + "SELECT CASE WHEN source_type = 'adagents_json' THEN adagents_json ELSE NULL END AS adagents_json, supply_path_provenance FROM publishers WHERE domain = $1 LIMIT 1", + [canonicalizePublisherDomain(domain)], + ); + const row = result.rows[0]; + const provenance = row?.supply_path_provenance; + return { + manifest: provenance ? row?.adagents_json ?? null : null, + resolvedUrl: provenance?.resolved_url ?? null, + discoveryMethod: provenance?.discovery_method ?? null, + fetchedAt: provenance?.fetched_at ? new Date(provenance.fetched_at) : null, + expiresAt: provenance?.expires_at ? new Date(provenance.expires_at) : null, + }; + } + /** * Insert one queue row per publisher delegating to this manager via * ads.txt MANAGERDOMAIN. Idempotent: if a publisher is already queued diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 742b20c5f24..f5bf05cc440 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -1,3 +1,5 @@ +import { supplyPathSnapshotEvidence } from '../services/supply-path-snapshot.js'; +import type { SupplyPathInput } from '../services/supply-path-contract.js'; import { domain as supplyPathDomain, agentIdentity as supplyPathAgentIdentity } from '../services/supply-path-input.js'; import { isAuthoritativeComplianceRun } from '../compliance/run-publication.js'; /** @@ -2110,6 +2112,10 @@ registry.registerPath({ agent_url: z.string(), collection_id: z.string().optional(), sources: z.object({ + owner_fetched_at: z.string().datetime().nullable(), + host_fetched_at: z.string().datetime().nullable(), + owner_resolved_url: z.string().nullable(), + host_resolved_url: z.string().nullable(), owner_adagents_url: z.string(), host_adagents_url: z.string(), cached: z.boolean().openapi({ description: "true: manifests came from the registry's crawl cache. Re-derive from the URLs above for enforcement." }), @@ -10663,36 +10669,23 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { return res.status(400).json({ error: "owner_domain, host_domain, and agent_url must be valid publisher and HTTPS agent identities" }); } - const [ownerManifest, hostManifest] = await Promise.all([ - publisherDb.getCachedAdagentsJson(ownerDomain), - publisherDb.getCachedAdagentsJson(hostDomain), + const [ownerSnapshot, hostSnapshot] = await Promise.all([ + publisherDb.getSupplyPathSnapshot(ownerDomain), publisherDb.getSupplyPathSnapshot(hostDomain), ]); - - // First pass without ads.txt — the fetch is only needed when the - // enforcement-grade host leg fails and the ladder falls through to - // host_delegated. - let verdict = verifySupplyPath({ - ownerDomain, - hostDomain, - agentUrl: parsed.data.agent_url, - collectionId: parsed.data.collection_id, - ownerManifest, - hostManifest, - hostInventoryPartnerDomains: null, - }); + const [owner, host] = await Promise.all([ + supplyPathSnapshotEvidence(ownerDomain, ownerSnapshot), + supplyPathSnapshotEvidence(hostDomain, hostSnapshot), + ]); + const input: SupplyPathInput = { + ownerDomain, hostDomain, agentUrl: parsed.data.agent_url, collectionId: parsed.data.collection_id, + ownerManifest: owner.manifest, hostManifest: host.manifest, hostInventoryPartnerDomains: null, + heldRevocations: { owner: owner.held, host: host.held }, requireExplicitHostPublisherDomain: host.explicitPublisher, requireExplicitOwnerPublisherDomain: owner.explicitPublisher, + }; + let verdict = verifySupplyPath(input); if (verdict.legs.host_authorization.ok) { - verdict.legs.inventory_partner_domain = { ok: false, failure: 'not_evaluated' }; + verdict = verifySupplyPath({ ...input, inventoryPartnerDomainEvaluated: false }); } else if (verdict.legs.host_authorization.failure !== 'evaluation_limit_exceeded') { - verdict = verifySupplyPath({ - ownerDomain, - hostDomain, - agentUrl: parsed.data.agent_url, - collectionId: parsed.data.collection_id, - ownerManifest, - hostManifest, - hostInventoryPartnerDomains: null, - hostInventoryPartnerDomainsByFile: await fetchHostInventoryPartnerDomains({ ownerDomain, hostDomain, agentUrl: parsed.data.agent_url, collectionId: parsed.data.collection_id, ownerManifest, hostManifest, hostInventoryPartnerDomains: null }), - }); + verdict = verifySupplyPath({ ...input, hostInventoryPartnerDomainsByFile: await fetchHostInventoryPartnerDomains(input) }); } res.json({ @@ -10705,6 +10698,10 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { owner_adagents_url: `https://${ownerDomain}/.well-known/adagents.json`, host_adagents_url: `https://${hostDomain}/.well-known/adagents.json`, cached: true, + owner_fetched_at: ownerSnapshot.fetchedAt?.toISOString() ?? null, + host_fetched_at: hostSnapshot.fetchedAt?.toISOString() ?? null, + owner_resolved_url: ownerSnapshot.resolvedUrl, + host_resolved_url: hostSnapshot.resolvedUrl, }, checked_at: new Date().toISOString(), }); diff --git a/server/src/services/supply-path-authority-state.ts b/server/src/services/supply-path-authority-state.ts new file mode 100644 index 00000000000..0d6cad35f5b --- /dev/null +++ b/server/src/services/supply-path-authority-state.ts @@ -0,0 +1,69 @@ +import { PostgresStateStore, patchWithRetry, type AdcpStateStore } from '@adcp/sdk/server'; +import { getPool } from '../db/client.js'; +import { domain, record } from './supply-path-input.js'; +import type { SupplyPathManifest } from './supply-path-contract.js'; + +const COLLECTION = 'supply_path_authority_v1'; +const HOLD_MS = 7 * 86400000; +const persistentStore = new PostgresStateStore({ + query: async (text: string, values?: unknown[]) => { + const config = { text, values, query_timeout: 5000 }; + return getPool().query(config); + }, +}); +type HeldRevocation = { publisher_domain: string; revoked_at: string; first_observed_at: number }; +type AuthorityState = Record & { location?: string; revoked: HeldRevocation[] }; + +/** Persist observations atomically in the existing SDK document store, shared by registry workers. */ +export async function observeSupplyPathAuthority( + authority: string, manifest: SupplyPathManifest | null, location?: string, store: AdcpStateStore = persistentStore, +): Promise<{ revoked: string[] }> { + if (!domain(authority)) throw new TypeError('Invalid publisher authority'); + if (location) validateLocation(location); + const observed = manifest?.revoked_publisher_domains; + if (observed !== undefined && (!Array.isArray(observed) || observed.length > 1024)) throw new Error('Invalid publisher revocation evidence'); + const incoming: Array<{ publisher_domain: string; revoked_at: string }> = []; + for (const raw of (observed as unknown[] | undefined) ?? []) { + const publisher = domain(record(raw) ? raw.publisher_domain : raw); + const timestamp = record(raw) ? raw.revoked_at : undefined; + if (!publisher || (timestamp !== undefined && (typeof timestamp !== 'string' || !Number.isFinite(Date.parse(timestamp))))) throw new Error('Invalid publisher revocation evidence'); + incoming.push({ publisher_domain: publisher, revoked_at: typeof timestamp === 'string' ? timestamp : 'unspecified' }); + } + const now = Date.now(); + let unchanged: AuthorityState | null = null; + const state = await patchWithRetry(store, COLLECTION, authority, current => { + if (!current && !location && !incoming.length) return null; + if (current?.location && location && current.location !== location) throw new Error('Authoritative location changed; publisher migration requires independent confirmation'); + const held = new Map((current?.revoked ?? []).filter(entry => entry.first_observed_at + HOLD_MS > now).map(entry => [entry.publisher_domain, entry])); + for (const entry of incoming) { + const prior = held.get(entry.publisher_domain); + if (!prior) held.set(entry.publisher_domain, { ...entry, first_observed_at: now }); + else if (Date.parse(entry.revoked_at) < Date.parse(prior.revoked_at)) held.set(entry.publisher_domain, { ...prior, revoked_at: entry.revoked_at }); + } + if (held.size > 1024) throw new Error('Supply-path authority revocation capacity exceeded'); + const next = { ...(location || current?.location ? { location: location ?? current?.location } : {}), revoked: [...held.values()] }; + if (current && current.location === next.location && current.revoked.length === held.size && current.revoked.every(entry => { + const retained = held.get(entry.publisher_domain); + return retained?.revoked_at === entry.revoked_at && retained.first_observed_at === entry.first_observed_at; + })) { + unchanged = current; + return null; // SDK CAS helper's explicit no-write result; retain this read snapshot locally. + } + return next; + }, { maxAttempts: 5 }); + const effective = state ?? (unchanged as AuthorityState | null); + return { revoked: effective?.revoked.map(entry => entry.publisher_domain) ?? [] }; +} + +/** Operator-only migration confirmation. Never invoke as an automatic retry of a rejected observation. */ +export async function approveSupplyPathAuthorityChange(authority: string, location: string, store: AdcpStateStore = persistentStore): Promise { + if (!domain(authority)) throw new TypeError('Invalid publisher authority'); + const parsed = validateLocation(location); + await patchWithRetry(store, COLLECTION, authority, current => ({ ...(current ?? { revoked: [] }), location: parsed.href })); +} + +function validateLocation(location: string): URL { + const parsed = new URL(location); + if (location.length > 8192 || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash || (parsed.port && parsed.port !== '443')) throw new TypeError('Invalid publisher authority'); + return parsed; +} diff --git a/server/src/services/supply-path-contract.ts b/server/src/services/supply-path-contract.ts index fbd5d1ca574..a93e63d79e1 100644 --- a/server/src/services/supply-path-contract.ts +++ b/server/src/services/supply-path-contract.ts @@ -79,6 +79,10 @@ export interface SupplyPathInput { collectionId?: string; /** Optional concrete product scope, resolved from the host manifest. Empty means unresolved. */ requiredHostPropertyIds?: string[]; + /** Cross-origin authoritative documents must explicitly attribute every host property. */ + requireExplicitHostPublisherDomain?: boolean; + /** Cross-origin owner catalogs must explicitly attribute collections and owner agent grants. */ + requireExplicitOwnerPublisherDomain?: boolean; ownerManifest: SupplyPathManifest | null; hostManifest: SupplyPathManifest | null; /** diff --git a/server/src/services/supply-path-evidence.ts b/server/src/services/supply-path-evidence.ts index f71800771e3..36e4b9a5048 100644 --- a/server/src/services/supply-path-evidence.ts +++ b/server/src/services/supply-path-evidence.ts @@ -1,8 +1,8 @@ -import { safeFetchAxiosLike } from '../utils/url-security.js'; +import { safeFetchAxiosLike, isNetworkPolicyRefusal } from '../utils/url-security.js'; import { AAO_UA_VALIDATOR } from '../config/user-agents.js'; import { supplyPathAdsTxtPolicy, parseInventoryPartnerDomains, type SupplyPathInput } from './supply-path-verifier.js'; -/** Bounded IAB evidence from the applicable host surfaces; redirect delegation is not accepted here. */ +/** Bounded IAB evidence from the applicable host surfaces; redirects remain within the exact publisher origin. */ export async function fetchHostInventoryPartnerDomains(input: SupplyPathInput): Promise> { const policy = supplyPathAdsTxtPolicy(input); const contents = await Promise.all(policy.files.map(async file => { @@ -10,13 +10,17 @@ export async function fetchHostInventoryPartnerDomains(input: SupplyPathInput): const response = await safeFetchAxiosLike(`https://${input.hostDomain}/${file}`, { timeoutMs: 5000, maxResponseBytes: 256 * 1024, - maxRedirects: 0, + maxRedirects: 3, + redirectHostPolicy: 'same-origin', headers: { Accept: "text/plain", "User-Agent": AAO_UA_VALIDATOR }, }); if (response.status === 404) return ""; const contentType = String(response.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase(); return response.status === 200 && contentType === 'text/plain' ? response.data.toString("utf-8") : null; - } catch { return null; } + } catch (error) { + if (isNetworkPolicyRefusal(error)) throw error; + return null; + } })); return Object.fromEntries(policy.files.map((file, index) => [file, contents[index] === null ? null : parseInventoryPartnerDomains(contents[index]!)])); } diff --git a/server/src/services/supply-path-snapshot.ts b/server/src/services/supply-path-snapshot.ts new file mode 100644 index 00000000000..ccf0d40c286 --- /dev/null +++ b/server/src/services/supply-path-snapshot.ts @@ -0,0 +1,26 @@ +import type { AdcpStateStore } from '@adcp/sdk/server'; +import type { SupplyPathManifest } from './supply-path-contract.js'; +import { observeSupplyPathAuthority } from './supply-path-authority-state.js'; + +export interface SupplyPathSnapshot { + manifest: SupplyPathManifest | null; + resolvedUrl: string | null; + discoveryMethod: string | null; + fetchedAt: Date | null; + expiresAt: Date | null; +} + +/** Only fresh, publisher-directed cache observations can supply authoritative legs. */ +export async function supplyPathSnapshotEvidence(publisher: string, snapshot: SupplyPathSnapshot, store?: AdcpStateStore) { + const origin = `https://${publisher}`; + const canonical = `${origin}/.well-known/adagents.json`; + const resolved = snapshot.resolvedUrl ? new URL(snapshot.resolvedUrl) : new URL(canonical); + const secure = typeof snapshot.resolvedUrl === 'string' && snapshot.resolvedUrl.length > 0 && snapshot.resolvedUrl.length <= 8192 && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.hash && (!resolved.port || resolved.port === '443'); + const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved.origin === origin)); + const age = snapshot.fetchedAt ? Date.now() - snapshot.fetchedAt.getTime() : Infinity; + const fresh = age >= 0 && age <= 7 * 86400000 && (!snapshot.expiresAt || snapshot.expiresAt.getTime() > Date.now()); + const manifest = trustedSource && fresh ? snapshot.manifest : null; + const location = manifest ? (snapshot.discoveryMethod === 'authoritative_location' ? resolved.href : canonical) : undefined; + const held = await observeSupplyPathAuthority(publisher, manifest, location, store); + return { manifest, held: held.revoked, explicitPublisher: resolved.origin !== origin }; +} diff --git a/server/src/services/supply-path-verifier.ts b/server/src/services/supply-path-verifier.ts index c11fff9b418..2d932efb639 100644 --- a/server/src/services/supply-path-verifier.ts +++ b/server/src/services/supply-path-verifier.ts @@ -55,12 +55,23 @@ function coversCollection(entry: Record, owner: string, id: str } /** Resolve one entry at a time: separate grants are alternatives, never merged across collection scopes. */ -function propertyScope(entry: Record, host: string, manifest: SupplyPathManifest): Set { +function propertyScope( + entry: Record, + host: string, + manifest: SupplyPathManifest, + requirePublisher = false +): Set { if (revoked(manifest, host)) return new Set(); + if ( + !['property_ids', 'property_tags', 'publisher_properties', 'inline_properties'].includes( + String(entry.authorization_type) + ) + ) + return new Set(); if (entry.authorization_type === 'publisher_properties') { if (!Array.isArray(entry.publisher_properties) || entry.publisher_properties.length === 0) return new Set(); const properties = records(manifest.properties).filter( - p => p.publisher_domain === undefined || domain(p.publisher_domain) === host + p => (p.publisher_domain === undefined && !requirePublisher) || domain(p.publisher_domain) === host ); const ids = new Set(); try { @@ -92,13 +103,20 @@ function propertyScope(entry: Record, host: string, manifest: S if (entry.authorization_type === 'property_ids' && !strings(entry.property_ids)) return new Set(); if (entry.authorization_type === 'property_tags' && !strings(entry.property_tags)) return new Set(); // Reuse discovery's discriminator, missing-selector, and revocation semantics. + if (manifest.properties !== undefined && (!Array.isArray(manifest.properties) || !manifest.properties.every(record))) + return new Set(); + if ( + entry.authorization_type === 'inline_properties' && + (!Array.isArray(entry.properties) || !entry.properties.every(record)) + ) + return new Set(); const result = resolveAgentProperties( { ...manifest, authorized_agents: [entry as unknown as AuthorizedAgent] } as AdAgentsJson, String(entry.url) ); return new Set( result.properties - .filter(p => p.publisher_domain === undefined || domain(p.publisher_domain) === host) + .filter(p => (p.publisher_domain === undefined && !requirePublisher) || domain(p.publisher_domain) === host) .map(p => p.property_id) .filter((id): id is string => typeof id === 'string' && id.length > 0) ); @@ -222,7 +240,11 @@ export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { ); }; const collectionLeg: SupplyPathLegs['owner_collection_declared'] = { ok: false }; - const declared = records(ownerManifest?.collections); + const declared = records(ownerManifest?.collections).filter( + c => + (c.publisher_domain === undefined && !input.requireExplicitOwnerPublisherDomain) || + domain(c.publisher_domain) === owner + ); const targets = declared.filter(c => c.collection_id === input.collectionId && typeof c.collection_id === 'string'); if (!ownerManifest) collectionLeg.failure = 'manifest_not_found'; else if (owner && isRevoked('owner', owner)) collectionLeg.failure = 'publisher_revoked'; @@ -272,7 +294,9 @@ export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { carriage.property_ids_unmatched = claimed; } else { const properties = records(hostManifest.properties).filter( - p => p.publisher_domain === undefined || domain(p.publisher_domain) === host + p => + (p.publisher_domain === undefined && !input.requireExplicitHostPublisherDomain) || + domain(p.publisher_domain) === host ); const counts = new Map(); for (const property of properties) counts.set(property.property_id, (counts.get(property.property_id) ?? 0) + 1); @@ -290,6 +314,7 @@ export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { e => agentIdentity(e.url) === agent && owner && + (!input.requireExplicitOwnerPublisherDomain || e.collections !== undefined) && coversCollection(e, owner, input.collectionId) && !unsupportedConstraints(e).length ) @@ -314,7 +339,7 @@ export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { collectionLeg.ok && covered.find(e => { if (unsupportedConstraints(e).length) return false; - const scope = propertyScope(e, host, hostManifest); + const scope = propertyScope(e, host, hostManifest, input.requireExplicitHostPublisherDomain); const required = input.requiredHostPropertyIds ?? claimed; if ( input.requiredHostPropertyIds && @@ -414,7 +439,12 @@ export function supplyPathAdsTxtPolicy(input: SupplyPathInput): { ) ); const requested = input.requiredHostPropertyIds ? new Set(input.requiredHostPropertyIds) : collectionIds; - const properties = records(input.hostManifest?.properties).filter(p => requested.has(String(p.property_id))); + const properties = records(input.hostManifest?.properties).filter( + p => + requested.has(String(p.property_id)) && + ((p.publisher_domain === undefined && !input.requireExplicitHostPublisherDomain) || + domain(p.publisher_domain) === domain(input.hostDomain)) + ); const files = new Set<'ads.txt' | 'app-ads.txt'>(); for (const property of properties) { if (property.property_type === 'website') files.add('ads.txt'); diff --git a/server/src/utils/url-security.ts b/server/src/utils/url-security.ts index 11457e92dda..9b9d7c60f7d 100644 --- a/server/src/utils/url-security.ts +++ b/server/src/utils/url-security.ts @@ -7,6 +7,17 @@ import { createLogger } from '../logger.js'; const logger = createLogger('url-security'); +/** A non-transient network policy denial, distinct from transport unavailability. */ +export class NetworkPolicyRefusedError extends Error {} +export function isNetworkPolicyRefusal(error: unknown): boolean { + let current = error; + for (let depth = 0; depth < 5 && current instanceof Error; depth++) { + if (current instanceof NetworkPolicyRefusedError) return true; + current = current.cause; + } + return false; +} + export const SSRF_CONNECT_TIMEOUT_MS = 5_000; /** @@ -248,7 +259,7 @@ function unresolvedHostnameError(hostname: string, results: readonly PromiseSett */ export async function validateHostResolution(hostname: string): Promise { if (isPrivateHostname(hostname)) { - throw new Error('URLs pointing to private or internal networks are not allowed'); + throw new NetworkPolicyRefusedError('URLs pointing to private or internal networks are not allowed'); } // If already an IP literal, the string check above is sufficient @@ -271,7 +282,7 @@ export async function validateHostResolution(hostname: string): Promise { for (const address of allAddresses) { if (isPrivateHostname(address)) { - throw new Error('URL resolved to a private or internal IP address'); + throw new NetworkPolicyRefusedError('URL resolved to a private or internal IP address'); } } } @@ -390,7 +401,7 @@ export const ssrfSafeLookup: LookupFunction = ( // Reject private hostname strings before resolving (covers IP literals // and hostnames the OS resolver would route to localhost). if (isPrivateHostname(hostname)) { - callback(new Error('Connection to private or internal address is blocked'), '', 0); + callback(new NetworkPolicyRefusedError('Connection to private or internal address is blocked'), '', 0); return; } @@ -406,7 +417,7 @@ export const ssrfSafeLookup: LookupFunction = ( const list = (addresses as unknown as LookupAddress[]) ?? []; const safe = list.filter((a) => !isPrivateHostname(a.address)); if (safe.length === 0) { - callback(new Error('Hostname resolved to a private or internal IP address'), '', 0); + callback(new NetworkPolicyRefusedError('Hostname resolved to a private or internal IP address'), '', 0); return; } // If the caller requested `all`, hand back the filtered list. Otherwise @@ -452,7 +463,8 @@ const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024; export type RedirectHostPolicy = | 'same-registrable-domain' - | 'original-host-and-www'; + | 'original-host-and-www' + | 'same-origin'; export async function safeFetch( url: string, @@ -486,7 +498,8 @@ export async function safeFetch( if ( redirectHostPolicy !== undefined && redirectHostPolicy !== 'same-registrable-domain' && - redirectHostPolicy !== 'original-host-and-www' + redirectHostPolicy !== 'original-host-and-www' && + redirectHostPolicy !== 'same-origin' ) { throw new Error(`Unsupported redirect host policy: ${String(redirectHostPolicy)}`); } @@ -564,6 +577,12 @@ export async function safeFetch( for (let i = 0; i < maxRedirects && [301, 302, 303, 307, 308].includes(response.status); i++) { const location = response.headers.get('location'); if (!location) throw new Error('Redirect with no Location header'); + // Reject an undelegated origin before resolving it, matching authoritative SDK evidence fetches. + if (redirectHostPolicy === 'same-origin') { + const candidate = new URL(location, currentUrl); + if (candidate.origin !== parsedUrl.origin || candidate.username || candidate.password) + throw new Error('Refused cross-origin or credential-bearing evidence redirect'); + } // Pre-flight check on the redirect hop, then dial through the same SSRF-safe dispatcher. const redirectUrl = await validateRedirectTarget(location, currentUrl); if (restrictRedirectHosts) { @@ -574,6 +593,9 @@ export async function safeFetch( ); } } + if (redirectHostPolicy === 'same-origin' && redirectUrl.origin !== parsedUrl.origin) { + throw new Error(`Refused cross-origin redirect: ${currentUrl.origin} -> ${redirectUrl.origin}`); + } if (redirectHostPolicy === 'same-registrable-domain') { // Same registrable domain (eTLD+1), anchored on the ORIGINAL request. const targetRegistrableDomain = getDomain(redirectUrl.hostname, { allowPrivateDomains: true }); diff --git a/server/tests/integration/registry-crawler-cache.test.ts b/server/tests/integration/registry-crawler-cache.test.ts index f61da0f6565..a47fa2da263 100644 --- a/server/tests/integration/registry-crawler-cache.test.ts +++ b/server/tests/integration/registry-crawler-cache.test.ts @@ -19,6 +19,8 @@ import { runMigrations } from '../../src/db/migrate.js'; import { PublisherDatabase } from '../../src/db/publisher-db.js'; import { FederatedIndexService } from '../../src/federated-index.js'; import type { Pool } from 'pg'; +import { PostgresStateStore } from '@adcp/sdk/server'; +import { observeSupplyPathAuthority, approveSupplyPathAuthorityChange } from '../../src/services/supply-path-authority-state.js'; const TEST_DOMAIN = 'crawler-cache.example.com'; const TEST_AGENT = 'https://agent.crawler-cache.example.com/mcp'; @@ -70,6 +72,41 @@ describe('Registry crawler cache (PR 2 of #3177)', () => { federatedIndex = new FederatedIndexService(); }); + it('binds supply-path provenance to the successful manifest across failed recrawls', async () => { + const target = 'https://shared.example/' + 'a'.repeat(2100) + '/publisher.json'; + await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, resolvedUrl: target, discoveryMethod: 'authoritative_location' }); + const before = await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN); + expect(before.resolvedUrl).toBe(target); + expect(before.discoveryMethod).toBe('authoritative_location'); + await publisherDb.recordFailedAdagentsFetch({ domain: TEST_DOMAIN, statusCode: 503, resolvedUrl: `https://${TEST_DOMAIN}/.well-known/adagents.json` }); + expect(await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).toEqual(before); + await publisherDb.recordFailedAdagentsFetch({ domain: TEST_DOMAIN, statusCode: 503 }); + expect(await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).toEqual(before); + }); + + it('requires a provenance-aware refresh and refuses URLs that cannot be retained completely', async () => { + await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST }); + expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toBeNull(); + await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, discoveryMethod: 'authoritative_location', resolvedUrl: 'https://shared.example/' + 'x'.repeat(8192) }); + expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toBeNull(); + await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, discoveryMethod: 'direct', resolvedUrl: `https://${TEST_DOMAIN}/.well-known/adagents.json` }); + expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toEqual({ ...FIXTURE_MANIFEST, collections: [] }); + }); + + it('shares durable revocations and authority pins across independent PostgreSQL store instances', async () => { + const first = new PostgresStateStore(pool); + const second = new PostgresStateStore(pool); + const location = 'https://shared.example/publisher.json'; + await Promise.all([ + observeSupplyPathAuthority(TEST_DOMAIN, { revoked_publisher_domains: ['owner.example'] }, location, first), + observeSupplyPathAuthority(TEST_DOMAIN, { revoked_publisher_domains: ['second-owner.example'] }, location, second), + ]); + expect((await observeSupplyPathAuthority(TEST_DOMAIN, null, undefined, new PostgresStateStore(pool))).revoked.sort()).toEqual(['owner.example', 'second-owner.example']); + await expect(observeSupplyPathAuthority(TEST_DOMAIN, {}, 'https://changed.example/publisher.json', second)).rejects.toThrow(/independent confirmation/); + await approveSupplyPathAuthorityChange(TEST_DOMAIN, 'https://changed.example/publisher.json', first); + expect((await observeSupplyPathAuthority(TEST_DOMAIN, {}, 'https://changed.example/publisher.json', second)).revoked).toHaveLength(2); + }); + // Scope cleanup tightly so parallel runs of other tests sharing the // .example.com pattern don't trample our fixtures. const TEST_CREATED_BY = [ @@ -81,6 +118,7 @@ describe('Registry crawler cache (PR 2 of #3177)', () => { const TEST_DOMAINS = [TEST_DOMAIN, VICTIM_DOMAIN, ATTACKER_DOMAIN]; async function clearTestFixtures() { + await pool.query("DELETE FROM adcp_state WHERE collection = 'supply_path_authority_v1' AND id = $1", [TEST_DOMAIN]); // Identifiers must clear before properties — catalog_identifiers FKs to // catalog_properties. Delete via the property_rid join so any identifier // value (including ones the tests didn't list explicitly) gets caught. diff --git a/server/tests/unit/services/supply-path-authority-state.test.ts b/server/tests/unit/services/supply-path-authority-state.test.ts new file mode 100644 index 00000000000..5ffd080224d --- /dev/null +++ b/server/tests/unit/services/supply-path-authority-state.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { InMemoryStateStore } from '@adcp/sdk/server'; +import { observeSupplyPathAuthority, approveSupplyPathAuthorityChange } from '../../../src/services/supply-path-authority-state.js'; + +const host = 'host.example'; +const pointer = 'https://cdn.example/host.json'; +const revoked = (publisher = 'owner.example', timestamp = '2026-09-01T00:00:00Z') => ({ revoked_publisher_domains: [{ publisher_domain: publisher, revoked_at: timestamp }] }); +afterEach(() => vi.useRealTimers()); +describe('persistent supply-path authority state', () => { + it('preserves concurrent revocations across missing and refreshed manifests', async () => { + const store = new InMemoryStateStore(); + await Promise.all([ + observeSupplyPathAuthority(host, revoked(), pointer, store), + observeSupplyPathAuthority(host, revoked('second.example'), pointer, store), + ]); + expect((await observeSupplyPathAuthority(host, null, undefined, store)).revoked.sort()).toEqual(['owner.example', 'second.example']); + expect((await observeSupplyPathAuthority('unrelated.example', null, undefined, store)).revoked).toEqual([]); + }); + it('holds for seven days from first observation regardless of changed publisher timestamps', async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-01T00:00:00Z')); + const store = new InMemoryStateStore(); + await observeSupplyPathAuthority(host, revoked(), pointer, store); + vi.setSystemTime(new Date('2026-09-07T00:00:00Z')); + await observeSupplyPathAuthority(host, revoked('owner.example', '2026-09-07T00:00:00Z'), pointer, store); + vi.setSystemTime(new Date('2026-09-08T00:00:00Z')); + expect((await observeSupplyPathAuthority(host, null, undefined, store)).revoked).toEqual([]); + }); + it('rejects changed pointers and preserves holds after independently confirmed migrations', async () => { + const store = new InMemoryStateStore(); + await observeSupplyPathAuthority(host, revoked(), pointer, store); + await expect(observeSupplyPathAuthority(host, {}, 'https://new.example/host.json', store)).rejects.toThrow(/independent confirmation/); + await approveSupplyPathAuthorityChange(host, 'https://new.example/host.json', store); + expect((await observeSupplyPathAuthority(host, {}, 'https://new.example/host.json', store)).revoked).toEqual(['owner.example']); + }); + it('rejects malformed revocations and insecure authoritative locations', async () => { + const store = new InMemoryStateStore(); + await expect(observeSupplyPathAuthority(host, { revoked_publisher_domains: ['owner.example', {}] }, pointer, store)).rejects.toThrow(/revocation evidence/); + await expect(observeSupplyPathAuthority(host, {}, 'http://cdn.example/host.json', store)).rejects.toThrow(/authority/); + }); +}); + +it('does not contend on unchanged observations after a concurrent first pin', async () => { + const store = new InMemoryStateStore(); + const write = vi.spyOn(store, 'putIfMatch'); + const results = await Promise.all(Array.from({ length: 40 }, () => observeSupplyPathAuthority(host, revoked(), pointer, store))); + for (const result of results) expect(result.revoked).toEqual(['owner.example']); + write.mockClear(); + await Promise.all(Array.from({ length: 40 }, () => observeSupplyPathAuthority(host, {}, pointer, store))); + expect(write).not.toHaveBeenCalled(); +}); diff --git a/server/tests/unit/services/supply-path-evidence.test.ts b/server/tests/unit/services/supply-path-evidence.test.ts index 1ba02ed6c79..5a24fd6a741 100644 --- a/server/tests/unit/services/supply-path-evidence.test.ts +++ b/server/tests/unit/services/supply-path-evidence.test.ts @@ -1,17 +1,18 @@ import { readFileSync } from 'node:fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const safeFetch = vi.hoisted(() => vi.fn()); -vi.mock('../../../src/utils/url-security.js', () => ({ safeFetchAxiosLike: safeFetch })); +vi.mock('../../../src/utils/url-security.js', async importOriginal => ({ ...await importOriginal(), safeFetchAxiosLike: safeFetch })); +import { NetworkPolicyRefusedError } from '../../../src/utils/url-security.js'; import { fetchHostInventoryPartnerDomains } from '../../../src/services/supply-path-evidence.js'; const corpus = JSON.parse(readFileSync(new URL('../../../../static/compliance/source/test-vectors/supply-path/vectors.json', import.meta.url), 'utf8')); const input = corpus.vectors[0].input; describe('registry supply-path IAB fetching', () => { beforeEach(() => safeFetch.mockReset()); - it('fetches only applicable evidence with bounded bytes/time and no redirects', async () => { + it('fetches only applicable evidence with bounded bytes/time and same-origin redirects', async () => { safeFetch.mockResolvedValue({ status: 200, data: Buffer.from('inventorypartnerdomain=channel-owner.example'), headers: { 'content-type': 'text/plain; charset=utf-8' } }); expect(await fetchHostInventoryPartnerDomains(input)).toEqual({ 'app-ads.txt': ['channel-owner.example'] }); expect(safeFetch).toHaveBeenCalledTimes(1); - expect(safeFetch).toHaveBeenCalledWith('https://hoststream.example/app-ads.txt', expect.objectContaining({ maxRedirects: 0, maxResponseBytes: 256 * 1024, timeoutMs: 5000 })); + expect(safeFetch).toHaveBeenCalledWith('https://hoststream.example/app-ads.txt', expect.objectContaining({ maxRedirects: 3, redirectHostPolicy: 'same-origin', maxResponseBytes: 256 * 1024, timeoutMs: 5000 })); }); it('distinguishes missing from failed and refuses mislabeled evidence', async () => { safeFetch.mockResolvedValueOnce({ status: 404, headers: {}, data: Buffer.from('missing') }); @@ -30,3 +31,9 @@ it('includes identifier-only fallback files when another bulk candidate is a web expect(await fetchHostInventoryPartnerDomains(fixture)).toEqual({ 'ads.txt': [], 'app-ads.txt': ['channel-owner.example'] }); expect(safeFetch).toHaveBeenCalledTimes(2); }); + +it('propagates non-transient network policy refusals instead of returning an IAB verdict', async () => { + safeFetch.mockReset(); + safeFetch.mockRejectedValue(new TypeError('fetch failed', { cause: new NetworkPolicyRefusedError('URL resolved to a private or internal IP address') })); + await expect(fetchHostInventoryPartnerDomains(input)).rejects.toThrow('fetch failed'); +}); diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts new file mode 100644 index 00000000000..51e6e62479f --- /dev/null +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryStateStore } from '@adcp/sdk/server'; +import { supplyPathSnapshotEvidence } from '../../../src/services/supply-path-snapshot.js'; + +const publisher = 'host.example'; +const snapshot = () => ({ manifest: { authorized_agents: [] }, fetchedAt: new Date(), expiresAt: new Date(Date.now() + 86400000), resolvedUrl: 'https://host.example/.well-known/adagents.json', discoveryMethod: 'direct' }); +describe('registry cache authority provenance', () => { + it('accepts fresh publisher evidence and requires attribution on cross-origin pointers', async () => { + const direct = snapshot(); + expect((await supplyPathSnapshotEvidence(publisher, direct, new InMemoryStateStore())).manifest).toEqual(direct.manifest); + const delegated = await supplyPathSnapshotEvidence(publisher, { ...direct, discoveryMethod: 'authoritative_location', resolvedUrl: 'https://cdn.example/host.json' }, new InMemoryStateStore()); + expect(delegated.manifest).toEqual(direct.manifest); + expect(delegated.explicitPublisher).toBe(true); + }); + it.each([ + { resolvedUrl: null }, { resolvedUrl: 'https://cdn.example/' + 'x'.repeat(8192) }, + { fetchedAt: null }, { fetchedAt: new Date(0) }, { fetchedAt: new Date('2100-01-01') }, + { expiresAt: new Date(0) }, { discoveryMethod: 'manager_domain' }, { discoveryMethod: 'community' }, + { resolvedUrl: 'https://other.example/adagents.json' }, + { discoveryMethod: 'authoritative_location', resolvedUrl: 'http://cdn.example/adagents.json' }, + ])('refuses unauthoritative or stale evidence: %j', async patch => { + const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), ...patch }, new InMemoryStateStore()); + expect(result.manifest).toBeNull(); + }); + it('retains a previous denial when the next snapshot is stale', async () => { + const store = new InMemoryStateStore(); + await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { revoked_publisher_domains: ['owner.example'] } }, store); + const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), fetchedAt: new Date(0) }, store); + expect(result.manifest).toBeNull(); + expect(result.held).toEqual(['owner.example']); + }); +}); diff --git a/server/tests/unit/url-security-redirects.test.ts b/server/tests/unit/url-security-redirects.test.ts index f395053f559..25f968bf50b 100644 --- a/server/tests/unit/url-security-redirects.test.ts +++ b/server/tests/unit/url-security-redirects.test.ts @@ -132,6 +132,22 @@ describe('safeFetch redirects', () => { expect(fetchMock.mock.calls[1][0]).toBe('https://www.publisher.example/ads.txt'); expect(fetchMock.mock.calls[2][0]).toBe('https://www.publisher.example/sites/pub/ads.txt'); }); + + it('follows relative redirects under the exact-origin supply-path policy', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 302, headers: { Location: '/iab/ads.txt' } })) + .mockResolvedValueOnce(new Response('inventorypartnerdomain=owner.example')); + const response = await safeFetch('https://publisher.example/ads.txt', { redirectHostPolicy: 'same-origin', maxRedirects: 3 }); + expect(response.status).toBe(200); + expect(fetchMock.mock.calls[1][0]).toBe('https://publisher.example/iab/ads.txt'); + }); + + it.each(['https://www.publisher.example/ads.txt', 'https://publisher.example:8443/ads.txt', 'http://publisher.example/ads.txt'])( + 'refuses a changed origin without dialing it: %s', async location => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 302, headers: { Location: location } })); + await expect(safeFetch('https://publisher.example/ads.txt', { redirectHostPolicy: 'same-origin', maxRedirects: 3 })).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); }); describe('safeFetch same-registrable-domain redirects (adagents /.well-known discovery)', () => { diff --git a/static/compliance/source/test-vectors/supply-path/README.md b/static/compliance/source/test-vectors/supply-path/README.md index 426dda3953c..d95f1207ba8 100644 --- a/static/compliance/source/test-vectors/supply-path/README.md +++ b/static/compliance/source/test-vectors/supply-path/README.md @@ -19,3 +19,5 @@ Malformed present collection selectors invalidate that grant, including when ano Authoritative consumers must retain previously observed revocations per publisher authority for seven days from first observation, including across stale refreshes, and pass the effective revocations to the evaluator. The corpus tests the effective evidence; persistent store choice and fetch provenance belong to the consuming client. Explicit publisher-origin pointers delegate retrieval, not authority to unrelated publisher namespaces. `hostInventoryPartnerDomainsByFile` retains parsed evidence separately for ads.txt and app-ads.txt. When supplied, it takes precedence over the legacy combined `hostInventoryPartnerDomains` input. Bulk inquiries select applicable files independently for each collection before choosing the strongest complete path. `heldRevocations` supplies authority-scoped owner/host holds even when a current manifest is null. Nested collection and publisher-property selector qualifiers fail closed, just like top-level qualifiers. + +The semantic version identifies the pure state/leg evaluator, not equal observation times across cached registry and live client fetches. Both orchestrators reject non-transient network-policy denials without producing a verdict. Ordinary transport failures become unavailable evidence (`null`); a redirect outside the exact publisher origin is refused before resolving that target and supplies no evidence. Durable storage failures and unconfirmed authority changes also reject the operation. These fetch/storage boundaries have integration regressions alongside the pure corpus. diff --git a/static/compliance/source/test-vectors/supply-path/vectors.json b/static/compliance/source/test-vectors/supply-path/vectors.json index 02122e9749f..381f0448037 100644 --- a/static/compliance/source/test-vectors/supply-path/vectors.json +++ b/static/compliance/source/test-vectors/supply-path/vectors.json @@ -5615,6 +5615,1952 @@ }, "resolved_collection_id": "web_channel" } + }, + { + "id": "inline-property-grant", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "inline_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "legacy-inline-without-discriminator-is-not-a-grant", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "property-tag-grant", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ], + "tags": [ + "ctv" + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_tags", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "property_tags": [ + "ctv" + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-all", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domain": "hoststream.example", + "selection_type": "all" + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-all-compact", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domains": [ + "hoststream.example", + "other.example" + ], + "selection_type": "all" + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-by_id", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domain": "hoststream.example", + "selection_type": "by_id", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-by_id-compact", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domains": [ + "hoststream.example", + "other.example" + ], + "selection_type": "by_id", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-by_tag", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ], + "tags": [ + "ctv" + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domain": "hoststream.example", + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "publisher-property-selector-by_tag-compact", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ], + "tags": [ + "ctv" + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "publisher_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "publisher_properties": [ + { + "publisher_domains": [ + "hoststream.example", + "other.example" + ], + "selection_type": "by_tag", + "property_tags": [ + "ctv" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-pointer-requires-explicit-property-publisher", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requireExplicitHostPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "property_ids_unresolved" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-pointer-explicit-property-publisher", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ], + "publisher_domain": "hoststream.example" + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requireExplicitHostPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "canonical-agent-origin-default-port", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "HTTPS://SALES.CHANNEL-OWNER.EXAMPLE:443/", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-top-level-property-record", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + }, + null + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "malformed-inline-property-record", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "inline_properties", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ], + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + }, + null + ] + } + ] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-owner-catalog-unattributed-collection", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "collection_not_declared" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "collection_leg_failed" + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + } + } + }, + { + "id": "shared-owner-catalog-explicit-namespace", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": null, + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "channel-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "verified_owner_sold", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": true + }, + "inventory_partner_domain": { + "ok": false, + "failure": "ads_txt_unavailable" + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-owner-catalog-cannot-borrow-collection-via-ipd", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "other-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "hoststream_ctv" + ], + "collections": [ + { + "publisher_domain": "channel-owner.example", + "collection_ids": [ + "retro_news" + ] + } + ] + } + ] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "unverified", + "legs": { + "owner_collection_declared": { + "ok": false, + "failure": "collection_not_declared" + }, + "owner_distribution_carriage": { + "ok": false, + "failure": "collection_leg_failed" + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "property_scope_mismatch" + }, + "inventory_partner_domain": { + "ok": true + } + } + } + }, + { + "id": "shared-owner-unscoped-agent-cannot-borrow-ipd", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails" + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "channel-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-owner-wrong-agent-namespace", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [ + { + "publisher_domain": "other-owner.example" + } + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "channel-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-owner-bulk-agent-grant", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [ + { + "publisher_domain": "channel-owner.example" + } + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "channel-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "host_delegated", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": true + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "shared-owner-empty-agent-selector", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "collections": [] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ], + "publisher_domain": "channel-owner.example" + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + }, + "requireExplicitOwnerPublisherDomain": true + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } } ], "ads_txt_policy_vectors": [ diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index f6718bfa251..7fe684d5ad1 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -8410,6 +8410,24 @@ paths: sources: type: object properties: + owner_fetched_at: + type: + - string + - "null" + format: date-time + host_fetched_at: + type: + - string + - "null" + format: date-time + owner_resolved_url: + type: + - string + - "null" + host_resolved_url: + type: + - string + - "null" owner_adagents_url: type: string host_adagents_url: @@ -8418,6 +8436,10 @@ paths: type: boolean description: "true: manifests came from the registry's crawl cache. Re-derive from the URLs above for enforcement." required: + - owner_fetched_at + - host_fetched_at + - owner_resolved_url + - host_resolved_url - owner_adagents_url - host_adagents_url - cached diff --git a/static/schemas/source/adagents.json b/static/schemas/source/adagents.json index a8a0a2be8e8..ef743ca0f2f 100644 --- a/static/schemas/source/adagents.json +++ b/static/schemas/source/adagents.json @@ -103,7 +103,7 @@ }, "revoked_publisher_domains": { "type": "array", - "description": "Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed `(publisher_domain, revoked_at)` tuples for 7 days from the validator's first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh.", + "description": "Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed publisher revocations, keyed by authority and publisher_domain, for 7 days from the validator's first observation; changing revoked_at MUST NOT reset that first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh.", "items": { "type": "object", "properties": { diff --git a/static/schemas/source/core/collection.json b/static/schemas/source/core/collection.json index 3fcfd097485..566c9dd2454 100644 --- a/static/schemas/source/core/collection.json +++ b/static/schemas/source/core/collection.json @@ -5,6 +5,11 @@ "description": "A recurring inventory container — a named program, publication, event series, rotation, or programmed channel that produces bookable installments on a defined cadence. The kind field indicates how to interpret this collection: 'series' for TV/podcast programs, 'publication' for print/newsletter titles, 'event_series' for live events, 'rotation' for DOOH scheduling, and 'channel' for continuously programmed audio or video streams regardless of carriage or monetization model. Declared in the publisher's adagents.json and referenced by products via collection selectors.", "type": "object", "properties": { + "publisher_domain": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", + "description": "Publisher namespace that owns this collection. Required for supply-path verification when the declaration is read from a cross-origin authoritative document; it must match the publisher whose origin delegated retrieval. A pointer alone cannot claim another publisher's collection. May be omitted for a publisher-origin document, where that origin supplies the namespace." + }, "collection_id": { "type": "string", "description": "Publisher-assigned identifier for this collection. Declared in the publisher's adagents.json collections array. Products reference collections via collection selectors with publisher_domain and collection_ids. Use distribution identifiers for cross-seller matching across publishers.", @@ -89,7 +94,12 @@ "items": { "type": "object", "properties": { - "collection_id": { + "publisher_domain": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", + "description": "Publisher namespace that owns this collection. Required for supply-path verification when the declaration is read from a cross-origin authoritative document; it must match the publisher whose origin delegated retrieval. A pointer alone cannot claim another publisher's collection. May be omitted for a publisher-origin document, where that origin supplies the namespace." + }, + "collection_id": { "type": "string", "description": "The related collection's collection_id within this seller's response", "x-entity": "collection" From e9316a294618089d69afd42176614f39f95be018 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 15:49:09 +0000 Subject: [PATCH 04/15] fix(registry): reject malformed provenance without losing held revocations --- server/src/services/supply-path-snapshot.ts | 17 ++++++++++++----- .../unit/services/supply-path-snapshot.test.ts | 9 +++++++++ static/schemas/source/core/collection.json | 7 +------ tests/fast-channel-collections.test.cjs | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/server/src/services/supply-path-snapshot.ts b/server/src/services/supply-path-snapshot.ts index ccf0d40c286..6994f8ef1bc 100644 --- a/server/src/services/supply-path-snapshot.ts +++ b/server/src/services/supply-path-snapshot.ts @@ -14,13 +14,20 @@ export interface SupplyPathSnapshot { export async function supplyPathSnapshotEvidence(publisher: string, snapshot: SupplyPathSnapshot, store?: AdcpStateStore) { const origin = `https://${publisher}`; const canonical = `${origin}/.well-known/adagents.json`; - const resolved = snapshot.resolvedUrl ? new URL(snapshot.resolvedUrl) : new URL(canonical); - const secure = typeof snapshot.resolvedUrl === 'string' && snapshot.resolvedUrl.length > 0 && snapshot.resolvedUrl.length <= 8192 && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.hash && (!resolved.port || resolved.port === '443'); - const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved.origin === origin)); + let resolved: URL | null = null; + if (typeof snapshot.resolvedUrl === 'string' && snapshot.resolvedUrl.length > 0 && snapshot.resolvedUrl.length <= 8192) { + try { + resolved = new URL(snapshot.resolvedUrl); + } catch { + // Corrupt cache provenance cannot establish authority. Still load held denials below. + } + } + const secure = resolved !== null && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.hash && (!resolved.port || resolved.port === '443'); + const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved?.origin === origin)); const age = snapshot.fetchedAt ? Date.now() - snapshot.fetchedAt.getTime() : Infinity; const fresh = age >= 0 && age <= 7 * 86400000 && (!snapshot.expiresAt || snapshot.expiresAt.getTime() > Date.now()); const manifest = trustedSource && fresh ? snapshot.manifest : null; - const location = manifest ? (snapshot.discoveryMethod === 'authoritative_location' ? resolved.href : canonical) : undefined; + const location = manifest && resolved ? (snapshot.discoveryMethod === 'authoritative_location' ? resolved.href : canonical) : undefined; const held = await observeSupplyPathAuthority(publisher, manifest, location, store); - return { manifest, held: held.revoked, explicitPublisher: resolved.origin !== origin }; + return { manifest, held: held.revoked, explicitPublisher: resolved !== null && resolved.origin !== origin }; } diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts index 51e6e62479f..8b6271691ab 100644 --- a/server/tests/unit/services/supply-path-snapshot.test.ts +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -29,4 +29,13 @@ describe('registry cache authority provenance', () => { expect(result.manifest).toBeNull(); expect(result.held).toEqual(['owner.example']); }); + it.each(['not a URL', 'https://[invalid', ''])('retains held denials when persisted provenance is malformed: %j', async resolvedUrl => { + const store = new InMemoryStateStore(); + await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { revoked_publisher_domains: ['owner.example'] } }, store); + const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), discoveryMethod: 'authoritative_location', resolvedUrl }, store); + expect(result.manifest).toBeNull(); + expect(result.held).toEqual(['owner.example']); + // Invalid provenance must not replace the previously established authority pin. + expect((await supplyPathSnapshotEvidence(publisher, snapshot(), store)).manifest).toEqual(snapshot().manifest); + }); }); diff --git a/static/schemas/source/core/collection.json b/static/schemas/source/core/collection.json index 566c9dd2454..45bf5a02149 100644 --- a/static/schemas/source/core/collection.json +++ b/static/schemas/source/core/collection.json @@ -94,12 +94,7 @@ "items": { "type": "object", "properties": { - "publisher_domain": { - "type": "string", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$", - "description": "Publisher namespace that owns this collection. Required for supply-path verification when the declaration is read from a cross-origin authoritative document; it must match the publisher whose origin delegated retrieval. A pointer alone cannot claim another publisher's collection. May be omitted for a publisher-origin document, where that origin supplies the namespace." - }, - "collection_id": { + "collection_id": { "type": "string", "description": "The related collection's collection_id within this seller's response", "x-entity": "collection" diff --git a/tests/fast-channel-collections.test.cjs b/tests/fast-channel-collections.test.cjs index e911ff8e2b6..0fdccbcf489 100644 --- a/tests/fast-channel-collections.test.cjs +++ b/tests/fast-channel-collections.test.cjs @@ -57,6 +57,22 @@ test('channel collections support publisher-scoped channel identifiers', async ( assert.equal(validate(collection), true, JSON.stringify(validate.errors, null, 2)); }); +test('collection ownership is explicit at the declaration while related collections remain local references', async () => { + const validate = await compile('/schemas/core/collection.json'); + const collection = { + publisher_domain: 'channel-owner.example', + collection_id: 'retro_news', + name: 'Retro News', + related_collections: [{ collection_id: 'retro_extra', relationship: 'companion' }], + }; + assert.equal(validate(collection), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(validate({ ...collection, publisher_domain: 'https://channel-owner.example' }), false); + assert.equal(validate({ + ...collection, + related_collections: [{ ...collection.related_collections[0], publisher_domain: 'other.example' }], + }), false, 'related collections are not cross-publisher declarations'); +}); + test('collection distributions reject empty carriage records', async () => { const validate = await compile('/schemas/core/collection.json'); const collection = { From 35d527bfd4c7de847367093bdea42a3d7f88ed2c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 15:50:49 +0000 Subject: [PATCH 05/15] test(registry): prove malformed provenance preserves authority pins --- server/tests/unit/services/supply-path-snapshot.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts index 8b6271691ab..657db025b46 100644 --- a/server/tests/unit/services/supply-path-snapshot.test.ts +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -36,6 +36,9 @@ describe('registry cache authority provenance', () => { expect(result.manifest).toBeNull(); expect(result.held).toEqual(['owner.example']); // Invalid provenance must not replace the previously established authority pin. + await expect(supplyPathSnapshotEvidence(publisher, { + ...snapshot(), discoveryMethod: 'authoritative_location', resolvedUrl: 'https://other.example/host.json', + }, store)).rejects.toThrow(/migration requires independent confirmation/); expect((await supplyPathSnapshotEvidence(publisher, snapshot(), store)).manifest).toEqual(snapshot().manifest); }); }); From 8ed940ae1ef441cac7b3eba09e80a4205ec99755 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 16:21:12 +0000 Subject: [PATCH 06/15] fix(supply-path): reject invalid authority envelopes before pin admission --- docs/governance/property/managed-networks.mdx | 2 ++ server/src/adagents-manager.ts | 12 ++++++- server/src/services/supply-path-snapshot.ts | 9 ++++-- server/tests/unit/adagents-manager.test.ts | 31 +++++++++++++++++++ .../services/supply-path-snapshot.test.ts | 24 ++++++++++++-- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/governance/property/managed-networks.mdx b/docs/governance/property/managed-networks.mdx index 51d71bfcb97..d0a5d5a6d54 100644 --- a/docs/governance/property/managed-networks.mdx +++ b/docs/governance/property/managed-networks.mdx @@ -662,6 +662,8 @@ Validators SHOULD also apply revocation **synchronously during manifest ingest** This last property is what makes pointer-swap distinct from a generic integrity failure: the whole point of the `authoritative_location` pattern is that publishers are *allowed* to change where they delegate, so the validator cannot treat any pointer change as adversarial without breaking legitimate delegation handoffs. The network-CDN threat is wide-and-shallow (one compromise, every publisher hijacked); the pointer-swap threat is narrow-and-deep (one publisher hijacked, but through a surface the network cannot monitor). Both are in scope. +A validator MUST establish its baseline authoritative-location pin only after successfully fetching and validating a single-hop authoritative manifest. An unavailable target, invalid manifest, or chained pointer MUST NOT establish or replace that pin. A read-only check against an existing pin does not reserve authority; successful admission MUST atomically recheck the pin before committing it. + Validators MUST treat a changed `authoritative_location` as a high-severity event, not a routine refresh. Concretely: - Validators MUST NOT auto-adopt a changed `authoritative_location`. Continue serving the previously cached authoritative file (subject to the 7-day cap above) while the change is under confirmation. This is the minimum normative floor; the SHOULDs below specify how confirmation is obtained. diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts index 38ee5ad7db4..5b2a85dd299 100644 --- a/server/src/adagents-manager.ts +++ b/server/src/adagents-manager.ts @@ -412,6 +412,15 @@ export class AdAgentsManager { // Check if this is a URL reference let wasUrlReference = false; + if (typeof adagentsData === 'object' && adagentsData !== null && 'authoritative_location' in adagentsData && + (typeof adagentsData.authoritative_location !== 'string' || 'authorized_agents' in adagentsData || 'superseded_by' in adagentsData)) { + result.errors.push({ + field: 'authoritative_location', + message: 'Authoritative location must be a URL-only reference, without inline agents or another pointer', + severity: 'error', + }); + return result; + } if (this.isUrlReference(adagentsData)) { wasUrlReference = true; // Follow the reference to get the authoritative file @@ -656,7 +665,8 @@ export class AdAgentsManager { } // Ensure the authoritative file is not also a reference (prevent infinite loops) - if (this.isUrlReference(authData)) { + if (typeof authData === 'object' && authData !== null && + ('authoritative_location' in authData || 'superseded_by' in authData)) { result.errors.push({ field: 'authoritative_location', message: 'Authoritative file cannot be another URL reference (nested references not allowed)', diff --git a/server/src/services/supply-path-snapshot.ts b/server/src/services/supply-path-snapshot.ts index 6994f8ef1bc..dfd6a880e12 100644 --- a/server/src/services/supply-path-snapshot.ts +++ b/server/src/services/supply-path-snapshot.ts @@ -1,6 +1,7 @@ import type { AdcpStateStore } from '@adcp/sdk/server'; import type { SupplyPathManifest } from './supply-path-contract.js'; import { observeSupplyPathAuthority } from './supply-path-authority-state.js'; +import { record } from './supply-path-input.js'; export interface SupplyPathSnapshot { manifest: SupplyPathManifest | null; @@ -26,8 +27,12 @@ export async function supplyPathSnapshotEvidence(publisher: string, snapshot: Su const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved?.origin === origin)); const age = snapshot.fetchedAt ? Date.now() - snapshot.fetchedAt.getTime() : Infinity; const fresh = age >= 0 && age <= 7 * 86400000 && (!snapshot.expiresAt || snapshot.expiresAt.getTime() > Date.now()); - const manifest = trustedSource && fresh ? snapshot.manifest : null; + const observation = trustedSource && fresh && record(snapshot.manifest) ? snapshot.manifest : null; + // Persist denial evidence from trusted observations even when their affirmative + // envelope is invalid. Only a valid, single-hop manifest may establish a pin. + const manifest = observation && Array.isArray(observation.authorized_agents) && + observation.authoritative_location === undefined && observation.superseded_by === undefined ? observation : null; const location = manifest && resolved ? (snapshot.discoveryMethod === 'authoritative_location' ? resolved.href : canonical) : undefined; - const held = await observeSupplyPathAuthority(publisher, manifest, location, store); + const held = await observeSupplyPathAuthority(publisher, observation, location, store); return { manifest, held: held.revoked, explicitPublisher: resolved !== null && resolved.origin !== origin }; } diff --git a/server/tests/unit/adagents-manager.test.ts b/server/tests/unit/adagents-manager.test.ts index c8c708f239a..19d5bad415a 100644 --- a/server/tests/unit/adagents-manager.test.ts +++ b/server/tests/unit/adagents-manager.test.ts @@ -1712,6 +1712,37 @@ describe('AdAgentsManager', () => { expect(result.errors.some(e => e.message.includes('nested references not allowed'))).toBe(true); }); + it.each([ + { authoritative_location: 'https://cdn2.example.com/adagents.json' }, + { authoritative_location: null }, + { authoritative_location: {} }, + { superseded_by: 'https://cdn2.example.com/adagents.json' }, + { superseded_by: null }, + ])('rejects pointer-bearing authoritative targets even with inline agents: %j', async pointer => { + mockedSafeFetch.mockResolvedValueOnce({ + status: 200, data: buf({ authoritative_location: 'https://cdn.example.com/adagents.json' }), headers: {}, + }).mockResolvedValueOnce({ + status: 200, data: buf({ authorized_agents: [], ...pointer }), headers: {}, + }); + const result = await manager.validateDomain('example.com'); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('nested references not allowed'))).toBe(true); + expect(mockedSafeFetch).toHaveBeenCalledTimes(2); + }); + + it.each([ + { authoritative_location: null }, + { authoritative_location: {} }, + { authoritative_location: 'https://cdn.example.com/adagents.json', authorized_agents: [] }, + { authoritative_location: 'https://cdn.example.com/adagents.json', superseded_by: 'https://other.example/' }, + ])('refuses ambiguous publisher pointers without fetching their targets: %j', async manifest => { + mockedSafeFetch.mockResolvedValueOnce({ status: 200, data: buf(manifest), headers: {} }); + const result = await manager.validateDomain('example.com'); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('URL-only reference'))).toBe(true); + expect(mockedSafeFetch).toHaveBeenCalledTimes(1); + }); + it('handles network errors fetching authoritative file', async () => { const referenceData = { authoritative_location: 'https://cdn.example.com/adagents.json', diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts index 657db025b46..81fe91893b8 100644 --- a/server/tests/unit/services/supply-path-snapshot.test.ts +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -24,14 +24,14 @@ describe('registry cache authority provenance', () => { }); it('retains a previous denial when the next snapshot is stale', async () => { const store = new InMemoryStateStore(); - await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { revoked_publisher_domains: ['owner.example'] } }, store); + await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { authorized_agents: [], revoked_publisher_domains: ['owner.example'] } }, store); const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), fetchedAt: new Date(0) }, store); expect(result.manifest).toBeNull(); expect(result.held).toEqual(['owner.example']); }); it.each(['not a URL', 'https://[invalid', ''])('retains held denials when persisted provenance is malformed: %j', async resolvedUrl => { const store = new InMemoryStateStore(); - await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { revoked_publisher_domains: ['owner.example'] } }, store); + await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: { authorized_agents: [], revoked_publisher_domains: ['owner.example'] } }, store); const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), discoveryMethod: 'authoritative_location', resolvedUrl }, store); expect(result.manifest).toBeNull(); expect(result.held).toEqual(['owner.example']); @@ -41,4 +41,24 @@ describe('registry cache authority provenance', () => { }, store)).rejects.toThrow(/migration requires independent confirmation/); expect((await supplyPathSnapshotEvidence(publisher, snapshot(), store)).manifest).toEqual(snapshot().manifest); }); + it.each([ + {}, { authorized_agents: null }, + { authorized_agents: [], authoritative_location: 'https://other.example/chain.json' }, + { authorized_agents: [], authoritative_location: null }, + { authorized_agents: [], superseded_by: 'https://other.example/chain.json' }, + ])('retains denials without pinning an invalid cached manifest: %j', async invalid => { + const store = new InMemoryStateStore(); + const result = await supplyPathSnapshotEvidence(publisher, { + ...snapshot(), manifest: { ...invalid, revoked_publisher_domains: ['owner.example'] }, + }, store); + expect(result.manifest).toBeNull(); + expect(result.held).toEqual(['owner.example']); + const delegated = { ...snapshot(), discoveryMethod: 'authoritative_location', resolvedUrl: 'https://cdn.example/host.json' }; + expect((await supplyPathSnapshotEvidence(publisher, delegated, store)).manifest).toEqual(delegated.manifest); + // The valid manifest establishes the baseline; later invalid observations + // preserve that pin and cannot erase the held denial. + expect((await supplyPathSnapshotEvidence(publisher, { ...snapshot(), manifest: invalid }, store)).held).toEqual(['owner.example']); + await expect(supplyPathSnapshotEvidence(publisher, snapshot(), store)).rejects.toThrow(/migration requires independent confirmation/); + expect((await supplyPathSnapshotEvidence(publisher, delegated, store)).manifest).toEqual(delegated.manifest); + }); }); From 40f1093c7d1f486301ca2a5f05ae963855277b4a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 16:26:41 +0000 Subject: [PATCH 07/15] fix(supply-path): refuse empty fragments in authority locations --- server/src/adagents-manager.ts | 4 ++++ server/src/services/supply-path-authority-state.ts | 2 +- server/src/services/supply-path-snapshot.ts | 2 +- server/tests/unit/adagents-manager.test.ts | 10 ++++++++++ .../unit/services/supply-path-authority-state.test.ts | 4 ++++ .../tests/unit/services/supply-path-snapshot.test.ts | 1 + 6 files changed, 21 insertions(+), 2 deletions(-) diff --git a/server/src/adagents-manager.ts b/server/src/adagents-manager.ts index 5b2a85dd299..6ff8207ca5b 100644 --- a/server/src/adagents-manager.ts +++ b/server/src/adagents-manager.ts @@ -604,6 +604,10 @@ export class AdAgentsManager { }); return null; } + if (parsedUrl.href.includes('#')) { + result.errors.push({ field: 'authoritative_location', message: 'Authoritative location must not contain a URL fragment', severity: 'error' }); + return null; + } } catch { result.errors.push({ field: 'authoritative_location', diff --git a/server/src/services/supply-path-authority-state.ts b/server/src/services/supply-path-authority-state.ts index 0d6cad35f5b..5dae281dc49 100644 --- a/server/src/services/supply-path-authority-state.ts +++ b/server/src/services/supply-path-authority-state.ts @@ -64,6 +64,6 @@ export async function approveSupplyPathAuthorityChange(authority: string, locati function validateLocation(location: string): URL { const parsed = new URL(location); - if (location.length > 8192 || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.hash || (parsed.port && parsed.port !== '443')) throw new TypeError('Invalid publisher authority'); + if (location.length > 8192 || parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.href.includes('#') || (parsed.port && parsed.port !== '443')) throw new TypeError('Invalid publisher authority'); return parsed; } diff --git a/server/src/services/supply-path-snapshot.ts b/server/src/services/supply-path-snapshot.ts index dfd6a880e12..d23eb24d880 100644 --- a/server/src/services/supply-path-snapshot.ts +++ b/server/src/services/supply-path-snapshot.ts @@ -23,7 +23,7 @@ export async function supplyPathSnapshotEvidence(publisher: string, snapshot: Su // Corrupt cache provenance cannot establish authority. Still load held denials below. } } - const secure = resolved !== null && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.hash && (!resolved.port || resolved.port === '443'); + const secure = resolved !== null && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.href.includes('#') && (!resolved.port || resolved.port === '443'); const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved?.origin === origin)); const age = snapshot.fetchedAt ? Date.now() - snapshot.fetchedAt.getTime() : Infinity; const fresh = age >= 0 && age <= 7 * 86400000 && (!snapshot.expiresAt || snapshot.expiresAt.getTime() > Date.now()); diff --git a/server/tests/unit/adagents-manager.test.ts b/server/tests/unit/adagents-manager.test.ts index 19d5bad415a..becf585cbbc 100644 --- a/server/tests/unit/adagents-manager.test.ts +++ b/server/tests/unit/adagents-manager.test.ts @@ -1680,6 +1680,16 @@ describe('AdAgentsManager', () => { expect(result.errors.some(e => e.field === 'authoritative_location' && e.message.includes('File not found'))).toBe(true); }); + it('refuses an empty URL fragment before fetching the authoritative target', async () => { + mockedSafeFetch.mockResolvedValueOnce({ + status: 200, data: buf({ authoritative_location: 'https://cdn.example.com/adagents.json#' }), headers: {}, + }); + const result = await manager.validateDomain('example.com'); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.message.includes('URL fragment'))).toBe(true); + expect(mockedSafeFetch).toHaveBeenCalledTimes(1); + }); + it('prevents nested URL references (infinite loop protection)', async () => { const referenceData1 = { authoritative_location: 'https://cdn.example.com/adagents.json', diff --git a/server/tests/unit/services/supply-path-authority-state.test.ts b/server/tests/unit/services/supply-path-authority-state.test.ts index 5ffd080224d..5e88e870a2b 100644 --- a/server/tests/unit/services/supply-path-authority-state.test.ts +++ b/server/tests/unit/services/supply-path-authority-state.test.ts @@ -36,6 +36,10 @@ describe('persistent supply-path authority state', () => { const store = new InMemoryStateStore(); await expect(observeSupplyPathAuthority(host, { revoked_publisher_domains: ['owner.example', {}] }, pointer, store)).rejects.toThrow(/revocation evidence/); await expect(observeSupplyPathAuthority(host, {}, 'http://cdn.example/host.json', store)).rejects.toThrow(/authority/); + await expect(observeSupplyPathAuthority(host, {}, `${pointer}#`, store)).rejects.toThrow(/authority/); + await observeSupplyPathAuthority(host, revoked(), pointer, store); + await expect(approveSupplyPathAuthorityChange(host, `${pointer}#`, store)).rejects.toThrow(/authority/); + expect((await observeSupplyPathAuthority(host, {}, pointer, store)).revoked).toEqual(['owner.example']); }); }); diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts index 81fe91893b8..359d617c0d1 100644 --- a/server/tests/unit/services/supply-path-snapshot.test.ts +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -18,6 +18,7 @@ describe('registry cache authority provenance', () => { { expiresAt: new Date(0) }, { discoveryMethod: 'manager_domain' }, { discoveryMethod: 'community' }, { resolvedUrl: 'https://other.example/adagents.json' }, { discoveryMethod: 'authoritative_location', resolvedUrl: 'http://cdn.example/adagents.json' }, + { discoveryMethod: 'authoritative_location', resolvedUrl: 'https://cdn.example/adagents.json#' }, ])('refuses unauthoritative or stale evidence: %j', async patch => { const result = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), ...patch }, new InMemoryStateStore()); expect(result.manifest).toBeNull(); From 17e08c8c7505e01d908f1ce0b7af9ed46404788e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:17:50 -0400 Subject: [PATCH 08/15] =?UTF-8?q?fix(registry):=20renumber=20migration=205?= =?UTF-8?q?91=E2=86=92597=20to=20avoid=20collision=20with=20#7457?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 591 was already reserved by #7457 (591_compliance_refresh_authenticated_credential.sql). Renumbering to 597, the next uncontested slot after a main/open-PR allocation audit (main tops at 590; no open PRs claim 592–596). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017jbrQJh44D9RmYbz2yM6pY --- .../db/migrations/597_supply_path_manifest_provenance.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 server/src/db/migrations/597_supply_path_manifest_provenance.sql diff --git a/server/src/db/migrations/597_supply_path_manifest_provenance.sql b/server/src/db/migrations/597_supply_path_manifest_provenance.sql new file mode 100644 index 00000000000..f6629447ccf --- /dev/null +++ b/server/src/db/migrations/597_supply_path_manifest_provenance.sql @@ -0,0 +1,8 @@ +-- Authority evidence must describe the cached manifest, not the latest failed +-- crawl attempt. Successful manifest writes populate this atomically with the +-- complete (untruncated) URL, method, observation time and expiry. Existing rows +-- deliberately remain untrusted until refreshed; last-attempt fields cannot be +-- used to reconstruct historical provenance safely. +ALTER TABLE publishers ADD COLUMN IF NOT EXISTS supply_path_provenance JSONB; +COMMENT ON COLUMN publishers.supply_path_provenance IS + 'Successful manifest fetch provenance for supply-path verification; NULL until a provenance-aware refresh'; From 3a2c35df16180368340e1a7da065ff129f95c8b1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:17:56 -0400 Subject: [PATCH 09/15] fix(registry): remove old migration 591 (superseded by 597) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017jbrQJh44D9RmYbz2yM6pY --- .../db/migrations/591_supply_path_manifest_provenance.sql | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 server/src/db/migrations/591_supply_path_manifest_provenance.sql diff --git a/server/src/db/migrations/591_supply_path_manifest_provenance.sql b/server/src/db/migrations/591_supply_path_manifest_provenance.sql deleted file mode 100644 index f6629447ccf..00000000000 --- a/server/src/db/migrations/591_supply_path_manifest_provenance.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Authority evidence must describe the cached manifest, not the latest failed --- crawl attempt. Successful manifest writes populate this atomically with the --- complete (untruncated) URL, method, observation time and expiry. Existing rows --- deliberately remain untrusted until refreshed; last-attempt fields cannot be --- used to reconstruct historical provenance safely. -ALTER TABLE publishers ADD COLUMN IF NOT EXISTS supply_path_provenance JSONB; -COMMENT ON COLUMN publishers.supply_path_provenance IS - 'Successful manifest fetch provenance for supply-path verification; NULL until a provenance-aware refresh'; From 618c389e2403d3d6af29f909382c1bc06352427b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 18:19:24 +0000 Subject: [PATCH 10/15] fix(db): reserve supply-path provenance migration 597 --- ...path-manifest-provenance-migration.test.ts | 91 +++++++++++++++++++ specs/supply-path-migration-allocation.md | 61 +++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 server/tests/integration/supply-path-manifest-provenance-migration.test.ts create mode 100644 specs/supply-path-migration-allocation.md diff --git a/server/tests/integration/supply-path-manifest-provenance-migration.test.ts b/server/tests/integration/supply-path-manifest-provenance-migration.test.ts new file mode 100644 index 00000000000..297a58f365b --- /dev/null +++ b/server/tests/integration/supply-path-manifest-provenance-migration.test.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { Pool, type PoolClient } from 'pg'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const migration = readFileSync( + resolve(__dirname, '../../src/db/migrations/597_supply_path_manifest_provenance.sql'), + 'utf8' +); +const schema = `supply_path_migration_${randomUUID().replaceAll('-', '')}`; + +describe.skipIf(!process.env.DATABASE_URL)('migration 597: manifest-bound supply-path provenance', () => { + let pool: Pool; + let client: PoolClient; + + beforeAll(async () => { + pool = new Pool({ connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 5000 }); + client = await pool.connect(); + await client.query(`CREATE SCHEMA ${schema}`); + await client.query(`SET search_path TO ${schema}, public`); + }); + + beforeEach(async () => { + await client.query('BEGIN'); + await client.query(`CREATE TABLE publishers ( + domain TEXT PRIMARY KEY, + adagents_json JSONB, + resolved_url TEXT, + last_validated TIMESTAMPTZ + )`); + }); + + afterEach(async () => { + await client.query('ROLLBACK'); + }); + + afterAll(async () => { + if (client) { + await client.query('RESET search_path'); + await client.query(`DROP SCHEMA ${schema} CASCADE`); + client.release(); + } + await pool?.end(); + }); + + it('keeps historical and newly inserted manifests untrusted until a provenance-aware refresh', async () => { + const manifest = { authorized_agents: [] }; + await client.query( + `INSERT INTO publishers (domain, adagents_json, resolved_url, last_validated) + VALUES ('publisher.example', $1, 'https://failed-target.example/adagents.json', NOW())`, + [manifest] + ); + await client.query(migration); + await client.query(`INSERT INTO publishers (domain) VALUES ('new.example')`); + + const result = await client.query( + 'SELECT domain, adagents_json, supply_path_provenance FROM publishers ORDER BY domain' + ); + expect(result.rows).toEqual([ + { domain: 'new.example', adagents_json: null, supply_path_provenance: null }, + { domain: 'publisher.example', adagents_json: manifest, supply_path_provenance: null }, + ]); + const column = await client.query( + `SELECT data_type, is_nullable, column_default FROM information_schema.columns + WHERE table_schema = $1 AND table_name = 'publishers' AND column_name = 'supply_path_provenance'`, + [schema] + ); + expect(column.rows).toEqual([{ data_type: 'jsonb', is_nullable: 'YES', column_default: null }]); + }); + + it('replays without changing a successful manifest or its full provenance', async () => { + await client.query(migration); + const manifest = { authorized_agents: [{ url: 'https://agent.example/mcp' }] }; + const provenance = { + resolved_url: `https://authority.example/manifests/${'a'.repeat(3000)}?publisher=publisher.example`, + discovery_method: 'authoritative_location', + fetched_at: '2026-09-14T00:00:00Z', + expires_at: '2026-09-15T00:00:00Z', + }; + await client.query( + `INSERT INTO publishers (domain, adagents_json, supply_path_provenance) + VALUES ('publisher.example', $1, $2)`, + [manifest, provenance] + ); + await client.query(migration); + await client.query(migration); + const result = await client.query('SELECT adagents_json, supply_path_provenance FROM publishers'); + expect(result.rows).toEqual([{ adagents_json: manifest, supply_path_provenance: provenance }]); + }); +}); diff --git a/specs/supply-path-migration-allocation.md b/specs/supply-path-migration-allocation.md new file mode 100644 index 00000000000..76f8d28701f --- /dev/null +++ b/specs/supply-path-migration-allocation.md @@ -0,0 +1,61 @@ +# Supply-path provenance migration allocation + +Migration `597_supply_path_manifest_provenance.sql` adds the nullable JSONB +`publishers.supply_path_provenance` column. Existing manifests remain untrusted +until a successful provenance-aware refresh writes both the manifest and its +provenance. Replaying the migration preserves those successful observations. + +## Allocation and protected neighbors + +The [coordinator hold on #7513](https://github.com/adcontextprotocol/adcp/pull/7513#issuecomment-5668561258) +identified that its original 591 filename collided with #7457's earlier +reservation. The correction changes only the filename: the SQL remains blob +`f6629447ccfecb28834380e9efe4863099454555`. Do not apply the obsolete +`591_supply_path_manifest_provenance.sql` filename or rewrite an existing +591 ledger entry to disguise a collision. + +The 2026-09-14 allocation audit used main +`1467e46117e8d329f4116e548e9290be75d8e3b3`, the migration directories at all +50 open PR heads, and the coordinator's unpublished reservation inventory: + +| Version | Owner | Migration / reservation | +| ------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | +| 588 | [#7433](https://github.com/adcontextprotocol/adcp/pull/7433) | `588_agent_compliance_runner_version.sql` | +| 589 | main | `589_compliance_run_publication.sql` | +| 590 | main | `590_compliance_run_provenance.sql` | +| 591 | [#7457](https://github.com/adcontextprotocol/adcp/pull/7457) | `591_compliance_refresh_authenticated_credential.sql` | +| 592 | [#7463](https://github.com/adcontextprotocol/adcp/pull/7463) | `592_email_mutations.sql` | +| 593 | [#7464](https://github.com/adcontextprotocol/adcp/pull/7464) | `593_admin_credential_bind_operations.sql`; inherited unchanged by #7460 | +| 594 | unpublished Slack work | Reserved; do not reallocate | +| 595 | [#7501](https://github.com/adcontextprotocol/adcp/pull/7501) | `595_normalized_email_invariant.sql` | +| 596 | unpublished grant lifecycle work | Reserved; do not reallocate | +| 597 | [#7513](https://github.com/adcontextprotocol/adcp/pull/7513) | `597_supply_path_manifest_provenance.sql` | + +#7457's protected 591 SQL is blob +`207b23673abb6fff82b36dc1adcdd1c9de9f894b` at commit +`be9cd83508bd4b00b7eaafac63b1fba84c92c396`. The open-PR audit found no other +597 file. Repeat the live main/open-PR/reservation audit immediately before +publishing; this snapshot does not inventory undisclosed worktrees. + +## Dependencies and qualification + +597 depends only on the existing `publishers` table. It has no SQL or +application dependency on the #6827 identity chain and can be applied before +or after that chain. Preserve the chain's required internal order: +**591 → 592 → 593 → 594 → 595 → 596**. No file or reservation in that chain +is imported, renumbered, or changed by #7513. + +The migrator identifies applied entries by version and filename, rejects +duplicate version files before applying them, and fails on a conflicting +modern ledger filename. Missing lower-numbered migrations can still be applied +when their owning changes subsequently land; 597 does not advance a high-water +mark that skips them. + +Qualification requires migration naming/duplicate validation, a clean database +apply and no-op replay, and a combined 588–597 apply/replay using the exact +reserved SQL in order. The dedicated PostgreSQL regression +`server/tests/integration/supply-path-manifest-provenance-migration.test.ts` +checks that historical/new rows remain untrusted and that repeated application +preserves the full successful manifest provenance. Keep the PR Draft with +auto-merge off until independent exact-head review and human maintainer +approval clear the coordinator hold. From 8807b37170e6e1707bdce1c955dafad1ed372e58 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 18:29:02 +0000 Subject: [PATCH 11/15] docs(db): record verified migration 588 through 597 composition --- specs/supply-path-migration-allocation.md | 55 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/specs/supply-path-migration-allocation.md b/specs/supply-path-migration-allocation.md index 76f8d28701f..e07a7af9148 100644 --- a/specs/supply-path-migration-allocation.md +++ b/specs/supply-path-migration-allocation.md @@ -18,18 +18,18 @@ The 2026-09-14 allocation audit used main `1467e46117e8d329f4116e548e9290be75d8e3b3`, the migration directories at all 50 open PR heads, and the coordinator's unpublished reservation inventory: -| Version | Owner | Migration / reservation | -| ------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | -| 588 | [#7433](https://github.com/adcontextprotocol/adcp/pull/7433) | `588_agent_compliance_runner_version.sql` | -| 589 | main | `589_compliance_run_publication.sql` | -| 590 | main | `590_compliance_run_provenance.sql` | -| 591 | [#7457](https://github.com/adcontextprotocol/adcp/pull/7457) | `591_compliance_refresh_authenticated_credential.sql` | -| 592 | [#7463](https://github.com/adcontextprotocol/adcp/pull/7463) | `592_email_mutations.sql` | -| 593 | [#7464](https://github.com/adcontextprotocol/adcp/pull/7464) | `593_admin_credential_bind_operations.sql`; inherited unchanged by #7460 | -| 594 | unpublished Slack work | Reserved; do not reallocate | -| 595 | [#7501](https://github.com/adcontextprotocol/adcp/pull/7501) | `595_normalized_email_invariant.sql` | -| 596 | unpublished grant lifecycle work | Reserved; do not reallocate | -| 597 | [#7513](https://github.com/adcontextprotocol/adcp/pull/7513) | `597_supply_path_manifest_provenance.sql` | +| Version | Owner | Migration / reservation | +| ------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| 588 | [#7433](https://github.com/adcontextprotocol/adcp/pull/7433) | `588_agent_compliance_runner_version.sql` | +| 589 | main | `589_compliance_run_publication.sql` | +| 590 | main | `590_compliance_run_provenance.sql` | +| 591 | [#7457](https://github.com/adcontextprotocol/adcp/pull/7457) | `591_compliance_refresh_authenticated_credential.sql` | +| 592 | [#7463](https://github.com/adcontextprotocol/adcp/pull/7463) | `592_email_mutations.sql` | +| 593 | [#7464](https://github.com/adcontextprotocol/adcp/pull/7464) | `593_admin_credential_bind_operations.sql`; inherited unchanged by #7460 | +| 594 | unpublished Slack work | `594_slack_identity_binding_operations.sql`; reserved, do not reallocate | +| 595 | [#7501](https://github.com/adcontextprotocol/adcp/pull/7501) | `595_normalized_email_invariant.sql` | +| 596 | unpublished grant lifecycle work | `596_organization_credential_grant_lifecycle.sql`; reserved, do not reallocate | +| 597 | [#7513](https://github.com/adcontextprotocol/adcp/pull/7513) | `597_supply_path_manifest_provenance.sql` | #7457's protected 591 SQL is blob `207b23673abb6fff82b36dc1adcdd1c9de9f894b` at commit @@ -59,3 +59,34 @@ checks that historical/new rows remain untrusted and that repeated application preserves the full successful manifest provenance. Keep the PR Draft with auto-merge off until independent exact-head review and human maintainer approval clear the coordinator hold. + +## Combined replay evidence + +The reservation owners supplied read-only SQL inputs, independently verified +against their Git blobs and SHA-256 digests before use: + +- 594: commit `21cbb4ffd8e889ef5ac56a69770ea793462585ac`, blob + `eb85dbf1271d278936b9d602abc9401fff6e100f`, 1518 bytes, SHA-256 + `19ee60aaa9f52b1abe3022d6b2533f05d4c61156edc1b405987c08ed0921158c`. +- 596: commit `9052790e34e461093f64c1149457488d8cdd13e3`, blob + `ed920189da8ea6519def714b80de8a4b5cc19017`, 22999 bytes, SHA-256 + `b3eb30a00e8d1fbaff679d75193bb4fc43c3bff4416bc8536a0e2b49210d0396`. + +The 2026-09-14 PostgreSQL composition check applied 579 ordinary migrations +from an empty database, including every version 588–597 in order. Evaluator-only +584/585 retained their existing external-provisioning exclusion. Replaying +preserved every ledger row, timestamp, and the full schema dump hash. + +A second database started with main plus 597, then applied the still-pending +588 and 591–596 in order. It preserved the existing 597 ledger entry and a +successful manifest/provenance pair; replay again made no changes. Its schema +diff against the fresh database consisted only of the physical column position +of `agent_compliance_runs.runner_capability_version`: reserved 588 arrived +after main's already-applied 589/590. All other schema dump bytes matched. + +Negative controls in isolated databases reproduced both the duplicate 591 +file and an incorrect modern 591 ledger filename. The first failed before any +migration was applied; the second failed without modifying the existing ledger +or schema. No production/shared database ledger was repaired or rewritten. +These SQL checks do not approve the held parent application compositions or +replace fresh final-head CI, independent review, or human maintainer approval. From dcd9af8e03dcf361a0ba59897c57e41d5b6cfeb6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 18:30:30 +0000 Subject: [PATCH 12/15] style(registry): remove trailing contract blank line --- server/src/services/supply-path-contract.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/services/supply-path-contract.ts b/server/src/services/supply-path-contract.ts index a93e63d79e1..1e62a9e1ded 100644 --- a/server/src/services/supply-path-contract.ts +++ b/server/src/services/supply-path-contract.ts @@ -98,4 +98,3 @@ export interface SupplyPathInput { /** False when the caller deliberately skipped IAB evidence after host authorization succeeded. */ inventoryPartnerDomainEvaluated?: boolean; } - From 4f2cff18e314b1bdd8ea233483d347720819234c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 18:36:56 +0000 Subject: [PATCH 13/15] docs(db): refresh migration allocation audit after main advances --- specs/supply-path-migration-allocation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/supply-path-migration-allocation.md b/specs/supply-path-migration-allocation.md index e07a7af9148..5a9bd5f3a75 100644 --- a/specs/supply-path-migration-allocation.md +++ b/specs/supply-path-migration-allocation.md @@ -15,7 +15,7 @@ reservation. The correction changes only the filename: the SQL remains blob 591 ledger entry to disguise a collision. The 2026-09-14 allocation audit used main -`1467e46117e8d329f4116e548e9290be75d8e3b3`, the migration directories at all +`70a91fe9f7eab38910e0877f56cf0e3332a21a78`, the migration directories at all 50 open PR heads, and the coordinator's unpublished reservation inventory: | Version | Owner | Migration / reservation | @@ -24,7 +24,7 @@ The 2026-09-14 allocation audit used main | 589 | main | `589_compliance_run_publication.sql` | | 590 | main | `590_compliance_run_provenance.sql` | | 591 | [#7457](https://github.com/adcontextprotocol/adcp/pull/7457) | `591_compliance_refresh_authenticated_credential.sql` | -| 592 | [#7463](https://github.com/adcontextprotocol/adcp/pull/7463) | `592_email_mutations.sql` | +| 592 | [#7463](https://github.com/adcontextprotocol/adcp/pull/7463) | `592_email_mutations.sql`; inherited unchanged by #7499 | | 593 | [#7464](https://github.com/adcontextprotocol/adcp/pull/7464) | `593_admin_credential_bind_operations.sql`; inherited unchanged by #7460 | | 594 | unpublished Slack work | `594_slack_identity_binding_operations.sql`; reserved, do not reallocate | | 595 | [#7501](https://github.com/adcontextprotocol/adcp/pull/7501) | `595_normalized_email_invariant.sql` | From afad2e9fc5eb3d3cec59792336102a661e63c7a1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 14 Sep 2026 14:46:18 +0000 Subject: [PATCH 14/15] test(addie): stabilize PostgreSQL ledger race barriers --- ...ent-smoke-private-ledger-migration.test.ts | 89 ++++++++++++++++--- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/server/tests/integration/addie/fixed-trace-component-smoke-private-ledger-migration.test.ts b/server/tests/integration/addie/fixed-trace-component-smoke-private-ledger-migration.test.ts index c210bc25814..2f260ae61d6 100644 --- a/server/tests/integration/addie/fixed-trace-component-smoke-private-ledger-migration.test.ts +++ b/server/tests/integration/addie/fixed-trace-component-smoke-private-ledger-migration.test.ts @@ -39,6 +39,29 @@ function deferred() { return { promise, resolve: () => resolve?.() }; } +async function waitForBarrier(promise: Promise, description: string) { + let timer: ReturnType | undefined; + try { + await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out waiting for ${description}`)), 5_000); + }), + ]); + } finally { clearTimeout(timer); } +} + +async function waitForRowLock(waiterPid: number, blockerPid: number, queryFragment: string) { + await expect.poll(async () => (await client!.query( + `SELECT state, wait_event_type, wait_event, query, pg_blocking_pids(pid) AS blockers + FROM pg_stat_activity WHERE pid = $1`, + [waiterPid], + )).rows, { timeout: 5_000, interval: 10, message: `backend ${waiterPid} must wait for a row held by ${blockerPid}` }).toEqual([{ + state: 'active', wait_event_type: 'Lock', wait_event: 'transactionid', + query: expect.stringContaining(queryFragment), blockers: expect.arrayContaining([blockerPid]), + }]); +} + async function insertAuthorization( subject = client!, aggregateAdmissionFingerprint = admission.fingerprints.aggregateAdmission, @@ -380,6 +403,7 @@ describe.skipIf(!databaseUrl)('private ledger migration on PostgreSQL', () => { const opened = dispatchEntry(21); const precheckRead = deferred(); const releasePrecheck = deferred(); + const recoveryPlanAcquired = deferred(); let blocked = false; await client!.query('COMMIT'); const pool = new Pool({ connectionString: databaseUrl, max: 2 }); @@ -389,11 +413,22 @@ describe.skipIf(!databaseUrl)('private ledger migration on PostgreSQL', () => { const mutable = connection as unknown as { query: (sql: string, values?: readonly unknown[]) => Promise }; const query = mutable.query.bind(connection); mutable.query = async (sql, values) => { + // This race checks ordering, including recovery's deferred COMMIT + // checks. Give only these test transactions room for CI scheduling; + // the lock-failure regression below retains the production bounds. + if (sql.startsWith('SET LOCAL lock_timeout =')) return query("SET LOCAL lock_timeout = '15s'"); + if (sql.startsWith('SET LOCAL statement_timeout =')) return query("SET LOCAL statement_timeout = '15s'"); const output = await query(sql, values); if (!blocked && sql.startsWith('SELECT 1 FROM addie_fixed_trace_component_smoke_attempts') && sql.includes("status = 'intent_recorded'")) { blocked = true; precheckRead.resolve(); - await releasePrecheck.promise; + await waitForBarrier(releasePrecheck.promise, 'precheck release'); + } + if (sql.startsWith('SELECT assignment_id FROM addie_fixed_trace_component_smoke_run_plan') + && sql.includes('ORDER BY assignment_id FOR UPDATE')) { + // Signal only after PostgreSQL returned the complete locked plan. + // The pending precheck cannot start its own recovery before release. + recoveryPlanAcquired.resolve(); } return output; }; @@ -401,20 +436,27 @@ describe.skipIf(!databaseUrl)('private ledger migration on PostgreSQL', () => { }, }; const contender = new Client({ connectionString: databaseUrl }); - await contender.connect(); + let pendingIntent: ReturnType | undefined; + let recovery: ReturnType | undefined; try { + await contender.connect(); const ledger = new PostgresFixedTraceComponentSmokePrivateLedger(controlledPool as never); - const pendingIntent = ledger.recordProviderIntent({ reservation: reservationFor(authorizationDigest), attemptId: uniqueAttemptId(), assignmentId: target.assignmentId, invocationOrdinal: 1, preparedRequestHmac: 'c'.repeat(64) }); - await precheckRead.promise; + pendingIntent = ledger.recordProviderIntent({ reservation: reservationFor(authorizationDigest), attemptId: uniqueAttemptId(), assignmentId: target.assignmentId, invocationOrdinal: 1, preparedRequestHmac: 'c'.repeat(64) }); + await waitForBarrier(precheckRead.promise, 'provider-intent precheck'); // This committed intent is deliberately injected after the initial // precheck but before the target plan lock is acquired. await insertIntent(contender, authorizationDigest, opened, uniqueAttemptId().slice('attempt_'.length)); - const recovery = ledger.recordUnknownExposure(reservationFor(authorizationDigest)); + recovery = ledger.recordUnknownExposure(reservationFor(authorizationDigest)); + await waitForBarrier(recoveryPlanAcquired.promise, 'external recovery complete-plan lock'); releasePrecheck.resolve(); expect(await recovery).toEqual({ status: 'recorded' }); expect(await pendingIntent).toEqual({ status: 'refused', reason: 'unknown_exposure' }); expect((await client!.query("SELECT a.status, count(*) FILTER (WHERE t.status = 'intent_recorded')::int AS open FROM addie_fixed_trace_component_smoke_authorizations a LEFT JOIN addie_fixed_trace_component_smoke_attempts t USING (authorization_digest) WHERE a.authorization_digest = $1 GROUP BY a.status", [authorizationDigest])).rows).toEqual([{ status: 'unknown_exposure', open: 0 }]); - } finally { await contender.end(); await pool.end(); await client!.query('BEGIN'); } + } finally { + releasePrecheck.resolve(); + await Promise.allSettled([pendingIntent, recovery]); + await contender.end(); await pool.end(); await client!.query('BEGIN'); + } }); it('reports recovery lock failure as uncertainty and never claims durable poisoning', async () => { @@ -468,30 +510,51 @@ describe.skipIf(!databaseUrl)('private ledger migration on PostgreSQL', () => { await client!.query('COMMIT'); const holder = new Client({ connectionString: databaseUrl }); const direct = new Client({ connectionString: databaseUrl }); - const pool = new Pool({ connectionString: databaseUrl, max: 1 }); - await holder.connect(); await direct.connect(); + const recoveryConnection = new Client({ connectionString: databaseUrl }); + const controlledPool = { + connect: async () => ({ + query: (sql: string, values?: unknown[]) => { + // Only this recovery transaction needs time for two DB-visible + // barriers. The separate 50 ms regression covers real lock failure. + if (sql.startsWith('SET LOCAL lock_timeout =')) return recoveryConnection.query("SET LOCAL lock_timeout = '15s'"); + if (sql.startsWith('SET LOCAL statement_timeout =')) return recoveryConnection.query("SET LOCAL statement_timeout = '15s'"); + return recoveryConnection.query(sql, values); + }, + release: () => undefined, // Dedicated session is closed in finally. + }), + }; + let recovery: ReturnType | undefined; + let directInsert: Promise | undefined; try { + await holder.connect(); await direct.connect(); await recoveryConnection.connect(); + const holderPid = (await holder.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid; + const directPid = (await direct.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid; + const recoveryPid = (await recoveryConnection.query<{ pid: number }>('SELECT pg_backend_pid() AS pid')).rows[0]!.pid; + // Bound the direct INSERT too, without changing any other session. + await direct.query("SET statement_timeout = '15s'"); await holder.query('BEGIN'); await holder.query('SELECT assignment_id FROM addie_fixed_trace_component_smoke_run_plan WHERE authorization_digest = $1 AND assignment_id = $2 FOR UPDATE', [authorizationDigest, held.assignmentId]); - const recovery = new PostgresFixedTraceComponentSmokePrivateLedger(pool).recordUnknownExposure(reservationFor(authorizationDigest)); + recovery = new PostgresFixedTraceComponentSmokePrivateLedger(controlledPool as never).recordUnknownExposure(reservationFor(authorizationDigest)); let recoverySettled = false; void recovery.then(() => { recoverySettled = true; }); - await new Promise((resolve) => setTimeout(resolve, 75)); + await waitForRowLock(recoveryPid, holderPid, 'ORDER BY assignment_id FOR UPDATE'); expect(recoverySettled).toBe(false); let directSettled = false; - const directInsert = insertIntent(direct, authorizationDigest, inserted, uniqueAttemptId().slice('attempt_'.length)) + directInsert = insertIntent(direct, authorizationDigest, inserted, uniqueAttemptId().slice('attempt_'.length)) .then(() => undefined, (error: unknown) => error) .then((outcome) => { directSettled = true; return outcome; }); - await new Promise((resolve) => setTimeout(resolve, 75)); + await waitForRowLock(directPid, recoveryPid, 'INSERT INTO addie_fixed_trace_component_smoke_attempts'); expect(directSettled).toBe(false); await holder.query('COMMIT'); expect(await recovery).toEqual({ status: 'recorded' }); expect(await directInsert).toBeInstanceOf(Error); + expect(await directInsert).toMatchObject({ code: 'P0001', message: 'provider assignment is already terminal' }); expect((await client!.query('SELECT status FROM addie_fixed_trace_component_smoke_authorizations WHERE authorization_digest = $1', [authorizationDigest])).rows).toEqual([{ status: 'unknown_exposure' }]); expect((await client!.query('SELECT count(*)::int AS count FROM addie_fixed_trace_component_smoke_attempts WHERE authorization_digest = $1', [authorizationDigest])).rows).toEqual([{ count: 0 }]); } finally { await holder.query('ROLLBACK').catch(() => undefined); - await holder.end(); await direct.end(); await pool.end(); await client!.query('BEGIN'); + await Promise.allSettled([recovery, directInsert]); + await holder.end(); await direct.end(); await recoveryConnection.end(); await client!.query('BEGIN'); } }); From c51a7bbca07f2f6c80aec2766d85077688bab7c3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 15 Sep 2026 18:35:06 +0000 Subject: [PATCH 15/15] fix(registry): harden supply-path cache admission --- .../collections-and-installments.mdx | 3 +- server/src/crawler.ts | 67 +- server/src/db/publisher-db.ts | 61 +- .../services/supply-path-authority-state.ts | 19 +- server/src/services/supply-path-snapshot.ts | 10 +- server/src/services/supply-path-verifier.ts | 58 ++ .../registry-crawler-cache.test.ts | 162 +++- ...wler-single-domain-profile-rebuild.test.ts | 39 + .../supply-path-authority-state.test.ts | 9 + .../services/supply-path-snapshot.test.ts | 14 + .../services/supply-path-verifier.test.ts | 7 +- .../source/test-vectors/supply-path/README.md | 4 +- .../test-vectors/supply-path/vectors.json | 738 ++++++++++++++++-- 13 files changed, 1085 insertions(+), 106 deletions(-) diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index 5db5c81afe9..8aad48333b3 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -42,6 +42,7 @@ A collection is a persistent, publisher-controlled content program that produces | Field | Type | Required | Description | |-------|------|----------|-------------| | `collection_id` | string | Yes | Publisher-assigned identifier. Declared in the publisher's `adagents.json`. Products reference collections via `collections` selectors. Use distribution identifiers for cross-seller matching. | +| `publisher_domain` | string | Conditional | Owning publisher namespace. Required when a collection is served from a cross-origin shared authoritative document; optional in a publisher-origin document. | | `kind` | string | No | What kind of content program: `series` (TV/podcast), `publication` (print issues), `event_series` (live events), `rotation` (DOOH), `channel` (continuously programmed audio/video stream). Defaults to `series`. | | `name` | string | Yes | Human-readable collection name | | `description` | string | No | What the collection is about | @@ -257,7 +258,7 @@ existing owner-sales clause, and the upgrade path from an The registry supply-path endpoint returns `semantics_version: "1"`. Its evaluator and authoritative SDK implementations consume the same [canonical golden vectors](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/supply-path/vectors.json). Consumers MUST NOT treat results from older evaluator semantics as equivalent to version 1. -Version 1 requires an affirmative host `collections` selector naming the owner. A broad property grant with no collection constraint does not attest owner-sold carriage. Every claimed host property ID must resolve, and the matching grant must cover all of them. Identifier-only distribution entries remain valid discovery data, but cannot prove a host property binding. Missing or unknown authorization discriminators, malformed selectors, and country, effective-time or placement constraints that cannot be evaluated in the supplied context fail closed. Revocations take precedence over declarations. Agent URL identity follows the protocol canonicalization rules and preserves case-sensitive paths and query values. +Version 1 requires an affirmative host `collections` selector naming the owner. A broad property grant with no collection constraint does not attest owner-sold carriage. Every claimed host property ID must resolve, and the matching grant must cover all of them. Identifier-only distribution entries remain valid discovery data, but cannot prove a host property binding. Missing or unknown authorization discriminators and their companion selectors fail closed on both the owner's and host's agent declarations. Malformed selectors and country, effective-time or placement constraints that cannot be evaluated in the supplied context also fail closed. Revocations take precedence over declarations. Agent URL identity follows the protocol canonicalization rules and preserves case-sensitive paths and query values. A query omitting `collection_id` returns the strongest complete individual collection path. It never combines one collection's carriage with another collection's authorization. The weaker `host_delegated` state retains its documented owner declarations plus host inventory-partner evidence requirements; it does not require a host `adagents.json`, prove concrete property coverage, or override revocation. diff --git a/server/src/crawler.ts b/server/src/crawler.ts index 45a2d8edf2b..197e3fe1bb4 100644 --- a/server/src/crawler.ts +++ b/server/src/crawler.ts @@ -8,6 +8,8 @@ import { AdAgentsManager, type AdAgentsValidationResult } from "./adagents-manag import { BrandManager, type BrandValidationResult, type HousePortfolioVariant } from "./brand-manager.js"; import { BrandDatabase } from "./db/brand-db.js"; import { + AdagentsManifestProvenanceError, + AdagentsManifestRollbackError, PublisherCrawlLeaseLostError, PublisherDatabase, adagentsChangedFields, @@ -15,6 +17,7 @@ import { type AdagentsManifest, type AdagentsAuthorizedAgent, } from "./db/publisher-db.js"; +import { SupplyPathAuthorityChangeUnconfirmedError } from './services/supply-path-authority-state.js'; import { canonicalizePublisherDomain } from "./services/publisher-domain.js"; import { MemberDatabase } from "./db/member-db.js"; import { CapabilityDiscovery } from "./capabilities.js"; @@ -1235,14 +1238,13 @@ export class CrawlerService { try { const validation = await this.adAgentsManager.validateDomain(pubConfig.domain); assertExecutionLock(); - processedDomains.add(pubConfig.domain); if (validation.valid && validation.raw_data?.authorized_agents) { const agentCount = validation.raw_data.authorized_agents.length; const propCount = validation.raw_data.properties?.length || 0; log.debug({ domain: pubConfig.domain, agentCount, propCount }, 'Domain crawled'); - await this.cacheAdagentsManifest( + const cachePersisted = await this.cacheAdagentsManifest( pubConfig.domain, validation.raw_data as AdagentsManifest, { @@ -1253,6 +1255,10 @@ export class CrawlerService { managerDomain: validation.manager_domain, }, ); + if (!cachePersisted) { + continue; + } + processedDomains.add(pubConfig.domain); // Record agents for (const authorizedAgent of validation.raw_data.authorized_agents) { @@ -1327,18 +1333,20 @@ export class CrawlerService { // Check if domain has valid adagents.json const validation = await this.adAgentsManager.validateDomain(domain); assertExecutionLock(); - await this.federatedIndex.recordPublisherFromAgent( - domain, - agent.url, - validation.valid - ); + if (!validation.valid || !validation.raw_data?.authorized_agents) { + await this.federatedIndex.recordPublisherFromAgent(domain, agent.url, false); + continue; + } + // A prior successful cache admission is the only reason a processed + // domain may accept another agent claim without repeating the write. + if (processedDomains.has(domain)) { + await this.federatedIndex.recordPublisherFromAgent(domain, agent.url, true); + continue; + } // If valid and not already processed, record agents and properties from adagents.json - if (validation.valid && validation.raw_data?.authorized_agents && !processedDomains.has(domain)) { - await this.federatedIndex.markPublisherHasValidAdagents(domain); - processedDomains.add(domain); - - await this.cacheAdagentsManifest( + { + const cachePersisted = await this.cacheAdagentsManifest( domain, validation.raw_data as AdagentsManifest, { @@ -1349,6 +1357,10 @@ export class CrawlerService { managerDomain: validation.manager_domain, }, ); + if (!cachePersisted) continue; + await this.federatedIndex.recordPublisherFromAgent(domain, agent.url, true); + await this.federatedIndex.markPublisherHasValidAdagents(domain); + processedDomains.add(domain); for (const authorizedAgent of validation.raw_data.authorized_agents) { assertExecutionLock(); @@ -1399,22 +1411,27 @@ export class CrawlerService { try { const validation = await this.adAgentsManager.validateDomain(domain); assertExecutionLock(); - // Always write the agent_claim row so this discovered agent's authorization - // edge is recorded even when the domain was already processed by step 1/2. - await this.federatedIndex.recordPublisherFromAgent(domain, da.agent_url, validation.valid); - if (processedDomains.has(domain)) continue; - - if (validation.valid && validation.raw_data?.authorized_agents) { - await this.federatedIndex.markPublisherHasValidAdagents(domain); - processedDomains.add(domain); + if (!validation.valid || !validation.raw_data?.authorized_agents) { + await this.federatedIndex.recordPublisherFromAgent(domain, da.agent_url, false); + continue; + } + if (processedDomains.has(domain)) { + await this.federatedIndex.recordPublisherFromAgent(domain, da.agent_url, true); + continue; + } - await this.cacheAdagentsManifest(domain, validation.raw_data as AdagentsManifest, { + { + const cachePersisted = await this.cacheAdagentsManifest(domain, validation.raw_data as AdagentsManifest, { statusCode: validation.status_code, responseBytes: validation.response_bytes, resolvedUrl: validation.resolved_url, discoveryMethod: validation.discovery_method, managerDomain: validation.manager_domain, }); + if (!cachePersisted) continue; + await this.federatedIndex.recordPublisherFromAgent(domain, da.agent_url, true); + await this.federatedIndex.markPublisherHasValidAdagents(domain); + processedDomains.add(domain); for (const authorizedAgent of validation.raw_data.authorized_agents) { assertExecutionLock(); @@ -1915,6 +1932,11 @@ export class CrawlerService { } catch (err) { if (err instanceof CrawlExecutionLockLostError) throw err; if (err instanceof PublisherCrawlLeaseLostError) throw err; + // Security admission failures must stop every caller before any legacy + // or federated projection outside the cache transaction can run. + if (err instanceof SupplyPathAuthorityChangeUnconfirmedError) throw err; + if (err instanceof AdagentsManifestProvenanceError) throw err; + if (err instanceof AdagentsManifestRollbackError) throw err; log.warn({ domain, err: err instanceof Error ? err.message : err }, 'Publisher cache write failed'); return false; } @@ -3506,7 +3528,7 @@ export class CrawlerService { return false; } - await this.cacheAdagentsManifest( + const cachePersisted = await this.cacheAdagentsManifest( domain, validation.raw_data as AdagentsManifest, { @@ -3519,6 +3541,7 @@ export class CrawlerService { eventSource: 'catalog_crawl', }, ); + if (!cachePersisted) return false; for (const authorizedAgent of validation.raw_data.authorized_agents) { assertExecutionLock(); diff --git a/server/src/db/publisher-db.ts b/server/src/db/publisher-db.ts index a46f9546ad8..a8b3cd11c90 100644 --- a/server/src/db/publisher-db.ts +++ b/server/src/db/publisher-db.ts @@ -6,6 +6,8 @@ import { normalizeIdentifier } from '../services/identifier-normalization.js'; import { canonicalizePublisherDomain } from '../services/publisher-domain.js'; import { createLogger } from '../logger.js'; import type { PoolClient } from 'pg'; +import { PostgresStateStore } from '@adcp/sdk/server'; +import { supplyPathSnapshotEvidence } from '../services/supply-path-snapshot.js'; const log = createLogger('publisher-db'); const ADAGENTS_CACHE_LOCK_TIMEOUT_MS = 5_000; @@ -161,6 +163,27 @@ export class PublisherCrawlLeaseLostError extends Error { } } +export class AdagentsManifestRollbackError extends Error { + constructor() { + super('Refreshed adagents.json last_updated is older than the cached manifest'); + this.name = 'AdagentsManifestRollbackError'; + } +} + +export class AdagentsManifestProvenanceError extends Error { + constructor() { + super('Refreshed adagents.json provenance is incomplete or not authoritative'); + this.name = 'AdagentsManifestProvenanceError'; + } +} + +function manifestUpdatedAt(manifest: AdagentsManifest | null): number | null { + const value = manifest?.last_updated; + if (typeof value !== 'string') return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + export interface RecordAdagentsValidationFailureInput { domain: string; statusCode?: number; @@ -1159,11 +1182,17 @@ export class PublisherDatabase { ); if (lease.rowCount !== 1) throw new PublisherCrawlLeaseLostError(); } - const previousResult = await client.query<{ adagents_json: AdagentsManifest | null }>( - `SELECT adagents_json FROM publishers WHERE domain = $1 FOR UPDATE`, + const previousResult = await client.query<{ + adagents_json: AdagentsManifest | null; + supply_path_provenance: { discovery_method?: string } | null; + }>( + `SELECT adagents_json, supply_path_provenance FROM publishers WHERE domain = $1 FOR UPDATE`, [domain], ); const previousManifest = previousResult.rows[0]?.adagents_json ?? null; + const previousDiscoveryMethod = previousResult.rows[0]?.supply_path_provenance?.discovery_method; + const previousAuthorityAdmitted = previousDiscoveryMethod === 'direct' || + previousDiscoveryMethod === 'authoritative_location'; // Normalize array fields before caching. The validator only enforces // `authorized_agents` shape, so a publisher serving a JSON-valid file @@ -1178,6 +1207,32 @@ export class PublisherDatabase { ? input.manifest.authorized_agents : [], }; + const resolvedUrlForProvenance = typeof input.resolvedUrl === 'string' && input.resolvedUrl.length <= 8192 + ? input.resolvedUrl + : null; + const isAuthorityRefresh = input.discoveryMethod === 'direct' || input.discoveryMethod === 'authoritative_location'; + if (isAuthorityRefresh) { + if (resolvedUrlForProvenance === null) throw new AdagentsManifestProvenanceError(); + // Migration 597 deliberately leaves historical cache rows without + // provenance. They are not an admitted rollback baseline and must not + // block their first authority-bound refresh. + const previousUpdatedAt = previousAuthorityAdmitted ? manifestUpdatedAt(previousManifest) : null; + const candidateUpdatedAt = manifestUpdatedAt(safeManifest); + if (previousUpdatedAt !== null && (candidateUpdatedAt === null || candidateUpdatedAt < previousUpdatedAt)) { + throw new AdagentsManifestRollbackError(); + } + // Bind pin admission to the same transaction that adopts the manifest + // and projects its catalog authorizations. A pointer swap throws here, + // before any cache/catalog mutation; later failures roll the pin back. + const admitted = await supplyPathSnapshotEvidence(domain, { + manifest: safeManifest, + resolvedUrl: resolvedUrlForProvenance, + discoveryMethod: input.discoveryMethod ?? null, + fetchedAt: new Date(), + expiresAt: input.expiresAt ?? null, + }, new PostgresStateStore(client)); + if (admitted.manifest === null) throw new AdagentsManifestProvenanceError(); + } const changedFields = adagentsChangedFields(previousManifest, safeManifest); const writePublisherRevisionEvent = async (): Promise => { if (!input.eventsDb || changedFields.length === 0) return; @@ -1256,7 +1311,7 @@ export class PublisherDatabase { truncateResolvedUrl(input.resolvedUrl), input.discoveryMethod ?? null, input.managerDomain ?? null, - typeof input.resolvedUrl === 'string' && input.resolvedUrl.length <= 8192 ? input.resolvedUrl : null, + resolvedUrlForProvenance, ] ); diff --git a/server/src/services/supply-path-authority-state.ts b/server/src/services/supply-path-authority-state.ts index 5dae281dc49..d9c3926ccc0 100644 --- a/server/src/services/supply-path-authority-state.ts +++ b/server/src/services/supply-path-authority-state.ts @@ -14,6 +14,13 @@ const persistentStore = new PostgresStateStore({ type HeldRevocation = { publisher_domain: string; revoked_at: string; first_observed_at: number }; type AuthorityState = Record & { location?: string; revoked: HeldRevocation[] }; +export class SupplyPathAuthorityChangeUnconfirmedError extends Error { + constructor() { + super('Authoritative location changed; publisher migration requires independent confirmation'); + this.name = 'SupplyPathAuthorityChangeUnconfirmedError'; + } +} + /** Persist observations atomically in the existing SDK document store, shared by registry workers. */ export async function observeSupplyPathAuthority( authority: string, manifest: SupplyPathManifest | null, location?: string, store: AdcpStateStore = persistentStore, @@ -33,8 +40,16 @@ export async function observeSupplyPathAuthority( let unchanged: AuthorityState | null = null; const state = await patchWithRetry(store, COLLECTION, authority, current => { if (!current && !location && !incoming.length) return null; - if (current?.location && location && current.location !== location) throw new Error('Authoritative location changed; publisher migration requires independent confirmation'); - const held = new Map((current?.revoked ?? []).filter(entry => entry.first_observed_at + HOLD_MS > now).map(entry => [entry.publisher_domain, entry])); + if (current?.location && location && current.location !== location) { + throw new SupplyPathAuthorityChangeUnconfirmedError(); + } + const incomingDomains = new Set(incoming.map(entry => entry.publisher_domain)); + // A still-published revocation remains effective without restarting its + // first-observation clock. Once it disappears after the original hold has + // expired, drop it immediately instead of inventing a second seven-day hold. + const held = new Map((current?.revoked ?? []) + .filter(entry => entry.first_observed_at + HOLD_MS > now || incomingDomains.has(entry.publisher_domain)) + .map(entry => [entry.publisher_domain, entry])); for (const entry of incoming) { const prior = held.get(entry.publisher_domain); if (!prior) held.set(entry.publisher_domain, { ...entry, first_observed_at: now }); diff --git a/server/src/services/supply-path-snapshot.ts b/server/src/services/supply-path-snapshot.ts index d23eb24d880..28e5e844629 100644 --- a/server/src/services/supply-path-snapshot.ts +++ b/server/src/services/supply-path-snapshot.ts @@ -1,4 +1,5 @@ import type { AdcpStateStore } from '@adcp/sdk/server'; +import { getDomain } from 'tldts'; import type { SupplyPathManifest } from './supply-path-contract.js'; import { observeSupplyPathAuthority } from './supply-path-authority-state.js'; import { record } from './supply-path-input.js'; @@ -24,7 +25,14 @@ export async function supplyPathSnapshotEvidence(publisher: string, snapshot: Su } } const secure = resolved !== null && resolved.protocol === 'https:' && !resolved.username && !resolved.password && !resolved.href.includes('#') && (!resolved.port || resolved.port === '443'); - const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || (snapshot.discoveryMethod === 'direct' && resolved?.origin === origin)); + const publisherSite = getDomain(publisher, { allowPrivateDomains: true }); + const resolvedSite = resolved ? getDomain(resolved.hostname, { allowPrivateDomains: true }) : null; + // Initial well-known discovery permits HTTPS redirects anywhere inside the + // original PSL+PRIVATE registrable domain. Explicit authoritative_location + // URLs remain exact, redirect-free locations validated by the fetcher. + const trustedSource = secure && (snapshot.discoveryMethod === 'authoritative_location' || + (snapshot.discoveryMethod === 'direct' && + (resolved?.origin === origin || (publisherSite !== null && resolvedSite === publisherSite)))); const age = snapshot.fetchedAt ? Date.now() - snapshot.fetchedAt.getTime() : Infinity; const fresh = age >= 0 && age <= 7 * 86400000 && (!snapshot.expiresAt || snapshot.expiresAt.getTime() > Date.now()); const observation = trustedSource && fresh && record(snapshot.manifest) ? snapshot.manifest : null; diff --git a/server/src/services/supply-path-verifier.ts b/server/src/services/supply-path-verifier.ts index 2d932efb639..aaf00c76206 100644 --- a/server/src/services/supply-path-verifier.ts +++ b/server/src/services/supply-path-verifier.ts @@ -14,6 +14,24 @@ import { agentIdentity, domain, record, records, strings } from './supply-path-i export const SUPPLY_PATH_STATES = ['unverified', 'owner_attested', 'host_delegated', 'verified_owner_sold'] as const; +const PROPERTY_TYPES = new Set([ + 'website', 'mobile_app', 'ctv_app', 'desktop_app', 'dooh', 'podcast', 'radio', + 'linear_tv', 'streaming_audio', 'ai_assistant', +]); +const PROPERTY_IDENTIFIER_TYPES = new Set([ + 'domain', 'subdomain', 'network_id', 'ios_bundle', 'android_package', + 'apple_app_store_id', 'google_play_id', 'roku_store_id', 'fire_tv_asin', + 'samsung_app_id', 'apple_tv_bundle', 'bundle_id', 'venue_id', 'screen_id', + 'openooh_venue_type', 'rss_url', 'apple_podcast_id', 'spotify_collection_id', + 'podcast_guid', 'station_id', 'facility_id', +]); +const PROPERTY_CHANNELS = new Set([ + 'display', 'olv', 'social', 'search', 'ctv', 'linear_tv', 'radio', + 'streaming_audio', 'podcast', 'dooh', 'ooh', 'print', 'cinema', 'email', + 'gaming', 'retail_media', 'influencer', 'affiliate', 'product_placement', + 'sponsored_intelligence', +]); + export function parseInventoryPartnerDomains(content: string): string[] { const partners = new Set(); for (const line of content.split(/\r?\n/)) { @@ -54,6 +72,45 @@ function coversCollection(entry: Record, owner: string, id: str ); } +function hasPropertyAuthorizationEnvelope(entry: Record): boolean { + const propertyToken = (value: unknown): value is string => + typeof value === 'string' && /^[a-z0-9_]+$/.test(value); + const propertyTokens = (value: unknown): value is string[] => + Array.isArray(value) && value.length > 0 && value.every(propertyToken); + const optionalUniqueList = (value: unknown, valid: (item: unknown) => boolean): boolean => + value === undefined || (Array.isArray(value) && value.every(valid) && new Set(value).size === value.length); + const validInlineProperty = (value: unknown): boolean => { + if (!record(value) || typeof value.property_type !== 'string' || !PROPERTY_TYPES.has(value.property_type) || + typeof value.name !== 'string' || + !Array.isArray(value.identifiers) || value.identifiers.length === 0) return false; + if (value.property_id !== undefined && !propertyToken(value.property_id)) return false; + if (value.publisher_domain !== undefined && typeof value.publisher_domain !== 'string') return false; + if (!optionalUniqueList(value.tags, propertyToken) || + !optionalUniqueList(value.supported_channels, item => typeof item === 'string' && PROPERTY_CHANNELS.has(item))) { + return false; + } + return value.identifiers.every(identifier => record(identifier) && + typeof identifier.type === 'string' && PROPERTY_IDENTIFIER_TYPES.has(identifier.type) && + typeof identifier.value === 'string'); + }; + if (entry.authorization_type === 'property_ids') return propertyTokens(entry.property_ids); + if (entry.authorization_type === 'property_tags') return propertyTokens(entry.property_tags); + if (entry.authorization_type === 'inline_properties') { + return Array.isArray(entry.properties) && entry.properties.length > 0 && entry.properties.every(validInlineProperty); + } + if (entry.authorization_type === 'publisher_properties') { + if (!Array.isArray(entry.publisher_properties) || entry.publisher_properties.length === 0 || + !entry.publisher_properties.every(record)) return false; + try { + entry.publisher_properties.forEach(selector => parsePublisherPropertySelector(selector)); + return true; + } catch { + return false; + } + } + return false; +} + /** Resolve one entry at a time: separate grants are alternatives, never merged across collection scopes. */ function propertyScope( entry: Record, @@ -314,6 +371,7 @@ export function verifySupplyPath(input: SupplyPathInput): SupplyPathVerdict { e => agentIdentity(e.url) === agent && owner && + hasPropertyAuthorizationEnvelope(e) && (!input.requireExplicitOwnerPublisherDomain || e.collections !== undefined) && coversCollection(e, owner, input.collectionId) && !unsupportedConstraints(e).length diff --git a/server/tests/integration/registry-crawler-cache.test.ts b/server/tests/integration/registry-crawler-cache.test.ts index a47fa2da263..469a38aa924 100644 --- a/server/tests/integration/registry-crawler-cache.test.ts +++ b/server/tests/integration/registry-crawler-cache.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; import { runMigrations } from '../../src/db/migrate.js'; -import { PublisherDatabase } from '../../src/db/publisher-db.js'; +import { AdagentsManifestProvenanceError, PublisherDatabase } from '../../src/db/publisher-db.js'; import { FederatedIndexService } from '../../src/federated-index.js'; import type { Pool } from 'pg'; import { PostgresStateStore } from '@adcp/sdk/server'; @@ -85,10 +85,21 @@ describe('Registry crawler cache (PR 2 of #3177)', () => { }); it('requires a provenance-aware refresh and refuses URLs that cannot be retained completely', async () => { - await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST }); - expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toBeNull(); - await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, discoveryMethod: 'authoritative_location', resolvedUrl: 'https://shared.example/' + 'x'.repeat(8192) }); - expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toBeNull(); + await publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, + manifest: { ...FIXTURE_MANIFEST, last_updated: '2099-01-01T00:00:00Z' }, + }); + const before = await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN); + expect(before.manifest).toBeNull(); + await expect(publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, + manifest: { ...FIXTURE_MANIFEST, last_updated: '2026-04-26T00:00:00Z' }, + discoveryMethod: 'authoritative_location', + resolvedUrl: 'https://shared.example/' + 'x'.repeat(8192), + })).rejects.toBeInstanceOf(AdagentsManifestProvenanceError); + expect(await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).toEqual(before); + // A pre-migration/unprovenanced cache timestamp is not trusted rollback + // state and cannot prevent the first authority-bound refresh. await publisherDb.upsertAdagentsCache({ domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, discoveryMethod: 'direct', resolvedUrl: `https://${TEST_DOMAIN}/.well-known/adagents.json` }); expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest).toEqual({ ...FIXTURE_MANIFEST, collections: [] }); }); @@ -212,6 +223,147 @@ describe('Registry crawler cache (PR 2 of #3177)', () => { expect(rows[0].workos_organization_id).toBe('org_test_publisher_owner'); expect(rows[0].created_by_email).toBe('owner@example.com'); }); + + it('atomically pins the first authority and rejects a pointer swap without changing cache or catalog', async () => { + const firstLocation = 'https://shared.example/first.json'; + const changedLocation = 'https://attacker.example/changed.json'; + await publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, + discoveryMethod: 'authoritative_location', resolvedUrl: firstLocation, + }); + const changedManifest = { + ...FIXTURE_MANIFEST, + last_updated: '2026-04-26T00:00:00Z', + authorized_agents: [{ ...FIXTURE_MANIFEST.authorized_agents[0], url: 'https://attacker.example/mcp' }], + }; + await expect(publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, manifest: changedManifest, + discoveryMethod: 'authoritative_location', resolvedUrl: changedLocation, + })).rejects.toThrow(/independent confirmation/); + + const snapshot = await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN); + expect(snapshot.resolvedUrl).toBe(firstLocation); + expect(snapshot.manifest?.authorized_agents).toEqual(FIXTURE_MANIFEST.authorized_agents); + const state = await pool.query<{ location: string }>( + "SELECT data->>'location' AS location FROM adcp_state WHERE collection = 'supply_path_authority_v1' AND id = $1", + [TEST_DOMAIN], + ); + expect(state.rows).toEqual([{ location: firstLocation }]); + const catalog = await pool.query<{ agent_url: string }>( + `SELECT DISTINCT caa.agent_url + FROM catalog_agent_authorizations caa + WHERE caa.evidence = 'adagents_json' AND caa.deleted_at IS NULL + AND (caa.publisher_domain = $1 OR caa.property_rid IN ( + SELECT property_rid FROM catalog_properties WHERE created_by = $2 + ))`, + [TEST_DOMAIN, `adagents_json:${TEST_DOMAIN}`], + ); + expect(new Set(catalog.rows.map(row => row.agent_url))).toEqual(new Set([TEST_AGENT])); + }); + + it('rejects policy-invalid provenance without changing an existing pin, cache, or catalog', async () => { + const firstLocation = 'https://shared.example/first.json'; + await publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, manifest: FIXTURE_MANIFEST, + discoveryMethod: 'authoritative_location', resolvedUrl: firstLocation, + }); + const before = await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN); + const changedManifest = { + ...FIXTURE_MANIFEST, + last_updated: '2026-04-26T00:00:00Z', + authorized_agents: [{ ...FIXTURE_MANIFEST.authorized_agents[0], url: 'https://attacker.example/mcp' }], + }; + await expect(publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, manifest: changedManifest, + discoveryMethod: 'authoritative_location', resolvedUrl: 'https://attacker.example:8443/changed.json', + })).rejects.toBeInstanceOf(AdagentsManifestProvenanceError); + + expect(await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).toEqual(before); + const state = await pool.query<{ location: string }>( + "SELECT data->>'location' AS location FROM adcp_state WHERE collection = 'supply_path_authority_v1' AND id = $1", + [TEST_DOMAIN], + ); + expect(state.rows).toEqual([{ location: firstLocation }]); + const catalog = await pool.query<{ agent_url: string }>( + `SELECT DISTINCT caa.agent_url + FROM catalog_agent_authorizations caa + WHERE caa.evidence = 'adagents_json' AND caa.deleted_at IS NULL + AND (caa.publisher_domain = $1 OR caa.property_rid IN ( + SELECT property_rid FROM catalog_properties WHERE created_by = $2 + ))`, + [TEST_DOMAIN, `adagents_json:${TEST_DOMAIN}`], + ); + expect(new Set(catalog.rows.map(row => row.agent_url))).toEqual(new Set([TEST_AGENT])); + }); + + it('rolls back a first authority pin when a later step in the cache transaction fails', async () => { + await expect(publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, + manifest: FIXTURE_MANIFEST, + discoveryMethod: 'authoritative_location', + resolvedUrl: 'https://shared.example/rollback.json', + eventsDb: { writeEvent: async () => { throw new Error('injected event failure'); } } as never, + })).rejects.toThrow('injected event failure'); + expect((await pool.query( + "SELECT 1 FROM adcp_state WHERE collection = 'supply_path_authority_v1' AND id = $1", + [TEST_DOMAIN], + )).rowCount).toBe(0); + expect((await pool.query('SELECT 1 FROM publishers WHERE domain = $1', [TEST_DOMAIN])).rowCount).toBe(0); + expect((await pool.query( + 'SELECT 1 FROM catalog_properties WHERE created_by = $1', + [`adagents_json:${TEST_DOMAIN}`], + )).rowCount).toBe(0); + }); + + it('rejects non-monotonic authority refreshes while accepting equal and newer timestamps', async () => { + const location = 'https://shared.example/monotonic.json'; + const write = (manifest: Record) => publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, manifest, + discoveryMethod: 'authoritative_location', resolvedUrl: location, + }); + await write(FIXTURE_MANIFEST); + await expect(write({ ...FIXTURE_MANIFEST, last_updated: '2026-04-24T23:59:59Z' })).rejects.toThrow(/older than the cached manifest/); + await expect(write({ ...FIXTURE_MANIFEST, last_updated: 'not-a-date' })).rejects.toThrow(/older than the cached manifest/); + const { last_updated: _lastUpdated, ...missingTimestamp } = FIXTURE_MANIFEST; + await expect(write(missingTimestamp)).rejects.toThrow(/older than the cached manifest/); + await expect(write({ ...FIXTURE_MANIFEST, authorized_agents: [{ ...FIXTURE_MANIFEST.authorized_agents[0], authorized_for: 'Equal timestamp' }] })).resolves.toBeDefined(); + await expect(write({ ...FIXTURE_MANIFEST, last_updated: '2026-04-26T00:00:00Z' })).resolves.toBeDefined(); + expect((await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN)).manifest?.last_updated).toBe('2026-04-26T00:00:00Z'); + }); + + it('serializes concurrent first pins so cache, authority, and catalog share one winner', async () => { + const candidates = [ + { location: 'https://a.example/manifest.json', agent: 'https://a-agent.example/mcp' }, + { location: 'https://b.example/manifest.json', agent: 'https://b-agent.example/mcp' }, + ]; + const results = await Promise.allSettled(candidates.map(candidate => publisherDb.upsertAdagentsCache({ + domain: TEST_DOMAIN, + manifest: { + ...FIXTURE_MANIFEST, + authorized_agents: [{ ...FIXTURE_MANIFEST.authorized_agents[0], url: candidate.agent }], + }, + discoveryMethod: 'authoritative_location', resolvedUrl: candidate.location, + }))); + expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected']); + const state = await pool.query<{ location: string }>( + "SELECT data->>'location' AS location FROM adcp_state WHERE collection = 'supply_path_authority_v1' AND id = $1", + [TEST_DOMAIN], + ); + const snapshot = await publisherDb.getSupplyPathSnapshot(TEST_DOMAIN); + const winner = candidates.find(candidate => candidate.location === state.rows[0]?.location)!; + expect(snapshot.resolvedUrl).toBe(winner.location); + expect(snapshot.manifest?.authorized_agents?.[0]?.url).toBe(winner.agent); + const catalog = await pool.query<{ agent_url: string }>( + `SELECT DISTINCT caa.agent_url + FROM catalog_agent_authorizations caa + WHERE caa.evidence = 'adagents_json' AND caa.deleted_at IS NULL + AND (caa.publisher_domain = $1 OR caa.property_rid IN ( + SELECT property_rid FROM catalog_properties WHERE created_by = $2 + ))`, + [TEST_DOMAIN, `adagents_json:${TEST_DOMAIN}`], + ); + expect(new Set(catalog.rows.map(row => row.agent_url))).toEqual(new Set([winner.agent])); + }); }); describe('catalog projection', () => { diff --git a/server/tests/unit/crawler-single-domain-profile-rebuild.test.ts b/server/tests/unit/crawler-single-domain-profile-rebuild.test.ts index d5d700423e0..137cf22a511 100644 --- a/server/tests/unit/crawler-single-domain-profile-rebuild.test.ts +++ b/server/tests/unit/crawler-single-domain-profile-rebuild.test.ts @@ -101,6 +101,45 @@ describe('CrawlerService single-domain profile rebuild', () => { expect(ctx.crawling).toBe(false); }); + it('does not treat a failed publisher cache write as a successful admission', async () => { + const domain = 'publisher.example'; + const recordPublisherFromAgent = vi.fn(); + const recordAgentFromAdagentsJson = vi.fn(); + const ctx = Object.create((CrawlerService as any).prototype); + Object.assign(ctx, { + memberDb: { + listProfiles: vi.fn().mockResolvedValue([{ publishers: [{ domain, is_public: true }] }]), + }, + adAgentsManager: { + validateDomain: vi.fn().mockResolvedValue({ + valid: true, + raw_data: { authorized_agents: [{ url: 'https://agent.example/mcp' }], properties: [] }, + resolved_url: `https://${domain}/.well-known/adagents.json`, + discovery_method: 'direct', + }), + }, + cacheAdagentsManifest: vi.fn().mockResolvedValue(false), + federatedIndex: { + listDiscoveredAgents: vi.fn().mockResolvedValue([]), + recordPublisherFromAgent, + recordAgentFromAdagentsJson, + getStats: vi.fn().mockRejectedValue(new Error('stats unavailable')), + }, + recordPropertiesForAgent: vi.fn(), + fanOutPublisherPropertiesAuthorizations: vi.fn(), + reconcileLegacyAdagentsAgents: vi.fn(), + refreshAgentSnapshots: vi.fn().mockResolvedValue(undefined), + }); + + const admitted = await ctx.populateFederatedIndex([]); + + expect(admitted).toEqual(new Set()); + expect(recordPublisherFromAgent).not.toHaveBeenCalled(); + expect(recordAgentFromAdagentsJson).not.toHaveBeenCalled(); + expect(ctx.recordPropertiesForAgent).not.toHaveBeenCalled(); + expect(ctx.reconcileLegacyAdagentsAgents).not.toHaveBeenCalled(); + }); + it('admits only one in-process full-crawl coordination attempt', async () => { const ctx = Object.create((CrawlerService as any).prototype); let resolveIntent!: (value: boolean) => void; diff --git a/server/tests/unit/services/supply-path-authority-state.test.ts b/server/tests/unit/services/supply-path-authority-state.test.ts index 5e88e870a2b..51054fb7b72 100644 --- a/server/tests/unit/services/supply-path-authority-state.test.ts +++ b/server/tests/unit/services/supply-path-authority-state.test.ts @@ -25,6 +25,15 @@ describe('persistent supply-path authority state', () => { vi.setSystemTime(new Date('2026-09-08T00:00:00Z')); expect((await observeSupplyPathAuthority(host, null, undefined, store)).revoked).toEqual([]); }); + it('does not restart an expired hold while the revocation remains published', async () => { + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-01T00:00:00Z')); + const store = new InMemoryStateStore(); + await observeSupplyPathAuthority(host, revoked(), pointer, store); + vi.setSystemTime(new Date('2026-09-09T00:00:00Z')); + expect((await observeSupplyPathAuthority(host, revoked(), pointer, store)).revoked).toEqual(['owner.example']); + vi.setSystemTime(new Date('2026-09-10T00:00:00Z')); + expect((await observeSupplyPathAuthority(host, null, undefined, store)).revoked).toEqual([]); + }); it('rejects changed pointers and preserves holds after independently confirmed migrations', async () => { const store = new InMemoryStateStore(); await observeSupplyPathAuthority(host, revoked(), pointer, store); diff --git a/server/tests/unit/services/supply-path-snapshot.test.ts b/server/tests/unit/services/supply-path-snapshot.test.ts index 359d617c0d1..fbbf418e5e8 100644 --- a/server/tests/unit/services/supply-path-snapshot.test.ts +++ b/server/tests/unit/services/supply-path-snapshot.test.ts @@ -12,6 +12,20 @@ describe('registry cache authority provenance', () => { expect(delegated.manifest).toEqual(direct.manifest); expect(delegated.explicitPublisher).toBe(true); }); + it.each([ + 'https://www.host.example/.well-known/adagents.json', + 'https://static.host.example/adagents.json', + ])('accepts a direct well-known redirect inside the original registrable domain: %s', async resolvedUrl => { + const direct = await supplyPathSnapshotEvidence(publisher, { ...snapshot(), resolvedUrl }, new InMemoryStateStore()); + expect(direct.manifest).toEqual(snapshot().manifest); + expect(direct.explicitPublisher).toBe(true); + }); + it('does not trust a direct redirect across private-suffix tenants', async () => { + const result = await supplyPathSnapshotEvidence('owner.github.io', { + ...snapshot(), resolvedUrl: 'https://attacker.github.io/.well-known/adagents.json', + }, new InMemoryStateStore()); + expect(result.manifest).toBeNull(); + }); it.each([ { resolvedUrl: null }, { resolvedUrl: 'https://cdn.example/' + 'x'.repeat(8192) }, { fetchedAt: null }, { fetchedAt: new Date(0) }, { fetchedAt: new Date('2100-01-01') }, diff --git a/server/tests/unit/services/supply-path-verifier.test.ts b/server/tests/unit/services/supply-path-verifier.test.ts index 2763df62a44..abca4820882 100644 --- a/server/tests/unit/services/supply-path-verifier.test.ts +++ b/server/tests/unit/services/supply-path-verifier.test.ts @@ -10,7 +10,12 @@ const AGENT = 'https://sales.channel-owner.example'; function ownerManifest(overrides: Partial = {}): AdagentsManifest { return { - authorized_agents: [{ url: AGENT, authorized_for: 'Owner-sold avails' }], + authorized_agents: [{ + url: AGENT, + authorized_for: 'Owner-sold avails', + authorization_type: 'property_ids', + property_ids: ['owner_inventory'], + }], collections: [{ collection_id: 'retro_news', name: 'Acme Retro News', diff --git a/static/compliance/source/test-vectors/supply-path/README.md b/static/compliance/source/test-vectors/supply-path/README.md index d95f1207ba8..ba284465bb4 100644 --- a/static/compliance/source/test-vectors/supply-path/README.md +++ b/static/compliance/source/test-vectors/supply-path/README.md @@ -6,7 +6,7 @@ Inputs contain owner/host manifests (or `null` for unavailable), publisher domai The `expected` projection is normative: semantics version, state, `resolved_collection_id` when a collection was declared, and all five legs' `ok` and `failure` fields. Human-readable `detail`, matched entries, and ancillary evidence fields are additive and excluded from this projection. The separate parser vectors pin comment, whitespace, case and domain handling for `INVENTORYPARTNERDOMAIN`. -Semantics version 1 requires an affirmative host collection selector, complete carriage property resolution, and an applicable property authorization discriminator. Omitted `collection_ids` is a bulk authorization grant; an absent `collections` constraint is not an affirmative host statement about an owner's carriage. Identifier-only carriage is discovery evidence and cannot prove a host property binding. Unevaluated country, time and placement qualifiers fail closed. Revocations take precedence over grants. Domain-level queries select the strongest complete individual collection path, never a join across different collections. Agent URL identity preserves case-sensitive paths and query values. +Semantics version 1 requires an affirmative host collection selector, complete carriage property resolution, and an applicable property authorization discriminator with its companion selector on both owner and host agent declarations. Omitted `collection_ids` is a bulk authorization grant; an absent `collections` constraint is not an affirmative host statement about an owner's carriage. Identifier-only carriage is discovery evidence and cannot prove a host property binding. Unevaluated country, time and placement qualifiers fail closed. Revocations take precedence over grants. Domain-level queries select the strongest complete individual collection path, never a join across different collections. Agent URL identity preserves case-sensitive paths and query values. `host_delegated` retains its weaker documented meaning: host inventory partner evidence plus the owner's agent and collection declarations. It does not require a host adagents manifest, establish concrete property coverage, or override an explicit revocation. Buyers must apply their own policy to this state. @@ -20,4 +20,4 @@ Authoritative consumers must retain previously observed revocations per publishe `hostInventoryPartnerDomainsByFile` retains parsed evidence separately for ads.txt and app-ads.txt. When supplied, it takes precedence over the legacy combined `hostInventoryPartnerDomains` input. Bulk inquiries select applicable files independently for each collection before choosing the strongest complete path. `heldRevocations` supplies authority-scoped owner/host holds even when a current manifest is null. Nested collection and publisher-property selector qualifiers fail closed, just like top-level qualifiers. -The semantic version identifies the pure state/leg evaluator, not equal observation times across cached registry and live client fetches. Both orchestrators reject non-transient network-policy denials without producing a verdict. Ordinary transport failures become unavailable evidence (`null`); a redirect outside the exact publisher origin is refused before resolving that target and supplies no evidence. Durable storage failures and unconfirmed authority changes also reject the operation. These fetch/storage boundaries have integration regressions alongside the pure corpus. +The semantic version identifies the pure state/leg evaluator, not equal observation times across cached registry and live client fetches. Both orchestrators reject non-transient network-policy denials without producing a verdict. Ordinary transport failures become unavailable evidence (`null`); the initial well-known fetch may redirect only within the original PSL+PRIVATE registrable domain, while an explicit authoritative URL refuses redirects outside its exact origin before resolving the target. Durable storage failures, non-monotonic manifest revisions, and unconfirmed authority changes also reject the operation. These fetch/storage boundaries have integration regressions alongside the pure corpus. diff --git a/static/compliance/source/test-vectors/supply-path/vectors.json b/static/compliance/source/test-vectors/supply-path/vectors.json index 381f0448037..955f5633a81 100644 --- a/static/compliance/source/test-vectors/supply-path/vectors.json +++ b/static/compliance/source/test-vectors/supply-path/vectors.json @@ -14,7 +14,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -103,7 +107,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -189,7 +197,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -271,7 +283,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -354,7 +370,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -437,7 +457,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -520,7 +544,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -603,7 +631,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -691,7 +723,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -779,7 +815,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -870,7 +910,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -960,7 +1004,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1050,7 +1098,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1140,7 +1192,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1232,7 +1288,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1311,7 +1371,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1404,7 +1468,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1499,7 +1567,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1587,7 +1659,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1678,7 +1754,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1769,7 +1849,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1860,7 +1944,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -1951,7 +2039,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2042,7 +2134,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2132,7 +2228,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2222,7 +2322,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2312,7 +2416,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2405,7 +2513,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2534,7 +2646,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2624,7 +2740,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2712,7 +2832,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2810,7 +2934,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -2871,7 +2999,7 @@ } }, { - "id": "host-delegated-without-host-adagents", + "id": "owner-agent-missing-discriminator", "input": { "ownerDomain": "channel-owner.example", "hostDomain": "hoststream.example", @@ -2903,6 +3031,85 @@ } ] }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "host-delegated-without-host-adagents", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, "hostManifest": null }, "expected": { @@ -3012,7 +3219,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3101,7 +3312,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3192,7 +3407,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3283,8 +3502,12 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" - } + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] + } ], "collections": [ { @@ -3376,7 +3599,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3468,7 +3695,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3559,7 +3790,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3652,7 +3887,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3746,7 +3985,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3839,7 +4082,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -3932,7 +4179,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -4022,7 +4273,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5130,7 +5385,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5198,7 +5457,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5273,7 +5536,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5366,7 +5633,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5464,7 +5735,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5526,7 +5801,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5628,7 +5907,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5727,7 +6010,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5826,7 +6113,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -5918,7 +6209,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6010,7 +6305,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6105,7 +6404,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6200,7 +6503,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6299,7 +6606,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6397,7 +6708,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6498,7 +6813,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6590,7 +6909,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6681,7 +7004,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6770,7 +7097,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6861,7 +7192,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -6962,7 +7297,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -7063,6 +7402,10 @@ "retro_news" ] } + ], + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" ] } ], @@ -7164,6 +7507,10 @@ "retro_news" ] } + ], + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" ] } ], @@ -7258,7 +7605,11 @@ "authorized_agents": [ { "url": "https://sales.channel-owner.example", - "authorized_for": "Owner-sold avails" + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -7340,6 +7691,10 @@ { "publisher_domain": "other-owner.example" } + ], + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" ] } ], @@ -7422,6 +7777,10 @@ { "publisher_domain": "channel-owner.example" } + ], + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" ] } ], @@ -7499,7 +7858,11 @@ { "url": "https://sales.channel-owner.example", "authorized_for": "Owner-sold avails", - "collections": [] + "collections": [], + "authorization_type": "property_ids", + "property_ids": [ + "owner_inventory" + ] } ], "collections": [ @@ -7561,6 +7924,243 @@ }, "resolved_collection_id": "retro_news" } + }, + { + "id": "owner-agent-invalid-property-id", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_ids", + "property_ids": [ + "INVALID ID" + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "owner-agent-invalid-property-tag", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "property_tags", + "property_tags": [ + "INVALID TAG" + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } + }, + { + "id": "owner-agent-malformed-inline-property", + "input": { + "ownerDomain": "channel-owner.example", + "hostDomain": "hoststream.example", + "agentUrl": "https://sales.channel-owner.example", + "collectionId": "retro_news", + "hostInventoryPartnerDomains": [ + "channel-owner.example" + ], + "ownerManifest": { + "authorized_agents": [ + { + "url": "https://sales.channel-owner.example", + "authorized_for": "Owner-sold avails", + "authorization_type": "inline_properties", + "properties": [ + {} + ] + } + ], + "collections": [ + { + "collection_id": "retro_news", + "name": "Retro News", + "kind": "channel", + "distribution": [ + { + "publisher_domain": "hoststream.example", + "property_ids": [ + "hoststream_ctv" + ] + } + ] + } + ] + }, + "hostManifest": { + "properties": [ + { + "property_id": "hoststream_ctv", + "name": "HostStream CTV", + "property_type": "ctv_app", + "identifiers": [ + { + "type": "roku_channel_id", + "value": "123" + } + ] + } + ], + "authorized_agents": [] + } + }, + "expected": { + "semantics_version": "1", + "state": "owner_attested", + "legs": { + "owner_collection_declared": { + "ok": true + }, + "owner_distribution_carriage": { + "ok": true + }, + "owner_agent_declared": { + "ok": false, + "failure": "agent_not_declared_by_owner" + }, + "host_authorization": { + "ok": false, + "failure": "no_agent_entry" + }, + "inventory_partner_domain": { + "ok": true + } + }, + "resolved_collection_id": "retro_news" + } } ], "ads_txt_policy_vectors": [