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

@ -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');
}