From 3eca5ac2011f169c9b0e4e7ccde8a0156ded5a76 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 23 Apr 2026 15:29:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(website):=20M6=20=E2=80=94=20subdomain=20p?= =?UTF-8?q?ublish=20+=20custom-domain=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../drizzle/website/0002_custom_domains.sql | 32 ++ apps/api/src/modules/website/domains.ts | 337 +++++++++++++++ apps/api/src/modules/website/public-routes.ts | 42 +- apps/api/src/modules/website/routes.ts | 4 + apps/api/src/modules/website/schema.ts | 41 +- apps/mana/apps/web/src/hooks.server.ts | 99 +++++ .../website/components/DomainsSection.svelte | 398 ++++++++++++++++++ .../components/SiteSettingsDialog.svelte | 5 + .../web/src/lib/modules/website/domains.ts | 92 ++++ docs/plans/website-builder.md | 43 +- 10 files changed, 1077 insertions(+), 16 deletions(-) create mode 100644 apps/api/drizzle/website/0002_custom_domains.sql create mode 100644 apps/api/src/modules/website/domains.ts create mode 100644 apps/mana/apps/web/src/lib/modules/website/components/DomainsSection.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/website/domains.ts diff --git a/apps/api/drizzle/website/0002_custom_domains.sql b/apps/api/drizzle/website/0002_custom_domains.sql new file mode 100644 index 000000000..76c4fe703 --- /dev/null +++ b/apps/api/drizzle/website/0002_custom_domains.sql @@ -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'; diff --git a/apps/api/src/modules/website/domains.ts b/apps/api/src/modules/website/domains.ts new file mode 100644 index 000000000..543dd37e0 --- /dev/null +++ b/apps/api/src/modules/website/domains.ts @@ -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 { + 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 { + 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; diff --git a/apps/api/src/modules/website/public-routes.ts b/apps/api/src/modules/website/public-routes.ts index fed6983cb..6c9468407 100644 --- a/apps/api/src/modules/website/public-routes.ts +++ b/apps/api/src/modules/website/public-routes.ts @@ -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 * diff --git a/apps/api/src/modules/website/routes.ts b/apps/api/src/modules/website/routes.ts index f22940334..a4fb2eb6b 100644 --- a/apps/api/src/modules/website/routes.ts +++ b/apps/api/src/modules/website/routes.ts @@ -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; diff --git a/apps/api/src/modules/website/schema.ts b/apps/api/src/modules/website/schema.ts index b1e64d7d8..e23bb1983 100644 --- a/apps/api/src/modules/website/schema.ts +++ b/apps/api/src/modules/website/schema.ts @@ -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; diff --git a/apps/mana/apps/web/src/hooks.server.ts b/apps/mana/apps/web/src/hooks.server.ts index be3a295ed..45d3f0f4e 100644 --- a/apps/mana/apps/web/src/hooks.server.ts +++ b/apps/mana/apps/web/src/hooks.server.ts @@ -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(); + +async function resolveCustomHost(host: string): Promise { + 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 { + // 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 = ` + +
+
+

Eigene Domain

+

+ Verbinde einen eigenen Hostnamen (z.B. meineseite.de) mit dieser Website. Nur für + Founder-Tier. +

+
+ + {#if loadError} +

{loadError}

+ {/if} + +
{ + e.preventDefault(); + void onAdd(); + }} + > + (newHost = e.currentTarget.value)} + /> + +
+ + {#if addError} +

{addError}

+ {/if} + + {#if domains === null} +

Lade…

+ {:else if domains.length === 0} +

Noch keine eigenen Domains verbunden.

+ {:else} +
    + {#each domains as d (d.id)} +
  • +
    +
    + {d.hostname} + {d.status} +
    +
    + {#if d.status !== 'verified'} + + {/if} + +
    +
    + + {#if d.errorMessage} +

    {d.errorMessage}

    + {/if} + + {#if d.status !== 'verified'} +
    +

    DNS konfigurieren:

    +
    +
    + CNAME + {d.hostname} +
    + +
    +
    +
    + TXT + _mana-challenge.{d.hostname} +
    + +
    +

    + DNS-Änderungen brauchen meist 5–30 Minuten, bis sie weltweit propagiert sind. Danach + "Verify" klicken. +

    +
    + {/if} +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte b/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte index 13f53dd26..40ccb7174 100644 --- a/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte +++ b/apps/mana/apps/web/src/lib/modules/website/components/SiteSettingsDialog.svelte @@ -1,6 +1,7 @@