@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
@@ -80,7 +80,7 @@ export const downloadByJobQuery = defineQueryHandler({
80
80
 
81
81
  if (!jobRow || jobRow.userId !== userId) {
82
82
  await recordInvalidAttempt({
83
- db: ctx.db.raw,
83
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
84
84
  tenantId,
85
85
  now,
86
86
  result: "notFound",
@@ -98,7 +98,7 @@ export const downloadByJobQuery = defineQueryHandler({
98
98
 
99
99
  if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
100
100
  await recordInvalidAttempt({
101
- db: ctx.db.raw,
101
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
102
102
  tenantId,
103
103
  now,
104
104
  result: "failed",
@@ -115,7 +115,7 @@ export const downloadByJobQuery = defineQueryHandler({
115
115
  }
116
116
  if (!jobRow.downloadStorageKey) {
117
117
  await recordInvalidAttempt({
118
- db: ctx.db.raw,
118
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
119
119
  tenantId,
120
120
  now,
121
121
  result: "expired",
@@ -138,13 +138,13 @@ export const downloadByJobQuery = defineQueryHandler({
138
138
  registry: ctx.registry,
139
139
  configResolver: ctx.configResolver,
140
140
  secrets: ctx.secrets,
141
- db: ctx.db.raw,
141
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
142
142
  userId: SYSTEM_USER_ID,
143
143
  handlerName: "user-data-rights:query:download-by-job",
144
144
  })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
145
145
  if (!provider.getSignedUrl) {
146
146
  await recordInvalidAttempt({
147
- db: ctx.db.raw,
147
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
148
148
  tenantId,
149
149
  now,
150
150
  result: "signedUrlNotSupported",
@@ -181,7 +181,7 @@ export const downloadByJobQuery = defineQueryHandler({
181
181
 
182
182
  if (tokenRow) {
183
183
  await recordDownloadUse({
184
- db: ctx.db.raw,
184
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
185
185
  tokenId: tokenRow.id,
186
186
  tokenVersion: tokenRow.version,
187
187
  tokenUseCount: tokenRow.useCount ?? 0,
@@ -123,7 +123,7 @@ export const downloadByTokenQuery = defineQueryHandler({
123
123
  );
124
124
  if (jobForAudit) {
125
125
  await recordInvalidAttempt({
126
- db: ctx.db.raw,
126
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
127
127
  tenantId: jobForAudit.requestedFromTenantId,
128
128
  now,
129
129
  result: "expired",
@@ -152,7 +152,7 @@ export const downloadByTokenQuery = defineQueryHandler({
152
152
  }
153
153
  if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
154
154
  await recordInvalidAttempt({
155
- db: ctx.db.raw,
155
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
156
156
  tenantId: jobRow.requestedFromTenantId,
157
157
  now,
158
158
  result: "failed",
@@ -169,7 +169,7 @@ export const downloadByTokenQuery = defineQueryHandler({
169
169
  }
170
170
  if (!jobRow.downloadStorageKey) {
171
171
  await recordInvalidAttempt({
172
- db: ctx.db.raw,
172
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
173
173
  tenantId: jobRow.requestedFromTenantId,
174
174
  now,
175
175
  result: "expired",
@@ -192,13 +192,13 @@ export const downloadByTokenQuery = defineQueryHandler({
192
192
  registry: ctx.registry,
193
193
  configResolver: ctx.configResolver,
194
194
  secrets: ctx.secrets,
195
- db: ctx.db.raw,
195
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
196
196
  userId: SYSTEM_USER_ID,
197
197
  handlerName: "user-data-rights:query:download-by-token",
198
198
  })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
199
199
  if (!provider.getSignedUrl) {
200
200
  await recordInvalidAttempt({
201
- db: ctx.db.raw,
201
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
202
202
  tenantId: jobRow.requestedFromTenantId,
203
203
  now,
204
204
  result: "signedUrlNotSupported",
@@ -229,7 +229,7 @@ export const downloadByTokenQuery = defineQueryHandler({
229
229
  // Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
230
230
  // Audit ist nicht security-relevant.
231
231
  await recordDownloadUse({
232
- db: ctx.db.raw,
232
+ db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
233
233
  tokenId: tokenRow.id,
234
234
  tokenVersion: tokenRow.version,
235
235
  tokenUseCount: tokenRow.useCount ?? 0,
@@ -1,8 +1,14 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
- import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import {
4
+ AccessDeniedError,
5
+ UnprocessableError,
6
+ writeFailure,
7
+ } from "@cosmicdrift/kumiko-framework/errors";
4
8
  import { z } from "zod";
9
+ import { tenantMembershipsTable } from "../../tenant";
5
10
  import { USER_STATUS, userTable } from "../../user";
11
+ import { isSystemAdminActor } from "../lib/is-admin-actor";
6
12
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
7
13
 
8
14
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
@@ -13,6 +19,16 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
13
19
  // endpoint. There is no self-service path today, so lifting a restriction
14
20
  // always targets someone else's account by id.
15
21
  //
22
+ // Tenant scope: `access.admin` includes TenantAdmin, which is tenant-
23
+ // scoped even though the User-entity lookup below uses `ctx.db.raw`
24
+ // (bypasses the auto-tenant-filter, same as restrict-account.write.ts —
25
+ // User status is intentionally global, see user-data-rights.md
26
+ // "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
27
+ // from tenant A could unrestrict/reactivate a user who has never been a
28
+ // member of tenant A. Only SystemAdmin (platform-wide) skips the check;
29
+ // TenantAdmin/Admin must have an active membership in the target's own
30
+ // tenantId.
31
+ //
16
32
  // State-Transitions:
17
33
  // Restricted → Active ✓
18
34
  // Active → ... ✗ 422 not_restricted (Idempotenz-Guard)
@@ -20,10 +36,25 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
20
36
  // Deleted → ... ✗ 422 not_restricted
21
37
  export const liftRestrictionWrite = defineWriteHandler({
22
38
  name: "lift-restriction",
23
- schema: z.object({ userId: z.string() }),
39
+ schema: z.object({ userId: z.string().uuid() }),
24
40
  access: { roles: access.admin },
25
41
  handler: async (event, ctx) => {
26
42
  const targetUserId = event.payload.userId;
43
+
44
+ if (!isSystemAdminActor(event.user)) {
45
+ const membership = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
46
+ userId: targetUserId,
47
+ tenantId: event.user.tenantId,
48
+ });
49
+ if (!membership) {
50
+ return writeFailure(
51
+ new AccessDeniedError({
52
+ details: { reason: "target_user_not_in_admin_tenant" },
53
+ }),
54
+ );
55
+ }
56
+ }
57
+
27
58
  const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
28
59
  id: targetUserId,
29
60
  });
@@ -1,5 +1,6 @@
1
1
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { Temporal } from "temporal-polyfill";
3
4
  import { z } from "zod";
4
5
  import { downloadAttemptsTable } from "../schema/download-attempt";
5
6
 
@@ -1,6 +1,7 @@
1
1
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { Temporal } from "temporal-polyfill";
4
5
  import { z } from "zod";
5
6
 
6
7
  // DSGVO Art. 15 Selbstauskunft — User reads HIS OWN audit-log.
@@ -1,12 +1,14 @@
1
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import {
4
4
  AccessDeniedError,
5
5
  UnprocessableError,
6
6
  writeFailure,
7
7
  } from "@cosmicdrift/kumiko-framework/errors";
8
8
  import { z } from "zod";
9
+ import { tenantMembershipsTable } from "../../tenant";
9
10
  import { USER_STATUS, userTable } from "../../user";
11
+ import { isAdminActor, isSystemAdminActor } from "../lib/is-admin-actor";
10
12
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
11
13
 
12
14
  // POST /api/user/restrict (S2.U6) — DSGVO Art. 18 Account-Freeze.
@@ -25,6 +27,16 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
25
27
  // sessionChecker and they can no longer call this (or any) endpoint
26
28
  // themselves; only an operator path reaches it at that point.
27
29
  //
30
+ // Tenant scope: `isAdminActor` accepts access.admin, which includes
31
+ // TenantAdmin — tenant-scoped, even though the User-entity lookup below
32
+ // uses `ctx.db.raw` (bypasses the auto-tenant-filter, same as
33
+ // lift-restriction.write.ts — User status is intentionally global, see
34
+ // user-data-rights.md "Cross-Tenant-Semantik"). Without a membership
35
+ // check, a TenantAdmin from tenant A could restrict/unrestrict a user who
36
+ // has never been a member of tenant A. Only SystemAdmin (platform-wide)
37
+ // skips the check; TenantAdmin/Admin must have an active membership in
38
+ // the target's own tenantId.
39
+ //
28
40
  // State-Transitions:
29
41
  // Active → Restricted ✓ (dieser Handler)
30
42
  // Restricted → Restricted ✗ 422 already_restricted (Idempotenz-Guard)
@@ -32,19 +44,36 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
32
44
  // Deleted → ... ✗ 422 user_not_in_active_state
33
45
  export const restrictAccountWrite = defineWriteHandler({
34
46
  name: "restrict-account",
35
- schema: z.object({ userId: z.string().optional() }),
47
+ schema: z.object({ userId: z.string().uuid().optional() }),
36
48
  access: { openToAll: true },
37
49
  handler: async (event, ctx) => {
38
50
  const targetUserId = event.payload.userId ?? event.user.id;
39
51
  if (targetUserId !== event.user.id) {
40
- const isAdmin = event.user.roles.some((role) => access.admin.includes(role));
41
- if (!isAdmin) {
52
+ if (!isAdminActor(event.user)) {
42
53
  return writeFailure(
43
54
  new AccessDeniedError({
44
55
  details: { reason: "admin_required_for_other_user" },
45
56
  }),
46
57
  );
47
58
  }
59
+ // Same cross-tenant guard as lift-restriction.write.ts: only
60
+ // SystemAdmin (platform-wide) may target a user without an active
61
+ // membership in the caller's own tenant. Without this, a TenantAdmin
62
+ // from tenant A could restrict — and force-revoke the sessions of —
63
+ // a user who has never been a member of tenant A.
64
+ if (!isSystemAdminActor(event.user)) {
65
+ const membership = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
66
+ userId: targetUserId,
67
+ tenantId: event.user.tenantId,
68
+ });
69
+ if (!membership) {
70
+ return writeFailure(
71
+ new AccessDeniedError({
72
+ details: { reason: "target_user_not_in_admin_tenant" },
73
+ }),
74
+ );
75
+ }
76
+ }
48
77
  }
49
78
 
50
79
  // ctx.db.raw weil User-Entity tenant-agnostisch ist (analog
@@ -77,6 +77,8 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
77
77
  hookCallsAttempted: result.hookCallsAttempted,
78
78
  errorCount: result.errors.length,
79
79
  errors: result.errors,
80
+ incompleteCount: result.incomplete.length,
81
+ incomplete: result.incomplete,
80
82
  },
81
83
  };
82
84
  },
@@ -0,0 +1,19 @@
1
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
3
+
4
+ const ADMIN_ROLES: ReadonlySet<string> = new Set(access.admin);
5
+ const SYSTEM_ADMIN_ROLES: ReadonlySet<string> = new Set(access.systemAdmin);
6
+
7
+ // Reused by handlers that are `openToAll: true` but still need a manual
8
+ // self-or-admin gate (e.g. restrict-account.write.ts) — Set-lookup instead
9
+ // of the O(n·m) `roles.some(role => access.admin.includes(role))` re-scan.
10
+ export function isAdminActor(user: Pick<SessionUser, "roles">): boolean {
11
+ return user.roles.some((role) => ADMIN_ROLES.has(role));
12
+ }
13
+
14
+ // Platform-wide operator role — exempt from the cross-tenant membership
15
+ // check that otherwise gates the tenant-scoped admin roles (TenantAdmin/
16
+ // Admin) before they act on another tenant's user.
17
+ export function isSystemAdminActor(user: Pick<SessionUser, "roles">): boolean {
18
+ return user.roles.some((role) => SYSTEM_ADMIN_ROLES.has(role));
19
+ }
@@ -142,6 +142,13 @@ export interface ForgetCleanupIncomplete {
142
142
  readonly tenantId: TenantId;
143
143
  readonly entityName: string;
144
144
  readonly reason: string;
145
+ /** True when a LATER hook in the same user's sub-tx threw, rolling back
146
+ * the DB rows (including the status flip) — but this hook's own
147
+ * non-transactional side effect (e.g. an already-issued S3 delete,
148
+ * a provider API call) already ran and does NOT get undone by that
149
+ * rollback. The user is still DeletionRequested and will retry, but
150
+ * this partial external effect needs its own operator follow-up. */
151
+ readonly rolledBack: boolean;
145
152
  }
146
153
 
147
154
  export interface RunForgetCleanupResult {
@@ -168,6 +175,15 @@ interface HookEntry {
168
175
  // hook that nulls that column. See custom-fields wire-user-data-rights.ts.
169
176
  const HOOK_ORDER_DEFAULT = EXT_USER_DATA_ORDER.DEFAULT;
170
177
 
178
+ // Hook-reported reasons are external input (provider error messages can embed
179
+ // user-controlled filenames/emails) — strip newlines before a single-line
180
+ // console.warn so a crafted reason can't forge extra log lines, and cap the
181
+ // length against a log-flood. The unsanitized reason still lands in the
182
+ // returned `incomplete` entry untouched.
183
+ export function sanitizeReasonForLog(reason: string): string {
184
+ return reason.replace(/[\r\n]+/g, " ").slice(0, 500);
185
+ }
186
+
171
187
  export async function runForgetCleanup(
172
188
  args: RunForgetCleanupArgs,
173
189
  ): Promise<RunForgetCleanupResult> {
@@ -177,7 +193,7 @@ export async function runForgetCleanup(
177
193
 
178
194
  // Step 1: Find users with expired grace period.
179
195
  const dueUsers = await selectUsersDueForForgetCleanup(
180
- db,
196
+ db as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
181
197
  USER_STATUS.DeletionRequested,
182
198
  now.toString(),
183
199
  );
@@ -285,7 +301,7 @@ async function processUser(args: {
285
301
  }): Promise<ProcessUserResult> {
286
302
  const { db, registry, userId, hookEntries, buildStorageProvider, appTenantModel, kms } = args;
287
303
  const errors: ForgetCleanupError[] = [];
288
- const incomplete: ForgetCleanupIncomplete[] = [];
304
+ const incomplete: Omit<ForgetCleanupIncomplete, "rolledBack">[] = [];
289
305
  let hookCallsAttempted = 0;
290
306
 
291
307
  // Atom 5b — userEmail VOR der Tx cachen. user-Hook (user-data-rights-
@@ -374,7 +390,7 @@ async function processUser(args: {
374
390
  });
375
391
  // biome-ignore lint/suspicious/noConsole: operator-visibility for partial hook completion
376
392
  console.warn(
377
- `[user-data-rights:run-forget-cleanup] hook incomplete userId=${userId} tenantId=${tenantId} entityName=${entry.entityName} reason=${hookResult.reason}`,
393
+ `[user-data-rights:run-forget-cleanup] hook incomplete userId=${userId} tenantId=${tenantId} entityName=${entry.entityName} reason=${sanitizeReasonForLog(hookResult.reason)}`,
378
394
  );
379
395
  }
380
396
  }
@@ -431,10 +447,14 @@ async function processUser(args: {
431
447
  success: txSucceeded,
432
448
  hookCallsAttempted,
433
449
  errors,
434
- // Only surfaced on a committed sub-tx — a later throw in the same
435
- // user's loop rolls this back too, so a rolled-back "incomplete"
436
- // report would misreport a non-deleted user as partially cleaned up.
437
- incomplete: txSucceeded ? incomplete : [],
450
+ // Reported either way — a rolled-back sub-tx doesn't undo an
451
+ // "incomplete" hook's own non-transactional side effect (S3 delete,
452
+ // provider call), so dropping the report entirely would hide exactly
453
+ // the case its own reason string describes ("would report a
454
+ // not-yet-deleted user as partially cleaned up" — true of the DB rows,
455
+ // not of that external effect). `rolledBack` lets the operator tell
456
+ // the two situations apart instead of losing the signal outright.
457
+ incomplete: incomplete.map((i) => ({ ...i, rolledBack: !txSucceeded })),
438
458
  userEmailBeforeDelete,
439
459
  userLocaleBeforeDelete,
440
460
  tenantIdsBeforeDelete,