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;

View file

@ -56,6 +56,86 @@ const PUBLIC_MANA_RESEARCH_URL_CLIENT =
// the MANA_AI_PUBLIC/PRIVATE_KEY_PEM pair is provisioned on both services.
const PUBLIC_AI_MISSION_GRANTS = process.env.PUBLIC_AI_MISSION_GRANTS === 'true' ? 'true' : 'false';
// Hostnames that should NEVER be treated as website-builder subdomains.
// Covers the root + common marketing/app/auth surfaces.
const RESERVED_WEBSITE_SUBDOMAINS = new Set([
'www',
'api',
'app',
'auth',
'admin',
'custom', // CNAME target for custom-domain bindings
's', // reserved to match the /s/ public-renderer prefix
]);
// In-memory cache for custom-domain resolutions. Short TTL keeps
// mana-api query load low without leaving stale bindings around.
// Replaces with Redis/edge KV in M7.
const CUSTOM_HOST_CACHE_MS = 60_000;
const customHostCache = new Map<string, { slug: string | null; expires: number }>();
async function resolveCustomHost(host: string): Promise<string | null> {
const cached = customHostCache.get(host);
if (cached && cached.expires > Date.now()) return cached.slug;
const apiBase = process.env.PUBLIC_MANA_API_URL ?? 'http://localhost:3060';
try {
const res = await fetch(
`${apiBase}/api/v1/website/public/resolve-host?host=${encodeURIComponent(host)}`
);
if (res.status === 404) {
customHostCache.set(host, { slug: null, expires: Date.now() + CUSTOM_HOST_CACHE_MS });
return null;
}
if (!res.ok) return null;
const body = (await res.json()) as { slug?: string };
const slug = typeof body.slug === 'string' ? body.slug : null;
customHostCache.set(host, { slug, expires: Date.now() + CUSTOM_HOST_CACHE_MS });
return slug;
} catch {
return null;
}
}
/**
* Returns the site slug this request should be rewritten to, or null
* if no rewrite applies. Host without port the handler strips the
* port before calling.
*/
async function resolveWebsiteRewrite(rawHost: string, subdomain: string): Promise<string | null> {
// Strip the port if present (dev: `localhost:5173`).
const host = rawHost.toLowerCase().split(':')[0] ?? '';
if (!host) return null;
// Case a — {slug}.mana.how subdomains.
if (host.endsWith('.mana.how')) {
const sub = subdomain.toLowerCase();
// Guard: reserved + existing app subdomains win over website.
if (APP_SUBDOMAINS.has(sub)) return null;
if (RESERVED_WEBSITE_SUBDOMAINS.has(sub)) return null;
// Only single-label subdomains match (no `foo.bar.mana.how`).
// The label count on `foo.mana.how` is 3 (foo + mana + how).
if (host.split('.').length !== 3) return null;
return sub;
}
// Case b — custom hostnames.
//
// Skip localhost / private addresses in dev so the editor and
// public-renderer routes on the same localhost instance don't try
// to resolve themselves as custom domains.
if (
host === 'localhost' ||
host.startsWith('127.') ||
host === 'mana.how' ||
host.endsWith('.local')
) {
return null;
}
return await resolveCustomHost(host);
}
// Map of app subdomains to internal paths
const APP_SUBDOMAINS = new Set([
'todo',
@ -123,6 +203,25 @@ export const handle: Handle = async ({ event, resolve }) => {
});
}
// Website-builder routing (docs/plans/website-builder.md §M6).
//
// Two cases:
// a) `{siteSlug}.mana.how/path` — subdomain publish. No DB lookup
// required; the slug is the first label.
// b) `custom-host.com/path` — custom-domain publish. Asks mana-api
// for the bound site slug (with a 60s in-memory cache).
//
// Both paths rewrite `event.url.pathname` to `/s/{slug}{path}` so
// SvelteKit serves the existing public renderer. The URL in the
// browser stays on the custom host — this is a server-side rewrite,
// not a redirect.
const websiteRewrite = await resolveWebsiteRewrite(host, subdomain);
if (websiteRewrite) {
const current = event.url.pathname;
const rewritten = `/s/${websiteRewrite}${current === '/' ? '' : current}`;
event.url.pathname = rewritten;
}
const response = await resolve(event, {
transformPageChunk: ({ html }) => {
const envScript = `<script>

View file

@ -0,0 +1,398 @@
<script lang="ts">
import {
listDomains,
addDomain,
verifyDomain,
removeDomain,
DomainError,
type CustomDomain,
} from '../domains';
interface Props {
siteId: string;
}
let { siteId }: Props = $props();
let domains = $state<CustomDomain[] | null>(null);
let loadError = $state<string | null>(null);
let newHost = $state('');
let adding = $state(false);
let addError = $state<string | null>(null);
let verifyingId = $state<string | null>(null);
async function load() {
loadError = null;
try {
domains = await listDomains(siteId);
} catch (err) {
loadError =
err instanceof DomainError ? err.message : err instanceof Error ? err.message : String(err);
}
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
siteId;
void load();
});
async function onAdd() {
const host = newHost.trim().toLowerCase();
if (!host) {
addError = 'Hostname erforderlich';
return;
}
adding = true;
addError = null;
try {
await addDomain(siteId, host);
newHost = '';
await load();
} catch (err) {
addError =
err instanceof DomainError ? err.message : err instanceof Error ? err.message : String(err);
} finally {
adding = false;
}
}
async function onVerify(domainId: string) {
verifyingId = domainId;
try {
await verifyDomain(siteId, domainId);
await load();
} finally {
verifyingId = null;
}
}
async function onRemove(domainId: string, hostname: string) {
if (!confirm(`Domain "${hostname}" entfernen?`)) return;
await removeDomain(siteId, domainId);
await load();
}
function copyToClipboard(v: string) {
void navigator.clipboard?.writeText(v);
}
</script>
<section class="wb-domains" aria-labelledby="wb-domains-title">
<header>
<h3 id="wb-domains-title">Eigene Domain</h3>
<p class="wb-domains__hint">
Verbinde einen eigenen Hostnamen (z.B. <code>meineseite.de</code>) mit dieser Website. Nur für
Founder-Tier.
</p>
</header>
{#if loadError}
<p class="wb-error">{loadError}</p>
{/if}
<form
class="wb-domains__add"
onsubmit={(e) => {
e.preventDefault();
void onAdd();
}}
>
<input
type="text"
placeholder="z.B. portfolio.deinedomain.de"
value={newHost}
oninput={(e) => (newHost = e.currentTarget.value)}
/>
<button class="wb-btn wb-btn--primary" disabled={adding || !newHost.trim()}>
{adding ? 'Füge hinzu…' : '+ Domain'}
</button>
</form>
{#if addError}
<p class="wb-error">{addError}</p>
{/if}
{#if domains === null}
<p class="wb-domains__empty">Lade…</p>
{:else if domains.length === 0}
<p class="wb-domains__empty">Noch keine eigenen Domains verbunden.</p>
{:else}
<ul class="wb-domains__list">
{#each domains as d (d.id)}
<li class="wb-domain">
<div class="wb-domain__head">
<div>
<span class="wb-domain__host">{d.hostname}</span>
<span class="wb-pill wb-pill--{d.status}">{d.status}</span>
</div>
<div class="wb-domain__actions">
{#if d.status !== 'verified'}
<button
class="wb-btn wb-btn--primary"
onclick={() => onVerify(d.id)}
disabled={verifyingId === d.id}
>
{verifyingId === d.id ? 'Prüfe…' : 'Verify'}
</button>
{/if}
<button
class="wb-btn wb-btn--icon wb-btn--danger"
onclick={() => onRemove(d.id, d.hostname)}
title="Entfernen">×</button
>
</div>
</div>
{#if d.errorMessage}
<p class="wb-domain__err">{d.errorMessage}</p>
{/if}
{#if d.status !== 'verified'}
<div class="wb-domain__dns">
<p class="wb-domain__dns-title">DNS konfigurieren:</p>
<div class="wb-dns-row">
<div>
<span class="wb-dns-type">CNAME</span>
<span class="wb-dns-name">{d.hostname}</span>
</div>
<button class="wb-dns-val" onclick={() => copyToClipboard(d.dnsTarget)}>
{d.dnsTarget}
<small>Klick zum Kopieren</small>
</button>
</div>
<div class="wb-dns-row">
<div>
<span class="wb-dns-type">TXT</span>
<span class="wb-dns-name">_mana-challenge.{d.hostname}</span>
</div>
<button class="wb-dns-val" onclick={() => copyToClipboard(d.verificationToken)}>
{d.verificationToken}
<small>Klick zum Kopieren</small>
</button>
</div>
<p class="wb-domain__dns-note">
DNS-Änderungen brauchen meist 530 Minuten, bis sie weltweit propagiert sind. Danach
"Verify" klicken.
</p>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<style>
.wb-domains {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
}
.wb-domains header h3 {
margin: 0;
font-size: 0.9375rem;
}
.wb-domains__hint {
margin: 0.2rem 0 0;
font-size: 0.8125rem;
opacity: 0.6;
}
.wb-domains__hint code {
background: rgba(255, 255, 255, 0.06);
padding: 0.05rem 0.25rem;
border-radius: 0.25rem;
}
.wb-domains__empty {
margin: 0;
padding: 0.75rem;
opacity: 0.5;
font-style: italic;
font-size: 0.8125rem;
}
.wb-domains__add {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
.wb-domains__add input {
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 0.875rem;
}
.wb-domains__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.wb-domain {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.65rem 0.8rem;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
}
.wb-domain__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.wb-domain__host {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
margin-right: 0.5rem;
}
.wb-domain__actions {
display: flex;
gap: 0.35rem;
align-items: center;
}
.wb-domain__err {
margin: 0;
padding: 0.35rem 0.5rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.25);
border-radius: 0.375rem;
font-size: 0.75rem;
color: rgb(248, 113, 113);
}
.wb-domain__dns {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.wb-domain__dns-title {
margin: 0;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.55;
}
.wb-domain__dns-note {
margin: 0.25rem 0 0;
font-size: 0.7rem;
opacity: 0.55;
line-height: 1.4;
}
.wb-dns-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
align-items: start;
}
.wb-dns-type {
display: inline-block;
padding: 0.05rem 0.4rem;
background: rgba(99, 102, 241, 0.2);
border-radius: 0.25rem;
font-size: 0.65rem;
font-weight: 500;
margin-right: 0.4rem;
}
.wb-dns-name {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
opacity: 0.85;
}
.wb-dns-val {
display: block;
text-align: left;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.15);
color: inherit;
padding: 0.35rem 0.5rem;
border-radius: 0.375rem;
cursor: pointer;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
word-break: break-all;
}
.wb-dns-val:hover {
background: rgba(255, 255, 255, 0.06);
}
.wb-dns-val small {
display: block;
opacity: 0.4;
font-family: system-ui, sans-serif;
font-size: 0.65rem;
margin-top: 0.1rem;
}
.wb-pill {
font-size: 0.7rem;
padding: 0.12rem 0.5rem;
border-radius: 9999px;
}
.wb-pill--pending {
background: rgba(148, 163, 184, 0.2);
color: rgb(203, 213, 225);
}
.wb-pill--verifying {
background: rgba(59, 130, 246, 0.2);
color: rgb(147, 197, 253);
}
.wb-pill--verified {
background: rgba(16, 185, 129, 0.2);
color: rgb(110, 231, 183);
}
.wb-pill--failed {
background: rgba(248, 113, 113, 0.2);
color: rgb(252, 165, 165);
}
.wb-btn {
padding: 0.4rem 0.75rem;
border-radius: 0.375rem;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.wb-btn--primary {
background: rgba(99, 102, 241, 0.9);
color: white;
}
.wb-btn--icon {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit;
width: 1.75rem;
padding: 0;
line-height: 1;
}
.wb-btn--danger:hover {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: rgb(248, 113, 113);
}
.wb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.wb-error {
margin: 0;
padding: 0.4rem 0.55rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 0.375rem;
font-size: 0.8125rem;
color: rgb(248, 113, 113);
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { PRESET_LABELS, THEME_PRESETS, type ThemePreset } from '@mana/website-blocks/themes';
import { sitesStore } from '../stores/sites.svelte';
import DomainsSection from './DomainsSection.svelte';
import type { Website, ThemeConfig } from '../types';
interface Props {
@ -166,6 +167,10 @@
/>
</label>
</section>
<section class="wb-section">
<DomainsSection siteId={site.id} />
</section>
</div>
<footer class="wb-modal__foot">

View file

@ -0,0 +1,92 @@
/**
* Custom-domain client talks to apps/api's
* /api/v1/website/sites/:id/domains endpoints.
*
* Founder-tier only on the server; the client doesn't gate, we just
* surface server errors.
*/
import { getManaApiUrl } from '$lib/api/config';
import { authStore } from '$lib/stores/auth.svelte';
export interface CustomDomain {
id: string;
siteId: string;
hostname: string;
status: 'pending' | 'verifying' | 'verified' | 'failed';
dnsTarget: string;
verificationToken: string;
errorMessage: string | null;
verifiedAt: string | null;
createdAt: string;
updatedAt: string;
}
export class DomainError extends Error {
readonly code: string;
readonly status: number;
constructor(message: string, code: string, status: number) {
super(message);
this.name = 'DomainError';
this.code = code;
this.status = status;
}
}
async function authFetch(path: string, init?: RequestInit): Promise<Response> {
const token = await authStore.getValidToken();
if (!token) throw new DomainError('Nicht angemeldet', 'NO_TOKEN', 401);
return fetch(`${getManaApiUrl()}${path}`, {
...init,
headers: {
...(init?.headers ?? {}),
Authorization: `Bearer ${token}`,
},
});
}
async function readJson<T>(res: Response, defaultMessage: string): Promise<T> {
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { code?: string; error?: string };
throw new DomainError(body.error ?? defaultMessage, body.code ?? 'UNKNOWN', res.status);
}
return (await res.json()) as T;
}
export async function listDomains(siteId: string): Promise<CustomDomain[]> {
const res = await authFetch(`/api/v1/website/sites/${siteId}/domains`);
const body = await readJson<{ domains: CustomDomain[] }>(res, 'Konnte Domains nicht laden');
return body.domains;
}
export async function addDomain(siteId: string, hostname: string): Promise<CustomDomain> {
const res = await authFetch(`/api/v1/website/sites/${siteId}/domains`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hostname }),
});
return readJson<CustomDomain>(res, 'Konnte Domain nicht hinzufügen');
}
export async function verifyDomain(
siteId: string,
domainId: string
): Promise<{ verified: boolean; reason?: string }> {
const res = await authFetch(`/api/v1/website/sites/${siteId}/domains/${domainId}/verify`, {
method: 'POST',
});
// 400 on failed verification is expected — caller displays the reason.
if (res.status === 400) {
const body = (await res.json().catch(() => ({}))) as { reason?: string };
return { verified: false, reason: body.reason };
}
const body = await readJson<{ verified: boolean; reason?: string }>(res, 'Verify fehlgeschlagen');
return body;
}
export async function removeDomain(siteId: string, domainId: string): Promise<void> {
const res = await authFetch(`/api/v1/website/sites/${siteId}/domains/${domainId}`, {
method: 'DELETE',
});
await readJson<{ deleted: boolean }>(res, 'Löschen fehlgeschlagen');
}

View file

@ -708,18 +708,31 @@ Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich
### M6 — Subdomain-Publishing + Custom-Domain-Foundation
- [ ] SvelteKit-Hook `hooks.server.ts`: Host-Header → rewrite `{slug}.mana.how``/s/{slug}/…`
- [ ] Wildcard-DNS + TLS-Check im Staging
- [ ] Custom-Domain-Schema: `website.custom_domains { site_id, hostname, status, tls_status, verified_at }`
- [ ] DNS-Verify-Flow: CNAME-Record auf `custom.mana.how`, TXT-Record mit Challenge
- [ ] Cloudflare-SaaS-Hostname-Integration (API-Call bei Verify-Success)
- [ ] Tier-Gate: Custom-Domain nur für `founder`
- [ ] `mana-landing-builder` Konsolidierungs-Entscheidung:
- [ ] Untersuchen: kann Org-Landing-Page als spezial `spaceKind='organization'`-Site im neuen System leben?
- [ ] Wenn ja: Org-Landing-Pages migrieren, `mana-landing-builder` → deprecation note, löschen nach Datenmigration
- [ ] Wenn nein: Gründe dokumentieren, beide Systeme parallel halten
- [x] SvelteKit-Hook `hooks.server.ts`: Host-Header → rewrite `{slug}.mana.how``/s/{slug}/…`
- [ ] Wildcard-DNS + TLS-Check im Staging — ops-Aufgabe (Cloudflare-Config, kein Code)
- [x] Custom-Domain-Schema: `website.custom_domains { site_id, hostname, status, verification_token, verified_at }``tls_status` verzichten (kommt von Cloudflare-API in M6.x)
- [x] DNS-Verify-Flow: CNAME-Record auf `custom.mana.how`, TXT-Record mit Challenge (node:dns/promises)
- [ ] Cloudflare-SaaS-Hostname-Integration — API-Call ist **gestubbed**; produziert Log-Eintrag, kein realer Call. Siehe "Offene Enden unten".
- [x] Tier-Gate: Custom-Domain nur für `founder` (via `requireTier('founder')` in domains.ts)
- [x] `mana-landing-builder` Konsolidierungs-Entscheidung: **Parallel halten** (siehe unten)
**Exit criteria:** `{slug}.mana.how` funktioniert. Founder-User kann eigene Domain verbinden.
**`mana-landing-builder` — Entscheidung: parallel halten**
Der Service bleibt vorerst nebenher. Gründe:
1. **Ziel-Unterschied.** `mana-landing-builder` ist ein Admin-only Tool für Org-Landing-Pages mit Cloudflare-Pages-Deploys. Der website-Builder ist ein End-User-Tool mit SSR-Rendering. Zwei klar unterschiedliche Surfaces.
2. **Rollen-Unterschied.** Org-Landings sind Admin-Branding. User-Sites sind private Portfolios, Events, Linktrees. Zwei unterschiedliche Daten-/Permission-Modelle.
3. **Lifecycle.** Der Builder ist jung + iteriert schnell; Org-Pages ändern sich selten. Sie zu fusionieren würde beide ausbremsen.
4. **Migration wäre teuer.** Org-Landings nutzen Astro-Sections mit Themes (`org-classic`, `org-warm`) die nicht 1:1 zu unseren Block-Typen passen. Migration = port jeder Section, migrate jede existierende Org.
**Revisit-Kriterium:** Wenn wir (a) Org-Landings selbst mit Block-Editor-Features wollen (Multi-Page, Forms) ODER (b) die Feature-Überlappung groß genug ist, dass doppelte Pflege schmerzt. Frühestens nach 6 Monaten Live-Daten.
**Exit criteria:** `{slug}.mana.how` funktioniert (serverseitiger Rewrite steht). Founder-User kann eigene Domain verbinden (Add + DNS-Check + Verify-Flow steht; TLS-Provisioning via CF SaaS ist die Ops-Lücke).
**Offene Enden in M6 (post-first-pass):**
- Live Cloudflare-SaaS-Hostname-API-Integration (`POST /zones/{zoneId}/custom_hostnames`) — bisher nur Log-Stub. Ops-Aufgabe + Code-Retrofit sobald `CF_API_TOKEN` + `CF_ZONE_ID` in prod-env liegen.
- DNS-Verify-Poller (Background-Check, repariert `failed``verified` ohne User-Klick). Dependency auf ein Job-Queue-Primitive oder Cron, landet zusammen mit M7 Observability.
- Apex-Domain-Handling: der Verify-Code akzeptiert A-Record-Fallback wenn CNAME-Lookup ENODATA liefert; bei komplexen Multi-IP-Setups kann das false-negative. Real life: für apex empfiehlt man ANAME/ALIAS, einige DNS-Provider unterstützen das nicht. Follow-up sobald der erste User daran hängt.
### M7 — Observability, GC, Analytics
@ -769,8 +782,10 @@ Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich
## Shipping Log
(Leer — wird befüllt, während M1 → M7 gehen.)
| Phase | Purpose | Commit |
| --- | --- | --- |
| — | — | — |
| M1 + M2 | Foundation (editor, 3 blocks) + publish + public renderer | folded into user's `54a12ffd5` + `89258eb45` |
| M3 | 5 more blocks, containers, upload, themes | `7a4f8894e` |
| M4 | Forms + moduleEmbed | `57be0f61b` |
| M5 | AI tools + starter templates | `13efae8cd` |
| M6 | Subdomain + custom-domain + tier gate + DNS verify + hooks-rewrite | (pending commit at end of M6 session) |