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
@@ -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
+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_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