Public: anyone can read this; the crew can change it.
An app gets its own domain #326
Check · Review · Land · Live done — Landed and live in production.
app-domains → main 9 commits
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 | ||
| 422 | 422 | not carried — a cookie an app sets on the handshake does not reach the |
| 423 | 423 | browser. |
| 424 | 424 | |
| 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 | ||
| 425 | 478 | ## A release does not drop a session |
| 426 | 479 | |
| 427 | 480 | 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 | ||
| 693 | 746 | Containers. An app runs as the forge's own user, in its own directory, and |
| 694 | 747 | nothing more separates it from the host than that. On the machine one |
| 695 | 748 | 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. | ||
| 44 | 44 | `forge.example, *.forge.example { reverse_proxy 127.0.0.1:3000 }` with a |
| 45 | 45 | DNS-challenge wildcard, or the existing Cloudflare tunnel with two ingress |
| 46 | 46 | 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 | ||
| 48 | 72 | 5. **DNS cutover.** Point `forge.example` and the wildcard at the box (or |
| 49 | 73 | the tunnel). The old cluster keeps serving until the switch; after it, |
| 50 | 74 | the platform's forge is a copy nobody visits. |
src/actions/apps.ts +7 −5
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | 10 | import { appsRuntime, type EnvStatus } from "../apps/runtime.ts"; |
| 11 | import { grantUrl } from "../apps/proxy.ts"; | |
| 11 | import { grantUrl, originFor } from "../apps/proxy.ts"; | |
| 12 | 12 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| 13 | 13 | import { |
| 14 | 14 | isEnvName, |
| @@ -323,7 +323,10 @@ export async function newApp( | ||
| 323 | 323 | { |
| 324 | 324 | name: input.name, |
| 325 | 325 | 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 | })), | |
| 327 | 330 | }, |
| 328 | 331 | { binary }, |
| 329 | 332 | ); |
| @@ -474,9 +477,8 @@ export function openEnvUrl( | ||
| 474 | 477 | return ok(grantUrl(runtime, { app: record, env }, path, host, who)); |
| 475 | 478 | } |
| 476 | 479 | 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}`); | |
| 480 | 482 | } |
| 481 | 483 | |
| 482 | 484 | /** 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 @@ | ||
| 17 | 17 | import { appHostSuffix } from "./host.ts"; |
| 18 | 18 | import { createHmac, timingSafeEqual } from "node:crypto"; |
| 19 | 19 | 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"; | |
| 21 | 22 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 22 | 23 | import { loadKey } from "../kernel/secret.ts"; |
| 23 | 24 | import { previewEnv, type EnvName } from "./data.ts"; |
| @@ -34,6 +35,15 @@ export interface AppTarget { | ||
| 34 | 35 | readonly nested?: boolean; |
| 35 | 36 | } |
| 36 | 37 | |
| 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 | ||
| 37 | 47 | /** |
| 38 | 48 | * Which app and environment a `Host` names, if any. Hostnames are |
| 39 | 49 | * case-insensitive and may carry a port. Only one label may sit in front |
| @@ -43,6 +53,7 @@ export function parseAppHost( | ||
| 43 | 53 | hostHeader: string, |
| 44 | 54 | domain: string, |
| 45 | 55 | ): { slug: string; env: EnvName; nested: boolean } | undefined { |
| 56 | if (!isHostPort(hostHeader)) return undefined; | |
| 46 | 57 | const host = hostHeader.toLowerCase().replace(/:\d+$/, ""); |
| 47 | 58 | const suffix = appHostSuffix(domain.toLowerCase().replace(/:\d+$/, "")); |
| 48 | 59 | if (!host.endsWith(suffix)) return undefined; |
| @@ -89,13 +100,54 @@ export function resolveAppHostname( | ||
| 89 | 100 | host: string, |
| 90 | 101 | ): AppTarget | "unknown" | undefined { |
| 91 | 102 | const parsed = parseAppHost(host, domain); |
| 92 | if (parsed === undefined) return undefined; | |
| 103 | if (parsed === undefined) return ownDomain(app, host); | |
| 93 | 104 | const record = getAppBySlug(app.db, parsed.slug); |
| 94 | 105 | return record |
| 95 | 106 | ? { app: record, env: parsed.env, nested: parsed.nested } |
| 96 | 107 | : "unknown"; |
| 97 | 108 | } |
| 98 | 109 | |
| 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 | ||
| 99 | 151 | /** |
| 100 | 152 | * The grant cookie is named for the app it admits to. Two forges can set |
| 101 | 153 | * grants on one hostname — this one, and a forge it hosts, for an app of |
| @@ -240,11 +292,10 @@ export function grantUrl( | ||
| 240 | 292 | ); |
| 241 | 293 | // The cookie a grant sets belongs to one hostname. A visitor who asked |
| 242 | 294 | // 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, | |
| 244 | 297 | // 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)}`; | |
| 248 | 299 | } |
| 249 | 300 | |
| 250 | 301 | const HOP_BY_HOP = [ |
src/apps/runtime.ts +19
| @@ -43,7 +43,9 @@ import { createLog } from "../kernel/log.ts"; | ||
| 43 | 43 | import type { Result } from "../kernel/result.ts"; |
| 44 | 44 | import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts"; |
| 45 | 45 | import { appendRunLog } from "../runs/log.ts"; |
| 46 | import { primaryDomain } from "../core/domains.ts"; | |
| 46 | 47 | import { exportTree, readFileAt } from "./checkout.ts"; |
| 48 | import type { DomainResolver } from "./domains.ts"; | |
| 47 | 49 | import { |
| 48 | 50 | appsRoot, |
| 49 | 51 | ensureEnvData, |
| @@ -89,6 +91,11 @@ export interface RuntimeOptions { | ||
| 89 | 91 | | undefined; |
| 90 | 92 | /** How often the runtime looks for environments to put to sleep. */ |
| 91 | 93 | 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; | |
| 92 | 99 | } |
| 93 | 100 | |
| 94 | 101 | export type EnvState = |
| @@ -294,7 +301,19 @@ export class AppsRuntime { | ||
| 294 | 301 | return sshCommand(this.app, login); |
| 295 | 302 | } |
| 296 | 303 | |
| 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 | */ | |
| 297 | 312 | 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 | } | |
| 298 | 317 | const port = |
| 299 | 318 | this.options.publicPort === undefined || |
| 300 | 319 | (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 { | ||
| 25 | 25 | if ( |
| 26 | 26 | kind.startsWith("webhook.") || |
| 27 | 27 | kind.startsWith("app.env_") || |
| 28 | kind.startsWith("app.domain.") || | |
| 28 | 29 | kind === "repo.settings_updated" |
| 29 | 30 | ) |
| 30 | 31 | return "admins"; |
src/core/schema.ts +19
| @@ -879,4 +879,23 @@ export const migrations: readonly string[] = [ | ||
| 879 | 879 | DROP TABLE look; |
| 880 | 880 | ALTER TABLE look_new RENAME TO look; |
| 881 | 881 | `, |
| 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 | `, | |
| 882 | 901 | ]; |
src/design/activity.ts +7
| @@ -62,6 +62,13 @@ export function eventVerb(event: ForgeEvent): string { | ||
| 62 | 62 | ? `set ${which} in the app's environment` |
| 63 | 63 | : `removed ${which} from the app's environment`; |
| 64 | 64 | } |
| 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 | } | |
| 65 | 72 | return WORDS[kind] ?? "acted on this repository"; |
| 66 | 73 | } |
| 67 | 74 |
src/http/api.ts +93 −6
| @@ -153,6 +153,14 @@ import { | ||
| 153 | 153 | updateAppSettings, |
| 154 | 154 | type AppSettingsInput, |
| 155 | 155 | } 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"; | |
| 156 | 164 | import { |
| 157 | 165 | migrateMessage, |
| 158 | 166 | migrateRepo, |
| @@ -635,8 +643,12 @@ function repoQuery(ctx: Ctx) { | ||
| 635 | 643 | }; |
| 636 | 644 | } |
| 637 | 645 | |
| 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 | */ | |
| 639 | 650 | function appJson( |
| 651 | db: Database, | |
| 640 | 652 | record: AppRecord, |
| 641 | 653 | repo: Repo, |
| 642 | 654 | runtime: AppsRuntime | undefined, |
| @@ -645,6 +657,9 @@ function appJson( | ||
| 645 | 657 | ...record, |
| 646 | 658 | repo: { owner: repo.ownerName, name: repo.name }, |
| 647 | 659 | url: runtime?.urlFor(record, "production") ?? null, |
| 660 | domains: listDomains(db, record.id) | |
| 661 | .filter((d) => d.verifiedAt !== null) | |
| 662 | .map((d) => d.host), | |
| 648 | 663 | serving: runtime !== undefined, |
| 649 | 664 | environments: runtime?.status(record) ?? [], |
| 650 | 665 | }; |
| @@ -2672,7 +2687,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2672 | 2687 | for (const record of records) { |
| 2673 | 2688 | const repo = repos.get(record.repoId); |
| 2674 | 2689 | 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)); | |
| 2676 | 2691 | } |
| 2677 | 2692 | return json({ items, next_cursor: null }); |
| 2678 | 2693 | }); |
| @@ -2696,7 +2711,12 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2696 | 2711 | return json( |
| 2697 | 2712 | { |
| 2698 | 2713 | 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 | ), | |
| 2700 | 2720 | issue: made.value.issue ? withLabels(ctx, made.value.issue) : null, |
| 2701 | 2721 | }, |
| 2702 | 2722 | 201, |
| @@ -2785,7 +2805,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2785 | 2805 | if (!repo) return notFound(); |
| 2786 | 2806 | const view = appView(ctx.app, ctx.auth.user, repo); |
| 2787 | 2807 | 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 | ); | |
| 2789 | 2811 | }); |
| 2790 | 2812 | |
| 2791 | 2813 | router.add("POST", "/api/v1/repos/:owner/:name/app", async (ctx) => { |
| @@ -2796,7 +2818,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2796 | 2818 | const input = await body(ctx.request); |
| 2797 | 2819 | const enabled = enableApp(ctx.app, ctx.auth.user, repo, appPatch(input)); |
| 2798 | 2820 | 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 | ); | |
| 2800 | 2825 | }); |
| 2801 | 2826 | |
| 2802 | 2827 | router.add("PATCH", "/api/v1/repos/:owner/:name/app", async (ctx) => { |
| @@ -2812,7 +2837,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2812 | 2837 | appPatch(input), |
| 2813 | 2838 | ); |
| 2814 | 2839 | 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))); | |
| 2816 | 2841 | }); |
| 2817 | 2842 | |
| 2818 | 2843 | router.add("DELETE", "/api/v1/repos/:owner/:name/app", (ctx) => { |
| @@ -2825,6 +2850,68 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2825 | 2850 | return json({}, 200); |
| 2826 | 2851 | }); |
| 2827 | 2852 | |
| 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 | ||
| 2828 | 2915 | // The app's environment: names in, names out, and never a value back. |
| 2829 | 2916 | // `:var`, not `:name` — `:name` is the repository's. |
| 2830 | 2917 | router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => { |
src/http/server.ts +30
| @@ -160,6 +160,9 @@ import { | ||
| 160 | 160 | } from "../web/pages/pull.ts"; |
| 161 | 161 | import { |
| 162 | 162 | appDisable, |
| 163 | appDomainAdd, | |
| 164 | appDomainCheck, | |
| 165 | appDomainDelete, | |
| 163 | 166 | appEnvDelete, |
| 164 | 167 | appEnvSet, |
| 165 | 168 | appSettingsPage, |
| @@ -275,6 +278,7 @@ import { Router } from "./router.ts"; | ||
| 275 | 278 | import { getUserById } from "../core/users.ts"; |
| 276 | 279 | import { appsRuntime } from "../apps/runtime.ts"; |
| 277 | 280 | import { appWebSocket, proxyApp, resolveAppHost } from "../apps/proxy.ts"; |
| 281 | import { getVerifiedDomain } from "../core/domains.ts"; | |
| 278 | 282 | |
| 279 | 283 | // ---- auth ---- |
| 280 | 284 | |
| @@ -432,6 +436,20 @@ export function buildRouter(): Router<Handler> { | ||
| 432 | 436 | // fetches a region and not the page. Literal, above /:owner. |
| 433 | 437 | router.add("GET", "/live/apps", liveAppsFragment); |
| 434 | 438 | 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 | }); | |
| 435 | 453 | router.add("GET", "/runs/:id", runRedirect); |
| 436 | 454 | router.add("GET", "/settings", accountPage); |
| 437 | 455 | router.add("POST", "/settings", accountUpdate); |
| @@ -779,6 +797,18 @@ export function buildRouter(): Router<Handler> { | ||
| 779 | 797 | "/:owner/:name/settings/app/env/:var/delete", |
| 780 | 798 | appEnvDelete, |
| 781 | 799 | ); |
| 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 | ); | |
| 782 | 812 | router.add("GET", "/:owner/:name/settings/crew", crewSettingsPage); |
| 783 | 813 | router.add("POST", "/:owner/:name/settings/crew", crewAdd); |
| 784 | 814 | router.add( |
src/web/pages/apps.ts +2 −2
| @@ -38,7 +38,7 @@ import { | ||
| 38 | 38 | } from "../../core/repos.ts"; |
| 39 | 39 | import type { User } from "../../core/users.ts"; |
| 40 | 40 | import { safeBack, type Ctx } from "../../http/context.ts"; |
| 41 | import { resolveAppHostname } from "../../apps/proxy.ts"; | |
| 41 | import { isHostPort, resolveAppHostname } from "../../apps/proxy.ts"; | |
| 42 | 42 | import { |
| 43 | 43 | crumbsFor, |
| 44 | 44 | loadRepo, |
| @@ -845,7 +845,7 @@ export function appsOpen(ctx: Ctx): Response { | ||
| 845 | 845 | const wanted = ctx.url.searchParams.get("to") ?? "/"; |
| 846 | 846 | const path = |
| 847 | 847 | wanted.startsWith("/") && !wanted.startsWith("//") ? wanted : "/"; |
| 848 | if (runtime === undefined || host === "") return notFoundPage(ctx); | |
| 848 | if (runtime === undefined || !isHostPort(host)) return notFoundPage(ctx); | |
| 849 | 849 | const target = resolveAppHostname(ctx.app, runtime.options.domain, host); |
| 850 | 850 | if (target === undefined || target === "unknown") return notFoundPage(ctx); |
| 851 | 851 | if (!ctx.auth.user) return signInFirst(ctx); |
src/web/pages/search.ts +12 −1
| @@ -402,6 +402,15 @@ const DESTRUCTIVE: { | ||
| 402 | 402 | phrase: "", |
| 403 | 403 | }), |
| 404 | 404 | }, |
| 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 | }, | |
| 405 | 414 | { |
| 406 | 415 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/labels\/([^/]+)\/delete$/, |
| 407 | 416 | describe: (m) => ({ |
| @@ -488,7 +497,9 @@ const DESTRUCTIVE: { | ||
| 488 | 497 | // this page never becomes a way to read a private pull's title. |
| 489 | 498 | const repo = getRepo(ctx.app.db, m[1]!, m[2]!); |
| 490 | 499 | 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; | |
| 492 | 503 | const name = pull ? `"${pull.title}"` : `change #${m[3]}`; |
| 493 | 504 | return { |
| 494 | 505 | verb: "Take it back", |
src/web/pages/settings.ts +212
| @@ -15,6 +15,14 @@ import { | ||
| 15 | 15 | setAppEnvVar, |
| 16 | 16 | updateAppSettings, |
| 17 | 17 | } 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"; | |
| 18 | 26 | import { |
| 19 | 27 | listAppEnvNames, |
| 20 | 28 | NAME_MAX, |
| @@ -76,6 +84,7 @@ import { icon } from "../icons.ts"; | ||
| 76 | 84 | import { flashTo, page } from "../layout.ts"; |
| 77 | 85 | import type { Flash } from "../flash.ts"; |
| 78 | 86 | import { |
| 87 | badge, | |
| 79 | 88 | btn, |
| 80 | 89 | card, |
| 81 | 90 | confirmAction, |
| @@ -442,10 +451,14 @@ function appSettingsView( | ||
| 442 | 451 | }, |
| 443 | 452 | ); |
| 444 | 453 | if (record === undefined) return form; |
| 454 | const domains = listAppDomains(ctx.app, ctx.auth.user, repo); | |
| 445 | 455 | return html`${form} |
| 446 | 456 | <div class="mt"> |
| 447 | 457 | ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))} |
| 448 | 458 | </div> |
| 459 | <div class="mt"> | |
| 460 | ${appDomainsView(base, domains.ok ? domains.value : undefined)} | |
| 461 | </div> | |
| 449 | 462 | <div class="mt"> |
| 450 | 463 | ${card( |
| 451 | 464 | html`<p> |
| @@ -553,6 +566,117 @@ function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 553 | 566 | ); |
| 554 | 567 | } |
| 555 | 568 | |
| 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 | ||
| 556 | 680 | const appValues = (repo: Repo, record: AppRecord | undefined): AppValues => ({ |
| 557 | 681 | slug: record?.slug ?? slugFor(repo.name), |
| 558 | 682 | access: record?.access ?? repo.visibility, |
| @@ -702,6 +826,94 @@ export async function appEnvDelete(ctx: Ctx): Promise<Response> { | ||
| 702 | 826 | ); |
| 703 | 827 | } |
| 704 | 828 | |
| 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 | ||
| 705 | 917 | /** What a change to the environment sets in motion — when anything serves. */ |
| 706 | 918 | function redeploying(ctx: Ctx, how: string): string { |
| 707 | 919 | 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); } | ||
| 297 | 297 | .backups-now { margin-top: 12px; } |
| 298 | 298 | .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); } |
| 299 | 299 | .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); } | |
| 300 | 302 | .policy-line { |
| 301 | 303 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
| 302 | 304 | 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 | }); |
new above
offline