mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 06:06:41 +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
|
|
@ -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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue