feat(registry): domain claim + bind-on-verify ownership (#5749 gap #1)#5752
Conversation
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=<token>). 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) <noreply@anthropic.com>
There was a problem hiding this comment.
Bind-on-verify is the right shape: domain control is the trust root, the token is the per-account artifact, and binding is keyed to which token the origin serves — never to who triggers verification. Closes gap #1 without re-closing the open community write surface from #5750.
Things I checked
- The owner-guard cannot overwrite a different owner.
bindOwnerFromVerifiedClaim(property-db.ts:124-135) gates onclaim_token = $2 AND claimant_org_id IS NOT NULL AND (workos_organization_id IS NULL OR workos_organization_id = claimant_org_id)— a row locked to another org matches zero rows. Token consumed (claim_token = NULL) atomically in the same UPDATE. Race-safe (single statement). - Binding is token-driven, not caller-driven. Verifier binds
claimant_org_idfrom the row matched by the token in the origin pointer (hosted-property-origin-verifier.ts:397-406), never the caller. The fetch target is server-derived frompublisher_domainviaexpectedAdagentsJsonUrl— caller can't redirect it. A squatter can't make the real origin serve their token. splitClaimfails closed. Stripsadcp_claim, re-serializes, base still matched viacanonicalTargetUriexact-equality against the fixed AAO URL (hosted-property-origin-verifier.ts:355-365). Host-swap / userinfo / port / IDN / traversal / double-encoding either fail to canonicalize or won't equal the AAO URL.safeFetchalready blocks private/loopback/metadata IPs.bound_org_iddoes not leak. Blanked unless=== callerOrgIdin the verify-origin handler — a third-party trigger learns nothing about which org bound.- Token secrecy. 256-bit CSPRNG (
randomBytes(32).base64url), never logged, returned only inside the claimant's ownauthoritative_location. issueDomainClaimlock refusal + owner-lock on/properties/savehold; single-pending-claim overwrite is DENY-but-never-SEIZE (griefing only — already flagged as a follow-up).code-reviewer: sound-with-caveats, no Blocker/Major.security-reviewer: ship-able, ownership-takeover NOT possible, no High.
Follow-ups (non-blocking — file as issues)
- Claim issuance isn't race-safe against a concurrent first-claim. Two concurrent claims on a new domain both see
existing == null, bothINSERT, and thepublisher_domainUNIQUE constraint (migration 202) makes the loser throw a unique-violation → spurious 500 for a legitimate claimant. No corruption, no wrong-owner bind. Collapse to a singleINSERT ... ON CONFLICT (publisher_domain) DO UPDATEguarded by the lock predicate — kills the create/update branches and the TOCTOU on the lock check at once. (property-db.ts:88-104) deserializeHostedPropertyspreadsclaim_token/claimant_org_idinto every in-memoryHostedProperty. No endpoint leaks it today (all hosted-returning paths emit curated subsets), but secrecy then depends on every future caller remembering to curate. Strip both fields in the deserializer so a futureres.json(hosted)can't silently disclose a live token. (security-reviewerLow, defense-in-depth.)
Minor nits (non-blocking)
- Stale
claimant_org_idafter bind. The bind nullsclaim_tokenbut leavesclaimant_org_idpopulated (property-db.ts:124-135) — harmless now (lock keys offworkos_organization_id), but dead state that will mislead the planned multi-pending-claim follow-up. Null it in the same UPDATE or comment that it's retained as an audit trail. recordOriginVerification(true)and the bind are separate UPDATEs with no enclosing transaction. A crash between them leaves a verified-but-unbound row with the token intact; a later re-verify re-binds, so it self-heals. A one-line comment noting the non-atomicity would help the next reader.
Five new tests cover claimant-not-caller binding, no-token/wrong-token no-bind, locked-domain re-claim refusal, and never-overwrite. The inverted verify-origin auth test correctly re-states the invariant (open trigger, no squat-driven binding, lock holds). Third PR in the #5749 series and the auth story is finally load-bearing rather than a NULL-owner fail-closed patch.
LGTM. Follow-ups noted below.
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
gap #1 follow-up) Bind-on-verify (#5752) binds a domain to its owner when the origin pointer points at AAO. But domains change hands and publishers remove pointers, so a verified row needs re-checking on a TTL — otherwise a transferred domain keeps its stale owner forever and can never be re-claimed. - Periodic re-verify job (crawler): startPeriodicHostedOriginReverification re-runs verifyHostedPropertyOrigin over verified rows past a 24h TTL, bounded batch + concurrency, mirroring the manager-revalidation pattern. Wired into startup (hourly tick). A permanent failure (origin no longer points at AAO) clears origin_verified_at via the existing verifier path — the owner lock lapses. Transient failures (5xx/429/timeout) leave verified state intact by design. - Lapse → re-claim: bindOwnerFromVerifiedClaim now also binds when origin_verified_at IS NULL, so a lapsed domain's stale workos_organization_id no longer blocks a new owner from re-binding. issueDomainClaim already keys its refusal on origin_verified_at, so a lapsed domain is re-claimable. - Verifier ordering fix: bind BEFORE stamping origin_verified_at, so the bind guard sees the pre-verification (lapsed) state rather than the lock it's about to re-arm. (Found by the re-bind test.) - getHostedPropertiesDueForReverification: the work-list query (verified rows past the TTL; unverified rows are never candidates). - The row is never deleted on lapse — the rid is forever; verification and the owner binding lapse, content stays. Tests: lapse releases the lock and lets a new owner re-claim and re-bind; the due-for-reverification query excludes unverified rows. Existing bind + verifier suites unchanged (7/7 and 10/10). Stacked on #5760 (the time-bomb fix) — that commit drops out on rebase once #5760 merges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gap #1 follow-up) Bind-on-verify (#5752) binds a domain to its owner when the origin pointer points at AAO. But domains change hands and publishers remove pointers, so a verified row needs re-checking on a TTL — otherwise a transferred domain keeps its stale owner forever and can never be re-claimed. - Periodic re-verify job (crawler): startPeriodicHostedOriginReverification re-runs verifyHostedPropertyOrigin over verified rows past a 24h TTL, bounded batch + concurrency, mirroring the manager-revalidation pattern. Wired into startup (hourly tick). A permanent failure (origin no longer points at AAO) clears origin_verified_at via the existing verifier path — the owner lock lapses. Transient failures (5xx/429/timeout) leave verified state intact by design. - Lapse → re-claim: bindOwnerFromVerifiedClaim now also binds when origin_verified_at IS NULL, so a lapsed domain's stale workos_organization_id no longer blocks a new owner from re-binding. issueDomainClaim already keys its refusal on origin_verified_at, so a lapsed domain is re-claimable. - Verifier ordering fix: bind BEFORE stamping origin_verified_at, so the bind guard sees the pre-verification (lapsed) state rather than the lock it's about to re-arm. (Found by the re-bind test.) - getHostedPropertiesDueForReverification: the work-list query (verified rows past the TTL; unverified rows are never candidates). - The row is never deleted on lapse — the rid is forever; verification and the owner binding lapse, content stays. Tests: lapse releases the lock and lets a new owner re-claim and re-bind; the due-for-reverification query excludes unverified rows. Existing bind + verifier suites unchanged (7/7 and 10/10). Stacked on #5760 (the time-bomb fix) — that commit drops out on rebase once #5760 merges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(registry): periodic hosted-origin re-verify + lapse→re-claim (#5749 gap #1 follow-up) Bind-on-verify (#5752) binds a domain to its owner when the origin pointer points at AAO. But domains change hands and publishers remove pointers, so a verified row needs re-checking on a TTL — otherwise a transferred domain keeps its stale owner forever and can never be re-claimed. - Periodic re-verify job (crawler): startPeriodicHostedOriginReverification re-runs verifyHostedPropertyOrigin over verified rows past a 24h TTL, bounded batch + concurrency, mirroring the manager-revalidation pattern. Wired into startup (hourly tick). A permanent failure (origin no longer points at AAO) clears origin_verified_at via the existing verifier path — the owner lock lapses. Transient failures (5xx/429/timeout) leave verified state intact by design. - Lapse → re-claim: bindOwnerFromVerifiedClaim now also binds when origin_verified_at IS NULL, so a lapsed domain's stale workos_organization_id no longer blocks a new owner from re-binding. issueDomainClaim already keys its refusal on origin_verified_at, so a lapsed domain is re-claimable. - Verifier ordering fix: bind BEFORE stamping origin_verified_at, so the bind guard sees the pre-verification (lapsed) state rather than the lock it's about to re-arm. (Found by the re-bind test.) - getHostedPropertiesDueForReverification: the work-list query (verified rows past the TTL; unverified rows are never candidates). - The row is never deleted on lapse — the rid is forever; verification and the owner binding lapse, content stays. Tests: lapse releases the lock and lets a new owner re-claim and re-bind; the due-for-reverification query excludes unverified rows. Existing bind + verifier suites unchanged (7/7 and 10/10). Stacked on #5760 (the time-bomb fix) — that commit drops out on rebase once #5760 merges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(registry): preserve transient DNS reverify state --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements gap #1 of RFC #5749 — the authority model for the AAO community registry. Companion to #5750 (gap #2, merged).
Model: convergence, not gatekeeping
The community write surface stays open (anyone can stage a non-authoritative, private, community row — the #5750 world). Authority over a domain is established by binding an owner on successful origin verification, not by gating the write.
POST /api/properties/hosted/:domain/claimmints aclaim_token+ recordsclaimant_org_id, and returns a claim-specificauthoritative_locationURL:…/publisher/{domain}/.well-known/adagents.json?adcp_claim=<token>./.well-known/adagents.json(one IT action — and the same pointer thereafter; they iterate the record in the DB, not on their prod site).verify-originreads the token, base-matches the rest of the pointer against AAO's hosted URL, and bindsworkos_organization_idto the claim's org, publishes the row, and marks it origin-verified.Why the token: a plain domain-keyed pointer proves only that the origin endorses AAO hosting — not which account owns it. The token is the per-account artifact that makes binding safe. 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 can't bind a domain they don't control (they can't make the real origin point at their token). An existing verified owner is never overwritten; claiming a domain locked to another org is refused.
This reframes gap #1: the authority model isn't a write-time gate, it's claim → origin-verify → bind, with the origin (domain control) as the trust root and mistakes reversible.
Changes
claim_token+claimant_org_idonhosted_properties.property-db—issueDomainClaim(mints/updates a pending claim; refuses a domain verified+locked to another org) andbindOwnerFromVerifiedClaim(binds the claimant + publishes the row; WHERE-clause guard never overwrites a different owner).POST /properties/hosted/:domain/claim— returns the claim-specific pointer URL + instructions.hosted-property-origin-verifier.ts) —splitClaimextractsadcp_claim, base-matches via the SDK'scanonicalTargetUri, binds on success; outcome carriesbound_org_id.verify-originhandler inverted — no longer requires a pre-bound owner; trigger is open, binding is token-driven (replaces the old NULL-owner/org-mismatch 403s). On a bound verification it re-syncs the now-public row to the federated index, and disclosesbound_org_idonly to the org that bound it./properties/save— owner-locked edit: once verified+bound, only the owner may edit (community rows stay openly editable).authorized_agentsstays[]per fix(registry): enforce identity-not-authorization on property-save (#5749 gap #2) #5750.static/openapi/registry.yaml.Reviews
splitClaimresistance to host-swap/extra-param/malformed pointers.https://{publisher_domain}from the server-side row (never caller/pointer);canonicalTargetUrifails closed on userinfo/port/IDN/traversal/double-encoding; the bind never overwrites a different owner. Token is 256-bit CSPRNG, never logged or serialized to clients (all hosted-returning endpoints emit curated subsets, not the raw row).Tests
registry-domain-claim-bind.test.ts(5): token binds the claimant not the caller; no/wrong token verifies but doesn't bind; locked domain refuses re-claim; owner never overwritten.verify-originauth test for the new invariant (open trigger, no squat-driven binding, lock holds).discovered_propertiescleanup leak +beforeAllhook-timeout in the fix(registry): enforce identity-not-authorization on property-save (#5749 gap #2) #5750 save test (exposed while validating against the shared local DB).Follow-ups (not this PR)
properties[]on bind (no sales authz is conferred either way —authorized_agentsstays[]).authorized_agentson a verified-locked record (re-enables authz for the proven owner; needs its own threat review).🤖 Generated with Claude Code