diff --git a/apps/docs/docs/self-host/bundle.md b/apps/docs/docs/self-host/bundle.md new file mode 100644 index 00000000..1a67fa59 --- /dev/null +++ b/apps/docs/docs/self-host/bundle.md @@ -0,0 +1,134 @@ +--- +title: Compose bundle +--- + +# Compose bundle + +The bundle is the adoption path: one `docker compose up` brings up the whole control plane — API +with embedded MCP and webhooks, worker, web UI, PostgreSQL, and the workspace runner image — from +the repository's `docker-compose.yml`. It needs no cloud account and no Terraform. + +Use the [quickstart](quickstart.md) instead when working on the Facility source, and the +[AWS reference deployment](aws.md) when a hosted control plane is the goal. The bundle runs every +service on one host with one Docker daemon, so it is an evaluation and small-team shape, not a +resilient deployment. + +## Prerequisites + +Docker with Compose v2, a running daemon the current user can reach, and a GitHub organization +whose repositories Facility may automate. Story workspaces hold repository checkouts, dependencies, +nested images, and persistent volumes; keep several gigabytes of disk free. + +## Start the control plane + +The master key encrypts every stored credential. Generate it once and keep it: an instance that +loses its key cannot decrypt the project secrets it already holds. + +```bash +git clone https://github.com/theam/facility.git +cd facility +printf 'SECRET_MASTER_KEY=%s\n' "$(openssl rand -base64 32)" >> .env +docker compose up -d +``` + +The first run builds the API, web, and runner images. `migrate` applies the schema and must exit +zero before the API and worker start; `runner-image` builds the workspace image the worker later +hands to stories. Both are one-shot services, so `docker compose ps` showing them as exited is the +expected steady state. + +Check the control plane: + +```bash +curl --fail http://localhost:4400/health +curl --fail http://localhost:4400/readyz +``` + +| Surface | URL | +| --- | --- | +| Web UI | `http://localhost:3400` | +| API, MCP, webhooks, OpenAPI | `http://localhost:4400` | +| Story previews | `http://preview.localhost:4400` | + +The preview origin is a separate security surface because it serves code an agent wrote. It stays a +registered site of its own even here; `preview.localhost` resolves to loopback in modern browsers. + +Both host ports are bound to `127.0.0.1`, so the bundle is reachable from the machine running it and +nowhere else. That binding is what lets it run without TLS: a `localhost` URL constrains nothing on +its own, and a port published on every interface would serve plaintext sessions and preview traffic +to the network segment. Reaching the instance from another machine therefore means changing the +binding, and changing it means putting TLS and the HTTPS requirements described in the +[production guide](production.md) in front of it first. A tunnel or reverse proxy is the same +decision. + +## Connect GitHub + +The bundle ships no identity, so sign-in and repository automation both have to be configured +before the first story. This is the longest step; the rest of the page takes minutes. + +1. Create a **GitHub OAuth App** for browser sign-in with callback + `http://localhost:3400/api/auth/callback`, and put its credentials in `.env`: + + ```dotenv + AUTH_IDENTITY_PROVIDER=github + GITHUB_OAUTH_CLIENT_ID=... + GITHUB_OAUTH_CLIENT_SECRET=... + ``` + + Without them the login page offers GitHub sign-in and the API answers `auth_unconfigured`. See + [Authentication](authentication.md) for the OIDC alternative and for the organization + restriction. + +2. Create and install the **GitHub App** that Facility uses for clone and push credentials, + kickstart pull requests, and webhook-driven agents, then add `GITHUB_APP_ID`, + `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_WEBHOOK_SECRET` and `GITHUB_APP_SLUG` to `.env`. The + permission table and event subscriptions are in the [GitHub App guide](github-app.md). + +3. Apply the new configuration: + + ```bash + docker compose up -d + ``` + +GitHub cannot deliver webhooks to `localhost`. Agents triggered from the UI or MCP work without a +tunnel; issue, comment, and pull-request triggers need an HTTPS tunnel whose payload URL is +`https:///webhooks/github`, and `PUBLIC_URL` must match it. Treat that tunnel as +public. + +## Bind the first owner + +Migrations create the schema but no organization. Bind one owner to the installed GitHub App with +the operator CLI, which ships inside the API image: + +```bash +docker compose exec api facility instance bootstrap \ + --org-name "Acme" --org-slug acme \ + --owner-email owner@acme.example --owner-name "Owner" \ + --github-user-id 1 --github-login owner \ + --github-account-id 2 --github-account-login acme \ + --github-installation-id 3 +``` + +Read the account, user, and installation identifiers from the GitHub App installation. Repeating +the exact binding is safe; a different binding against a populated instance is refused rather than +applied. + +## First story + +Sign in at `http://localhost:3400`, create a project, choose a repository, and open its kickstart +pull request. After merging that configuration pull request, sync the project on the Pipeline page +and start a small disposable story. The [story operations guide](../guides/operate-story.md) covers +normal work, and the [end-to-end validation](../guides/validate-workspace-loop.md) is worth running +before connecting code that matters. + +## Operate + +```bash +docker compose logs -f api worker # follow the control plane +docker compose up -d --build # apply a new checkout +docker compose stop # stop; volumes and stories remain +``` + +Facility does not delete worktrees or session volumes by age, so watch disk usage. Story workspace +volumes are managed by Facility and outlive the API and worker containers: remove a story through +the product, not with broad Docker volume pruning. Before reusing a database from an earlier +release, read the [0.12 upgrade guide](../reference/upgrade-012.md). diff --git a/apps/docs/docs/self-host/production.md b/apps/docs/docs/self-host/production.md index 00e5073d..1ed430cc 100644 --- a/apps/docs/docs/self-host/production.md +++ b/apps/docs/docs/self-host/production.md @@ -16,8 +16,9 @@ generic scheduler and GitHub reconciliation, PostgreSQL, the web UI, and a works accounting, budgets, observability, audit events, analytics summaries, and pipeline state live in those services. They do not require sidecars or separate control-plane applications. -The single-host Compose file uses Docker named volumes. The [AWS reference deployment](aws.md) -runs the control plane on ECS and RDS while Vercel Sandbox runs and retains story workspaces. +The single-host [Compose bundle](bundle.md) uses Docker named volumes. The [AWS reference +deployment](aws.md) runs the control plane on ECS and RDS while Vercel Sandbox runs and retains +story workspaces. Run at least one API, one worker, and one web process. API and worker must use the same release, database, master key, GitHub App configuration, workspace provider configuration, and project value diff --git a/apps/docs/docs/self-host/quickstart.md b/apps/docs/docs/self-host/quickstart.md index 477cd829..a2e0804e 100644 --- a/apps/docs/docs/self-host/quickstart.md +++ b/apps/docs/docs/self-host/quickstart.md @@ -4,7 +4,8 @@ title: Quickstart # Self-host quickstart -This path runs Facility from source for local evaluation and development. Use the [production +This path runs Facility from source for local evaluation and development. To run the product +without a source toolchain, use the [Compose bundle](bundle.md) instead. Use the [production guide](production.md) before exposing an instance to other users or repositories. ## Prerequisites diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 250e7357..83e17987 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = { label: "self-hosting", collapsed: false, items: [ + "self-host/bundle", "self-host/quickstart", "self-host/local-development", "self-host/production", diff --git a/docker-compose.yml b/docker-compose.yml index 3429ee50..37cb68d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,7 +72,12 @@ services: GITHUB_CLONE_TOKEN: ${GITHUB_CLONE_TOKEN:-} FACILITY_WORKSPACE_DRIVER: docker FACILITY_WORKSPACE_IMAGE: ${FACILITY_WORKSPACE_IMAGE:-facility-runner:dev} - ports: ["4400:4400"] + # Bound to loopback, not published on every interface. The bundle runs + # without TLS, and a localhost URL constrains nothing about who can reach + # the port: `4400:4400` would serve plaintext sessions and preview traffic + # to the whole network segment. Fronting this with TLS means changing the + # binding deliberately, not discovering it was already open. + ports: ["127.0.0.1:4400:4400"] volumes: # The API inspects workspace state through the host Docker daemon. - /var/run/docker.sock:/var/run/docker.sock @@ -125,7 +130,10 @@ services: restart: unless-stopped environment: FACILITY_API_URL: http://api:4400 - ports: ["3400:3400"] + # Loopback for the same reason as the api port. Container-to-container + # traffic uses the Compose network (`http://api:4400`), so this publishes + # nothing the stack needs internally. + ports: ["127.0.0.1:3400:3400"] healthcheck: test: [ diff --git a/services/api/src/config.ts b/services/api/src/config.ts index 84378dd4..39e59270 100644 --- a/services/api/src/config.ts +++ b/services/api/src/config.ts @@ -160,10 +160,24 @@ const EnvSchema = z } else { const preview = new URL(env.FACILITY_PREVIEW_URL); const previewSite = registeredSite(preview.hostname); - const controlSites = [env.PUBLIC_URL, env.WEB_URL ?? env.PUBLIC_URL, env.MCP_PUBLIC_URL] + const controlOrigins = [env.PUBLIC_URL, env.WEB_URL ?? env.PUBLIC_URL, env.MCP_PUBLIC_URL] .filter((value): value is string => Boolean(value)) - .map((value) => registeredSite(new URL(value).hostname)); - if (preview.protocol !== "https:") { + .map((value) => new URL(value)); + const controlSites = controlOrigins.map((url) => registeredSite(url.hostname)); + // The single-host bundle serves the whole instance over loopback, where + // there is no name to obtain a certificate for and nothing is reachable + // off the machine. Requiring HTTPS there refuses a configuration that + // has no transport to protect, so exempt an instance whose every origin + // — preview included — is loopback HTTP. Any origin that leaves the + // machine puts the whole set back under the HTTPS requirement. This is + // the carve-out the interactive OAuth block below already makes. + const loopbackInstance = + preview.protocol === "http:" && + isLoopbackHostname(preview.hostname) && + controlOrigins.every( + (url) => url.protocol === "http:" && isLoopbackHostname(url.hostname), + ); + if (preview.protocol !== "https:" && !loopbackInstance) { ctx.addIssue({ code: "custom", path: ["FACILITY_PREVIEW_URL"], @@ -430,7 +444,13 @@ function isExactAuthCallbackUrl(url: URL, webOrigin: string) { ); } +// RFC 6761 section 6.3 reserves `localhost` and every name under `.localhost` +// for the loopback interface. Facility needs more than the bare name because +// the preview origin must stay a registered site of its own; `preview.localhost` +// satisfies both, and resolvers are required not to send it to the network. function isLoopbackHostname(hostname: string) { const normalized = hostname.toLowerCase(); - return ["localhost", "127.0.0.1", "[::1]"].includes(normalized); + return ( + ["localhost", "127.0.0.1", "[::1]"].includes(normalized) || normalized.endsWith(".localhost") + ); } diff --git a/services/api/test/config.test.ts b/services/api/test/config.test.ts index c397dcf0..6acbd029 100644 --- a/services/api/test/config.test.ts +++ b/services/api/test/config.test.ts @@ -1,6 +1,13 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseDotenv } from "dotenv"; import { describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; import { readConfig } from "../src/config.js"; +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + const validEnv = { DATABASE_URL: "postgres://facility:facility@localhost:5432/facility", SECRET_MASTER_KEY: Buffer.alloc(32, 9).toString("base64"), @@ -82,6 +89,95 @@ describe("Facility 0.12 configuration", () => { ).toMatchObject({ previewUrl: "https://preview.example.net" }); }); + it("boots from the shipped .env.example once the master key is supplied", () => { + // dotenv delivers a bare `KEY=` as an empty string rather than omitting the + // key, so every blank line in the template reaches validation as a present + // value. The template is the documented first step of self-hosting: it has + // to parse as written, with only the master key it tells the operator to + // generate filled in. + const template = parseDotenv(readFileSync(join(repoRoot, ".env.example"), "utf8")); + expect(template.SECRET_MASTER_KEY).toBe(""); + expect(() => + readConfig({ ...template, SECRET_MASTER_KEY: validEnv.SECRET_MASTER_KEY }), + ).not.toThrow(); + }); + + it("keeps the bundle's own ports on loopback, which is what the carve-out rests on", () => { + // Allowing plain HTTP because every origin is a loopback URL is only sound + // while the bundle is actually reachable from loopback alone. A host port + // published as `4400:4400` listens on every interface, and the URL says + // nothing about the binding, so the premise lives in a different file from + // the rule. Pin it next to the rule that depends on it. + const compose = parseYaml(readFileSync(join(repoRoot, "docker-compose.yml"), "utf8")) as { + services: Record; + }; + const published = Object.entries(compose.services).flatMap(([service, definition]) => + (definition.ports ?? []).map((port) => ({ service, port })), + ); + + // Not vacuous: the bundle does publish ports, and these are the ones. + expect(published.map(({ service }) => service)).toEqual(["api", "web"]); + expect(published.filter(({ port }) => !port.startsWith("127.0.0.1:"))).toEqual([]); + }); + + it("boots the single-host bundle on loopback origins", () => { + // The defaults docker-compose.yml hands the api container, which runs the + // `api` image and therefore NODE_ENV=production. Keep this aligned with the + // Compose file: it is the configuration the bundle actually starts with. + const bundleEnv = { + ...validEnv, + NODE_ENV: "production", + PUBLIC_URL: "http://localhost:4400", + WEB_URL: "http://localhost:3400", + FACILITY_PREVIEW_URL: "http://preview.localhost:4400", + MCP_PUBLIC_URL: "http://localhost:4400/mcp", + }; + expect(readConfig(bundleEnv)).toMatchObject({ + previewUrl: "http://preview.localhost:4400", + }); + + // The preview origin stays a separate registered site even on loopback, so + // the bare control hostname is still refused as a preview origin. + expect(() => + readConfig({ ...bundleEnv, FACILITY_PREVIEW_URL: "http://localhost:4400" }), + ).toThrow("must use a registered site separate"); + }); + + it("keeps the production HTTPS requirement for any origin that leaves the machine", () => { + const loopbackControl = { + ...validEnv, + NODE_ENV: "production", + PUBLIC_URL: "http://localhost:4400", + WEB_URL: "http://localhost:3400", + }; + // A preview origin off the machine is not covered by the loopback carve-out. + expect(() => + readConfig({ ...loopbackControl, FACILITY_PREVIEW_URL: "http://preview.example.net" }), + ).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production"); + + // Neither is a loopback preview whose control plane is published. + expect(() => + readConfig({ + ...validEnv, + NODE_ENV: "production", + PUBLIC_URL: "https://api.example.com", + WEB_URL: "https://app.example.com", + FACILITY_PREVIEW_URL: "http://preview.localhost:4400", + }), + ).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production"); + + // A published deployment keeps the requirement it always had. + expect(() => + readConfig({ + ...validEnv, + NODE_ENV: "production", + PUBLIC_URL: "https://api.example.com", + WEB_URL: "https://app.example.com", + FACILITY_PREVIEW_URL: "http://preview.example.net", + }), + ).toThrow("FACILITY_PREVIEW_URL must use HTTPS in production"); + }); + it("rejects preview URLs with credentials, paths, queries, or fragments", () => { expect(() => readConfig({