Public: anyone can read this; the crew can change it.

An app gets its own domain #326

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

app-domains → main 9 commits

  • Merged. app-domains was merged into main as 8bc14d0.

Review

Sign in to review this change.
19 files changed +1939 −25
docs/APPS.md +57 −4
@@ -422,6 +422,59 @@ ping for 120 seconds is dropped. The app's own 101 response headers are
422422 not carried — a cookie an app sets on the handshake does not reach the
423423 browser.
424424
425 ## Your own domain
426
427 An app can answer at a name you own — `app.example.com`, or an apex like
428 `example.com` — beside the hostname the forge gave it. Under **Settings →
429 App → Domains** an admin of the repository adds the name (or `POST
430 /api/v1/repos/:owner/:name/app/domains` with a `{ "host" }` body) and is
431 shown the two records to publish in the zone:
432
433 - a TXT record at `_forge-verify.<host>` whose value is `forge-verify=<token>`
434 — the proof that whoever added the name holds it. The token is made for
435 that one domain and is not a secret: it is published;
436 - a CNAME from `<host>` to the app's own hostname (`notes.forge.example`),
437 which carries the traffic. An apex cannot carry a CNAME: use the DNS
438 provider's ALIAS or ANAME record instead, or A and AAAA records with the
439 address the forge is served at.
440
441 The forge checks when asked — **Check now** on the page, or `POST
442 …/app/domains/:host/check` — never on a timer. It looks the TXT record up;
443 the domain is verified when some record there is exactly the value shown.
444 Every check leaves one sentence a person can act on ("No TXT record at
445 `_forge-verify.app.example.com` yet.", "The TXT record is there but holds a
446 different value.", "Verified; app.example.com points at
447 notes.forge.example."), and when it was said. Verification is a proof of
448 control at one moment, like a certificate's: the first check that finds the
449 record verifies the domain, and a record tidied away later does not take a
450 live site down. Removing the domain is what stops the forge answering there.
451
452 Once verified, the forge serves the app at that host — production, at the
453 same port, through the same proxy, with the same grant — and production's
454 URL becomes `https://app.example.com`: every "Open the app" link, the app's
455 JSON `url`, and the `FORGE_APP_URL` its process starts with. The app itself
456 learns its new address at its next deploy, because a process reads its
457 environment once. Until a domain is verified the forge answers for itself
458 at that name, exactly as it does for any hostname it does not know; a name
459 that is nobody's proof serves nobody's app. The hostname the forge gave the
460 app keeps working beside the domain.
461
462 A private app's sign-in works on the new host as it does on the old: a
463 visitor arriving at `app.example.com` is sent to the forge, vouched for, and
464 sent back to `app.example.com/__forge/grant`, where the grant cookie is set
465 for that host. One host names one app's production; a second domain on the
466 same app is served too, and the oldest verified one is the URL.
467
468 Whatever fronts the forge must route the name to it. A wildcard certificate
469 covers `*.forge.example` and not `app.example.com`, so the proxy needs a
470 certificate per domain. With Caddy, `on_demand_tls` fetches one the first
471 time a name is asked for, and asks the forge first whether the name is
472 anyone's: `GET /__forge/domains/ask?domain=<host>` answers 200 for a
473 verified domain and 404 for anything else, so a stranger cannot make the
474 proxy fetch certificates for names that are not here (`docs/EXTRACT.md`
475 has the Caddyfile). A tunnel routes the host the way it routes the
476 wildcard. The forge itself speaks plain HTTP either way.
477
425478 ## A release does not drop a session
426479
427480 The forge is one process on one volume, and a release replaces it. The new
@@ -693,7 +746,7 @@ the data directory `FORGE_APP_DATA`, and the defaults become
693746 Containers. An app runs as the forge's own user, in its own directory, and
694747 nothing more separates it from the host than that. On the machine one
695748 person owns this is the honest, fast answer; a sandboxed runtime is the
696 next layer, and the process boundary here is where it will attach. Custom
697 domains, TLS termination, and an approve-and-land gate are additions of
698 the same kind: the shapes above hold, and a new column or listener carries
699 each of them.
749 next layer, and the process boundary here is where it will attach. TLS
750 termination and an approve-and-land gate are additions of the same kind:
751 the shapes above hold, and a new column or listener carries each of them —
752 as one column carried a domain of the app's own.
docs/EXTRACT.md +25 −1
@@ -44,7 +44,31 @@ turned off, with what each move costs and what it gives up.
4444 `forge.example, *.forge.example { reverse_proxy 127.0.0.1:3000 }` with a
4545 DNS-challenge wildcard, or the existing Cloudflare tunnel with two ingress
4646 rules to `http://127.0.0.1:3000`. Either way the forge sees
47 `x-forwarded-proto` and answers with its canonical URL.
47 `x-forwarded-proto` and answers with its canonical URL. An app's own
48 domain (`docs/APPS.md`, _Your own domain_) is a name the wildcard does
49 not cover, so Caddy fetches its certificate on demand — after asking the
50 forge whether the name is anyone's:
51
52 ```
53 {
54 on_demand_tls {
55 ask http://127.0.0.1:3000/__forge/domains/ask
56 }
57 }
58 forge.example, *.forge.example {
59 reverse_proxy 127.0.0.1:3000
60 }
61 https:// {
62 tls { on_demand }
63 reverse_proxy 127.0.0.1:3000
64 }
65 ```
66
67 The forge answers the ask with 200 for a verified domain and 404 for
68 anything else, so nobody can make the proxy fetch certificates for names
69 that are not here. With the tunnel, each domain is one more ingress rule
70 to the same address.
71
4872 5. **DNS cutover.** Point `forge.example` and the wildcard at the box (or
4973 the tunnel). The old cluster keeps serving until the switch; after it,
5074 the platform's forge is a copy nobody visits.
src/actions/apps.ts +7 −5
@@ -8,7 +8,7 @@
88
99 import type { App } from "../app.ts";
1010 import { appsRuntime, type EnvStatus } from "../apps/runtime.ts";
11 import { grantUrl } from "../apps/proxy.ts";
11 import { grantUrl, originFor } from "../apps/proxy.ts";
1212 import { scaffoldFiles } from "../apps/scaffold.ts";
1313 import {
1414 isEnvName,
@@ -323,7 +323,10 @@ export async function newApp(
323323 {
324324 name: input.name,
325325 description,
326 agents: agents.map((a) => ({ name: a.user.username, brief: a.brief })),
326 agents: agents.map((a) => ({
327 name: a.user.username,
328 brief: a.brief,
329 })),
327330 },
328331 { binary },
329332 );
@@ -474,9 +477,8 @@ export function openEnvUrl(
474477 return ok(grantUrl(runtime, { app: record, env }, path, host, who));
475478 }
476479 if (record.access === "private") return err("unauthorized");
477 const base = new URL(runtime.urlFor(record, env));
478 if (host !== undefined) base.hostname = host.replace(/:\d+$/, "");
479 return ok(`${base.origin}${path === "/" ? "" : path}`);
480 const origin = originFor(runtime, { app: record, env }, host);
481 return ok(`${origin}${path === "/" ? "" : path}`);
480482 }
481483
482484 /** Deploy an environment again at its current commit. */
src/actions/domains.ts added +198
@@ -0,0 +1,198 @@
1 /**
2 * Domain actions: give an app a name of its own, check that the person who
3 * added it holds it, and take it away. Admin over the repository, like
4 * every other app setting. The check is the one thing here that leaves the
5 * box — a DNS lookup — and it happens when someone asks, never on a timer.
6 */
7
8 import type { App } from "../app.ts";
9 import { checkDomain, recordsFor, type DnsRecord } from "../apps/domains.ts";
10 import { appHostSuffix } from "../apps/host.ts";
11 import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
12 import { getAppByRepo, type AppRecord } from "../core/apps.ts";
13 import {
14 addDomain,
15 getDomain,
16 listDomains,
17 recordCheck,
18 removeDomain,
19 type AddDomainError,
20 type AppDomain,
21 } from "../core/domains.ts";
22 import type { Repo } from "../core/repos.ts";
23 import type { User } from "../core/users.ts";
24 import { err, ok, type Result } from "../kernel/result.ts";
25 import { requireRepoAdmin } from "./access.ts";
26
27 /** A domain as a page or the API shows it: the facts, and what to publish. */
28 export interface AppDomainView {
29 readonly host: string;
30 readonly verified: boolean;
31 readonly verifiedAt: string | null;
32 readonly checkedAt: string | null;
33 readonly said: string;
34 /** The proof, and where the traffic goes. */
35 readonly records: DnsRecord[];
36 }
37
38 export type DomainError = "not_found" | "no_runtime";
39
40 /** The admin, and the app they administer. */
41 function admitted(
42 app: App,
43 actor: User | undefined,
44 repo: Repo,
45 ): Result<{ admin: User; record: AppRecord }, "not_found"> {
46 const admin = requireRepoAdmin(app, actor, repo);
47 if (!admin.ok) return admin;
48 const record = getAppByRepo(app.db, repo.id);
49 if (!record) return err("not_found");
50 return ok({ admin: admin.value, record });
51 }
52
53 /** A domain has nowhere to point when nothing serves apps. */
54 function serving(app: App): Result<AppsRuntime, "no_runtime"> {
55 const runtime = appsRuntime(app);
56 return runtime === undefined ? err("no_runtime") : ok(runtime);
57 }
58
59 /** The forge's own hostname — the one name no app may take. */
60 function forgeHost(app: App): string {
61 return app.config.url !== undefined
62 ? new URL(app.config.url).hostname
63 : (app.config.host ?? "localhost");
64 }
65
66 /** One of this app's domains, by host — never another app's. */
67 function ownedDomain(
68 app: App,
69 record: AppRecord,
70 host: string,
71 ): AppDomain | undefined {
72 const domain = getDomain(app.db, host);
73 return domain?.appId === record.id ? domain : undefined;
74 }
75
76 function view(
77 runtime: AppsRuntime,
78 record: AppRecord,
79 domain: AppDomain,
80 ): AppDomainView {
81 return {
82 host: domain.host,
83 verified: domain.verifiedAt !== null,
84 verifiedAt: domain.verifiedAt,
85 checkedAt: domain.checkedAt,
86 said: domain.said,
87 records: recordsFor(
88 domain.host,
89 domain.token,
90 runtime.hostFor(record.slug, domain.env),
91 ),
92 };
93 }
94
95 export function listAppDomains(
96 app: App,
97 actor: User | undefined,
98 repo: Repo,
99 ): Result<AppDomainView[], DomainError> {
100 const gate = admitted(app, actor, repo);
101 if (!gate.ok) return gate;
102 const runtime = serving(app);
103 if (!runtime.ok) return runtime;
104 return ok(
105 listDomains(app.db, gate.value.record.id).map((domain) =>
106 view(runtime.value, gate.value.record, domain),
107 ),
108 );
109 }
110
111 /** Add a domain; the answer carries the two records to publish. */
112 export function addAppDomain(
113 app: App,
114 actor: User | undefined,
115 repo: Repo,
116 host: string,
117 ): Result<AppDomainView, DomainError | AddDomainError> {
118 const gate = admitted(app, actor, repo);
119 if (!gate.ok) return gate;
120 const runtime = serving(app);
121 if (!runtime.ok) return runtime;
122 const { admin, record } = gate.value;
123 const added = addDomain(
124 app.db,
125 { host, appId: record.id, createdBy: admin.id },
126 {
127 forgeHost: forgeHost(app),
128 appHostSuffix: appHostSuffix(
129 runtime.value.options.domain.toLowerCase().replace(/:\d+$/, ""),
130 ),
131 },
132 );
133 if (!added.ok) return added;
134 app.bus.append({
135 kind: "app.domain.added",
136 repoId: repo.id,
137 actorId: admin.id,
138 payload: { host: added.value.host, slug: record.slug },
139 });
140 return ok(view(runtime.value, record, added.value));
141 }
142
143 /**
144 * Look the proof up now. The first check that finds it verifies the domain
145 * for good and says so on the bus; every check leaves its sentence.
146 */
147 export async function checkAppDomain(
148 app: App,
149 actor: User | undefined,
150 repo: Repo,
151 host: string,
152 ): Promise<Result<AppDomainView, DomainError | "no_such_domain">> {
153 const gate = admitted(app, actor, repo);
154 if (!gate.ok) return gate;
155 const runtime = serving(app);
156 if (!runtime.ok) return runtime;
157 const { admin, record } = gate.value;
158 const domain = ownedDomain(app, record, host);
159 if (domain === undefined) return err("no_such_domain");
160 const found = await checkDomain(
161 domain.host,
162 domain.token,
163 runtime.value.hostFor(record.slug, domain.env),
164 runtime.value.options.dns,
165 );
166 const updated = recordCheck(app.db, domain.host, found) ?? domain;
167 if (found.verified && domain.verifiedAt === null) {
168 app.bus.append({
169 kind: "app.domain.verified",
170 repoId: repo.id,
171 actorId: admin.id,
172 payload: { host: domain.host, slug: record.slug },
173 });
174 }
175 return ok(view(runtime.value, record, updated));
176 }
177
178 /** Take a domain away. The forge stops answering there at once. */
179 export function removeAppDomain(
180 app: App,
181 actor: User | undefined,
182 repo: Repo,
183 host: string,
184 ): Result<void, "not_found" | "no_such_domain"> {
185 const gate = admitted(app, actor, repo);
186 if (!gate.ok) return gate;
187 const { admin, record } = gate.value;
188 const domain = ownedDomain(app, record, host);
189 if (domain === undefined || !removeDomain(app.db, domain.host))
190 return err("no_such_domain");
191 app.bus.append({
192 kind: "app.domain.removed",
193 repoId: repo.id,
194 actorId: admin.id,
195 payload: { host: domain.host, slug: record.slug },
196 });
197 return ok();
198 }
src/apps/domains.ts added +154
@@ -0,0 +1,154 @@
1 /**
2 * Proving a domain, and saying what was found. The person who adds
3 * `app.example.com` publishes two records: a TXT under `_forge-verify.`
4 * carrying the token the forge gave them, which proves they hold the name,
5 * and a CNAME to the app's own hostname, which carries the traffic. Only
6 * the TXT decides; the CNAME is reported, because a name can reach the
7 * forge in more ways than one — an apex through ALIAS or A records, a
8 * tunnel. Every outcome of a lookup — no record, another value, a resolver
9 * that will not answer — is a sentence a person can act on, never a throw.
10 */
11
12 import { resolveCname, resolveTxt } from "node:dns/promises";
13
14 export interface DnsRecord {
15 readonly type: "TXT" | "CNAME" | "ALIAS";
16 readonly name: string;
17 readonly value: string;
18 /** A word of explanation, when the record needs one. */
19 readonly note?: string;
20 }
21
22 const VERIFY_LABEL = "_forge-verify";
23
24 export const verifyName = (host: string): string => `${VERIFY_LABEL}.${host}`;
25 export const verifyValue = (token: string): string => `forge-verify=${token}`;
26
27 /** A name of exactly two labels cannot carry a CNAME. */
28 export const isApex = (host: string): boolean => host.split(".").length === 2;
29
30 const APEX_NOTE =
31 "An apex cannot carry a CNAME: use your DNS provider's ALIAS or ANAME record to point it at the app's hostname, or A and AAAA records with the address the forge is served at.";
32
33 /** The two records to publish for `host`, given the app's own hostname. */
34 export function recordsFor(
35 host: string,
36 token: string,
37 appHost: string,
38 ): DnsRecord[] {
39 const proof: DnsRecord = {
40 type: "TXT",
41 name: verifyName(host),
42 value: verifyValue(token),
43 };
44 return isApex(host)
45 ? [proof, { type: "ALIAS", name: host, value: appHost, note: APEX_NOTE }]
46 : [proof, { type: "CNAME", name: host, value: appHost }];
47 }
48
49 /** What a check asks of DNS — the system resolver, or a test's stand-in. */
50 export interface DomainResolver {
51 resolveTxt(name: string): Promise<string[][]>;
52 resolveCname(name: string): Promise<string[]>;
53 }
54
55 const systemResolver: DomainResolver = { resolveTxt, resolveCname };
56
57 /** How long a lookup may take before it is called a timeout. */
58 const LOOKUP_TIMEOUT_MS = 5_000;
59
60 export interface DomainCheck {
61 readonly verified: boolean;
62 /** One plain sentence: what was found, and what to do about it. */
63 readonly said: string;
64 }
65
66 /**
67 * Look the proof up, and — once it is there — where the name points.
68 * `verified` iff some TXT record at `_forge-verify.<host>` is exactly
69 * `forge-verify=<token>`.
70 */
71 export async function checkDomain(
72 host: string,
73 token: string,
74 appHost: string,
75 resolver: DomainResolver = systemResolver,
76 ): Promise<DomainCheck> {
77 const name = verifyName(host);
78 const proof = await lookup(() => resolver.resolveTxt(name));
79 if (proof.kind === "none")
80 return { verified: false, said: `No TXT record at ${name} yet.` };
81 if (proof.kind === "failed")
82 return {
83 verified: false,
84 said: `The DNS lookup for ${name} ${proof.why}; try again in a minute.`,
85 };
86 const values = proof.value.map((chunks) => chunks.join(""));
87 if (!values.includes(verifyValue(token)))
88 return {
89 verified: false,
90 said: "The TXT record is there but holds a different value.",
91 };
92 return {
93 verified: true,
94 said: `Verified; ${await pointing(host, appHost, resolver)}.`,
95 };
96 }
97
98 /** Where the name sends traffic, as one clause of the verified sentence. */
99 async function pointing(
100 host: string,
101 appHost: string,
102 resolver: DomainResolver,
103 ): Promise<string> {
104 if (isApex(host))
105 return `${host} is an apex, so point it at ${appHost} with an ALIAS record or at the forge's address`;
106 const cname = await lookup(() => resolver.resolveCname(host));
107 if (cname.kind === "none") return `${host} has no CNAME yet`;
108 if (cname.kind === "failed")
109 return `where ${host} points could not be looked up`;
110 const first = cname.value[0];
111 if (first === undefined) return `${host} has no CNAME yet`;
112 const target = canonical(first);
113 return target === canonical(appHost)
114 ? `${host} points at ${appHost}`
115 : `${host} points at ${target}, not ${appHost}`;
116 }
117
118 const canonical = (name: string): string =>
119 name.toLowerCase().replace(/\.$/, "");
120
121 type Lookup<T> =
122 | { kind: "found"; value: T }
123 | { kind: "none" }
124 | { kind: "failed"; why: string };
125
126 /**
127 * One lookup, with every way it can go wrong folded into a value. "No such
128 * record" and "no such name" both mean nothing is published yet; anything
129 * else is the resolver's trouble, named so the sentence can carry it.
130 */
131 async function lookup<T>(run: () => Promise<T>): Promise<Lookup<T>> {
132 let timer: ReturnType<typeof setTimeout> | undefined;
133 const timeout = new Promise<never>((_, reject) => {
134 timer = setTimeout(
135 () => reject(Object.assign(new Error("timed out"), { code: "ETIMEOUT" })),
136 LOOKUP_TIMEOUT_MS,
137 );
138 });
139 try {
140 return { kind: "found", value: await Promise.race([run(), timeout]) };
141 } catch (error) {
142 const code =
143 typeof error === "object" && error !== null && "code" in error
144 ? String((error as { code: unknown }).code)
145 : "";
146 if (code === "ENODATA" || code === "ENOTFOUND") return { kind: "none" };
147 return {
148 kind: "failed",
149 why: code === "ETIMEOUT" ? "timed out" : `failed (${code || "error"})`,
150 };
151 } finally {
152 clearTimeout(timer);
153 }
154 }
src/apps/proxy.ts +57 −6
@@ -17,7 +17,8 @@
1717 import { appHostSuffix } from "./host.ts";
1818 import { createHmac, timingSafeEqual } from "node:crypto";
1919 import type { App } from "../app.ts";
20 import { getAppBySlug, type AppRecord } from "../core/apps.ts";
20 import { getAppById, getAppBySlug, type AppRecord } from "../core/apps.ts";
21 import { getVerifiedDomain } from "../core/domains.ts";
2122 import { err, ok, type Result } from "../kernel/result.ts";
2223 import { loadKey } from "../kernel/secret.ts";
2324 import { previewEnv, type EnvName } from "./data.ts";
@@ -34,6 +35,15 @@ export interface AppTarget {
3435 readonly nested?: boolean;
3536 }
3637
38 /**
39 * What a `Host` may look like before it is read as anything: a hostname of
40 * letters, digits, dots and hyphens, and at most a port. Nothing here can
41 * be reparsed into another host by a URL parser (`#`, `@`, `/`, `?`,
42 * spaces), so every reading below agrees on which machine is meant.
43 */
44 export const isHostPort = (host: string): boolean =>
45 /^[a-z0-9](?:[a-z0-9.-]{0,252})?(?::\d{1,5})?$/i.test(host);
46
3747 /**
3848 * Which app and environment a `Host` names, if any. Hostnames are
3949 * case-insensitive and may carry a port. Only one label may sit in front
@@ -43,6 +53,7 @@ export function parseAppHost(
4353 hostHeader: string,
4454 domain: string,
4555 ): { slug: string; env: EnvName; nested: boolean } | undefined {
56 if (!isHostPort(hostHeader)) return undefined;
4657 const host = hostHeader.toLowerCase().replace(/:\d+$/, "");
4758 const suffix = appHostSuffix(domain.toLowerCase().replace(/:\d+$/, ""));
4859 if (!host.endsWith(suffix)) return undefined;
@@ -89,13 +100,54 @@ export function resolveAppHostname(
89100 host: string,
90101 ): AppTarget | "unknown" | undefined {
91102 const parsed = parseAppHost(host, domain);
92 if (parsed === undefined) return undefined;
103 if (parsed === undefined) return ownDomain(app, host);
93104 const record = getAppBySlug(app.db, parsed.slug);
94105 return record
95106 ? { app: record, env: parsed.env, nested: parsed.nested }
96107 : "unknown";
97108 }
98109
110 /**
111 * A hostname that is no label of ours may still be an app's own domain,
112 * once the person who added it has proved they hold it (core/domains.ts).
113 * Until then, and for every other name, the forge answers for itself —
114 * its own pages, at whatever name it is reached by — so a domain added
115 * but not yet verified serves nothing of the app.
116 */
117 function ownDomain(app: App, host: string): AppTarget | undefined {
118 const domain = getVerifiedDomain(app.db, host.replace(/:\d+$/, ""));
119 if (domain === undefined) return undefined;
120 const record = getAppById(app.db, domain.appId);
121 return record === undefined
122 ? undefined
123 : { app: record, env: domain.env, nested: false };
124 }
125
126 /**
127 * The origin a visitor is sent back to: the hostname they asked for, at
128 * the port they asked for it on when the name carried one; otherwise the
129 * environment's own URL decides the port. An app's canonical host carries
130 * the forge's, its own domain none — and only what the browser sent says
131 * which the visitor is at.
132 */
133 export function originFor(
134 runtime: AppsRuntime,
135 target: AppTarget,
136 host?: string,
137 ): string {
138 const base = new URL(runtime.urlFor(target.app, target.env));
139 // Only a bare hostname, with or without a port, is a place to send
140 // anyone. A URL parser reads `evil#x--notes.apps.test` as the host `evil`
141 // while the label check above read it as ours — the same string must
142 // never mean two hosts, so anything else is not a host at all.
143 if (host === undefined || !isHostPort(host)) return base.origin;
144 const asked = URL.parse(`${base.protocol}//${host}`);
145 if (asked === null) return base.origin;
146 base.hostname = asked.hostname;
147 if (asked.port !== "") base.port = asked.port;
148 return base.origin;
149 }
150
99151 /**
100152 * The grant cookie is named for the app it admits to. Two forges can set
101153 * grants on one hostname — this one, and a forge it hosts, for an app of
@@ -240,11 +292,10 @@ export function grantUrl(
240292 );
241293 // The cookie a grant sets belongs to one hostname. A visitor who asked
242294 // for a hostname inside the app's own world — an app of a forge hosted
243 // here — must be granted on that hostname, not the app's canonical one,
295 // here, or the app's canonical host when it has a domain of its own —
296 // must be granted on that hostname, not the one production's URL names,
244297 // or the cookie lands where the request never goes.
245 const base = new URL(runtime.urlFor(target.app, target.env));
246 if (host !== undefined) base.hostname = host.replace(/:\d+$/, "");
247 return `${base.origin}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(path)}`;
298 return `${originFor(runtime, target, host)}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(path)}`;
248299 }
249300
250301 const HOP_BY_HOP = [
src/apps/runtime.ts +19
@@ -43,7 +43,9 @@ import { createLog } from "../kernel/log.ts";
4343 import type { Result } from "../kernel/result.ts";
4444 import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts";
4545 import { appendRunLog } from "../runs/log.ts";
46 import { primaryDomain } from "../core/domains.ts";
4647 import { exportTree, readFileAt } from "./checkout.ts";
48 import type { DomainResolver } from "./domains.ts";
4749 import {
4850 appsRoot,
4951 ensureEnvData,
@@ -89,6 +91,11 @@ export interface RuntimeOptions {
8991 | undefined;
9092 /** How often the runtime looks for environments to put to sleep. */
9193 readonly sweepEveryMs?: number | undefined;
94 /**
95 * What a domain check asks: the system resolver unless a test hands in
96 * its own, so proving `app.example.com` needs no network in a test.
97 */
98 readonly dns?: DomainResolver | undefined;
9299 }
93100
94101 export type EnvState =
@@ -294,7 +301,19 @@ export class AppsRuntime {
294301 return sshCommand(this.app, login);
295302 }
296303
304 /**
305 * Where an environment is reached. Production answers at the app's own
306 * domain once one is verified — every link, the app's JSON `url` and the
307 * `FORGE_APP_URL` its next process starts with all follow — and at
308 * `<slug>.<domain>` until then; a preview is always at its label. A
309 * domain carries no port: it reaches the forge through whatever fronts
310 * it, which holds its own.
311 */
297312 urlFor(record: AppRecord, env: EnvName): string {
313 if (env === "production") {
314 const own = primaryDomain(this.app.db, record.id);
315 if (own !== undefined) return `${this.options.scheme}://${own.host}`;
316 }
298317 const port =
299318 this.options.publicPort === undefined ||
300319 (this.options.scheme === "http" && this.options.publicPort === 80) ||
src/core/domains.ts added +207
@@ -0,0 +1,207 @@
1 /**
2 * An app's own domains — the rows. `app.example.com` is served by the forge
3 * once the person who added it has shown they hold the name: a TXT record
4 * carrying the token the row remembers. The token is published in DNS, so
5 * it is not a secret; the proof is that only the zone's owner could put it
6 * there. Pure data: no DNS, no authz, no events. What is published and how
7 * it is checked is `apps/domains.ts`.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import type { EnvName } from "../apps/data.ts";
12 import { err, ok, type Result } from "../kernel/result.ts";
13
14 export interface AppDomain {
15 readonly host: string;
16 readonly appId: string;
17 /** The environment the host reaches — production, for now. */
18 readonly env: EnvName;
19 /** Published in DNS as `forge-verify=<token>`, to prove control of the name. */
20 readonly token: string;
21 readonly createdBy: string | null;
22 readonly createdAt: string;
23 readonly verifiedAt: string | null;
24 readonly checkedAt: string | null;
25 /** What the last check said, one sentence for a person. */
26 readonly said: string;
27 }
28
29 interface DomainRow {
30 host: string;
31 app_id: string;
32 env: EnvName;
33 token: string;
34 created_by: string | null;
35 created_at: string;
36 verified_at: string | null;
37 checked_at: string | null;
38 said: string;
39 }
40
41 const toDomain = (r: DomainRow): AppDomain => ({
42 host: r.host,
43 appId: r.app_id,
44 env: r.env,
45 token: r.token,
46 createdBy: r.created_by,
47 createdAt: r.created_at,
48 verifiedAt: r.verified_at,
49 checkedAt: r.checked_at,
50 said: r.said,
51 });
52
53 const now = (): string => new Date().toISOString();
54
55 /**
56 * The names an app may not take: the forge's own hostname, and anything
57 * that hangs off the app domain — those are the forge's to hand out
58 * (`<slug>.<domain>`), and a row here would shadow them.
59 */
60 export interface DomainRules {
61 /** The forge's own hostname, lowercase. */
62 readonly forgeHost: string;
63 /** What app hostnames attach to (`appHostSuffix`), when apps are served. */
64 readonly appHostSuffix?: string | undefined;
65 }
66
67 export type AddDomainError = "invalid_host" | "reserved_host" | "taken";
68
69 /** A hostname the way DNS wants it: lowercase, no trailing dot. */
70 export function normalizeHost(host: string): string {
71 return host.trim().toLowerCase().replace(/\.$/, "");
72 }
73
74 const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
75
76 /**
77 * A hostname a person can own: at least two labels of letters, digits and
78 * inside hyphens, and not an address. A last label of digits is an IPv4
79 * literal or a name no URL can hold, and is refused either way.
80 */
81 export function isHostname(host: string): boolean {
82 if (host.length === 0 || host.length > 253) return false;
83 const labels = host.split(".");
84 if (labels.length < 2 || !labels.every((label) => LABEL.test(label)))
85 return false;
86 return !/^\d+$/.test(labels.at(-1)!);
87 }
88
89 function isReserved(host: string, rules: DomainRules): boolean {
90 if (host === rules.forgeHost.toLowerCase()) return true;
91 const suffix = rules.appHostSuffix?.toLowerCase();
92 if (suffix === undefined) return false;
93 // `.apps.example` and `--apps.example` both hang off `apps.example`.
94 const zone = suffix.replace(/^[^a-z0-9]+/, "");
95 return host === zone || host.endsWith(suffix) || host.endsWith(`.${zone}`);
96 }
97
98 /** 24 characters of Crockford base32 — 120 bits — for the TXT record. */
99 export const TOKEN_LENGTH = 24;
100 const TOKEN_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
101
102 function newToken(): string {
103 const bytes = new Uint8Array(TOKEN_LENGTH);
104 crypto.getRandomValues(bytes);
105 let out = "";
106 for (const byte of bytes) out += TOKEN_ALPHABET[byte & 31];
107 return out;
108 }
109
110 export function addDomain(
111 db: Database,
112 input: { host: string; appId: string; createdBy?: string | undefined },
113 rules: DomainRules,
114 ): Result<AppDomain, AddDomainError> {
115 const host = normalizeHost(input.host);
116 if (!isHostname(host)) return err("invalid_host");
117 if (isReserved(host, rules)) return err("reserved_host");
118 if (getDomain(db, host)) return err("taken");
119 try {
120 db.query(
121 "INSERT INTO app_domain (host, app_id, env, token, created_by, created_at) VALUES (?1, ?2, 'production', ?3, ?4, ?5)",
122 ).run(host, input.appId, newToken(), input.createdBy ?? null, now());
123 } catch (error) {
124 // Two racing adds: the primary key settles it, the check above only
125 // named it early.
126 if (
127 error instanceof Error &&
128 /UNIQUE constraint failed/.test(error.message)
129 )
130 return err("taken");
131 throw error;
132 }
133 return ok(getDomain(db, host)!);
134 }
135
136 export function getDomain(db: Database, host: string): AppDomain | undefined {
137 const row = db
138 .query<DomainRow, [string]>("SELECT * FROM app_domain WHERE host = ?1")
139 .get(normalizeHost(host));
140 return row ? toDomain(row) : undefined;
141 }
142
143 /** The row for a host that has been verified — the only kind the proxy serves. */
144 export function getVerifiedDomain(
145 db: Database,
146 host: string,
147 ): AppDomain | undefined {
148 const found = getDomain(db, host);
149 return found?.verifiedAt === null ? undefined : found;
150 }
151
152 /** An app's domains, oldest first. */
153 export function listDomains(db: Database, appId: string): AppDomain[] {
154 return db
155 .query<
156 DomainRow,
157 [string]
158 >("SELECT * FROM app_domain WHERE app_id = ?1 ORDER BY created_at, host")
159 .all(appId)
160 .map(toDomain);
161 }
162
163 /**
164 * The domain production's URL is built from: the one verified longest ago,
165 * so a second domain added later never moves links that already work.
166 */
167 export function primaryDomain(
168 db: Database,
169 appId: string,
170 ): AppDomain | undefined {
171 const row = db
172 .query<
173 DomainRow,
174 [string]
175 >("SELECT * FROM app_domain WHERE app_id = ?1 AND verified_at IS NOT NULL ORDER BY verified_at, host LIMIT 1")
176 .get(appId);
177 return row ? toDomain(row) : undefined;
178 }
179
180 export function removeDomain(db: Database, host: string): boolean {
181 return (
182 db.query("DELETE FROM app_domain WHERE host = ?1").run(normalizeHost(host))
183 .changes > 0
184 );
185 }
186
187 /**
188 * What a check found. Verification is a proof of control at one moment,
189 * like a certificate's: once given it stays, so a TXT record tidied away
190 * later — or a resolver that would not answer today — never takes a live
191 * site down. Removing the domain is the one way to unserve it.
192 */
193 export function recordCheck(
194 db: Database,
195 host: string,
196 check: { verified: boolean; said: string },
197 ): AppDomain | undefined {
198 const at = now();
199 db.query(
200 `UPDATE app_domain
201 SET checked_at = ?2,
202 said = ?3,
203 verified_at = CASE WHEN ?4 = 1 AND verified_at IS NULL THEN ?2 ELSE verified_at END
204 WHERE host = ?1`,
205 ).run(normalizeHost(host), at, check.said, check.verified ? 1 : 0);
206 return getDomain(db, host);
207 }
src/core/event_audience.ts +1
@@ -25,6 +25,7 @@ export function eventAudience(kind: string): EventAudience {
2525 if (
2626 kind.startsWith("webhook.") ||
2727 kind.startsWith("app.env_") ||
28 kind.startsWith("app.domain.") ||
2829 kind === "repo.settings_updated"
2930 )
3031 return "admins";
src/core/schema.ts +19
@@ -879,4 +879,23 @@ export const migrations: readonly string[] = [
879879 DROP TABLE look;
880880 ALTER TABLE look_new RENAME TO look;
881881 `,
882
883 // 32 — an app's own domains. One host names one app's production; the
884 // token is published in DNS to prove the name is held, so it is not a
885 // secret. `verified_at` is set by the first check that finds the proof
886 // and never cleared by a later one; `said` is the last check's sentence.
887 `
888 CREATE TABLE app_domain (
889 host TEXT PRIMARY KEY,
890 app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE,
891 env TEXT NOT NULL DEFAULT 'production',
892 token TEXT NOT NULL,
893 created_by TEXT REFERENCES user(id) ON DELETE SET NULL,
894 created_at TEXT NOT NULL,
895 verified_at TEXT,
896 checked_at TEXT,
897 said TEXT NOT NULL DEFAULT ''
898 );
899 CREATE INDEX app_domain_app ON app_domain(app_id);
900 `,
882901 ];
src/design/activity.ts +7
@@ -62,6 +62,13 @@ export function eventVerb(event: ForgeEvent): string {
6262 ? `set ${which} in the app's environment`
6363 : `removed ${which} from the app's environment`;
6464 }
65 if (kind.startsWith("app.domain.")) {
66 const host = payload["host"];
67 const which = typeof host === "string" ? host : "a domain";
68 if (kind === "app.domain.added") return `gave the app the domain ${which}`;
69 if (kind === "app.domain.verified") return `verified the domain ${which}`;
70 return `removed the domain ${which}`;
71 }
6572 return WORDS[kind] ?? "acted on this repository";
6673 }
6774
src/http/api.ts +93 −6
@@ -153,6 +153,14 @@ import {
153153 updateAppSettings,
154154 type AppSettingsInput,
155155 } from "../actions/apps.ts";
156 import {
157 addAppDomain,
158 checkAppDomain,
159 listAppDomains,
160 removeAppDomain,
161 } from "../actions/domains.ts";
162 import { listDomains } from "../core/domains.ts";
163 import type { Database } from "bun:sqlite";
156164 import {
157165 migrateMessage,
158166 migrateRepo,
@@ -635,8 +643,12 @@ function repoQuery(ctx: Ctx) {
635643 };
636644 }
637645
638 /** An app as the API returns it, with what is running when a runtime is up. */
646 /**
647 * An app as the API returns it, with what is running when a runtime is up
648 * and the domains of its own that are verified — the ones the forge serves.
649 */
639650 function appJson(
651 db: Database,
640652 record: AppRecord,
641653 repo: Repo,
642654 runtime: AppsRuntime | undefined,
@@ -645,6 +657,9 @@ function appJson(
645657 ...record,
646658 repo: { owner: repo.ownerName, name: repo.name },
647659 url: runtime?.urlFor(record, "production") ?? null,
660 domains: listDomains(db, record.id)
661 .filter((d) => d.verifiedAt !== null)
662 .map((d) => d.host),
648663 serving: runtime !== undefined,
649664 environments: runtime?.status(record) ?? [],
650665 };
@@ -2672,7 +2687,7 @@ export function apiRoutes(router: Router<Handler>): void {
26722687 for (const record of records) {
26732688 const repo = repos.get(record.repoId);
26742689 if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) continue;
2675 items.push(appJson(record, repo, runtime));
2690 items.push(appJson(ctx.app.db, record, repo, runtime));
26762691 }
26772692 return json({ items, next_cursor: null });
26782693 });
@@ -2696,7 +2711,12 @@ export function apiRoutes(router: Router<Handler>): void {
26962711 return json(
26972712 {
26982713 repo: repoJson(ctx, made.value.repo),
2699 app: appJson(made.value.app, made.value.repo, appsRuntime(ctx.app)),
2714 app: appJson(
2715 ctx.app.db,
2716 made.value.app,
2717 made.value.repo,
2718 appsRuntime(ctx.app),
2719 ),
27002720 issue: made.value.issue ? withLabels(ctx, made.value.issue) : null,
27012721 },
27022722 201,
@@ -2785,7 +2805,9 @@ export function apiRoutes(router: Router<Handler>): void {
27852805 if (!repo) return notFound();
27862806 const view = appView(ctx.app, ctx.auth.user, repo);
27872807 if (!view.ok) return notFound();
2788 return json(appJson(view.value.app, repo, appsRuntime(ctx.app)));
2808 return json(
2809 appJson(ctx.app.db, view.value.app, repo, appsRuntime(ctx.app)),
2810 );
27892811 });
27902812
27912813 router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => {
@@ -2796,7 +2818,10 @@ export function apiRoutes(router: Router<Handler>): void {
27962818 const input = await body(ctx.request);
27972819 const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input));
27982820 if (!enabled.ok) return respondAccess(enabled.error, 422);
2799 return json(appJson(enabled.value, repo, appsRuntime(ctx.app)), 201);
2821 return json(
2822 appJson(ctx.app.db, enabled.value, repo, appsRuntime(ctx.app)),
2823 201,
2824 );
28002825 });
28012826
28022827 router.add("PATCH", "/api/v1/repos/:owner/:name/app", async (ctx) => {
@@ -2812,7 +2837,7 @@ export function apiRoutes(router: Router<Handler>): void {
28122837 appPatch(input),
28132838 );
28142839 if (!updated.ok) return respondAccess(updated.error, 422);
2815 return json(appJson(updated.value, repo, appsRuntime(ctx.app)));
2840 return json(appJson(ctx.app.db, updated.value, repo, appsRuntime(ctx.app)));
28162841 });
28172842
28182843 router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => {
@@ -2825,6 +2850,68 @@ export function apiRoutes(router: Router<Handler>): void {
28252850 return json({}, 200);
28262851 });
28272852
2853 // The app's own domains (docs/APPS.md, "Your own domain"). Admin scope
2854 // and admin over the repository, like the environment below; `:host` is
2855 // one path segment, dots included.
2856 router.add("GET", "/api/v1/repos/:owner/:name/app/domains", (ctx) => {
2857 const denied = requireScope(ctx, "admin");
2858 if (denied) return denied;
2859 const repo = loadRepo(ctx);
2860 if (!repo) return notFound();
2861 const listed = listAppDomains(ctx.app, ctx.auth.user, repo);
2862 if (!listed.ok) return respondAccess(listed.error, 422);
2863 return json({ items: listed.value, next_cursor: null });
2864 });
2865
2866 router.add("POST", "/api/v1/repos/:owner/:name/app/domains", async (ctx) => {
2867 const denied = requireScope(ctx, "admin");
2868 if (denied) return denied;
2869 const repo = loadRepo(ctx);
2870 if (!repo) return notFound();
2871 const input = await body(ctx.request);
2872 const added = addAppDomain(ctx.app, ctx.auth.user, repo, str(input.host));
2873 if (!added.ok) return respondAccess(added.error, 422);
2874 return json(added.value, 201);
2875 });
2876
2877 router.add(
2878 "POST",
2879 "/api/v1/repos/:owner/:name/app/domains/:host/check",
2880 async (ctx) => {
2881 const denied = requireScope(ctx, "admin");
2882 if (denied) return denied;
2883 const repo = loadRepo(ctx);
2884 if (!repo) return notFound();
2885 const checked = await checkAppDomain(
2886 ctx.app,
2887 ctx.auth.user,
2888 repo,
2889 ctx.params.host ?? "",
2890 );
2891 if (!checked.ok) return respondAccess(checked.error, 422);
2892 return json(checked.value);
2893 },
2894 );
2895
2896 router.add(
2897 "DELETE",
2898 "/api/v1/repos/:owner/:name/app/domains/:host",
2899 (ctx) => {
2900 const denied = requireScope(ctx, "admin");
2901 if (denied) return denied;
2902 const repo = loadRepo(ctx);
2903 if (!repo) return notFound();
2904 const removed = removeAppDomain(
2905 ctx.app,
2906 ctx.auth.user,
2907 repo,
2908 ctx.params.host ?? "",
2909 );
2910 if (!removed.ok) return respondAccess(removed.error, 422);
2911 return json({}, 200);
2912 },
2913 );
2914
28282915 // The app's environment: names in, names out, and never a value back.
28292916 // `:var`, not `:name` — `:name` is the repository's.
28302917 router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => {
src/http/server.ts +30
@@ -160,6 +160,9 @@ import {
160160 } from "../web/pages/pull.ts";
161161 import {
162162 appDisable,
163 appDomainAdd,
164 appDomainCheck,
165 appDomainDelete,
163166 appEnvDelete,
164167 appEnvSet,
165168 appSettingsPage,
@@ -275,6 +278,7 @@ import { Router } from "./router.ts";
275278 import { getUserById } from "../core/users.ts";
276279 import { appsRuntime } from "../apps/runtime.ts";
277280 import { appWebSocket, proxyApp, resolveAppHost } from "../apps/proxy.ts";
281 import { getVerifiedDomain } from "../core/domains.ts";
278282
279283 // ---- auth ----
280284
@@ -432,6 +436,20 @@ export function buildRouter(): Router<Handler> {
432436 // fetches a region and not the page. Literal, above /:owner.
433437 router.add("GET", "/live/apps", liveAppsFragment);
434438 router.add("GET", "/live/on-you", liveOnYouFragment);
439 // Whether a hostname is an app's verified domain — what Caddy's
440 // `on_demand_tls { ask … }` asks before it fetches a certificate for a
441 // name it has never seen (docs/EXTRACT.md). 200 says yes, 404 says no,
442 // and nothing else is told: a hostname is not a secret, an app is.
443 // Literal, above /:owner.
444 router.add("GET", "/__forge/domains/ask", (ctx) => {
445 const host = ctx.url.searchParams.get("domain") ?? "";
446 const known =
447 host !== "" && getVerifiedDomain(ctx.app.db, host) !== undefined;
448 return new Response(null, {
449 status: known ? 200 : 404,
450 headers: { "cache-control": "no-store" },
451 });
452 });
435453 router.add("GET", "/runs/:id", runRedirect);
436454 router.add("GET", "/settings", accountPage);
437455 router.add("POST", "/settings", accountUpdate);
@@ -779,6 +797,18 @@ export function buildRouter(): Router<Handler> {
779797 "/:owner/:name/settings/app/env/:var/delete",
780798 appEnvDelete,
781799 );
800 // `:host` is one path segment — dots and all — like `:var` above it.
801 router.add("POST", "/:owner/:name/settings/app/domains", appDomainAdd);
802 router.add(
803 "POST",
804 "/:owner/:name/settings/app/domains/:host/check",
805 appDomainCheck,
806 );
807 router.add(
808 "POST",
809 "/:owner/:name/settings/app/domains/:host/delete",
810 appDomainDelete,
811 );
782812 router.add("GET", "/:owner/:name/settings/crew", crewSettingsPage);
783813 router.add("POST", "/:owner/:name/settings/crew", crewAdd);
784814 router.add(
src/web/pages/apps.ts +2 −2
@@ -38,7 +38,7 @@ import {
3838 } from "../../core/repos.ts";
3939 import type { User } from "../../core/users.ts";
4040 import { safeBack, type Ctx } from "../../http/context.ts";
41 import { resolveAppHostname } from "../../apps/proxy.ts";
41 import { isHostPort, resolveAppHostname } from "../../apps/proxy.ts";
4242 import {
4343 crumbsFor,
4444 loadRepo,
@@ -845,7 +845,7 @@ export function appsOpen(ctx: Ctx): Response {
845845 const wanted = ctx.url.searchParams.get("to") ?? "/";
846846 const path =
847847 wanted.startsWith("/") && !wanted.startsWith("//") ? wanted : "/";
848 if (runtime === undefined || host === "") return notFoundPage(ctx);
848 if (runtime === undefined || !isHostPort(host)) return notFoundPage(ctx);
849849 const target = resolveAppHostname(ctx.app, runtime.options.domain, host);
850850 if (target === undefined || target === "unknown") return notFoundPage(ctx);
851851 if (!ctx.auth.user) return signInFirst(ctx);
src/web/pages/search.ts +12 −1
@@ -402,6 +402,15 @@ const DESTRUCTIVE: {
402402 phrase: "",
403403 }),
404404 },
405 {
406 pattern: /^\/([^/]+)\/([^/]+)\/settings\/app\/domains\/([^/]+)\/delete$/,
407 describe: (m) => ({
408 verb: "Remove domain",
409 title: `Remove ${decodeURIComponent(m[3] ?? "")}?`,
410 detail: `The forge stops answering there at once; links to it stop working until the name is added and verified again. ${m[1]}/${m[2]} keeps its own hostname.`,
411 phrase: "",
412 }),
413 },
405414 {
406415 pattern: /^\/([^/]+)\/([^/]+)\/settings\/labels\/([^/]+)\/delete$/,
407416 describe: (m) => ({
@@ -488,7 +497,9 @@ const DESTRUCTIVE: {
488497 // this page never becomes a way to read a private pull's title.
489498 const repo = getRepo(ctx.app.db, m[1]!, m[2]!);
490499 const writer = repo && canWrite(ctx.app.db, repo, ctx.auth.user);
491 const pull = writer ? getPull(ctx.app.db, repo.id, Number(m[3])) : undefined;
500 const pull = writer
501 ? getPull(ctx.app.db, repo.id, Number(m[3]))
502 : undefined;
492503 const name = pull ? `"${pull.title}"` : `change #${m[3]}`;
493504 return {
494505 verb: "Take it back",
src/web/pages/settings.ts +212
@@ -15,6 +15,14 @@ import {
1515 setAppEnvVar,
1616 updateAppSettings,
1717 } from "../../actions/apps.ts";
18 import {
19 addAppDomain,
20 checkAppDomain,
21 listAppDomains,
22 removeAppDomain,
23 type AppDomainView,
24 } from "../../actions/domains.ts";
25 import type { DnsRecord } from "../../apps/domains.ts";
1826 import {
1927 listAppEnvNames,
2028 NAME_MAX,
@@ -76,6 +84,7 @@ import { icon } from "../icons.ts";
7684 import { flashTo, page } from "../layout.ts";
7785 import type { Flash } from "../flash.ts";
7886 import {
87 badge,
7988 btn,
8089 card,
8190 confirmAction,
@@ -442,10 +451,14 @@ function appSettingsView(
442451 },
443452 );
444453 if (record === undefined) return form;
454 const domains = listAppDomains(ctx.app, ctx.auth.user, repo);
445455 return html`${form}
446456 <div class="mt">
447457 ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))}
448458 </div>
459 <div class="mt">
460 ${appDomainsView(base, domains.ok ? domains.value : undefined)}
461 </div>
449462 <div class="mt">
450463 ${card(
451464 html`<p>
@@ -553,6 +566,117 @@ function appEnvView(base: string, names: AppEnvName[]): Html {
553566 );
554567 }
555568
569 /**
570 * The app's own domains: each with what the last check said, the records
571 * to publish while it waits, and a form for one more. The forge serves a
572 * domain the moment it is verified; the app itself hears its new address
573 * at its next deploy, because a process reads its URL once, at start.
574 * `undefined` is a forge where nothing serves apps: a domain would have
575 * nowhere to point, so the card says so and offers no form.
576 */
577 function appDomainsView(
578 base: string,
579 domains: AppDomainView[] | undefined,
580 ): Html {
581 const at = (host: string) =>
582 `${base}/settings/app/domains/${encodeURIComponent(host)}`;
583 const rows = (domains ?? []).map(
584 (d) =>
585 html`<li>
586 <div class="row">
587 <span class="row-lead">${icon("globe")}</span>
588 <span class="row-main">
589 <span class="row-title"
590 ><span class="mono">${d.host}</span> ${d.verified
591 ? badge("live", { tone: "success" })
592 : badge("waiting for DNS", { tone: "attn" })}</span
593 >
594 <span class="row-meta" title="${d.said}"
595 >${d.said === "" ? "Not checked yet." : d.said}
596 ${d.checkedAt === null
597 ? null
598 : html` · checked ${time(d.checkedAt)}`}</span
599 >
600 ${when(!d.verified, () => recordsToPublish(d.records))}
601 </span>
602 <span class="row-trail">
603 <form method="post" action="${at(d.host)}/check">
604 ${btn("Check now", { size: "sm", icon: "globe" })}
605 </form>
606 ${confirmAction({
607 action: `${at(d.host)}/delete`,
608 verb: "Remove",
609 size: "sm",
610 })}
611 </span>
612 </div>
613 </li>`,
614 );
615 return card(
616 html`<div class="card-body">
617 <p class="muted small">
618 Serve the app at a name of your own —
619 <span class="mono">app.example.com</span>, or an apex. Add it, publish
620 the two records it shows, and press Check now.
621 </p>
622 </div>
623 ${when(
624 rows.length > 0,
625 () =>
626 html`<ul class="list">
627 ${rows}
628 </ul>`,
629 )}
630 ${domains === undefined
631 ? html`<div class="card-body">
632 <p class="muted small">
633 Nothing is serving apps on this forge, so a domain would have
634 nowhere to point.
635 </p>
636 </div>`
637 : html`<div class="card-body">
638 <p class="muted small">
639 The forge serves the app there as soon as the record is
640 verified. The app itself learns its new address at its next
641 deploy.
642 </p>
643 </div>
644 <form
645 class="edit-row"
646 method="post"
647 action="${base}/settings/app/domains"
648 >
649 <input
650 class="input mono"
651 type="text"
652 name="host"
653 placeholder="app.example.com"
654 aria-label="Domain"
655 autocomplete="off"
656 spellcheck="false"
657 required
658 />
659 ${btn("Add", { variant: "primary", size: "sm", icon: "plus" })}
660 </form>`}`,
661 {
662 head: html`${icon("globe")}
663 <h2>Domains</h2>`,
664 pad: false,
665 },
666 );
667 }
668
669 /** The records as a person copies them into a zone: type, name, value. */
670 function recordsToPublish(records: DnsRecord[]): Html {
671 const width = Math.max(...records.map((r) => r.name.length)) + 2;
672 const lines = records
673 .map((r) => `${r.type.padEnd(6)} ${r.name.padEnd(width)} ${r.value}`)
674 .join("\n");
675 const notes = records.filter((r) => r.note !== undefined);
676 return html`<pre class="domain-records">${lines}</pre>
677 ${notes.map((r) => html`<span class="row-meta">${r.note}</span>`)}`;
678 }
679
556680 const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({
557681 slug: record?.slug ?? slugFor(repo.name),
558682 access: record?.access ?? repo.visibility,
@@ -702,6 +826,94 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> {
702826 );
703827 }
704828
829 const APP_DOMAIN_ERRORS: Record<string, string> = {
830 invalid_host:
831 "A domain is a hostname: letters, digits and hyphens in at least two labels, like app.example.com.",
832 reserved_host: "That name is the forge's own to hand out.",
833 taken: "Another app already answers to that domain.",
834 no_runtime: "Nothing is serving apps on this forge.",
835 };
836
837 /** What the records say, as one flash: type, name and value, in words. */
838 function recordsInWords(records: DnsRecord[]): string {
839 return records
840 .map((r) =>
841 r.type === "TXT"
842 ? `a TXT record at ${r.name} with the value ${r.value}`
843 : r.type === "CNAME"
844 ? `a CNAME from ${r.name} to ${r.value}`
845 : `an ALIAS from ${r.name} to ${r.value}`,
846 )
847 .join(" and ");
848 }
849
850 // POST /:owner/:name/settings/app/domains — add one.
851 export async function appDomainAdd(ctx: Ctx): Promise<Response> {
852 const gate = await adminForm(ctx);
853 if (gate instanceof Response) return gate;
854 const { repo, form } = gate;
855 const added = addAppDomain(ctx.app, ctx.auth.user, repo, field(form, "host"));
856 if (!added.ok && added.error === "not_found") return repoNotFound(ctx);
857 return flashTo(
858 ctx,
859 `${repoBase(repo)}/settings/app`,
860 added.ok
861 ? {
862 message: `${added.value.host} is added. Publish ${recordsInWords(added.value.records)}, then press Check now.`,
863 }
864 : {
865 tone: "danger",
866 message:
867 APP_DOMAIN_ERRORS[added.error] ?? "That domain could not be added.",
868 },
869 );
870 }
871
872 // POST /:owner/:name/settings/app/domains/:host/check — look the proof up.
873 export async function appDomainCheck(ctx: Ctx): Promise<Response> {
874 const gate = await adminForm(ctx);
875 if (gate instanceof Response) return gate;
876 const { repo } = gate;
877 const checked = await checkAppDomain(
878 ctx.app,
879 ctx.auth.user,
880 repo,
881 ctx.params.host ?? "",
882 );
883 if (!checked.ok && checked.error === "not_found") return repoNotFound(ctx);
884 return flashTo(
885 ctx,
886 `${repoBase(repo)}/settings/app`,
887 checked.ok
888 ? {
889 ...(checked.value.verified ? {} : { tone: "attn" as const }),
890 message: checked.value.said,
891 }
892 : {
893 tone: "danger",
894 message:
895 APP_DOMAIN_ERRORS[checked.error] ?? "No domain by that name.",
896 },
897 );
898 }
899
900 // POST /:owner/:name/settings/app/domains/:host/delete
901 export async function appDomainDelete(ctx: Ctx): Promise<Response> {
902 const gate = await adminForm(ctx);
903 if (gate instanceof Response) return gate;
904 const { repo } = gate;
905 const host = ctx.params.host ?? "";
906 const removed = removeAppDomain(ctx.app, ctx.auth.user, repo, host);
907 if (!removed.ok && removed.error === "not_found") return repoNotFound(ctx);
908 return flashTo(
909 ctx,
910 `${repoBase(repo)}/settings/app`,
911 removed.ok
912 ? { message: `${host} is removed. The forge no longer answers there.` }
913 : { tone: "danger", message: "No domain by that name." },
914 );
915 }
916
705917 /** What a change to the environment sets in motion — when anything serves. */
706918 function redeploying(ctx: Ctx, how: string): string {
707919 return appsRuntime(ctx.app) === undefined
src/web/style/content.ts +2
@@ -297,6 +297,8 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
297297 .backups-now { margin-top: 12px; }
298298 .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); }
299299 .backups-restore { margin: 8px 0 0; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); }
300 /* The records a domain waits on: type, name, value — as a zone file reads. */
301 .domain-records { margin: 6px 0 2px; padding: 8px 10px; font-size: 12px; line-height: 1.6; white-space: pre; overflow-x: auto; scrollbar-width: thin; background: var(--muted); border-radius: var(--radius); }
300302 .policy-line {
301303 display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
302304 margin: 12px 0 0; font-size: 13px; color: var(--muted-fg);
test/app-domains.test.ts added +706
@@ -0,0 +1,706 @@
1 /**
2 * An app gets a domain of its own (docs/APPS.md, "Your own domain"): the
3 * names it may take, the two records to publish, what a check says, and
4 * the moment the forge starts answering there — with a private app's
5 * sign-in landing on the new host. DNS is a stand-in the test controls,
6 * so nothing here touches the network.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
15 import {
16 addAppDomain,
17 checkAppDomain,
18 removeAppDomain,
19 } from "../src/actions/domains.ts";
20 import { createRepo } from "../src/actions/repos.ts";
21 import {
22 checkDomain,
23 recordsFor,
24 type DomainResolver,
25 } from "../src/apps/domains.ts";
26 import { AppsRuntime } from "../src/apps/runtime.ts";
27 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
28 import { addDomain, getDomain, TOKEN_LENGTH } from "../src/core/domains.ts";
29 import { listEvents } from "../src/core/events.ts";
30 import type { Repo } from "../src/core/repos.ts";
31 import { listRuns, type Run } from "../src/core/runs.ts";
32 import { createToken } from "../src/core/tokens.ts";
33 import { createUser, type User } from "../src/core/users.ts";
34 import { serve, type Servers } from "../src/http/server.ts";
35 import { quietLogs } from "./helpers/globals.ts";
36
37 quietLogs();
38
39 const DOMAIN = "apps.test";
40 const HOST = "app.example.com";
41 const PROOF = `_forge-verify.${HOST}`;
42
43 /** A resolver the test writes the zone of; anything unwritten does not exist. */
44 class FakeDns implements DomainResolver {
45 readonly txt = new Map<string, string[][]>();
46 readonly cname = new Map<string, string[]>();
47 resolveTxt(name: string): Promise<string[][]> {
48 return this.answer(this.txt.get(name));
49 }
50 resolveCname(name: string): Promise<string[]> {
51 return this.answer(this.cname.get(name));
52 }
53 private answer<T>(found: T | undefined): Promise<T> {
54 return found === undefined
55 ? Promise.reject(Object.assign(new Error("no data"), { code: "ENODATA" }))
56 : Promise.resolve(found);
57 }
58 }
59
60 /** A page with its whitespace collapsed, for asserting on a sentence. */
61 const words = (html: string): string => html.replace(/\s+/g, " ");
62
63 describe("checkDomain: every outcome is a sentence", () => {
64 const dns = new FakeDns();
65 const token = "abcdefghjkmnpqrstvwxyz01";
66
67 test("nothing published, another value, the proof, and where the name points", async () => {
68 expect(await checkDomain(HOST, token, "notes.apps.test", dns)).toEqual({
69 verified: false,
70 said: `No TXT record at ${PROOF} yet.`,
71 });
72 dns.txt.set(PROOF, [["forge-verify=somethingelse"]]);
73 expect(await checkDomain(HOST, token, "notes.apps.test", dns)).toEqual({
74 verified: false,
75 said: "The TXT record is there but holds a different value.",
76 });
77 // The right value beside a wrong one is still the proof; a record split
78 // into chunks is read whole.
79 dns.txt.set(PROOF, [
80 ["forge-verify=somethingelse"],
81 ["forge-", `verify=${token}`],
82 ]);
83 expect(await checkDomain(HOST, token, "notes.apps.test", dns)).toEqual({
84 verified: true,
85 said: `Verified; ${HOST} has no CNAME yet.`,
86 });
87 dns.cname.set(HOST, ["Notes.Apps.Test."]);
88 expect(await checkDomain(HOST, token, "notes.apps.test", dns)).toEqual({
89 verified: true,
90 said: `Verified; ${HOST} points at notes.apps.test.`,
91 });
92 dns.cname.set(HOST, ["elsewhere.example"]);
93 expect(await checkDomain(HOST, token, "notes.apps.test", dns)).toEqual({
94 verified: true,
95 said: `Verified; ${HOST} points at elsewhere.example, not notes.apps.test.`,
96 });
97 });
98
99 test("a resolver that will not answer is a calm sentence, never a throw", async () => {
100 const broken: DomainResolver = {
101 resolveTxt: () =>
102 Promise.reject(
103 Object.assign(new Error("servfail"), { code: "ESERVFAIL" }),
104 ),
105 resolveCname: () => Promise.reject(new Error("boom")),
106 };
107 expect(await checkDomain(HOST, token, "notes.apps.test", broken)).toEqual({
108 verified: false,
109 said: `The DNS lookup for ${PROOF} failed (ESERVFAIL); try again in a minute.`,
110 });
111 const cnameOnlyBroken: DomainResolver = {
112 resolveTxt: () => Promise.resolve([[`forge-verify=${token}`]]),
113 resolveCname: () => Promise.reject(new Error("boom")),
114 };
115 expect(
116 await checkDomain(HOST, token, "notes.apps.test", cnameOnlyBroken),
117 ).toEqual({
118 verified: true,
119 said: `Verified; where ${HOST} points could not be looked up.`,
120 });
121 });
122
123 test("an apex gets an ALIAS line instead of a CNAME, and is told why", () => {
124 expect(recordsFor(HOST, token, "notes.apps.test")).toEqual([
125 { type: "TXT", name: PROOF, value: `forge-verify=${token}` },
126 { type: "CNAME", name: HOST, value: "notes.apps.test" },
127 ]);
128 const apex = recordsFor("example.com", token, "notes.apps.test");
129 expect(apex[1]?.type).toBe("ALIAS");
130 expect(apex[1]?.value).toBe("notes.apps.test");
131 expect(apex[1]?.note).toContain("cannot carry a CNAME");
132 });
133 });
134
135 describe("an app gets its own domain", () => {
136 let dataDir: string;
137 let app: App;
138 let servers: Servers;
139 let runtime: AppsRuntime;
140 let alice: User;
141 let bob: User;
142 let repo: Repo;
143 let record: AppRecord;
144 let other: AppRecord;
145 let otherRepo: Repo;
146 let aliceCookie = "";
147 let bobCookie = "";
148 let adminToken = "";
149 let rwToken = "";
150 let bobToken = "";
151 const dns = new FakeDns();
152
153 const viaHost = (host: string, path = "/", init: RequestInit = {}) =>
154 fetch(`${servers.url}${path}`, {
155 ...init,
156 headers: { host, ...(init.headers as Record<string, string>) },
157 redirect: "manual",
158 });
159
160 /** A browser's request to the forge itself: a cookie, and a same-origin form. */
161 const web = (
162 cookie: string,
163 path: string,
164 init: RequestInit & { form?: Record<string, string>; origin?: string } = {},
165 ): Promise<Response> => {
166 const headers = new Headers(init.headers);
167 if (cookie !== "") headers.set("cookie", cookie);
168 const method = init.form !== undefined ? "POST" : (init.method ?? "GET");
169 if (init.form !== undefined)
170 headers.set("origin", init.origin ?? new URL(servers.url).origin);
171 return fetch(`${servers.url}${path}`, {
172 ...init,
173 method,
174 headers,
175 redirect: "manual",
176 ...(init.form === undefined
177 ? {}
178 : { body: new URLSearchParams(init.form) }),
179 });
180 };
181 const flashOf = (response: Response): string =>
182 decodeURIComponent(response.headers.get("set-cookie") ?? "");
183
184 const api = (
185 path: string,
186 init: RequestInit & { token?: string | null } = {},
187 ) => {
188 const headers = new Headers(init.headers);
189 if (typeof init.body === "string")
190 headers.set("content-type", "application/json");
191 const token = init.token === undefined ? adminToken : init.token;
192 if (token) headers.set("authorization", `Bearer ${token}`);
193 return fetch(`${servers.url}${path}`, { ...init, headers });
194 };
195 const DOMAINS = "/api/v1/repos/alice/notes/app/domains";
196
197 const signIn = async (username: string): Promise<string> => {
198 const response = await fetch(`${servers.url}/signin`, {
199 method: "POST",
200 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
201 redirect: "manual",
202 });
203 return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
204 };
205
206 async function until<T>(read: () => T | undefined, ms = 60_000): Promise<T> {
207 const deadline = Date.now() + ms;
208 while (Date.now() < deadline) {
209 const value = read();
210 if (value !== undefined) return value;
211 await Bun.sleep(100);
212 }
213 throw new Error("did not happen in time");
214 }
215 const settled = (where: (run: Run) => boolean) =>
216 until(() =>
217 listRuns(app.db, repo.id, { kind: "deploy", limit: 50 }).find(
218 (r) => where(r) && (r.state === "ok" || r.state === "failed"),
219 ),
220 );
221 const kinds = (prefix: string): string[] =>
222 listEvents(app.db, { repoId: repo.id, limit: 100 })
223 .map((e) => e.kind)
224 .filter((k) => k.startsWith(prefix));
225
226 beforeAll(async () => {
227 dataDir = await mkdtemp(join(tmpdir(), "forge-domains-"));
228 // The forge's own hostname is `forge.test`: the one name no app may take.
229 app = createApp({ dataDir, port: 0, host: "forge.test" });
230 servers = await serve(app);
231 runtime = new AppsRuntime(app, {
232 domain: DOMAIN,
233 scheme: "http",
234 publicPort: servers.port,
235 forgeUrl: servers.url,
236 concurrency: 1,
237 dns,
238 });
239 await runtime.start();
240 const a = await createUser(app.db, {
241 username: "alice",
242 email: "[email protected]",
243 password: "a-good-passphrase",
244 adminIfFirst: true,
245 });
246 if (!a.ok) throw new Error(a.error);
247 alice = a.value;
248 const b = await createUser(app.db, {
249 username: "bob",
250 email: "[email protected]",
251 password: "a-good-passphrase",
252 });
253 if (!b.ok) throw new Error(b.error);
254 bob = b.value;
255 aliceCookie = await signIn("alice");
256 bobCookie = await signIn("bob");
257 adminToken = createToken(app.db, alice.id, "admin", "admin").token;
258 rwToken = createToken(app.db, alice.id, "rw", "rw").token;
259 bobToken = createToken(app.db, bob.id, "admin", "admin").token;
260
261 const made = await newApp(app, alice, {
262 name: "notes",
263 description: "A place for notes",
264 visibility: "public",
265 });
266 if (!made.ok) throw new Error(String(made.error));
267 repo = made.value.repo;
268 record = made.value.app;
269 // A second app, a row only: enough to contest a name.
270 const plain = await createRepo(app, alice, {
271 name: "other",
272 visibility: "public",
273 description: "",
274 });
275 if (!plain.ok) throw new Error(String(plain.error));
276 otherRepo = plain.value;
277 const row = insertApp(app.db, {
278 repoId: otherRepo.id,
279 slug: "other",
280 access: "public",
281 });
282 if (!row.ok) throw new Error(row.error);
283 other = row.value;
284 const run = await settled((r) => r.env === "production");
285 expect(run.state).toBe("ok");
286 expect((await viaHost(`notes.${DOMAIN}`)).status).toBe(200);
287 }, 120_000);
288
289 afterAll(async () => {
290 await runtime.stop();
291 servers.stop();
292 app.close();
293 await rm(dataDir, { recursive: true, force: true });
294 });
295
296 test("what a domain may be: a hostname someone can own, and not the forge's", () => {
297 const add = (host: string) => addAppDomain(app, alice, repo, host);
298 for (const bad of ["bad_host", "-x.example.com", "1.2.3.4", "example"]) {
299 const refused = add(bad);
300 expect(refused.ok).toBe(false);
301 if (!refused.ok)
302 expect(`${bad}: ${refused.error}`).toBe(`${bad}: invalid_host`);
303 }
304 for (const ours of ["forge.test", `x.${DOMAIN}`, `a.b.${DOMAIN}`, DOMAIN]) {
305 const refused = add(ours);
306 expect(refused.ok).toBe(false);
307 if (!refused.ok)
308 expect(`${ours}: ${refused.error}`).toBe(`${ours}: reserved_host`);
309 }
310 // A forge whose apps hang off a single label (`--forge.example`)
311 // reserves that whole shape, and the zone beneath it.
312 const single = {
313 forgeHost: "forge.example",
314 appHostSuffix: "--forge.example",
315 };
316 for (const ours of [
317 "notes--forge.example",
318 "forge.example",
319 "a.forge.example",
320 ]) {
321 const refused = addDomain(
322 app.db,
323 { host: ours, appId: record.id },
324 single,
325 );
326 expect(refused.ok).toBe(false);
327 if (!refused.ok)
328 expect(`${ours}: ${refused.error}`).toBe(`${ours}: reserved_host`);
329 }
330 // Nothing above left a row behind.
331 expect(getDomain(app.db, "forge.example")).toBeUndefined();
332 });
333
334 test("adding a domain answers with the two records to publish", () => {
335 const added = addAppDomain(app, alice, repo, ` App.Example.Com. `);
336 expect(added.ok).toBe(true);
337 if (!added.ok) return;
338 expect(added.value.host).toBe(HOST);
339 expect(added.value.verified).toBe(false);
340 expect(added.value.said).toBe("");
341 const token = getDomain(app.db, HOST)!.token;
342 expect(token).toMatch(new RegExp(`^[0-9a-hjkmnp-tv-z]{${TOKEN_LENGTH}}$`));
343 expect(added.value.records).toEqual([
344 { type: "TXT", name: PROOF, value: `forge-verify=${token}` },
345 { type: "CNAME", name: HOST, value: `notes.${DOMAIN}` },
346 ]);
347 expect(kinds("app.domain.")).toEqual(["app.domain.added"]);
348
349 // One host, one app: the same name for another app is taken, however
350 // it is spelled.
351 const contested = addAppDomain(app, alice, otherRepo, "APP.example.com");
352 expect(contested.ok).toBe(false);
353 if (!contested.ok) expect(contested.error).toBe("taken");
354 expect(other.id).not.toBe(record.id);
355
356 // An apex is told to use an ALIAS.
357 const apex = addAppDomain(app, alice, repo, "example.com");
358 expect(apex.ok).toBe(true);
359 if (apex.ok) expect(apex.value.records[1]?.type).toBe("ALIAS");
360 });
361
362 test("before verification the forge answers for itself at the host", async () => {
363 const page = await viaHost(HOST, "/");
364 expect(page.status).toBe(200);
365 expect(await page.text()).not.toContain("<h1>notes</h1>");
366 expect(
367 (await viaHost("forge.test", `/__forge/domains/ask?domain=${HOST}`))
368 .status,
369 ).toBe(404);
370 expect(runtime.urlFor(record, "production")).toBe(
371 `http://notes.${DOMAIN}:${servers.port}`,
372 );
373 });
374
375 test("a check says what it found; the proof verifies the domain", async () => {
376 const first = await checkAppDomain(app, alice, repo, HOST);
377 expect(first.ok).toBe(true);
378 if (!first.ok) return;
379 expect(first.value.verified).toBe(false);
380 expect(first.value.said).toBe(`No TXT record at ${PROOF} yet.`);
381 expect(first.value.checkedAt).not.toBeNull();
382
383 dns.txt.set(PROOF, [["forge-verify=not-this-one"]]);
384 const wrong = await checkAppDomain(app, alice, repo, HOST);
385 expect(wrong.ok && wrong.value.said).toBe(
386 "The TXT record is there but holds a different value.",
387 );
388 expect(kinds("app.domain.verified")).toEqual([]);
389
390 const token = getDomain(app.db, HOST)!.token;
391 dns.txt.set(PROOF, [[`forge-verify=${token}`]]);
392 dns.cname.set(HOST, [`notes.${DOMAIN}`]);
393 const right = await checkAppDomain(app, alice, repo, HOST);
394 expect(right.ok).toBe(true);
395 if (!right.ok) return;
396 expect(right.value.verified).toBe(true);
397 expect(right.value.verifiedAt).not.toBeNull();
398 expect(right.value.said).toBe(
399 `Verified; ${HOST} points at notes.${DOMAIN}.`,
400 );
401 expect(kinds("app.domain.verified")).toEqual(["app.domain.verified"]);
402 // Verified once is verified: a later check leaves its sentence and
403 // says so a second time to nobody.
404 dns.txt.delete(PROOF);
405 const later = await checkAppDomain(app, alice, repo, HOST);
406 expect(later.ok && later.value.verified).toBe(true);
407 expect(later.ok && later.value.said).toBe(`No TXT record at ${PROOF} yet.`);
408 expect(kinds("app.domain.verified")).toEqual(["app.domain.verified"]);
409 dns.txt.set(PROOF, [[`forge-verify=${token}`]]);
410
411 // A host that is not this app's is nobody's to check.
412 const stranger = await checkAppDomain(app, alice, otherRepo, HOST);
413 expect(stranger.ok).toBe(false);
414 if (!stranger.ok) expect(stranger.error).toBe("no_such_domain");
415 });
416
417 test("verified, the forge serves the app at the host and every URL follows", async () => {
418 const page = await viaHost(HOST, "/");
419 expect(page.status).toBe(200);
420 expect(await page.text()).toContain("<h1>notes</h1>");
421 // The hostname the forge gave it keeps working beside the domain.
422 expect(await (await viaHost(`notes.${DOMAIN}`)).text()).toContain(
423 "<h1>notes</h1>",
424 );
425 expect(runtime.urlFor(record, "production")).toBe(`http://${HOST}`);
426 expect(runtime.urlFor(record, "pull-3")).toBe(
427 `http://notes--3.${DOMAIN}:${servers.port}`,
428 );
429 expect(runtime.hostFor(record.slug, "production")).toBe(`notes.${DOMAIN}`);
430 const json = (await (
431 await api("/api/v1/repos/alice/notes/app", { token: null })
432 ).json()) as { url: string; domains: string[] };
433 expect(json.url).toBe(`http://${HOST}`);
434 expect(json.domains).toEqual([HOST]);
435
436 // Caddy's question: is this name anyone's?
437 const ask = (domain: string) =>
438 viaHost("forge.test", `/__forge/domains/ask?domain=${domain}`);
439 const yes = await ask(HOST);
440 expect(yes.status).toBe(200);
441 expect(await yes.text()).toBe("");
442 expect((await ask("APP.EXAMPLE.COM.")).status).toBe(200);
443 expect((await ask("example.com")).status).toBe(404);
444 expect((await ask("nope.example.com")).status).toBe(404);
445 expect((await viaHost("forge.test", "/__forge/domains/ask")).status).toBe(
446 404,
447 );
448 });
449
450 test("a private app's sign-in lands on the new host", async () => {
451 expect(updateAppSettings(app, alice, repo, { access: "private" }).ok).toBe(
452 true,
453 );
454 // A person arriving at the domain is sent to the forge to be vouched for.
455 const arrived = await viaHost(HOST, "/", {
456 headers: { accept: "text/html,*/*" },
457 });
458 expect(arrived.status).toBe(303);
459 const open = new URL(arrived.headers.get("location") ?? "");
460 expect(`${open.origin}${open.pathname}`).toBe(`${servers.url}/apps/open`);
461 expect(open.searchParams.get("host")).toBe(HOST);
462 // A script gets the flat answer.
463 expect((await viaHost(HOST, "/")).status).toBe(404);
464
465 // Signed in, alice is sent back to the domain with a grant; following
466 // it sets the cookie for that host, and the page opens.
467 const vouched = await web(aliceCookie, `${open.pathname}${open.search}`);
468 expect(vouched.status).toBe(302);
469 const grant = new URL(vouched.headers.get("location") ?? "");
470 expect(grant.origin).toBe(`http://${HOST}`);
471 expect(grant.pathname).toBe("/__forge/grant");
472 const granted = await viaHost(HOST, `${grant.pathname}${grant.search}`);
473 expect(granted.status).toBe(303);
474 const cookie = granted.headers.get("set-cookie") ?? "";
475 expect(cookie).toContain("forge_grant_");
476 const greeted = await viaHost(HOST, "/", {
477 headers: { cookie: cookie.split(";")[0]! },
478 });
479 expect(greeted.status).toBe(200);
480 expect(await greeted.text()).toContain("<b>alice</b>");
481
482 // Someone at the app's own hostname is granted there, port and all,
483 // not sent across to the domain.
484 const canonical = await web(
485 aliceCookie,
486 `/apps/open?host=${encodeURIComponent(`notes.${DOMAIN}:${servers.port}`)}&to=/`,
487 );
488 expect(canonical.status).toBe(302);
489 expect(new URL(canonical.headers.get("location") ?? "").host).toBe(
490 `notes.${DOMAIN}:${servers.port}`,
491 );
492 // A stranger to the repository gets what a private repository gives.
493 expect(
494 (await web(bobCookie, `${open.pathname}${open.search}`)).status,
495 ).toBe(302);
496 expect(updateAppSettings(app, alice, repo, { access: "public" }).ok).toBe(
497 true,
498 );
499 });
500
501 test("the settings page: the card, the records, the badge, and the doors", async () => {
502 const settings = "/alice/notes/settings/app";
503 const page = words(await (await web(aliceCookie, settings)).text());
504 expect(page).toContain("Domains");
505 expect(page).toContain(HOST);
506 expect(page).toContain(">live<");
507 // The apex waits, and shows what to publish.
508 expect(page).toContain("waiting for DNS");
509 expect(page).toContain("_forge-verify.example.com");
510 expect(page).toContain(
511 `forge-verify=${getDomain(app.db, "example.com")!.token}`,
512 );
513 expect(page).toContain("ALIAS");
514 expect(page).toContain(`${settings}/domains/example.com/check`);
515 expect(page).toContain(
516 encodeURIComponent(`${settings}/domains/example.com/delete`),
517 );
518 expect(page).toContain("learns its new address at its next deploy");
519
520 // Adding from the form flashes the records.
521 const added = await web(aliceCookie, `${settings}/domains`, {
522 form: { host: "staging.example.com" },
523 });
524 expect(added.status).toBe(303);
525 expect(added.headers.get("location")).toBe(settings);
526 expect(flashOf(added)).toContain("staging.example.com is added");
527 expect(flashOf(added)).toContain("_forge-verify.staging.example.com");
528 expect(flashOf(added)).toContain(
529 `CNAME from staging.example.com to notes.${DOMAIN}`,
530 );
531 const refused = await web(aliceCookie, `${settings}/domains`, {
532 form: { host: "not a host" },
533 });
534 expect(flashOf(refused)).toContain("A domain is a hostname");
535
536 // Check now says what it found.
537 const checked = await web(
538 aliceCookie,
539 `${settings}/domains/staging.example.com/check`,
540 { form: {} },
541 );
542 expect(checked.status).toBe(303);
543 expect(flashOf(checked)).toContain(
544 "No TXT record at _forge-verify.staging.example.com yet.",
545 );
546 expect(words(await (await web(aliceCookie, settings)).text())).toContain(
547 "No TXT record at _forge-verify.staging.example.com yet.",
548 );
549
550 // The confirm page knows this door.
551 const confirm = await web(
552 aliceCookie,
553 `/-/confirm?to=${encodeURIComponent(`${settings}/domains/staging.example.com/delete`)}`,
554 );
555 expect(confirm.status).toBe(200);
556 expect(await confirm.text()).toContain("Remove staging.example.com?");
557
558 // Not for a non-admin — the one 404 — and not from another origin.
559 expect((await web(bobCookie, settings)).status).toBe(404);
560 expect(
561 (
562 await web(bobCookie, `${settings}/domains`, {
563 form: { host: "stolen.example.com" },
564 })
565 ).status,
566 ).toBe(404);
567 expect(
568 (
569 await web(bobCookie, `${settings}/domains/staging.example.com/delete`, {
570 form: {},
571 })
572 ).status,
573 ).toBe(404);
574 expect(
575 (
576 await web(aliceCookie, `${settings}/domains`, {
577 form: { host: "stolen.example.com" },
578 origin: "https://evil.example",
579 })
580 ).status,
581 ).toBe(403);
582 expect(getDomain(app.db, "stolen.example.com")).toBeUndefined();
583 expect(getDomain(app.db, "staging.example.com")).toBeDefined();
584
585 const removed = await web(
586 aliceCookie,
587 `${settings}/domains/staging.example.com/delete`,
588 { form: {} },
589 );
590 expect(removed.status).toBe(303);
591 expect(flashOf(removed)).toContain("staging.example.com is removed");
592 expect(getDomain(app.db, "staging.example.com")).toBeUndefined();
593 const again = await web(
594 aliceCookie,
595 `${settings}/domains/staging.example.com/delete`,
596 { form: {} },
597 );
598 expect(flashOf(again)).toContain("No domain by that name");
599 });
600
601 test("the API: admin scope and admin over the repository, like the environment", async () => {
602 const listed = (await (await api(DOMAINS)).json()) as {
603 items: { host: string; verified: boolean; records: unknown[] }[];
604 };
605 expect(listed.items.map((d) => [d.host, d.verified])).toEqual([
606 [HOST, true],
607 ["example.com", false],
608 ]);
609 expect(listed.items[0]?.records).toHaveLength(2);
610
611 const added = await api(DOMAINS, {
612 method: "POST",
613 body: JSON.stringify({ host: "api.example.com" }),
614 });
615 expect(added.status).toBe(201);
616 const item = (await added.json()) as {
617 host: string;
618 verified: boolean;
619 records: { type: string; name: string }[];
620 };
621 expect(item.host).toBe("api.example.com");
622 expect(item.verified).toBe(false);
623 expect(item.records.map((r) => r.type)).toEqual(["TXT", "CNAME"]);
624 const refusals: [string, string][] = [
625 ["bad_host", "invalid_host"],
626 [`x.${DOMAIN}`, "reserved_host"],
627 [HOST, "taken"],
628 ];
629 for (const [host, error] of refusals) {
630 const refused = await api(DOMAINS, {
631 method: "POST",
632 body: JSON.stringify({ host }),
633 });
634 expect(refused.status).toBe(422);
635 expect(((await refused.json()) as { error: string }).error).toBe(error);
636 }
637
638 const checked = await api(`${DOMAINS}/api.example.com/check`, {
639 method: "POST",
640 });
641 expect(checked.status).toBe(200);
642 expect(((await checked.json()) as { said: string }).said).toBe(
643 "No TXT record at _forge-verify.api.example.com yet.",
644 );
645 expect(
646 (await api(`${DOMAINS}/nobody.example.com/check`, { method: "POST" }))
647 .status,
648 ).toBe(422);
649
650 // The floors: scope, then admin over the repository — a stranger with
651 // an admin token is not told the app has domains at all.
652 expect((await api(DOMAINS, { token: rwToken })).status).toBe(403);
653 expect((await api(DOMAINS, { token: null })).status).toBe(401);
654 expect((await api(DOMAINS, { token: bobToken })).status).toBe(404);
655 expect(
656 (
657 await api(DOMAINS, {
658 method: "POST",
659 body: JSON.stringify({ host: "stolen.example.com" }),
660 token: bobToken,
661 })
662 ).status,
663 ).toBe(404);
664 expect(
665 (
666 await api(`${DOMAINS}/api.example.com`, {
667 method: "DELETE",
668 token: rwToken,
669 })
670 ).status,
671 ).toBe(403);
672 expect(getDomain(app.db, "api.example.com")).toBeDefined();
673
674 const removed = await api(`${DOMAINS}/api.example.com`, {
675 method: "DELETE",
676 });
677 expect(removed.status).toBe(200);
678 expect(await removed.json()).toEqual({});
679 expect(getDomain(app.db, "api.example.com")).toBeUndefined();
680 });
681
682 test("removal makes the host unknown again", async () => {
683 const removed = removeAppDomain(app, alice, repo, HOST);
684 expect(removed.ok).toBe(true);
685 // Newest first: the removal, after everything the page and the API did.
686 expect(kinds("app.domain.")[0]).toBe("app.domain.removed");
687 expect(kinds("app.domain.removed")).toHaveLength(3);
688 const page = await viaHost(HOST, "/");
689 expect(page.status).toBe(200);
690 expect(await page.text()).not.toContain("<h1>notes</h1>");
691 expect(
692 (await viaHost("forge.test", `/__forge/domains/ask?domain=${HOST}`))
693 .status,
694 ).toBe(404);
695 expect(runtime.urlFor(record, "production")).toBe(
696 `http://notes.${DOMAIN}:${servers.port}`,
697 );
698 // The name is free again — for anyone, with a new token.
699 const oldToken = getDomain(app.db, "example.com")!.token;
700 const reclaimed = addAppDomain(app, alice, otherRepo, HOST);
701 expect(reclaimed.ok).toBe(true);
702 expect(getDomain(app.db, HOST)!.appId).toBe(other.id);
703 expect(getDomain(app.db, HOST)!.token).not.toBe(oldToken);
704 expect(removeAppDomain(app, alice, repo, HOST).ok).toBe(false);
705 });
706 });
test/apps-open-host.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * `/apps/open?host=` sends a signed grant to the host it names. That host
3 * is read twice — once as an app label, once by a URL parser to build the
4 * redirect — and the two readings must agree: `evil#x--notes.apps.test`
5 * once passed the label check (no dot, last part a real slug) while the
6 * parser called its host `evil`, and a valid grant left for a stranger's
7 * origin. Now only a bare hostname, with at most a port, is a host at all.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { newApp, updateAppSettings } from "../src/actions/apps.ts";
16 import { AppsRuntime } from "../src/apps/runtime.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import type { Repo } from "../src/core/repos.ts";
21 import type { AppRecord } from "../src/core/apps.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 quietLogs();
25
26 const DOMAIN = "apps.test";
27
28 describe("/apps/open sends a grant only to a host that reads one way", () => {
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let runtime: AppsRuntime;
33 let alice: User;
34 let repo: Repo;
35 let record: AppRecord;
36 let aliceCookie = "";
37
38 const signIn = async (username: string): Promise<string> => {
39 const response = await fetch(`${servers.url}/signin`, {
40 method: "POST",
41 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
42 redirect: "manual",
43 });
44 return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
45 };
46
47 beforeAll(async () => {
48 dataDir = await mkdtemp(join(tmpdir(), "forge-open-redirect-"));
49 app = createApp({ dataDir, port: 0, host: "forge.test" });
50 servers = await serve(app);
51 runtime = new AppsRuntime(app, {
52 domain: DOMAIN,
53 scheme: "http",
54 publicPort: servers.port,
55 forgeUrl: servers.url,
56 concurrency: 1,
57 });
58 await runtime.start();
59 const a = await createUser(app.db, {
60 username: "alice",
61 email: "[email protected]",
62 password: "a-good-passphrase",
63 adminIfFirst: true,
64 });
65 if (!a.ok) throw new Error(a.error);
66 alice = a.value;
67 aliceCookie = await signIn("alice");
68
69 const made = await newApp(app, alice, {
70 name: "notes",
71 description: "A place for notes",
72 visibility: "public",
73 });
74 if (!made.ok) throw new Error(String(made.error));
75 repo = made.value.repo;
76 record = made.value.app;
77 }, 120_000);
78
79 afterAll(async () => {
80 await runtime.stop();
81 servers.stop();
82 app.close();
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 const open = (host: string) => {
87 const u = new URL(`${servers.url}/apps/open`);
88 u.searchParams.set("host", host);
89 u.searchParams.set("to", "/");
90 return fetch(u, { headers: { cookie: aliceCookie }, redirect: "manual" });
91 };
92
93 test("a plain app host, with or without a port, is granted on itself", async () => {
94 const plain = await open(`notes.${DOMAIN}`);
95 expect(plain.status).toBe(302);
96 expect(new URL(plain.headers.get("location")!).hostname).toBe(
97 `notes.${DOMAIN}`,
98 );
99 const ported = await open(`notes.${DOMAIN}:8443`);
100 expect(ported.status).toBe(302);
101 expect(new URL(ported.headers.get("location")!).host).toBe(
102 `notes.${DOMAIN}:8443`,
103 );
104 });
105
106 test("a host a URL parser would read as another machine is not a host", async () => {
107 for (const crafted of [
108 `evil#stuff--notes.${DOMAIN}`,
109 `2130706433@notes--${DOMAIN}`,
110 `evil.example/@notes.${DOMAIN}`,
111 `notes.${DOMAIN}/x`,
112 `notes.${DOMAIN}?x`,
113 `notes.${DOMAIN}:80:80`,
114 `-notes.${DOMAIN}`,
115 ]) {
116 const res = await open(crafted);
117 expect([crafted, res.status]).toEqual([crafted, 404]);
118 expect(res.headers.get("location")).toBeNull();
119 }
120 });
121
122 test("the proxy reads such a Host the same way: not ours", async () => {
123 const res = await fetch(`${servers.url}/`, {
124 headers: { host: `evil#stuff--notes.${DOMAIN}` },
125 redirect: "manual",
126 });
127 // The forge answers for itself, as for any hostname it does not know.
128 expect(res.status).toBe(200);
129 expect(await res.text()).toContain("<title>");
130 });
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs