@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
16
17
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
18
  import {
18
19
  createRegistry,
@@ -58,7 +59,7 @@ import { createDataRetentionFeature } from "../../data-retention";
58
59
  import { createSessionsFeature } from "../../sessions";
59
60
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
60
61
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
61
- import { sessionCallbacksFromLateBound } from "../../sessions/testing";
62
+ import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
62
63
  import { hashPassword } from "../../shared";
63
64
  import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
64
65
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -91,6 +92,7 @@ function buildFeatures() {
91
92
  createDataRetentionFeature(),
92
93
  createComplianceProfilesFeature(),
93
94
  createAuthEmailPasswordFeature(),
95
+ authFoundationFeature,
94
96
  createSessionsFeature(),
95
97
  createUserDataRightsFeature(),
96
98
  ];
@@ -137,6 +139,12 @@ beforeEach(async () => {
137
139
  ]);
138
140
  });
139
141
 
142
+ // Any actor hitting a write handler needs a live sid once sessionChecker
143
+ // is wired.
144
+ function mintActor(user: { id: string; tenantId: TenantId; roles: readonly string[] }) {
145
+ return withMintedSession((u, meta) => callbacks.get().sessionCreator(u, meta), user);
146
+ }
147
+
140
148
  describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
141
149
  test("Restricted-Status ueberlebt einen Projection-Rebuild (T_create != T_active)", async () => {
142
150
  // Diskriminierende Praemisse: user.created landet auf systemAdmin.tenantId
@@ -149,7 +157,7 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
149
157
  const created = await stack.http.writeOk<{ id: string }>(
150
158
  UserHandlers.create,
151
159
  { email: ALICE_EMAIL, passwordHash: hash, displayName: "Alice" },
152
- TestUsers.systemAdmin,
160
+ await mintActor(TestUsers.systemAdmin),
153
161
  );
154
162
  // Mitgliedschaft + Lifecycle auf einem ANDEREN, aktiven Tenant.
155
163
  await seedTenantMembership(stack.db, {
@@ -158,7 +166,7 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
158
166
  roles: ["Member"],
159
167
  });
160
168
 
161
- const aliceActive = { id: created.id, tenantId: T_ACTIVE, roles: ["Member"] };
169
+ const aliceActive = await mintActor({ id: created.id, tenantId: T_ACTIVE, roles: ["Member"] });
162
170
  const restricted = await stack.http.writeOk<{ status: string }>(RESTRICT, {}, aliceActive);
163
171
  expect(restricted.status).toBe(USER_STATUS.Restricted);
164
172
 
@@ -187,7 +195,7 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
187
195
  const created = await stack.http.writeOk<{ id: string }>(
188
196
  UserHandlers.create,
189
197
  { email: "grace.rebuild@example.com", passwordHash: hash, displayName: "Grace" },
190
- TestUsers.systemAdmin,
198
+ await mintActor(TestUsers.systemAdmin),
191
199
  );
192
200
 
193
201
  const T = getTemporal();
@@ -218,7 +226,7 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
218
226
  const created = await stack.http.writeOk<{ id: string }>(
219
227
  UserHandlers.create,
220
228
  { email: "legacy.rebuild@example.com", passwordHash: hash, displayName: "Legacy" },
221
- TestUsers.systemAdmin,
229
+ await mintActor(TestUsers.systemAdmin),
222
230
  );
223
231
 
224
232
  // Prae-Fix-Zustand simulieren: roher Write OHNE Event.
@@ -256,14 +264,14 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
256
264
  const goodRow = await stack.http.writeOk<{ id: string }>(
257
265
  UserHandlers.create,
258
266
  { email: "healthy.rebuild@example.com", passwordHash: hash, displayName: "Healthy" },
259
- TestUsers.systemAdmin,
267
+ await mintActor(TestUsers.systemAdmin),
260
268
  );
261
269
  await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: goodRow.id });
262
270
 
263
271
  const badRow = await stack.http.writeOk<{ id: string }>(
264
272
  UserHandlers.create,
265
273
  { email: "corrupt.rebuild@example.com", passwordHash: hash, displayName: "Corrupt" },
266
- TestUsers.systemAdmin,
274
+ await mintActor(TestUsers.systemAdmin),
267
275
  );
268
276
  await updateRows(stack.db, userTable, { status: USER_STATUS.Restricted }, { id: badRow.id });
269
277
  // userEntity is systemStream:true (schema/user.ts) — its event stream
@@ -10,6 +10,7 @@
10
10
  // hier; der Frist-Ablauf-Cleanup folgt mit S2.U5b.
11
11
 
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
13
14
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
14
15
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
16
  import {
@@ -57,6 +58,7 @@ const features = [
57
58
  createUserFeature(),
58
59
  createDataRetentionFeature(),
59
60
  createComplianceProfilesFeature(),
61
+ authFoundationFeature,
60
62
  createSessionsFeature(),
61
63
 
62
64
  createUserDataRightsFeature(),
@@ -8,6 +8,7 @@
8
8
  // Properties — dieser Test verifiziert sie end-to-end.
9
9
 
10
10
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
11
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
13
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
14
  import {
@@ -64,6 +65,7 @@ beforeAll(async () => {
64
65
  createUserFeature(),
65
66
  createDataRetentionFeature(),
66
67
  createComplianceProfilesFeature(),
68
+ authFoundationFeature,
67
69
  createSessionsFeature(),
68
70
 
69
71
  createUserDataRightsFeature({ sendDeletionRequestedEmail }),
@@ -18,6 +18,7 @@
18
18
  // hasJob=false wenn nichts da
19
19
 
20
20
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
21
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
21
22
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
22
23
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
23
24
  import {
@@ -53,6 +54,7 @@ beforeAll(async () => {
53
54
  createUserFeature(),
54
55
  createDataRetentionFeature(),
55
56
  createComplianceProfilesFeature(),
57
+ authFoundationFeature,
56
58
  createSessionsFeature(),
57
59
 
58
60
  createUserDataRightsFeature(),
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
14
  import { randomBytes } from "node:crypto";
15
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
15
16
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
17
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
17
18
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -44,7 +45,7 @@ import { createSessionsFeature } from "../../sessions";
44
45
  import { SessionHandlers } from "../../sessions/constants";
45
46
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
46
47
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
47
- import { sessionCallbacksFromLateBound } from "../../sessions/testing";
48
+ import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
48
49
  import { hashPassword } from "../../shared";
49
50
  import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
50
51
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -77,6 +78,7 @@ beforeAll(async () => {
77
78
  createDataRetentionFeature(),
78
79
  createComplianceProfilesFeature(),
79
80
  createAuthEmailPasswordFeature(),
81
+ authFoundationFeature,
80
82
  createSessionsFeature(),
81
83
  createUserDataRightsFeature(),
82
84
  ],
@@ -115,10 +117,14 @@ async function seedAliceWithMembership(
115
117
  status: string = USER_STATUS.Active,
116
118
  ): Promise<{ userId: string }> {
117
119
  const hash = await hashPassword(ALICE_PW);
120
+ const actor = await withMintedSession(
121
+ (user, meta) => callbacks.get().sessionCreator(user, meta),
122
+ TestUsers.systemAdmin,
123
+ );
118
124
  const created = await stack.http.writeOk<{ id: string }>(
119
125
  UserHandlers.create,
120
126
  { email: ALICE_EMAIL, passwordHash: hash, displayName: "Alice" },
121
- TestUsers.systemAdmin,
127
+ actor,
122
128
  );
123
129
  if (status !== USER_STATUS.Active) {
124
130
  await updateRows(stack.db, userTable, { status }, { id: created.id });
@@ -136,6 +142,12 @@ async function login(email: string, password: string): Promise<{ status: number;
136
142
  return { status: res.status, body: await res.json() };
137
143
  }
138
144
 
145
+ // Any actor hitting a write handler needs a live sid once sessionChecker
146
+ // is wired (same gate as seedAliceWithMembership's bootstrap actor above).
147
+ function mintActor(user: { id: string; tenantId: TenantId; roles: readonly string[] }) {
148
+ return withMintedSession((u, meta) => callbacks.get().sessionCreator(u, meta), user);
149
+ }
150
+
139
151
  function reasonOf(err: { details?: unknown }): string | undefined {
140
152
  return (err.details as { reason?: string } | undefined)?.reason;
141
153
  }
@@ -156,11 +168,11 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
156
168
  expect(sessionsBefore.every((s) => s.revokedAt === null)).toBe(true);
157
169
 
158
170
  // Restrict-Account.
159
- const aliceUser = {
171
+ const aliceUser = await mintActor({
160
172
  id: userId,
161
173
  tenantId: TENANT,
162
174
  roles: ["Member"],
163
- };
175
+ });
164
176
  const result = await stack.http.writeOk<{ userId: string; status: string }>(
165
177
  RESTRICT,
166
178
  {},
@@ -182,17 +194,36 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
182
194
  expect(sessionsAfter.every((s) => s.revokedAt !== null)).toBe(true);
183
195
  });
184
196
 
197
+ test("non-admin targets another user's account: 403 admin_required_for_other_user", async () => {
198
+ const { userId: targetId } = await seedAliceWithMembership();
199
+ const attacker = await mintActor({
200
+ id: crypto.randomUUID(),
201
+ tenantId: TENANT,
202
+ roles: ["Member"],
203
+ });
204
+ const err = await stack.http.writeErr(RESTRICT, { userId: targetId }, attacker);
205
+ expect(err.httpStatus).toBe(403);
206
+ expect(reasonOf(err)).toBe("admin_required_for_other_user");
207
+
208
+ const userRow = (await selectMany(stack.db, userTable, { id: targetId })) as Array<{
209
+ status: string;
210
+ }>;
211
+ expect(userRow[0]?.status).toBe(USER_STATUS.Active);
212
+ });
213
+
185
214
  test("Restricted → Restricted (Idempotenz-Guard): 422 already_restricted", async () => {
186
215
  const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
187
- const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
188
- const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
216
+ // Alice's own session is blocked while Restricted — an admin targets
217
+ // her account instead, same as production would have to.
218
+ const admin = await mintActor(TestUsers.admin);
219
+ const err = await stack.http.writeErr(RESTRICT, { userId }, admin);
189
220
  expect(err.httpStatus).toBe(422);
190
221
  expect(reasonOf(err)).toBe("already_restricted");
191
222
  });
192
223
 
193
224
  test("DeletionRequested → restrict-account: 422 user_not_in_active_state", async () => {
194
225
  const { userId } = await seedAliceWithMembership(USER_STATUS.DeletionRequested);
195
- const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
226
+ const aliceUser = await mintActor({ id: userId, tenantId: TENANT, roles: ["Member"] });
196
227
  const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
197
228
  expect(reasonOf(err)).toBe("user_not_in_active_state");
198
229
  });
@@ -201,9 +232,11 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
201
232
  describe("S2.U6 :: lift-restriction state-transitions", () => {
202
233
  test("Restricted → Active: status flippt zurueck", async () => {
203
234
  const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
204
- const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
235
+ // lift-restriction is operator-only (access.admin) — Alice's own
236
+ // session is blocked while Restricted anyway.
237
+ const admin = await mintActor(TestUsers.admin);
205
238
 
206
- const result = await stack.http.writeOk<{ status: string }>(LIFT, {}, aliceUser);
239
+ const result = await stack.http.writeOk<{ status: string }>(LIFT, { userId }, admin);
207
240
  expect(result.status).toBe(USER_STATUS.Active);
208
241
 
209
242
  const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
@@ -214,15 +247,15 @@ describe("S2.U6 :: lift-restriction state-transitions", () => {
214
247
 
215
248
  test("Active → lift-restriction: 422 not_restricted (Idempotenz-Guard)", async () => {
216
249
  const { userId } = await seedAliceWithMembership(USER_STATUS.Active);
217
- const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
218
- const err = await stack.http.writeErr(LIFT, {}, aliceUser);
250
+ const admin = await mintActor(TestUsers.admin);
251
+ const err = await stack.http.writeErr(LIFT, { userId }, admin);
219
252
  expect(reasonOf(err)).toBe("not_restricted");
220
253
  });
221
254
 
222
255
  test("DeletionRequested → lift-restriction: 422 not_restricted", async () => {
223
256
  const { userId } = await seedAliceWithMembership(USER_STATUS.DeletionRequested);
224
- const aliceUser = { id: userId, tenantId: TENANT, roles: ["Member"] };
225
- const err = await stack.http.writeErr(LIFT, {}, aliceUser);
257
+ const admin = await mintActor(TestUsers.admin);
258
+ const err = await stack.http.writeErr(LIFT, { userId }, admin);
226
259
  expect(reasonOf(err)).toBe("not_restricted");
227
260
  });
228
261
  });
@@ -286,11 +319,11 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
286
319
  expect(liveBefore.length).toBe(2);
287
320
 
288
321
  // System-Caller.
289
- const systemUser = {
322
+ const systemUser = await mintActor({
290
323
  id: "00000000-0000-4000-8000-000000000000",
291
324
  tenantId: TENANT,
292
325
  roles: ["SystemAdmin"],
293
- };
326
+ });
294
327
  const result = await stack.http.writeOk<{ count: number; userId: string }>(
295
328
  SessionHandlers.revokeAllForUser,
296
329
  { userId },
@@ -10,6 +10,7 @@
10
10
  // jeden Export auf "failed" setzte).
11
11
 
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
13
14
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
14
15
  import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
15
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -65,6 +66,7 @@ beforeAll(async () => {
65
66
  fileProviderInMemoryFeature,
66
67
  mailFoundationFeature,
67
68
  mailTransportInMemoryFeature,
69
+ authFoundationFeature,
68
70
  createSessionsFeature(),
69
71
  // appExportDownloadUrl set → the default export-ready mail is enabled, so
70
72
  // this also proves the export cron's mail bridge end-to-end (C6).
@@ -17,6 +17,7 @@ import { spawn } from "node:child_process";
17
17
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
21
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
21
22
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
22
23
  import {
@@ -59,6 +60,7 @@ beforeAll(async () => {
59
60
  createUserFeature(),
60
61
  createDataRetentionFeature(),
61
62
  createComplianceProfilesFeature(),
63
+ authFoundationFeature,
62
64
  createSessionsFeature(),
63
65
 
64
66
  createUserDataRightsFeature(),
@@ -385,6 +387,47 @@ describe("runExportJobs :: storage-cleanup", () => {
385
387
  expect(result.cleanedJobIds).not.toContain(jobId);
386
388
  expect(await provider.exists(storageKey)).toBe(true);
387
389
  });
390
+
391
+ test("storage-delete Throw → Job bleibt uncleaned (retry naechster Pass)", async () => {
392
+ const jobId = await seedPendingJob();
393
+ const T = getTemporal();
394
+ const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
395
+ const storageKey = `${tenantA}/exports/${jobId}.zip`;
396
+ const provider = await buildProvider(tenantA);
397
+ await provider.write(storageKey, new Uint8Array([7, 8, 9]));
398
+
399
+ await updateRows(
400
+ stack.db,
401
+ exportJobsTable,
402
+ {
403
+ status: EXPORT_JOB_STATUS.Done,
404
+ startedAt: longAgo,
405
+ completedAt: longAgo,
406
+ downloadStorageKey: storageKey,
407
+ expiresAt: longAgo,
408
+ },
409
+ { id: jobId },
410
+ );
411
+
412
+ const result = await runExportJobs({
413
+ db: stack.db,
414
+ registry: stack.registry,
415
+ buildStorageProvider: async () => ({
416
+ ...provider,
417
+ delete: async () => {
418
+ throw new Error("synthetic storage delete failure");
419
+ },
420
+ }),
421
+ now: NOW(),
422
+ });
423
+
424
+ expect(result.cleanedJobIds).not.toContain(jobId);
425
+ expect(await provider.exists(storageKey)).toBe(true);
426
+ const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
427
+ downloadStorageKey: string | null;
428
+ }>;
429
+ expect(row?.downloadStorageKey).toBe(storageKey);
430
+ });
388
431
  });
389
432
 
390
433
  describe("runExportJobs :: idempotency", () => {
@@ -706,6 +749,145 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
706
749
  expect(row?.errorMessage).toMatch(/Token-Creation failed/);
707
750
  });
708
751
 
752
+ test("failed-Job: sendExportFailedEmail wird mit errorMessage + userEmail gerufen", async () => {
753
+ const jobId = await seedPendingJob();
754
+
755
+ // Force a deterministic failure the same way as the UNIQUE-violation
756
+ // test above — pre-seed a token row for this jobId so the worker's
757
+ // tokenCrud.create hits the unique index and the job flips to failed.
758
+ await asRawClient(stack.db).unsafe(
759
+ `
760
+ INSERT INTO read_export_download_tokens
761
+ (id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
762
+ VALUES (
763
+ gen_random_uuid(),
764
+ $1,
765
+ $2,
766
+ $3,
767
+ now(),
768
+ now() + interval '7 days',
769
+ 1,
770
+ now(),
771
+ now()
772
+ )
773
+ `,
774
+ [tenantA, jobId, "existing-hash"],
775
+ );
776
+
777
+ type SentArgs = {
778
+ userId: string;
779
+ userEmail: string;
780
+ jobId: string;
781
+ errorMessage: string;
782
+ tenantId: string;
783
+ };
784
+ const sentEmails: SentArgs[] = [];
785
+
786
+ const result = await runExportJobs({
787
+ db: stack.db,
788
+ registry: stack.registry,
789
+ buildStorageProvider: buildProvider,
790
+ now: NOW(),
791
+ sendExportFailedEmail: async (args) => {
792
+ sentEmails.push(args);
793
+ },
794
+ });
795
+
796
+ expect(result.failedJobIds).toContain(jobId);
797
+ expect(sentEmails).toHaveLength(1);
798
+ const sent = sentEmails[0];
799
+ if (!sent) throw new Error("expected 1 sent email");
800
+ expect(sent.userId).toBe(String(aliceUser.id));
801
+ expect(sent.userEmail).toBe("alice@example.com");
802
+ expect(sent.jobId).toBe(jobId);
803
+ expect(sent.errorMessage).toMatch(/Token-Creation failed/);
804
+ });
805
+
806
+ test("failed-Job ohne Callback: kein Email, Worker-Run succeeded", async () => {
807
+ const jobId = await seedPendingJob();
808
+ await asRawClient(stack.db).unsafe(
809
+ `
810
+ INSERT INTO read_export_download_tokens
811
+ (id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
812
+ VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
813
+ `,
814
+ [tenantA, jobId, "existing-hash"],
815
+ );
816
+
817
+ const result = await runExportJobs({
818
+ db: stack.db,
819
+ registry: stack.registry,
820
+ buildStorageProvider: buildProvider,
821
+ now: NOW(),
822
+ // Kein sendExportFailedEmail
823
+ });
824
+ expect(result.failedJobIds).toContain(jobId);
825
+ // Kein Email, kein Throw — Worker laeuft normal durch
826
+ });
827
+
828
+ test("failed-Job, sendExportFailedEmail-Throw wird abgefangen (best-effort, kein Batch-Abbruch)", async () => {
829
+ const jobId = await seedPendingJob();
830
+ await asRawClient(stack.db).unsafe(
831
+ `
832
+ INSERT INTO read_export_download_tokens
833
+ (id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
834
+ VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
835
+ `,
836
+ [tenantA, jobId, "existing-hash"],
837
+ );
838
+
839
+ // Should not throw despite the callback throwing — the try/catch
840
+ // around fireExportFailedCallback swallows it (operator-visibility
841
+ // via console.warn only).
842
+ const result = await runExportJobs({
843
+ db: stack.db,
844
+ registry: stack.registry,
845
+ buildStorageProvider: buildProvider,
846
+ now: NOW(),
847
+ sendExportFailedEmail: async () => {
848
+ throw new Error("synthetic email transport failure");
849
+ },
850
+ });
851
+ expect(result.failedJobIds).toContain(jobId);
852
+ });
853
+
854
+ test("failed-Job, user ohne email → sendExportFailedEmail skipped (kein Throw)", async () => {
855
+ await resetTestTables(stack.db, [userTable]);
856
+ await seedRow(stack.db, userTable, {
857
+ id: String(aliceUser.id),
858
+ tenantId: tenantA,
859
+ email: "" as string,
860
+ passwordHash: "hashed",
861
+ displayName: "Alice",
862
+ locale: "de",
863
+ emailVerified: true,
864
+ roles: '["Member"]',
865
+ status: USER_STATUS.Active,
866
+ });
867
+ const jobId = await seedPendingJob();
868
+ await asRawClient(stack.db).unsafe(
869
+ `
870
+ INSERT INTO read_export_download_tokens
871
+ (id, tenant_id, job_id, token_hash, issued_at, expires_at, version, inserted_at, modified_at)
872
+ VALUES (gen_random_uuid(), $1, $2, $3, now(), now() + interval '7 days', 1, now(), now())
873
+ `,
874
+ [tenantA, jobId, "existing-hash"],
875
+ );
876
+
877
+ let callbackInvoked = false;
878
+ const result = await runExportJobs({
879
+ db: stack.db,
880
+ registry: stack.registry,
881
+ buildStorageProvider: buildProvider,
882
+ now: NOW(),
883
+ sendExportFailedEmail: async () => {
884
+ callbackInvoked = true;
885
+ },
886
+ });
887
+ expect(result.failedJobIds).toContain(jobId);
888
+ expect(callbackInvoked).toBe(false);
889
+ });
890
+
709
891
  test("failed-Job mit downloadStorageKey → ZIP wird im Cleanup-Pass entfernt (orphan-fix)", async () => {
710
892
  // Atom 4a.fix orphan-cleanup: failed-Jobs haben downloadStorageKey
711
893
  // gesetzt (path-pre-claim) — der ZIP-Pfad ist persistiert ab claim.
@@ -803,6 +985,7 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
803
985
  createUserFeature(),
804
986
  createDataRetentionFeature(),
805
987
  createComplianceProfilesFeature(),
988
+ authFoundationFeature,
806
989
  createSessionsFeature(),
807
990
 
808
991
  createUserDataRightsFeature(),
@@ -1063,7 +1246,7 @@ describe("runExportJobs :: Atom 5 notification-callbacks", () => {
1063
1246
  expect(sent.userEmail).toBe("alice@example.com");
1064
1247
  expect(sent.jobId).toBe(jobId);
1065
1248
  expect(sent.downloadUrl).toMatch(
1066
- /^https:\/\/app\.example\.com\/user-export\/by-token\?token=[A-Za-z0-9_%-]+$/,
1249
+ /^https:\/\/app\.example\.com\/user-export\/by-token#token=[A-Za-z0-9_%-]+$/,
1067
1250
  );
1068
1251
  // plain-token aus dem callback-arg entspricht dem result.tokenByJobId
1069
1252
  const plainFromResult = result.tokenByJobId.get(jobId);
@@ -16,6 +16,7 @@
16
16
  // der end-to-end-Default-Pfad (delete).
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
19
20
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
21
  import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
21
22
  import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
@@ -67,6 +68,7 @@ beforeAll(async () => {
67
68
  createFilesFeature(),
68
69
  createDataRetentionFeature(),
69
70
  createComplianceProfilesFeature(),
71
+ authFoundationFeature,
70
72
  createSessionsFeature(),
71
73
 
72
74
  createUserDataRightsFeature(),
@@ -15,6 +15,7 @@
15
15
  // ueber Pseudo-Tenant.
16
16
 
17
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
18
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
19
20
  import {
20
21
  configurePiiSubjectKms,
@@ -61,6 +62,7 @@ beforeAll(async () => {
61
62
  createFilesFeature(),
62
63
  createDataRetentionFeature(),
63
64
  createComplianceProfilesFeature(),
65
+ authFoundationFeature,
64
66
  createSessionsFeature(),
65
67
 
66
68
  createUserDataRightsFeature(),
@@ -13,6 +13,7 @@
13
13
  // value (the failure mode that shipped the ctx.config export bug).
14
14
 
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
16
17
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
17
18
  import {
18
19
  createEntity,
@@ -82,6 +83,7 @@ beforeAll(async () => {
82
83
  stack = await setupTestStack({
83
84
  features: [
84
85
  createUserFeature(),
86
+ authFoundationFeature,
85
87
  createSessionsFeature(),
86
88
  createDataRetentionFeature(),
87
89
  createComplianceProfilesFeature(),
@@ -7,6 +7,7 @@
7
7
  // Sprint-2-H1/H2-Hooks) kommt in S2.T1 (Cross-Data-Matrix).
8
8
 
9
9
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
10
11
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
11
12
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
12
13
  import { createDataRetentionFeature } from "../../data-retention";
@@ -24,7 +25,14 @@ const userDataRights = createUserDataRightsFeature();
24
25
 
25
26
  beforeAll(async () => {
26
27
  stack = await setupTestStack({
27
- features: [userFeature, sessionsFeature, dataRetention, complianceProfiles, userDataRights],
28
+ features: [
29
+ authFoundationFeature,
30
+ userFeature,
31
+ sessionsFeature,
32
+ dataRetention,
33
+ complianceProfiles,
34
+ userDataRights,
35
+ ],
28
36
  });
29
37
  });
30
38