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/hostname.ts

96 lines4,359 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 * Hostnames people bring for their apps: checked, spelled the one way
3 * Cloudflare and browsers agree on (lowercase, punycode), and paired with
4 * their www or apex twin.
5 *
6 * Kept free of imports so its tests run on Node as they are.
7 */
8
9/** Domains a custom hostname may never be on: g1t's own. */
10const RESERVED = ["g1t.page", "g1t.sh", "g1t.dev"];
11
12/**
13 * Public suffixes of more than one label, the common ones. A hostname
14 * directly under one of these is an apex (`shop.co.uk`), not a subdomain.
15 */
16const MULTI_LABEL_SUFFIXES = new Set([
17 "co.uk", "org.uk", "me.uk", "ltd.uk", "plc.uk", "net.uk", "ac.uk", "gov.uk",
18 "com.au", "net.au", "org.au", "edu.au", "id.au",
19 "co.nz", "net.nz", "org.nz",
20 "co.jp", "ne.jp", "or.jp",
21 "co.za", "org.za",
22 "com.br", "net.br", "org.br",
23 "com.mx", "org.mx",
24 "com.ar", "com.co", "com.tr", "com.sg", "com.hk", "com.tw", "com.cn", "com.my", "com.ph",
25 "co.in", "net.in", "org.in", "co.id", "co.il", "co.kr", "or.kr",
26 "com.es", "com.pl", "com.ua",
27]);
28
29const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
30
31export type HostnameCheck = { ok: true; hostname: string } | { ok: false; reason: string };
32
33/**
34 * `input` as a hostname g1t can serve: lowercase and punycode, with no
35 * scheme, path, port or trailing dot; at least two labels, each a valid
36 * label; never a wildcard, an IP address or one of g1t's own domains.
37 */
38export function checkHostname(input: string): HostnameCheck {
39 let text = String(input ?? "").trim().toLowerCase();
40 // What people paste: a URL, or a hostname with a path or a trailing dot.
41 text = text.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, "");
42 if (!text) return { ok: false, reason: "Enter a domain, such as example.com or www.example.com." };
43 if (text.includes("*")) return { ok: false, reason: "Wildcard domains are not supported yet; add each hostname on its own." };
44 if (/:\d+$/.test(text)) return { ok: false, reason: "Leave out the port: apps are served on 443." };
45 if (/[\s@:]/.test(text)) return { ok: false, reason: "That is not a domain." };
46 let hostname: string;
47 try {
48 // The URL parser applies IDNA, so `bücher.example` becomes `xn--bcher-kva.example`.
49 hostname = new URL(`http://${text}/`).hostname;
50 } catch {
51 return { ok: false, reason: "That is not a domain." };
52 }
53 if (/^\[|^\d+(\.\d+){3}$/.test(hostname)) return { ok: false, reason: "Use a domain name, not an IP address." };
54 if (hostname.length > 253) return { ok: false, reason: "That domain is too long." };
55 const labels = hostname.split(".");
56 if (labels.length < 2) return { ok: false, reason: "Include the domain's ending, such as .com or .io." };
57 if (labels.some((label) => !LABEL.test(label))) {
58 return { ok: false, reason: "Each part of a domain is 1 to 63 letters, digits or hyphens, and does not start or end with a hyphen." };
59 }
60 if (/^\d+$/.test(labels[labels.length - 1])) return { ok: false, reason: "That is not a domain." };
61 for (const reserved of RESERVED) {
62 if (hostname === reserved || hostname.endsWith(`.${reserved}`)) {
63 return { ok: false, reason: `${reserved} is g1t's own domain; every app already has an address on it.` };
64 }
65 }
66 return { ok: true, hostname };
67}
68
69/** The registrable domain `hostname` is under: `example.com` for `www.example.com`. */
70export function apexOf(hostname: string): string {
71 const labels = hostname.split(".");
72 const two = labels.slice(-2).join(".");
73 const keep = MULTI_LABEL_SUFFIXES.has(two) ? 3 : 2;
74 return labels.slice(-keep).join(".");
75}
76
77/** Whether `hostname` is a registrable domain itself, which DNS cannot point with a CNAME. */
78export function isApex(hostname: string): boolean {
79 return apexOf(hostname) === hostname;
80}
81
82/**
83 * The other half of a www/apex pair: `www.example.com` for `example.com`
84 * and back. Null for any other subdomain.
85 */
86export function twinOf(hostname: string): string | null {
87 if (isApex(hostname)) return `www.${hostname}`;
88 if (hostname.startsWith("www.") && isApex(hostname.slice(4))) return hostname.slice(4);
89 return null;
90}
91
92/** What a hostname's own record is called in its DNS zone: `@` for the apex, else the part before it. */
93export function recordName(hostname: string): string {
94 const apex = apexOf(hostname);
95 return hostname === apex ? "@" : hostname.slice(0, -apex.length - 1);
96}