fix: fail closed on production signup

Production configuration accepted publicSignupEnabled: true and started a
working Identity/API runtime, even though Ekklesia has no verified email,
invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor
merely reported "Public signup is explicitly ENABLED" as a pass.

validatePublicSignupEnabled now takes the same IdentityEnvironment the rest
of this config validates against and throws the stable
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in
production. Parsing stays separate from the policy, so a malformed value
still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the
executable entry point validates configuration before startApi, such a
deployment fails before any pool, Better Auth instance, module runtime, or
listener exists, and Doctor reports NOT READY via its existing
IdentityConfigError path. Development and test composition still enable it
explicitly through the identical typed path.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 15:58:08 -05:00
co-authored by Claude Opus 5
parent d3c6bd73d3
commit 1161f5a076
10 changed files with 293 additions and 53 deletions
+14 -3
View File
@@ -346,6 +346,15 @@ async function startConfiguredApi(
url: string,
options: StartConfiguredApiOptions = {},
): Promise<RunningApi> {
const publicSignupEnabled = options.publicSignupEnabled ?? true;
// Production configuration refuses to enable public signup at all until durable abuse controls
// exist (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`), so a scenario
// that creates accounts over HTTP must validate its configuration as development — the explicit,
// intended way to opt in. This changes only Ekklesia's own config-validation strictness:
// `environment` never reaches Better Auth, and this harness keeps the identical https,
// non-loopback base URL and trusted origins either way, so the production-equivalent origin/CSRF
// behavior ADR 0012 established is exercised exactly the same in both cases.
const environment = publicSignupEnabled ? "development" : "production";
const config = createApiRuntimeConfig(
{
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
@@ -353,13 +362,13 @@ async function startConfiguredApi(
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: options.publicSignupEnabled ?? true,
publicSignupEnabled,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "production" },
{ environment },
);
return startApi(config, {
...("rateLimitOverride" in options
@@ -902,7 +911,9 @@ describe.sequential("live API — startup and migration guarantees", () => {
},
port: 0,
},
{ environment: "production" },
// Development validation, for the same reason `startConfiguredApi` documents: this scenario
// drives a real sign-up request, which production configuration refuses to enable at all.
{ environment: "development" },
);
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
const origin = apiOrigin(running);
+29 -5
View File
@@ -133,16 +133,40 @@ describe("API runtime configuration", () => {
expect(config.identity.baseUrl).toBe("http://localhost:3000");
});
it("defaults public signup to disabled and threads an explicit enablement through to identity config", () => {
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: { ...validInput().identity, publicSignupEnabled: true },
}).identity.publicSignupEnabled,
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" });
+38
View File
@@ -418,6 +418,44 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(serveMock).not.toHaveBeenCalled();
});
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The rejection happens in `createApiRuntimeConfig` itself — the same boundary the executable
// entry point calls before `startApi` — so `startApi` is never even reachable with such a
// configuration, and no runtime resource is ever constructed to leak.
let captured: unknown;
try {
createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
publicSignupEnabled: true,
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(poolEnd).not.toHaveBeenCalled();
});
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
@@ -269,7 +269,13 @@ async function startMigratedApi(
},
port: 0,
},
{ environment: "production" },
// Development validation: this suite signs an owner account up over HTTP to exercise real
// authorized module database access, and production configuration refuses to enable public
// signup at all until durable abuse controls exist
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
// URL and trusted origins here are identical either way.
{ environment: "development" },
);
return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },