@cosmicdrift/kumiko-bundled-features 0.109.0 → 0.111.0
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 +6 -6
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +9 -10
- package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +7 -4
- package/src/auth-email-password/__tests__/feature-options.test.ts +33 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
- package/src/auth-email-password/constants.ts +4 -0
- package/src/auth-email-password/feature.ts +8 -4
- package/src/config/__tests__/config.integration.test.ts +7 -8
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
- package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
- package/src/config/feature.ts +14 -8
- package/src/config/handlers/reencrypt.job.ts +197 -0
- package/src/config/handlers/set.write.ts +2 -2
- package/src/config/index.ts +4 -3
- package/src/config/resolver.ts +34 -10
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
- package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
- package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
- package/src/secrets/handlers/rotate.job.ts +77 -98
- package/src/secrets/secrets-context.ts +7 -38
- package/src/secrets/table.ts +6 -7
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +71 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- package/src/sessions/feature.ts +51 -18
- package/src/sessions/index.ts +2 -2
- package/src/shared/chunked-entity-migration.ts +89 -0
- package/src/shared/index.ts +7 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
- package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
- package/src/user/__tests__/user.integration.test.ts +26 -0
- package/src/user/handlers/find-for-auth.query.ts +7 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
- package/src/user-data-rights/run-user-export.ts +42 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.111.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
98
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.111.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.111.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.111.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.111.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.111.0",
|
|
103
103
|
"@mollie/api-client": "^4.5.0",
|
|
104
104
|
"@node-rs/argon2": "^2.0.2",
|
|
105
105
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
14
|
import { randomBytes } from "node:crypto";
|
|
15
15
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
17
16
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
17
|
import {
|
|
19
18
|
setupTestStack,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
unsafeCreateEntityTable,
|
|
23
22
|
unsafePushTables,
|
|
24
23
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
25
|
import { createConfigFeature } from "../../config";
|
|
26
26
|
import { createConfigResolver } from "../../config/resolver";
|
|
27
27
|
import { configValuesTable } from "../../config/table";
|
|
@@ -42,8 +42,8 @@ const systemAdmin = TestUsers.systemAdmin;
|
|
|
42
42
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
43
43
|
|
|
44
44
|
beforeAll(async () => {
|
|
45
|
-
const encryption =
|
|
46
|
-
const resolver = createConfigResolver({ encryption });
|
|
45
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
46
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
47
47
|
|
|
48
48
|
stack = await setupTestStack({
|
|
49
49
|
features: [
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
16
15
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
16
|
import {
|
|
18
17
|
setupTestStack,
|
|
@@ -21,6 +20,7 @@ import {
|
|
|
21
20
|
unsafeCreateEntityTable,
|
|
22
21
|
unsafePushTables,
|
|
23
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
24
24
|
import { createConfigFeature } from "../../config";
|
|
25
25
|
import { createConfigResolver } from "../../config/resolver";
|
|
26
26
|
import { configValuesTable } from "../../config/table";
|
|
@@ -48,8 +48,8 @@ const MAX_ATTEMPTS = 3;
|
|
|
48
48
|
const LOCK_MINUTES = 1;
|
|
49
49
|
|
|
50
50
|
beforeAll(async () => {
|
|
51
|
-
const encryption =
|
|
52
|
-
const resolver = createConfigResolver({ encryption });
|
|
51
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
52
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
53
53
|
|
|
54
54
|
stack = await setupTestStack({
|
|
55
55
|
features: [
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
4
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
6
|
import {
|
|
@@ -12,7 +11,7 @@ import {
|
|
|
12
11
|
unsafeCreateEntityTable,
|
|
13
12
|
unsafePushTables,
|
|
14
13
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
-
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
14
|
+
import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
16
15
|
import { createConfigFeature } from "../../config";
|
|
17
16
|
import { createConfigResolver } from "../../config/resolver";
|
|
18
17
|
import { configValuesTable } from "../../config/table";
|
|
@@ -68,8 +67,8 @@ const segmentsByUserAndTenant = new Map<string, string>();
|
|
|
68
67
|
const plansByTenant = new Map<TenantId, string>();
|
|
69
68
|
|
|
70
69
|
beforeAll(async () => {
|
|
71
|
-
const encryption =
|
|
72
|
-
const resolver = createConfigResolver({ encryption });
|
|
70
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
71
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
73
72
|
|
|
74
73
|
stack = await setupTestStack({
|
|
75
74
|
features: [
|
|
@@ -260,8 +259,8 @@ describe("scenario 2: switch-tenant recomputes claims (no stale tenant-A claims
|
|
|
260
259
|
|
|
261
260
|
describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
|
|
262
261
|
test("one feature returns key with ':' → only that key dropped, other features merge cleanly", async () => {
|
|
263
|
-
const localEncryption =
|
|
264
|
-
const localResolver = createConfigResolver({
|
|
262
|
+
const localEncryption = createTestEnvelopeCipher(encryptionKey);
|
|
263
|
+
const localResolver = createConfigResolver({ cipher: localEncryption });
|
|
265
264
|
// A polluted feature that tries to smuggle in a qualified-name-shaped
|
|
266
265
|
// inner key (injecting ":" would otherwise let it spoof another
|
|
267
266
|
// feature's prefix). Plus a clean companion to prove the drop is
|
|
@@ -337,8 +336,8 @@ describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
|
|
|
337
336
|
|
|
338
337
|
describe("scenario 2.6: multi-feature drift warnings fire independently", () => {
|
|
339
338
|
test("feature A + feature B both return undeclared keys → each logs its own warning", async () => {
|
|
340
|
-
const localEncryption =
|
|
341
|
-
const localResolver = createConfigResolver({
|
|
339
|
+
const localEncryption = createTestEnvelopeCipher(encryptionKey);
|
|
340
|
+
const localResolver = createConfigResolver({ cipher: localEncryption });
|
|
342
341
|
|
|
343
342
|
// Capture warn() calls to verify drift warnings fire per-feature, not
|
|
344
343
|
// globally collapsed.
|
|
@@ -425,8 +424,8 @@ describe("scenario 2.6: multi-feature drift warnings fire independently", () =>
|
|
|
425
424
|
|
|
426
425
|
describe("scenario 3: a broken claims hook does not break login", () => {
|
|
427
426
|
test("hook throws → login still succeeds, broken feature simply contributes nothing, warn logged with featureName+err", async () => {
|
|
428
|
-
const localEncryption =
|
|
429
|
-
const localResolver = createConfigResolver({
|
|
427
|
+
const localEncryption = createTestEnvelopeCipher(encryptionKey);
|
|
428
|
+
const localResolver = createConfigResolver({ cipher: localEncryption });
|
|
430
429
|
|
|
431
430
|
// Capture warn-calls so the test can verify the resolver actually
|
|
432
431
|
// logged the broken hook with featureName + err.message — previously
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
4
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import {
|
|
7
6
|
createTestUser,
|
|
@@ -13,6 +12,7 @@ import {
|
|
|
13
12
|
unsafePushTables,
|
|
14
13
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
14
|
import {
|
|
15
|
+
createTestEnvelopeCipher,
|
|
16
16
|
expectErrorIncludes,
|
|
17
17
|
getSetCookieRaw,
|
|
18
18
|
getSetCookieValue,
|
|
@@ -37,8 +37,8 @@ const systemAdmin = TestUsers.systemAdmin;
|
|
|
37
37
|
const encryptionKey = randomBytes(32).toString("base64");
|
|
38
38
|
|
|
39
39
|
beforeAll(async () => {
|
|
40
|
-
const encryption =
|
|
41
|
-
const resolver = createConfigResolver({ encryption });
|
|
40
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
41
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
42
42
|
|
|
43
43
|
stack = await setupTestStack({
|
|
44
44
|
features: [
|
|
@@ -374,8 +374,8 @@ describe("scenario 7b: login rate limiting", () => {
|
|
|
374
374
|
let rlStack: TestStack;
|
|
375
375
|
|
|
376
376
|
beforeAll(async () => {
|
|
377
|
-
const encryption =
|
|
378
|
-
const resolver = createConfigResolver({ encryption });
|
|
377
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
378
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
379
379
|
const { createInMemoryLoginRateLimiter } = await import("@cosmicdrift/kumiko-framework/api");
|
|
380
380
|
|
|
381
381
|
rlStack = await setupTestStack({
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
4
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import {
|
|
7
6
|
setupTestStack,
|
|
@@ -10,7 +9,11 @@ import {
|
|
|
10
9
|
unsafeCreateEntityTable,
|
|
11
10
|
unsafePushTables,
|
|
12
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
createTestEnvelopeCipher,
|
|
14
|
+
seedRow,
|
|
15
|
+
updateRows,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
17
|
import { Temporal } from "temporal-polyfill";
|
|
15
18
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
16
19
|
import { createConfigFeature } from "../../config";
|
|
@@ -50,8 +53,8 @@ const appVerifyUrl = "https://app.example.com/verify";
|
|
|
50
53
|
const appResetUrl = "https://app.example.com/reset";
|
|
51
54
|
|
|
52
55
|
beforeAll(async () => {
|
|
53
|
-
const encryption =
|
|
54
|
-
const resolver = createConfigResolver({ encryption });
|
|
56
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
57
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
55
58
|
|
|
56
59
|
stack = await setupTestStack({
|
|
57
60
|
features: [
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { MIN_HMAC_SECRET_LENGTH } from "../constants";
|
|
3
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
4
|
+
|
|
5
|
+
// A short HMAC secret makes reset/verify tokens forgeable (account takeover),
|
|
6
|
+
// so the factory must fail fast — same bar as the ≥32-char JWT_SECRET check.
|
|
7
|
+
|
|
8
|
+
const okSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH);
|
|
9
|
+
const shortSecret = "x".repeat(MIN_HMAC_SECRET_LENGTH - 1);
|
|
10
|
+
const appUrl = "https://app.example.com/flow";
|
|
11
|
+
|
|
12
|
+
describe("createAuthEmailPasswordFeature hmacSecret validation", () => {
|
|
13
|
+
test("rejects a short passwordReset.hmacSecret", () => {
|
|
14
|
+
expect(() =>
|
|
15
|
+
createAuthEmailPasswordFeature({ passwordReset: { hmacSecret: shortSecret, appUrl } }),
|
|
16
|
+
).toThrow(/passwordReset\.hmacSecret must be/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("rejects a short emailVerification.hmacSecret", () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
createAuthEmailPasswordFeature({ emailVerification: { hmacSecret: shortSecret, appUrl } }),
|
|
22
|
+
).toThrow(/emailVerification\.hmacSecret must be/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("accepts secrets at the minimum length", () => {
|
|
26
|
+
expect(() =>
|
|
27
|
+
createAuthEmailPasswordFeature({
|
|
28
|
+
passwordReset: { hmacSecret: okSecret, appUrl },
|
|
29
|
+
emailVerification: { hmacSecret: okSecret, appUrl },
|
|
30
|
+
}),
|
|
31
|
+
).not.toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
9
|
import { pbkdf2Sync, randomBytes } from "node:crypto";
|
|
10
10
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
12
11
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
12
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
13
|
import {
|
|
@@ -18,6 +17,7 @@ import {
|
|
|
18
17
|
unsafeCreateEntityTable,
|
|
19
18
|
unsafePushTables,
|
|
20
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
21
|
import { createConfigFeature } from "../../config";
|
|
22
22
|
import { createConfigResolver } from "../../config/resolver";
|
|
23
23
|
import { configValuesTable } from "../../config/table";
|
|
@@ -50,8 +50,8 @@ function buildBmcStyleV3Hash(password: string, salt: Buffer): string {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
beforeAll(async () => {
|
|
53
|
-
const encryption =
|
|
54
|
-
const resolver = createConfigResolver({ encryption });
|
|
53
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
54
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
55
55
|
|
|
56
56
|
stack = await setupTestStack({
|
|
57
57
|
features: [
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
4
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import {
|
|
7
6
|
setupTestStack,
|
|
@@ -10,7 +9,7 @@ import {
|
|
|
10
9
|
unsafeCreateEntityTable,
|
|
11
10
|
unsafePushTables,
|
|
12
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
-
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
|
+
import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
14
13
|
import { Temporal } from "temporal-polyfill";
|
|
15
14
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
16
15
|
import { createConfigFeature } from "../../config";
|
|
@@ -51,8 +50,8 @@ const resetSecret = randomBytes(32).toString("base64");
|
|
|
51
50
|
const appResetUrl = "https://app.example.com/reset";
|
|
52
51
|
|
|
53
52
|
beforeAll(async () => {
|
|
54
|
-
const encryption =
|
|
55
|
-
const resolver = createConfigResolver({ encryption });
|
|
53
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
54
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
56
55
|
|
|
57
56
|
stack = await setupTestStack({
|
|
58
57
|
features: [
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
8
|
import { randomBytes } from "node:crypto";
|
|
9
9
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
11
10
|
import {
|
|
12
11
|
setupTestStack,
|
|
13
12
|
type TestStack,
|
|
14
13
|
unsafeCreateEntityTable,
|
|
15
14
|
unsafePushTables,
|
|
16
15
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
17
|
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
18
18
|
import { createConfigFeature } from "../../config";
|
|
19
19
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -40,8 +40,8 @@ const resetSecret = randomBytes(32).toString("base64");
|
|
|
40
40
|
const verifySecret = randomBytes(32).toString("base64");
|
|
41
41
|
|
|
42
42
|
beforeAll(async () => {
|
|
43
|
-
const encryption =
|
|
44
|
-
const resolver = createConfigResolver({ encryption });
|
|
43
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
44
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
45
45
|
|
|
46
46
|
stack = await setupTestStack({
|
|
47
47
|
features: [
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
5
4
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
5
|
import {
|
|
7
6
|
setupTestStack,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
unsafeCreateEntityTable,
|
|
11
10
|
unsafePushTables,
|
|
12
11
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
12
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
13
13
|
import * as jose from "jose";
|
|
14
14
|
import { createConfigFeature } from "../../config";
|
|
15
15
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -90,8 +90,8 @@ const TENANT_A: TenantId = "00000000-0000-4000-8000-0000000000a1";
|
|
|
90
90
|
const TENANT_B: TenantId = "00000000-0000-4000-8000-0000000000b1";
|
|
91
91
|
|
|
92
92
|
beforeAll(async () => {
|
|
93
|
-
const encryption =
|
|
94
|
-
const resolver = createConfigResolver({ encryption });
|
|
93
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
94
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
95
95
|
|
|
96
96
|
store = createFakeStore();
|
|
97
97
|
sidStream = deterministicSids();
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// der server-side Zugriff (handlers, dispatcher) erhalten.
|
|
7
7
|
export const AUTH_EMAIL_PASSWORD_FEATURE = "auth-email-password" as const;
|
|
8
8
|
|
|
9
|
+
// Minimum length for reset/verify hmacSecret — mirrors the ≥32-char
|
|
10
|
+
// JWT_SECRET env check (HMAC-SHA256 key material).
|
|
11
|
+
export const MIN_HMAC_SECRET_LENGTH = 32;
|
|
12
|
+
|
|
9
13
|
// Qualified handler names. Non-CRUD handlers, no entity prefix.
|
|
10
14
|
export const AuthHandlers = {
|
|
11
15
|
login: "auth-email-password:write:login",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { MIN_HMAC_SECRET_LENGTH } from "./constants";
|
|
3
4
|
import type { AuthMailLocale } from "./email-templates";
|
|
4
5
|
import { changePasswordWrite } from "./handlers/change-password.write";
|
|
5
6
|
import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
|
|
@@ -122,14 +123,17 @@ export type AuthEmailPasswordOptions = {
|
|
|
122
123
|
export function createAuthEmailPasswordFeature(
|
|
123
124
|
opts: AuthEmailPasswordOptions = {},
|
|
124
125
|
): FeatureDefinition {
|
|
125
|
-
|
|
126
|
+
// ≥32 chars, symmetric to the JWT_SECRET env check: a short HMAC secret
|
|
127
|
+
// makes reset/verify tokens forgeable — that's account takeover, so the
|
|
128
|
+
// factory fails fast instead of trusting the caller.
|
|
129
|
+
if (opts.passwordReset && opts.passwordReset.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
126
130
|
throw new Error(
|
|
127
|
-
|
|
131
|
+
`[auth-email-password] passwordReset.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
128
132
|
);
|
|
129
133
|
}
|
|
130
|
-
if (opts.emailVerification &&
|
|
134
|
+
if (opts.emailVerification && opts.emailVerification.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
|
|
131
135
|
throw new Error(
|
|
132
|
-
|
|
136
|
+
`[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
|
|
133
137
|
);
|
|
134
138
|
}
|
|
135
139
|
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
|
-
import {
|
|
5
|
-
createEncryptionProvider,
|
|
6
|
-
type DbConnection,
|
|
7
|
-
seedConfigValues,
|
|
8
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { type DbConnection, seedConfigValues } from "@cosmicdrift/kumiko-framework/db";
|
|
9
5
|
import {
|
|
10
6
|
access,
|
|
11
7
|
createSeed,
|
|
@@ -24,7 +20,10 @@ import {
|
|
|
24
20
|
TestUsers,
|
|
25
21
|
unsafePushTables,
|
|
26
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
27
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createTestEnvelopeCipher,
|
|
25
|
+
expectErrorIncludes,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
28
27
|
import { z } from "zod";
|
|
29
28
|
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
30
29
|
import { createConfigAccessor, createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
@@ -257,8 +256,8 @@ const transportFeature = defineFeature("transport", (r) => {
|
|
|
257
256
|
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
258
257
|
|
|
259
258
|
beforeAll(async () => {
|
|
260
|
-
const encryption =
|
|
261
|
-
resolver = createConfigResolver({ encryption });
|
|
259
|
+
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
260
|
+
resolver = createConfigResolver({ cipher: encryption });
|
|
262
261
|
|
|
263
262
|
stack = await setupTestStack({
|
|
264
263
|
features: [
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Pins the legacy → envelope migration story for encrypted config values:
|
|
2
|
+
// 1. rows written under the old CONFIG_ENCRYPTION_KEY format stay readable
|
|
3
|
+
// as long as the legacy key is wired as the cipher's fallback,
|
|
4
|
+
// 2. reading an encrypted row WITHOUT any cipher throws (the pre-envelope
|
|
5
|
+
// code silently returned the ciphertext as the value),
|
|
6
|
+
// 3. the reencrypt job migrates legacy rows onto the envelope format
|
|
7
|
+
// through the event-store executor (event payload carries the new
|
|
8
|
+
// envelope, so a projection rebuild no longer needs the legacy key),
|
|
9
|
+
// and is idempotent on re-run.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import {
|
|
15
|
+
createEncryptionProvider,
|
|
16
|
+
createEventStoreExecutor,
|
|
17
|
+
createTenantDb,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import {
|
|
20
|
+
type AppContext,
|
|
21
|
+
access,
|
|
22
|
+
createRegistry,
|
|
23
|
+
createSystemUser,
|
|
24
|
+
createTenantConfig,
|
|
25
|
+
defineFeature,
|
|
26
|
+
type TenantId,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
28
|
+
import {
|
|
29
|
+
createEnvelopeCipher,
|
|
30
|
+
type EnvelopeCipher,
|
|
31
|
+
isStoredEnvelope,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
33
|
+
import {
|
|
34
|
+
setupTestStack,
|
|
35
|
+
type TestStack,
|
|
36
|
+
testTenantId,
|
|
37
|
+
unsafePushTables,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
39
|
+
import { createTestMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
|
|
40
|
+
import { createConfigFeature } from "../feature";
|
|
41
|
+
import { reencryptJob } from "../handlers/reencrypt.job";
|
|
42
|
+
import { createConfigResolver } from "../resolver";
|
|
43
|
+
import { configValueEntity, configValuesTable } from "../table";
|
|
44
|
+
|
|
45
|
+
const KEY = "enc-mig:config:secret-pass";
|
|
46
|
+
const TENANT: TenantId = testTenantId(1);
|
|
47
|
+
const PLAINTEXT = "legacy-smtp-password";
|
|
48
|
+
|
|
49
|
+
const keyDef = createTenantConfig("text", {
|
|
50
|
+
encrypted: true,
|
|
51
|
+
read: access.all,
|
|
52
|
+
write: access.all,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// legacy single-key provider — the pre-envelope CONFIG_ENCRYPTION_KEY world
|
|
56
|
+
const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
|
|
57
|
+
const masterKeyProvider = createTestMasterKeyProvider();
|
|
58
|
+
const cipherWithLegacy = createEnvelopeCipher(masterKeyProvider, { legacy });
|
|
59
|
+
const cipherWithoutLegacy = createEnvelopeCipher(masterKeyProvider);
|
|
60
|
+
|
|
61
|
+
const encMigFeature = defineFeature("enc-mig", (r) => {
|
|
62
|
+
r.config({ keys: { "secret-pass": keyDef } });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const executor = createEventStoreExecutor(configValuesTable, configValueEntity, {
|
|
66
|
+
entityName: "config-value",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let stack: TestStack;
|
|
70
|
+
|
|
71
|
+
async function seedLegacyRow(): Promise<string> {
|
|
72
|
+
const legacyValue = legacy.encrypt(JSON.stringify(PLAINTEXT));
|
|
73
|
+
const systemUser = createSystemUser(TENANT);
|
|
74
|
+
const tdb = createTenantDb(stack.db, TENANT, "system");
|
|
75
|
+
const result = await executor.create(
|
|
76
|
+
{ key: KEY, value: legacyValue, tenantId: TENANT, userId: null },
|
|
77
|
+
systemUser,
|
|
78
|
+
tdb,
|
|
79
|
+
);
|
|
80
|
+
if (!result.isSuccess) throw new Error(`seed failed: ${result.error.code}`);
|
|
81
|
+
return (result.data as { id: string }).id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readStoredValue(): Promise<string> {
|
|
85
|
+
const row = await fetchOne<{ value: string }>(stack.db, configValuesTable, {
|
|
86
|
+
key: KEY,
|
|
87
|
+
tenantId: TENANT,
|
|
88
|
+
});
|
|
89
|
+
if (!row) throw new Error("row missing");
|
|
90
|
+
return row.value;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ReencryptCtx = Pick<AppContext, "db" | "masterKeyProvider" | "configEncryption" | "registry">;
|
|
94
|
+
function jobCtx(cipher: EnvelopeCipher): Parameters<typeof reencryptJob>[1] {
|
|
95
|
+
const ctx: ReencryptCtx = {
|
|
96
|
+
db: stack.db,
|
|
97
|
+
masterKeyProvider,
|
|
98
|
+
configEncryption: cipher,
|
|
99
|
+
registry: stack.registry,
|
|
100
|
+
};
|
|
101
|
+
return ctx as unknown as Parameters<typeof reencryptJob>[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
stack = await setupTestStack({ features: [createConfigFeature(), encMigFeature] });
|
|
106
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
107
|
+
await seedLegacyRow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
await stack.cleanup();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("legacy encrypted config rows", () => {
|
|
115
|
+
test("stay readable through the cipher's legacy fallback (get + cascade path)", async () => {
|
|
116
|
+
const resolver = createConfigResolver({ cipher: cipherWithLegacy });
|
|
117
|
+
const value = await resolver.get(KEY, keyDef, TENANT, "u1", stack.db);
|
|
118
|
+
expect(value).toBe(PLAINTEXT);
|
|
119
|
+
|
|
120
|
+
const cascade = await resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db);
|
|
121
|
+
const active = cascade.levels.find((l) => l.hasValue);
|
|
122
|
+
expect(active?.value).toBe(PLAINTEXT);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("throw without any cipher instead of leaking ciphertext as the value", async () => {
|
|
126
|
+
const resolver = createConfigResolver({});
|
|
127
|
+
await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
|
|
128
|
+
/encrypted but no cipher/,
|
|
129
|
+
);
|
|
130
|
+
await expect(resolver.getCascade(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(
|
|
131
|
+
/encrypted but no cipher/,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("throw with a remediation hint when the legacy key is missing", async () => {
|
|
136
|
+
const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
|
|
137
|
+
await expect(resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).rejects.toThrow(/legacy/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("config:reencrypt job", () => {
|
|
142
|
+
test("migrates the legacy row to envelope format via the executor", async () => {
|
|
143
|
+
expect((await readStoredValue()).startsWith("{")).toBe(false);
|
|
144
|
+
|
|
145
|
+
await reencryptJob({}, jobCtx(cipherWithLegacy));
|
|
146
|
+
|
|
147
|
+
const stored = await readStoredValue();
|
|
148
|
+
expect(stored.startsWith("{")).toBe(true);
|
|
149
|
+
expect(isStoredEnvelope(JSON.parse(stored))).toBe(true);
|
|
150
|
+
|
|
151
|
+
// decryptable WITHOUT the legacy key now — retiring CONFIG_ENCRYPTION_KEY
|
|
152
|
+
// after a clean run is safe
|
|
153
|
+
const resolver = createConfigResolver({ cipher: cipherWithoutLegacy });
|
|
154
|
+
expect(await resolver.get(KEY, keyDef, TENANT, "u1", stack.db)).toBe(PLAINTEXT);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("event payload carries the new envelope — rebuild-safe without legacy key", async () => {
|
|
158
|
+
// The migration wrote a configValue.updated event whose payload holds
|
|
159
|
+
// the envelope value; replaying events verbatim reproduces the
|
|
160
|
+
// migrated state, so the final projection never needs the legacy key.
|
|
161
|
+
const registryCheck = createRegistry([createConfigFeature(), encMigFeature]);
|
|
162
|
+
expect(registryCheck.getConfigKey(KEY)?.encrypted).toBe(true);
|
|
163
|
+
|
|
164
|
+
const events = await asRawClient(stack.db).unsafe<{
|
|
165
|
+
payload: { changes?: { value?: string } };
|
|
166
|
+
}>(
|
|
167
|
+
`SELECT payload FROM kumiko_events WHERE aggregate_type = 'config-value' AND type LIKE '%.updated' ORDER BY created_at DESC LIMIT 1`,
|
|
168
|
+
);
|
|
169
|
+
const lastValue = events[0]?.payload?.changes?.value;
|
|
170
|
+
expect(typeof lastValue).toBe("string");
|
|
171
|
+
expect((lastValue as string).startsWith("{")).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("is idempotent — a second run migrates nothing", async () => {
|
|
175
|
+
const before = await readStoredValue();
|
|
176
|
+
await reencryptJob({}, jobCtx(cipherWithLegacy));
|
|
177
|
+
expect(await readStoredValue()).toBe(before);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import {
|
|
5
5
|
access,
|
|
6
6
|
type ConfigCascade,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
TestUsers,
|
|
15
15
|
unsafePushTables,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
18
|
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
18
19
|
import { createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
19
20
|
import { type ConfigResolver, createConfigResolver } from "../resolver";
|
|
@@ -76,8 +77,8 @@ const platformFeature = defineFeature("platform", (r) => {
|
|
|
76
77
|
});
|
|
77
78
|
|
|
78
79
|
beforeAll(async () => {
|
|
79
|
-
const encryption =
|
|
80
|
-
resolver = createConfigResolver({ encryption });
|
|
80
|
+
const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
|
|
81
|
+
resolver = createConfigResolver({ cipher: encryption });
|
|
81
82
|
|
|
82
83
|
stack = await setupTestStack({
|
|
83
84
|
features: [configFeature, platformFeature],
|