@cosmicdrift/kumiko-bundled-features 0.165.0 → 0.165.2

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 (159) 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 +205 -0
  4. package/src/audit/__tests__/audit.integration.test.ts +52 -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__/login-gates.test.ts +39 -0
  9. package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
  11. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
  12. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
  13. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
  14. package/src/auth-email-password/handlers/login.write.ts +196 -155
  15. package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
  16. package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
  17. package/src/auth-email-password/i18n.ts +2 -0
  18. package/src/auth-email-password/seeding.ts +41 -23
  19. package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
  20. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
  21. package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
  22. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
  23. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
  24. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
  25. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
  26. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
  27. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
  28. package/src/auth-email-password/web/auth-client.ts +32 -10
  29. package/src/auth-email-password/web/auth-gate.tsx +17 -21
  30. package/src/auth-email-password/web/client-plugin.ts +15 -2
  31. package/src/auth-email-password/web/index.ts +5 -1
  32. package/src/auth-email-password/web/login-screen.tsx +7 -3
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
  34. package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
  35. package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
  37. package/src/auth-foundation/boot-checks.ts +74 -69
  38. package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
  39. package/src/auth-foundation/types.ts +3 -3
  40. package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
  41. package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
  42. package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +157 -4
  43. package/src/auth-mfa/handlers/disable.write.ts +16 -1
  44. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
  45. package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
  46. package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
  47. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
  48. package/src/auth-mfa/handlers/verify.write.ts +28 -24
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
  51. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
  52. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
  53. package/src/auth-mfa/web/i18n.ts +4 -0
  54. package/src/auth-mfa/web/mfa-client.ts +13 -2
  55. package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
  56. package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
  57. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
  58. package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
  59. package/src/config/__tests__/cascade.integration.test.ts +13 -2
  60. package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
  61. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
  62. package/src/config/db/queries/resolver.ts +3 -3
  63. package/src/config/handlers/reencrypt.job.ts +36 -24
  64. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
  65. package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
  66. package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
  67. package/src/document-ingest-foundation/entity.ts +10 -3
  68. package/src/document-ingest-foundation/feature.ts +26 -8
  69. package/src/document-ingest-foundation/index.ts +1 -0
  70. package/src/document-ingest-foundation/pages.ts +34 -0
  71. package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
  72. package/src/feature-toggles/compose-tier-resolver.ts +77 -8
  73. package/src/feature-toggles/global-feature-state-table.ts +15 -12
  74. package/src/files-provider-s3/s3-provider.ts +12 -1
  75. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
  76. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
  77. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
  78. package/src/inbound-mail-foundation/entities.ts +3 -3
  79. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
  80. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
  81. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
  82. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
  83. package/src/jobs/__tests__/job-instant.test.ts +15 -0
  84. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
  85. package/src/jobs/feature.ts +6 -5
  86. package/src/jobs/job-instant.ts +9 -0
  87. package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
  88. package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
  89. package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
  90. package/src/page-render/__tests__/markdown.test.ts +29 -0
  91. package/src/page-render/markdown.ts +1 -9
  92. package/src/personal-access-tokens/feature.ts +3 -3
  93. package/src/personal-access-tokens/schema/api-token.ts +3 -3
  94. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
  96. package/src/sessions/__tests__/sessions.integration.test.ts +88 -1
  97. package/src/sessions/feature.ts +21 -6
  98. package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
  99. package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
  100. package/src/sessions/handlers/revoke.write.ts +13 -0
  101. package/src/sessions/index.ts +7 -0
  102. package/src/sessions/schema/user-session.ts +6 -7
  103. package/src/sessions/session-callbacks.ts +58 -5
  104. package/src/sessions/session-revoked-event.ts +29 -0
  105. package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
  106. package/src/shared/chunked-entity-migration.ts +2 -2
  107. package/src/shared/grace-period.test.ts +26 -0
  108. package/src/shared/grace-period.ts +20 -0
  109. package/src/shared/index.ts +2 -0
  110. package/src/shared/map-with-concurrency.test.ts +104 -0
  111. package/src/shared/map-with-concurrency.ts +38 -0
  112. package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
  113. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
  114. package/src/subscription-mollie/verify-webhook.ts +1 -0
  115. package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
  116. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
  117. package/src/subscription-stripe/verify-webhook.ts +3 -2
  118. package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
  119. package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
  120. package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
  121. package/src/tenant/handlers/invitations.query.ts +13 -15
  122. package/src/tenant/handlers/members.query.ts +12 -12
  123. package/src/tenant/seeding.ts +110 -16
  124. package/src/tenant-lifecycle/boot-checks.ts +11 -4
  125. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -6
  126. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +94 -0
  127. package/src/tenant-settings/config.ts +44 -0
  128. package/src/tenant-settings/constants.ts +16 -0
  129. package/src/tenant-settings/feature.ts +49 -0
  130. package/src/tenant-settings/index.ts +8 -0
  131. package/src/tenant-settings/tenant-defaults.ts +72 -0
  132. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +33 -6
  133. package/src/text-content/handlers/set.write.ts +2 -2
  134. package/src/text-content/seeding.ts +31 -29
  135. package/src/user/handlers/find-for-auth.query.ts +1 -1
  136. package/src/user/seeding.ts +8 -0
  137. package/src/user-data-rights/COMPLIANCE.md +1 -1
  138. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
  139. package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
  140. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
  141. package/src/user-data-rights/__tests__/download.integration.test.ts +25 -41
  142. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
  143. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
  144. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +140 -0
  145. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
  146. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
  147. package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
  148. package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
  149. package/src/user-data-rights/feature.ts +45 -45
  150. package/src/user-data-rights/handlers/cancel-deletion.write.ts +6 -11
  151. package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
  152. package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
  153. package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
  154. package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
  155. package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
  156. package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
  157. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
  158. package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
  159. package/src/user-data-rights/run-forget-cleanup.ts +27 -7
@@ -10,13 +10,22 @@ import {
10
10
  } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
12
12
  import { z } from "zod";
13
- import { EXT_SESSION_STORE } from "../../auth-foundation";
13
+ import { authFoundationFeature } from "../../auth-foundation";
14
14
  import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
15
15
  import { createConfigFeature } from "../../config/feature";
16
16
  import { createDataRetentionFeature } from "../../data-retention/feature";
17
+ import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
17
18
  import { createSessionsFeature } from "../../sessions/feature";
18
19
  import { createTenantFeature } from "../../tenant/feature";
19
20
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle/feature";
21
+
22
+ function projectionProbeTable(name: string): SchemaTable {
23
+ return table(name, {
24
+ id: uuid("id").primaryKey(),
25
+ count: integer("count").notNull().default(0),
26
+ }) as unknown as SchemaTable; // @cast-boundary test-fixture — minimal probe shape, real schema-table irrelevant here
27
+ }
28
+
20
29
  import { createUserFeature } from "../../user/feature";
21
30
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
22
31
  import { createUserDataRightsFeature } from "../feature";
@@ -30,18 +39,13 @@ import { createUserDataRightsFeature } from "../feature";
30
39
  // Unlike the old direct-function tests, these only run inside a real
31
40
  // validateBoot() pass — exercising the actual `r.bootCheck()` plumbing, not
32
41
  // just the guard logic.
33
- // Stub instead of the real auth-foundation — these boot-checks are about
34
- // GDPR-storage guards, not auth; only sessions' sessionStore registration
35
- // needs an owner present.
36
- const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
37
- r.extendsRegistrar(EXT_SESSION_STORE, {});
38
- });
39
-
40
42
  function baseFeatures() {
41
43
  return [
42
44
  createConfigFeature(),
43
45
  createUserFeature(),
44
- sessionStoreOwnerStub,
46
+ createTenantFeature(),
47
+ authFoundationFeature,
48
+ createPersonalAccessTokensFeature({ scopes: {} }),
45
49
  createSessionsFeature(),
46
50
  createDataRetentionFeature(),
47
51
  createComplianceProfilesFeature(),
@@ -84,10 +88,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
84
88
  });
85
89
 
86
90
  test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
87
- const projectionTable = table("crm_contact_summary", {
88
- id: uuid("id").primaryKey(),
89
- count: integer("count").notNull().default(0),
90
- }) as unknown as SchemaTable;
91
+ const projectionTable = projectionProbeTable("crm_contact_summary");
91
92
  const bad = defineFeature("crm", (r) => {
92
93
  const contactCreated = r.defineEvent("contact-created", z.unknown());
93
94
  r.projection({
@@ -102,10 +103,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
102
103
  });
103
104
 
104
105
  test("V3: projection-only pii entity WITH EXT_USER_DATA hook registered for its entity name → no throw", () => {
105
- const projectionTable = table("crm_contact_summary_hooked", {
106
- id: uuid("id").primaryKey(),
107
- count: integer("count").notNull().default(0),
108
- }) as unknown as SchemaTable;
106
+ const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
109
107
  const hooked = defineFeature("crm-hooked", (r) => {
110
108
  r.requires("user-data-rights");
111
109
  const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
@@ -155,11 +155,15 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
155
155
  }
156
156
 
157
157
  const res = await stack.app.fetch(
158
- new Request(`http://test/user-export/by-token?token=${plainToken}`, { method: "GET" }),
158
+ new Request("http://test/user-export/by-token", {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json" },
161
+ body: JSON.stringify({ token: plainToken }),
162
+ }),
159
163
  );
160
- expect(res.status).toBe(302);
161
- const location = res.headers.get("location");
162
- expect(location).toMatch(/^memory:\/\//);
163
- expect(location).toContain(`${jobTenant}/exports/${jobId}.zip`);
164
+ expect(res.status).toBe(200);
165
+ const body = (await res.json()) as { data?: { url?: string } };
166
+ expect(body.data?.url).toMatch(/^memory:\/\//);
167
+ expect(body.data?.url).toContain(`${jobTenant}/exports/${jobId}.zip`);
164
168
  });
165
169
  });
@@ -437,8 +437,8 @@ describe("download-by-job :: happy path", () => {
437
437
  });
438
438
 
439
439
  describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
440
- test("happy: 302-Redirect mit Location-Header zur signed-URL", async () => {
441
- const { plainToken } = await seedDoneJobWithToken();
440
+ test("`?token=` in query is ignored — serves interstitial HTML instead of resolving it (no access-log leak)", async () => {
441
+ const { jobId, plainToken } = await seedDoneJobWithToken();
442
442
 
443
443
  const res = await stack.app.fetch(
444
444
  new Request(`http://test/user-export/by-token?token=${plainToken}`, {
@@ -449,21 +449,15 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
449
449
  },
450
450
  }),
451
451
  );
452
- expect(res.status).toBe(302);
453
- const location = res.headers.get("location");
454
- expect(location).toBeTruthy();
455
- expect(location).toMatch(/^memory:\/\//);
456
- });
452
+ expect(res.status).toBe(200);
453
+ expect(res.headers.get("content-type")).toContain("text/html");
454
+ const body = await res.text();
455
+ expect(body).toContain("/user-export/by-token.js");
457
456
 
458
- test("invalid token → 404 passthrough mit i18nKey", async () => {
459
- const res = await stack.app.fetch(
460
- new Request("http://test/user-export/by-token?token=fake-xxxxx", {
461
- method: "GET",
462
- }),
463
- );
464
- expect(res.status).toBe(404);
465
- const body = (await res.json()) as { error?: { i18nKey?: string } };
466
- expect(body.error?.i18nKey).toBe("userDataRights.errors.download.notFound");
457
+ const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
458
+ useCount: number;
459
+ }>;
460
+ expect(row?.useCount).toBe(0);
467
461
  });
468
462
 
469
463
  test("no query token (fragment-Pfad) → serviert Interstitial-HTML statt 400", async () => {
@@ -487,29 +481,6 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
487
481
  expect(body).toContain("/user-export/by-token");
488
482
  });
489
483
 
490
- test("Audit-Update: useCount + IP/UA aus httpRoute-Headers (nicht aus payload)", async () => {
491
- const { jobId, plainToken } = await seedDoneJobWithToken();
492
- await stack.app.fetch(
493
- new Request(`http://test/user-export/by-token?token=${plainToken}`, {
494
- method: "GET",
495
- headers: {
496
- "user-agent": "e2e-test/2.0",
497
- "x-forwarded-for": "198.51.100.7, 10.0.0.1",
498
- },
499
- }),
500
- );
501
-
502
- const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
503
- useCount: number;
504
- lastUsedFromIp: string | null;
505
- lastUsedUserAgent: string | null;
506
- }>;
507
- expect(row?.useCount).toBe(1);
508
- // X-Forwarded-For: erster Wert, comma-trimmed
509
- expect(row?.lastUsedFromIp).toBe("198.51.100.7");
510
- expect(row?.lastUsedUserAgent).toBe("e2e-test/2.0");
511
- });
512
-
513
484
  test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
514
485
  const { jobId, plainToken } = await seedDoneJobWithToken();
515
486
 
@@ -550,6 +521,19 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
550
521
  const body = (await res.json()) as { error?: string };
551
522
  expect(body.error).toBe("missing_token");
552
523
  });
524
+
525
+ test("POST-Exchange: invalid token → 404 + download.notFound i18nKey", async () => {
526
+ const res = await stack.app.fetch(
527
+ new Request("http://test/user-export/by-token", {
528
+ method: "POST",
529
+ headers: { "content-type": "application/json" },
530
+ body: JSON.stringify({ token: "fake-xxxxx" }),
531
+ }),
532
+ );
533
+ expect(res.status).toBe(404);
534
+ const body = (await res.json()) as { error: { i18nKey: string } };
535
+ expect(body.error.i18nKey).toBe("userDataRights.errors.download.notFound");
536
+ });
553
537
  });
554
538
 
555
539
  describe("download-by-job :: cross-user + cross-tenant", () => {
@@ -569,8 +553,8 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
569
553
  });
570
554
 
571
555
  test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported (job-Pfad)", async () => {
572
- // Symmetrisch zum token-Pfad: derselbe Operator-Konfig-Bug muss auch
573
- // beim UI-Klick-Pfad als 422 raus, nicht 404.
556
+ // Same as the token path: the operator misconfig must also surface as
557
+ // 422 on the UI click path, not 404.
574
558
  const { jobId } = await seedDoneJobWithToken();
575
559
  await stack.http.writeOk(
576
560
  ConfigHandlers.set,
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { access, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
4
- import { EXT_SESSION_STORE } from "../../auth-foundation";
4
+ import { authFoundationFeature } from "../../auth-foundation";
5
5
  import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
6
6
  import { createConfigFeature } from "../../config/feature";
7
7
  import { createDataRetentionFeature } from "../../data-retention/feature";
8
+ import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
8
9
  import { createSessionsFeature } from "../../sessions/feature";
10
+ import { createTenantFeature } from "../../tenant/feature";
9
11
  import { createUserFeature } from "../../user/feature";
10
12
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
11
13
  import { createUserDataRightsFeature } from "../feature";
@@ -18,13 +20,6 @@ import { createUserDataRightsFeature } from "../feature";
18
20
  // entities (export-job, download-attempt) are r.entity, not direct-write stores,
19
21
  // so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
20
22
 
21
- // Stub instead of the real auth-foundation — this test is about screen
22
- // wiring, not auth; only sessions' sessionStore registration needs an owner
23
- // present.
24
- const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
25
- r.extendsRegistrar(EXT_SESSION_STORE, {});
26
- });
27
-
28
23
  describe("user-data-rights read-only inspector screens", () => {
29
24
  // A realistic assembly: mounting user-data-rights without its default hooks
30
25
  // now fails the V3 boot gate (the framework `user` entity holds PII with no
@@ -33,7 +28,9 @@ describe("user-data-rights read-only inspector screens", () => {
33
28
  const features = [
34
29
  createConfigFeature(),
35
30
  createUserFeature(),
36
- sessionStoreOwnerStub,
31
+ createTenantFeature(),
32
+ authFoundationFeature,
33
+ createPersonalAccessTokensFeature({ scopes: {} }),
37
34
  createSessionsFeature(),
38
35
  createDataRetentionFeature(),
39
36
  createComplianceProfilesFeature(),
@@ -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
+ sessionRevokedSchema,
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", () => {
@@ -305,6 +415,26 @@ describe("S2.U6 :: Login-Block fuer Restricted/DeletionRequested/Deleted", () =>
305
415
  });
306
416
 
307
417
  describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
418
+ test("Privileged revoke-all-for-user with no live sessions emits no event", async () => {
419
+ const { userId } = await seedAliceWithMembership();
420
+ const systemUser = await mintActor({
421
+ id: "00000000-0000-4000-8000-000000000000",
422
+ tenantId: TENANT,
423
+ roles: ["SystemAdmin"],
424
+ });
425
+
426
+ const result = await stack.http.writeOk<{ count: number; userId: string }>(
427
+ SessionHandlers.revokeAllForUser,
428
+ { userId },
429
+ systemUser,
430
+ );
431
+ expect(result.count).toBe(0);
432
+ expect(result.userId).toBe(userId);
433
+
434
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
435
+ expect(events).toHaveLength(0);
436
+ });
437
+
308
438
  test("Privileged-Caller revoked alle live sessions eines Users", async () => {
309
439
  const { userId } = await seedAliceWithMembership();
310
440
  // 2 Sessions erzeugen via Login + zweiter Login.
@@ -337,5 +467,15 @@ describe("S2.U6 :: Cross-Feature sessions.revokeAllForUser direct", () => {
337
467
  revokedAt: unknown;
338
468
  }>;
339
469
  expect(revoked.every((s) => s.revokedAt !== null)).toBe(true);
470
+
471
+ // #1559 — cross-tenant privileged revoke uses the low-level append(),
472
+ // not ctx.unsafeAppendEvent (see revoke-all-for-user.write.ts for why:
473
+ // the SystemAdmin caller's own tenantId has no relationship to the
474
+ // target user's session tenant). One event, both sids listed.
475
+ const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
476
+ expect(events).toHaveLength(1);
477
+ const payload = sessionRevokedSchema.parse(events[0]?.["payload"]);
478
+ expect(payload.userId).toBe(userId);
479
+ expect(new Set(payload.sessionIds)).toEqual(new Set(liveBefore.map((s) => s.id)));
340
480
  });
341
481
  });
@@ -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