CI / verify (pull_request) Successful in 49s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m47s
CI / image-build (pull_request) Successful in 19s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / deploy-smoke (split) (pull_request) Successful in 1m4s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { ApiConfigError, apiTrustedOrigins, createApiRuntimeConfig } from "../src/config.js";
|
|
|
|
const validSecret = "correct-horse-battery-staple-0123456789";
|
|
|
|
function validInput() {
|
|
return {
|
|
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: "https://identity.example.test",
|
|
secret: validSecret,
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
port: 3000,
|
|
};
|
|
}
|
|
|
|
describe("API runtime configuration", () => {
|
|
it("accepts valid API and identity configuration", () => {
|
|
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
|
|
|
expect(config).toEqual({
|
|
clientAddress: { mode: "direct" },
|
|
// Production defaults to certificate-verified TLS with no configuration at all.
|
|
databaseTransport: { mode: "verify-full" },
|
|
databaseUrl: validInput().databaseUrl,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: "https://identity.example.test",
|
|
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
|
|
// deployment separates them (ADR 0038).
|
|
dashboardOrigin: "https://identity.example.test",
|
|
publicSignupEnabled: false,
|
|
secret: validSecret,
|
|
trustedOrigins: ["https://identity.example.test"],
|
|
},
|
|
port: 3000,
|
|
});
|
|
expect(Object.isFrozen(config)).toBe(true);
|
|
});
|
|
|
|
it("defaults host to loopback and port to 3000 when omitted", () => {
|
|
const { host: _host, port: _port, ...rest } = validInput();
|
|
const config = createApiRuntimeConfig(rest, { environment: "production" });
|
|
|
|
expect(config.host).toBe("127.0.0.1");
|
|
expect(config.port).toBe(3000);
|
|
});
|
|
|
|
it("permits an explicit non-loopback bind address", () => {
|
|
const config = createApiRuntimeConfig(
|
|
{ ...validInput(), host: "0.0.0.0" },
|
|
{ environment: "production" },
|
|
);
|
|
|
|
expect(config.host).toBe("0.0.0.0");
|
|
});
|
|
|
|
it("rejects a missing database URL", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({ ...validInput(), databaseUrl: undefined });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
|
expect(captured).toMatchObject({ code: "DATABASE_URL_MISSING" });
|
|
});
|
|
|
|
it("rejects an invalid database URL", () => {
|
|
for (const databaseUrl of ["", "not-a-url", "mysql://example.test/db", "http://example.test"]) {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({ ...validInput(), databaseUrl });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
|
}
|
|
});
|
|
|
|
it("rejects an empty listen host", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({ ...validInput(), host: "" });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "HOST_INVALID" });
|
|
});
|
|
|
|
it("rejects an invalid listen port", () => {
|
|
for (const port of [-1, 1.5, 70_000, Number.NaN, "not-a-port"]) {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({ ...validInput(), port });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "PORT_INVALID" });
|
|
}
|
|
});
|
|
|
|
it("accepts port 0 as an explicit request for an OS-assigned ephemeral port", () => {
|
|
const config = createApiRuntimeConfig({ ...validInput(), port: 0 });
|
|
expect(config.port).toBe(0);
|
|
});
|
|
|
|
it("delegates identity validation to Core's createIdentityConfig, rejecting a short secret", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({
|
|
...validInput(),
|
|
identity: { ...validInput().identity, secret: "too-short" },
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
|
|
});
|
|
|
|
it("explicitly allows local development origins only when environment is development", () => {
|
|
const config = createApiRuntimeConfig(
|
|
{
|
|
databaseUrl: validInput().databaseUrl,
|
|
host: "127.0.0.1",
|
|
identity: {
|
|
baseUrl: "http://localhost:3000",
|
|
secret: validSecret,
|
|
trustedOrigins: ["http://localhost:3000"],
|
|
},
|
|
port: 3000,
|
|
},
|
|
{ environment: "development" },
|
|
);
|
|
|
|
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
|
});
|
|
|
|
it("defaults public signup to disabled and threads an explicit development enablement through to identity config", () => {
|
|
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
|
|
expect(
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
identity: {
|
|
baseUrl: "http://localhost:3000",
|
|
publicSignupEnabled: true,
|
|
secret: validSecret,
|
|
trustedOrigins: ["http://localhost:3000"],
|
|
},
|
|
},
|
|
{ environment: "development" },
|
|
).identity.publicSignupEnabled,
|
|
).toBe(true);
|
|
});
|
|
|
|
it("refuses a production runtime configuration that enables public signup, before any resource is constructed", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
identity: { ...validInput().identity, publicSignupEnabled: true },
|
|
},
|
|
{ environment: "production" },
|
|
);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
|
});
|
|
|
|
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
|
|
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
|
|
|
|
const config = createApiRuntimeConfig({
|
|
...validInput(),
|
|
clientAddress: {
|
|
header: "x-forwarded-for",
|
|
mode: "trusted-proxy",
|
|
trustedProxies: ["10.0.0.1"],
|
|
},
|
|
});
|
|
expect(config.clientAddress).toEqual({
|
|
header: "x-forwarded-for",
|
|
mode: "trusted-proxy",
|
|
trustedProxies: ["10.0.0.1"],
|
|
});
|
|
});
|
|
|
|
describe("PostgreSQL transport policy", () => {
|
|
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
|
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
|
|
mode: "verify-full",
|
|
});
|
|
expect(
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
identity: {
|
|
baseUrl: "http://localhost:3000",
|
|
secret: validSecret,
|
|
trustedOrigins: ["http://localhost:3000"],
|
|
},
|
|
},
|
|
{ environment: "development" },
|
|
).databaseTransport,
|
|
).toEqual({ mode: "disable" });
|
|
});
|
|
|
|
it("refuses a production configuration that disables TLS", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig(
|
|
{ ...validInput(), databaseTransport: { mode: "disable" } },
|
|
{ environment: "production" },
|
|
);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
|
});
|
|
|
|
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
|
|
for (const mode of [
|
|
"VERIFY-FULL",
|
|
" verify-full ",
|
|
"require",
|
|
"verify-ca",
|
|
"prefer",
|
|
"no-verify",
|
|
"true",
|
|
true,
|
|
1,
|
|
"",
|
|
]) {
|
|
for (const environment of ["production", "development"] as const) {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
databaseTransport: { mode },
|
|
...(environment === "development"
|
|
? {
|
|
identity: {
|
|
baseUrl: "http://localhost:3000",
|
|
secret: validSecret,
|
|
trustedOrigins: ["http://localhost:3000"],
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
{ environment },
|
|
);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
|
code: "DATABASE_TLS_MODE_INVALID",
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
|
|
for (const parameter of [
|
|
"sslmode=disable",
|
|
"sslmode=require",
|
|
"sslmode=verify-full",
|
|
"ssl=true",
|
|
"sslrootcert=/tmp/ca.crt",
|
|
"sslcert=/tmp/c.crt",
|
|
"sslkey=/tmp/c.key",
|
|
"uselibpqcompat=true",
|
|
]) {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
|
|
},
|
|
{ environment: "production" },
|
|
);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
|
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig(
|
|
{
|
|
...validInput(),
|
|
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
|
|
databaseUrl:
|
|
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
|
|
},
|
|
{ environment: "production" },
|
|
);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
const publicText = `${String(captured)}\n${
|
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
|
}\n${JSON.stringify(captured)}`;
|
|
expect(publicText).not.toContain("hunter2");
|
|
expect(publicText).not.toContain("db.internal.example");
|
|
expect(publicText).not.toContain("SECRET");
|
|
expect(publicText).not.toContain("sslmode");
|
|
});
|
|
});
|
|
|
|
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
|
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
|
let captured: unknown;
|
|
try {
|
|
createApiRuntimeConfig({ ...validInput(), databaseUrl: hostileUrl, port: -1 });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
|
const publicText = `${String(captured)}\n${
|
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
|
}\n${JSON.stringify(captured)}`;
|
|
expect(publicText).not.toContain(hostileUrl);
|
|
expect(publicText).not.toContain("s3cr3t");
|
|
expect(publicText).not.toContain("db.internal.example");
|
|
});
|
|
});
|
|
|
|
describe("the API's trusted browser origins (ADR 0045)", () => {
|
|
it("adds the API's own origin to the configured ones, as Better Auth does", () => {
|
|
const origins = apiTrustedOrigins({
|
|
baseUrl: "https://api.example.test",
|
|
trustedOrigins: ["https://dash.example.test"],
|
|
});
|
|
|
|
expect(origins).toEqual(["https://dash.example.test", "https://api.example.test"]);
|
|
expect(Object.isFrozen(origins)).toBe(true);
|
|
});
|
|
|
|
it("does not duplicate an API origin already listed (the same-origin topology)", () => {
|
|
expect(
|
|
apiTrustedOrigins({
|
|
baseUrl: "https://ekklesia.example.test",
|
|
trustedOrigins: ["https://ekklesia.example.test"],
|
|
}),
|
|
).toEqual(["https://ekklesia.example.test"]);
|
|
});
|
|
});
|