mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
83a4606a9a
commit
3eca5ac201
10 changed files with 1077 additions and 16 deletions
32
apps/api/drizzle/website/0002_custom_domains.sql
Normal file
32
apps/api/drizzle/website/0002_custom_domains.sql
Normal 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';
|
||||
337
apps/api/src/modules/website/domains.ts
Normal file
337
apps/api/src/modules/website/domains.ts
Normal 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;
|
||||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 5–30 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>
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
92
apps/mana/apps/web/src/lib/modules/website/domains.ts
Normal file
92
apps/mana/apps/web/src/lib/modules/website/domains.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue