Skip to content

feat(registry): domain claim + bind-on-verify ownership (#5749 gap #1)#5752

Merged
bokelley merged 1 commit into
mainfrom
feat/registry-domain-claim-bind-on-verify
Jun 30, 2026
Merged

feat(registry): domain claim + bind-on-verify ownership (#5749 gap #1)#5752
bokelley merged 1 commit into
mainfrom
feat/registry-domain-claim-bind-on-verify

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

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.

  1. An authenticated account claims a domain → POST /api/properties/hosted/:domain/claim mints a claim_token + records claimant_org_id, and returns a claim-specific authoritative_location URL: …/publisher/{domain}/.well-known/adagents.json?adcp_claim=<token>.
  2. The account places that single pointer at their own origin /.well-known/adagents.json (one IT action — and the same pointer thereafter; they iterate the record in the DB, not on their prod site).
  3. verify-origin reads the token, base-matches the rest of the pointer against AAO's hosted URL, and binds workos_organization_id to 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

  • Migration 523claim_token + claimant_org_id on hosted_properties.
  • property-dbissueDomainClaim (mints/updates a pending claim; refuses a domain verified+locked to another org) and bindOwnerFromVerifiedClaim (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.
  • Verifier (hosted-property-origin-verifier.ts) — splitClaim extracts adcp_claim, base-matches via the SDK's canonicalTargetUri, binds 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 old NULL-owner/org-mismatch 403s). On a bound verification it re-syncs the now-public row to the federated index, and discloses bound_org_id only 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_agents stays [] per fix(registry): enforce identity-not-authorization on property-save (#5749 gap #2) #5750.
  • Regenerated static/openapi/registry.yaml.

Reviews

  • Code review: clean — no blocking/major. Confirmed token-driven (not caller-driven) binding, the SQL owner-guard, and splitClaim resistance to host-swap/extra-param/malformed pointers.
  • Security review: ship-able — ownership-takeover NOT possible. The verifier fetches https://{publisher_domain} from the server-side row (never caller/pointer); canonicalTargetUri fails 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

  • New 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.
  • Updated the verify-origin auth test for the new invariant (open trigger, no squat-driven binding, lock holds).
  • Fixed a discovered_properties cleanup leak + beforeAll hook-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)

  • Periodic TTL re-verify + lapse (demote, never delete) — closes the domain-transfer/stale-pointer window.
  • DNS-TXT pointer flavor for DNS-only owners (HTTP-can't, DNS-can).
  • Multi-pending-claim side table — closes single-pending-claim griefing (a squatter overwriting a pending token can deny, never seize).
  • Owner-confirmation of community-staged properties[] on bind (no sales authz is conferred either way — authorized_agents stays []).
  • Owner-authored authorized_agents on a verified-locked record (re-enables authz for the proven owner; needs its own threat review).
  • Extend owner-lock enforcement to the Addie/admin write paths (Audit all hosted_properties.adagents_json write paths for the identity-not-authorization invariant (#5749 gap #2, platform-wide) #5751).

🤖 Generated with Claude Code

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>

@aao-release-bot aao-release-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on claim_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_id from 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 from publisher_domain via expectedAdagentsJsonUrl — caller can't redirect it. A squatter can't make the real origin serve their token.
  • splitClaim fails closed. Strips adcp_claim, re-serializes, base still matched via canonicalTargetUri exact-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. safeFetch already blocks private/loopback/metadata IPs.
  • bound_org_id does not leak. Blanked unless === callerOrgId in 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 own authoritative_location.
  • issueDomainClaim lock refusal + owner-lock on /properties/save hold; 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, both INSERT, and the publisher_domain UNIQUE 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 single INSERT ... ON CONFLICT (publisher_domain) DO UPDATE guarded by the lock predicate — kills the create/update branches and the TOCTOU on the lock check at once. (property-db.ts:88-104)
  • deserializeHostedProperty spreads claim_token/claimant_org_id into every in-memory HostedProperty. 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 future res.json(hosted) can't silently disclose a live token. (security-reviewer Low, defense-in-depth.)

Minor nits (non-blocking)

  1. Stale claimant_org_id after bind. The bind nulls claim_token but leaves claimant_org_id populated (property-db.ts:124-135) — harmless now (lock keys off workos_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.
  2. 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.

@mintlify

mintlify Bot commented Jun 29, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
adcp 🟢 Ready View Preview Jun 29, 2026, 10:52 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@bokelley bokelley merged commit 541914a into main Jun 30, 2026
16 checks passed
@bokelley bokelley deleted the feat/registry-domain-claim-bind-on-verify branch June 30, 2026 04:15
bokelley added a commit that referenced this pull request Jun 30, 2026
 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>
bokelley added a commit that referenced this pull request Jun 30, 2026
 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>
bokelley added a commit that referenced this pull request Jun 30, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant