diff --git a/documentor/FORK.md b/documentor/FORK.md index 21fa288f99fc..da50d81c43b7 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -52,14 +52,15 @@ because those files still type-check against it. ## Settings -| Setting | Meaning | -| --------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `OPENCODE_DATABASE_URL` | Required. A host that is not loopback must carry `sslmode=verify-full` | -| `OPENCODE_DATABASE_SCHEMA` | The scope's schema, sent as the search path. 1 to 63 characters of `a-z`, `0-9`, `_` | -| `OPENCODE_DATABASE_POOL_MAX` | Connections per process, 1 to 100, default 4 | -| `OPENCODE_DATABASE_ROLE_PASSWORD` | Read by `db provision` when it has to create the role | -| `OPENCODE_DATABASE_EPHEMERAL=1` | Tests only: each database layer creates a schema `t_<32 hex>`, migrates it, and drops it when it closes | -| `OPENCODE_TEST_DATABASE_URL` | Tests only: a superuser on a Postgres 18 whose database collation is `C.UTF-8` | +| Setting | Meaning | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `OPENCODE_DATABASE_URL` | Required. A host that is not loopback must carry `sslmode=verify-full`, but for the one exception below | +| `OPENCODE_DATABASE_PLAINTEXT_HOST` | `documentor-dev-agentstate` lets that host, DocuMentor's development Postgres, connect with `sslmode=disable`; any other value relaxes nothing | +| `OPENCODE_DATABASE_SCHEMA` | The scope's schema, sent as the search path. 1 to 63 characters of `a-z`, `0-9`, `_` | +| `OPENCODE_DATABASE_POOL_MAX` | Connections per process, 1 to 100, default 4 | +| `OPENCODE_DATABASE_ROLE_PASSWORD` | Read by `db provision` when it has to create the role | +| `OPENCODE_DATABASE_EPHEMERAL=1` | Tests only: each database layer creates a schema `t_<32 hex>`, migrates it, and drops it when it closes | +| `OPENCODE_TEST_DATABASE_URL` | Tests only: a superuser on a Postgres 18 whose database collation is `C.UTF-8` | The database must use byte-order collation (`C`, `POSIX` or `C.UTF-8`). opencode pages by comparing text ids, and a locale collation reorders them. diff --git a/packages/core/src/database/target.ts b/packages/core/src/database/target.ts index 1a64bdd5e0c5..aeefe3cad3a7 100644 --- a/packages/core/src/database/target.ts +++ b/packages/core/src/database/target.ts @@ -16,6 +16,23 @@ export interface Target { const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]) const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/ +/** DocuMentor's development Postgres on a dev host's Docker network; it holds development data only. */ +const DEV_PLAINTEXT_HOST = "documentor-dev-agentstate" + +/** + * A remote host is reached only over verified TLS, with one exception: the + * development Postgres, which has no certificate. The host is fixed here; + * OPENCODE_DATABASE_PLAINTEXT_HOST only switches the exception on, so no + * setting can downgrade another host. The URL must say `sslmode=disable` itself. + */ +function plaintextAllowed(url: URL, env: Record): boolean { + return ( + env.OPENCODE_DATABASE_PLAINTEXT_HOST?.trim() === DEV_PLAINTEXT_HOST && + url.hostname === DEV_PLAINTEXT_HOST && + url.searchParams.get("sslmode") === "disable" + ) +} + /** Reads where this process connects. Throws `InvalidTargetError` naming the setting at fault. */ export function read(env: Record = process.env): Target { const address = env.OPENCODE_DATABASE_URL @@ -27,7 +44,7 @@ export function read(env: Record = process.env): Tar throw new InvalidTargetError("OPENCODE_DATABASE_URL is not a valid URL") } })() - if (!LOOPBACK.has(url.hostname) && url.searchParams.get("sslmode") !== "verify-full") + if (!LOOPBACK.has(url.hostname) && url.searchParams.get("sslmode") !== "verify-full" && !plaintextAllowed(url, env)) throw new InvalidTargetError( `OPENCODE_DATABASE_URL names the remote host ${url.hostname} without sslmode=verify-full`, ) diff --git a/packages/core/test/pg/target.test.ts b/packages/core/test/pg/target.test.ts index 9ebd43e65f4d..7a7bd6d0aa07 100644 --- a/packages/core/test/pg/target.test.ts +++ b/packages/core/test/pg/target.test.ts @@ -20,6 +20,24 @@ describe("database target", () => { ) }) + test("accepts the development Postgres without TLS only where switched on, by name, with sslmode=disable", () => { + const dev = "postgresql://u@documentor-dev-agentstate:5432/db?sslmode=disable" + expect(() => read({ OPENCODE_DATABASE_URL: dev })).toThrow("sslmode=verify-full") + expect( + read({ OPENCODE_DATABASE_URL: dev, OPENCODE_DATABASE_PLAINTEXT_HOST: "documentor-dev-agentstate" }).url, + ).toContain("documentor-dev-agentstate") + // The switch names no host of its own choosing, and never relaxes the URL's own mode. + for (const [url, host] of [ + ["postgresql://u@db.internal:5432/db?sslmode=disable", "db.internal"], + ["postgresql://u@db.internal:5432/db?sslmode=disable", "documentor-dev-agentstate"], + ["postgresql://u@documentor-dev-agentstate:5432/db", "documentor-dev-agentstate"], + ["postgresql://u@documentor-dev-agentstate:5432/db?sslmode=require", "documentor-dev-agentstate"], + ]) + expect(() => read({ OPENCODE_DATABASE_URL: url, OPENCODE_DATABASE_PLAINTEXT_HOST: host })).toThrow( + "sslmode=verify-full", + ) + }) + test("accepts a remote host with sslmode=verify-full", () => { expect(read({ OPENCODE_DATABASE_URL: "postgresql://u@db.internal:5432/db?sslmode=verify-full" }).url).toContain( "sslmode=verify-full",