@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.
Files changed (46) 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__/feature-options.test.ts +33 -0
  8. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  9. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
  10. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  11. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  12. package/src/auth-email-password/constants.ts +4 -0
  13. package/src/auth-email-password/feature.ts +8 -4
  14. package/src/config/__tests__/config.integration.test.ts +7 -8
  15. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
  16. package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
  17. package/src/config/feature.ts +14 -8
  18. package/src/config/handlers/reencrypt.job.ts +197 -0
  19. package/src/config/handlers/set.write.ts +2 -2
  20. package/src/config/index.ts +4 -3
  21. package/src/config/resolver.ts +34 -10
  22. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
  23. package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
  24. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
  25. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
  26. package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
  27. package/src/secrets/handlers/rotate.job.ts +77 -98
  28. package/src/secrets/secrets-context.ts +7 -38
  29. package/src/secrets/table.ts +6 -7
  30. package/src/sessions/__tests__/auto-revoke-binding.test.ts +71 -0
  31. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
  32. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  33. package/src/sessions/feature.ts +51 -18
  34. package/src/sessions/index.ts +2 -2
  35. package/src/shared/chunked-entity-migration.ts +89 -0
  36. package/src/shared/index.ts +7 -0
  37. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
  38. package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
  39. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
  40. package/src/user/__tests__/user.integration.test.ts +26 -0
  41. package/src/user/handlers/find-for-auth.query.ts +7 -5
  42. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
  43. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
  44. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  45. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  46. 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.109.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.109.0",
99
- "@cosmicdrift/kumiko-framework": "0.109.0",
100
- "@cosmicdrift/kumiko-headless": "0.109.0",
101
- "@cosmicdrift/kumiko-renderer": "0.109.0",
102
- "@cosmicdrift/kumiko-renderer-web": "0.109.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: [
@@ -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 = 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();
@@ -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
- if (opts.passwordReset && !opts.passwordReset.hmacSecret) {
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
- "[auth-email-password] passwordReset.hmacSecret must be non-empty when passwordReset is configured",
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 && !opts.emailVerification.hmacSecret) {
134
+ if (opts.emailVerification && opts.emailVerification.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
131
135
  throw new Error(
132
- "[auth-email-password] emailVerification.hmacSecret must be non-empty when emailVerification is configured",
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 { 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],