@cosmicdrift/kumiko-bundled-features 0.110.0 → 0.112.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__/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/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/folders/__tests__/folders.integration.test.ts +3 -0
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
- 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__/password-auto-revoke.integration.test.ts +6 -4
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
- 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-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__/forget-hook-registry.integration.test.ts +108 -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-forget-cleanup.ts +1 -0
- package/src/user-data-rights/run-user-export.ts +43 -4
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.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.112.0",
|
|
99
|
+
"@cosmicdrift/kumiko-framework": "0.112.0",
|
|
100
|
+
"@cosmicdrift/kumiko-headless": "0.112.0",
|
|
101
|
+
"@cosmicdrift/kumiko-renderer": "0.112.0",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer-web": "0.112.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: [
|
|
@@ -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();
|
|
@@ -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],
|
package/src/config/feature.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DbConnection,
|
|
1
|
+
import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
type ConfigAccessor,
|
|
4
4
|
type ConfigAccessorFactory,
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
type TenantId,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
16
|
+
import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
|
|
16
17
|
import { cascadeQuery } from "./handlers/cascade.query";
|
|
17
18
|
import { readinessQuery } from "./handlers/readiness.query";
|
|
19
|
+
import { reencryptJob } from "./handlers/reencrypt.job";
|
|
18
20
|
import { resetWrite } from "./handlers/reset.write";
|
|
19
21
|
import { schemaQuery } from "./handlers/schema.query";
|
|
20
22
|
import { setWrite } from "./handlers/set.write";
|
|
@@ -27,7 +29,7 @@ export type ConfigContext = { readonly config: ConfigAccessor };
|
|
|
27
29
|
export function createConfigFeature(): FeatureDefinition {
|
|
28
30
|
return defineFeature("config", (r) => {
|
|
29
31
|
r.describe(
|
|
30
|
-
"Stores per-tenant (and optionally per-user) configuration values with a multi-layer cascade: user-row \u2192 tenant-row \u2192 system-row \u2192 app-override (deploy-time `AppConfigOverrides`) \u2192 computed \u2192 feature default. Access a value in handlers via `ctx.config(handle)`, declare keys with `r.config({ keys: { ... } })` inside a feature's registry callback, and optionally mark them `encrypted: true` to route storage through
|
|
32
|
+
"Stores per-tenant (and optionally per-user) configuration values with a multi-layer cascade: user-row \u2192 tenant-row \u2192 system-row \u2192 app-override (deploy-time `AppConfigOverrides`) \u2192 computed \u2192 feature default. Access a value in handlers via `ctx.config(handle)`, declare keys with `r.config({ keys: { ... } })` inside a feature's registry callback, and optionally mark them `encrypted: true` to route storage through the envelope cipher (versioned master key). Use this feature whenever a tenant admin needs to customise behaviour at runtime without a code deploy.",
|
|
31
33
|
);
|
|
32
34
|
r.uiHints({
|
|
33
35
|
displayLabel: "Tenant Config Store",
|
|
@@ -55,6 +57,12 @@ export function createConfigFeature(): FeatureDefinition {
|
|
|
55
57
|
readiness: r.queryHandler(readinessQuery),
|
|
56
58
|
};
|
|
57
59
|
|
|
60
|
+
// Migration + KEK-rotation for encrypted values (legacy-format →
|
|
61
|
+
// envelope, old kekVersion → current). Manual trigger — ops runs it
|
|
62
|
+
// once after adding a new master key version or before retiring the
|
|
63
|
+
// legacy CONFIG_ENCRYPTION_KEY.
|
|
64
|
+
r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
|
|
65
|
+
|
|
58
66
|
return { handlers, queries };
|
|
59
67
|
});
|
|
60
68
|
}
|
|
@@ -116,16 +124,14 @@ export function requireConfigResolver(ctx: HandlerContext, handlerName: string):
|
|
|
116
124
|
// lazily so apps that never wire encryption still boot (and only crash
|
|
117
125
|
// if a handler tries to write an encrypted key without the provider in
|
|
118
126
|
// place, pointing at the exact wiring gap).
|
|
119
|
-
export function requireConfigEncryption(
|
|
120
|
-
ctx: HandlerContext,
|
|
121
|
-
handlerName: string,
|
|
122
|
-
): EncryptionProvider {
|
|
127
|
+
export function requireConfigEncryption(ctx: HandlerContext, handlerName: string): EnvelopeCipher {
|
|
123
128
|
if (!ctx.configEncryption) {
|
|
124
129
|
throw new InternalError({
|
|
125
130
|
message:
|
|
126
131
|
`[${handlerName}] ctx.configEncryption missing — at least one config key declares ` +
|
|
127
|
-
`encrypted: true, so the boot wiring must pass an
|
|
128
|
-
`extraContext.configEncryption (
|
|
132
|
+
`encrypted: true, so the boot wiring must pass an EnvelopeCipher via ` +
|
|
133
|
+
`extraContext.configEncryption (run{Prod,Dev}App wire it automatically from ` +
|
|
134
|
+
`KUMIKO_SECRETS_MASTER_KEY_V<n>; same instance the resolver was built with).`,
|
|
129
135
|
});
|
|
130
136
|
}
|
|
131
137
|
return ctx.configEncryption;
|