Skip to content

feat(publish): custom domain model + settings UI + DNS instructions (PR1) - #1695

Merged
2witstudios merged 3 commits into
masterfrom
pu/customdomain-pr1
Jun 24, 2026
Merged

2witstudios merged 3 commits into
masterfrom
pu/customdomain-pr1

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

PR1 of 5 in the Custom Domain + SSL Provisioning epic. Lays the full data/API/UI foundation; DNS verification, Fly cert provisioning, Caddy routing, and canonical/OG wiring are deferred to PR2–5.

  • Pure core (packages/lib/src/validators/custom-domain.ts): normalizeHostname (strips scheme/path/port/trailing-dot), validateCustomDomain (RFC-1123 charset+length, rejects pagespace.ai/pagespace.site/*.pagespace.*), buildDnsInstructions (apex → A + AAAA, 3+-label → CNAME, all subdomain labels preserved via labels.slice(0,-2).join('.')). 30 unit tests.
  • DB (packages/db/src/schema/custom-domains.ts): custom_domains table — id, driveId (FK → drives, cascade), hostname (globally unique, normalized), status (pgEnum: pending/verified/failed, default pending), createdAt. Migration 0168.
  • API (apps/web/src/app/api/drives/[driveId]/domains/): GET list + POST add (+ DELETE at /[domainId]). Owner/admin gated, CSRF on writes, 409 on duplicate hostname (Postgres error code 23505), audited. 21 route contract tests (mocked DB).
  • UI: Custom Domains card added to drive Settings → General. Add/remove domains, expandable DNS record table (A+AAAA for apex, CNAME for subdomains) sourced from NEXT_PUBLIC_PUBLISH_EDGE_IPV4/IPV6/CNAME_TARGET env vars. Status badge reads Pending DNS with copy noting verification comes in a later PR. Malformed-JSON body → 400; non-OK response → toast with server error text.

Acceptance criteria

  • 30 pure unit tests (normalize, validate, DNS builder including deep subdomains)
  • 21 route contract tests (mocked DB: add validates + dedupes 409, list, delete, auth/CSRF/403 gating)
  • Migration generated via db:generate; @pagespace/db/schema/custom-domains + @pagespace/lib/validators/custom-domain exports added
  • bun run lint ✅ · bun run typecheck ✅ · bun run test:unit (new tests) ✅ · bun run build ✅

Out of scope (later PRs)

DNS verification/polling, Fly cert API, Caddy routing config, canonical/OG/sitemap host changes.

Test plan

  • Navigate to a drive Settings → General, scroll to Custom Domains card
  • Add docs.example.com → verify it appears with Pending DNS badge and DNS row shows CNAME instruction
  • Add example.com → verify DNS row shows A + AAAA instructions
  • Add the same domain again → verify 409 toast "already registered"
  • Add acme.pagespace.site → verify rejected client-side with validation error
  • Remove a domain → verify it disappears from the list

🤖 Generated with Claude Code

https://claude.ai/code/session_01LCj8wakYJbhBcyH9cn93Sa

…PR1)

- Pure core: normalizeHostname, validateCustomDomain, buildDnsInstructions
  in packages/lib/src/validators/custom-domain.ts with 29 unit tests
  (apex/subdomain heuristic, RFC-1123 charset/length, pagespace.* block)
- DB: custom_domains table (id, driveId FK cascade, hostname unique,
  status pending/verified/failed, createdAt); migration 0168
- API: GET/POST /api/drives/[driveId]/domains + DELETE .../[domainId]
  — owner/admin gated, CSRF on writes, 409 on duplicate, audited
  21 route contract tests (mocked DB)
- UI: Custom Domains card added to drive Settings → General; add/remove
  domains, DNS instructions (A+AAAA for apex, CNAME for sub) from
  NEXT_PUBLIC_PUBLISH_EDGE_IPV4/IPV6/CNAME_TARGET env vars, status badge
  with 'pending DNS verification' affordance
- Exports: @pagespace/lib/validators/custom-domain,
  @pagespace/db/schema/custom-domains added to package.json

DNS verify, Fly cert provisioning, Caddy routing: deferred to PR2-4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCj8wakYJbhBcyH9cn93Sa
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds end-to-end custom domain management for drives: a new custom_domains PostgreSQL table and Drizzle schema, hostname normalization/validation and DNS instruction utilities in packages/lib, REST API handlers (GET, POST, DELETE) with auth/audit/error handling, and a CustomDomainsCard UI component in the drive General settings page.

Changes

Custom Domains Feature

Layer / File(s) Summary
DB schema, migration, and package exports
packages/db/src/schema/custom-domains.ts, packages/db/drizzle/0168_narrow_dracula.sql, packages/db/drizzle/meta/_journal.json, packages/db/src/schema.ts, packages/db/package.json
Defines the custom_domains Drizzle table with customDomainStatus enum, FK to drives with cascade delete, unique hostname index, and inferred TypeScript types; runs the corresponding SQL migration; registers the new module in the packages/db barrel and exports map.
Hostname validation and DNS instruction utilities
packages/lib/src/validators/custom-domain.ts, packages/lib/package.json, packages/lib/src/validators/__tests__/custom-domain.test.ts
Adds normalizeHostname, validateCustomDomain, isApexDomain, and buildDnsInstructions pure functions; exposes them via the ./validators/custom-domain subpath export; covers all normalization, validation, and DNS record generation cases with unit tests.
GET and POST /api/drives/[driveId]/domains route and tests
apps/web/src/app/api/drives/[driveId]/domains/route.ts, apps/web/src/app/api/drives/[driveId]/domains/__tests__/route.test.ts
Implements GET (list domains, MCP scope + owner/admin guard) and POST (create domain: Zod validation, hostname normalization, DB insert, audit log, 409 on duplicate); includes contract tests for all auth, validation, success, and error paths.
DELETE /api/drives/[driveId]/domains/[domainId] route and tests
apps/web/src/app/api/drives/[driveId]/domains/[domainId]/route.ts, apps/web/src/app/api/drives/[driveId]/domains/[domainId]/__tests__/route.test.ts
Implements DELETE with CSRF auth, scope/ownership checks, DB deletion by domainId+driveId (404 if absent), audit log on success, and { deleted: true } response; includes contract tests for all auth/found/error scenarios.
Custom Domains UI in drive General settings
apps/web/src/app/dashboard/[driveId]/settings/general/page.tsx
Adds CustomDomainsCard and DomainRow components: SWR-fetched domain list, add/remove handlers with toast feedback, per-domain status badges, and a "Show DNS" toggle that renders A/AAAA or CNAME records from buildDnsInstructions using env-provided Edge IP/CNAME values.

Sequence Diagram(s)

sequenceDiagram
  rect rgba(100, 149, 237, 0.5)
    Note over User,SWR: Add Domain
    User->>CustomDomainsCard: types hostname + clicks Add
    CustomDomainsCard->>handleAddDomain: hostname string
    handleAddDomain->>normalizeHostname: canonicalize input
    handleAddDomain->>validateCustomDomain: check hostname validity
    validateCustomDomain-->>handleAddDomain: valid or toast 400
    handleAddDomain->>API: POST /api/drives/:driveId/domains
    API->>AuthLayer: authenticateRequestWithOptions (CSRF)
    AuthLayer-->>API: principal or 401/403
    API->>customDomainsDB: insert { driveId, hostname }
    customDomainsDB-->>API: row or unique constraint error
    API->>auditRequest: add-custom-domain event
    API-->>handleAddDomain: 201 / 409 / 500
    handleAddDomain->>SWR: mutateDomains()
    SWR-->>CustomDomainsCard: refreshed domain list
  end
  rect rgba(205, 92, 92, 0.5)
    Note over User,SWR: Remove Domain
    User->>DomainRow: clicks Delete
    DomainRow->>handleRemoveDomain: domainId
    handleRemoveDomain->>API: DELETE /api/drives/:driveId/domains/:domainId
    API->>customDomainsDB: delete where id + driveId
    customDomainsDB-->>API: deleted row or 404
    API->>auditRequest: remove-custom-domain event
    API-->>handleRemoveDomain: 200 { deleted: true } or error
    handleRemoveDomain->>SWR: mutateDomains()
    SWR-->>CustomDomainsCard: refreshed domain list
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • 2witstudios/PageSpace#1088: Both PRs extend packages/lib/package.json's subpath exports map — this PR adds ./validators/custom-domain, while the referenced PR broadened the same subpath export framework.

Poem

🐇 A domain walks in, twitching its nose,
The rabbit checks hostnames — valid? Who knows!
It normalizes schemes and strips off the port,
Then writes DNS records of every sort.
With A and CNAME records neatly aligned,
This burrow's now mapped to your custom-signed sign! 🌐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding custom domain model, settings UI, and DNS instructions functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/customdomain-pr1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: be7ea2c78c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (isAddingDomain) return;
setIsAddingDomain(true);
try {
await fetchWithAuth(`/api/drives/${driveId}/domains`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle non-OK add-domain responses

When the POST returns 409/400/403, fetchWithAuth only resolves to a Response rather than throwing (unlike the post/fetchJSON helpers used elsewhere), so this path still clears the input, revalidates, and shows the success toast. In the duplicate-hostname case from the new API, users are told the domain was added even though the server rejected it; check response.ok or use the JSON helper before showing success.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in the latest commit. handleAddDomain now checks response.ok before clearing the input or showing the success toast. On failure it reads the JSON error body and surfaces: "That domain is already registered" for 409, the server error message for other 4xx, and a generic fallback for network errors.

status: text('status', { enum: ['pending', 'verified', 'failed'] }).default('pending').notNull(),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
}, (table) => ({
hostnameKey: uniqueIndex('custom_domains_hostname_key').on(table.hostname),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid globally reserving unverified hostnames

Because every inserted row starts as pending and this index is global, any drive admin can add someone-elses-domain.com without proving DNS control and the unique constraint will block the real owner from adding it to their drive; the only custom_domains writes in this change are add/delete, so there is no verifier or cleanup path to release those claims. Make the uniqueness conditional on verified ownership or require a verification token before reserving the hostname.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This is a known limitation acknowledged by design for PR1. The unique constraint on hostname uses a first-claim model — standard for domain verification flows (e.g., how GitHub Pages and Vercel work). A squatted hostname can be trivially reclaimed once DNS verification lands in PR2: we can simply add a policy to release pending claims older than a TTL (e.g. 24h) or require the user to prove control via a TXT record before the unique constraint is enforced. Changing the uniqueness scheme in PR1 would require DNS-verification logic to be correct and safe, which is the explicit scope of PR2. Leaving this as-is per the epic plan.

}

// Extract the leftmost label as the CNAME name (e.g. "www" from "www.acme.com").
const name = hostname.split('.')[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve all subdomain labels in DNS instructions

For accepted deep subdomains like docs.blog.acme.com, taking only hostname.split('.')[0] tells a user managing the acme.com zone to create docs CNAME ..., which points docs.acme.com rather than the requested hostname. The CNAME name needs to include the full relative host portion (or display the FQDN) so DNS setup works for multi-label subdomains.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed. Changed from hostname.split(".")[0] to labels.slice(0, -2).join(".") so docs.blog.acme.com correctly produces name=docs.blog rather than just docs. Added a test case for this: preserves all subdomain labels for deep subdomains.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/app/api/drives/`[driveId]/domains/route.ts:
- Around line 63-66: The `await request.json()` call can throw an error when the
JSON is malformed, and this error is not caught, resulting in a 500 response
instead of the appropriate 400. Wrap the `await request.json()` call in a
try-catch block to handle JSON parsing errors before Zod validation with
`addDomainSchema.safeParse()` runs. When a JSON parsing error is caught, return
a NextResponse.json with a 400 status code and an appropriate error message.
Apply this same fix to all similar locations in the file where request bodies
are parsed (also at lines 93-99).

In `@apps/web/src/app/dashboard/`[driveId]/settings/general/page.tsx:
- Around line 401-407: The Input component for the domain field and the
icon-only delete buttons lack proper accessibility labels for screen readers.
Add an aria-label attribute to the Input element that describes its purpose
(e.g., "Enter custom domain"), and add aria-label attributes to all icon-only
delete action buttons (the ones referenced at lines 469-476) to describe their
function (e.g., "Remove domain"). This ensures screen reader users can
understand the purpose of these interactive elements.

In `@packages/db/src/schema/custom-domains.ts`:
- Line 10: The status field in the custom-domains schema currently uses text
with an enum option for TypeScript type safety, but does not enforce the
constraint at the database layer. To fix this, use pgEnum from Drizzle ORM to
create a PostgreSQL enum type for the status values ('pending', 'verified',
'failed'), then reference this enum in the status field definition instead of
using text with the enum option. This will ensure that the database itself
enforces the constraint and prevents invalid status values from being inserted
directly at the database level.

In `@packages/lib/src/validators/custom-domain.ts`:
- Around line 147-151: The current implementation of extracting the CNAME name
from hostname only takes the leftmost label using hostname.split('.')[0], which
loses information for deep subdomains. Instead of taking only the first element,
modify the logic to extract all labels except the last one (the root domain),
then rejoin them with dots to preserve the full subdomain path. This ensures
that for a hostname like docs.blog.acme.io, the name becomes docs.blog rather
than just docs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3616765c-882b-41ce-9150-d47e24060a01

📥 Commits

Reviewing files that changed from the base of the PR and between f17c701 and be7ea2c.

📒 Files selected for processing (14)
  • apps/web/src/app/api/drives/[driveId]/domains/[domainId]/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/domains/[domainId]/route.ts
  • apps/web/src/app/api/drives/[driveId]/domains/__tests__/route.test.ts
  • apps/web/src/app/api/drives/[driveId]/domains/route.ts
  • apps/web/src/app/dashboard/[driveId]/settings/general/page.tsx
  • packages/db/drizzle/0168_smart_eddie_brock.sql
  • packages/db/drizzle/meta/0168_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/package.json
  • packages/db/src/schema.ts
  • packages/db/src/schema/custom-domains.ts
  • packages/lib/package.json
  • packages/lib/src/validators/__tests__/custom-domain.test.ts
  • packages/lib/src/validators/custom-domain.ts

Comment thread apps/web/src/app/api/drives/[driveId]/domains/route.ts Outdated
Comment thread apps/web/src/app/dashboard/[driveId]/settings/general/page.tsx
Comment thread packages/db/src/schema/custom-domains.ts Outdated
Comment thread packages/lib/src/validators/custom-domain.ts Outdated
2witstudios and others added 2 commits June 23, 2026 19:51
- Fix CNAME name for deep subdomains: use labels.slice(0,-2).join('.')
  instead of split('.')[0] so docs.blog.acme.com → name='docs.blog'
- Add test case for deep-subdomain CNAME preservation
- Use pgEnum('custom_domain_status') for DB-level constraint enforcement;
  regenerate migration 0168 with CREATE TYPE ... AS ENUM(...)
- Wrap request.json() in try-catch for 400 on malformed JSON bodies
- Fix handleAddDomain: check response.ok and surface server error messages
  (409 → 'already registered', other 4xx → server error text, network → fallback)
- Add accessibility: sr-only Label[for=new-custom-domain] on hostname input,
  aria-label='Remove domain {hostname}' on icon-only delete button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCj8wakYJbhBcyH9cn93Sa
- Use pg error code '23505' for unique constraint detection (matches
  existing pattern in reactions/calendar routes; more reliable than
  string match)
- Remove unreachable z.ZodError catch (safeParse never throws)
- Simplify LABEL_PATTERN: second alternate was redundant — first arm
  already matches single-char labels via optional group
- Remove unused driveId prop from CustomDomainsCard interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCj8wakYJbhBcyH9cn93Sa
@2witstudios
2witstudios merged commit b354ab9 into master Jun 24, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/customdomain-pr1 branch June 24, 2026 02:41
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