@cosmicdrift/kumiko-bundled-features 0.113.1 → 1.0.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 (125) hide show
  1. package/package.json +6 -8
  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 +10 -9
  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 +4 -7
  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 +4 -3
  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/auth-email-password/constants.ts +0 -4
  12. package/src/auth-email-password/feature.ts +4 -8
  13. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  14. package/src/auth-email-password/handlers/confirm-token-flow.ts +104 -33
  15. package/src/auth-email-password/handlers/login.write.ts +1 -4
  16. package/src/auth-email-password/password-hashing.ts +0 -11
  17. package/src/auth-email-password/stream-tenant.ts +31 -0
  18. package/src/auth-email-password/web/auth-form-primitives.tsx +1 -18
  19. package/src/auth-email-password/web/auth-gate.tsx +4 -3
  20. package/src/auth-email-password/web/invite-accept-screen.tsx +2 -2
  21. package/src/auth-email-password/web/reset-password-screen.tsx +11 -6
  22. package/src/auth-email-password/web/signup-complete-screen.tsx +5 -5
  23. package/src/auth-email-password/web/user-menu.tsx +1 -3
  24. package/src/auth-email-password/web/verify-email-screen.tsx +7 -2
  25. package/src/config/__tests__/config.integration.test.ts +8 -7
  26. package/src/config/__tests__/deserialize-value.test.ts +6 -3
  27. package/src/config/__tests__/inherited-redaction.integration.test.ts +3 -4
  28. package/src/config/feature.ts +8 -14
  29. package/src/config/handlers/set.write.ts +2 -2
  30. package/src/config/index.ts +3 -4
  31. package/src/config/resolver.ts +10 -34
  32. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +0 -65
  33. package/src/custom-fields/constants.ts +0 -1
  34. package/src/custom-fields/events.ts +0 -6
  35. package/src/custom-fields/handlers/delete-system-field.write.ts +1 -2
  36. package/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -2
  37. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  38. package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -10
  39. package/src/custom-fields/wire-for-entity.ts +77 -116
  40. package/src/data-retention/__tests__/parse-override.test.ts +13 -13
  41. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +3 -4
  42. package/src/folders/__tests__/folders.integration.test.ts +0 -3
  43. package/src/folders/web/__tests__/tree.test.ts +0 -8
  44. package/src/folders/web/folder-manager.tsx +3 -1
  45. package/src/folders/web/i18n.ts +2 -0
  46. package/src/folders/web/tree.ts +2 -13
  47. package/src/folders-user-data/__tests__/hooks.integration.test.ts +0 -1
  48. package/src/folders-user-data/index.ts +4 -4
  49. package/src/jobs/__tests__/job-system-user.integration.test.ts +8 -4
  50. package/src/jobs/handlers/projection-rebuild.job.ts +4 -14
  51. package/src/ledger/__tests__/ledger.integration.test.ts +3 -8
  52. package/src/ledger/handlers/confirm-schedule-period.write.ts +4 -17
  53. package/src/ledger/recurring.ts +8 -17
  54. package/src/ledger/web/index.ts +2 -3
  55. package/src/legal-pages/feature.ts +3 -1
  56. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +3 -4
  57. package/src/managed-pages/__tests__/branding-coerce.test.ts +6 -2
  58. package/src/managed-pages/feature.ts +3 -1
  59. package/src/readiness/__tests__/readiness.integration.test.ts +3 -4
  60. package/src/secrets/handlers/rotate.job.ts +98 -77
  61. package/src/secrets/secrets-context.ts +38 -7
  62. package/src/secrets/table.ts +7 -6
  63. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +4 -6
  64. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  65. package/src/sessions/feature.ts +18 -51
  66. package/src/sessions/index.ts +2 -2
  67. package/src/subscription-stripe/__tests__/runtime.test.ts +24 -14
  68. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +5 -6
  69. package/src/tags/__tests__/tags.integration.test.ts +1 -12
  70. package/src/tags/entity.ts +1 -6
  71. package/src/tags/schemas.ts +2 -9
  72. package/src/tags/web/tag-manager.tsx +1 -1
  73. package/src/template-resolver/testing.ts +1 -2
  74. package/src/tenant/__tests__/tenant.integration.test.ts +4 -8
  75. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +4 -7
  76. package/src/user/__tests__/user.integration.test.ts +0 -26
  77. package/src/user/handlers/find-for-auth.query.ts +5 -7
  78. package/src/user/index.ts +0 -4
  79. package/src/user-data-rights/__tests__/download.integration.test.ts +4 -7
  80. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  81. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  82. package/src/user-data-rights/feature.ts +3 -1
  83. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -4
  84. package/src/user-data-rights/lib/update-user-lifecycle.ts +24 -8
  85. package/src/user-data-rights/run-forget-cleanup.ts +0 -1
  86. package/src/user-data-rights/run-user-export.ts +4 -43
  87. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +1 -7
  88. package/src/user-data-rights/web/client-plugin.tsx +1 -6
  89. package/src/user-data-rights/web/i18n.ts +10 -0
  90. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +9 -31
  91. package/src/user-profile/__tests__/profile-screen.test.tsx +0 -4
  92. package/src/user-profile/handlers/change-email.write.ts +8 -11
  93. package/src/user-profile/web/profile-screen.tsx +1 -12
  94. package/src/auth-email-password/__tests__/feature-options.test.ts +0 -33
  95. package/src/auth-email-password/password-hashing.test.ts +0 -35
  96. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +0 -29
  97. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
  98. package/src/config/handlers/reencrypt.job.ts +0 -197
  99. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +0 -204
  100. package/src/personal-access-tokens/__tests__/scopes.test.ts +0 -42
  101. package/src/personal-access-tokens/constants.ts +0 -33
  102. package/src/personal-access-tokens/feature.ts +0 -78
  103. package/src/personal-access-tokens/handlers/available-scopes.query.ts +0 -21
  104. package/src/personal-access-tokens/handlers/create.write.ts +0 -41
  105. package/src/personal-access-tokens/handlers/list.query.ts +0 -47
  106. package/src/personal-access-tokens/handlers/revoke.write.ts +0 -37
  107. package/src/personal-access-tokens/hash.ts +0 -17
  108. package/src/personal-access-tokens/index.ts +0 -45
  109. package/src/personal-access-tokens/resolver.ts +0 -58
  110. package/src/personal-access-tokens/roles.ts +0 -29
  111. package/src/personal-access-tokens/schema/api-token.ts +0 -56
  112. package/src/personal-access-tokens/scopes.ts +0 -43
  113. package/src/personal-access-tokens/web/client-plugin.tsx +0 -29
  114. package/src/personal-access-tokens/web/i18n.ts +0 -69
  115. package/src/personal-access-tokens/web/index.ts +0 -7
  116. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +0 -251
  117. package/src/sessions/__tests__/auto-revoke-binding.test.ts +0 -71
  118. package/src/shared/chunked-entity-migration.ts +0 -89
  119. package/src/shared/index.ts +0 -7
  120. package/src/tags/__tests__/schemas.test.ts +0 -34
  121. package/src/tags/web/__tests__/tag-manager.test.tsx +0 -64
  122. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +0 -186
  123. package/src/user/db/queries/stream-tenant-backfill.ts +0 -112
  124. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +0 -152
  125. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +0 -108
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.113.1",
3
+ "version": "1.0.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>",
@@ -77,8 +77,6 @@
77
77
  "./secrets": "./src/secrets/index.ts",
78
78
  "./sessions": "./src/sessions/index.ts",
79
79
  "./sessions/testing": "./src/sessions/testing.ts",
80
- "./personal-access-tokens": "./src/personal-access-tokens/index.ts",
81
- "./personal-access-tokens/web": "./src/personal-access-tokens/web/index.ts",
82
80
  "./feature-toggles": "./src/feature-toggles/index.ts",
83
81
  "./text-content": "./src/text-content/index.ts",
84
82
  "./text-content/seeding": "./src/text-content/seeding.ts",
@@ -95,11 +93,11 @@
95
93
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
94
  },
97
95
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.113.1",
99
- "@cosmicdrift/kumiko-framework": "0.113.1",
100
- "@cosmicdrift/kumiko-headless": "0.113.1",
101
- "@cosmicdrift/kumiko-renderer": "0.113.1",
102
- "@cosmicdrift/kumiko-renderer-web": "0.113.1",
96
+ "@cosmicdrift/kumiko-dispatcher-live": "1.0.0",
97
+ "@cosmicdrift/kumiko-framework": "1.0.0",
98
+ "@cosmicdrift/kumiko-headless": "1.0.0",
99
+ "@cosmicdrift/kumiko-renderer": "1.0.0",
100
+ "@cosmicdrift/kumiko-renderer-web": "1.0.0",
103
101
  "@mollie/api-client": "^4.5.0",
104
102
  "@node-rs/argon2": "^2.0.2",
105
103
  "@types/nodemailer": "^8.0.0",
@@ -13,6 +13,7 @@
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";
16
17
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import {
18
19
  setupTestStack,
@@ -21,7 +22,6 @@ import {
21
22
  unsafeCreateEntityTable,
22
23
  unsafePushTables,
23
24
  } 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 = createTestEnvelopeCipher(encryptionKey);
46
- const resolver = createConfigResolver({ cipher: encryption });
45
+ const encryption = createEncryptionProvider(encryptionKey);
46
+ const resolver = createConfigResolver({ encryption });
47
47
 
48
48
  stack = await setupTestStack({
49
49
  features: [
@@ -12,6 +12,7 @@
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";
15
16
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
17
  import {
17
18
  setupTestStack,
@@ -20,7 +21,6 @@ import {
20
21
  unsafeCreateEntityTable,
21
22
  unsafePushTables,
22
23
  } 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 = createTestEnvelopeCipher(encryptionKey);
52
- const resolver = createConfigResolver({ cipher: encryption });
51
+ const encryption = createEncryptionProvider(encryptionKey);
52
+ const resolver = createConfigResolver({ encryption });
53
53
 
54
54
  stack = await setupTestStack({
55
55
  features: [
@@ -1,6 +1,7 @@
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";
4
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import {
@@ -11,7 +12,7 @@ import {
11
12
  unsafeCreateEntityTable,
12
13
  unsafePushTables,
13
14
  } from "@cosmicdrift/kumiko-framework/stack";
14
- import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
15
16
  import { createConfigFeature } from "../../config";
16
17
  import { createConfigResolver } from "../../config/resolver";
17
18
  import { configValuesTable } from "../../config/table";
@@ -67,8 +68,8 @@ const segmentsByUserAndTenant = new Map<string, string>();
67
68
  const plansByTenant = new Map<TenantId, string>();
68
69
 
69
70
  beforeAll(async () => {
70
- const encryption = createTestEnvelopeCipher(encryptionKey);
71
- const resolver = createConfigResolver({ cipher: encryption });
71
+ const encryption = createEncryptionProvider(encryptionKey);
72
+ const resolver = createConfigResolver({ encryption });
72
73
 
73
74
  stack = await setupTestStack({
74
75
  features: [
@@ -259,8 +260,8 @@ describe("scenario 2: switch-tenant recomputes claims (no stale tenant-A claims
259
260
 
260
261
  describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
261
262
  test("one feature returns key with ':' → only that key dropped, other features merge cleanly", async () => {
262
- const localEncryption = createTestEnvelopeCipher(encryptionKey);
263
- const localResolver = createConfigResolver({ cipher: localEncryption });
263
+ const localEncryption = createEncryptionProvider(encryptionKey);
264
+ const localResolver = createConfigResolver({ encryption: localEncryption });
264
265
  // A polluted feature that tries to smuggle in a qualified-name-shaped
265
266
  // inner key (injecting ":" would otherwise let it spoof another
266
267
  // feature's prefix). Plus a clean companion to prove the drop is
@@ -336,8 +337,8 @@ describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
336
337
 
337
338
  describe("scenario 2.6: multi-feature drift warnings fire independently", () => {
338
339
  test("feature A + feature B both return undeclared keys → each logs its own warning", async () => {
339
- const localEncryption = createTestEnvelopeCipher(encryptionKey);
340
- const localResolver = createConfigResolver({ cipher: localEncryption });
340
+ const localEncryption = createEncryptionProvider(encryptionKey);
341
+ const localResolver = createConfigResolver({ encryption: localEncryption });
341
342
 
342
343
  // Capture warn() calls to verify drift warnings fire per-feature, not
343
344
  // globally collapsed.
@@ -424,8 +425,8 @@ describe("scenario 2.6: multi-feature drift warnings fire independently", () =>
424
425
 
425
426
  describe("scenario 3: a broken claims hook does not break login", () => {
426
427
  test("hook throws → login still succeeds, broken feature simply contributes nothing, warn logged with featureName+err", async () => {
427
- const localEncryption = createTestEnvelopeCipher(encryptionKey);
428
- const localResolver = createConfigResolver({ cipher: localEncryption });
428
+ const localEncryption = createEncryptionProvider(encryptionKey);
429
+ const localResolver = createConfigResolver({ encryption: localEncryption });
429
430
 
430
431
  // Capture warn-calls so the test can verify the resolver actually
431
432
  // logged the broken hook with featureName + err.message — previously
@@ -1,6 +1,7 @@
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";
4
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  createTestUser,
@@ -12,7 +13,6 @@ import {
12
13
  unsafePushTables,
13
14
  } from "@cosmicdrift/kumiko-framework/stack";
14
15
  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 = createTestEnvelopeCipher(encryptionKey);
41
- const resolver = createConfigResolver({ cipher: encryption });
40
+ const encryption = createEncryptionProvider(encryptionKey);
41
+ const resolver = createConfigResolver({ 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 = createTestEnvelopeCipher(encryptionKey);
378
- const resolver = createConfigResolver({ cipher: encryption });
377
+ const encryption = createEncryptionProvider(encryptionKey);
378
+ const resolver = createConfigResolver({ encryption });
379
379
  const { createInMemoryLoginRateLimiter } = await import("@cosmicdrift/kumiko-framework/api");
380
380
 
381
381
  rlStack = await setupTestStack({
@@ -1,6 +1,7 @@
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";
4
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  setupTestStack,
@@ -9,11 +10,7 @@ import {
9
10
  unsafeCreateEntityTable,
10
11
  unsafePushTables,
11
12
  } from "@cosmicdrift/kumiko-framework/stack";
12
- import {
13
- createTestEnvelopeCipher,
14
- seedRow,
15
- updateRows,
16
- } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
17
14
  import { Temporal } from "temporal-polyfill";
18
15
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
19
16
  import { createConfigFeature } from "../../config";
@@ -53,8 +50,8 @@ const appVerifyUrl = "https://app.example.com/verify";
53
50
  const appResetUrl = "https://app.example.com/reset";
54
51
 
55
52
  beforeAll(async () => {
56
- const encryption = createTestEnvelopeCipher(encryptionKey);
57
- const resolver = createConfigResolver({ cipher: encryption });
53
+ const encryption = createEncryptionProvider(encryptionKey);
54
+ const resolver = createConfigResolver({ encryption });
58
55
 
59
56
  stack = await setupTestStack({
60
57
  features: [
@@ -8,6 +8,7 @@
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";
11
12
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
13
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
14
  import {
@@ -17,7 +18,6 @@ import {
17
18
  unsafeCreateEntityTable,
18
19
  unsafePushTables,
19
20
  } 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 = createTestEnvelopeCipher(encryptionKey);
54
- const resolver = createConfigResolver({ cipher: encryption });
53
+ const encryption = createEncryptionProvider(encryptionKey);
54
+ const resolver = createConfigResolver({ encryption });
55
55
 
56
56
  stack = await setupTestStack({
57
57
  features: [
@@ -1,6 +1,7 @@
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";
4
5
  import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  setupTestStack,
@@ -9,7 +10,7 @@ import {
9
10
  unsafeCreateEntityTable,
10
11
  unsafePushTables,
11
12
  } from "@cosmicdrift/kumiko-framework/stack";
12
- import { createTestEnvelopeCipher, seedRow } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
13
14
  import { Temporal } from "temporal-polyfill";
14
15
  import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
15
16
  import { createConfigFeature } from "../../config";
@@ -50,8 +51,8 @@ const resetSecret = randomBytes(32).toString("base64");
50
51
  const appResetUrl = "https://app.example.com/reset";
51
52
 
52
53
  beforeAll(async () => {
53
- const encryption = createTestEnvelopeCipher(encryptionKey);
54
- const resolver = createConfigResolver({ cipher: encryption });
54
+ const encryption = createEncryptionProvider(encryptionKey);
55
+ const resolver = createConfigResolver({ encryption });
55
56
 
56
57
  stack = await setupTestStack({
57
58
  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";
10
11
  import {
11
12
  setupTestStack,
12
13
  type TestStack,
13
14
  unsafeCreateEntityTable,
14
15
  unsafePushTables,
15
16
  } 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 = createTestEnvelopeCipher(encryptionKey);
44
- const resolver = createConfigResolver({ cipher: encryption });
43
+ const encryption = createEncryptionProvider(encryptionKey);
44
+ const resolver = createConfigResolver({ encryption });
45
45
 
46
46
  stack = await setupTestStack({
47
47
  features: [
@@ -1,6 +1,7 @@
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";
4
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import {
6
7
  setupTestStack,
@@ -9,7 +10,6 @@ import {
9
10
  unsafeCreateEntityTable,
10
11
  unsafePushTables,
11
12
  } 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 = createTestEnvelopeCipher(encryptionKey);
94
- const resolver = createConfigResolver({ cipher: encryption });
93
+ const encryption = createEncryptionProvider(encryptionKey);
94
+ const resolver = createConfigResolver({ encryption });
95
95
 
96
96
  store = createFakeStore();
97
97
  sidStream = deterministicSids();
@@ -6,10 +6,6 @@
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
-
13
9
  // Qualified handler names. Non-CRUD handlers, no entity prefix.
14
10
  export const AuthHandlers = {
15
11
  login: "auth-email-password:write:login",
@@ -1,6 +1,5 @@
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";
4
3
  import type { AuthMailLocale } from "./email-templates";
5
4
  import { changePasswordWrite } from "./handlers/change-password.write";
6
5
  import { createInviteAcceptHandler } from "./handlers/invite-accept.write";
@@ -123,17 +122,14 @@ export type AuthEmailPasswordOptions = {
123
122
  export function createAuthEmailPasswordFeature(
124
123
  opts: AuthEmailPasswordOptions = {},
125
124
  ): FeatureDefinition {
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) {
125
+ if (opts.passwordReset && !opts.passwordReset.hmacSecret) {
130
126
  throw new Error(
131
- `[auth-email-password] passwordReset.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
127
+ "[auth-email-password] passwordReset.hmacSecret must be non-empty when passwordReset is configured",
132
128
  );
133
129
  }
134
- if (opts.emailVerification && opts.emailVerification.hmacSecret.length < MIN_HMAC_SECRET_LENGTH) {
130
+ if (opts.emailVerification && !opts.emailVerification.hmacSecret) {
135
131
  throw new Error(
136
- `[auth-email-password] emailVerification.hmacSecret must be ≥${MIN_HMAC_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`,
132
+ "[auth-email-password] emailVerification.hmacSecret must be non-empty when emailVerification is configured",
137
133
  );
138
134
  }
139
135
 
@@ -1,11 +1,7 @@
1
- import {
2
- access,
3
- createSystemUser,
4
- defineWriteHandler,
5
- SYSTEM_TENANT_ID,
6
- } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
7
3
  import { z } from "zod";
8
- import { UserHandlers, UserQueries } from "../../user";
4
+ import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
9
5
  import { invalidCredentials } from "../errors";
10
6
  import { hashPassword, verifyPassword } from "../password-hashing";
11
7
 
@@ -39,10 +35,14 @@ export const changePasswordWrite = defineWriteHandler({
39
35
 
40
36
  const newHash = await hashPassword(event.payload.newPassword);
41
37
 
42
- // The user aggregate is systemStream (#497): its event stream lives on
43
- // SYSTEM_TENANT_ID deterministically. Pre-#497 scattered streams need the
44
- // one-time backfillUserStreamTenants migration (#762).
45
- const writer = createSystemUser(SYSTEM_TENANT_ID);
38
+ // The user aggregate is r.systemScope(): its event stream can live in a
39
+ // tenant that isn't the session tenant (a platform operator's stream sits
40
+ // in the seed executor's tenant, not their membership). Write against the
41
+ // real stream tenant so optimistic locking targets the right stream instead
42
+ // of version_conflict-ing against an empty one. Falls back to the session
43
+ // tenant when the lookup finds nothing (fresh/unknown stream).
44
+ const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
45
+ const writer = createSystemUser(streamTenant ?? event.user.tenantId);
46
46
 
47
47
  // Apply via user feature's update handler — writeAs(system) satisfies
48
48
  // the privileged-only write rule on passwordHash. Pass the current version
@@ -5,8 +5,9 @@
5
5
  // 1. Redis check + burn (single-use enforcement)
6
6
  // 2. Load user + deleted/missing/no-version guard
7
7
  // 3. Optional idempotent short-circuit (verify-email when already done)
8
- // 4. Apply the handler-specific `changes` on the user's SYSTEM stream
9
- // 5. Release the burn on ANY non-success path so a legit retry isn't
8
+ // 4. Resolve memberships → tenant-order for stream-matching
9
+ // 5. Try each tenant's stream with the handler-specific `changes`
10
+ // 6. Release the burn on ANY non-success path so a legit retry isn't
10
11
  // locked out by a stale marker
11
12
  //
12
13
  // The top-level `runConfirmTokenFlow` orchestrates and owns the
@@ -14,26 +15,26 @@
14
15
  // burn (success, already-done) flips `committed = true`; everything
15
16
  // else — including future branches a maintainer adds — releases
16
17
  // automatically.
17
- //
18
- // The user aggregate is systemStream (#497): its event stream lives on
19
- // SYSTEM_TENANT_ID deterministically, so the write targets exactly one
20
- // stream. The former membership-probing (`tryWriteAcrossTenants`) existed
21
- // for pre-#497 scattered streams; those need the one-time
22
- // backfillUserStreamTenants migration (#762) — probing them stopped working
23
- // the moment the executor's stream choke-point landed anyway.
24
18
 
25
19
  import {
26
20
  createSystemUser,
27
21
  type HandlerContext,
28
22
  type SessionUser,
29
23
  SYSTEM_TENANT_ID,
24
+ type TenantId,
30
25
  type WriteResult,
31
26
  } from "@cosmicdrift/kumiko-framework/engine";
32
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
27
+ import {
28
+ InternalError,
29
+ type WriteFailure,
30
+ writeFailure,
31
+ } from "@cosmicdrift/kumiko-framework/errors";
32
+ import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
33
33
  import type Redis from "ioredis";
34
- import { UserHandlers, UserQueries } from "../../user";
34
+ import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
35
35
  import type { AuthUserRow } from "../auth-user-row";
36
36
  import { parseAuthUserRow } from "../auth-user-row";
37
+ import { orderTenantsByPreference } from "../stream-tenant";
37
38
  import { burnToken, unburnToken } from "../token-burn-store";
38
39
 
39
40
  export type ConfirmTokenFlowSpec<TSuccessData> = {
@@ -45,11 +46,13 @@ export type ConfirmTokenFlowSpec<TSuccessData> = {
45
46
  // is misconfigured, not the caller's fault.
46
47
  readonly redisRequiredMessage: string;
47
48
  // Standard failure returned for every "token can't be consumed" path
48
- // (bad state, version conflict on the stream). The route layer returns
49
- // 422 with a uniform code so the caller can't tell which branch fired.
49
+ // (bad state, missing memberships, every tenant rejected). The route
50
+ // layer returns 422 with a uniform code so the caller can't tell which
51
+ // branch fired.
50
52
  readonly invalidToken: () => ReturnType<typeof writeFailure>;
51
- // Handler-specific payload for user:update. Runs once per token. Can be
52
- // async (password-reset hashes here).
53
+ // Handler-specific payload for user:update. Runs once per token the
54
+ // result is shared across every tenant-stream attempt. Can be async
55
+ // (password-reset hashes here).
53
56
  readonly buildChanges: (me: AuthUserRow) => Promise<Record<string, unknown>>;
54
57
  // Returned verbatim on a successful write.
55
58
  readonly successData: TSuccessData;
@@ -93,21 +96,20 @@ export async function runConfirmTokenFlow<TSuccessData>(
93
96
  return { isSuccess: true, data: spec.alreadyDone.data };
94
97
  }
95
98
 
99
+ const tenantOrder = await resolveStreamTenants(ctx, systemUser, me);
100
+ if (tenantOrder.length === 0) return spec.invalidToken();
101
+
96
102
  const changes = await spec.buildChanges(me);
97
- const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
98
- id: me.id,
99
- version: me.version,
100
- changes,
101
- });
102
- if (writeRes.isSuccess) {
103
+ const writeResult = await tryWriteAcrossTenants(ctx, me, tenantOrder, changes);
104
+ if (writeResult.isSuccess) {
103
105
  committed = true;
104
106
  return { isSuccess: true, data: spec.successData };
105
107
  }
106
- // version_conflict = concurrent modification (or an un-migrated pre-#497
107
- // stream, see header) token-level failure. Anything else (DB down,
108
- // access denied) bubbles unchanged so ops sees the real failure class.
109
- if (writeRes.error.code === "version_conflict") return spec.invalidToken();
110
- return writeRes;
108
+ // `all_conflicts` = every tenant returned version_conflict → token-level
109
+ // failure. `hard_failure` = a real write error (DB down, access
110
+ // denied) that bubbles unchanged.
111
+ if (writeResult.reason === "all_conflicts") return spec.invalidToken();
112
+ return writeResult.failure;
111
113
  } finally {
112
114
  // committed===false covers EVERY failure path — including branches a
113
115
  // future maintainer adds without reading this file. The original
@@ -119,13 +121,14 @@ export async function runConfirmTokenFlow<TSuccessData>(
119
121
  }
120
122
  }
121
123
 
124
+ // --- Private helpers ------------------------------------------------------
125
+
122
126
  // Fetches the user row via the privileged findForAuth query and validates
123
- // it's usable for a write: not deleted, has a row.version >= 1 (a row
124
- // without any event streame.g. inserted straight into read_users — must
125
- // not be confirmable: the write would otherwise seed a fresh stream with a
126
- // bare user.updated). Return type narrows `version` to `number` so the
127
- // write-callsite doesn't need a `?? 0` fallback — the guard lives here,
128
- // not at every callsite.
127
+ // it's usable for a write: not deleted, has a row.version (the version
128
+ // column is a findForAuth contract field absence is a schema bug, but
129
+ // we still handle it gracefully rather than throwing past the burn).
130
+ // Return type narrows `version` to `number` so the write-callsite doesn't
131
+ // need a `?? 0` fallback — the guard lives here, not at every callsite.
129
132
  async function loadValidatedUser(
130
133
  ctx: HandlerContext,
131
134
  systemUser: SessionUser,
@@ -134,6 +137,74 @@ async function loadValidatedUser(
134
137
  const me = parseAuthUserRow(
135
138
  await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
136
139
  );
137
- if (!me || me.isDeleted || me.version === undefined || me.version < 1) return null;
140
+ if (!me || me.isDeleted || me.version === undefined) return null;
138
141
  return { ...me, version: me.version };
139
142
  }
143
+
144
+ // Loads the user's memberships and returns a prioritised tenant list, with the
145
+ // aggregate's real stream tenant recovered from the event log prepended.
146
+ //
147
+ // Empty only when the user has NO memberships AND no recoverable stream tenant
148
+ // — the caller treats that as invalid_token. A zero-membership systemScope user
149
+ // whose stream lives outside any membership (a platform operator seeded under a
150
+ // fixture/SYSTEM tenant) still resolves, because the stream-tenant lookup runs
151
+ // before the empty check rather than being short-circuited by it.
152
+ async function resolveStreamTenants(
153
+ ctx: HandlerContext,
154
+ systemUser: SessionUser,
155
+ me: AuthUserRow,
156
+ ): Promise<readonly TenantId[]> {
157
+ const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
158
+ userId: me.id,
159
+ })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
160
+ const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
161
+
162
+ // The user aggregate is r.systemScope(): its event stream lives in whichever
163
+ // tenant the creating executor used, which need NOT be a membership tenant.
164
+ // A platform operator seeded under a fixture/platform tenant is the live case
165
+ // — its stream tenant is absent from `ordered`, so a membership-only search
166
+ // rejects every write and collapses to invalid_token. Recover the real stream
167
+ // tenant from the event log and try it first; memberships stay as fallback.
168
+ // Pulled BEFORE the empty-membership check so a zero-membership operator whose
169
+ // stream lives in SYSTEM_TENANT is recoverable instead of collapsing to
170
+ // invalid_token — mirrors change-password.write.ts's unconditional recovery.
171
+ const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
172
+ if (streamTenant && !ordered.includes(streamTenant)) {
173
+ return [streamTenant, ...ordered];
174
+ }
175
+ return ordered;
176
+ }
177
+
178
+ // Discriminated result for the write-across-tenants loop.
179
+ // all_conflicts → every candidate rejected with version_conflict →
180
+ // token-level failure; caller returns invalidToken.
181
+ // hard_failure → a non-conflict error that should bubble unchanged
182
+ // (DB down, access denied, …); caller returns it as-is.
183
+ type TenantWriteResult =
184
+ | { isSuccess: true }
185
+ | { isSuccess: false; reason: "all_conflicts" }
186
+ | { isSuccess: false; reason: "hard_failure"; failure: WriteFailure };
187
+
188
+ // Attempts the update against each candidate stream. memberships-query
189
+ // has no deterministic ORDER BY, so the matching stream is discovered by
190
+ // attempt: version_conflict → try the next candidate, anything else →
191
+ // bubble immediately so ops sees the real failure class.
192
+ async function tryWriteAcrossTenants(
193
+ ctx: HandlerContext,
194
+ me: AuthUserRow & { version: number },
195
+ tenantOrder: readonly TenantId[],
196
+ changes: Record<string, unknown>,
197
+ ): Promise<TenantWriteResult> {
198
+ for (const tenantId of tenantOrder) {
199
+ const writeRes = await ctx.writeAs(createSystemUser(tenantId), UserHandlers.update, {
200
+ id: me.id,
201
+ version: me.version,
202
+ changes,
203
+ });
204
+ if (writeRes.isSuccess) return { isSuccess: true };
205
+ if (writeRes.error.code !== "version_conflict") {
206
+ return { isSuccess: false, reason: "hard_failure", failure: writeRes };
207
+ }
208
+ }
209
+ return { isSuccess: false, reason: "all_conflicts" };
210
+ }
@@ -21,7 +21,7 @@ import {
21
21
  noMembership,
22
22
  } from "../errors";
23
23
  import { clearLockoutState, getLockoutState, recordFailedAttempt } from "../lockout-store";
24
- import { verifyDummyPassword, verifyPassword } from "../password-hashing";
24
+ import { verifyPassword } from "../password-hashing";
25
25
 
26
26
  export type LoginHandlerOptions = {
27
27
  // When true, a valid (email + password) login fails with email_not_verified
@@ -74,9 +74,6 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
74
74
  // Uniform response on any credential mismatch (no user, wrong password,
75
75
  // soft-deleted user) — prevents email enumeration.
76
76
  if (!found?.passwordHash || found.isDeleted) {
77
- // Burn the same argon2 verify cost as the hit path so response
78
- // latency doesn't reveal whether the email is registered (#774).
79
- await verifyDummyPassword(event.payload.password);
80
77
  return invalidCredentials();
81
78
  }
82
79
 
@@ -42,14 +42,3 @@ export async function verifyPassword(hashString: string, password: string): Prom
42
42
  return false;
43
43
  }
44
44
  }
45
-
46
- // Anti-enumeration timing equaliser (#774). The login handler runs this on
47
- // the no-user / no-hash path so a missing account costs the same argon2
48
- // latency as a real verify — otherwise response timing leaks whether an
49
- // email is registered. Derived from hashPassword once and cached, so it
50
- // always tracks HASH_OPTIONS; the password never matches, result discarded.
51
- let dummyHash: Promise<string> | undefined;
52
- export async function verifyDummyPassword(password: string): Promise<void> {
53
- dummyHash ??= hashPassword("anti-enumeration-dummy");
54
- await verifyPassword(await dummyHash, password);
55
- }