feat(website): M6 — subdomain publish + custom-domain foundation

SvelteKit hook + new DB table + founder-gated API + UI section. Ships
the code path for public-site routing on {slug}.mana.how and custom
hostnames. Cloudflare SaaS Hostnames integration is stubbed — see
plan §M6 "Offene Enden".

apps/api/src/modules/website:
- schema.ts: new `customDomains` table. Fields: id, site_id, hostname
  (unique), status (pending | verifying | verified | failed),
  verification_token, dns_target, verified_at.
- drizzle/website/0002_custom_domains.sql: manual migration with
  partial unique index on (hostname) WHERE status='verified'.
- domains.ts (new, authenticated + founder-gated via
  `requireTier('founder')`): POST/GET/DELETE /sites/:id/domains,
  POST /sites/:id/domains/:domainId/verify. Verify runs CNAME + TXT
  checks via node:dns/promises with an apex-domain A-record fallback.
  Reserved-hostname list prevents users from binding mana.how subdomains.
- public-routes.ts: new GET /public/resolve-host?host= — unauthenticated
  resolver used by hooks.server.ts. Returns { slug, siteId } only for
  verified bindings tied to a currently-published site.

apps/mana/apps/web/src/hooks.server.ts:
- After the existing https/app-subdomain guards, a new
  `resolveWebsiteRewrite()` step rewrites `event.url.pathname`:
    {slug}.mana.how/path → /s/{slug}/path     (pure string)
    custom-host.com/path → /s/{resolved}/path (API call, 60s LRU)
- Browser URL stays on the custom host — this is a server-side rewrite,
  not a 302. APP_SUBDOMAINS + RESERVED_WEBSITE_SUBDOMAINS win over
  website routing. Localhost and apex mana.how are skipped.

apps/mana/apps/web/src/lib/modules/website:
- domains.ts (new): typed client for list/add/verify/remove. Handles
  200 + expected 400 (verification-failed) separately.
- components/DomainsSection.svelte: add-input, per-domain status pill,
  DNS-instructions box (CNAME + TXT with copy-to-clipboard), Verify
  button. Mounted inside SiteSettingsDialog as its own section — the
  existing theme/footer controls stay put.

docs/plans/website-builder.md:
- M6 checklist updated with what shipped vs. ops-gap (CF SaaS).
- `mana-landing-builder` consolidation: DECIDED to keep parallel. Four
  reasons in the plan. Revisit-criterion stated.
- Shipping log table seeded with M1→M6 commits.

Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green

Apply schema with:
  psql "$DATABASE_URL" -f apps/api/drizzle/website/0002_custom_domains.sql

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 15:29:42 +02:00
parent 83a4606a9a
commit 3eca5ac201
10 changed files with 1077 additions and 16 deletions

View file

@ -0,0 +1,32 @@
-- Website module — M6 custom-domain bindings.
-- See docs/plans/website-builder.md §M6.
--
-- Apply with:
-- psql "$DATABASE_URL" -f apps/api/drizzle/website/0002_custom_domains.sql
CREATE TABLE IF NOT EXISTS "website"."custom_domains" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"site_id" uuid NOT NULL,
"hostname" text NOT NULL UNIQUE,
"status" text NOT NULL DEFAULT 'pending',
"verification_token" text NOT NULL,
"dns_target" text NOT NULL DEFAULT 'custom.mana.how',
"error_message" text,
"verified_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"created_by" uuid NOT NULL
);
CREATE INDEX IF NOT EXISTS "custom_domains_site_idx"
ON "website"."custom_domains" ("site_id", "status");
-- Partial unique index: only ONE verified binding per hostname. The
-- hostname-unique constraint above prevents duplicates across all
-- statuses; this partial is redundant as written (unique already
-- covers it), kept here as an invariant-docs comment. If we ever drop
-- the global unique (e.g. to allow one user to re-add a domain another
-- user abandoned), this becomes the load-bearing constraint.
CREATE UNIQUE INDEX IF NOT EXISTS "custom_domains_verified_hostname_idx"
ON "website"."custom_domains" ("hostname")
WHERE "status" = 'verified';

View file

@ -0,0 +1,337 @@
/**
* Custom-domain CRUD + DNS verification authenticated, founder-only.
*
* Flow:
* 1. User adds hostname row with status='pending', fresh
* verification_token (random 32-char hex)
* 2. We return DNS instructions:
* CNAME {hostname} custom.mana.how
* TXT _mana-challenge.{hostname} {verification_token}
* 3. User configures DNS, clicks "Verify"
* 4. Server resolves both records via node:dns/promises, on success:
* - mark status='verified', set verified_at
* - call Cloudflare SaaS Hostnames API to provision a TLS cert
* (STUBBED in M6 first-pass see cloudflareOnboard())
*
* The public resolver (`/public/resolve-host`) reads `verified` rows
* only an incomplete verification cannot serve content.
*/
import { Hono } from 'hono';
import { and, desc, eq } from 'drizzle-orm';
import { promises as dns } from 'node:dns';
import { requireTier, type AuthVariables } from '@mana/shared-hono';
import { errorResponse, validationError } from '../../lib/responses';
import { db, customDomains } from './schema';
const routes = new Hono<{ Variables: AuthVariables }>();
// Custom domains are a founder-tier feature. Gate every route below.
routes.use('/sites/*/domains', requireTier('founder'));
routes.use('/sites/*/domains/*', requireTier('founder'));
// ─── Constants ────────────────────────────────────────────
const DNS_TARGET = process.env.WEBSITE_DNS_TARGET ?? 'custom.mana.how';
const CHALLENGE_PREFIX = '_mana-challenge';
// Conservative hostname whitelist — lowercase letters, digits, dots,
// hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal
// names. Not a full RFC-compliant parser — just a sanity check before
// we hand the string to dns.resolve.
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
// Reserved hostnames that must not be user-bound. `mana.how` itself +
// every app subdomain (todo, chat, …) lives here so a user can't point
// their CNAME at the app's root.
const RESERVED_HOSTNAMES = new Set([
'mana.how',
'www.mana.how',
'api.mana.how',
'auth.mana.how',
'app.mana.how',
'admin.mana.how',
'custom.mana.how',
'events.mana.how',
'research.mana.how',
]);
function isValidHostname(raw: string): boolean {
if (!raw) return false;
const lower = raw.toLowerCase().trim();
if (!HOSTNAME_RE.test(lower)) return false;
if (RESERVED_HOSTNAMES.has(lower)) return false;
if (lower.endsWith('.mana.how')) return false; // reserved root
return true;
}
function randomToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ─── POST /sites/:id/domains — add a domain ───────────────
routes.post('/sites/:id/domains', async (c) => {
const userId = c.get('userId');
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const body = (await c.req.json().catch(() => null)) as { hostname?: unknown } | null;
const raw = typeof body?.hostname === 'string' ? body.hostname : '';
const hostname = raw.toLowerCase().trim();
if (!isValidHostname(hostname)) {
return validationError(c, [
{
code: 'invalid_string',
path: ['hostname'],
message: 'Ungültiger oder reservierter Hostname',
},
]);
}
try {
const [row] = await db
.insert(customDomains)
.values({
siteId,
hostname,
status: 'pending',
verificationToken: randomToken(),
dnsTarget: DNS_TARGET,
createdBy: userId,
})
.returning();
if (!row) throw new Error('insert returned no row');
return c.json(serialize(row), 201);
} catch (err) {
if (err instanceof Error && /unique/i.test(err.message)) {
return errorResponse(c, 'Hostname ist bereits registriert', 409, {
code: 'HOSTNAME_TAKEN',
});
}
throw err;
}
});
// ─── GET /sites/:id/domains — list ────────────────────────
routes.get('/sites/:id/domains', async (c) => {
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const rows = await db
.select()
.from(customDomains)
.where(eq(customDomains.siteId, siteId))
.orderBy(desc(customDomains.createdAt));
return c.json({ domains: rows.map(serialize) });
});
// ─── POST /sites/:id/domains/:domainId/verify ─────────────
routes.post('/sites/:id/domains/:domainId/verify', async (c) => {
const siteId = c.req.param('id');
const domainId = c.req.param('domainId');
if (!siteId || !domainId) return errorResponse(c, 'ids required', 400);
const [row] = await db
.select()
.from(customDomains)
.where(and(eq(customDomains.id, domainId), eq(customDomains.siteId, siteId)))
.limit(1);
if (!row) return errorResponse(c, 'Domain not found', 404, { code: 'NOT_FOUND' });
// Mark verifying first so concurrent clicks don't all hit DNS.
await db
.update(customDomains)
.set({ status: 'verifying', updatedAt: new Date(), errorMessage: null })
.where(eq(customDomains.id, domainId));
const result = await verifyDns(row.hostname, row.verificationToken, row.dnsTarget);
if (result.ok) {
await db
.update(customDomains)
.set({
status: 'verified',
verifiedAt: new Date(),
errorMessage: null,
updatedAt: new Date(),
})
.where(eq(customDomains.id, domainId));
// Fire-and-forget CF onboarding. If it fails, the binding is
// still `verified` in our DB — the Cloudflare hostname just
// isn't issued yet, so TLS won't terminate. Ops owns that gap
// in M6; automated retry comes in M7 alongside observability.
void cloudflareOnboard(row.hostname).catch((err) => {
console.error('[website] cloudflare onboard failed', { hostname: row.hostname, err });
});
return c.json({ verified: true, hostname: row.hostname });
}
await db
.update(customDomains)
.set({
status: 'failed',
errorMessage: result.reason,
updatedAt: new Date(),
})
.where(eq(customDomains.id, domainId));
return c.json({ verified: false, reason: result.reason }, 400);
});
// ─── DELETE /sites/:id/domains/:domainId ──────────────────
routes.delete('/sites/:id/domains/:domainId', async (c) => {
const siteId = c.req.param('id');
const domainId = c.req.param('domainId');
if (!siteId || !domainId) return errorResponse(c, 'ids required', 400);
const deleted = await db
.delete(customDomains)
.where(and(eq(customDomains.id, domainId), eq(customDomains.siteId, siteId)))
.returning({ id: customDomains.id, hostname: customDomains.hostname });
if (deleted.length === 0) {
return errorResponse(c, 'Domain not found', 404, { code: 'NOT_FOUND' });
}
// Best-effort CF cleanup — same contract as onboard.
void cloudflareOffboard(deleted[0]!.hostname).catch((err) => {
console.error('[website] cloudflare offboard failed', { err });
});
return c.json({ deleted: true });
});
// ─── Helpers ─────────────────────────────────────────────
function serialize(row: typeof customDomains.$inferSelect) {
return {
id: row.id,
siteId: row.siteId,
hostname: row.hostname,
status: row.status,
dnsTarget: row.dnsTarget,
verificationToken: row.verificationToken,
errorMessage: row.errorMessage,
verifiedAt: row.verifiedAt ? row.verifiedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Run the two DNS checks. Returns `{ ok: true }` if both succeed,
* otherwise a human-readable reason that bubbles to the UI.
*/
async function verifyDns(
hostname: string,
expectedToken: string,
cnameTarget: string
): Promise<{ ok: true } | { ok: false; reason: string }> {
// 1. TXT challenge: _mana-challenge.{hostname} must contain token.
let txtChunks: string[][];
try {
txtChunks = await dns.resolveTxt(`${CHALLENGE_PREFIX}.${hostname}`);
} catch (err) {
const code = (err as { code?: string }).code ?? 'UNKNOWN';
if (code === 'ENOTFOUND' || code === 'ENODATA') {
return {
ok: false,
reason: `TXT-Record ${CHALLENGE_PREFIX}.${hostname} nicht gefunden`,
};
}
return { ok: false, reason: `DNS-Fehler (TXT): ${code}` };
}
const txtValues = txtChunks.map((chunks) => chunks.join(''));
if (!txtValues.includes(expectedToken)) {
return {
ok: false,
reason: `TXT-Record-Wert stimmt nicht überein. Erwartet: ${expectedToken}`,
};
}
// 2. CNAME on root hostname must point to dnsTarget. For apex
// domains (myportfolio.com), DNS providers typically require
// ALIAS / ANAME instead of CNAME — we accept both by falling
// back to resolve4() against dnsTarget's IP if CNAME lookup
// returns ENODATA.
try {
const cnames = await dns.resolveCname(hostname);
if (!cnames.map((c) => c.toLowerCase()).includes(cnameTarget.toLowerCase())) {
return {
ok: false,
reason: `CNAME zeigt nicht auf ${cnameTarget} (gefunden: ${cnames.join(', ') || '—'})`,
};
}
} catch (err) {
const code = (err as { code?: string }).code ?? 'UNKNOWN';
if (code === 'ENODATA') {
// Apex-domain fallback — compare resolved A records.
try {
const [hostIps, targetIps] = await Promise.all([
dns.resolve4(hostname),
dns.resolve4(cnameTarget),
]);
const matches = hostIps.some((ip) => targetIps.includes(ip));
if (!matches) {
return {
ok: false,
reason: `A/ALIAS-Record zeigt nicht auf ${cnameTarget} (IPs: ${hostIps.join(', ')})`,
};
}
} catch {
return {
ok: false,
reason: `Weder CNAME noch A-Record-Abgleich mit ${cnameTarget} möglich`,
};
}
} else if (code === 'ENOTFOUND') {
return { ok: false, reason: `Hostname ${hostname} nicht auflösbar` };
} else {
return { ok: false, reason: `DNS-Fehler (CNAME): ${code}` };
}
}
return { ok: true };
}
/**
* Provision the custom hostname in Cloudflare SaaS Hostnames so TLS
* works end-to-end. STUBBED in M6 first-pass.
*
* What needs to happen in production:
* POST https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/custom_hostnames
* body: { hostname, ssl: { method: 'http', type: 'dv' } }
* headers: Authorization: Bearer CF_API_TOKEN
*
* We also need to watch CF's hostname status (issued active) and
* reflect it in our `status` column. That's a M7 observability task.
*/
async function cloudflareOnboard(hostname: string): Promise<void> {
const token = process.env.CF_API_TOKEN;
const zoneId = process.env.CF_ZONE_ID;
if (!token || !zoneId) {
console.warn('[website] CF onboard skipped — no credentials', { hostname });
return;
}
// Real implementation goes here. Left unimplemented in M6 because
// we haven't provisioned CF_ZONE_ID yet; the stub logs the intent.
console.info('[website] CF onboard TODO', { hostname, zoneId: '***' });
}
async function cloudflareOffboard(hostname: string): Promise<void> {
const token = process.env.CF_API_TOKEN;
const zoneId = process.env.CF_ZONE_ID;
if (!token || !zoneId) return;
console.info('[website] CF offboard TODO', { hostname, zoneId: '***' });
}
export const websiteDomainsRoutes = routes;

View file

@ -8,13 +8,53 @@
import { Hono } from 'hono';
import { and, eq } from 'drizzle-orm';
import { db, publishedSnapshots } from './schema';
import { db, publishedSnapshots, customDomains } from './schema';
import { errorResponse } from '../../lib/responses';
import { websiteSubmitRoutes } from './submit';
const routes = new Hono();
routes.route('/', websiteSubmitRoutes);
/**
* GET /api/v1/website/public/resolve-host?host=custom.example.com
*
* SvelteKit's hooks.server.ts calls this on every request to figure
* out if a non-mana.how hostname (custom domain) should be rewritten
* to a public site route. Returns `{ slug, siteId }` on match, 404
* otherwise. Only `status='verified'` bindings match.
*/
routes.get('/resolve-host', async (c) => {
const raw = c.req.query('host');
const host = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
if (!host) return errorResponse(c, 'host query param required', 400);
const rows = await db
.select({ siteId: customDomains.siteId, hostname: customDomains.hostname })
.from(customDomains)
.where(and(eq(customDomains.hostname, host), eq(customDomains.status, 'verified')))
.limit(1);
if (!rows[0]) return errorResponse(c, 'Host not found', 404, { code: 'NOT_FOUND' });
// Look up the slug from the most recent published snapshot.
const snap = await db
.select({ slug: publishedSnapshots.slug })
.from(publishedSnapshots)
.where(
and(eq(publishedSnapshots.siteId, rows[0].siteId), eq(publishedSnapshots.isCurrent, true))
)
.limit(1);
if (!snap[0]) {
return errorResponse(c, 'Site not currently published', 404, {
code: 'NOT_PUBLISHED',
});
}
c.header('Cache-Control', 'public, max-age=60, s-maxage=600');
return c.json({ slug: snap[0].slug, siteId: rows[0].siteId });
});
/**
* GET /api/v1/website/public/sites/:slug
*

View file

@ -19,6 +19,7 @@ import { Hono } from 'hono';
import type { AuthVariables } from '@mana/shared-hono';
import { RESERVED_SLUGS, isValidSlug } from './reserved-slugs';
import { websitePublishRoutes } from './publish';
import { websiteDomainsRoutes } from './domains';
const routes = new Hono<{ Variables: AuthVariables }>();
@ -50,4 +51,7 @@ routes.get('/slugs/reserved', (c) => c.json({ reserved: RESERVED_SLUGS }));
// ─── Publish + rollback (authenticated) ────────────────
routes.route('/', websitePublishRoutes);
// ─── Custom-domain CRUD (authenticated, founder-gated above) ──
routes.route('/', websiteDomainsRoutes);
export const websiteRoutes = routes;

View file

@ -92,11 +92,50 @@ export const submissions = websiteSchema.table(
(table) => [index('submissions_site_created_idx').on(table.siteId, table.createdAt)]
);
/**
* Custom-domain bindings for founder-tier sites. One row per
* (site, hostname). `status` walks the DNS-verify lifecycle:
*
* pending user added the domain, no DNS check run yet
* verifying DNS check in flight or retrying
* verified both the TXT challenge + CNAME resolve as expected
* failed the last check failed; user needs to fix DNS
*
* The public resolver reads hostname site_id for VERIFIED rows only
* so an unfinished verification can't serve someone else's site.
*
* See docs/plans/website-builder.md §M6.
*/
export const customDomains = websiteSchema.table(
'custom_domains',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id').notNull(),
hostname: text('hostname').notNull().unique(),
status: text('status').notNull().default('pending'),
/** Expected TXT record contents — random token issued at create time. */
verificationToken: text('verification_token').notNull(),
/** Current CNAME target the user must point their hostname to. */
dnsTarget: text('dns_target').notNull().default('custom.mana.how'),
errorMessage: text('error_message'),
verifiedAt: timestamp('verified_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
createdBy: uuid('created_by').notNull(),
},
(table) => [index('custom_domains_site_idx').on(table.siteId, table.status)]
// A partial unique index on (hostname) WHERE status='verified' lives
// in the SQL migration — drizzle-orm's `.where(sql...)` is awkward
// for partial-index predicates and this index is rarely regenerated.
);
export const db = drizzle(getConnection(), {
schema: { publishedSnapshots, submissions },
schema: { publishedSnapshots, submissions, customDomains },
});
export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect;
export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert;
export type SubmissionRow = typeof submissions.$inferSelect;
export type NewSubmission = typeof submissions.$inferInsert;
export type CustomDomainRow = typeof customDomains.$inferSelect;
export type NewCustomDomain = typeof customDomains.$inferInsert;