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: