@cosmicdrift/kumiko-server-runtime 0.166.0 → 0.167.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.166.0",
3
+ "version": "0.167.1",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -72,8 +72,8 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@cosmicdrift/kumiko-bundled-features": "0.166.0",
76
- "@cosmicdrift/kumiko-framework": "0.166.0",
75
+ "@cosmicdrift/kumiko-bundled-features": "0.167.1",
76
+ "@cosmicdrift/kumiko-framework": "0.167.1",
77
77
  "temporal-polyfill": "^0.3.2"
78
78
  },
79
79
  "publishConfig": {
@@ -329,6 +329,75 @@ describe("composeFeatures wiring — asymmetric activation", () => {
329
329
  });
330
330
  });
331
331
 
332
+ describe("composeFeatures wiring — accountLockout (kumiko-framework#1627)", () => {
333
+ // Pinst dass authOptions.accountLockout genauso durchgereicht wird wie
334
+ // passwordReset/emailVerification — der Bug-Fall war, dass runDevApp die
335
+ // Option gar nicht erst annahm (kein Feld auf RunDevAppAuthOptions) und
336
+ // buildComposeAuthOptions sie ignoriert hätte, selbst wenn doch. Anders als
337
+ // reset/verify mountet accountLockout keine eigenen Routes — der Beweis
338
+ // ist Login-Verhalten, kein Mail-Capture.
339
+ let stack: TestStack;
340
+ const MAX_ATTEMPTS = 2;
341
+
342
+ beforeAll(async () => {
343
+ const features = composeFeatures([], {
344
+ includeBundled: true,
345
+ authOptions: {
346
+ accountLockout: { maxFailedAttempts: MAX_ATTEMPTS, lockoutDurationMinutes: 1 },
347
+ },
348
+ });
349
+ stack = await setupTestStack({
350
+ features,
351
+ extraContext: { configResolver: createConfigResolver() },
352
+ authConfig: {
353
+ membershipQuery: "tenant:query:memberships",
354
+ loginHandler: AuthHandlers.login,
355
+ loginErrorStatusMap: {
356
+ [AuthErrors.invalidCredentials]: 401,
357
+ [AuthErrors.noMembership]: 403,
358
+ [AuthErrors.accountLocked]: 423,
359
+ },
360
+ },
361
+ });
362
+ await unsafeCreateEntityTable(stack.db, userEntity);
363
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
364
+ await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
365
+ });
366
+
367
+ afterAll(async () => {
368
+ await stack.cleanup();
369
+ });
370
+
371
+ afterEach(async () => {
372
+ await deleteRows(stack.db, userTable, {});
373
+ await deleteRows(stack.db, tenantMembershipsTable, {});
374
+ await stack.redis.flushNamespace();
375
+ });
376
+
377
+ test("MAX_ATTEMPTS wrong passwords via the wrapper path lock the account (423)", async () => {
378
+ await seedUser(stack, { email: "lockout@example.com", password: "right-pw-1234" });
379
+
380
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
381
+ const res = await stack.http.raw("POST", "/api/auth/login", {
382
+ email: "lockout@example.com",
383
+ password: `wrong-${i}`,
384
+ });
385
+ expect(res.status).toBe(401);
386
+ }
387
+
388
+ // Correct password no longer helps once locked — proves the option
389
+ // actually reached createLoginHandler's accountLockout param, not just
390
+ // the type.
391
+ const lockedRes = await stack.http.raw("POST", "/api/auth/login", {
392
+ email: "lockout@example.com",
393
+ password: "right-pw-1234",
394
+ });
395
+ expect(lockedRes.status).toBe(423);
396
+ const body = (await lockedRes.json()) as { error?: { details?: { reason?: string } } };
397
+ expect(body.error?.details?.reason).toBe(AuthErrors.accountLocked);
398
+ });
399
+ });
400
+
332
401
  describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
333
402
  // Der Bug den der Review-Agent gefangen hat. Whitebox-Variante in
334
403
  // compose-features.test.ts checkt nur Object.keys(writeHandlers); hier
@@ -14,6 +14,7 @@
14
14
  // ones (e.g. authClaims hooks on user/tenant).
15
15
 
16
16
  import {
17
+ type AccountLockoutOptions,
17
18
  type AccountUnlockOptions,
18
19
  type AuthEmailPasswordOptions,
19
20
  type AuthMailLocale,
@@ -116,6 +117,7 @@ export type AuthOptionsCarrier = {
116
117
  readonly signup?: SignupOptions;
117
118
  readonly invite?: InviteOptions;
118
119
  readonly accountUnlock?: AccountUnlockOptions;
120
+ readonly accountLockout?: AccountLockoutOptions;
119
121
  };
120
122
 
121
123
  /** Baut den authOptions-Block für composeFeatures aus einem
@@ -178,11 +180,12 @@ export function buildComposeAuthOptions(
178
180
  ...pickMailFields(auth.accountUnlock),
179
181
  };
180
182
  }
181
- return opts.passwordReset ||
182
- opts.emailVerification ||
183
- opts.signup ||
184
- opts.invite ||
185
- opts.accountUnlock
186
- ? opts
187
- : undefined;
183
+ if (auth.accountLockout) {
184
+ opts.accountLockout = auth.accountLockout;
185
+ }
186
+ return hasAnyAuthFlow(opts) ? opts : undefined;
187
+ }
188
+
189
+ function hasAnyAuthFlow(opts: AuthEmailPasswordOptions): boolean {
190
+ return Object.values(opts).some(Boolean);
188
191
  }
@@ -159,10 +159,9 @@ type AuthMailNormalizable = {
159
159
 
160
160
  // accountUnlock (#1266) deliberately does NOT join this convenience block —
161
161
  // unlike reset/verify/signup/invite it's only meaningful paired with
162
- // `accountLockout`, which itself isn't wired through RunProdAppAuthOptions
163
- // today. Apps that mount `accountLockout` set `auth.accountUnlock` alongside
164
- // it explicitly (same shape as `passwordReset`), so `mail` alone can't
165
- // silently expose a new public endpoint an app didn't ask for.
162
+ // `accountLockout` (kumiko-framework#1627), and apps set both explicitly
163
+ // alongside each other (same shape as `passwordReset`), so `mail` alone
164
+ // can't silently expose a new public endpoint an app didn't ask for.
166
165
 
167
166
  export function resolveAuthMail<T extends AuthMailNormalizable>(
168
167
  auth: T,
@@ -40,6 +40,7 @@
40
40
  // KUMIKO_INSTANCE_ID=<stable per replica>
41
41
 
42
42
  import {
43
+ type AccountLockoutOptions,
43
44
  type AccountUnlockOptions,
44
45
  AuthErrors,
45
46
  AuthHandlers,
@@ -270,6 +271,14 @@ export type InviteSetup = InviteOptions;
270
271
  * independently like the other flows. */
271
272
  export type AccountUnlockSetup = AccountUnlockOptions;
272
273
 
274
+ /** Wrapper API for the account-lockout brute-force guard on the login
275
+ * handler. = AccountLockoutOptions (maxFailedAttempts, lockoutDurationMinutes
276
+ * — no mail/appUrl fields, it doesn't mount any routes). Symmetric to
277
+ * RunDevAppAuthOptions.accountLockout (kumiko-framework#1627) — before this,
278
+ * `accountUnlock` existed on both wrappers with no way to ever set the
279
+ * lockout it's meant to escape. */
280
+ export type AccountLockoutSetup = AccountLockoutOptions;
281
+
273
282
  /** Auth-Mail-Convenience-Optionen — shared zwischen runProdApp + runDevApp.
274
283
  * Verdrahtet alle 4 Mail-Flows aus einem env-SMTP-Transport + Standard-
275
284
  * Templates (siehe `auth.mail` + resolveAuthMail). */
@@ -331,6 +340,11 @@ export type RunProdAppAuthOptions = {
331
340
  * hatch for accountLockout's monotonic failure-counter — confirming
332
341
  * clears the Redis lockout state, no entity write. */
333
342
  readonly accountUnlock?: AccountUnlockSetup;
343
+ /** Brute-force guard on the login handler (maxFailedAttempts,
344
+ * lockoutDurationMinutes). Mounts no routes — unlike the other flows,
345
+ * it's a login-handler config knob, not a magic-link flow. Pair with
346
+ * `accountUnlock` for a self-service escape hatch. */
347
+ readonly accountLockout?: AccountLockoutSetup;
334
348
  /** Domain attribute for both auth cookies (see
335
349
  * AuthRoutesConfig.cookieDomain). Set to the registrable parent
336
350
  * domain when login and app live on different subdomains. */