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