diff --git a/README.md b/README.md index 38f9544..9f4873a 100644 --- a/README.md +++ b/README.md @@ -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 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 -order never grants elevation — but is now **disabled by default** and requires an explicit opt-in -(Phase 5D pass 1, below). See [API](docs/development/api.md) and +order never grants elevation — but is now **disabled by default, and cannot be enabled in +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). **No email verification, password recovery, or Dashboard exists yet** — that, along with 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 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 -rate-limit bypass. Public sign-up is now **disabled by default** and requires an explicit typed -opt-in (`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true`). Every public body-consuming route — Better Auth, +rate-limit bypass. Public sign-up is now **disabled by default, and a production configuration that +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 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 diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index cdad99a..0bbf2ef 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -346,6 +346,15 @@ async function startConfiguredApi( url: string, options: StartConfiguredApiOptions = {}, ): Promise { + 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); diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 00c2e95..8a3dfa4 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -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" }); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 48d1e51..fc5d71c 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -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:ekklesia@127.0.0.1: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() { diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 47ccea0..97ad02d 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -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 }, diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts index 57309ee..e5f8247 100644 --- a/core/src/identity/config.ts +++ b/core/src/identity/config.ts @@ -6,6 +6,7 @@ export type IdentityConfigErrorCode = | "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_MISSING" | "PUBLIC_SIGNUP_ENABLED_INVALID" + | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "SECRET_LOW_ENTROPY" | "SECRET_MISSING" | "SECRET_TOO_SHORT" @@ -32,6 +33,8 @@ const errorMessages: Readonly> = Object. BASE_URL_MISSING: "The identity base URL is required.", PUBLIC_SIGNUP_ENABLED_INVALID: '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_MISSING: "The identity secret is required.", SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, @@ -66,8 +69,10 @@ export interface IdentityConfig { readonly baseUrl: string; /** 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 - * loosely parsed. Development and test composition may set this explicitly; production - * composition must set it explicitly too, since there is no environment-sensitive default. */ + * loosely parsed. Development and test composition may enable it explicitly; production + * 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 secret: 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 * (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. + * + * 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) { - 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 true; - } - if (value === "false") { - return false; - } - throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID"); + return enabled; } function validateTrustedOrigins( @@ -211,7 +233,7 @@ export function createIdentityConfig( const secret = validateSecret(input.secret); const baseUrl = validateBaseUrl(input.baseUrl, 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 }); } diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index e330d4f..c4fdbb9 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -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( { ...options(), @@ -651,8 +671,12 @@ describe("Ekklesia Doctor diagnostics", () => { ); const identity = check(report, "identity.configuration"); - expect(identity.status).toBe("pass"); - expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true); + expect(identity.status).toBe("failure"); + 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 () => { diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts index 1080999..90d629f 100644 --- a/core/tests/identity-config.test.ts +++ b/core/tests/identity-config.test.ts @@ -9,6 +9,17 @@ function validInput() { 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", () => { it("accepts a valid secret, base URL, and trusted origins", () => { const config = createIdentityConfig(validInput(), { environment: "production" }); @@ -173,24 +184,89 @@ describe("Core identity configuration", () => { expect(config.publicSignupEnabled).toBe(false); }); - it("accepts an explicit boolean publicSignupEnabled", () => { + it("accepts an explicit boolean publicSignupEnabled in development", () => { expect( - createIdentityConfig({ ...validInput(), publicSignupEnabled: true }).publicSignupEnabled, + createIdentityConfig( + { ...developmentInput(), publicSignupEnabled: true }, + { environment: "development" }, + ).publicSignupEnabled, ).toBe(true); expect( - createIdentityConfig({ ...validInput(), publicSignupEnabled: false }).publicSignupEnabled, + createIdentityConfig( + { ...developmentInput(), publicSignupEnabled: false }, + { environment: "development" }, + ).publicSignupEnabled, ).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( - createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" }).publicSignupEnabled, + createIdentityConfig( + { ...developmentInput(), publicSignupEnabled: "true" }, + { environment: "development" }, + ).publicSignupEnabled, ).toBe(true); expect( - createIdentityConfig({ ...validInput(), publicSignupEnabled: "false" }).publicSignupEnabled, + createIdentityConfig( + { ...developmentInput(), publicSignupEnabled: "false" }, + { environment: "development" }, + ).publicSignupEnabled, ).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", () => { for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) { let captured: unknown; diff --git a/docs/architecture/decisions/0017-public-request-perimeter-hardening.md b/docs/architecture/decisions/0017-public-request-perimeter-hardening.md index 2e8bd69..a4c0e26 100644 --- a/docs/architecture/decisions/0017-public-request-perimeter-hardening.md +++ b/docs/architecture/decisions/0017-public-request-perimeter-hardening.md @@ -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 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: `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 -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 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 @@ -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 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 -default only — public registration remains the intended eventual product policy, and development -or test composition may still enable it explicitly (`publicSignupEnabled: true`); ADR 0011's +This amends ADR 0011's framing ("Anyone may create an Ekklesia account") for production entirely — +public registration remains the intended eventual product policy, but is currently reachable only +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 membership record) is otherwise unchanged and still authoritative. This pass adds no email 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 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 -mode, and an invalid configuration. Public signup is closed by default everywhere except where a -deployment explicitly opts in. Every body-consuming public route — auth, Core, and every module +mode, and an invalid configuration. Public signup is closed by default everywhere, and production +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 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 diff --git a/docs/development/api.md b/docs/development/api.md index dcd68b1..1bdd805 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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_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_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_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. | @@ -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 {"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 -registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. Set -`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 -other configuration change). This pass adds no email verification, CAPTCHA, invitation, or durable -distributed abuse control — enabling public signup in production without those remains explicitly -not ready for a real public launch; see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). +registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. + +**Production cannot enable public signup at all right now.** A production configuration that sets +the flag to `true` is rejected outright with the stable +`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` configuration error, and `pnpm doctor` reports that state +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 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 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 -default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie -values, and generated ids below are sanitized/truncated examples, not real credentials. +database, configured as **development** with public sign-up explicitly enabled — public sign-up is +disabled by default and cannot be enabled in production at all (see +[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and +generated ids below are sanitized/truncated examples, not real credentials. ```text POST /api/auth/sign-up/email