From 1161f5a076b66548767d67b58853bf83ab8f24b4 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 26 Jul 2026 15:58:08 -0500 Subject: [PATCH] fix: fail closed on production signup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 11 ++- apps/api/tests/api-http.integration.ts | 17 +++- apps/api/tests/config.test.ts | 34 +++++-- apps/api/tests/lifecycle.test.ts | 38 ++++++++ apps/api/tests/module-database.integration.ts | 8 +- core/src/identity/config.ts | 50 ++++++++--- core/tests/doctor.test.ts | 30 ++++++- core/tests/identity-config.test.ts | 88 +++++++++++++++++-- ...0017-public-request-perimeter-hardening.md | 36 ++++++-- docs/development/api.md | 34 ++++--- 10 files changed, 293 insertions(+), 53 deletions(-) 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