From 6c8cc04d7584341e1765db67757faf7bb70ed22e Mon Sep 17 00:00:00 2001 From: duyetbot <101855044+duyetbot@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:10:49 +0700 Subject: [PATCH] fix(api): reclaim stale domain claims with neutral conflicts Preserve global one-owner uniqueness with an atomic conditional upsert. Reclaim never-verified pending/failed claims after seven days, rotate claim identity, and avoid disclosing other projects in conflict errors. Document the lifecycle and cover tenant isolation, deadlines, and concurrent adds. JSON-path hardening from #441 remains unchanged. Co-Authored-By: Duyet Le Co-Authored-By: duyetbot --- PLAN.md | 6 + docs/INDEX.md | 1 + docs/api-reference.md | 38 ++ packages/api/src/routes/domains.ts | 3 + packages/api/src/services/domains.ts | 41 ++- packages/api/test/domains.test.ts | 506 ++++++++++++++++++++++++++- 6 files changed, 581 insertions(+), 14 deletions(-) diff --git a/PLAN.md b/PLAN.md index aca9f0ef..4f8aa533 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,6 +2,12 @@ This file is the single source of truth for autonomous maintenance. Read by Claude on every `/loop` iteration. +## Targeted Security Follow-up + +- [x] #436 Part 1: preserve global domain uniqueness, return neutral cross-project errors, atomically reclaim never-verified pending/failed claims after 7 days, and document the one-owner rule with regression coverage. +- [x] #436 Part 2: verify existing JSON-path hardening from #441; no implementation changes. +- PR review/green CI required before merge; do not merge this follow-up or release-please #442 as part of this task. + ## Phase 0 — Benchmark (EVERY iteration) Run quality scorecard first. If any metric regresses, fix it before doing anything else. diff --git a/docs/INDEX.md b/docs/INDEX.md index adf4b2a2..065a461a 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -16,6 +16,7 @@ |-------|-------------| | [Getting Started](getting-started.md) | Zero to working in 2 minutes | | [API Reference](api-reference.md) | Complete REST API documentation | +| [Custom Domains](api-reference.md#custom-domains) | One-owner rule, neutral errors, verification, and 7-day unverified claim reclamation | | [Integration Guide](integration.md) | Chat apps, AI frameworks (Vercel AI SDK, LangChain, LangGraph, OpenAI, Cloudflare), LLM tracing, multi-tenant | | [Webhooks](webhooks.md) | Register endpoints for `conversation.created` and other events; delivery payload, signature verification, retries | | [V2 Migration Guide](v2-migration.md) | Migrate from V1 to V2 API | diff --git a/docs/api-reference.md b/docs/api-reference.md index a1bb1374..15bce6c3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -58,6 +58,44 @@ Requests with a missing, malformed, or revoked key receive: Status: **401** +## Custom Domains + +Custom domains use a **one-domain → one-project owner** rule across all organizations. +Domain names are trimmed and lowercased before registration; global uniqueness is enforced +by the database. A domain cannot be shared between projects, even within one organization. +All domain-management endpoints below require a Clerk session for the project's organization. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/v1/projects/:projectId/domains` | List the project's domains | +| POST | `/api/v1/projects/:projectId/domains` | Add a domain with `{ "domain": "app.example.com" }` | +| GET | `/api/v1/projects/:projectId/domains/:domainId` | Read a domain | +| DELETE | `/api/v1/projects/:projectId/domains/:domainId` | Remove a domain | +| POST | `/api/v1/projects/:projectId/domains/:domainId/verify` | Check ownership proof | + +Adding a domain returns **201** with a new verification token and DNS TXT, HTTP file, and +HTML meta-tag instructions. An active duplicate in the requesting project returns +**409 `DOMAIN_EXISTS`**. Outside that project, an unavailable domain returns only: + +```json +{ "error": { "code": "DOMAIN_UNAVAILABLE", "message": "Domain cannot be added. Please try again later." } } +``` + +This **409** response does not identify another project or organization, reveal its token, +verification status, or timestamps, or explicitly confirm another tenant's registration. +It is a neutral error, not an indistinguishable-success protocol: clients can still observe +whether an add succeeded. No verification instructions are issued for an unsuccessful add. + +**Reclaiming an unverified claim:** when adding a domain, a `pending` or `failed` claim +that has never been verified becomes reclaimable **7 days (168 hours) after creation**. +Verification retries do not extend this deadline. Reclamation is on demand, not a scheduled +deletion; the original claim remains until another successful add (including a re-add from +the same project). The replacement receives a new ID and token, fresh timestamps, `pending` +status, and disabled SSL. Publish the new proof; the previous token and ID no longer apply. +Concurrent adds cannot create multiple owners, and a concurrently verified claim cannot be +reclaimed. Verified claims never expire through this mechanism; their owner must remove them +before moving the domain to another project. + ## Rate Limiting All authenticated endpoints enforce a fixed-window rate limit per API key. diff --git a/packages/api/src/routes/domains.ts b/packages/api/src/routes/domains.ts index 9bc91887..ecf7d448 100644 --- a/packages/api/src/routes/domains.ts +++ b/packages/api/src/routes/domains.ts @@ -118,6 +118,9 @@ router.post("/:projectId/domains", async (c) => { if (e instanceof Error && e.message === "DOMAIN_EXISTS") { return errorResponse(c, "DOMAIN_EXISTS", "Domain already exists", 409); } + if (e instanceof Error && e.message === "DOMAIN_UNAVAILABLE") { + return errorResponse(c, "DOMAIN_UNAVAILABLE", "Domain cannot be added. Please try again later.", 409); + } throw e; } }); diff --git a/packages/api/src/services/domains.ts b/packages/api/src/services/domains.ts index 5ed03e5f..60b9c19b 100644 --- a/packages/api/src/services/domains.ts +++ b/packages/api/src/services/domains.ts @@ -2,7 +2,7 @@ // Domains service — Business logic for custom domain management // --------------------------------------------------------------------------- -import { and, asc, eq, inArray } from "drizzle-orm"; +import { and, asc, eq, inArray, isNull, lte } from "drizzle-orm"; import type { DrizzleD1Database } from "drizzle-orm/d1"; import type { CustomDomain } from "../db/schema"; import { customDomains } from "../db/schema"; @@ -23,6 +23,9 @@ const DOMAIN_REGEX = /** Max domain name length */ const MAX_DOMAIN_LENGTH = 255; +/** Unverified claims can be reclaimed seven days after creation, not the last retry. */ +export const DOMAIN_CLAIM_TTL_MS = 7 * 24 * 60 * 60 * 1000; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -180,7 +183,7 @@ export async function getDomainByName( * @param projectId - Project ID * @param domain - Domain name (will be normalized) * @returns Domain with verification instructions - * @throws Error if domain already exists + * @throws DOMAIN_EXISTS for this project's active claim, otherwise DOMAIN_UNAVAILABLE */ export async function createDomain( db: DrizzleD1Database, @@ -189,12 +192,6 @@ export async function createDomain( ): Promise { const normalized = normalizeDomain(domain); - // Check if domain already exists - const existing = await getDomainByName(db, normalized); - if (existing) { - throw new Error("DOMAIN_EXISTS"); - } - // Generate verification token const verificationToken = generateVerificationToken(); const now = Date.now(); @@ -212,7 +209,33 @@ export async function createDomain( updatedAt: now, }; - await db.insert(customDomains).values(newDomain); + // One atomic statement preserves global uniqueness and cannot steal a claim + // that was verified concurrently. Rotate the ID as well as the token so an + // in-flight verification/deletion for the old claim cannot touch its replacement. + const [claimed] = await db + .insert(customDomains) + .values(newDomain) + .onConflictDoUpdate({ + target: customDomains.domain, + set: newDomain, + setWhere: and( + inArray(customDomains.verificationStatus, ["pending", "failed"]), + isNull(customDomains.verifiedAt), + lte(customDomains.createdAt, now - DOMAIN_CLAIM_TTL_MS), + ), + }) + .returning({ id: customDomains.id }); + + if (!claimed) { + // Only disclose duplicates within the authorized project. Never expose + // another project's owner, verification state, token, or claim deadline. + const ownClaim = await db + .select({ id: customDomains.id }) + .from(customDomains) + .where(and(eq(customDomains.domain, normalized), eq(customDomains.projectId, projectId))) + .get(); + throw new Error(ownClaim ? "DOMAIN_EXISTS" : "DOMAIN_UNAVAILABLE"); + } // Return with verification instructions return { diff --git a/packages/api/test/domains.test.ts b/packages/api/test/domains.test.ts index ce922926..dd33d1be 100644 --- a/packages/api/test/domains.test.ts +++ b/packages/api/test/domains.test.ts @@ -1,5 +1,9 @@ import { env, SELF } from "cloudflare:test"; -import { beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/d1"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { type CustomDomain, customDomains } from "../src/db/schema"; +import { createDomain as createDomainService } from "../src/services/domains"; import { sessionCookie, signTestSessionToken } from "./clerk-jwt"; import { applyMigrations, seedProject, TEST_PROJECT_ID } from "./setup"; @@ -10,8 +14,11 @@ const OTHER_ORG_ID = "clerk_other_org_999"; const JSON_HEADERS = { "Content-Type": "application/json" }; -async function dashboardHeaders(extra: Record = {}): Promise> { - const token = await signTestSessionToken({ orgId: SESSION_ORG_ID }); +async function dashboardHeaders( + extra: Record = {}, + orgId = SESSION_ORG_ID, +): Promise> { + const token = await signTestSessionToken({ orgId }); return { Cookie: sessionCookie(token), ...extra }; } @@ -19,10 +26,10 @@ function domainsUrl(projectId: string, suffix = ""): string { return `http://localhost/api/v1/projects/${projectId}/domains${suffix}`; } -async function createDomain(projectId: string, domain: string): Promise { +async function createDomain(projectId: string, domain: string, orgId = SESSION_ORG_ID): Promise { return SELF.fetch(domainsUrl(projectId), { method: "POST", - headers: await dashboardHeaders(JSON_HEADERS), + headers: await dashboardHeaders(JSON_HEADERS, orgId), body: JSON.stringify({ domain }), }); } @@ -57,6 +64,48 @@ interface ErrorBody { error: { code: string; message: string }; } +// Reclaim window: a pending/failed claim older than this is stale and may be +// taken over by a new project. Must match the service constant. +const RECLAIM_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +async function insertDomainRow(row: { + id: string; + projectId: string; + domain: string; + verificationStatus: "pending" | "verified" | "failed"; + verifiedAt: number | null; + createdAt: number; + updatedAt?: number; +}): Promise { + await env.DB.prepare( + `INSERT INTO custom_domains + (id, project_id, domain, verification_token, verification_status, verified_at, ssl_enabled, created_at, updated_at) + VALUES (?, ?, ?, 'agentstate-verify-testrow000000000', ?, ?, 0, ?, ?)`, + ) + .bind( + row.id, + row.projectId, + row.domain, + row.verificationStatus, + row.verifiedAt, + row.createdAt, + row.updatedAt ?? row.createdAt, + ) + .run(); +} + +async function getDomainRow(domain: string): Promise { + const row = await serviceDb().select().from(customDomains) + .where(eq(customDomains.domain, domain)).get(); + return row ?? null; +} + +// Drizzle handle over the same D1 binding the worker uses, for calling the +// service directly (boundary tests and concurrent adds). +function serviceDb() { + return drizzle(env.DB); +} + describe("Custom domains (/api/v1/projects/:projectId/domains)", () => { beforeAll(async () => { await applyMigrations(); @@ -335,6 +384,453 @@ describe("Custom domains (/api/v1/projects/:projectId/domains)", () => { expect(row?.id).toBe(domainId); }); }); + + // ------------------------------------------------------------------------- + // Regression: atomic domain claim with stale reclamation + // + // createDomain must be a single atomic insert/onConflictDoUpdate that: + // - claims an absent domain OR reclaims an existing row that is + // pending/failed, unverified, and older than the 7-day reclaim window + // (same-project reclaims included) + // - leaves an unexpired same-project claim as 409 DOMAIN_EXISTS + // - answers every different-project conflict with the same neutral 409 + // DOMAIN_UNAVAILABLE, never disclosing owner/id/token/status + // - never reclaims a verified row (or any row with historical verifiedAt) + // - does not extend the reclaim deadline on conflicting retries + // ------------------------------------------------------------------------- + + describe("domain claim atomicity and stale reclaim", () => { + const STALE_AGE_MS = RECLAIM_WINDOW_MS + 60 * 60 * 1000; // 7 days + 1h + const FRESH_AGE_MS = RECLAIM_WINDOW_MS - 60 * 60 * 1000; // 7 days - 1h + + let otherProjectId: string; + + beforeAll(async () => { + otherProjectId = await insertOtherOrgProject("reclaim"); + }); + + it("reclaims a stale pending domain from the same project with a new claim", async () => { + const domain = uniqueDomain("stale-pending"); + const createdAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_stale_pending_old", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.id).not.toBe("dom_stale_pending_old"); + expect(body.domain).toBe(domain); + expect(body.verification_instructions.dns_txt.value).toMatch(/^agentstate-verify-/); + + const row = await getDomainRow(domain); + expect(row).toMatchObject({ + id: body.id, + projectId: TEST_PROJECT_ID, + verificationStatus: "pending", + verifiedAt: null, + sslEnabled: false, + }); + expect(row!.verificationToken).toBe(body.verification_instructions.dns_txt.value); + expect(row!.verificationToken).not.toBe("agentstate-verify-testrow000000000"); + expect(row!.createdAt).toBeGreaterThanOrEqual(Date.now() - 60_000); + expect(row!.updatedAt).toBe(row!.createdAt); + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM custom_domains WHERE domain = ?") + .bind(domain).first<{ n: number }>()).toEqual({ n: 1 }); + }); + + it("reclaims a stale failed domain from the same project", async () => { + const domain = uniqueDomain("stale-failed"); + const createdAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_stale_failed_old", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "failed", + verifiedAt: null, + createdAt, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).not.toBe("dom_stale_failed_old"); + + const row = await getDomainRow(domain); + expect(row).toMatchObject({ + id: body.id, + projectId: TEST_PROJECT_ID, + verificationStatus: "pending", + verifiedAt: null, + sslEnabled: false, + }); + }); + + it("reclaims a stale pending domain from another organization", async () => { + const domain = uniqueDomain("stale-cross-project"); + const createdAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_stale_sameorg_old", + projectId: otherProjectId, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).not.toBe("dom_stale_sameorg_old"); + + const row = await getDomainRow(domain); + expect(row?.projectId).toBe(TEST_PROJECT_ID); + }); + + it("returns a neutral 409 for a different-project conflict regardless of status", async () => { + const cases: Array<{ + label: string; + status: "pending" | "verified" | "failed"; + verifiedAt: number | null; + ageMs: number; + }> = [ + { label: "unexpired-pending", status: "pending", verifiedAt: null, ageMs: FRESH_AGE_MS }, + { label: "unexpired-failed", status: "failed", verifiedAt: null, ageMs: FRESH_AGE_MS }, + { label: "expired-verified", status: "verified", verifiedAt: Date.now(), ageMs: STALE_AGE_MS }, + { label: "freshly-verified", status: "verified", verifiedAt: Date.now(), ageMs: 0 }, + ]; + + for (const c of cases) { + const domain = uniqueDomain(`conflict-${c.label}`); + await insertDomainRow({ + id: `dom_conflict_${c.label}`, + projectId: otherProjectId, + domain, + verificationStatus: c.status, + verifiedAt: c.verifiedAt, + createdAt: Date.now() - c.ageMs, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status, c.label).toBe(409); + + const body = await res.json(); + expect(body.error.code, c.label).toBe("DOMAIN_UNAVAILABLE"); + expect(body.error.message, c.label).toBe( + "Domain cannot be added. Please try again later.", + ); + + const raw = JSON.stringify(body); + expect(raw).not.toContain("dom_conflict_"); + expect(raw).not.toContain(otherProjectId); + expect(raw).not.toContain("agentstate-verify-"); + expect(raw).not.toMatch(/"status"\s*:/); + + // The conflicting row must be untouched. + const row = await getDomainRow(domain); + expect(row?.id).toBe(`dom_conflict_${c.label}`); + expect(row?.projectId).toBe(otherProjectId); + } + }); + + it("gives identical neutral responses across tenants for the same conflict", async () => { + const domain = uniqueDomain("cross-tenant"); + await insertDomainRow({ + id: "dom_cross_tenant_holder", + projectId: otherProjectId, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt: Date.now() - FRESH_AGE_MS, + }); + + const holder = await createDomain(TEST_PROJECT_ID, domain); + expect(holder.status).toBe(409); + const holderBody = await holder.json(); + expect(holderBody.error).toEqual({ + code: "DOMAIN_UNAVAILABLE", + message: "Domain cannot be added. Please try again later.", + }); + + const thirdProjectId = await insertOtherOrgProject("third-tenant"); + const challenger = await createDomain(thirdProjectId, domain, OTHER_ORG_ID); + expect(challenger.status).toBe(409); + const challengerBody = await challenger.json(); + expect(challengerBody.error).toEqual(holderBody.error); + }); + + it("does not extend the reclaim deadline when retries hit an unexpired claim", async () => { + const domain = uniqueDomain("retry-deadline"); + const originalCreatedAt = Date.now() - FRESH_AGE_MS; + await insertDomainRow({ + id: "dom_retry_deadline", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt: originalCreatedAt, + updatedAt: Date.now() - 60_000, + }); + + // Repeated conflicting attempts must leave createdAt (the reclaim + // deadline anchor) untouched. + for (let i = 0; i < 3; i++) { + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(409); + expect((await res.json()).error.code).toBe("DOMAIN_EXISTS"); + } + + const row = await getDomainRow(domain); + expect(row?.createdAt).toBe(originalCreatedAt); + }); + + it("reclaims after the deadline passes once retries have stopped", async () => { + const domain = uniqueDomain("retry-then-reclaim"); + const originalCreatedAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_retry_then_reclaim", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt: originalCreatedAt, + updatedAt: Date.now() - 60_000, + }); + + // Recent verification retries must not renew the claim's creation deadline. + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).not.toBe("dom_retry_then_reclaim"); + + const row = await getDomainRow(domain); + expect(row?.createdAt).toBeGreaterThanOrEqual(Date.now() - 60_000); + expect(row?.updatedAt).toBe(row?.createdAt); + }); + + it("never reclaims a verified domain, even from the same project", async () => { + const domain = uniqueDomain("verified-no-reclaim"); + await insertDomainRow({ + id: "dom_verified_holder", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "verified", + verifiedAt: Date.now(), + createdAt: Date.now() - STALE_AGE_MS, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(409); + expect((await res.json()).error.code).toBe("DOMAIN_EXISTS"); + + const row = await getDomainRow(domain); + expect(row?.id).toBe("dom_verified_holder"); + expect(row?.verificationStatus).toBe("verified"); + }); + + it("never reclaims a row with historical verifiedAt even if now failed", async () => { + const domain = uniqueDomain("historical-verified"); + await insertDomainRow({ + id: "dom_historical_verified", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "failed", + verifiedAt: Date.now() - STALE_AGE_MS, + createdAt: Date.now() - STALE_AGE_MS, + }); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(409); + expect((await res.json()).error.code).toBe("DOMAIN_EXISTS"); + + const row = await getDomainRow(domain); + expect(row?.id).toBe("dom_historical_verified"); + expect(row?.verifiedAt).not.toBeNull(); + }); + + it("keeps normalized uniqueness: conflicting case variants hit one row", async () => { + const label = uniqueDomain("norm").split(".")[0]; + const domain = `${label}.Example.COM`; + const first = await createDomain(TEST_PROJECT_ID, domain); + expect(first.status).toBe(201); + + // Same row must answer the differently-cased retry. + const second = await createDomain(TEST_PROJECT_ID, domain.toUpperCase()); + expect(second.status).toBe(409); + expect((await second.json()).error.code).toBe("DOMAIN_EXISTS"); + + expect(await env.DB.prepare("SELECT COUNT(*) AS n FROM custom_domains WHERE domain = ?") + .bind(domain.toLowerCase()).first<{ n: number }>()).toEqual({ n: 1 }); + }); + + it("replaces the old claim so the previous owner loses access", async () => { + const domain = uniqueDomain("takeover"); + const createdAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_takeover_old", + projectId: otherProjectId, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt, + }); + const oldToken = "agentstate-verify-testrow000000000"; + const oldOwnerHeaders = await dashboardHeaders({}, OTHER_ORG_ID); + + // Old owner can still see it before the takeover. + const before = await SELF.fetch(domainsUrl(otherProjectId, "/dom_takeover_old"), { + headers: oldOwnerHeaders, + }); + expect(before.status).toBe(200); + + const res = await createDomain(TEST_PROJECT_ID, domain); + expect(res.status).toBe(201); + const body = await res.json(); + + // New owner sees the replacement row under the new id; old id is gone. + const row = await getDomainRow(domain); + expect(row?.id).toBe(body.id); + expect(row?.id).not.toBe("dom_takeover_old"); + + const oldId = await env.DB.prepare("SELECT id FROM custom_domains WHERE id = ?") + .bind("dom_takeover_old") + .first<{ id: string }>(); + expect(oldId).toBeNull(); + + // Old owner's verify against the stale id must not resurrect anything. + const oldOwnerVerify = await SELF.fetch( + domainsUrl(otherProjectId, "/dom_takeover_old/verify"), + { method: "POST", headers: oldOwnerHeaders }, + ); + expect(oldOwnerVerify.status).toBe(404); + const oldOwnerGet = await SELF.fetch(domainsUrl(otherProjectId, `/${body.id}`), { + headers: oldOwnerHeaders, + }); + expect(oldOwnerGet.status).toBe(404); + + // Old token can no longer verify the domain: the row carries the new + // token, so a verification attempt with the old one must fail. + expect(row?.verificationToken).not.toBe(oldToken); + + // Old owner cannot delete the replacement either. + const oldOwnerDelete = await SELF.fetch(domainsUrl(otherProjectId, `/${body.id}`), { + method: "DELETE", + headers: oldOwnerHeaders, + }); + expect(oldOwnerDelete.status).toBe(404); + + const stillThere = await getDomainRow(domain); + expect(stillThere?.id).toBe(body.id); + }); + + it("concurrent adds for a new domain produce exactly one winner and no 500", async () => { + const domain = uniqueDomain("concurrent-new"); + const db = serviceDb(); + + const attempts = await Promise.all( + Array.from({ length: 6 }, () => + createDomainService(db, TEST_PROJECT_ID, domain).then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), + ), + ), + ); + + const winners = attempts.filter((a) => a.ok); + const losers = attempts.filter((a) => !a.ok); + expect(winners.length).toBe(1); + // Losers must surface the typed conflict, not an unexpected crash. + for (const loser of losers) { + expect((loser.error as Error).message).toBe("DOMAIN_EXISTS"); + } + + const rows = await env.DB.prepare("SELECT id FROM custom_domains WHERE domain = ?") + .bind(domain) + .all<{ id: string }>(); + expect(rows.results.length).toBe(1); + expect(rows.results[0].id).toBe(winners[0]!.result.id); + }); + + it("concurrent adds for a stale reclaim produce exactly one winner and no 500", async () => { + const domain = uniqueDomain("concurrent-reclaim"); + const createdAt = Date.now() - STALE_AGE_MS; + await insertDomainRow({ + id: "dom_concurrent_reclaim_old", + projectId: TEST_PROJECT_ID, + domain, + verificationStatus: "pending", + verifiedAt: null, + createdAt, + }); + const db = serviceDb(); + + const attempts = await Promise.all( + Array.from({ length: 6 }, () => + createDomainService(db, TEST_PROJECT_ID, domain).then( + (result) => ({ ok: true as const, result }), + (error: unknown) => ({ ok: false as const, error }), + ), + ), + ); + + const winners = attempts.filter((a) => a.ok); + for (const loser of attempts.filter((a) => !a.ok)) { + expect((loser.error as Error).message).toBe("DOMAIN_EXISTS"); + } + expect(winners.length).toBe(1); + + const rows = await env.DB.prepare( + "SELECT id, verification_token FROM custom_domains WHERE domain = ?", + ) + .bind(domain) + .all<{ id: string; verification_token: string }>(); + expect(rows.results.length).toBe(1); + expect(rows.results[0].id).toBe(winners[0]!.result.id); + expect(rows.results[0].verification_token).toBe( + winners[0]!.result.verification_instructions.dns_txt.value, + ); + }); + + it.each(["pending", "failed"] as const)("reclaims %s exactly at the seven-day boundary", async (status) => { + const domain = uniqueDomain("boundary"); + const base = Date.now(); + await insertDomainRow({ + id: "dom_boundary", + projectId: otherProjectId, + domain, + verificationStatus: status, + verifiedAt: null, + createdAt: base, + }); + + const db = serviceDb(); + const clock = vi.spyOn(Date, "now"); + try { + clock.mockReturnValue(base + RECLAIM_WINDOW_MS - 1); + await expect(createDomainService(db, TEST_PROJECT_ID, domain)).rejects.toThrow("DOMAIN_UNAVAILABLE"); + expect((await getDomainRow(domain))?.id).toBe("dom_boundary"); + + clock.mockReturnValue(base + RECLAIM_WINDOW_MS); + const claimed = await createDomainService(db, TEST_PROJECT_ID, domain); + expect(claimed.id).not.toBe("dom_boundary"); + expect(await getDomainRow(domain)).toMatchObject({ + id: claimed.id, + projectId: TEST_PROJECT_ID, + verificationStatus: "pending", + createdAt: base + RECLAIM_WINDOW_MS, + updatedAt: base + RECLAIM_WINDOW_MS, + }); + } finally { + clock.mockRestore(); + } + }); + }); }); async function insertOtherOrgProject(label: string): Promise {