@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.
|
|
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.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "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
|
package/src/compose-features.ts
CHANGED
|
@@ -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
|
-
|
|
182
|
-
opts.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
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,
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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. */
|