/** * The Cloudflare-for-SaaS custom-hostname provisioner (#305, control-plane.md §4.7). * * Binding a custom domain to a surface is not a status flip — it is DNS validation and * certificate issuance, which take time and can fail (routing.ts `hostnameStatus`). This * module is the seam that actually asks Cloudflare: `create` registers a custom hostname * and returns the DNS records the tenant must publish; `check` polls its validation + * cert state. Both map Cloudflare's many internal states onto the four the platform * exposes (`pending`/`verifying`/`active`/`failed`). * * It is pure web-standard `fetch` + JSON — no Cloudflare SDK, no node built-ins — so it * runs unchanged in a Worker (the control plane holds the token as a secret) or in node * (tests, a dev server). It is INJECTED into `createControlPlaneApi` exactly like * `createWfpUploader`, so this package holds no Cloudflare credential and the builder * never holds one (D-34). Absent ⇒ the bind route records the row `pending` and issuance * simply does not run (a self-host / dev environment with no CF-for-SaaS zone). */ import type { DnsRecord, HostnameBinding, HostnameStatus, PlatformActorId, ScopeId, ScopeStatus } from '@substrat-run/contracts'; /** * The subset of `fetch` this module uses — DOM-typed, injectable for tests. Deliberately * NOT the kernel's `FetchLike`: a real `fetch` is not assignable to that under strict * TypeScript (see `globalFetch`), and a caller injecting a DOM-typed fetch here must not * need a cast. The bound global below IS this type, with no conversion. */ export type FetchFn = (input: string, init?: RequestInit) => Promise; /** The normalized result of an issuance step — what the control plane persists. */ export interface CustomHostnameIssuance { /** Cloudflare's custom-hostname id (`ch_…` / a uuid). The handle a later poll needs. */ customHostnameId: string; /** Mapped onto the platform's four states; never `pending` (create/poll has run). */ status: Extract; /** A human reason when `failed` (Cloudflare's verification errors), else null. */ note: string | null; /** The DNS records the tenant must publish (routing CNAME + any DCV TXT). */ records: DnsRecord[]; } export interface CustomHostnameProvisioner { /** Register a custom hostname for issuance. Idempotent on Cloudflare's side per name. */ create(hostname: string): Promise; /** Poll a registered custom hostname's validation + certificate state. */ check(customHostnameId: string): Promise; /** Delete the Cloudflare custom hostname — the inverse of `create`, for an unbind. */ remove(customHostnameId: string): Promise; } export interface CustomHostnameProvisionerOptions { /** The Cloudflare zone (the tenant-apps zone, e.g. the `substrat.run` zone) id. */ zoneId: string; /** A Cloudflare API token with SSL/custom-hostname write on the zone. Platform-held. */ apiToken: string; /** * The CNAME value a tenant points their custom domain at — the SaaS fallback ingress * (e.g. `edge.substrat.run`). Surfaced verbatim as the routing record so the tenant * knows where to point DNS. */ routingTarget: string; /** * DCV method. `txt` (default) validates via a TXT record and works before the domain * resolves; `http` validates by serving a token over the routing CNAME, so it needs * the CNAME live first. TXT is the safer default for a self-serve flow. */ sslMethod?: 'txt' | 'http'; /** Injectable fetch (defaults to the runtime `fetch`) — tests pass a stub. */ fetch?: FetchFn; } /** Cloudflare's custom-hostname result shape (only the fields we read). */ interface CfCustomHostname { id: string; hostname: string; status?: string; verification_errors?: string[]; ownership_verification?: { type?: string; name?: string; value?: string; }; ssl?: { status?: string; validation_errors?: { message?: string; }[]; validation_records?: { txt_name?: string; txt_value?: string; status?: string; }[]; }; } /** * Is `hostname` a custom domain (one the platform issues a cert for), or a platform * mint that rides the wildcard? A platform mint is a hostname AT or UNDER one of the * platform's base domains (`substrat.run`, `global.substrat.run`, …). Everything else * is custom and walks the Cloudflare-for-SaaS issuance lifecycle. */ export declare function isCustomHostname(hostname: string, platformBaseDomains: string[]): boolean; /** * The registrable-suffix guard at bind time (#305, D-35): a custom hostname must be a * real registrable domain or a name under one — never a bare public suffix (`com`, * `co.uk`, `pages.dev`) that a tenant cannot own and whose cookie would span every * tenant beneath it. Returns an error message to reject with, or null when bindable. * Platform mints skip this (they are the platform's own registrable domain by * construction). Uses the vendored PSL, so `co.uk` is caught where a label-count check * would wave it through. */ export declare function validateBindableHostname(hostname: string): string | null; /** * Map Cloudflare's hostname + SSL states onto the platform's four. The rule of thumb: * anything mid-flight (validation/issuance/deployment/initializing) is `verifying`; a * terminal bad state (blocked/moved/timed-out/expired, or explicit errors) is `failed`; * both hostname and cert `active` is `active`. Unknown strings default to `verifying` * rather than `failed` — a state we do not recognise is more likely new-than-broken, and * a stuck `verifying` is visible in the UI without falsely declaring a domain dead. */ export declare function mapCfStatus(ch: CfCustomHostname): { status: CustomHostnameIssuance['status']; note: string | null; }; /** Extract the DNS records the tenant must publish from a CF custom-hostname result. */ export declare function extractRecords(ch: CfCustomHostname, routingTarget: string): DnsRecord[]; export declare function createCustomHostnameProvisioner(opts: CustomHostnameProvisionerOptions): CustomHostnameProvisioner; /** The narrow admin surface the reconcile pass needs — a slice of `HostAdmin`. */ export interface HostnameReconcileAdmin { /** Unfiltered = the whole map. Called adapter-side, where an omitted limit means everything. */ listHostnames(actor: PlatformActorId, filter?: { status?: HostnameStatus; }): Promise; setHostnameIssuance(actor: PlatformActorId, hostname: string, fields: { status: HostnameStatus; note?: string | null; customHostnameId?: string | null; validationRecords: DnsRecord[]; }): Promise; /** The dead-scope set for the orphan pass — only `id` is read off each row. */ listScopes(actor: PlatformActorId, filter?: { status?: ScopeStatus | ScopeStatus[]; }): Promise>; unbindHostname(actor: PlatformActorId, hostname: string): Promise; } export interface ReconcileHostnamesResult { /** Rows polled this pass (status `verifying` with a CF id). */ polled: number; /** Rows that reached `active` this pass. */ activated: number; /** Rows that moved to `failed` this pass. */ failed: number; /** Custom rows (`pending`, or `failed` with no CF id) for which a CF create was (re)attempted. */ created: number; /** Platform mints found off-`active` (issuance relics, #423) and flipped back. */ healed: number; /** Rows unbound because their scope is archived/reaped — a hostname must not outlive its app. */ orphaned: number; /** Per-hostname errors — one bad row never sinks the pass. */ errors: { hostname: string; error: string; }[]; } /** * One reconcile pass over the hostname map (#305, §4.7). It opens with an ORPHAN pass: * any row — whatever its status — whose scope is archived/reaped is unbound (CF object * released first, same sequence as the manual DELETE /hostnames route). Deleting an app * archives its scope, and without this pass its hostnames lingered on the Domains page * forever — worse, the heal below would flip them back to `active`. * * The live rows then split two ways: * * - `verifying` with a CF id — poll Cloudflare and persist the new status/records; a * `verifying → active` here is the automated flip that replaces the old manual one. * - `pending` OR `failed` with NO CF id but a custom hostname — a create that never * landed (the bind route failed transiently, ran before a provisioner was configured, * or hit a misconfigured credential and was recorded `failed`). Retry the create so * the row self-heals once the cause is fixed, instead of waiting on a human click. * * `isCustom` splits the rows: custom domains walk the issuance machinery above, while a * platform hostname (rides the wildcard, no CF object) found in ANY in-flight state is * healed straight to `active` (#423 — see the heal pass). Per-row failures are caught * and reported, never thrown, so the sweep that calls this stays alive (scheduler.md). */ export declare function reconcilePendingHostnames(deps: { admin: HostnameReconcileAdmin; actor: PlatformActorId; provisioner: CustomHostnameProvisioner; /** True for a hostname the platform issues certs for (a custom domain), false for a platform mint. */ isCustom: (hostname: string) => boolean; }): Promise; export {}; //# sourceMappingURL=custom-hostnames.d.ts.map