diff --git a/.changeset/registry-property-identity.md b/.changeset/registry-property-identity.md new file mode 100644 index 000000000..29c072703 --- /dev/null +++ b/.changeset/registry-property-identity.md @@ -0,0 +1,7 @@ +--- +'@adcp/sdk': minor +--- + +Allow registry saveProperty/saveProperties writes to include full property identity facts: property_type, identifiers, and tags. + +The registry CLI `save-property` positional now accepts optional payload JSON instead of the old `[agent-url]` authorization positional. That old argument had already stopped producing authorization after the registry began forcing community writes to `authorized_agents: []`, so scripts that still pass an agent URL now fail fast with exit code 2 instead of silently writing a stripped authorization claim. diff --git a/bin/adcp-registry.js b/bin/adcp-registry.js index 25508a931..394c1d9d1 100644 --- a/bin/adcp-registry.js +++ b/bin/adcp-registry.js @@ -218,9 +218,9 @@ SAVE COMMANDS (requires --auth): Save or update a community brand save-brand @manifest.json Save brand with manifest from file - save-property [agent-url] [payload-json] + save-property [payload-json] Save or update a hosted property - save-property [agent-url] @property.json + save-property @property.json Save property with full payload from file LIST & SEARCH: @@ -296,7 +296,7 @@ EXAMPLES: # Save a property adcp registry save-property example.com --auth sk_your_key - adcp registry save-property example.com https://agent.example.com --auth sk_your_key`); + adcp registry save-property example.com '{"properties":[{"property_type":"website","name":"Example","identifiers":[{"type":"domain","value":"example.com"}],"tags":["news"]}]}' --auth sk_your_key`); } /** @@ -458,16 +458,33 @@ async function handleRegistryCommand(args) { const domain = positional[1]; if (!domain) { console.error('Error: domain is required\n'); - console.error('Usage: adcp registry save-property [agent-url] [payload-json]\n'); + console.error('Usage: adcp registry save-property [payload-json]\n'); + return 2; + } + const extraArg = positional[2]; + const payloadArg = extraArg?.trim(); + if (payloadArg && !payloadArg.startsWith('{') && !payloadArg.startsWith('@')) { + if (/^https?:\/\//i.test(payloadArg)) { + console.error( + 'Error: save-property no longer accepts an agent URL. Authorization is managed at the publisher origin adagents.json; pass property identity facts as payload JSON when needed.\n' + ); + } else { + console.error('Error: expected payload JSON object or @file for save-property\n'); + } + console.error( + 'Example: adcp registry save-property example.com \'{"properties":[{"property_type":"website","name":"Example","identifiers":[{"type":"domain","value":"example.com"}],"tags":["news"]}]}\' --auth sk_your_key' + ); + console.error('Usage: adcp registry save-property [payload-json]\n'); + return 2; + } + const extraPayload = payloadArg ? parsePayload(payloadArg) : {}; + if (extraPayload == null || typeof extraPayload !== 'object' || Array.isArray(extraPayload)) { + console.error('Error: save-property payload must be a JSON object or @file containing a JSON object\n'); return 2; } - const maybeAgentUrl = positional[2]; - const hasAgentUrl = maybeAgentUrl && !maybeAgentUrl.startsWith('{') && !maybeAgentUrl.startsWith('@'); - const extraArg = hasAgentUrl ? positional[3] : positional[2]; const payload = { publisher_domain: domain, - ...(hasAgentUrl ? { authorized_agents: [{ url: maybeAgentUrl }] } : {}), - ...(extraArg ? parsePayload(extraArg) : {}), + ...extraPayload, }; const result = await client.saveProperty(payload); if (flags.json) { diff --git a/schemas/registry/registry.yaml b/schemas/registry/registry.yaml index b3a3f4fd5..56b3bcc5d 100644 --- a/schemas/registry/registry.yaml +++ b/schemas/registry/registry.yaml @@ -3213,6 +3213,208 @@ components: - 250m_1b - 1b_plus description: Annual revenue band, USD. Drives membership-tier eligibility for company-tier seats. + ResolveResponse: + type: object + properties: + resolved: + type: array + items: + $ref: "#/components/schemas/ResolvedEntry" + summary: + type: object + properties: + total: + type: integer + resolved: + type: integer + created: + type: integer + excluded: + type: integer + not_found: + type: integer + required: + - total + - resolved + - created + - excluded + - not_found + server_timestamp: + type: string + required: + - resolved + - summary + - server_timestamp + ResolvedEntry: + type: object + properties: + identifier: + $ref: "#/components/schemas/CatalogIdentifier" + property_rid: + type: + - string + - "null" + description: Stable catalog handle for joining/dedup and TMP matching. NOT an authorization credential. `null` for excluded (ad_infra / publisher_mask) or unresolved-in-lookup identifiers. + classification: + type: string + example: property + status: + type: string + enum: + - existing + - created + - excluded + source: + type: + - string + - "null" + required: + - identifier + - property_rid + - classification + - status + - source + CatalogIdentifier: + type: object + properties: + type: + type: string + example: domain + value: + type: string + example: nytimes.com + required: + - type + - value + ResolveRequest: + type: object + properties: + identifiers: + type: array + items: + $ref: "#/components/schemas/CatalogIdentifier" + minItems: 1 + maxItems: 10000 + description: Identifiers to resolve (and, in resolve mode, contribute). Max 10,000 per call for all callers. + provenance: + $ref: "#/components/schemas/FactProvenance" + mode: + type: string + enum: + - resolve + - lookup + default: resolve + description: "`resolve` (default) contributes the identifiers, auto-creates missing catalog entries, logs demand activity, and returns rids — requires authentication. `lookup` is a pure read: no write, no activity log, no auth." + required: + - identifiers + - provenance + FactProvenance: + type: object + properties: + type: + type: string + enum: + - agency_allowlist + - publisher_declaration + - impression_log + - ssp_inventory + - deal_history + - crawl + - data_partner + - member_assertion + description: How the caller knows these identifiers — the trust/audit envelope on the fact. Determines the confidence the catalog assigns. `crawl` is reserved for server-side pipelines; callers use the others. + context: + type: string + example: unilever_q3 + description: Optional free-text annotation (campaign, dataset). + required: + - type + CatalogDisputeTriageResult: + type: object + properties: + dispute_id: + type: string + action_taken: + type: string + enum: + - link_suspended + - queued_for_review + - escalated + description: "What filing the dispute did: a medium/weak link is suspended immediately; otherwise the dispute is queued or escalated for review." + reason: + type: string + required: + - dispute_id + - action_taken + - reason + CatalogDisputeRequest: + type: object + properties: + dispute_type: + type: string + enum: + - identifier_link + - classification + - property_data + - false_merge + subject_type: + type: string + example: identifier + description: What is being disputed — e.g. `identifier` or `property_rid`. + subject_value: + type: string + example: com.example.app + claim: + type: string + minLength: 10 + maxLength: 2000 + description: The dispute claim (10–2000 chars). + evidence: + type: string + maxLength: 5000 + description: Optional supporting evidence (≤5000 chars). + required: + - dispute_type + - subject_type + - subject_value + - claim + CatalogDisputeRecord: + type: object + properties: + id: + type: string + dispute_type: + type: string + enum: + - identifier_link + - classification + - property_data + - false_merge + subject_type: + type: string + subject_value: + type: string + claim: + type: string + evidence: + type: + - string + - "null" + status: + type: string + example: suspended + description: Current dispute status. + created_at: + type: string + required: + - id + - dispute_type + - subject_type + - subject_value + - claim + - status + - created_at + additionalProperties: {} AdagentsJson: type: object properties: @@ -10350,6 +10552,101 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /api/registry/resolve: + post: + operationId: resolveIdentifiers + summary: Resolve identifiers to property_rids (and contribute them) + description: The primary fact-contribution path. Takes identifiers plus a provenance envelope and returns stable `property_rid`s. In `resolve` mode (default) it auto-creates missing catalog entries and logs demand activity — so resolving your own identifier list IS the contribution. `property_rid` is a non-authoritative join/match handle, never an authorization credential. Re-resolving is idempotent on the identifier→rid mapping but additive on the activity log. + tags: + - Property Catalog + security: + - bearerAuth: [] + - oauth2: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveRequest" + responses: + "200": + description: Resolve/lookup result + content: + application/json: + schema: + $ref: "#/components/schemas/ResolveResponse" + "400": + description: Invalid request (bad identifiers, unknown provenance type, batch > 10,000) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required for resolve mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/registry/catalog/disputes: + post: + operationId: fileCatalogDispute + summary: Dispute a catalog fact + description: "Challenge or correct a catalog claim — the community disavow/challenge verb. Adding links is hard; suspending suspicious ones is easy: a disputed medium/weak link is suspended immediately (`action_taken: 'link_suspended'`); stronger claims queue for review. Poll status with getCatalogDispute." + tags: + - Property Catalog + security: + - bearerAuth: [] + - oauth2: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeRequest" + responses: + "200": + description: Dispute filed and triaged + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeTriageResult" + "400": + description: Invalid dispute request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/registry/catalog/disputes/{id}: + get: + operationId: getCatalogDispute + summary: Get a catalog dispute + description: Fetch the current state of a filed dispute by id. + tags: + - Property Catalog + parameters: + - schema: + type: string + example: 019539a0-b1c2-7d3e-8f4a-5b6c7d8e9f0a + required: true + name: id + in: path + responses: + "200": + description: Dispute record + content: + application/json: + schema: + $ref: "#/components/schemas/CatalogDisputeRecord" + "404": + description: Dispute not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" tags: - name: Onboarding description: Explicitly bootstrap a third-party integration into the AAO registry. Most callers don't need this tag — `POST /api/me/agents` auto-creates the org (for fresh users) and the member profile (for first-time agent registration) without a separate round trip. Use `POST /api/organizations` only when you need to override the auto-derived org name / company_type / revenue_tier. Tier transitions happen via the billing flow only; the Stripe webhook is the sole writer of `organizations.membership_tier`. @@ -10379,3 +10676,5 @@ tags: description: Agent compliance status, storyboard test results, and compliance history. - name: Policy Registry description: Browse, resolve, and contribute governance policies for campaign compliance. + - name: Property Catalog + description: "Contribute facts to the property fact-graph: resolve identifiers to stable property_rids (which also contributes them, with provenance) and dispute catalog claims." diff --git a/skills/adcp/SKILL.md b/skills/adcp/SKILL.md index 74269a987..b6064fd4c 100644 --- a/skills/adcp/SKILL.md +++ b/skills/adcp/SKILL.md @@ -242,7 +242,7 @@ adcp registry stats ### Save operations (requires --auth or ADCP_REGISTRY_API_KEY) ```bash adcp registry save-brand acme.com "Acme Corp" --auth $KEY -adcp registry save-property example.com https://agent.com --auth $KEY +adcp registry save-property example.com '{"properties":[{"property_type":"website","name":"Example","identifiers":[{"type":"domain","value":"example.com"}],"tags":["news"]}]}' --auth $KEY ``` ## Agent management diff --git a/src/lib/index.ts b/src/lib/index.ts index 5baba7565..57fed939d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -57,8 +57,15 @@ export type { SaveBrandLogoResponse, UploadBrandLogoInput, UploadBrandLogoResponse, + SavePropertyIdentity, + RegistryPropertyIdentity, SavePropertyRequest, SavePropertyResponse, + ResolveIdentifiersRequest, + ResolveIdentifiersResponse, + FileCatalogDisputeRequest, + FileCatalogDisputeResponse, + GetCatalogDisputeResponse, BrandRegistryItem, PropertyRegistryItem, ValidationResult as RegistryValidationResult, diff --git a/src/lib/registry/index.ts b/src/lib/registry/index.ts index 9d2db0bd2..c5bc0ac25 100644 --- a/src/lib/registry/index.ts +++ b/src/lib/registry/index.ts @@ -14,8 +14,15 @@ import type { SaveBrandLogoResponse, UploadBrandLogoInput, UploadBrandLogoResponse, + SavePropertyIdentity, + RegistryPropertyIdentity, SavePropertyRequest, SavePropertyResponse, + ResolveIdentifiersRequest, + ResolveIdentifiersResponse, + FileCatalogDisputeRequest, + FileCatalogDisputeResponse, + GetCatalogDisputeResponse, ClaimHostedPropertyDomainResponse, VerifyHostedPropertyOriginResponse, BrandRegistryItem, @@ -82,6 +89,7 @@ import type { import { openFeedStream } from './feed-stream'; import type { FeedStreamQuery, FeedStreamMessage } from './feed-stream'; +import type { PropertyType } from '../discovery/types'; export type { ResolvedBrand, @@ -104,8 +112,15 @@ export type { SaveBrandLogoResponse, UploadBrandLogoInput, UploadBrandLogoResponse, + SavePropertyIdentity, + RegistryPropertyIdentity, SavePropertyRequest, SavePropertyResponse, + ResolveIdentifiersRequest, + ResolveIdentifiersResponse, + FileCatalogDisputeRequest, + FileCatalogDisputeResponse, + GetCatalogDisputeResponse, ClaimHostedPropertyDomainResponse, VerifyHostedPropertyOriginResponse, BrandRegistryItem, @@ -609,10 +624,7 @@ export class RegistryClient { async saveProperty(property: SavePropertyRequest): Promise { if (!property?.publisher_domain?.trim()) throw new Error('publisher_domain is required'); if (!this.apiKey) throw new Error('apiKey is required for save operations'); - const payload: SavePropertyRequest = { - ...property, - authorized_agents: property.authorized_agents ?? [], - }; + const payload = this.normalizeSavePropertyRequest(property); return this.post(`${this.baseUrl}/api/properties/save`, payload); } @@ -661,6 +673,36 @@ export class RegistryClient { return results; } + /** + * Resolve catalog identifiers to stable property_rids. + * In default `resolve` mode the registry also records a provenance-backed + * contribution; `lookup` mode is read-only. + */ + async resolveIdentifiers(request: ResolveIdentifiersRequest): Promise { + if (!request?.identifiers?.length) throw new Error('identifiers are required'); + if ((request.mode ?? 'resolve') !== 'lookup' && !this.apiKey) { + throw new Error('apiKey is required for resolveIdentifiers in resolve mode'); + } + return this.post(`${this.baseUrl}/api/registry/resolve`, request); + } + + /** File a catalog fact dispute. Requires authentication. */ + async fileCatalogDispute(request: FileCatalogDisputeRequest): Promise { + if (!request?.subject_type?.trim()) throw new Error('subject_type is required'); + if (!request?.subject_value?.trim()) throw new Error('subject_value is required'); + if (!request?.claim?.trim()) throw new Error('claim is required'); + if (!this.apiKey) throw new Error('apiKey is required for catalog disputes'); + return this.post(`${this.baseUrl}/api/registry/catalog/disputes`, request); + } + + /** Fetch a catalog dispute by id. */ + async getCatalogDispute(id: string): Promise { + if (!id?.trim()) throw new Error('id is required'); + return this.get(`${this.baseUrl}/api/registry/catalog/disputes/${encodeURIComponent(id.trim())}`, { + nullOn404: true, + }); + } + // ====== Agent Discovery ====== /** List registered agents with optional filtering. */ @@ -1407,6 +1449,31 @@ export class RegistryClient { return normalized; } + private normalizeSavePropertyRequest(property: SavePropertyRequest): SavePropertyRequest { + const payload: SavePropertyRequest = { + ...property, + authorized_agents: [], + }; + if (Array.isArray(property.properties)) { + payload.properties = property.properties.map(p => this.normalizeSavePropertyIdentity(p)); + } + return payload; + } + + private normalizeSavePropertyIdentity(property: SavePropertyIdentity): SavePropertyIdentity { + const normalized = { ...property } as SavePropertyIdentity & { + property_type?: PropertyType | string; + type?: PropertyType | string; + }; + if (normalized.property_type === undefined && normalized.type !== undefined) { + normalized.property_type = normalized.type as PropertyType; + } + if (normalized.type === undefined && normalized.property_type !== undefined) { + normalized.type = normalized.property_type; + } + return normalized as SavePropertyIdentity; + } + private toBrandLogoBlob(data: SaveBrandLogoInput['data'], mimeType: string): Blob { if (data instanceof Blob) return new Blob([data], { type: mimeType }); if (data instanceof ArrayBuffer) return new Blob([data], { type: mimeType }); diff --git a/src/lib/registry/types.generated.ts b/src/lib/registry/types.generated.ts index 7d24484a8..63689de13 100644 --- a/src/lib/registry/types.generated.ts +++ b/src/lib/registry/types.generated.ts @@ -1,5 +1,5 @@ // Generated AdCP Registry types from OpenAPI spec -// Generated at: 2026-07-01T04:18:50.842Z +// Generated at: 2026-07-01T14:06:29.296Z // Source: https://agenticadvertising.org/openapi/registry.yaml // // Do not edit this file manually. Run: npm run generate-registry-types @@ -1984,6 +1984,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/registry/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Resolve identifiers to property_rids (and contribute them) + * @description The primary fact-contribution path. Takes identifiers plus a provenance envelope and returns stable `property_rid`s. In `resolve` mode (default) it auto-creates missing catalog entries and logs demand activity — so resolving your own identifier list IS the contribution. `property_rid` is a non-authoritative join/match handle, never an authorization credential. Re-resolving is idempotent on the identifier→rid mapping but additive on the activity log. + */ + post: operations["resolveIdentifiers"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/registry/catalog/disputes": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dispute a catalog fact + * @description Challenge or correct a catalog claim — the community disavow/challenge verb. Adding links is hard; suspending suspicious ones is easy: a disputed medium/weak link is suspended immediately (`action_taken: 'link_suspended'`); stronger claims queue for review. Poll status with getCatalogDispute. + */ + post: operations["fileCatalogDispute"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/registry/catalog/disputes/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a catalog dispute + * @description Fetch the current state of a filed dispute by id. + */ + get: operations["getCatalogDispute"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -3383,6 +3443,97 @@ export interface components { * @enum {string} */ OrganizationRevenueTier: "under_1m" | "1m_5m" | "5m_50m" | "50m_250m" | "250m_1b" | "1b_plus"; + ResolveResponse: { + resolved: components["schemas"]["ResolvedEntry"][]; + summary: { + total: number; + resolved: number; + created: number; + excluded: number; + not_found: number; + }; + server_timestamp: string; + }; + ResolvedEntry: { + identifier: components["schemas"]["CatalogIdentifier"]; + /** @description Stable catalog handle for joining/dedup and TMP matching. NOT an authorization credential. `null` for excluded (ad_infra / publisher_mask) or unresolved-in-lookup identifiers. */ + property_rid: string | null; + /** @example property */ + classification: string; + /** @enum {string} */ + status: "existing" | "created" | "excluded"; + source: string | null; + }; + CatalogIdentifier: { + /** @example domain */ + type: string; + /** @example nytimes.com */ + value: string; + }; + ResolveRequest: { + /** @description Identifiers to resolve (and, in resolve mode, contribute). Max 10,000 per call for all callers. */ + identifiers: components["schemas"]["CatalogIdentifier"][]; + provenance: components["schemas"]["FactProvenance"]; + /** + * @description `resolve` (default) contributes the identifiers, auto-creates missing catalog entries, logs demand activity, and returns rids — requires authentication. `lookup` is a pure read: no write, no activity log, no auth. + * @default resolve + * @enum {string} + */ + mode: "resolve" | "lookup"; + }; + FactProvenance: { + /** + * @description How the caller knows these identifiers — the trust/audit envelope on the fact. Determines the confidence the catalog assigns. `crawl` is reserved for server-side pipelines; callers use the others. + * @enum {string} + */ + type: "agency_allowlist" | "publisher_declaration" | "impression_log" | "ssp_inventory" | "deal_history" | "crawl" | "data_partner" | "member_assertion"; + /** + * @description Optional free-text annotation (campaign, dataset). + * @example unilever_q3 + */ + context?: string; + }; + CatalogDisputeTriageResult: { + dispute_id: string; + /** + * @description What filing the dispute did: a medium/weak link is suspended immediately; otherwise the dispute is queued or escalated for review. + * @enum {string} + */ + action_taken: "link_suspended" | "queued_for_review" | "escalated"; + reason: string; + }; + CatalogDisputeRequest: { + /** @enum {string} */ + dispute_type: "identifier_link" | "classification" | "property_data" | "false_merge"; + /** + * @description What is being disputed — e.g. `identifier` or `property_rid`. + * @example identifier + */ + subject_type: string; + /** @example com.example.app */ + subject_value: string; + /** @description The dispute claim (10–2000 chars). */ + claim: string; + /** @description Optional supporting evidence (≤5000 chars). */ + evidence?: string; + }; + CatalogDisputeRecord: { + id: string; + /** @enum {string} */ + dispute_type: "identifier_link" | "classification" | "property_data" | "false_merge"; + subject_type: string; + subject_value: string; + claim: string; + evidence?: string | null; + /** + * @description Current dispute status. + * @example suspended + */ + status: string; + created_at: string; + } & { + [key: string]: unknown; + }; AdagentsJson: { /** Format: uri */ $schema?: string; @@ -8978,6 +9129,121 @@ export interface operations { }; }; }; + resolveIdentifiers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["ResolveRequest"]; + }; + }; + responses: { + /** @description Resolve/lookup result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResolveResponse"]; + }; + }; + /** @description Invalid request (bad identifiers, unknown provenance type, batch > 10,000) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication required for resolve mode */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + fileCatalogDispute: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CatalogDisputeRequest"]; + }; + }; + responses: { + /** @description Dispute filed and triaged */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogDisputeTriageResult"]; + }; + }; + /** @description Invalid dispute request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getCatalogDispute: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Dispute record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CatalogDisputeRecord"]; + }; + }; + /** @description Dispute not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; } diff --git a/src/lib/registry/types.ts b/src/lib/registry/types.ts index a585255d0..74de64bc0 100644 --- a/src/lib/registry/types.ts +++ b/src/lib/registry/types.ts @@ -11,7 +11,6 @@ export type { BrandRegistryItem, ResolvedProperty, PropertyIdentifier, - PropertyRegistryItem, ValidationResult, RegistryError, PublisherPropertySelector, @@ -57,6 +56,7 @@ export type { paths, operations, components } from './types.generated'; import type { ResolvedBrand as GeneratedResolvedBrand, + PropertyRegistryItem as GeneratedPropertyRegistryItem, operations, CommunityMirrorListResponse, CommunityMirrorSummary, @@ -66,6 +66,7 @@ import type { CommunityMirrorPublishRequest, CommunityMirrorDeleteResponse, } from './types.generated'; +import type { PropertyIdentifierType, PropertyType } from '../discovery/types'; import type { MediaChannel, ProductFormatDeclaration } from '../types/tools.generated'; /** @@ -118,12 +119,95 @@ export type SaveBrandRequest = NonNullable['content']['application/json']; + +// TODO(#2318): Once the official registry OpenAPI publishes saveProperty +// identity facts, regenerate the registry types and collapse these hand-written +// write types onto the generated request shape, or assert they are structurally equal. +type SavePropertyIdentityBase = { + /** Human-readable property name. */ + name: string; + /** Register this property by known identifiers such as domain, bundle id, or app-store id. */ + identifiers?: { type: PropertyIdentifierType; value: string }[]; + /** Tags used by downstream `by_tag` property selection. */ + tags?: string[]; +}; + +/** Property identity accepted by POST /api/properties/save. */ +export type SavePropertyIdentity = SavePropertyIdentityBase & + ( + | { + /** Preferred field name, aligned with adagents.json property declarations. */ + property_type: PropertyType; + /** Current registry wire field; emitted alongside `property_type` while the OpenAPI catches up. */ + type?: PropertyType | string; + } + | { + /** Current registry wire field; accepts custom/self-hosted registry values for compatibility. */ + type: PropertyType | string; + property_type?: PropertyType; + } + ); + +/** Property identity facts returned by registry property read/list APIs. */ +export type RegistryPropertyIdentity = { + /** Stable property identifier when the registry has assigned one. */ + id?: string; + /** Preferred field name, aligned with adagents.json property declarations. */ + property_type?: PropertyType; + /** Legacy read/write alias for `property_type`. */ + type?: PropertyType | string; + /** Human-readable property name. */ + name?: string; + /** Known identifiers such as domain, bundle id, or app-store id. */ + identifiers?: { type: PropertyIdentifierType; value: string }[]; + /** Tags used by downstream `by_tag` property selection. */ + tags?: string[]; +}; + +/** Property registry list item, including identity facts when returned by the registry. */ +export type PropertyRegistryItem = GeneratedPropertyRegistryItem & { + properties?: RegistryPropertyIdentity[]; +}; + /** Request body for POST /api/properties/save */ -export type SavePropertyRequest = NonNullable['content']['application/json']; +export type SavePropertyRequest = Omit & { + /** Ignored by the registry client; identity-only property saves always write `authorized_agents: []`. */ + authorized_agents?: []; + properties?: SavePropertyIdentity[]; +}; /** Response from POST /api/properties/save (200) */ export type SavePropertyResponse = operations['saveProperty']['responses']['200']['content']['application/json']; +type RegistryResolveIdentifiersRequest = NonNullable< + operations['resolveIdentifiers']['requestBody'] +>['content']['application/json']; + +/** Request body for POST /api/registry/resolve. `mode` defaults to `resolve` server-side. */ +export type ResolveIdentifiersRequest = Omit & { + mode?: RegistryResolveIdentifiersRequest['mode']; +}; + +/** Response from POST /api/registry/resolve (200) */ +export type ResolveIdentifiersResponse = + operations['resolveIdentifiers']['responses']['200']['content']['application/json']; + +/** Request body for POST /api/registry/catalog/disputes */ +export type FileCatalogDisputeRequest = NonNullable< + operations['fileCatalogDispute']['requestBody'] +>['content']['application/json']; + +/** Response from POST /api/registry/catalog/disputes (200) */ +export type FileCatalogDisputeResponse = + operations['fileCatalogDispute']['responses']['200']['content']['application/json']; + +/** Response from GET /api/registry/catalog/disputes/{id} (200) */ +export type GetCatalogDisputeResponse = + operations['getCatalogDispute']['responses']['200']['content']['application/json']; + /** Response from POST /api/properties/hosted/{domain}/claim (200) */ export type ClaimHostedPropertyDomainResponse = operations['claimHostedPropertyDomain']['responses']['200']['content']['application/json']; diff --git a/test/lib/registry-cli.test.js b/test/lib/registry-cli.test.js index 94e9b2632..7ea86935a 100644 --- a/test/lib/registry-cli.test.js +++ b/test/lib/registry-cli.test.js @@ -430,17 +430,11 @@ describe('CLI registry command', () => { }); output = captureOutput(); - const code = await handleRegistryCommand([ - 'save-property', - 'example.com', - 'https://agent.example.com', - '--auth', - 'sk_test', - ]); + const code = await handleRegistryCommand(['save-property', 'example.com', '--auth', 'sk_test']); assert.strictEqual(code, 0); assert.strictEqual(capturedBody.publisher_domain, 'example.com'); - assert.deepStrictEqual(capturedBody.authorized_agents, [{ url: 'https://agent.example.com' }]); + assert.deepStrictEqual(capturedBody.authorized_agents, []); assert.ok(output.stdout.includes('Saved successfully')); assert.ok(output.stdout.includes('prop_456')); }); @@ -456,7 +450,6 @@ describe('CLI registry command', () => { const code = await handleRegistryCommand([ 'save-property', 'example.com', - 'https://agent.example.com', '{"contact":{"email":"admin@example.com"}}', '--auth', 'sk_test', @@ -467,6 +460,36 @@ describe('CLI registry command', () => { assert.strictEqual(capturedBody.contact.email, 'admin@example.com'); }); + test('saves a property with identity facts payload JSON', async () => { + let capturedBody; + restoreFetch = mockFetch(async (_url, opts) => { + capturedBody = JSON.parse(opts.body); + return new Response(JSON.stringify(SAVE_RESULT), { status: 200 }); + }); + output = captureOutput(); + + const code = await handleRegistryCommand([ + 'save-property', + 'example.com', + '{"properties":[{"property_type":"website","name":"Example","identifiers":[{"type":"domain","value":"example.com"}],"tags":["news"]}]}', + '--auth', + 'sk_test', + ]); + + assert.strictEqual(code, 0); + assert.strictEqual(capturedBody.publisher_domain, 'example.com'); + assert.deepStrictEqual(capturedBody.authorized_agents, []); + assert.deepStrictEqual(capturedBody.properties, [ + { + type: 'website', + property_type: 'website', + name: 'Example', + identifiers: [{ type: 'domain', value: 'example.com' }], + tags: ['news'], + }, + ]); + }); + test('saves a property without an authorized agent URL', async () => { let capturedBody; restoreFetch = mockFetch(async (_url, opts) => { @@ -508,18 +531,69 @@ describe('CLI registry command', () => { restoreFetch = mockFetch(async () => new Response(JSON.stringify(SAVE_RESULT), { status: 200 })); output = captureOutput(); + const code = await handleRegistryCommand(['save-property', 'example.com', '--auth', 'sk_test', '--json']); + + assert.strictEqual(code, 0); + const parsed = JSON.parse(output.stdout); + assert.strictEqual(parsed.id, 'prop_456'); + }); + + test('returns exit code 2 for legacy authorized agent URL positional', async () => { + output = captureOutput(); + const code = await handleRegistryCommand([ 'save-property', 'example.com', 'https://agent.example.com', '--auth', 'sk_test', - '--json', + ]); + + assert.strictEqual(code, 2); + assert.ok(output.stderr.includes('no longer accepts an agent URL')); + assert.ok(output.stderr.includes('Authorization is managed at the publisher origin adagents.json')); + assert.ok(output.stderr.includes('Example: adcp registry save-property')); + }); + + test('accepts payload JSON with leading whitespace', async () => { + let capturedBody; + restoreFetch = mockFetch(async (_url, opts) => { + capturedBody = JSON.parse(opts.body); + return new Response(JSON.stringify(SAVE_RESULT), { status: 200 }); + }); + output = captureOutput(); + + const code = await handleRegistryCommand([ + 'save-property', + 'example.com', + ' {"properties":[{"property_type":"website","name":"Example"}]}', + '--auth', + 'sk_test', ]); assert.strictEqual(code, 0); - const parsed = JSON.parse(output.stdout); - assert.strictEqual(parsed.id, 'prop_456'); + assert.deepStrictEqual(capturedBody.properties, [ + { + type: 'website', + property_type: 'website', + name: 'Example', + }, + ]); + }); + + test('returns exit code 2 for non-object payload JSON', async () => { + output = captureOutput(); + + const code = await handleRegistryCommand([ + 'save-property', + 'example.com', + '[{"name":"Example"}]', + '--auth', + 'sk_test', + ]); + + assert.strictEqual(code, 2); + assert.ok(output.stderr.includes('expected payload JSON object or @file')); }); test('returns exit code 2 when domain is missing', async () => { @@ -535,7 +609,7 @@ describe('CLI registry command', () => { output = captureOutput(); try { - const code = await handleRegistryCommand(['save-property', 'example.com', 'https://agent.example.com']); + const code = await handleRegistryCommand(['save-property', 'example.com']); assert.strictEqual(code, 1); assert.ok(output.stderr.includes('apiKey is required')); } finally { diff --git a/test/lib/registry.test.js b/test/lib/registry.test.js index b59516601..2b77fa5db 100644 --- a/test/lib/registry.test.js +++ b/test/lib/registry.test.js @@ -57,9 +57,10 @@ const PROPERTY = { properties: [ { id: 'prop_1', - type: 'website', + property_type: 'website', name: 'NYTimes', identifiers: [{ type: 'domain', value: 'nytimes.com' }], + tags: ['news', 'premium'], }, ], verified: true, @@ -830,6 +831,8 @@ describe('RegistryClient', () => { assert.strictEqual(result.publisher_domain, 'nytimes.com'); assert.strictEqual(result.verified, true); assert.strictEqual(result.properties.length, 1); + assert.deepStrictEqual(result.properties[0].identifiers, [{ type: 'domain', value: 'nytimes.com' }]); + assert.deepStrictEqual(result.properties[0].tags, ['news', 'premium']); }); test('returns null on 404', async () => { @@ -1507,6 +1510,7 @@ describe('RegistryClient', () => { assert.strictEqual(capturedOpts.headers['Authorization'], 'Bearer sk_test'); const body = JSON.parse(capturedOpts.body); assert.strictEqual(body.publisher_domain, 'example.com'); + assert.deepStrictEqual(body.authorized_agents, []); assert.strictEqual(result.success, true); assert.strictEqual(result.id, 'pr_456'); }); @@ -1572,6 +1576,85 @@ describe('RegistryClient', () => { assert.strictEqual(result.success, true); }); + test('forwards property identity identifiers and tags', async () => { + let capturedOpts; + restore = mockFetch(async (_url, opts) => { + capturedOpts = opts; + return new Response( + JSON.stringify({ + success: true, + message: 'Property saved', + id: 'pr_identity', + revision_number: 7, + }), + { status: 200 } + ); + }); + + const propertyIdentity = { + property_type: 'website', + name: 'Example Publisher', + identifiers: [ + { type: 'domain', value: 'example.com' }, + { type: 'ios_bundle', value: 'com.example.news' }, + ], + tags: ['news', 'premium'], + }; + + const client = new RegistryClient({ apiKey: 'sk_test' }); + const result = await client.saveProperty({ + publisher_domain: 'example.com', + properties: [propertyIdentity], + authorized_agents: [], + }); + + const body = JSON.parse(capturedOpts.body); + assert.deepStrictEqual(body.authorized_agents, []); + assert.deepStrictEqual(body.properties, [{ ...propertyIdentity, type: 'website' }]); + assert.strictEqual(result.id, 'pr_identity'); + assert.strictEqual(result.revision_number, 7); + }); + + test('normalizes legacy property type alias before sending', async () => { + let capturedOpts; + restore = mockFetch(async (_url, opts) => { + capturedOpts = opts; + return new Response( + JSON.stringify({ + success: true, + message: 'Property saved', + id: 'pr_legacy', + revision_number: 8, + }), + { status: 200 } + ); + }); + + const client = new RegistryClient({ apiKey: 'sk_test' }); + await client.saveProperty({ + publisher_domain: 'legacy.example', + properties: [ + { + type: 'mobile_app', + name: 'Legacy App', + identifiers: [{ type: 'android_package', value: 'com.example.legacy' }], + tags: ['app'], + }, + ], + }); + + const body = JSON.parse(capturedOpts.body); + assert.deepStrictEqual(body.properties, [ + { + type: 'mobile_app', + property_type: 'mobile_app', + name: 'Legacy App', + identifiers: [{ type: 'android_package', value: 'com.example.legacy' }], + tags: ['app'], + }, + ]); + }); + test('throws on 401 unauthorized', async () => { restore = mockFetch(async () => { return new Response(JSON.stringify({ error: 'Authentication required' }), { status: 401 }); @@ -1590,6 +1673,234 @@ describe('RegistryClient', () => { } ); }); + + test('preserves authoritative-property 409 errors', async () => { + restore = mockFetch(async () => { + return new Response(JSON.stringify({ error: 'Cannot edit authoritative property' }), { status: 409 }); + }); + + const client = new RegistryClient({ apiKey: 'sk_test' }); + await assert.rejects( + () => + client.saveProperty({ + publisher_domain: 'example.com', + properties: [{ property_type: 'website', name: 'Example Publisher' }], + authorized_agents: [], + }), + err => { + assert.ok(err.message.includes('409')); + assert.ok(err.message.includes('Cannot edit authoritative property')); + return true; + } + ); + }); + }); + + describe('saveProperties', () => { + test('fans out property identity payloads with canonical property_type', async () => { + const capturedBodies = []; + restore = mockFetch(async (_url, opts) => { + capturedBodies.push(JSON.parse(opts.body)); + const body = capturedBodies[capturedBodies.length - 1]; + return new Response( + JSON.stringify({ + success: true, + message: 'Property saved', + id: `pr_${body.publisher_domain}`, + revision_number: capturedBodies.length, + }), + { status: 200 } + ); + }); + + const requests = [ + { + publisher_domain: 'example.com', + authorized_agents: [], + properties: [ + { + property_type: 'website', + name: 'Example Publisher', + identifiers: [{ type: 'domain', value: 'example.com' }], + tags: ['news'], + }, + ], + }, + { + publisher_domain: 'legacy.example', + authorized_agents: [], + properties: [ + { + type: 'mobile_app', + name: 'Legacy App', + identifiers: [{ type: 'android_package', value: 'com.example.legacy' }], + tags: ['app'], + }, + ], + }, + ]; + + const client = new RegistryClient({ apiKey: 'sk_test' }); + const results = await client.saveProperties(requests, { concurrency: 1 }); + + assert.deepStrictEqual(capturedBodies, [ + { + publisher_domain: 'example.com', + authorized_agents: [], + properties: [ + { + type: 'website', + property_type: 'website', + name: 'Example Publisher', + identifiers: [{ type: 'domain', value: 'example.com' }], + tags: ['news'], + }, + ], + }, + { + publisher_domain: 'legacy.example', + authorized_agents: [], + properties: [ + { + type: 'mobile_app', + property_type: 'mobile_app', + name: 'Legacy App', + identifiers: [{ type: 'android_package', value: 'com.example.legacy' }], + tags: ['app'], + }, + ], + }, + ]); + assert.strictEqual(results['example.com'].revision_number, 1); + assert.strictEqual(results['legacy.example'].revision_number, 2); + }); + }); + + describe('property catalog fact APIs', () => { + test('resolves identifiers with provenance and auth', async () => { + let capturedUrl, capturedOpts; + restore = mockFetch(async (url, opts) => { + capturedUrl = url; + capturedOpts = opts; + return new Response( + JSON.stringify({ + resolved: [ + { + identifier: { type: 'domain', value: 'example.com' }, + property_rid: 'rid_example', + classification: 'property', + status: 'existing', + source: 'member_assertion', + }, + ], + summary: { total: 1, resolved: 1, created: 0, excluded: 0, not_found: 0 }, + server_timestamp: '2026-07-01T00:00:00Z', + }), + { status: 200 } + ); + }); + + const client = new RegistryClient({ apiKey: 'sk_test' }); + const result = await client.resolveIdentifiers({ + identifiers: [{ type: 'domain', value: 'example.com' }], + provenance: { type: 'member_assertion' }, + }); + + assert.ok(capturedUrl.includes('/api/registry/resolve')); + assert.strictEqual(capturedOpts.method, 'POST'); + assert.strictEqual(capturedOpts.headers['Authorization'], 'Bearer sk_test'); + assert.deepStrictEqual(JSON.parse(capturedOpts.body), { + identifiers: [{ type: 'domain', value: 'example.com' }], + provenance: { type: 'member_assertion' }, + }); + assert.strictEqual(result.resolved[0].property_rid, 'rid_example'); + }); + + test('allows identifier lookup mode without an apiKey', async () => { + let capturedOpts; + restore = mockFetch(async (_url, opts) => { + capturedOpts = opts; + return new Response( + JSON.stringify({ + resolved: [], + summary: { total: 1, resolved: 0, created: 0, excluded: 0, not_found: 1 }, + server_timestamp: '2026-07-01T00:00:00Z', + }), + { status: 200 } + ); + }); + + const client = new RegistryClient(); + await client.resolveIdentifiers({ + identifiers: [{ type: 'domain', value: 'missing.example' }], + provenance: { type: 'member_assertion' }, + mode: 'lookup', + }); + + assert.strictEqual(capturedOpts.headers.Authorization, undefined); + }); + + test('requires an apiKey for identifier resolve mode', async () => { + const savedEnv = process.env.ADCP_REGISTRY_API_KEY; + delete process.env.ADCP_REGISTRY_API_KEY; + try { + const client = new RegistryClient(); + await assert.rejects( + () => + client.resolveIdentifiers({ + identifiers: [{ type: 'domain', value: 'example.com' }], + provenance: { type: 'member_assertion' }, + }), + /apiKey is required/ + ); + } finally { + if (savedEnv !== undefined) process.env.ADCP_REGISTRY_API_KEY = savedEnv; + } + }); + + test('files and fetches catalog disputes', async () => { + const calls = []; + restore = mockFetch(async (url, opts) => { + calls.push({ url, opts }); + if (opts?.method === 'POST') { + return new Response( + JSON.stringify({ + dispute_id: 'disp_123', + action_taken: 'queued_for_review', + reason: 'queued', + }), + { status: 200 } + ); + } + return new Response( + JSON.stringify({ + id: 'disp_123', + dispute_type: 'identifier_link', + subject_type: 'identifier', + subject_value: 'example.com', + claim: 'This identifier is incorrectly linked.', + status: 'queued_for_review', + created_at: '2026-07-01T00:00:00Z', + }), + { status: 200 } + ); + }); + + const client = new RegistryClient({ apiKey: 'sk_test' }); + const filed = await client.fileCatalogDispute({ + dispute_type: 'identifier_link', + subject_type: 'identifier', + subject_value: 'example.com', + claim: 'This identifier is incorrectly linked.', + }); + const fetched = await client.getCatalogDispute('disp_123'); + + assert.ok(calls[0].url.includes('/api/registry/catalog/disputes')); + assert.strictEqual(calls[0].opts.headers['Authorization'], 'Bearer sk_test'); + assert.strictEqual(filed.dispute_id, 'disp_123'); + assert.ok(calls[1].url.endsWith('/api/registry/catalog/disputes/disp_123')); + assert.strictEqual(fetched.id, 'disp_123'); + }); }); // ============ hosted property ownership ============ @@ -1837,7 +2148,19 @@ describe('RegistryClient', () => { describe('listProperties', () => { test('lists properties without options', async () => { - const responseData = { properties: [PROPERTY], stats: { total: 1 } }; + const responseData = { + properties: [ + { + domain: 'nytimes.com', + source: 'hosted', + property_count: 1, + agent_count: 0, + verified: true, + properties: PROPERTY.properties, + }, + ], + stats: { total: 1 }, + }; let capturedUrl; restore = mockFetch(async url => { capturedUrl = url; @@ -1850,6 +2173,10 @@ describe('RegistryClient', () => { assert.ok(capturedUrl.includes('/api/properties/registry')); assert.ok(!capturedUrl.includes('?')); assert.strictEqual(result.properties.length, 1); + assert.deepStrictEqual(result.properties[0].properties[0].identifiers, [ + { type: 'domain', value: 'nytimes.com' }, + ]); + assert.deepStrictEqual(result.properties[0].properties[0].tags, ['news', 'premium']); }); test('passes search, limit, and offset params', async () => {