flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/deployments/src/domains.ts

430 lines18,011 bytesCodeBlame
/**
 * Custom domains: a project's production served at a hostname of its own,
 * such as `example.com` or `www.example.com`, beside its address on
 * g1t.page.
 *
 * Each hostname is a Cloudflare for SaaS custom hostname on the g1t.page
 * zone. Its owner points it at `domains.g1t.page` (a CNAME, or a flattened
 * CNAME at the apex); Cloudflare checks it, issues its certificate, and
 * sends its traffic to the dispatcher, which reads the `DOMAINS` KV
 * namespace to find the app: `{ script, redirect }` under the hostname.
 *
 * A domain maps to the project, not to a build: production redeploys
 * leave it alone, and the KV entry follows the production script if its
 * name ever changes (see `follow`).
 *
 * Until Cloudflare for SaaS is switched on for the zone, domains are kept
 * as pending with a note saying so, and the sweep adds them at Cloudflare
 * the moment it is.
 */

import { CUSTOM_DOMAIN_TARGET, DEPLOYMENTS_ALLOWANCE, newId, type Domain, type DomainRecord, type DomainStatus } from "@g1t/contracts";

import { CustomHostnames, NotEnabled, Refused, recordsOf, statusOf, type CustomHostname } from "./custom-hostnames";
import { checkHostname, isApex, twinOf } from "./hostname";

/** What the UI says while Cloudflare for SaaS is not on for the zone yet. */
export const NOT_ENABLED_NOTICE =
  "Custom domains are being switched on for g1t.page. Domains you add now are kept, and set up by themselves the moment they are.";

/** A project has at most this many hostnames. */
const PER_PROJECT = 20;
/** How long the answer to "is it switched on" is trusted, in this isolate. */
const AVAILABILITY_TTL_MS = 5 * 60 * 1000;
/** An active domain is checked again this often, in case its DNS moved away. */
const RECHECK_ACTIVE_MS = 24 * 60 * 60 * 1000;
const SWEEP_LIMIT = 100;
/** A pending domain is read again from Cloudflare when the page is, at most this often. */
const CATCH_UP_MS = 10 * 1000;

let availability: { at: number; available: boolean } | null = null;

export type DomainRow = {
  id: string;
  project_id: string;
  workspace: string;
  slug: string;
  hostname: string;
  target: string;
  cf_hostname_id: string | null;
  status: DomainStatus;
  ssl_status: string | null;
  records: string;
  redirect_to: string | null;
  script: string | null;
  error: string | null;
  created_by: string;
  created_at: string;
  verified_at: string | null;
  checked_at: string | null;
};

const now = () => new Date().toISOString();

/** The record that sends a hostname's traffic to g1t. */
export function routingRecord(hostname: string): DomainRecord {
  return isApex(hostname)
    ? { type: "ALIAS", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app (a flattened CNAME, or ALIAS)" }
    : { type: "CNAME", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app" };
}

export function toDomain(row: DomainRow): Domain {
  return {
    id: row.id,
    hostname: row.hostname,
    target: "production",
    status: row.status,
    sslStatus: row.ssl_status,
    apex: isApex(row.hostname),
    records: [routingRecord(row.hostname), ...(JSON.parse(row.records || "[]") as DomainRecord[])],
    redirectTo: row.redirect_to,
    error: row.error,
    createdBy: row.created_by,
    createdAt: row.created_at,
    verifiedAt: row.verified_at,
  };
}

/** What the dispatcher reads under a hostname. */
export type DomainEntry = { script: string; redirect: string | null; project: string };

export class Domains {
  constructor(
    private readonly db: D1Database,
    private readonly kv: KVNamespace | undefined,
    private readonly hostnames: CustomHostnames | null,
  ) {}

  /**
   * The project's domains still on their way to active, read again from
   * Cloudflare if not read in the last few seconds: what keeps the page's
   * live view moving between sweeps.
   */
  async catchUp(projectId: string): Promise<void> {
    const since = new Date(Date.now() - CATCH_UP_MS).toISOString();
    const rows = await this.db
      .prepare(
        `SELECT * FROM domains WHERE project_id = ? AND status IN ('pending', 'verifying')
           AND cf_hostname_id IS NOT NULL AND COALESCE(checked_at, '') < ? LIMIT 5`,
      )
      .bind(projectId, since)
      .all<DomainRow>();
    await Promise.all(rows.results.map((row) => (row.script ? this.refresh(row, row.script) : null)));
  }

  async forProject(projectId: string): Promise<DomainRow[]> {
    const rows = await this.db
      .prepare("SELECT * FROM domains WHERE project_id = ? ORDER BY redirect_to IS NOT NULL, created_at")
      .bind(projectId)
      .all<DomainRow>();
    return rows.results;
  }

  /** The workspace's custom domains that Cloudflare holds, which are what is charged. */
  async countFor(workspace: string): Promise<number> {
    const row = await this.db
      .prepare("SELECT COUNT(*) AS n FROM domains WHERE workspace = ? AND cf_hostname_id IS NOT NULL")
      .bind(workspace)
      .first<{ n: number }>();
    return row?.n ?? 0;
  }

  /** The first active domain that serves the app itself. */
  async primary(projectId: string): Promise<string | null> {
    const row = await this.db
      .prepare(
        `SELECT hostname FROM domains WHERE project_id = ? AND status = 'active' AND redirect_to IS NULL
         ORDER BY created_at LIMIT 1`,
      )
      .bind(projectId)
      .first<{ hostname: string }>();
    return row?.hostname ?? null;
  }

  /** Whether Cloudflare for SaaS is on for the zone, asked at most every few minutes. */
  async available(): Promise<boolean> {
    if (!this.hostnames) return false;
    if (availability && Date.now() - availability.at < AVAILABILITY_TTL_MS) return availability.available;
    let available = true;
    try {
      await this.hostnames.find("probe.invalid");
    } catch (error) {
      if (error instanceof NotEnabled) available = false;
      else console.error("could not ask whether custom hostnames are on", error);
    }
    availability = { at: Date.now(), available };
    return available;
  }

  /**
   * Adds `input` for the project's production, and with `twin` its www or
   * apex twin, redirecting to it. Answers with what was added, or why not.
   */
  async add(input: {
    project: { id: string; workspace: string; slug: string };
    script: string;
    hostname: string;
    twin: boolean;
    by: string;
  }): Promise<{ ok: true; rows: DomainRow[] } | { ok: false; code: "invalid" | "conflict"; message: string }> {
    const checked = checkHostname(input.hostname);
    if (!checked.ok) return { ok: false, code: "invalid", message: checked.reason };
    const hostname = checked.hostname;
    const twin = input.twin ? twinOf(hostname) : null;
    if (input.twin && !twin) {
      return { ok: false, code: "invalid", message: "Only a domain and its www subdomain can be paired. Add other subdomains on their own." };
    }
    const wanted = twin ? [hostname, twin] : [hostname];
    const taken = await this.db
      .prepare(`SELECT hostname, project_id FROM domains WHERE hostname IN (${wanted.map(() => "?").join(", ")})`)
      .bind(...wanted)
      .all<{ hostname: string; project_id: string }>();
    const fresh = wanted.filter((h) => !taken.results.some((t) => t.hostname === h));
    const elsewhere = taken.results.find((t) => t.project_id !== input.project.id);
    if (elsewhere) return { ok: false, code: "conflict", message: `${elsewhere.hostname} is already in use on g1t.` };
    if (!fresh.includes(hostname) && !twin) return { ok: false, code: "conflict", message: `${hostname} is already added to this project.` };
    const count = await this.db
      .prepare("SELECT COUNT(*) AS n FROM domains WHERE project_id = ?")
      .bind(input.project.id)
      .first<{ n: number }>();
    if ((count?.n ?? 0) + fresh.length > PER_PROJECT) {
      return { ok: false, code: "conflict", message: `A project can have up to ${PER_PROJECT} domains.` };
    }

    const at = now();
    const added: DomainRow[] = [];
    for (const name of fresh) {
      const row: DomainRow = {
        id: newId("dom"),
        project_id: input.project.id,
        workspace: input.project.workspace,
        slug: input.project.slug,
        hostname: name,
        target: "production",
        cf_hostname_id: null,
        status: "pending",
        ssl_status: null,
        records: "[]",
        redirect_to: name === hostname ? null : hostname,
        script: null,
        error: null,
        created_by: input.by,
        created_at: at,
        verified_at: null,
        checked_at: null,
      };
      try {
        await this.db
          .prepare(
            `INSERT INTO domains (id, project_id, workspace, slug, hostname, target, status, records, redirect_to, created_by, created_at)
             VALUES (?, ?, ?, ?, ?, 'production', 'pending', '[]', ?, ?, ?)`,
          )
          .bind(row.id, row.project_id, row.workspace, row.slug, row.hostname, row.redirect_to, row.created_by, row.created_at)
          .run();
      } catch (error) {
        if (/UNIQUE/i.test(String(error))) return { ok: false, code: "conflict", message: `${name} is already in use on g1t.` };
        throw error;
      }
      await this.point(row, input.script);
      added.push(await this.register(row, input.script));
    }
    // Pairing with a domain the project already had: it now redirects too.
    if (twin && !fresh.includes(twin)) {
      await this.db
        .prepare("UPDATE domains SET redirect_to = ? WHERE hostname = ? AND project_id = ?")
        .bind(hostname, twin, input.project.id)
        .run();
      const row = await this.byHostname(twin);
      if (row) await this.point(row, input.script);
    }
    return { ok: true, rows: added };
  }

  private async byHostname(hostname: string): Promise<DomainRow | null> {
    return this.db.prepare("SELECT * FROM domains WHERE hostname = ?").bind(hostname).first<DomainRow>();
  }

  async byId(projectId: string, id: string): Promise<DomainRow | null> {
    return this.db.prepare("SELECT * FROM domains WHERE id = ? AND project_id = ?").bind(id, projectId).first<DomainRow>();
  }

  /** Writes what the dispatcher reads for the hostname, and remembers the script it names. */
  private async point(row: DomainRow, script: string): Promise<void> {
    const entry: DomainEntry = { script, redirect: row.redirect_to, project: row.project_id };
    await this.kv?.put(row.hostname, JSON.stringify(entry));
    await this.db.prepare("UPDATE domains SET script = ? WHERE id = ?").bind(script, row.id).run();
  }

  /**
   * Adds the hostname at Cloudflare, or finds it there from an add that
   * half-finished, and records what Cloudflare asks for. While Cloudflare
   * for SaaS is off, the row stays pending with a note.
   */
  private async register(row: DomainRow, script: string): Promise<DomainRow> {
    if (!this.hostnames) {
      return this.note(row, "Custom domains are not set up on this g1t: it has no Cloudflare token or zone.");
    }
    let found: CustomHostname;
    try {
      found =
        (await this.hostnames.find(row.hostname)) ??
        (await this.hostnames.create(row.hostname, { script, project: row.project_id, domain: row.id }));
    } catch (error) {
      if (error instanceof NotEnabled) {
        availability = { at: Date.now(), available: false };
        return this.note(row, NOT_ENABLED_NOTICE);
      }
      if (error instanceof Refused && error.status < 500) {
        return this.save(row, { status: "failed", error: error.message.replace(/^Cloudflare answered \d+: /, "Cloudflare refused it: ") });
      }
      console.error("could not add custom hostname", row.hostname, error);
      return this.note(row, "Cloudflare could not be reached; it is tried again in a few minutes.");
    }
    availability = { at: Date.now(), available: true };
    await this.db.prepare("UPDATE domains SET cf_hostname_id = ? WHERE id = ?").bind(found.id, row.id).run();
    return this.apply({ ...row, cf_hostname_id: found.id }, found);
  }

  private async note(row: DomainRow, error: string): Promise<DomainRow> {
    return this.save(row, { status: "pending", error });
  }

  private async save(row: DomainRow, changes: Partial<DomainRow>): Promise<DomainRow> {
    const next = { ...row, ...changes, checked_at: now() };
    await this.db
      .prepare(
        `UPDATE domains SET status = ?, ssl_status = ?, records = ?, error = ?, verified_at = ?, checked_at = ? WHERE id = ?`,
      )
      .bind(next.status, next.ssl_status, next.records, next.error, next.verified_at, next.checked_at, row.id)
      .run();
    return next;
  }

  /** Cloudflare's view of the hostname, onto its row. */
  private async apply(row: DomainRow, found: CustomHostname): Promise<DomainRow> {
    const seen = statusOf(found);
    return this.save(row, {
      status: seen.status,
      ssl_status: seen.ssl,
      records: JSON.stringify(recordsOf(found)),
      error: seen.status === "active" ? null : seen.error,
      verified_at: seen.status === "active" ? (row.verified_at ?? now()) : row.verified_at,
    });
  }

  /**
   * Reads the domain's state from Cloudflare; with `recheck`, first asks
   * Cloudflare to check its DNS and certificate again now.
   */
  async refresh(row: DomainRow, script: string, recheck = false): Promise<DomainRow> {
    if (!row.cf_hostname_id) return this.register(row, script);
    if (!this.hostnames) return row;
    try {
      const found =
        recheck && row.status !== "active"
          ? await this.hostnames.recheck(row.cf_hostname_id)
          : await this.hostnames.get(row.cf_hostname_id);
      return this.apply(row, found);
    } catch (error) {
      if (error instanceof Refused && error.status === 404) {
        // Gone at Cloudflare: add it again.
        await this.db.prepare("UPDATE domains SET cf_hostname_id = NULL WHERE id = ?").bind(row.id).run();
        return this.register({ ...row, cf_hostname_id: null }, script);
      }
      if (error instanceof NotEnabled) return this.note(row, NOT_ENABLED_NOTICE);
      console.error("could not check custom hostname", row.hostname, error);
      return row;
    }
  }

  /**
   * Removes the domain, and any of the project's domains redirecting to it:
   * from the dispatcher at once, then from Cloudflare. A removal Cloudflare
   * does not take now is left `removing`, for the sweep to finish.
   */
  async remove(row: DomainRow): Promise<void> {
    const redirecting = await this.db
      .prepare("SELECT * FROM domains WHERE project_id = ? AND redirect_to = ?")
      .bind(row.project_id, row.hostname)
      .all<DomainRow>();
    for (const target of [row, ...redirecting.results]) await this.drop(target);
  }

  private async drop(row: DomainRow): Promise<void> {
    await this.kv?.delete(row.hostname);
    if (row.cf_hostname_id) {
      try {
        if (!this.hostnames) throw new Error("no Cloudflare token");
        await this.hostnames.delete(row.cf_hostname_id);
      } catch (error) {
        console.error("could not remove custom hostname; the sweep tries again", row.hostname, error);
        await this.db.prepare("UPDATE domains SET status = 'removing' WHERE id = ?").bind(row.id).run();
        return;
      }
    }
    await this.db.prepare("DELETE FROM domains WHERE id = ?").bind(row.id).run();
  }

  /** Every domain of the project's, removed: its deployments were turned off for good, or its plan ended. */
  async removeWhere(column: "project_id" | "workspace", value: string): Promise<void> {
    const rows = await this.db.prepare(`SELECT * FROM domains WHERE ${column} = ?`).bind(value).all<DomainRow>();
    for (const row of rows.results) await this.drop(row);
  }

  /**
   * Production is up under `script`: the project's domains serve it. Only
   * entries naming another script are written, so a redeploy under the
   * same name writes nothing.
   */
  async follow(projectId: string, script: string): Promise<void> {
    const rows = await this.db
      .prepare("SELECT * FROM domains WHERE project_id = ? AND status != 'removing' AND script IS NOT ?")
      .bind(projectId, script)
      .all<DomainRow>();
    for (const row of rows.results) await this.point(row, script);
  }

  /**
   * From the sweep: domains not yet at Cloudflare are added (as soon as
   * custom hostnames are switched on), pending ones are checked, active
   * ones now and then, and removals that failed are tried again.
   */
  async sweep(productionScript: (projectId: string) => Promise<string | null>): Promise<void> {
    const recheck = new Date(Date.now() - RECHECK_ACTIVE_MS).toISOString();
    const rows = await this.db
      .prepare(
        `SELECT * FROM domains
         WHERE status IN ('pending', 'verifying', 'removing')
            OR (status = 'active' AND COALESCE(checked_at, '') < ?)
         ORDER BY checked_at IS NOT NULL, checked_at LIMIT ?`,
      )
      .bind(recheck, SWEEP_LIMIT)
      .all<DomainRow>();
    let enabled = true;
    for (const row of rows.results) {
      try {
        if (row.status === "removing") {
          await this.drop(row);
          continue;
        }
        if (!row.cf_hostname_id && !enabled) continue;
        const script = row.script ?? (await productionScript(row.project_id));
        if (!script) continue;
        const after = await this.refresh(row, script);
        if (!after.cf_hostname_id && after.error === NOT_ENABLED_NOTICE) enabled = false;
      } catch (error) {
        console.error("could not sweep domain", row.hostname, error);
      }
    }
  }

  /** The workspace's slug changed; the rows follow. Their KV entries name scripts, which `follow` moves. */
  rename(from: string, to: string): D1PreparedStatement {
    return this.db.prepare("UPDATE domains SET workspace = ?1 WHERE workspace = ?2").bind(to, from);
  }
}

/** Custom domains past the plan's, for a month's charge. */
export function extraDomains(peak: number): number {
  return Math.max(0, peak - DEPLOYMENTS_ALLOWANCE.customDomains);
}