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