@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.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 (152) hide show
  1. package/package.json +10 -6
  2. package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
  3. package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +50 -21
  5. package/src/audit/handlers/list.query.ts +1 -0
  6. package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
  7. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
  8. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  9. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  10. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  11. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  12. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  13. package/src/auth-email-password/handlers/login.write.ts +8 -0
  14. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  15. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  16. package/src/auth-email-password/i18n.ts +2 -0
  17. package/src/auth-email-password/seeding.ts +41 -23
  18. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  19. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  20. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  21. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  22. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  23. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  24. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  25. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  26. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/auth-client.ts +32 -10
  28. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  29. package/src/auth-email-password/web/client-plugin.ts +15 -2
  30. package/src/auth-email-password/web/index.ts +5 -1
  31. package/src/auth-email-password/web/login-screen.tsx +7 -3
  32. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  33. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  34. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  35. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  36. package/src/auth-foundation/boot-checks.ts +74 -69
  37. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  38. package/src/auth-foundation/types.ts +3 -3
  39. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  40. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  41. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
  42. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  43. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  44. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  45. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  46. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  47. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  48. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  49. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  50. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  52. package/src/auth-mfa/web/i18n.ts +4 -0
  53. package/src/auth-mfa/web/mfa-client.ts +13 -2
  54. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  55. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  56. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  57. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  58. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  59. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  61. package/src/config/db/queries/resolver.ts +3 -3
  62. package/src/config/handlers/reencrypt.job.ts +36 -24
  63. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  64. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  65. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  66. package/src/document-ingest-foundation/entity.ts +10 -3
  67. package/src/document-ingest-foundation/feature.ts +26 -8
  68. package/src/document-ingest-foundation/index.ts +1 -0
  69. package/src/document-ingest-foundation/pages.ts +34 -0
  70. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  71. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  72. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  73. package/src/files-provider-s3/s3-provider.ts +12 -1
  74. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  76. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  77. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  78. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  79. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  80. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  81. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  82. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  83. package/src/jobs/feature.ts +6 -5
  84. package/src/jobs/job-instant.ts +9 -0
  85. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  86. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  87. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  88. package/src/page-render/__tests__/markdown.test.ts +29 -0
  89. package/src/page-render/markdown.ts +1 -9
  90. package/src/personal-access-tokens/feature.ts +1 -1
  91. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  92. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  93. package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
  94. package/src/sessions/feature.ts +12 -0
  95. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  96. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  97. package/src/sessions/handlers/revoke.write.ts +13 -0
  98. package/src/sessions/index.ts +7 -0
  99. package/src/sessions/session-callbacks.ts +58 -5
  100. package/src/sessions/session-revoked-event.ts +29 -0
  101. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  102. package/src/shared/chunked-entity-migration.ts +2 -2
  103. package/src/shared/grace-period.test.ts +26 -0
  104. package/src/shared/grace-period.ts +20 -0
  105. package/src/shared/index.ts +2 -0
  106. package/src/shared/map-with-concurrency.test.ts +104 -0
  107. package/src/shared/map-with-concurrency.ts +38 -0
  108. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  109. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  110. package/src/subscription-mollie/verify-webhook.ts +1 -0
  111. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  112. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  113. package/src/subscription-stripe/verify-webhook.ts +3 -2
  114. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  115. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  116. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  117. package/src/tenant/handlers/invitations.query.ts +13 -15
  118. package/src/tenant/handlers/members.query.ts +12 -12
  119. package/src/tenant/seeding.ts +110 -16
  120. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  121. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
  122. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
  123. package/src/tenant-settings/config.ts +44 -0
  124. package/src/tenant-settings/constants.ts +16 -0
  125. package/src/tenant-settings/feature.ts +49 -0
  126. package/src/tenant-settings/index.ts +8 -0
  127. package/src/tenant-settings/tenant-defaults.ts +67 -0
  128. package/src/user/handlers/find-for-auth.query.ts +1 -1
  129. package/src/user/seeding.ts +8 -0
  130. package/src/user-data-rights/COMPLIANCE.md +1 -1
  131. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  132. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  133. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  134. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
  135. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  136. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  137. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
  138. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  139. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  140. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  141. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  142. package/src/user-data-rights/feature.ts +45 -45
  143. package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
  144. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  145. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  146. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  147. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  148. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  149. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  150. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  151. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  152. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -123,7 +123,21 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
123
123
  expect(job).toBeDefined();
124
124
 
125
125
  // EXAKT der prod-Job-Kontext: configResolver gesetzt, config undefined.
126
- const jobCtx = { db: stack.db, registry: stack.registry, configResolver };
126
+ // log required since #1572 persists incomplete[] via ctx.log.warn.
127
+ const jobCtx = {
128
+ db: stack.db,
129
+ registry: stack.registry,
130
+ configResolver,
131
+ log: {
132
+ info() {},
133
+ warn() {},
134
+ error() {},
135
+ debug() {},
136
+ child() {
137
+ return this;
138
+ },
139
+ },
140
+ };
127
141
  await job?.handler({}, jobCtx as never);
128
142
 
129
143
  // Loeschung lief autonom durch.
@@ -45,6 +45,10 @@ import { createSessionsFeature } from "../../sessions";
45
45
  import { SessionHandlers } from "../../sessions/constants";
46
46
  import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
47
47
  import { createSessionCallbacks, type SessionCallbacks } from "../../sessions/session-callbacks";
48
+ import {
49
+ SESSION_REVOKED_EVENT_QN,
50
+ type SessionRevokedPayload,
51
+ } from "../../sessions/session-revoked-event";
48
52
  import { sessionCallbacksFromLateBound, withMintedSession } from "../../sessions/testing";
49
53
  import { hashPassword } from "../../shared";
50
54
  import { createTenantFeature, tenantMembershipsTable } from "../../tenant";
@@ -211,6 +215,36 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
211
215
  expect(userRow[0]?.status).toBe(USER_STATUS.Active);
212
216
  });
213
217
 
218
+ // kumiko-framework#1545: mirrors the lift-restriction cross-tenant guard
219
+ // below — an isAdminActor check alone lets an Admin/TenantAdmin from a
220
+ // DIFFERENT tenant restrict (and force-revoke the sessions of) a user
221
+ // who was never a member of the admin's tenant.
222
+ test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
223
+ const { userId } = await seedAliceWithMembership();
224
+ const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
225
+
226
+ const err = await stack.http.writeErr(RESTRICT, { userId }, otherTenantAdmin);
227
+ expect(err.httpStatus).toBe(403);
228
+ expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
229
+
230
+ const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
231
+ status: string;
232
+ }>;
233
+ expect(userRow[0]?.status).toBe(USER_STATUS.Active);
234
+ });
235
+
236
+ test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator)", async () => {
237
+ const { userId } = await seedAliceWithMembership();
238
+ const systemAdmin = await mintActor({
239
+ id: crypto.randomUUID(),
240
+ tenantId: testTenantId(2),
241
+ roles: ["SystemAdmin"],
242
+ });
243
+
244
+ const result = await stack.http.writeOk<{ status: string }>(RESTRICT, { userId }, systemAdmin);
245
+ expect(result.status).toBe(USER_STATUS.Restricted);
246
+ });
247
+
214
248
  test("Restricted → Restricted (Idempotenz-Guard): 422 already_restricted", async () => {
215
249
  const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
216
250
  // Alice's own session is blocked while Restricted — an admin targets
@@ -227,6 +261,51 @@ describe("S2.U6 :: restrict-account state-transitions", () => {
227
261
  const err = await stack.http.writeErr(RESTRICT, {}, aliceUser);
228
262
  expect(reasonOf(err)).toBe("user_not_in_active_state");
229
263
  });
264
+
265
+ // kumiko-framework#1540: access.admin includes TenantAdmin/Admin, which
266
+ // are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
267
+ // (no auto-tenant-filter — User status is intentionally global). Without
268
+ // a membership check, an Admin from a DIFFERENT tenant than the target
269
+ // user could still pass the isAdminActor gate and flip the target's
270
+ // status. Mirrors the sibling lift-restriction fix (kumiko-framework#1342).
271
+ test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Active", async () => {
272
+ const { userId: targetId } = await seedAliceWithMembership();
273
+ const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
274
+
275
+ const err = await stack.http.writeErr(RESTRICT, { userId: targetId }, otherTenantAdmin);
276
+ expect(err.httpStatus).toBe(403);
277
+ expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
278
+
279
+ const userRow = (await selectMany(stack.db, userTable, { id: targetId })) as Array<{
280
+ status: string;
281
+ }>;
282
+ expect(userRow[0]?.status).toBe(USER_STATUS.Active);
283
+ });
284
+
285
+ test("SystemAdmin darf tenant-übergreifend restricten (platform-weiter Operator) + revoked Sessions", async () => {
286
+ const { userId: targetId } = await seedAliceWithMembership();
287
+ const loginRes = await login(ALICE_EMAIL, ALICE_PW);
288
+ expect(loginRes.status).toBe(200);
289
+
290
+ const systemAdmin = await mintActor({
291
+ id: crypto.randomUUID(),
292
+ tenantId: testTenantId(2),
293
+ roles: ["SystemAdmin"],
294
+ });
295
+
296
+ const result = await stack.http.writeOk<{ userId: string; status: string }>(
297
+ RESTRICT,
298
+ { userId: targetId },
299
+ systemAdmin,
300
+ );
301
+ expect(result.status).toBe(USER_STATUS.Restricted);
302
+
303
+ const sessionsAfter = (await selectMany(stack.db, userSessionTable, {
304
+ userId: targetId,
305
+ })) as Array<{ revokedAt: unknown }>;
306
+ expect(sessionsAfter.length).toBeGreaterThanOrEqual(1);
307
+ expect(sessionsAfter.every((s) => s.revokedAt !== null)).toBe(true);
308
+ });
230
309
  });
231
310
 
232
311
  describe("S2.U6 :: lift-restriction state-transitions", () => {
@@ -258,6 +337,37 @@ describe("S2.U6 :: lift-restriction state-transitions", () => {
258
337
  const err = await stack.http.writeErr(LIFT, { userId }, admin);
259
338
  expect(reasonOf(err)).toBe("not_restricted");
260
339
  });
340
+
341
+ // kumiko-framework#1342: access.admin includes TenantAdmin/Admin, which
342
+ // are tenant-scoped roles, but the User-entity lookup uses ctx.db.raw
343
+ // (no auto-tenant-filter — User status is intentionally global). Without
344
+ // a membership check, an Admin from a DIFFERENT tenant than the target
345
+ // user could still pass the access-gate and flip the target's status.
346
+ test("Admin aus fremdem Tenant: 403 target_user_not_in_admin_tenant, Status bleibt Restricted", async () => {
347
+ const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
348
+ const otherTenantAdmin = await mintActor(TestUsers.otherTenant);
349
+
350
+ const err = await stack.http.writeErr(LIFT, { userId }, otherTenantAdmin);
351
+ expect(err.httpStatus).toBe(403);
352
+ expect(reasonOf(err)).toBe("target_user_not_in_admin_tenant");
353
+
354
+ const userRow = (await selectMany(stack.db, userTable, { id: userId })) as Array<{
355
+ status: string;
356
+ }>;
357
+ expect(userRow[0]?.status).toBe(USER_STATUS.Restricted);
358
+ });
359
+
360
+ test("SystemAdmin darf tenant-übergreifend liften (platform-weiter Operator)", async () => {
361
+ const { userId } = await seedAliceWithMembership(USER_STATUS.Restricted);
362
+ const systemAdmin = await mintActor({
363
+ id: crypto.randomUUID(),
364
+ tenantId: testTenantId(2),
365
+ roles: ["SystemAdmin"],
366
+ });
367
+
368
+ const result = await stack.http.writeOk<{ status: string }>(LIFT, { userId }, systemAdmin);
369
+ expect(result.status).toBe(USER_STATUS.Active);
370
+ });
261
371
  });
262
372
 
263
373
  describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () => {
@@ -337,5 +447,15 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
337
447
  revokedAt: unknown;
338
448
  }>;
339
449
  expect(revoked.every((s) => s.revokedAt !== null)).toBe(true);
450
+
451
+ // #1559 — cross-tenant privileged revoke uses the low-level append(),
452
+ // not ctx.unsafeAppendEvent (see revoke-all-for-user.write.ts for why:
453
+ // the SystemAdmin caller's own tenantId has no relationship to the
454
+ // target user's session tenant). One event, both sids listed.
455
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
456
+ expect(events).toHaveLength(1);
457
+ const payload = events[0]?.["payload"] as SessionRevokedPayload;
458
+ expect(payload.userId).toBe(userId);
459
+ expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
340
460
  });
341
461
  });
@@ -12,7 +12,7 @@
12
12
  // downloadStorageKey wird genullt + storage-key geloescht
13
13
  // 6. Idempotency: 2× run → kein Re-Processing von done/failed-Jobs
14
14
 
15
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
16
16
  import { spawn } from "node:child_process";
17
17
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
18
18
  import { tmpdir } from "node:os";
@@ -818,7 +818,10 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
818
818
  const calls: string[] = [];
819
819
  // Should not throw despite job A's callback throwing — the try/catch
820
820
  // around fireExportFailedCallback swallows it (operator-visibility
821
- // via console.warn only) and the loop continues to job B.
821
+ // via console.warn only) and the loop continues to job B. A regress to
822
+ // a fully silent swallow (no log call at all) would still pass every
823
+ // assertion below unless the warn itself is pinned.
824
+ const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
822
825
  const result = await runExportJobs({
823
826
  db: stack.db,
824
827
  registry: stack.registry,
@@ -833,6 +836,8 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
833
836
  });
834
837
  expect(result.failedJobIds).toEqual(expect.arrayContaining([jobAId, jobBId]));
835
838
  expect(calls.sort()).toEqual([jobAId, jobBId].sort());
839
+ expect(warnSpy).toHaveBeenCalled();
840
+ warnSpy.mockRestore();
836
841
  });
837
842
 
838
843
  test("failed-Job, user ohne email → sendExportFailedEmail skipped (kein Throw)", async () => {
@@ -840,7 +845,7 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
840
845
  await seedRow(stack.db, userTable, {
841
846
  id: String(aliceUser.id),
842
847
  tenantId: tenantA,
843
- email: "" as string,
848
+ email: "", // empty string — lookupUserEmail returns null
844
849
  passwordHash: "hashed",
845
850
  displayName: "Alice",
846
851
  locale: "de",
@@ -1270,7 +1275,7 @@ describe("runExportJobs :: Atom 5 notification-callbacks", () => {
1270
1275
  await seedRow(stack.db, userTable, {
1271
1276
  id: String(aliceUser.id),
1272
1277
  tenantId: tenantA,
1273
- email: "" as string, // empty string — lookupUserEmail returnt null
1278
+ email: "" as string, // empty string — lookupUserEmail returns null
1274
1279
  passwordHash: "h",
1275
1280
  displayName: "Alice",
1276
1281
  locale: "de",
@@ -282,6 +282,22 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
282
282
  // getJob undefined und die Loeschung laeuft nach Grace-Ablauf NIE. Wir
283
283
  // treiben den ECHTEN registrierten Job durch den echten JobContext (db +
284
284
  // registry, kein config) — nicht den inneren runForgetCleanup-Helper.
285
+ function capturingJobLog() {
286
+ const warns: string[] = [];
287
+ const log: JobContext["log"] = {
288
+ info() {},
289
+ warn(message: string) {
290
+ warns.push(message);
291
+ },
292
+ error() {},
293
+ debug() {},
294
+ child(): JobContext["log"] {
295
+ return this;
296
+ },
297
+ };
298
+ return { log, warns };
299
+ }
300
+
285
301
  test("registered job exists + erases through the real JobContext", async () => {
286
302
  const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
287
303
  expect(job).toBeTruthy();
@@ -295,10 +311,8 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
295
311
  });
296
312
  await seedMembership(CRON_USER, TENANT_A);
297
313
 
298
- const jobCtx = { db: stack.db, registry: stack.registry };
299
- // jobCtx is a deliberately minimal prod-context replica (missing
300
- // systemUser/log/triggeredBy) — double-cast at this test boundary since
301
- // the handler under test never touches those fields.
314
+ const { log } = capturingJobLog();
315
+ const jobCtx = { db: stack.db, registry: stack.registry, log };
302
316
  await job?.handler({}, jobCtx as unknown as JobContext);
303
317
 
304
318
  const row = await fetchUser(CRON_USER);
@@ -306,6 +320,52 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
306
320
  expect(row?.email).not.toContain("cron-delete@example.com");
307
321
  expect(row?.email).toContain("anonymized.invalid");
308
322
  });
323
+
324
+ test("incomplete hooks are written to ctx.log (job-run audit trail, #1572)", async () => {
325
+ const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
326
+ expect(job).toBeTruthy();
327
+
328
+ await seedUser(ALICE_ID, {
329
+ status: USER_STATUS.DeletionRequested,
330
+ gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
331
+ });
332
+ await seedMembership(ALICE_ID, TENANT_A);
333
+
334
+ const usages = stack.registry.getExtensionUsages("userData");
335
+ const userUsage = usages.find((u) => u.entityName === "user");
336
+ if (!userUsage?.options) throw new Error("user usage not found");
337
+ type DeleteHook = (
338
+ ctx: { userId: string; tenantId: string; db: unknown },
339
+ strategy: string,
340
+ ) => Promise<undefined | { status: "ok" } | { status: "incomplete"; reason: string }>;
341
+ const originalUserDelete = (userUsage.options as { delete: DeleteHook }).delete;
342
+ (userUsage.options as { delete: DeleteHook }).delete = async (ctx, strategy) => {
343
+ if (ctx.userId === ALICE_ID) {
344
+ return { status: "incomplete", reason: "synthetic partial cleanup for alice" };
345
+ }
346
+ return originalUserDelete(ctx, strategy);
347
+ };
348
+
349
+ const { log, warns } = capturingJobLog();
350
+ try {
351
+ await job?.handler({}, {
352
+ db: stack.db,
353
+ registry: stack.registry,
354
+ log,
355
+ } as unknown as JobContext);
356
+ } finally {
357
+ (userUsage.options as { delete: DeleteHook }).delete = originalUserDelete;
358
+ }
359
+
360
+ const hit = warns.find((w) => w.includes("incomplete hook") && w.includes(ALICE_ID));
361
+ expect(hit).toBeDefined();
362
+ expect(hit).toContain("entityName=user");
363
+ expect(hit).toContain("rolledBack=false");
364
+ expect(hit).toContain("reason=synthetic partial cleanup for alice");
365
+
366
+ const aliceRow = await fetchUser(ALICE_ID);
367
+ expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
368
+ });
309
369
  });
310
370
 
311
371
  describe("runForgetCleanup :: time-window guards", () => {
@@ -798,6 +858,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
798
858
  expect(aliceIncomplete?.reason).toBe("synthetic partial cleanup for alice");
799
859
  expect(aliceIncomplete?.tenantId).toBe(TENANT_A);
800
860
  expect(aliceIncomplete?.entityName).toBe("user");
861
+ expect(aliceIncomplete?.rolledBack).toBe(false);
801
862
 
802
863
  // Status flip proceeded despite the incomplete report.
803
864
  const aliceRow = await fetchUser(ALICE_ID);
@@ -814,7 +875,7 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
814
875
  }
815
876
  });
816
877
 
817
- test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report is discarded by the rollback", async () => {
878
+ test("hook incomplete on one tenant, then throws on a later tenant → the incomplete report survives, flagged rolledBack (#1489)", async () => {
818
879
  await seedUser(ALICE_ID, {
819
880
  status: USER_STATUS.DeletionRequested,
820
881
  gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
@@ -857,9 +918,14 @@ describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architek
857
918
  now: NOW(),
858
919
  });
859
920
 
860
- // The whole per-user sub-tx rolled back — no incomplete entry from
861
- // tenant A survives, and the failure surfaces as a hard error instead.
862
- expect(result.incomplete.some((i) => i.userId === ALICE_ID)).toBe(false);
921
+ // The whole per-user sub-tx rolled back — the DB rows (including the
922
+ // status flip) are undone, and the failure surfaces as a hard error
923
+ // too. But tenant A's "incomplete" report describes an external,
924
+ // non-transactional side effect (already ran, not rolled back by a
925
+ // DB rollback) — it must still reach the operator, just marked
926
+ // rolledBack so they know the user itself is still DeletionRequested.
927
+ const aliceIncomplete = result.incomplete.find((i) => i.userId === ALICE_ID);
928
+ expect(aliceIncomplete?.rolledBack).toBe(true);
863
929
  expect(result.errors.some((e) => e.userId === ALICE_ID)).toBe(true);
864
930
  expect(result.processedUserIds).not.toContain(ALICE_ID);
865
931
 
@@ -0,0 +1,69 @@
1
+ // kumiko-framework#1525: selectUsersDueForForgetCleanup must not rely on
2
+ // globalThis.Temporal when the caller passes a string cutoff (the
3
+ // run-forget-cleanup.integration.test.ts suite exercises the full
4
+ // pipeline; this is the narrow seam for the ambient-global regression).
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import {
8
+ setupTestStack,
9
+ type TestStack,
10
+ unsafeCreateEntityTable,
11
+ } from "@cosmicdrift/kumiko-framework/stack";
12
+ import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
13
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
14
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../../../user";
15
+ import { selectUsersDueForForgetCleanup } from "../forget-cleanup";
16
+
17
+ let stack: TestStack;
18
+
19
+ const TENANT = "00000000-0000-4000-8000-00000000000a";
20
+ const USER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-000000000001";
21
+
22
+ beforeAll(async () => {
23
+ stack = await setupTestStack({ features: [createUserFeature()] });
24
+ await unsafeCreateEntityTable(stack.db, userEntity);
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await stack.cleanup();
29
+ });
30
+
31
+ beforeEach(async () => {
32
+ await resetTestTables(stack.db, [userTable]);
33
+ });
34
+
35
+ describe("selectUsersDueForForgetCleanup — kumiko-framework#1525: no ambient Temporal global", () => {
36
+ test("string cutoff resolves without relying on globalThis.Temporal", async () => {
37
+ const pastGracePeriodEnd = getTemporal().Now.instant().subtract({ hours: 1 });
38
+ await seedRow(stack.db, userTable, {
39
+ id: USER_ID,
40
+ tenantId: TENANT,
41
+ email: "due@example.com",
42
+ passwordHash: "hashed",
43
+ displayName: "Due User",
44
+ locale: "de",
45
+ emailVerified: true,
46
+ roles: '["Member"]',
47
+ status: USER_STATUS.DeletionRequested,
48
+ gracePeriodEnd: pastGracePeriodEnd,
49
+ });
50
+
51
+ // Computed with Temporal intact — only the query call itself runs
52
+ // ambient-free below.
53
+ const cutoffIso = getTemporal().Now.instant().toString();
54
+
55
+ const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
56
+ delete (globalThis as { Temporal?: unknown }).Temporal;
57
+ try {
58
+ const due = await selectUsersDueForForgetCleanup(
59
+ stack.db,
60
+ USER_STATUS.DeletionRequested,
61
+ cutoffIso,
62
+ );
63
+ expect(due.map((u) => u.id)).toContain(USER_ID);
64
+ } finally {
65
+ if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
66
+ else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
67
+ }
68
+ });
69
+ });
@@ -1,15 +1,22 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ // Value-only import, aliased to avoid shadowing the ambient global
4
+ // `Temporal` TYPE that gracePeriodEndCutoff's parameter type resolves
5
+ // against (same #1438 dual-package-hazard pattern as event-store.ts).
6
+ import { Temporal as TemporalPolyfill } from "temporal-polyfill";
3
7
  import { userTable } from "../../../user";
4
8
 
5
9
  export async function selectUsersDueForForgetCleanup(
6
10
  db: DbConnection,
7
11
  status: string,
8
- gracePeriodEndCutoff: Temporal.Instant | string,
12
+ gracePeriodEndCutoff: InstanceType<typeof TemporalPolyfill.Instant> | string,
9
13
  ): Promise<readonly { id: string }[]> {
14
+ // @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
15
+ // at runtime, distinct nominal types across the two .d.ts sources (see
16
+ // event-store.ts). Only ever consumed via the `lte` comparison below.
10
17
  const cutoff =
11
18
  typeof gracePeriodEndCutoff === "string"
12
- ? Temporal.Instant.from(gracePeriodEndCutoff)
19
+ ? TemporalPolyfill.Instant.from(gracePeriodEndCutoff)
13
20
  : gracePeriodEndCutoff;
14
21
  return selectMany<{ id: string }>(db, userTable, {
15
22
  status,
@@ -45,7 +45,11 @@ import {
45
45
  type SendExportFailedEmailFn,
46
46
  type SendExportReadyEmailFn,
47
47
  } from "./run-export-jobs";
48
- import { runForgetCleanup, type SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
48
+ import {
49
+ runForgetCleanup,
50
+ type SendDeletionExecutedEmailFn,
51
+ sanitizeReasonForLog,
52
+ } from "./run-forget-cleanup";
49
53
  import { downloadAttemptEntity } from "./schema/download-attempt";
50
54
  import { exportDownloadTokenEntity } from "./schema/download-token";
51
55
  import { exportJobEntity } from "./schema/export-job";
@@ -297,9 +301,13 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
297
301
  // external script reads the fragment client-side and exchanges the
298
302
  // token via POST for the signed URL.
299
303
  //
300
- // The old token-in-query format stays supported as a fallback (not a
301
- // transitional shim — permanent): emails sent before this deploy still
302
- // use the old format and must keep working until their TTL expires.
304
+ // No `?token=` query fallback: framework is zero-legacy (#1512) — no
305
+ // live customer data / no pre-#1271 emails in flight that would need
306
+ // bridging, so there's nothing a fallback would need to cover. A query
307
+ // param reaches the server as part of the request URL and gets written
308
+ // to proxy/access logs regardless of what the handler does with it
309
+ // afterward (a redirect doesn't undo an already-logged request) — the
310
+ // only leak-proof fix is to never accept the token that way.
303
311
  //
304
312
  // Path liegt AUSSERHALB /api/* weil r.httpRoute den /api-namespace nicht
305
313
  // claimen darf (reserved fuer write/query/batch/auth/sse-dispatcher).
@@ -312,25 +320,10 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
312
320
  method: "GET",
313
321
  path: "/user-export/by-token",
314
322
  anonymous: true,
315
- handler: async (c, { app }) => {
316
- const url = new URL(c.req.url);
317
- const token = url.searchParams.get("token");
318
- if (!token) {
319
- return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
320
- "content-type": "text/html; charset=utf-8",
321
- });
322
- }
323
- const queryRes = await app.fetch(
324
- new Request(`${url.origin}/api/query`, {
325
- method: "POST",
326
- headers: { "content-type": "application/json" },
327
- body: JSON.stringify({
328
- type: "user-data-rights:query:download-by-token",
329
- payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
330
- }),
331
- }),
332
- );
333
- return mapQueryResponseToRedirect(c, queryRes);
323
+ handler: async (c) => {
324
+ return c.body(TOKEN_EXCHANGE_PAGE_HTML, 200, {
325
+ "content-type": "text/html; charset=utf-8",
326
+ });
334
327
  },
335
328
  });
336
329
 
@@ -374,13 +367,26 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
374
367
  if (typeof token !== "string" || token.length === 0) {
375
368
  return c.json({ error: "missing_token" }, 400);
376
369
  }
370
+ const auditMeta = extractAuditMeta(c.req.raw.headers);
377
371
  return app.fetch(
378
372
  new Request(`${url.origin}/api/query`, {
379
373
  method: "POST",
380
- headers: { "content-type": "application/json" },
374
+ headers: {
375
+ "content-type": "application/json",
376
+ // download-by-token.query.ts's `rateLimit: { per: "ip", limit: 30 }`
377
+ // keys off request-id-middleware's own x-forwarded-for read on
378
+ // THIS internal request — without forwarding it, every fragment-
379
+ // POST download shares the request's own (empty/localhost) IP,
380
+ // turning the per-user 30/min backstop into one global bucket
381
+ // (#1307). Forwarding the same value extractAuditMeta already
382
+ // computed from the original request's headers doesn't widen the
383
+ // trust boundary: it's the identical XFF-trusts-first-hop model
384
+ // request-id-middleware.ts already applies framework-wide.
385
+ ...(auditMeta.ip ? { "x-forwarded-for": auditMeta.ip } : {}),
386
+ },
381
387
  body: JSON.stringify({
382
388
  type: "user-data-rights:query:download-by-token",
383
- payload: { token, auditMeta: extractAuditMeta(c.req.raw.headers) },
389
+ payload: { token, auditMeta },
384
390
  }),
385
391
  }),
386
392
  );
@@ -513,7 +519,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
513
519
  ? makeDefaultDeletionExecutedEmail(forgetMailResolver, opts.mailDefaults)
514
520
  : undefined);
515
521
 
516
- await runForgetCleanup({
522
+ const forgetResult = await runForgetCleanup({
517
523
  db: forgetDb,
518
524
  registry: forgetRegistry,
519
525
  now: T.Now.instant(),
@@ -530,30 +536,24 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
530
536
  }),
531
537
  ...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
532
538
  });
539
+
540
+ // Persist each incomplete report on the job-run log (#1572). The write
541
+ // handler returns incomplete[] in its response; the cron path used to
542
+ // discard the array (only a console.warn for rolledBack cases).
543
+ // ctx.log feeds createJobRunLogger → store_job_run_logs.
544
+ for (const entry of forgetResult.incomplete) {
545
+ ctx.log?.warn?.(
546
+ `[user-data-rights:run-forget-cleanup] incomplete hook ` +
547
+ `userId=${entry.userId} tenantId=${entry.tenantId} ` +
548
+ `entityName=${entry.entityName} rolledBack=${entry.rolledBack} ` +
549
+ `reason=${sanitizeReasonForLog(entry.reason)}`,
550
+ );
551
+ }
533
552
  },
534
553
  );
535
554
  });
536
555
  }
537
556
 
538
- // Map /api/query-Response auf 302-Redirect oder Error-Passthrough.
539
- async function mapQueryResponseToRedirect(
540
- c: import("hono").Context,
541
- queryRes: Response,
542
- ): Promise<Response> {
543
- if (!queryRes.ok) {
544
- const errorBody = await queryRes.text();
545
- const statusCode = queryRes.status as 400 | 401 | 404 | 410 | 500; // @cast-boundary engine-payload
546
- return c.body(errorBody, statusCode, {
547
- "content-type": queryRes.headers.get("content-type") ?? "application/json",
548
- });
549
- }
550
- const body = (await queryRes.json()) as { data?: { url?: string } }; // @cast-boundary engine-payload
551
- if (!body.data?.url) {
552
- return c.json({ error: "download_resolution_failed" }, 500);
553
- }
554
- return c.redirect(body.data.url, 302);
555
- }
556
-
557
557
  // Extract Audit-Meta (IP + UA) aus den HTTP-Headers + steck es in die
558
558
  // query-payload. Der httpRoute-Wrapper ist trusted-source — er hat den
559
559
  // raw-request gesehen, nicht der direkter /api/query-Caller. User der
@@ -2,9 +2,12 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import { z } from "zod";
5
+ import { isWithinGracePeriod } from "../../shared";
5
6
  import { USER_STATUS, userTable } from "../../user";
6
7
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
7
8
 
9
+ export { isWithinGracePeriod };
10
+
8
11
  // POST /api/user/cancel-deletion (S2.U5).
9
12
  //
10
13
  // Innerhalb der Grace-Period kann User seinen Forget-Antrag zurueck-
@@ -24,11 +27,10 @@ export const cancelDeletionWrite = defineWriteHandler({
24
27
  // ctx.db.raw (kein TenantDb-Wrapper) weil User-Entity tenant-agnostisch
25
28
  // ist — siehe request-deletion.write.ts fuer die Begruendung. Cancel
26
29
  // muss aus jedem Tenant-Mode den User finden + zuruecksetzen koennen.
27
- const row = await fetchOne<{ status: string; gracePeriodEnd: Temporal.Instant | null }>(
28
- ctx.db.raw,
29
- userTable,
30
- { id: event.user.id },
31
- );
30
+ const row = await fetchOne<{
31
+ status: string;
32
+ gracePeriodEnd: Temporal.Instant | null;
33
+ }>(ctx.db.raw, userTable, { id: event.user.id });
32
34
 
33
35
  if (!row) {
34
36
  return writeFailure(
@@ -49,12 +51,7 @@ export const cancelDeletionWrite = defineWriteHandler({
49
51
  );
50
52
  }
51
53
 
52
- const gracePeriodEnd = row.gracePeriodEnd;
53
- const inGrace =
54
- gracePeriodEnd != null &&
55
- Temporal.Instant.compare(gracePeriodEnd, Temporal.Now.instant()) > 0;
56
-
57
- if (!inGrace) {
54
+ if (!isWithinGracePeriod(row.gracePeriodEnd)) {
58
55
  return writeFailure(
59
56
  new UnprocessableError("grace_period_expired", {
60
57
  details: { reason: "grace_period_expired" },