Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
38 changes: 38 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/routes/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Expand Down
41 changes: 32 additions & 9 deletions packages/api/src/services/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand All @@ -189,12 +192,6 @@ export async function createDomain(
): Promise<DomainWithVerificationInstructions> {
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();
Expand All @@ -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 {
Expand Down
Loading