From f96d401f2b3364305f50cbd984415bdce283904c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 29 Jun 2026 06:56:20 +0100 Subject: [PATCH] feat(registry): domain claim + bind-on-verify ownership (#5749 gap #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community-registry write stays open (anyone can stage a non-authoritative, private community row — #5750). Authority over a domain is established by binding an owner ON successful origin verification, not by gating the write. Flow: 1. An authenticated account claims a domain → AAO issues a claim-specific authoritative_location URL (…/adagents.json?adcp_claim=). 2. The account places that single pointer at their own origin (one IT action). 3. verify-origin reads the token and binds workos_organization_id to the claim's org, publishes the row, and marks it origin-verified. The token is the per-account artifact that proves WHICH account owns a domain — a plain domain-keyed pointer proves only that the origin endorses AAO hosting, not who the owner is. Binding is driven by the token the origin pointer carries, never by who triggers verification, so any authenticated caller may trigger it and a squatter cannot bind a domain they don't control. An existing verified owner is never overwritten, and issuing a claim for a domain locked to another org is refused. - Migration 523: claim_token + claimant_org_id on hosted_properties. - property-db: issueDomainClaim, bindOwnerFromVerifiedClaim (never changes a different owner; publishes the row on bind). - POST /properties/hosted/:domain/claim returns the claim-specific pointer URL. - Verifier: extract adcp_claim from the pointer, base-match against the AAO hosted URL, bind on success; outcome carries bound_org_id. - verify-origin handler inverted: no longer requires a pre-bound owner; trigger is open, binding is token-driven (replaces the NULL-owner/org-mismatch 403s). - /properties/save: owner-locked edit — once verified+bound, only the owner may edit (community rows stay openly editable). authorized_agents stays [] per #5750; owner-authored authz is a deliberate follow-up. - Regenerated static/openapi/registry.yaml. Tests: new registry-domain-claim-bind.test.ts (token binds claimant not caller; no/wrong token verifies but doesn't bind; locked domain refuses re-claim; owner never overwritten). Updated the verify-origin auth test for the new invariant. Fixed a discovered_properties cleanup leak + hook-timeout in the #5750 save test. Follow-ups: periodic TTL re-verify + lapse; DNS-TXT pointer flavor; owner-authored authorized_agents on a verified-locked record; extend owner-lock to Addie/admin write paths (#5751). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../523_hosted_property_domain_claim.sql | 32 ++++ server/src/db/property-db.ts | 88 +++++++++ server/src/routes/registry-api.ts | 133 ++++++++++--- .../hosted-property-origin-verifier.ts | 44 ++++- server/src/types.ts | 11 ++ ...sted-property-origin-verifier-auth.test.ts | 39 ++-- .../registry-domain-claim-bind.test.ts | 179 ++++++++++++++++++ .../registry-property-save-identity.test.ts | 8 +- static/openapi/registry.yaml | 77 +++++++- 9 files changed, 561 insertions(+), 50 deletions(-) create mode 100644 server/src/db/migrations/523_hosted_property_domain_claim.sql create mode 100644 server/tests/integration/registry-domain-claim-bind.test.ts diff --git a/server/src/db/migrations/523_hosted_property_domain_claim.sql b/server/src/db/migrations/523_hosted_property_domain_claim.sql new file mode 100644 index 0000000000..48a1dd69f8 --- /dev/null +++ b/server/src/db/migrations/523_hosted_property_domain_claim.sql @@ -0,0 +1,32 @@ +-- Migration: 523_hosted_property_domain_claim.sql +-- Purpose: Bind-on-verify domain claims for AAO-hosted properties (RFC #5749 gap #1). +-- +-- The community write surface stays open (anyone can stage a non-authoritative, +-- private community row). Authority over a domain is established by binding an +-- owner ON successful origin verification, not by gating the write. +-- +-- An account "claims" a domain and receives a claim-specific authoritative_location +-- URL carrying a token (…/adagents.json?adcp_claim=). The account places that +-- single pointer at their own origin; verify-origin reads the token and binds +-- workos_organization_id to the claim's org. The token is the per-account artifact +-- that proves *which* account owns the domain — a plain domain-keyed pointer proves +-- only that the origin endorses AAO hosting, not who the owner is. +-- +-- - claim_token: pending claim token. The owner pastes a pointer carrying it; the +-- verifier matches it to bind. NULL once consumed/absent. +-- - claimant_org_id: the org that requested the pending claim. Becomes +-- workos_organization_id on successful verification. NULL if no pending claim. +-- +-- A row is "locked" when workos_organization_id IS NOT NULL AND origin_verified_at +-- IS NOT NULL. The application layer refuses to change the owner of a locked row and +-- refuses to issue a claim for a domain locked to a different org. + +ALTER TABLE hosted_properties + ADD COLUMN IF NOT EXISTS claim_token TEXT NULL, + ADD COLUMN IF NOT EXISTS claimant_org_id VARCHAR(255) NULL + REFERENCES organizations(workos_organization_id) ON DELETE SET NULL; + +-- Look up a pending claim by token at verify time. +CREATE INDEX IF NOT EXISTS idx_hosted_properties_claim_token + ON hosted_properties(claim_token) + WHERE claim_token IS NOT NULL; diff --git a/server/src/db/property-db.ts b/server/src/db/property-db.ts index 504cab36c9..916f8ba0b7 100644 --- a/server/src/db/property-db.ts +++ b/server/src/db/property-db.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto'; import { query, getClient } from './client.js'; import type { HostedProperty, ResolvedProperty, RegistryRevision } from '../types.js'; @@ -897,6 +898,93 @@ export class PropertyDatabase { ); return result.rows[0] ? this.deserializeHostedProperty(result.rows[0]) : null; } + + /** + * Issue a pending domain claim for `claimantOrgId`. Creates the hosted row + * if absent (community / private / pending) and stamps a fresh claim token. + * The caller pastes a pointer carrying this token at their origin; a later + * `verify-origin` binds the owner via {@link bindOwnerFromVerifiedClaim}. + * + * Refuses to issue a claim for a domain already locked to a DIFFERENT org (a + * verified owner exists) — returns `{ token: null, lockedToOrgId }`. + */ + async issueDomainClaim( + publisherDomain: string, + claimantOrgId: string, + ): Promise<{ token: string | null; lockedToOrgId?: string }> { + const domain = publisherDomain.toLowerCase(); + const existing = await this.getHostedPropertyByDomain(domain); + + // A row is "locked" once a verified owner is bound. Issuing a claim to a + // different org would be a takeover attempt — refuse. (Same org may re-issue.) + if ( + existing?.origin_verified_at && + existing.workos_organization_id && + existing.workos_organization_id !== claimantOrgId + ) { + return { token: null, lockedToOrgId: existing.workos_organization_id }; + } + + const token = randomBytes(32).toString('base64url'); + + if (existing) { + await query( + `UPDATE hosted_properties + SET claim_token = $2, claimant_org_id = $3 + WHERE publisher_domain = $1`, + [domain, token, claimantOrgId], + ); + } else { + await this.createHostedProperty({ + publisher_domain: domain, + adagents_json: { + $schema: 'https://adcontextprotocol.org/schemas/latest/adagents.json', + authorized_agents: [], + properties: [], + }, + source_type: 'community', + }); + await query( + `UPDATE hosted_properties + SET claim_token = $2, claimant_org_id = $3 + WHERE publisher_domain = $1`, + [domain, token, claimantOrgId], + ); + } + return { token }; + } + + /** + * Bind the owner from a verified claim. Sets `workos_organization_id` to the + * pending `claimant_org_id` and consumes the token — but ONLY when `token` + * matches the pending `claim_token` and the row is not already locked to a + * different org. Binding is therefore driven by which token the origin + * pointer carries, never by who triggers verification. + * + * Returns the bound org id, or null when no matching pending claim binds. + */ + async bindOwnerFromVerifiedClaim( + publisherDomain: string, + token: string, + ): Promise<{ boundOrgId: string } | null> { + // Binding also publishes the row: an origin-verified, owned record is the + // publisher's authoritative document, not a pending community contribution. + const result = await query( + `UPDATE hosted_properties + SET workos_organization_id = claimant_org_id, + claim_token = NULL, + is_public = TRUE, + review_status = 'approved' + WHERE publisher_domain = $1 + AND claim_token = $2 + AND claimant_org_id IS NOT NULL + AND (workos_organization_id IS NULL OR workos_organization_id = claimant_org_id) + RETURNING workos_organization_id`, + [publisherDomain.toLowerCase(), token], + ); + const bound = result.rows[0]?.workos_organization_id; + return bound ? { boundOrgId: bound } : null; + } } // Singleton export diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index 6b3c702cd1..82e9ea7fee 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -885,13 +885,48 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "post", + path: "/api/properties/hosted/{domain}/claim", + operationId: "claimHostedPropertyDomain", + summary: "Claim a domain for bind-on-verify", + description: + "Issue a pending domain claim for the caller's organization and return a claim-specific `authoritative_location` URL (`…/adagents.json?adcp_claim=`). The caller places that single pointer at their own origin `/.well-known/adagents.json`; a subsequent verify-origin reads the token and binds the domain to the caller's org. The token is the per-account artifact that proves WHICH account owns the domain — a plain domain-keyed pointer proves only that the origin endorses AAO hosting, not who the owner is.\n\nThe community write surface stays open; this does not gate writes — it establishes ownership on successful verification. Refused with 409 only when the domain is already verified and locked to a different owner.", + tags: ["Property Resolution"], + security: [{ bearerAuth: [] }, { oauth2: [] }], + request: { + params: z.object({ + domain: z.string().openapi({ example: "examplepub.com" }), + }), + }, + responses: { + 200: { + description: "Claim issued", + content: { + "application/json": { + schema: z.object({ + success: z.literal(true), + domain: z.string(), + authoritative_location: z.string(), + instructions: z.string(), + }), + }, + }, + }, + 400: { description: "Invalid domain", content: { "application/json": { schema: ErrorSchema } } }, + 401: { description: "Authentication required", content: { "application/json": { schema: ErrorSchema } } }, + 403: { description: "Caller is not a member of any organization", content: { "application/json": { schema: ErrorSchema } } }, + 409: { description: "Domain already verified and locked to another owner", content: { "application/json": { schema: ErrorSchema } } }, + }, +}); + registry.registerPath({ method: "post", path: "/api/properties/hosted/{domain}/verify-origin", operationId: "verifyHostedPropertyOrigin", summary: "Verify AAO-hosted publisher origin", description: - "Trigger origin verification for an AAO-hosted publisher: fetches the publisher's own `/.well-known/adagents.json` and checks for an `authoritative_location` field pointing at the AAO-hosted URL. On success, promotes `agent_publisher_authorizations` rows from `source='aao_hosted'` to `source='adagents_json'` for the manifest's authorized agents — buyers reading the registry then see them as origin-attested.\n\nRequires authentication and either AAO admin OR org-membership matching the hosted property's owner. Fail-closed on NULL ownership.\n\nFailure classification:\n- `not_found`: publisher origin returned 404 (permanent — demotes if previously verified).\n- `invalid_json` / `no_authoritative_location` / `authoritative_location_mismatch`: publisher origin returned a parseable response that doesn't satisfy the spec stub pattern (permanent — demotes).\n- `unresolvable`: DNS NXDOMAIN, private IP, or non-http scheme (permanent — demotes).\n- `transient`: 5xx / 429 / 3xx / network timeout (leaves persisted state alone, stamps `origin_last_checked_at`).", + "Trigger origin verification for an AAO-hosted publisher: fetches the publisher's own `/.well-known/adagents.json` and checks for an `authoritative_location` field pointing at the AAO-hosted URL. On success, promotes `agent_publisher_authorizations` rows from `source='aao_hosted'` to `source='adagents_json'` for the manifest's authorized agents — buyers reading the registry then see them as origin-attested.\n\nBind-on-verify: when the pointer carries an `adcp_claim` token (see the claim endpoint), a successful verification binds the domain to that claim's organization and returns `bound_org_id`. Binding is driven by which token the origin pointer carries, never by who triggers verification, so any authenticated caller may trigger it and a squatter cannot bind a domain they don't control. An existing verified owner is never overwritten.\n\nFailure classification:\n- `not_found`: publisher origin returned 404 (permanent — demotes if previously verified).\n- `invalid_json` / `no_authoritative_location` / `authoritative_location_mismatch`: publisher origin returned a parseable response that doesn't satisfy the spec stub pattern (permanent — demotes).\n- `unresolvable`: DNS NXDOMAIN, private IP, or non-http scheme (permanent — demotes).\n- `transient`: 5xx / 429 / 3xx / network timeout (leaves persisted state alone, stamps `origin_last_checked_at`).", tags: ["Property Resolution"], security: [{ bearerAuth: [] }, { oauth2: [] }], request: { @@ -917,13 +952,13 @@ registry.registerPath({ ]), checked_at: z.string(), detail: z.string().optional(), + bound_org_id: z.string().optional(), }), }, }, }, 400: { description: "Invalid domain", content: { "application/json": { schema: ErrorSchema } } }, 401: { description: "Authentication required", content: { "application/json": { schema: ErrorSchema } } }, - 403: { description: "Caller does not own this hosted property (and is not an AAO admin)", content: { "application/json": { schema: ErrorSchema } } }, 404: { description: "No hosted property for this domain", content: { "application/json": { schema: ErrorSchema } } }, }, }); @@ -4505,6 +4540,19 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R }); } + // Owner lock: once a domain is origin-verified and bound to an owner + // (bind-on-verify), only that owner may edit the record. Unverified + // community rows remain openly editable (the contribute-back path). + if (existing.origin_verified_at && existing.workos_organization_id) { + const callerOrgId = await resolveCallerOrgId(req); + if (existing.workos_organization_id !== callerOrgId) { + return res.status(403).json({ + error: "Domain is locked to its verified owner; only the owner can edit this record", + domain: publisher_domain, + }); + } + } + const { property, revision_number } = await propertyDb.editCommunityProperty(publisher_domain, { adagents_json: adagentsJson, edit_summary: "API: updated property data", @@ -4549,6 +4597,47 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R } }); + // ── Domain claim (bind-on-verify) ────────────────────────────── + + // Issue a pending claim for the caller's org. Returns the claim-specific + // `authoritative_location` URL the caller pastes at their own origin; a later + // verify-origin reads the token and binds ownership. The token is the + // per-account artifact that proves WHICH account owns the domain — a plain + // domain-keyed pointer proves only that the origin endorses AAO hosting. + router.post("/properties/hosted/:domain/claim", ...saveMiddleware, async (req, res) => { + try { + const domain = (req.params.domain || '').toLowerCase(); + if (!isValidDomain(domain)) { + return res.status(400).json({ error: 'Invalid domain' }); + } + const callerOrgId = await resolveCallerOrgId(req); + if (!callerOrgId) { + return res.status(403).json({ error: 'Claiming a domain requires membership in an organization' }); + } + const { token, lockedToOrgId } = await propertyDb.issueDomainClaim(domain, callerOrgId); + if (!token) { + return res.status(409).json({ + error: 'Domain is already verified and locked to another owner', + domain, + locked_to_org_id: lockedToOrgId, + }); + } + const authoritativeLocation = `${aaoHostedAdagentsJsonUrl(domain)}?adcp_claim=${token}`; + return res.json({ + success: true, + domain, + authoritative_location: authoritativeLocation, + instructions: + `Place a JSON document at https://${domain}/.well-known/adagents.json with ` + + `{"authoritative_location": "${authoritativeLocation}"}, then call verify-origin. ` + + `Origin verification binds this domain to your organization.`, + }); + } catch (error) { + logger.error({ error }, 'Failed to issue domain claim'); + return res.status(500).json({ error: 'Failed to issue domain claim' }); + } + }); + // ── Origin verification (AAO-hosted publishers) ──────────────── router.post("/properties/hosted/:domain/verify-origin", ...saveMiddleware, async (req, res) => { @@ -4561,29 +4650,27 @@ export function createRegistryApiRouters(config: RegistryApiConfig): { router: R if (!hosted) { return res.status(404).json({ error: 'No hosted property for this domain' }); } - // Org-ownership check fails CLOSED on NULL ownership. The - // upstream `/api/properties/save` create path can leave the row - // with no `workos_organization_id` (community-edit pattern), and - // a "fail open if NULL" check would let any authenticated caller - // trigger verification on a squatted/unclaimed row — promoting - // source labels for a domain they don't actually own. Require - // either an explicit org match OR admin. The broader squatting - // risk in the create path is a separate fix (needs DNS / well- - // known domain-ownership challenge before write). - const callerOrgId = await resolveCallerOrgId(req); - const isAdmin = (req.user as { isAdmin?: boolean })?.isAdmin === true; - if (!isAdmin) { - if (!hosted.workos_organization_id) { - return res.status(403).json({ - error: 'Hosted property has no claimed owner — origin verification requires a claimed owner or an admin trigger', - domain, - }); - } - if (hosted.workos_organization_id !== callerOrgId) { - return res.status(403).json({ error: 'Not authorized to verify this domain' }); + // Bind-on-verify: ownership is established by the `adcp_claim` token + // carried in the publisher's origin pointer, NOT by the caller. Any + // authenticated caller may trigger verification; the outcome binds the + // claim's org (or no-ops). A squatter cannot make the real origin point + // at their token, so they can never bind a domain they don't control. + const outcome = await verifyHostedPropertyOrigin({ hosted }); + if (outcome.verified && outcome.bound_org_id) { + // Binding flips the row public (is_public=true, review_status=approved). + // Re-read and mirror that state change into the federated index so + // /api/registry/publisher reflects it immediately, rather than waiting + // for the next save. No-op when the row carries no properties. + const bound = await propertyDb.getHostedPropertyByDomain(domain); + if (bound) await syncHostedPropertyToFederatedIndex(bound); + // Disclose bound_org_id only to the org that bound it. Binding is + // token-driven, so a third party may trigger verification — but it + // shouldn't learn which org just bound the domain. + const callerOrgId = await resolveCallerOrgId(req); + if (outcome.bound_org_id !== callerOrgId) { + outcome.bound_org_id = undefined; } } - const outcome = await verifyHostedPropertyOrigin({ hosted }); return res.json(outcome); } catch (error) { logger.error({ error }, 'Origin verification failed'); diff --git a/server/src/services/hosted-property-origin-verifier.ts b/server/src/services/hosted-property-origin-verifier.ts index b01464e698..7a7eea2913 100644 --- a/server/src/services/hosted-property-origin-verifier.ts +++ b/server/src/services/hosted-property-origin-verifier.ts @@ -63,7 +63,7 @@ const MAX_RESPONSE_BYTES = 1_000_000; const FETCH_TIMEOUT_MS = 10_000; export type VerificationOutcome = - | { verified: true; reason: 'authoritative_location_pointer'; checked_at: Date } + | { verified: true; reason: 'authoritative_location_pointer'; checked_at: Date; bound_org_id?: string } | { verified: false; reason: VerificationFailureReason; checked_at: Date; detail?: string }; export type VerificationFailureReason = @@ -82,9 +82,28 @@ interface VerifyHostedOriginInput { propertyDb?: { recordOriginVerification: PropertyDatabase['recordOriginVerification']; touchOriginLastCheckedAt: PropertyDatabase['touchOriginLastCheckedAt']; + bindOwnerFromVerifiedClaim?: PropertyDatabase['bindOwnerFromVerifiedClaim']; }; } +/** + * Split an `adcp_claim` token out of a publisher's `authoritative_location` + * pointer. The token is the per-account artifact that binds a domain to its + * owner; the base URL (everything else) must still match AAO's hosted URL. + * Returns the base URL with the `adcp_claim` param removed, and the token. + */ +function splitClaim(authLoc: string): { base: string; claimToken: string | null } { + try { + const u = new URL(authLoc); + const claimToken = u.searchParams.get('adcp_claim'); + u.searchParams.delete('adcp_claim'); + u.search = u.searchParams.toString(); + return { base: u.toString(), claimToken: claimToken || null }; + } catch { + return { base: authLoc, claimToken: null }; + } +} + /** * Spec-canonical URL form for `authoritative_location` comparison. Uses * the SDK's `canonicalTargetUri` so we follow the same eight-step @@ -265,7 +284,10 @@ export async function verifyHostedPropertyOrigin( return { verified: false, reason: 'no_authoritative_location', checked_at: new Date() }; } - const canonicalPublisherPointer = canonicalize(authLoc); + // The pointer may carry an `adcp_claim` token that binds the domain to a + // specific owner. Split it out and match only the base against our hosted URL. + const { base: pointerBase, claimToken } = splitClaim(authLoc); + const canonicalPublisherPointer = canonicalize(pointerBase); if (!expectedAaoUrl || !canonicalPublisherPointer || canonicalPublisherPointer !== expectedAaoUrl) { await stampChecked(false); await demoteIfPreviouslyVerified(domain, hosted, propertyDb); @@ -273,16 +295,30 @@ export async function verifyHostedPropertyOrigin( verified: false, reason: 'authoritative_location_mismatch', checked_at: new Date(), - detail: `expected ${expectedAaoUrl ?? '(unparsable AAO URL)'}, got ${canonicalPublisherPointer ?? authLoc}`, + detail: `expected ${expectedAaoUrl ?? '(unparsable AAO URL)'}, got ${canonicalPublisherPointer ?? pointerBase}`, }; } // Verified. await stampChecked(true); + // Bind-on-verify: if the pointer carried a claim token, bind the domain to + // the org that requested THAT claim — never the caller who triggered this. + // bindOwnerFromVerifiedClaim is a no-op unless the token matches the pending + // claim and the row isn't already locked to a different org. + let bound_org_id: string | undefined; + if (claimToken && propertyDb.bindOwnerFromVerifiedClaim) { + try { + const bind = await propertyDb.bindOwnerFromVerifiedClaim(domain, claimToken); + bound_org_id = bind?.boundOrgId; + if (bound_org_id) logger.info({ domain, bound_org_id }, 'Domain bound to owner via verified claim'); + } catch (err) { + logger.warn({ err, domain }, 'Origin verified but owner binding failed'); + } + } const manifestAgents = readManifestAgentUrls(hosted.adagents_json || {}); const promotion = await promoteVerifiedAuthorizations(domain, manifestAgents); logger.info({ domain, promoted: promotion.promoted }, 'Origin verified via authoritative_location pointer'); - return { verified: true, reason: 'authoritative_location_pointer', checked_at: new Date() }; + return { verified: true, reason: 'authoritative_location_pointer', checked_at: new Date(), bound_org_id }; } /** diff --git a/server/src/types.ts b/server/src/types.ts index 8c79a93dcd..95f18333e0 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -632,6 +632,17 @@ export interface HostedProperty { * UI render "checked recently, not yet verified" vs "never checked." */ origin_last_checked_at?: Date | null; + /** + * Pending domain-claim token. An account claims the domain and pastes a + * pointer carrying this token at their origin; verify-origin matches it to + * bind the owner. NULL when there is no pending claim. + */ + claim_token?: string | null; + /** + * Org that requested the pending claim. Becomes `workos_organization_id` on + * successful origin verification. NULL when there is no pending claim. + */ + claimant_org_id?: string | null; created_at: Date; updated_at: Date; } diff --git a/server/tests/integration/hosted-property-origin-verifier-auth.test.ts b/server/tests/integration/hosted-property-origin-verifier-auth.test.ts index 9f5629ba9c..d28a0a2a99 100644 --- a/server/tests/integration/hosted-property-origin-verifier-auth.test.ts +++ b/server/tests/integration/hosted-property-origin-verifier-auth.test.ts @@ -8,13 +8,13 @@ * * Squatting risk being guarded: * `/api/properties/save` allows any authenticated caller to create - * a hosted_properties row for any publisher_domain, leaving - * `workos_organization_id` NULL. A "fail open if NULL" auth check - * on verify-origin would let any authenticated caller trigger - * verification on those orphan rows — the source-label promotion - * would land under the squatter's trigger if the publisher's origin - * happens to point at AAO. The fix in registry-api.ts:3470 region - * fails closed: NULL ownership → 403 unless admin. + * a hosted_properties row for any publisher_domain. Under bind-on-verify, + * triggering verify-origin is open to any authenticated caller (binding is + * driven by the `adcp_claim` token in the publisher's origin pointer, not by + * the caller). The invariant these tests guard is that triggering + * verification can never bind/rebind an owner without a matching pending + * claim — so a squatter who can't make the origin point at their token can + * never seize a domain, and an existing owner is never overwritten. */ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import request from 'supertest'; @@ -98,9 +98,8 @@ describe('verify-origin endpoint — non-admin auth (squat prevention)', () => { await clearFixtures(); }); - it('returns 403 when hosted property has NULL ownership (no squat-driven verification)', async () => { - // Mirror the create path used by /api/properties/save: no - // workos_organization_id passed → row owner is NULL. + it('does not bind an owner when verify-origin is triggered on an unclaimed row (squat prevention)', async () => { + // Mirror the create path used by /api/properties/save: NULL owner, no pending claim. await propertyDb.createHostedProperty({ publisher_domain: PUB, adagents_json: { authorized_agents: [], properties: [] }, @@ -109,14 +108,22 @@ describe('verify-origin endpoint — non-admin auth (squat prevention)', () => { // workos_organization_id intentionally omitted }); + // Triggering verification is no longer gated on caller ownership (binding is + // driven by the claim token in the origin pointer, not the caller). But with + // no pending claim, no origin outcome can bind an owner. The fake .example + // origin is unresolvable → verified:false. const res = await request(app) .post(`/api/properties/hosted/${encodeURIComponent(PUB)}/verify-origin`) .send({}); - expect(res.status).toBe(403); - expect(String(res.body.error)).toMatch(/no claimed owner/i); + expect(res.status).toBe(200); + expect(res.body.verified).toBe(false); + + // The invariant that matters: no squat-driven binding. + const row = await propertyDb.getHostedPropertyByDomain(PUB); + expect(row!.workos_organization_id ?? null).toBeNull(); }); - it('returns 403 when caller org does not match hosted_property owner', async () => { + it('a non-owner trigger never changes the bound owner (the lock holds)', async () => { await seedOtherOrg(); await propertyDb.createHostedProperty({ publisher_domain: PUB, @@ -129,6 +136,10 @@ describe('verify-origin endpoint — non-admin auth (squat prevention)', () => { const res = await request(app) .post(`/api/properties/hosted/${encodeURIComponent(PUB)}/verify-origin`) .send({}); - expect(res.status).toBe(403); + // The caller-org gate is gone — triggering is allowed... + expect(res.status).toBe(200); + // ...but it cannot rebind: ownership is unchanged. + const row = await propertyDb.getHostedPropertyByDomain(PUB); + expect(row!.workos_organization_id).toBe(OTHER_ORG_ID); }); }); diff --git a/server/tests/integration/registry-domain-claim-bind.test.ts b/server/tests/integration/registry-domain-claim-bind.test.ts new file mode 100644 index 0000000000..a4f642ff9c --- /dev/null +++ b/server/tests/integration/registry-domain-claim-bind.test.ts @@ -0,0 +1,179 @@ +/** + * Bind-on-verify domain claims (RFC #5749 gap #1). + * + * Ownership of a domain is established by binding an owner ON successful origin + * verification, driven by an `adcp_claim` token the owner places in their origin + * pointer — NOT by who triggers verification. These tests exercise the DB claim + * methods through the verifier end-to-end: + * - a valid token binds the claim's org (and publishes the row) + * - a pointer with no/wrong token verifies but does not bind + * - a domain already locked to one owner cannot be re-claimed by another + * + * The squat-proofing in production is that a squatter cannot make the real + * origin point at THEIR token; here we simulate the origin response via + * `fetchImpl` and assert that only the exact pending token binds. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import type { Pool } from 'pg'; + +vi.hoisted(() => { + process.env.WORKOS_API_KEY = process.env.WORKOS_API_KEY ?? 'test'; + process.env.WORKOS_CLIENT_ID = process.env.WORKOS_CLIENT_ID ?? 'client_test'; +}); + +vi.mock('../../src/middleware/auth.js', async () => { + const actual = await vi.importActual>('../../src/middleware/auth.js'); + const pass = (req: { user: unknown }, _res: unknown, next: () => void) => { + req.user = { id: 'user_claim_test', email: 'claim@test.com', isAdmin: false }; + next(); + }; + return { ...actual, requireAuth: pass, requireAdmin: (_r: unknown, _s: unknown, n: () => void) => n() }; +}); + +vi.mock('../../src/billing/stripe-client.js', () => ({ + stripe: null, + getSubscriptionInfo: vi.fn().mockResolvedValue(null), + createStripeCustomer: vi.fn().mockResolvedValue(null), + createCustomerSession: vi.fn().mockResolvedValue(null), + createBillingPortalSession: vi.fn().mockResolvedValue(null), +})); + +import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; +import { runMigrations } from '../../src/db/migrate.js'; +import { PropertyDatabase } from '../../src/db/property-db.js'; +import { verifyHostedPropertyOrigin } from '../../src/services/hosted-property-origin-verifier.js'; +import { aaoHostedAdagentsJsonUrl } from '../../src/config/aao.js'; + +const PUB = 'claim-bind.registry-baseline.example'; +const DOMAIN_LIKE = 'claim-bind%.registry-baseline.example'; +const ORG_A = 'org_claim_owner_a'; +const ORG_B = 'org_claim_owner_b'; + +/** Origin pointer that targets AAO's hosted URL, optionally carrying a claim token. */ +function pointerFetch(domain: string, token?: string) { + const authoritative_location = token + ? `${aaoHostedAdagentsJsonUrl(domain)}?adcp_claim=${token}` + : aaoHostedAdagentsJsonUrl(domain); + return vi.fn().mockResolvedValue({ status: 200, body: JSON.stringify({ authoritative_location }) }); +} + +describe('bind-on-verify domain claims', () => { + let pool: Pool; + let propertyDb: PropertyDatabase; + + async function clearFixtures() { + await pool.query('DELETE FROM hosted_properties WHERE publisher_domain LIKE $1', [DOMAIN_LIKE]); + } + + async function seedOrgs() { + await pool.query( + `INSERT INTO organizations (workos_organization_id, name) VALUES ($1, $2), ($3, $4) + ON CONFLICT (workos_organization_id) DO NOTHING`, + [ORG_A, 'Claim Owner A', ORG_B, 'Claim Owner B'], + ); + } + + beforeAll(async () => { + pool = initializeDatabase({ + connectionString: process.env.DATABASE_URL || 'postgresql://adcp:localdev@localhost:5432/adcp_test', + }); + await runMigrations(); + propertyDb = new PropertyDatabase(); + await seedOrgs(); + }, 60000); + + afterAll(async () => { + await clearFixtures(); + await pool.query('DELETE FROM organizations WHERE workos_organization_id IN ($1, $2)', [ORG_A, ORG_B]); + await closeDatabase(); + }, 30000); + + beforeEach(async () => { + await clearFixtures(); + }); + + it('binds the claim org and publishes the row when the origin pointer carries the matching token', async () => { + const { token } = await propertyDb.issueDomainClaim(PUB, ORG_A); + expect(token).toBeTruthy(); + + const outcome = await verifyHostedPropertyOrigin({ + hosted: (await propertyDb.getHostedPropertyByDomain(PUB))!, + fetchImpl: pointerFetch(PUB, token!), + }); + + expect(outcome.verified).toBe(true); + if (outcome.verified) expect(outcome.bound_org_id).toBe(ORG_A); + + const row = await propertyDb.getHostedPropertyByDomain(PUB); + expect(row!.workos_organization_id).toBe(ORG_A); + expect(row!.is_public).toBe(true); + expect(row!.review_status).toBe('approved'); + expect(row!.origin_verified_at).toBeTruthy(); + // Token is consumed on bind. + expect(row!.claim_token).toBeNull(); + }); + + it('verifies but does NOT bind when the pointer carries no claim token', async () => { + await propertyDb.issueDomainClaim(PUB, ORG_A); + + const outcome = await verifyHostedPropertyOrigin({ + hosted: (await propertyDb.getHostedPropertyByDomain(PUB))!, + fetchImpl: pointerFetch(PUB), // no token + }); + + expect(outcome.verified).toBe(true); + if (outcome.verified) expect(outcome.bound_org_id).toBeUndefined(); + + const row = await propertyDb.getHostedPropertyByDomain(PUB); + // Origin endorses AAO hosting, but no token → no owner bound. + expect(row!.workos_organization_id ?? null).toBeNull(); + }); + + it('does NOT bind when the pointer token does not match the pending claim', async () => { + await propertyDb.issueDomainClaim(PUB, ORG_A); + + const outcome = await verifyHostedPropertyOrigin({ + hosted: (await propertyDb.getHostedPropertyByDomain(PUB))!, + fetchImpl: pointerFetch(PUB, 'a-token-that-was-never-issued'), + }); + + expect(outcome.verified).toBe(true); + if (outcome.verified) expect(outcome.bound_org_id).toBeUndefined(); + + const row = await propertyDb.getHostedPropertyByDomain(PUB); + expect(row!.workos_organization_id ?? null).toBeNull(); + }); + + it('refuses to issue a claim for a domain already verified and locked to another owner', async () => { + // Lock the domain to ORG_A. + const { token } = await propertyDb.issueDomainClaim(PUB, ORG_A); + await verifyHostedPropertyOrigin({ + hosted: (await propertyDb.getHostedPropertyByDomain(PUB))!, + fetchImpl: pointerFetch(PUB, token!), + }); + expect((await propertyDb.getHostedPropertyByDomain(PUB))!.workos_organization_id).toBe(ORG_A); + + // ORG_B tries to claim — refused. + const second = await propertyDb.issueDomainClaim(PUB, ORG_B); + expect(second.token).toBeNull(); + expect(second.lockedToOrgId).toBe(ORG_A); + + // Owner is unchanged. + expect((await propertyDb.getHostedPropertyByDomain(PUB))!.workos_organization_id).toBe(ORG_A); + }); + + it('never overwrites an existing different owner even if a stale token is presented', async () => { + // Lock to ORG_A. + const { token: tokenA } = await propertyDb.issueDomainClaim(PUB, ORG_A); + await verifyHostedPropertyOrigin({ + hosted: (await propertyDb.getHostedPropertyByDomain(PUB))!, + fetchImpl: pointerFetch(PUB, tokenA!), + }); + + // Directly attempt to bind ORG_B via the DB method with a fabricated token — + // the guard requires a matching pending claim AND not-locked-to-another-owner. + const bind = await propertyDb.bindOwnerFromVerifiedClaim(PUB, 'fabricated-token'); + expect(bind).toBeNull(); + expect((await propertyDb.getHostedPropertyByDomain(PUB))!.workos_organization_id).toBe(ORG_A); + }); +}); diff --git a/server/tests/integration/registry-property-save-identity.test.ts b/server/tests/integration/registry-property-save-identity.test.ts index 208a36cf78..dafdbb68ea 100644 --- a/server/tests/integration/registry-property-save-identity.test.ts +++ b/server/tests/integration/registry-property-save-identity.test.ts @@ -60,6 +60,10 @@ describe('POST /api/properties/save — identity, not authorization', () => { async function clearFixtures() { await pool.query('DELETE FROM hosted_properties WHERE publisher_domain LIKE $1', [DOMAIN_LIKE]); + // The edit-path success case calls syncHostedPropertyToFederatedIndex, which + // derives a discovered_properties row; clear it too so re-runs against a + // persistent DB don't leave a row that trips the "authoritative" 409 guard. + await pool.query('DELETE FROM discovered_properties WHERE publisher_domain LIKE $1', [DOMAIN_LIKE]); } beforeAll(async () => { @@ -71,13 +75,13 @@ describe('POST /api/properties/save — identity, not authorization', () => { server = new HTTPServer(); await server.start(0); app = (server as unknown as { app: unknown }).app; - }); + }, 60000); afterAll(async () => { await clearFixtures(); await server?.stop(); await closeDatabase(); - }); + }, 30000); beforeEach(async () => { await clearFixtures(); diff --git a/static/openapi/registry.yaml b/static/openapi/registry.yaml index b23f1c8812..a72d3fc464 100644 --- a/static/openapi/registry.yaml +++ b/static/openapi/registry.yaml @@ -4118,6 +4118,73 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /api/properties/hosted/{domain}/claim: + post: + operationId: claimHostedPropertyDomain + summary: Claim a domain for bind-on-verify + description: |- + Issue a pending domain claim for the caller's organization and return a claim-specific `authoritative_location` URL (`…/adagents.json?adcp_claim=`). The caller places that single pointer at their own origin `/.well-known/adagents.json`; a subsequent verify-origin reads the token and binds the domain to the caller's org. The token is the per-account artifact that proves WHICH account owns the domain — a plain domain-keyed pointer proves only that the origin endorses AAO hosting, not who the owner is. + + The community write surface stays open; this does not gate writes — it establishes ownership on successful verification. Refused with 409 only when the domain is already verified and locked to a different owner. + tags: + - Property Resolution + security: + - bearerAuth: [] + - oauth2: [] + parameters: + - schema: + type: string + example: examplepub.com + required: true + name: domain + in: path + responses: + "200": + description: Claim issued + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + enum: + - true + domain: + type: string + authoritative_location: + type: string + instructions: + type: string + required: + - success + - domain + - authoritative_location + - instructions + "400": + description: Invalid domain + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Caller is not a member of any organization + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Domain already verified and locked to another owner + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/properties/hosted/{domain}/verify-origin: post: operationId: verifyHostedPropertyOrigin @@ -4125,7 +4192,7 @@ paths: description: |- Trigger origin verification for an AAO-hosted publisher: fetches the publisher's own `/.well-known/adagents.json` and checks for an `authoritative_location` field pointing at the AAO-hosted URL. On success, promotes `agent_publisher_authorizations` rows from `source='aao_hosted'` to `source='adagents_json'` for the manifest's authorized agents — buyers reading the registry then see them as origin-attested. - Requires authentication and either AAO admin OR org-membership matching the hosted property's owner. Fail-closed on NULL ownership. + Bind-on-verify: when the pointer carries an `adcp_claim` token (see the claim endpoint), a successful verification binds the domain to that claim's organization and returns `bound_org_id`. Binding is driven by which token the origin pointer carries, never by who triggers verification, so any authenticated caller may trigger it and a squatter cannot bind a domain they don't control. An existing verified owner is never overwritten. Failure classification: - `not_found`: publisher origin returned 404 (permanent — demotes if previously verified). @@ -4168,6 +4235,8 @@ paths: type: string detail: type: string + bound_org_id: + type: string required: - verified - reason @@ -4184,12 +4253,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" - "403": - description: Caller does not own this hosted property (and is not an AAO admin) - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "404": description: No hosted property for this domain content: