@cosmicdrift/kumiko-bundled-features 0.110.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.
Files changed (38) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +9 -10
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +7 -4
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/config/__tests__/config.integration.test.ts +7 -8
  12. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
  13. package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
  14. package/src/config/feature.ts +14 -8
  15. package/src/config/handlers/reencrypt.job.ts +197 -0
  16. package/src/config/handlers/set.write.ts +2 -2
  17. package/src/config/index.ts +4 -3
  18. package/src/config/resolver.ts +34 -10
  19. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
  20. package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
  21. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
  22. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
  23. package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
  24. package/src/secrets/handlers/rotate.job.ts +77 -98
  25. package/src/secrets/secrets-context.ts +7 -38
  26. package/src/secrets/table.ts +6 -7
  27. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
  28. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  29. package/src/shared/chunked-entity-migration.ts +89 -0
  30. package/src/shared/index.ts +7 -0
  31. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
  32. package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
  33. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
  34. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
  35. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
  36. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  37. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  38. 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.110.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.110.0",
99
- "@cosmicdrift/kumiko-framework": "0.110.0",
100
- "@cosmicdrift/kumiko-headless": "0.110.0",
101
- "@cosmicdrift/kumiko-renderer": "0.110.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.110.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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
264
- const localResolver = createConfigResolver({ encryption: localEncryption });
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 = createEncryptionProvider(encryptionKey);
341
- const localResolver = createConfigResolver({ encryption: localEncryption });
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 = createEncryptionProvider(encryptionKey);
429
- const localResolver = createConfigResolver({ encryption: localEncryption });
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 = createEncryptionProvider(encryptionKey);
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 { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
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 = createEncryptionProvider(testEncryptionKey);
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 { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
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 = createEncryptionProvider(randomBytes(32).toString("base64"));
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],
@@ -1,4 +1,4 @@
1
- import type { DbConnection, EncryptionProvider, TenantDb } from "@cosmicdrift/kumiko-framework/db";
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 an `EncryptionProvider`. Use this feature whenever a tenant admin needs to customise behaviour at runtime without a code deploy.",
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 EncryptionProvider via ` +
128
- `extraContext.configEncryption (same instance the resolver was built with).`,
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;