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
+7 -4
View File
@@ -37,8 +37,9 @@ Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` compositi
Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules
still cannot reach any of this. Public email/password registration is the intended eventual still cannot reach any of this. Public email/password registration is the intended eventual
product policy — every account receives identical lowest-privilege baseline status and signup product policy — every account receives identical lowest-privilege baseline status and signup
order never grants elevation — but is now **disabled by default** and requires an explicit opt-in order never grants elevation — but is now **disabled by default, and cannot be enabled in
(Phase 5D pass 1, below). See [API](docs/development/api.md) and production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, or Dashboard exists yet** — that, along with **No email verification, password recovery, or Dashboard exists yet** — that, along with
production approval-provider composition, jobs, physical storage provisioning, surfaces, and production approval-provider composition, jobs, physical storage provisioning, surfaces, and
@@ -101,8 +102,10 @@ Better Auth's authentication rate limiter now resolves client identity from a se
client-address boundary (`core/src/identity/client-address.ts`, wired in at the Node/API transport client-address boundary (`core/src/identity/client-address.ts`, wired in at the Node/API transport
boundary) — direct mode trusts only the real socket peer by default, and an explicit trusted-proxy boundary) — direct mode trusts only the real socket peer by default, and an explicit trusted-proxy
mode is required before any forwarded header is ever consulted, closing a rotating-header mode is required before any forwarded header is ever consulted, closing a rotating-header
rate-limit bypass. Public sign-up is now **disabled by default** and requires an explicit typed rate-limit bypass. Public sign-up is now **disabled by default, and a production configuration that
opt-in (`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true`). Every public body-consuming route — Better Auth, tries to enable it fails closed** (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, reported by `pnpm
doctor` as a blocking failure) — the durable abuse-control prerequisites do not exist yet, so only
development/test composition may enable it. Every public body-consuming route — Better Auth,
Core's grant-mutation endpoint, and every module route (via a new, required `maxBodyBytes` field on Core's grant-mutation endpoint, and every module route (via a new, required `maxBodyBytes` field on
the executable route contract) — now has an explicit, platform-enforced byte ceiling with one the executable route contract) — now has an explicit, platform-enforced byte ceiling with one
sanitized `413` contract. The Node HTTP server now has finite header/request timeouts. See sanitized `413` contract. The Node HTTP server now has finite header/request timeouts. See
+14 -3
View File
@@ -346,6 +346,15 @@ async function startConfiguredApi(
url: string, url: string,
options: StartConfiguredApiOptions = {}, options: StartConfiguredApiOptions = {},
): Promise<RunningApi> { ): 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( const config = createApiRuntimeConfig(
{ {
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }), ...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
@@ -353,13 +362,13 @@ async function startConfiguredApi(
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: identityBaseUrl, baseUrl: identityBaseUrl,
publicSignupEnabled: options.publicSignupEnabled ?? true, publicSignupEnabled,
secret: "integration-test-secret-0123456789abcdefgh", secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin], trustedOrigins: [identityBaseUrl, dashboardOrigin],
}, },
port: 0, port: 0,
}, },
{ environment: "production" }, { environment },
); );
return startApi(config, { return startApi(config, {
...("rateLimitOverride" in options ...("rateLimitOverride" in options
@@ -902,7 +911,9 @@ describe.sequential("live API — startup and migration guarantees", () => {
}, },
port: 0, 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 running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
const origin = apiOrigin(running); const origin = apiOrigin(running);
+29 -5
View File
@@ -133,16 +133,40 @@ describe("API runtime configuration", () => {
expect(config.identity.baseUrl).toBe("http://localhost:3000"); 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.publicSignupEnabled).toBe(false);
expect( expect(
createApiRuntimeConfig({ createApiRuntimeConfig(
...validInput(), {
identity: { ...validInput().identity, publicSignupEnabled: true }, ...validInput(),
}).identity.publicSignupEnabled, identity: {
baseUrl: "http://localhost:3000",
publicSignupEnabled: true,
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
},
{ environment: "development" },
).identity.publicSignupEnabled,
).toBe(true); ).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", () => { it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" }); expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
+38
View File
@@ -418,6 +418,44 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(serveMock).not.toHaveBeenCalled(); 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 () => { it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
@@ -269,7 +269,13 @@ async function startMigratedApi(
}, },
port: 0, 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, { return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 }, rateLimitOverride: { max: 1000, window: 1 },
+36 -14
View File
@@ -6,6 +6,7 @@ export type IdentityConfigErrorCode =
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_ENABLED_INVALID"
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
| "SECRET_MISSING" | "SECRET_MISSING"
| "SECRET_TOO_SHORT" | "SECRET_TOO_SHORT"
@@ -32,6 +33,8 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_MISSING: "The identity base URL is required.", BASE_URL_MISSING: "The identity base URL is required.",
PUBLIC_SIGNUP_ENABLED_INVALID: PUBLIC_SIGNUP_ENABLED_INVALID:
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
"Public signup cannot be enabled in production until durable signup-abuse controls exist.",
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.", SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.", SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
@@ -66,8 +69,10 @@ export interface IdentityConfig {
readonly baseUrl: string; readonly baseUrl: string;
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
* public signup never becomes reachable merely because this input was omitted, misspelled, or * public signup never becomes reachable merely because this input was omitted, misspelled, or
* loosely parsed. Development and test composition may set this explicitly; production * loosely parsed. Development and test composition may enable it explicitly; production
* composition must set it explicitly too, since there is no environment-sensitive default. */ * currently cannot enable it at all (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`), because the
* durable signup-abuse prerequisites do not exist yet — see `validatePublicSignupEnabled`. In
* production this is therefore always `false`. */
readonly publicSignupEnabled: boolean; readonly publicSignupEnabled: boolean;
readonly secret: string; readonly secret: string;
readonly trustedOrigins: readonly string[]; readonly trustedOrigins: readonly string[];
@@ -168,21 +173,38 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment
* `"false"` are accepted so an environment variable (always a string) can opt in — anything else * `"false"` are accepted so an environment variable (always a string) can opt in — anything else
* (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than * (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than
* silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch. * silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch.
*
* Parsing is deliberately separated from the production policy below: a malformed value is
* reported as malformed in *every* environment (`PUBLIC_SIGNUP_ENABLED_INVALID`), so an operator
* who typo'd the flag is never told the misleading "unsupported in production" story instead.
*
* Enabling public signup is then refused outright in production
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). This is not a preference — the durable
* signup-abuse prerequisites a public registration endpoint needs (verified email or an
* invitation requirement, plus durable shared/edge throttling rather than this release's
* per-process in-memory limiter) do not exist in this repository yet, so there is no safe
* production configuration in which the flag could be honored. Development and test composition
* may still enable it explicitly through this same typed path. When those prerequisites are
* actually implemented and proven, this check is the single place that changes.
*/ */
function validatePublicSignupEnabled(value: unknown): boolean { function validatePublicSignupEnabled(value: unknown, environment: IdentityEnvironment): boolean {
let enabled: boolean;
if (value === undefined) { if (value === undefined) {
return false; enabled = false;
} else if (typeof value === "boolean") {
enabled = value;
} else if (value === "true") {
enabled = true;
} else if (value === "false") {
enabled = false;
} else {
throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID");
} }
if (typeof value === "boolean") {
return value; if (enabled && environment === "production") {
throw new IdentityConfigError("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION");
} }
if (value === "true") { return enabled;
return true;
}
if (value === "false") {
return false;
}
throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID");
} }
function validateTrustedOrigins( function validateTrustedOrigins(
@@ -211,7 +233,7 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
} }
+27 -3
View File
@@ -635,7 +635,27 @@ describe("Ekklesia Doctor diagnostics", () => {
); );
}); });
it("reports public signup as explicitly enabled when configured", async () => { it("reports public signup as explicitly enabled when configured in development", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "http://localhost:3000",
environment: "development",
publicSignupEnabled: "true",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["http://localhost:3000"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass");
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true);
});
it("blocks readiness — not merely warns — when production configuration enables public signup", async () => {
const report = await diagnoseEkklesia( const report = await diagnoseEkklesia(
{ {
...options(), ...options(),
@@ -651,8 +671,12 @@ describe("Ekklesia Doctor diagnostics", () => {
); );
const identity = check(report, "identity.configuration"); const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass"); expect(identity.status).toBe("failure");
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true); expect(
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION")),
).toBe(true);
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
}); });
it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => { it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => {
+82 -6
View File
@@ -9,6 +9,17 @@ function validInput() {
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins }; return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
} }
/** The loopback/`http` shape only `{ environment: "development" }` accepts — used by the tests that
* exercise development-only behavior (such as explicitly enabling public signup) so they never
* accidentally pass through production validation. */
function developmentInput() {
return {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
};
}
describe("Core identity configuration", () => { describe("Core identity configuration", () => {
it("accepts a valid secret, base URL, and trusted origins", () => { it("accepts a valid secret, base URL, and trusted origins", () => {
const config = createIdentityConfig(validInput(), { environment: "production" }); const config = createIdentityConfig(validInput(), { environment: "production" });
@@ -173,24 +184,89 @@ describe("Core identity configuration", () => {
expect(config.publicSignupEnabled).toBe(false); expect(config.publicSignupEnabled).toBe(false);
}); });
it("accepts an explicit boolean publicSignupEnabled", () => { it("accepts an explicit boolean publicSignupEnabled in development", () => {
expect( expect(
createIdentityConfig({ ...validInput(), publicSignupEnabled: true }).publicSignupEnabled, createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: true },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true); ).toBe(true);
expect( expect(
createIdentityConfig({ ...validInput(), publicSignupEnabled: false }).publicSignupEnabled, createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: false },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false); ).toBe(false);
}); });
it('accepts the exact strings "true"/"false" (environment variables are always strings)', () => { it('accepts the exact strings "true"/"false" in development (environment variables are always strings)', () => {
expect( expect(
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" }).publicSignupEnabled, createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "true" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true); ).toBe(true);
expect( expect(
createIdentityConfig({ ...validInput(), publicSignupEnabled: "false" }).publicSignupEnabled, createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "false" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false); ).toBe(false);
}); });
it("accepts an explicitly disabled publicSignupEnabled in production", () => {
for (const publicSignupEnabled of [false, "false"]) {
expect(
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
).publicSignupEnabled,
).toBe(false);
}
});
it("refuses to enable public signup in production until durable abuse controls exist", () => {
// Ekklesia has no verified email, invitation requirement, CAPTCHA, or durable shared/edge
// limiter yet, so a production deployment must not be able to open public registration at all
// — not even deliberately. This is a fail-closed configuration error, never a warning.
for (const publicSignupEnabled of [true, "true"]) {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
}
});
it("defaults to production validation for public signup when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
});
it("reports a malformed public-signup value as malformed even in production, never as unsupported", () => {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled: "TRUE" },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
});
it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => { it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => {
for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) { for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) {
let captured: unknown; let captured: unknown;
@@ -76,12 +76,32 @@ is never consulted at all. The in-memory rate-limit store remains per-process (`
runtime.ts`, unchanged from ADR 0011) — this pass does not add distributed storage; a genuinely runtime.ts`, unchanged from ADR 0011) — this pass does not add distributed storage; a genuinely
multi-instance deployment still needs a shared or edge limiter, reserved for pass 5. multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
### Public signup is disabled by default ### Public signup is disabled by default, and cannot be enabled in production at all
`IdentityConfig.publicSignupEnabled` (`core/src/identity/config.ts`) is a strict boolean: `IdentityConfig.publicSignupEnabled` (`core/src/identity/config.ts`) is a strict boolean:
`undefined` input means `false` (disabled); only an explicit boolean or the exact strings `undefined` input means `false` (disabled); only an explicit boolean or the exact strings
`"true"`/`"false"` are accepted, so an absent, misspelled, or loosely parsed environment variable `"true"`/`"false"` are accepted, so an absent, misspelled, or loosely parsed environment variable
can never enable it. `core/src/identity/runtime.ts` wires this directly to Better Auth's own can never enable it.
**Production refuses enablement outright.** `validatePublicSignupEnabled` takes the same
`IdentityEnvironment` the rest of this config already validates against, and throws the stable
`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` error whenever the parsed value is `true` in production.
The durable signup-abuse prerequisites a public registration endpoint requires — verified email or
an invitation requirement, plus durable shared/edge throttling rather than this release's
per-process in-memory limiter — do not exist in this repository, so there is no production
configuration in which the flag could be honored safely; permitting a deliberate opt-in would be
offering a switch whose safe position does not exist yet. Parsing is kept separate from this policy
so a malformed value still reports `PUBLIC_SIGNUP_ENABLED_INVALID` in every environment, and an
operator who typo'd the flag is never told the misleading "unsupported in production" story
instead. Because `apps/api`'s executable entry point calls `createApiRuntimeConfig` *before*
`startApi`, such a deployment fails before any PostgreSQL pool, Better Auth instance, module
runtime, or listener exists. `pnpm doctor` surfaces the same state as a blocking `NOT READY`
failure (via its existing `IdentityConfigError` path), never a warning. Development and test
composition may still enable it explicitly through the identical typed path — that is how this
repository's own integration suite creates accounts over HTTP. When the prerequisites are actually
implemented and proven, this one function is the single place that changes.
`core/src/identity/runtime.ts` wires the validated flag directly to Better Auth's own
`emailAndPassword.disableSignUp` flag: when disabled, `POST /sign-up/email` rejects every call `emailAndPassword.disableSignUp` flag: when disabled, `POST /sign-up/email` rejects every call
with a fixed `400 {"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` before Better Auth ever looks up the with a fixed `400 {"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` before Better Auth ever looks up the
email, so a disabled deployment cannot leak whether an address is already registered, and email, so a disabled deployment cannot leak whether an address is already registered, and
@@ -92,9 +112,10 @@ platform-owned check, since the pinned release's own mechanism already satisfies
property (no email-existence leak, no business logic invoked, a stable code). Since email/password property (no email-existence leak, no business logic invoked, a stable code). Since email/password
is the only enabled provider (ADR 0011), there is no second signup entry point to separately guard. is the only enabled provider (ADR 0011), there is no second signup entry point to separately guard.
This amends ADR 0011's framing ("Anyone may create an Ekklesia account") for the production This amends ADR 0011's framing ("Anyone may create an Ekklesia account") for production entirely —
default only — public registration remains the intended eventual product policy, and development public registration remains the intended eventual product policy, but is currently reachable only
or test composition may still enable it explicitly (`publicSignupEnabled: true`); ADR 0011's in development/test composition (`publicSignupEnabled: true` with `environment: "development"`);
ADR 0011's
architectural description of what a registered account *is* (an authentication identity, not a architectural description of what a registered account *is* (an authentication identity, not a
membership record) is otherwise unchanged and still authoritative. This pass adds no email membership record) is otherwise unchanged and still authoritative. This pass adds no email
verification, CAPTCHA, invitation, or durable abuse control — those remain explicitly deferred verification, CAPTCHA, invitation, or durable abuse control — those remain explicitly deferred
@@ -198,8 +219,9 @@ Direct mode (the default) trusts only the real socket peer for every request; ex
trusted-proxy mode requires a header name and a concrete, non-empty trusted-proxy list, validated trusted-proxy mode requires a header name and a concrete, non-empty trusted-proxy list, validated
the same way in `core/src/identity/client-address.ts` and surfaced by Doctor the same way in `core/src/identity/client-address.ts` and surfaced by Doctor
(`identity.client-address`), which distinguishes safe direct mode, an explicitly configured proxy (`identity.client-address`), which distinguishes safe direct mode, an explicitly configured proxy
mode, and an invalid configuration. Public signup is closed by default everywhere except where a mode, and an invalid configuration. Public signup is closed by default everywhere, and production
deployment explicitly opts in. Every body-consuming public route — auth, Core, and every module cannot open it at all until the durable abuse-control prerequisites exist — only development and
test composition may enable it. Every body-consuming public route — auth, Core, and every module
route — now has an explicit, enforced byte ceiling with one sanitized `413` contract. The Node route — now has an explicit, enforced byte ceiling with one sanitized `413` contract. The Node
server itself now bounds a slow-header or slow-body connection instead of relying on Node's server itself now bounds a slow-header or slow-body connection instead of relying on Node's
generous multi-minute defaults. None of this constitutes distributed rate limiting, a durable generous multi-minute defaults. None of this constitutes distributed rate limiting, a durable
+24 -10
View File
@@ -55,7 +55,7 @@ No real values are shown here — configure per environment and never commit the
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. | | `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. | | `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. | | `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional. Exactly `"true"` to enable public sign-up. Defaults to disabled — omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). | | `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). | | `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). | | `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. | | `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
@@ -112,12 +112,25 @@ pinned release's own mechanism already has every required property: every `POST
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400 /api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever {"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
looks up the email, so a disabled deployment never reveals whether an address is already looks up the email, so a disabled deployment never reveals whether an address is already
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. Set registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate.
`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly to enable it (development and test composition do
this routinely; production composition must do so deliberately, with the same audit trail as any **Production cannot enable public signup at all right now.** A production configuration that sets
other configuration change). This pass adds no email verification, CAPTCHA, invitation, or durable the flag to `true` is rejected outright with the stable
distributed abuse control — enabling public signup in production without those remains explicitly `PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` configuration error, and `pnpm doctor` reports that state
not ready for a real public launch; see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). as a blocking `NOT READY` failure rather than a warning. This is not a preference: the durable
signup-abuse prerequisites a public registration endpoint needs — verified email or an invitation
requirement, and durable shared/edge throttling rather than this release's per-process in-memory
limiter — **do not exist in this repository yet**, so there is no production configuration in which
the flag could be honored safely. The rejection happens in `createIdentityConfig`, which
`apps/api`'s executable entry point calls *before* `startApi`, so such a deployment never opens a
PostgreSQL pool, constructs Better Auth, starts the module runtime, or binds a listener.
Development and test composition may still enable it explicitly, through the same typed path
(`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` with `NODE_ENV=development`, or
`{ environment: "development" }` programmatically) — that is how this repository's own integration
suite creates accounts over HTTP. When the prerequisites above are actually implemented and proven,
`validatePublicSignupEnabled` (`core/src/identity/config.ts`) is the single place that changes. See
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
registered account is an **authentication identity** — proof that someone can present this email registered account is an **authentication identity** — proof that someone can present this email
@@ -489,9 +502,10 @@ runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never
## A real signup → sign-in → principal → sign-out transcript ## A real signup → sign-in → principal → sign-out transcript
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
database, with `EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly set (public sign-up is disabled by database, configured as **development** with public sign-up explicitly enabled — public sign-up is
default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie disabled by default and cannot be enabled in production at all (see
values, and generated ids below are sanitized/truncated examples, not real credentials. [Public registration policy](#public-registration-policy)). Session tokens, cookie values, and
generated ids below are sanitized/truncated examples, not real credentials.
```text ```text
POST /api/auth/sign-up/email POST /api/auth/sign-up/email