flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/deployments/src/domains.ts

430 lines18,011 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1/**
2 * Custom domains: a project's production served at a hostname of its own,
3 * such as `example.com` or `www.example.com`, beside its address on
4 * g1t.page.
5 *
6 * Each hostname is a Cloudflare for SaaS custom hostname on the g1t.page
7 * zone. Its owner points it at `domains.g1t.page` (a CNAME, or a flattened
8 * CNAME at the apex); Cloudflare checks it, issues its certificate, and
9 * sends its traffic to the dispatcher, which reads the `DOMAINS` KV
10 * namespace to find the app: `{ script, redirect }` under the hostname.
11 *
12 * A domain maps to the project, not to a build: production redeploys
13 * leave it alone, and the KV entry follows the production script if its
14 * name ever changes (see `follow`).
15 *
16 * Until Cloudflare for SaaS is switched on for the zone, domains are kept
17 * as pending with a note saying so, and the sweep adds them at Cloudflare
18 * the moment it is.
19 */
20
21import { CUSTOM_DOMAIN_TARGET, DEPLOYMENTS_ALLOWANCE, newId, type Domain, type DomainRecord, type DomainStatus } from "@g1t/contracts";
22
23import { CustomHostnames, NotEnabled, Refused, recordsOf, statusOf, type CustomHostname } from "./custom-hostnames";
24import { checkHostname, isApex, twinOf } from "./hostname";
25
26/** What the UI says while Cloudflare for SaaS is not on for the zone yet. */
27export const NOT_ENABLED_NOTICE =
28 "Custom domains are being switched on for g1t.page. Domains you add now are kept, and set up by themselves the moment they are.";
29
30/** A project has at most this many hostnames. */
31const PER_PROJECT = 20;
32/** How long the answer to "is it switched on" is trusted, in this isolate. */
33const AVAILABILITY_TTL_MS = 5 * 60 * 1000;
34/** An active domain is checked again this often, in case its DNS moved away. */
35const RECHECK_ACTIVE_MS = 24 * 60 * 60 * 1000;
36const SWEEP_LIMIT = 100;
37/** A pending domain is read again from Cloudflare when the page is, at most this often. */
38const CATCH_UP_MS = 10 * 1000;
39
40let availability: { at: number; available: boolean } | null = null;
41
42export type DomainRow = {
43 id: string;
44 project_id: string;
45 workspace: string;
46 slug: string;
47 hostname: string;
48 target: string;
49 cf_hostname_id: string | null;
50 status: DomainStatus;
51 ssl_status: string | null;
52 records: string;
53 redirect_to: string | null;
54 script: string | null;
55 error: string | null;
56 created_by: string;
57 created_at: string;
58 verified_at: string | null;
59 checked_at: string | null;
60};
61
62const now = () => new Date().toISOString();
63
64/** The record that sends a hostname's traffic to g1t. */
65export function routingRecord(hostname: string): DomainRecord {
66 return isApex(hostname)
67 ? { type: "ALIAS", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app (a flattened CNAME, or ALIAS)" }
68 : { type: "CNAME", name: hostname, value: CUSTOM_DOMAIN_TARGET, purpose: "Sends traffic to your app" };
69}
70
71export function toDomain(row: DomainRow): Domain {
72 return {
73 id: row.id,
74 hostname: row.hostname,
75 target: "production",
76 status: row.status,
77 sslStatus: row.ssl_status,
78 apex: isApex(row.hostname),
79 records: [routingRecord(row.hostname), ...(JSON.parse(row.records || "[]") as DomainRecord[])],
80 redirectTo: row.redirect_to,
81 error: row.error,
82 createdBy: row.created_by,
83 createdAt: row.created_at,
84 verifiedAt: row.verified_at,
85 };
86}
87
88/** What the dispatcher reads under a hostname. */
89export type DomainEntry = { script: string; redirect: string | null; project: string };
90
91export class Domains {
92 constructor(
93 private readonly db: D1Database,
94 private readonly kv: KVNamespace | undefined,
95 private readonly hostnames: CustomHostnames | null,
96 ) {}
97
98 /**
99 * The project's domains still on their way to active, read again from
100 * Cloudflare if not read in the last few seconds: what keeps the page's
101 * live view moving between sweeps.
102 */
103 async catchUp(projectId: string): Promise<void> {
104 const since = new Date(Date.now() - CATCH_UP_MS).toISOString();
105 const rows = await this.db
106 .prepare(
107 `SELECT * FROM domains WHERE project_id = ? AND status IN ('pending', 'verifying')
108 AND cf_hostname_id IS NOT NULL AND COALESCE(checked_at, '') < ? LIMIT 5`,
109 )
110 .bind(projectId, since)
111 .all<DomainRow>();
112 await Promise.all(rows.results.map((row) => (row.script ? this.refresh(row, row.script) : null)));
113 }
114
115 async forProject(projectId: string): Promise<DomainRow[]> {
116 const rows = await this.db
117 .prepare("SELECT * FROM domains WHERE project_id = ? ORDER BY redirect_to IS NOT NULL, created_at")
118 .bind(projectId)
119 .all<DomainRow>();
120 return rows.results;
121 }
122
123 /** The workspace's custom domains that Cloudflare holds, which are what is charged. */
124 async countFor(workspace: string): Promise<number> {
125 const row = await this.db
126 .prepare("SELECT COUNT(*) AS n FROM domains WHERE workspace = ? AND cf_hostname_id IS NOT NULL")
127 .bind(workspace)
128 .first<{ n: number }>();
129 return row?.n ?? 0;
130 }
131
132 /** The first active domain that serves the app itself. */
133 async primary(projectId: string): Promise<string | null> {
134 const row = await this.db
135 .prepare(
136 `SELECT hostname FROM domains WHERE project_id = ? AND status = 'active' AND redirect_to IS NULL
137 ORDER BY created_at LIMIT 1`,
138 )
139 .bind(projectId)
140 .first<{ hostname: string }>();
141 return row?.hostname ?? null;
142 }
143
144 /** Whether Cloudflare for SaaS is on for the zone, asked at most every few minutes. */
145 async available(): Promise<boolean> {
146 if (!this.hostnames) return false;
147 if (availability && Date.now() - availability.at < AVAILABILITY_TTL_MS) return availability.available;
148 let available = true;
149 try {
150 await this.hostnames.find("probe.invalid");
151 } catch (error) {
152 if (error instanceof NotEnabled) available = false;
153 else console.error("could not ask whether custom hostnames are on", error);
154 }
155 availability = { at: Date.now(), available };
156 return available;
157 }
158
159 /**
160 * Adds `input` for the project's production, and with `twin` its www or
161 * apex twin, redirecting to it. Answers with what was added, or why not.
162 */
163 async add(input: {
164 project: { id: string; workspace: string; slug: string };
165 script: string;
166 hostname: string;
167 twin: boolean;
168 by: string;
169 }): Promise<{ ok: true; rows: DomainRow[] } | { ok: false; code: "invalid" | "conflict"; message: string }> {
170 const checked = checkHostname(input.hostname);
171 if (!checked.ok) return { ok: false, code: "invalid", message: checked.reason };
172 const hostname = checked.hostname;
173 const twin = input.twin ? twinOf(hostname) : null;
174 if (input.twin && !twin) {
175 return { ok: false, code: "invalid", message: "Only a domain and its www subdomain can be paired. Add other subdomains on their own." };
176 }
177 const wanted = twin ? [hostname, twin] : [hostname];
178 const taken = await this.db
179 .prepare(`SELECT hostname, project_id FROM domains WHERE hostname IN (${wanted.map(() => "?").join(", ")})`)
180 .bind(...wanted)
181 .all<{ hostname: string; project_id: string }>();
182 const fresh = wanted.filter((h) => !taken.results.some((t) => t.hostname === h));
183 const elsewhere = taken.results.find((t) => t.project_id !== input.project.id);
184 if (elsewhere) return { ok: false, code: "conflict", message: `${elsewhere.hostname} is already in use on g1t.` };
185 if (!fresh.includes(hostname) && !twin) return { ok: false, code: "conflict", message: `${hostname} is already added to this project.` };
186 const count = await this.db
187 .prepare("SELECT COUNT(*) AS n FROM domains WHERE project_id = ?")
188 .bind(input.project.id)
189 .first<{ n: number }>();
190 if ((count?.n ?? 0) + fresh.length > PER_PROJECT) {
191 return { ok: false, code: "conflict", message: `A project can have up to ${PER_PROJECT} domains.` };
192 }
193
194 const at = now();
195 const added: DomainRow[] = [];
196 for (const name of fresh) {
197 const row: DomainRow = {
198 id: newId("dom"),
199 project_id: input.project.id,
200 workspace: input.project.workspace,
201 slug: input.project.slug,
202 hostname: name,
203 target: "production",
204 cf_hostname_id: null,
205 status: "pending",
206 ssl_status: null,
207 records: "[]",
208 redirect_to: name === hostname ? null : hostname,
209 script: null,
210 error: null,
211 created_by: input.by,
212 created_at: at,
213 verified_at: null,
214 checked_at: null,
215 };
216 try {
217 await this.db
218 .prepare(
219 `INSERT INTO domains (id, project_id, workspace, slug, hostname, target, status, records, redirect_to, created_by, created_at)
220 VALUES (?, ?, ?, ?, ?, 'production', 'pending', '[]', ?, ?, ?)`,
221 )
222 .bind(row.id, row.project_id, row.workspace, row.slug, row.hostname, row.redirect_to, row.created_by, row.created_at)
223 .run();
224 } catch (error) {
225 if (/UNIQUE/i.test(String(error))) return { ok: false, code: "conflict", message: `${name} is already in use on g1t.` };
226 throw error;
227 }
228 await this.point(row, input.script);
229 added.push(await this.register(row, input.script));
230 }
231 // Pairing with a domain the project already had: it now redirects too.
232 if (twin && !fresh.includes(twin)) {
233 await this.db
234 .prepare("UPDATE domains SET redirect_to = ? WHERE hostname = ? AND project_id = ?")
235 .bind(hostname, twin, input.project.id)
236 .run();
237 const row = await this.byHostname(twin);
238 if (row) await this.point(row, input.script);
239 }
240 return { ok: true, rows: added };
241 }
242
243 private async byHostname(hostname: string): Promise<DomainRow | null> {
244 return this.db.prepare("SELECT * FROM domains WHERE hostname = ?").bind(hostname).first<DomainRow>();
245 }
246
247 async byId(projectId: string, id: string): Promise<DomainRow | null> {
248 return this.db.prepare("SELECT * FROM domains WHERE id = ? AND project_id = ?").bind(id, projectId).first<DomainRow>();
249 }
250
251 /** Writes what the dispatcher reads for the hostname, and remembers the script it names. */
252 private async point(row: DomainRow, script: string): Promise<void> {
253 const entry: DomainEntry = { script, redirect: row.redirect_to, project: row.project_id };
254 await this.kv?.put(row.hostname, JSON.stringify(entry));
255 await this.db.prepare("UPDATE domains SET script = ? WHERE id = ?").bind(script, row.id).run();
256 }
257
258 /**
259 * Adds the hostname at Cloudflare, or finds it there from an add that
260 * half-finished, and records what Cloudflare asks for. While Cloudflare
261 * for SaaS is off, the row stays pending with a note.
262 */
263 private async register(row: DomainRow, script: string): Promise<DomainRow> {
264 if (!this.hostnames) {
265 return this.note(row, "Custom domains are not set up on this g1t: it has no Cloudflare token or zone.");
266 }
267 let found: CustomHostname;
268 try {
269 found =
270 (await this.hostnames.find(row.hostname)) ??
271 (await this.hostnames.create(row.hostname, { script, project: row.project_id, domain: row.id }));
272 } catch (error) {
273 if (error instanceof NotEnabled) {
274 availability = { at: Date.now(), available: false };
275 return this.note(row, NOT_ENABLED_NOTICE);
276 }
277 if (error instanceof Refused && error.status < 500) {
278 return this.save(row, { status: "failed", error: error.message.replace(/^Cloudflare answered \d+: /, "Cloudflare refused it: ") });
279 }
280 console.error("could not add custom hostname", row.hostname, error);
281 return this.note(row, "Cloudflare could not be reached; it is tried again in a few minutes.");
282 }
283 availability = { at: Date.now(), available: true };
284 await this.db.prepare("UPDATE domains SET cf_hostname_id = ? WHERE id = ?").bind(found.id, row.id).run();
285 return this.apply({ ...row, cf_hostname_id: found.id }, found);
286 }
287
288 private async note(row: DomainRow, error: string): Promise<DomainRow> {
289 return this.save(row, { status: "pending", error });
290 }
291
292 private async save(row: DomainRow, changes: Partial<DomainRow>): Promise<DomainRow> {
293 const next = { ...row, ...changes, checked_at: now() };
294 await this.db
295 .prepare(
296 `UPDATE domains SET status = ?, ssl_status = ?, records = ?, error = ?, verified_at = ?, checked_at = ? WHERE id = ?`,
297 )
298 .bind(next.status, next.ssl_status, next.records, next.error, next.verified_at, next.checked_at, row.id)
299 .run();
300 return next;
301 }
302
303 /** Cloudflare's view of the hostname, onto its row. */
304 private async apply(row: DomainRow, found: CustomHostname): Promise<DomainRow> {
305 const seen = statusOf(found);
306 return this.save(row, {
307 status: seen.status,
308 ssl_status: seen.ssl,
309 records: JSON.stringify(recordsOf(found)),
310 error: seen.status === "active" ? null : seen.error,
311 verified_at: seen.status === "active" ? (row.verified_at ?? now()) : row.verified_at,
312 });
313 }
314
315 /**
316 * Reads the domain's state from Cloudflare; with `recheck`, first asks
317 * Cloudflare to check its DNS and certificate again now.
318 */
319 async refresh(row: DomainRow, script: string, recheck = false): Promise<DomainRow> {
320 if (!row.cf_hostname_id) return this.register(row, script);
321 if (!this.hostnames) return row;
322 try {
323 const found =
324 recheck && row.status !== "active"
325 ? await this.hostnames.recheck(row.cf_hostname_id)
326 : await this.hostnames.get(row.cf_hostname_id);
327 return this.apply(row, found);
328 } catch (error) {
329 if (error instanceof Refused && error.status === 404) {
330 // Gone at Cloudflare: add it again.
331 await this.db.prepare("UPDATE domains SET cf_hostname_id = NULL WHERE id = ?").bind(row.id).run();
332 return this.register({ ...row, cf_hostname_id: null }, script);
333 }
334 if (error instanceof NotEnabled) return this.note(row, NOT_ENABLED_NOTICE);
335 console.error("could not check custom hostname", row.hostname, error);
336 return row;
337 }
338 }
339
340 /**
341 * Removes the domain, and any of the project's domains redirecting to it:
342 * from the dispatcher at once, then from Cloudflare. A removal Cloudflare
343 * does not take now is left `removing`, for the sweep to finish.
344 */
345 async remove(row: DomainRow): Promise<void> {
346 const redirecting = await this.db
347 .prepare("SELECT * FROM domains WHERE project_id = ? AND redirect_to = ?")
348 .bind(row.project_id, row.hostname)
349 .all<DomainRow>();
350 for (const target of [row, ...redirecting.results]) await this.drop(target);
351 }
352
353 private async drop(row: DomainRow): Promise<void> {
354 await this.kv?.delete(row.hostname);
355 if (row.cf_hostname_id) {
356 try {
357 if (!this.hostnames) throw new Error("no Cloudflare token");
358 await this.hostnames.delete(row.cf_hostname_id);
359 } catch (error) {
360 console.error("could not remove custom hostname; the sweep tries again", row.hostname, error);
361 await this.db.prepare("UPDATE domains SET status = 'removing' WHERE id = ?").bind(row.id).run();
362 return;
363 }
364 }
365 await this.db.prepare("DELETE FROM domains WHERE id = ?").bind(row.id).run();
366 }
367
368 /** Every domain of the project's, removed: its deployments were turned off for good, or its plan ended. */
369 async removeWhere(column: "project_id" | "workspace", value: string): Promise<void> {
370 const rows = await this.db.prepare(`SELECT * FROM domains WHERE ${column} = ?`).bind(value).all<DomainRow>();
371 for (const row of rows.results) await this.drop(row);
372 }
373
374 /**
375 * Production is up under `script`: the project's domains serve it. Only
376 * entries naming another script are written, so a redeploy under the
377 * same name writes nothing.
378 */
379 async follow(projectId: string, script: string): Promise<void> {
380 const rows = await this.db
381 .prepare("SELECT * FROM domains WHERE project_id = ? AND status != 'removing' AND script IS NOT ?")
382 .bind(projectId, script)
383 .all<DomainRow>();
384 for (const row of rows.results) await this.point(row, script);
385 }
386
387 /**
388 * From the sweep: domains not yet at Cloudflare are added (as soon as
389 * custom hostnames are switched on), pending ones are checked, active
390 * ones now and then, and removals that failed are tried again.
391 */
392 async sweep(productionScript: (projectId: string) => Promise<string | null>): Promise<void> {
393 const recheck = new Date(Date.now() - RECHECK_ACTIVE_MS).toISOString();
394 const rows = await this.db
395 .prepare(
396 `SELECT * FROM domains
397 WHERE status IN ('pending', 'verifying', 'removing')
398 OR (status = 'active' AND COALESCE(checked_at, '') < ?)
399 ORDER BY checked_at IS NOT NULL, checked_at LIMIT ?`,
400 )
401 .bind(recheck, SWEEP_LIMIT)
402 .all<DomainRow>();
403 let enabled = true;
404 for (const row of rows.results) {
405 try {
406 if (row.status === "removing") {
407 await this.drop(row);
408 continue;
409 }
410 if (!row.cf_hostname_id && !enabled) continue;
411 const script = row.script ?? (await productionScript(row.project_id));
412 if (!script) continue;
413 const after = await this.refresh(row, script);
414 if (!after.cf_hostname_id && after.error === NOT_ENABLED_NOTICE) enabled = false;
415 } catch (error) {
416 console.error("could not sweep domain", row.hostname, error);
417 }
418 }
419 }
420
421 /** The workspace's slug changed; the rows follow. Their KV entries name scripts, which `follow` moves. */
422 rename(from: string, to: string): D1PreparedStatement {
423 return this.db.prepare("UPDATE domains SET workspace = ?1 WHERE workspace = ?2").bind(to, from);
424 }
425}
426
427/** Custom domains past the plan's, for a month's charge. */
428export function extraDomains(peak: number): number {
429 return Math.max(0, peak - DEPLOYMENTS_ALLOWANCE.customDomains);
430}