From 0f5276c43938bf4271a74b03cb415d71320991e1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 8 Sep 2026 00:34:15 +0000 Subject: [PATCH 1/3] feat(brand): require portable dotted domains --- .changeset/strict-brand-domains.md | 5 +++++ docs/brand-protocol/key-concepts.mdx | 13 +++++++++++++ static/schemas/source/core/brand-key.json | 4 ++-- static/schemas/source/core/brand-ref.json | 4 ++-- tests/schema-validation.test.cjs | 21 +++++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 .changeset/strict-brand-domains.md diff --git a/.changeset/strict-brand-domains.md b/.changeset/strict-brand-domains.md new file mode 100644 index 0000000000..b5c6ca4b16 --- /dev/null +++ b/.changeset/strict-brand-domains.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Require dotted domains in BrandRef and BrandKey wire schemas, define explicit reserved-domain development exceptions for SDKs, and reserve public registrability plus DNS/SSRF checks for production resolution. diff --git a/docs/brand-protocol/key-concepts.mdx b/docs/brand-protocol/key-concepts.mdx index a42e5fa7f4..062fe6ef17 100644 --- a/docs/brand-protocol/key-concepts.mdx +++ b/docs/brand-protocol/key-concepts.mdx @@ -155,6 +155,19 @@ There are three ways to resolve brand identity, each returning the same data str Regardless of source, the result is a brand identity that can be referenced by any AdCP task via a brand reference (`{ "domain": "...", "brand_id": "..." }`). +`BrandRef.domain` is a lowercase dotted domain, not a local hostname alias. Bare +names such as `localhost`, `unknown`, and `intranet` are invalid because another +agent cannot resolve them in the same DNS context. Production resolvers require +a registrable domain and apply the protocol's DNS and SSRF checks before fetching +`/.well-known/brand.json`. + +For local development and deterministic fixtures, SDKs may explicitly allow +dotted names under `.localhost`, `.test`, `.example`, or `.invalid` (including +the reserved `example.com`, `example.net`, and `example.org` names). This must be +an explicit development option, never inferred from the process environment. +Bare `localhost` remains invalid, and `.local` is not a development exception +because it participates in multicast DNS. + ## Use cases ### Creative generation diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json index a57e3bc40f..9eebc2901c 100644 --- a/static/schemas/source/core/brand-key.json +++ b/static/schemas/source/core/brand-key.json @@ -7,8 +7,8 @@ "properties": { "domain": { "type": "string", - "description": "Domain that hosts /.well-known/brand.json or is registered for the brand.", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + "description": "Lowercase dotted domain that hosts /.well-known/brand.json or is registered for the brand. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, "brand_id": { "$ref": "/schemas/core/brand-id.json", diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index e13c3aec71..3c6c8381eb 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -7,8 +7,8 @@ "properties": { "domain": { "type": "string", - "description": "Domain where /.well-known/brand.json is hosted, or the brand's operating domain", - "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + "description": "Lowercase dotted domain where /.well-known/brand.json is hosted, or the brand's operating domain. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, "brand_id": { "$ref": "/schemas/core/brand-id.json", diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 3242ebc063..ed413d1208 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -2958,6 +2958,27 @@ async function runTests() { return true; }); + await test('brand identity references require portable dotted domains', async () => { + for (const schemaFile of ['core/brand-ref.json', 'core/brand-key.json']) { + const schema = loadSchema(path.join(SCHEMA_BASE_DIR, schemaFile)); + const testAjv = new Ajv({ allErrors: true, strict: false, loadSchema: loadExternalSchema }); + addFormats(testAjv); + const validate = await testAjv.compileAsync(schema); + + for (const domain of ['brand.example', 'ads.brand.co.uk', 'brand.localhost']) { + if (!validate({ domain })) { + return `${schemaFile} rejected dotted wire domain ${domain}: ${JSON.stringify(validate.errors)}`; + } + } + for (const domain of ['localhost', 'unknown', 'intranet']) { + if (validate({ domain })) { + return `${schemaFile} accepted single-label domain ${domain}`; + } + } + } + return true; + }); + // Test 13: Validate schema examples against their schemas await test('Schema examples validate against their own schemas', async () => { // Skip schemas that require format-aware validation (creative manifests need format context) From 90aab3de9504a478d43ed7726a739ee10371f5d1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 8 Sep 2026 09:17:19 +0000 Subject: [PATCH 2/3] fix(brand): enforce production domain policy --- .changeset/strict-brand-domains.md | 2 +- docs/brand-protocol/key-concepts.mdx | 2 +- .../lint-storyboard-sample-request-schema.cjs | 3 ++ server/src/brand-manager.ts | 8 ++- .../src/services/identifier-normalization.ts | 50 ++++++++++++++++++- server/tests/unit/brand-manager-cache.test.ts | 18 +++++++ .../unit/canonicalize-brand-domain.test.ts | 28 ++++++++++- static/schemas/source/core/brand-key.json | 1 + static/schemas/source/core/brand-ref.json | 1 + ...-storyboard-sample-request-schema.test.cjs | 7 +++ tests/schema-validation.test.cjs | 3 ++ 11 files changed, 117 insertions(+), 6 deletions(-) diff --git a/.changeset/strict-brand-domains.md b/.changeset/strict-brand-domains.md index b5c6ca4b16..bbd07cd2f4 100644 --- a/.changeset/strict-brand-domains.md +++ b/.changeset/strict-brand-domains.md @@ -1,5 +1,5 @@ --- -"adcontextprotocol": minor +"adcontextprotocol": major --- Require dotted domains in BrandRef and BrandKey wire schemas, define explicit reserved-domain development exceptions for SDKs, and reserve public registrability plus DNS/SSRF checks for production resolution. diff --git a/docs/brand-protocol/key-concepts.mdx b/docs/brand-protocol/key-concepts.mdx index 062fe6ef17..8ae17b0e69 100644 --- a/docs/brand-protocol/key-concepts.mdx +++ b/docs/brand-protocol/key-concepts.mdx @@ -158,7 +158,7 @@ Regardless of source, the result is a brand identity that can be referenced by a `BrandRef.domain` is a lowercase dotted domain, not a local hostname alias. Bare names such as `localhost`, `unknown`, and `intranet` are invalid because another agent cannot resolve them in the same DNS context. Production resolvers require -a registrable domain and apply the protocol's DNS and SSRF checks before fetching +a hostname with a registrable ICANN or private-PSL parent and apply the protocol's DNS and SSRF checks before fetching `/.well-known/brand.json`. For local development and deterministic fixtures, SDKs may explicitly allow diff --git a/scripts/lint-storyboard-sample-request-schema.cjs b/scripts/lint-storyboard-sample-request-schema.cjs index 4ae0d6a402..2095a4ce89 100644 --- a/scripts/lint-storyboard-sample-request-schema.cjs +++ b/scripts/lint-storyboard-sample-request-schema.cjs @@ -181,6 +181,9 @@ function placeholderFor(schema, depth = 0) { if (resolved.pattern === '^sha256:[A-Za-z0-9_-]{43}$') return SHA256_DIGEST_PLACEHOLDER; if (resolved.pattern === '^[A-Fa-f0-9]{64}$') return 'a'.repeat(64); if (resolved.pattern === '^[A-Z]{3}$') return 'USD'; + if (resolved.pattern === '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$') { + return 'placeholder.example'; + } // Object variant nested inside a oneOf/anyOf at a location where the // author's substitution will resolve to that shape at runtime. Synthesize // the concrete shape instead of returning a string that fails required. diff --git a/server/src/brand-manager.ts b/server/src/brand-manager.ts index e6ba41fb46..0603aa148c 100644 --- a/server/src/brand-manager.ts +++ b/server/src/brand-manager.ts @@ -203,15 +203,19 @@ export class BrandManager { // Cache for failed lookups (5 minutes, never longer than a resolution miss) private failedLookupCache: Cache; private observeRelationshipDeclaration: RelationshipDeclarationObserver; + private allowDevelopmentDomains: boolean; constructor(options: { observeRelationshipDeclaration?: RelationshipDeclarationObserver; + /** Admit only the protocol's reserved dotted development names. */ + allowDevelopmentDomains?: boolean; } = {}) { this.validationCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES); this.resolutionCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.origin / 60, BRAND_CACHE_MAX_ENTRIES); this.failedLookupCache = new Cache(BRAND_MANAGER_CACHE_TTL_SECONDS.negative / 60, BRAND_FAILED_CACHE_MAX_ENTRIES); this.observeRelationshipDeclaration = options.observeRelationshipDeclaration ?? observeBrandRelationshipDeclaration; + this.allowDevelopmentDomains = options.allowDevelopmentDomains === true; } /** @@ -273,7 +277,9 @@ export class BrandManager { .replace(/^https?:\/\//, '') .replace(/\/$/, ''); try { - assertValidBrandDomain(normalized); + assertValidBrandDomain(normalized, { + allowDevelopmentDomains: this.allowDevelopmentDomains, + }); return normalized; } catch { return null; diff --git a/server/src/services/identifier-normalization.ts b/server/src/services/identifier-normalization.ts index 3d2c337907..26bee1b13f 100644 --- a/server/src/services/identifier-normalization.ts +++ b/server/src/services/identifier-normalization.ts @@ -1,3 +1,5 @@ +import { parse as parseTld } from 'tldts'; + /** * Identifier normalization for the property catalog. * @@ -22,6 +24,23 @@ export function canonicalizeBrandDomain(raw: string): string { } const BRAND_DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const DEVELOPMENT_DOMAIN_SUFFIXES = ['localhost', 'test', 'example', 'invalid'] as const; +const DEVELOPMENT_DOMAIN_EXACT_NAMES = new Set(['example.com', 'example.net', 'example.org']); +const SPECIAL_USE_DOMAIN_SUFFIXES = [ + 'alt', '6tisch.arpa', 'eap.arpa', 'eap-noob.arpa', 'home.arpa', + 'in-addr.arpa', 'ip6.arpa', 'ipv4only.arpa', 'resolver.arpa', 'service.arpa', + 'example', 'example.com', 'example.net', 'example.org', 'invalid', 'local', + 'localhost', 'onion', 'test', +] as const; + +function hasDomainSuffix(domain: string, suffix: string): boolean { + return domain === suffix || domain.endsWith(`.${suffix}`); +} + +export function isDevelopmentBrandDomain(domain: string): boolean { + return DEVELOPMENT_DOMAIN_EXACT_NAMES.has(domain) + || DEVELOPMENT_DOMAIN_SUFFIXES.some((suffix) => domain.endsWith(`.${suffix}`)); +} /** * Throw if the canonicalized value isn't a plausible domain (multi-label, @@ -29,10 +48,37 @@ const BRAND_DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a- * like "localhost", empty strings, or unparseable garbage from upstream * profile fields. */ -export function assertValidBrandDomain(canonical: string): void { - if (!BRAND_DOMAIN_RE.test(canonical) || canonical.length > 253) { +export function assertValidBrandDomain( + canonical: string, + options: { allowDevelopmentDomains?: boolean } = {}, +): void { + if ( + !BRAND_DOMAIN_RE.test(canonical) + || canonical.length > 253 + || canonical.split('.').some((label) => label.length > 63) + ) { throw new Error(`"${canonical}" is not a valid brand domain.`); } + + const developmentDomain = isDevelopmentBrandDomain(canonical); + if (developmentDomain && options.allowDevelopmentDomains === true) return; + + const parsed = parseTld(canonical, { + allowPrivateDomains: true, + detectSpecialUse: true, + extractHostname: false, + }); + const specialUse = SPECIAL_USE_DOMAIN_SUFFIXES.some((suffix) => hasDomainSuffix(canonical, suffix)); + if ( + developmentDomain + || specialUse + || parsed.isIp + || parsed.isSpecialUse + || !parsed.domain + || (!parsed.isIcann && !parsed.isPrivate) + ) { + throw new Error(`"${canonical}" is not a registrable production brand domain.`); + } } /** diff --git a/server/tests/unit/brand-manager-cache.test.ts b/server/tests/unit/brand-manager-cache.test.ts index ed74378bc2..db2e66c121 100644 --- a/server/tests/unit/brand-manager-cache.test.ts +++ b/server/tests/unit/brand-manager-cache.test.ts @@ -19,6 +19,7 @@ describe('BrandManager caching', () => { let relationshipDeclarations: Map; const createManager = () => new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async (declaration) => { const key = [ declaration.houseDomain.toLowerCase(), @@ -50,6 +51,21 @@ describe('BrandManager caching', () => { }); describe('validateDomain caching', () => { + it.each([ + '1.2.3.4', + 'co.uk', + 'brand.unknown', + 'brand.localhost', + 'brand.local', + 'brand.10.in-addr.arpa', + 'example.com', + ])('rejects non-production domain %s before network access', async (domain) => { + const productionManager = new BrandManager(); + const result = await productionManager.validateDomain(domain); + expect(result.valid).toBe(false); + expect(mockedSafeFetch).not.toHaveBeenCalled(); + }); + it('caches successful validation results', async () => { const mockBrandJson = { $schema: 'https://adcontextprotocol.org/schemas/latest/brand.json', @@ -1299,6 +1315,7 @@ describe('BrandManager caching', () => { it('fails closed when a missing effective_at cannot be durably observed', async () => { manager = new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async () => { throw new Error('database unavailable'); }, @@ -1317,6 +1334,7 @@ describe('BrandManager caching', () => { it('can evaluate explicit effective_at while durable storage is unavailable', async () => { const effectiveAt = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); manager = new BrandManager({ + allowDevelopmentDomains: true, observeRelationshipDeclaration: async () => { throw new Error('database unavailable'); }, diff --git a/server/tests/unit/canonicalize-brand-domain.test.ts b/server/tests/unit/canonicalize-brand-domain.test.ts index 312fa3e47b..2ca17de5cb 100644 --- a/server/tests/unit/canonicalize-brand-domain.test.ts +++ b/server/tests/unit/canonicalize-brand-domain.test.ts @@ -49,6 +49,32 @@ describe('assertValidBrandDomain', () => { expect(() => assertValidBrandDomain('app.kyber1.com')).not.toThrow(); }); + it('rejects public suffixes, unknown suffixes, IP literals, and special-use names', () => { + for (const domain of [ + 'co.uk', + 'brand.unknown', + '1.2.3.4', + 'brand.local', + 'brand.10.in-addr.arpa', + 'example.com', + ]) { + expect(() => assertValidBrandDomain(domain)).toThrow(); + } + }); + + it('admits only narrow dotted development names with explicit opt-in', () => { + for (const domain of ['brand.localhost', 'brand.test', 'brand.example', 'brand.invalid', 'example.com']) { + expect(() => assertValidBrandDomain(domain)).toThrow(); + expect(() => assertValidBrandDomain(domain, { allowDevelopmentDomains: true })).not.toThrow(); + } + expect(() => assertValidBrandDomain('localhost', { allowDevelopmentDomains: true })).toThrow(); + expect(() => assertValidBrandDomain('brand.local', { allowDevelopmentDomains: true })).toThrow(); + }); + + it('rejects labels longer than 63 octets', () => { + expect(() => assertValidBrandDomain(`${'a'.repeat(64)}.com`)).toThrow(); + }); + it('rejects a single-label hostname', () => { expect(() => assertValidBrandDomain('localhost')).toThrow(); }); @@ -145,7 +171,7 @@ describe('assertClaimableBrandDomain', () => { it('does NOT match domains that merely look like a suffix substring', () => { // The suffix matcher requires a leading `.`; otherwise `xhubspotusercontent.com` // would falsely match `hubspotusercontent.com`. - expect(() => assertClaimableBrandDomain('foo.example.com')).not.toThrow(); + expect(() => assertClaimableBrandDomain('foo.example-corp.com')).not.toThrow(); expect(() => assertClaimableBrandDomain('myhubspotusercontent.com')).not.toThrow(); }); diff --git a/static/schemas/source/core/brand-key.json b/static/schemas/source/core/brand-key.json index 9eebc2901c..b8b26df80a 100644 --- a/static/schemas/source/core/brand-key.json +++ b/static/schemas/source/core/brand-key.json @@ -7,6 +7,7 @@ "properties": { "domain": { "type": "string", + "maxLength": 253, "description": "Lowercase dotted domain that hosts /.well-known/brand.json or is registered for the brand. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index 3c6c8381eb..518283819a 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -7,6 +7,7 @@ "properties": { "domain": { "type": "string", + "maxLength": 253, "description": "Lowercase dotted domain where /.well-known/brand.json is hosted, or the brand's operating domain. Single-label names such as localhost and intranet aliases are invalid. This schema checks portable wire syntax only; production resolvers additionally require a registrable domain and reject IP literals, IANA special-use names, and private or reserved resolution targets. SDK development overrides may admit only explicitly configured dotted test names and do not make those names production-conformant.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" }, diff --git a/tests/lint-storyboard-sample-request-schema.test.cjs b/tests/lint-storyboard-sample-request-schema.test.cjs index 9fa2dcdc85..de2f9e9aee 100644 --- a/tests/lint-storyboard-sample-request-schema.test.cjs +++ b/tests/lint-storyboard-sample-request-schema.test.cjs @@ -289,6 +289,13 @@ test('normalizeSubstitutions produces a schema-valid currency placeholder', () = }), 'USD'); }); +test('normalizeSubstitutions produces a schema-valid dotted domain placeholder', () => { + assert.equal(normalizeSubstitutions('$context.brand_domain', { + type: 'string', + pattern: '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$', + }), 'placeholder.example'); +}); + // Object-typed substitution synthesis — the lint change landed in this PR. // A substitution that lands at an object location (plain or inside a // discriminated oneOf) must produce a shape-valid placeholder or ajv will diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index ed413d1208..51c348a1c5 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -2975,6 +2975,9 @@ async function runTests() { return `${schemaFile} accepted single-label domain ${domain}`; } } + if (validate({ domain: `${'a'.repeat(63)}.${'b'.repeat(63)}.${'c'.repeat(63)}.${'d'.repeat(62)}` })) { + return `${schemaFile} accepted a domain longer than 253 characters`; + } } return true; }); From 6bfd712f1ab56373545e096d257a766af54a4202 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 8 Sep 2026 09:41:09 +0000 Subject: [PATCH 3/3] fix(brand): scope resolution and pin PR schemas --- scripts/build-protocol-tarball.cjs | 26 +++++++++++++++++++ server/src/brand-manager.ts | 4 +-- .../src/services/identifier-normalization.ts | 18 ++++++++++--- .../unit/canonicalize-brand-domain.test.ts | 14 +++++----- tests/sign-protocol-tarball.test.cjs | 26 +++++++++++++++++++ 5 files changed, 75 insertions(+), 13 deletions(-) diff --git a/scripts/build-protocol-tarball.cjs b/scripts/build-protocol-tarball.cjs index b1b81754c8..916d2699d8 100644 --- a/scripts/build-protocol-tarball.cjs +++ b/scripts/build-protocol-tarball.cjs @@ -168,6 +168,30 @@ function pinPublishedVersion(filePath, publishedVersion) { }); } +function pinSchemaTreeVersion(schemaDir, publishedVersion) { + const latestMarker = '/schemas/latest/'; + const pinnedMarker = `/schemas/${publishedVersion}/`; + + function pinReferences(value) { + if (!value || typeof value !== 'object') return false; + let changed = false; + for (const [key, child] of Object.entries(value)) { + if ((key === '$id' || key === '$ref') && typeof child === 'string' && child.includes(latestMarker)) { + value[key] = child.replace(latestMarker, pinnedMarker); + changed = true; + } else if (pinReferences(child)) { + changed = true; + } + } + return changed; + } + + for (const relativePath of walk(schemaDir)) { + if (!relativePath.endsWith('.json')) continue; + updateJsonFile(path.join(schemaDir, relativePath), pinReferences); + } +} + function writeBundleReadme(bundleDir, version, isDev) { const extractedDir = isDev ? 'adcp-latest' : `adcp-${version}`; const quickstart = isDev @@ -304,6 +328,7 @@ function stageBundle( pinGeneratedAt(path.join(schemasDst, 'manifest.json'), metadata.generatedAt); if (publishedVersion !== version) { pinPublishedVersion(path.join(schemasDst, 'index.json'), publishedVersion); + pinSchemaTreeVersion(schemasDst, publishedVersion); } const complianceSource = path.join(DIST_COMPLIANCE, version); @@ -472,6 +497,7 @@ module.exports = { buildTarball, pinGeneratedAt, pinPublishedVersion, + pinSchemaTreeVersion, resolveBuildMetadata, writeIntegritySidecars }; diff --git a/server/src/brand-manager.ts b/server/src/brand-manager.ts index 0603aa148c..23921e9709 100644 --- a/server/src/brand-manager.ts +++ b/server/src/brand-manager.ts @@ -13,7 +13,7 @@ import type { } from './types'; import { AAO_UA_VALIDATOR } from './config/user-agents.js'; import { withSdkSafeTransport } from './utils/sdk-safe-fetch.js'; -import { assertValidBrandDomain } from './services/identifier-normalization.js'; +import { assertRegistrableBrandDomain } from './services/identifier-normalization.js'; import { observeBrandRelationshipDeclaration, type BrandRelationshipDeclaration, @@ -277,7 +277,7 @@ export class BrandManager { .replace(/^https?:\/\//, '') .replace(/\/$/, ''); try { - assertValidBrandDomain(normalized, { + assertRegistrableBrandDomain(normalized, { allowDevelopmentDomains: this.allowDevelopmentDomains, }); return normalized; diff --git a/server/src/services/identifier-normalization.ts b/server/src/services/identifier-normalization.ts index 26bee1b13f..9d28e07c1d 100644 --- a/server/src/services/identifier-normalization.ts +++ b/server/src/services/identifier-normalization.ts @@ -48,10 +48,7 @@ export function isDevelopmentBrandDomain(domain: string): boolean { * like "localhost", empty strings, or unparseable garbage from upstream * profile fields. */ -export function assertValidBrandDomain( - canonical: string, - options: { allowDevelopmentDomains?: boolean } = {}, -): void { +export function assertValidBrandDomain(canonical: string): void { if ( !BRAND_DOMAIN_RE.test(canonical) || canonical.length > 253 @@ -60,6 +57,19 @@ export function assertValidBrandDomain( throw new Error(`"${canonical}" is not a valid brand domain.`); } +} + +/** + * Throw unless a syntactically valid domain has a registrable parent in the + * ICANN or private Public Suffix List. Reserved dotted names may be admitted + * only by an explicit development-only caller option. + */ +export function assertRegistrableBrandDomain( + canonical: string, + options: { allowDevelopmentDomains?: boolean } = {}, +): void { + assertValidBrandDomain(canonical); + const developmentDomain = isDevelopmentBrandDomain(canonical); if (developmentDomain && options.allowDevelopmentDomains === true) return; diff --git a/server/tests/unit/canonicalize-brand-domain.test.ts b/server/tests/unit/canonicalize-brand-domain.test.ts index 2ca17de5cb..5987509fba 100644 --- a/server/tests/unit/canonicalize-brand-domain.test.ts +++ b/server/tests/unit/canonicalize-brand-domain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { canonicalizeBrandDomain, assertValidBrandDomain, assertClaimableBrandDomain } from '../../src/services/identifier-normalization.js'; +import { canonicalizeBrandDomain, assertValidBrandDomain, assertRegistrableBrandDomain, assertClaimableBrandDomain } from '../../src/services/identifier-normalization.js'; describe('canonicalizeBrandDomain', () => { it('strips https:// protocol', () => { @@ -40,7 +40,7 @@ describe('canonicalizeBrandDomain', () => { }); }); -describe('assertValidBrandDomain', () => { +describe('brand domain validation', () => { it('accepts a typical apex domain', () => { expect(() => assertValidBrandDomain('kyber1.com')).not.toThrow(); }); @@ -58,17 +58,17 @@ describe('assertValidBrandDomain', () => { 'brand.10.in-addr.arpa', 'example.com', ]) { - expect(() => assertValidBrandDomain(domain)).toThrow(); + expect(() => assertRegistrableBrandDomain(domain)).toThrow(); } }); it('admits only narrow dotted development names with explicit opt-in', () => { for (const domain of ['brand.localhost', 'brand.test', 'brand.example', 'brand.invalid', 'example.com']) { - expect(() => assertValidBrandDomain(domain)).toThrow(); - expect(() => assertValidBrandDomain(domain, { allowDevelopmentDomains: true })).not.toThrow(); + expect(() => assertRegistrableBrandDomain(domain)).toThrow(); + expect(() => assertRegistrableBrandDomain(domain, { allowDevelopmentDomains: true })).not.toThrow(); } - expect(() => assertValidBrandDomain('localhost', { allowDevelopmentDomains: true })).toThrow(); - expect(() => assertValidBrandDomain('brand.local', { allowDevelopmentDomains: true })).toThrow(); + expect(() => assertRegistrableBrandDomain('localhost', { allowDevelopmentDomains: true })).toThrow(); + expect(() => assertRegistrableBrandDomain('brand.local', { allowDevelopmentDomains: true })).toThrow(); }); it('rejects labels longer than 63 octets', () => { diff --git a/tests/sign-protocol-tarball.test.cjs b/tests/sign-protocol-tarball.test.cjs index 117dea8ff4..0c7fd263a7 100644 --- a/tests/sign-protocol-tarball.test.cjs +++ b/tests/sign-protocol-tarball.test.cjs @@ -12,6 +12,7 @@ const { buildTarball, pinGeneratedAt, pinPublishedVersion, + pinSchemaTreeVersion, resolveBuildMetadata, writeIntegritySidecars, } = require('../scripts/build-protocol-tarball.cjs'); @@ -257,4 +258,29 @@ describe('build-protocol-tarball.cjs', () => { fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it('pins schema ids and references in release-compatible PR bundles', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'adcp-protocol-schema-tree-')); + try { + fs.mkdirSync(path.join(tmp, 'core'), { recursive: true }); + const schemaPath = path.join(tmp, 'core', 'manifest.json'); + fs.writeFileSync(schemaPath, JSON.stringify({ + $id: 'https://adcontextprotocol.org/schemas/latest/core/manifest.json', + properties: { + entry: { $ref: '/schemas/latest/core/entry.json' }, + }, + })); + + pinSchemaTreeVersion(tmp, '3.2.0-beta.9'); + + assert.deepEqual(JSON.parse(fs.readFileSync(schemaPath, 'utf8')), { + $id: 'https://adcontextprotocol.org/schemas/3.2.0-beta.9/core/manifest.json', + properties: { + entry: { $ref: '/schemas/3.2.0-beta.9/core/entry.json' }, + }, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); });