@cosmicdrift/kumiko-bundled-features 0.265.0 → 0.268.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 (70) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +747 -0
  3. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +16 -6
  4. package/src/auth-email-password/handlers/invite-accept.write.ts +19 -6
  5. package/src/auth-email-password/handlers/invite-create.write.ts +1 -2
  6. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +21 -7
  7. package/src/auth-email-password/handlers/login.write.ts +3 -1
  8. package/src/auth-email-password/handlers/signup-confirm.write.ts +9 -5
  9. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +10 -2
  10. package/src/auth-mfa/handlers/enable-start-preauth.write.ts +11 -1
  11. package/src/auth-mfa/handlers/verify.write.ts +10 -2
  12. package/src/auth-mfa/mfa-code-verifier.ts +5 -1
  13. package/src/auth-mfa/mfa-status-checker.ts +7 -1
  14. package/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -3
  15. package/src/cap-counter/stock-cap-guard.ts +6 -1
  16. package/src/crypto-shredding/handlers/forget-subject.write.ts +43 -27
  17. package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +10 -5
  18. package/src/custom-fields/db/queries/field-access.ts +3 -1
  19. package/src/custom-fields/db/queries/quota.ts +3 -1
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +6 -0
  21. package/src/custom-fields/handlers/define-tenant-field.write.ts +6 -0
  22. package/src/custom-fields/handlers/set-custom-field.write.ts +6 -0
  23. package/src/delivery/jobs.ts +6 -5
  24. package/src/feature-toggles/global-feature-state-table.ts +15 -11
  25. package/src/feature-toggles/handlers/list.query.ts +1 -2
  26. package/src/feature-toggles/handlers/registered.query.ts +1 -2
  27. package/src/form-draft/handlers/discard.write.ts +7 -1
  28. package/src/form-draft/handlers/save.write.ts +11 -1
  29. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +3 -1
  30. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +16 -6
  31. package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -3
  32. package/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -2
  33. package/src/jobs/handlers/projection-rebuild.job.ts +4 -4
  34. package/src/jobs/handlers/reindex-entity.job.ts +5 -2
  35. package/src/ledger/__tests__/ledger-reports-tenant-isolation.integration.test.ts +148 -0
  36. package/src/ledger/handlers/confirm-schedule-period.write.ts +2 -3
  37. package/src/ledger/handlers/reports.query.ts +2 -3
  38. package/src/ledger/handlers/reverse-transaction.write.ts +1 -2
  39. package/src/managed-pages/handlers/set.write.ts +13 -1
  40. package/src/notes-history/handlers/add-note.write.ts +17 -8
  41. package/src/personal-access-tokens/handlers/create.write.ts +2 -1
  42. package/src/secrets/handlers/list.query.ts +1 -3
  43. package/src/sessions/handlers/revoke-all-for-user.write.ts +9 -2
  44. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +11 -1
  45. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +10 -3
  46. package/src/tier-engine/feature.ts +11 -19
  47. package/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -6
  48. package/src/tier-engine/handlers/set-tenant-tier.write.ts +6 -2
  49. package/src/user/__tests__/user-global-tenancy.integration.test.ts +97 -0
  50. package/src/user/handlers/update-roles.ts +14 -3
  51. package/src/user/handlers/update.write.ts +14 -1
  52. package/src/user/principal-status.ts +22 -1
  53. package/src/user/schema/user.ts +3 -0
  54. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +3 -1
  55. package/src/user-data-rights/handlers/cancel-deletion.write.ts +11 -10
  56. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +7 -5
  57. package/src/user-data-rights/handlers/deletion-grace-period.ts +11 -10
  58. package/src/user-data-rights/handlers/download-by-job.query.ts +17 -10
  59. package/src/user-data-rights/handlers/download-by-token.query.ts +21 -12
  60. package/src/user-data-rights/handlers/export-status.query.ts +8 -4
  61. package/src/user-data-rights/handlers/lift-restriction.write.ts +21 -9
  62. package/src/user-data-rights/handlers/my-audit-log.query.ts +7 -4
  63. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +13 -8
  64. package/src/user-data-rights/handlers/request-deletion.write.ts +2 -1
  65. package/src/user-data-rights/handlers/request-export.write.ts +13 -8
  66. package/src/user-data-rights/handlers/restrict-account.write.ts +18 -13
  67. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +9 -5
  68. package/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  69. package/src/user-data-rights/run-forget-cleanup.ts +2 -2
  70. package/src/user-data-rights/schema/export-job.ts +2 -4
@@ -40,6 +40,9 @@ import { EXPORT_JOB_STATUS, exportJobsTable } from "../schema/export-job";
40
40
 
41
41
  const SIGNED_URL_TTL_SECONDS = 300; // 5 min — matched download-by-token
42
42
 
43
+ const DOWNLOAD_BY_JOB_REASON =
44
+ "export jobs, their download tokens and audit rows are keyed by job id / userId across the user's tenant memberships, not the caller's current tenant";
45
+
43
46
  interface TokenRow {
44
47
  readonly id: string;
45
48
  readonly version: number;
@@ -63,6 +66,9 @@ export const downloadByJobQuery = defineQueryHandler({
63
66
  access: { openToAll: true }, // openToAll = auth-required, kein anonymous
64
67
  description:
65
68
  "Returns a short-lived signed download URL for the calling user's own finished data-export job named by job id, backing the download button in the privacy center once export-status reports the job done.",
69
+ escapeHatch: {
70
+ reason: DOWNLOAD_BY_JOB_REASON,
71
+ },
66
72
  handler: async (query, ctx) => {
67
73
  const T = getTemporal();
68
74
  const now = T.Now.instant();
@@ -74,15 +80,14 @@ export const downloadByJobQuery = defineQueryHandler({
74
80
  // Wert (603/2).
75
81
  const auditIp = requestContext.get()?.ip ?? null;
76
82
  const auditUa = requestContext.get()?.userAgent ?? null;
83
+ const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_JOB_REASON);
77
84
 
78
85
  // Step 1-2: job-lookup + cross-user-isolation
79
- // ctx.db.raw weil tenant-agnostisch — Alice in Tenant B sucht den
80
- // aus Tenant A erstellten Job.
81
- const jobRow = await fetchOne<JobRow>(ctx.db.raw, exportJobsTable, { id: jobId });
86
+ const jobRow = await fetchOne<JobRow>(runner, exportJobsTable, { id: jobId });
82
87
 
83
88
  if (!jobRow || jobRow.userId !== userId) {
84
89
  await recordInvalidAttempt({
85
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
90
+ db: runner,
86
91
  tenantId,
87
92
  now,
88
93
  result: "notFound",
@@ -100,7 +105,7 @@ export const downloadByJobQuery = defineQueryHandler({
100
105
 
101
106
  if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
102
107
  await recordInvalidAttempt({
103
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
108
+ db: runner,
104
109
  tenantId,
105
110
  now,
106
111
  result: "failed",
@@ -117,7 +122,7 @@ export const downloadByJobQuery = defineQueryHandler({
117
122
  }
118
123
  if (!jobRow.downloadStorageKey) {
119
124
  await recordInvalidAttempt({
120
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
125
+ db: runner,
121
126
  tenantId,
122
127
  now,
123
128
  result: "expired",
@@ -140,13 +145,15 @@ export const downloadByJobQuery = defineQueryHandler({
140
145
  registry: ctx.registry,
141
146
  configResolver: ctx.configResolver,
142
147
  secrets: ctx.secrets,
143
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
148
+ // @cast-boundary db-runner — helpers use only the query API that
149
+ // DbConnection and DbTx share.
150
+ db: runner as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
144
151
  userId: SYSTEM_USER_ID,
145
152
  handlerName: "user-data-rights:query:download-by-job",
146
153
  })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
147
154
  if (!provider.getSignedUrl) {
148
155
  await recordInvalidAttempt({
149
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
156
+ db: runner,
150
157
  tenantId,
151
158
  now,
152
159
  result: "signedUrlNotSupported",
@@ -177,13 +184,13 @@ export const downloadByJobQuery = defineQueryHandler({
177
184
  // den plain-Token, aber wir wollen den useCount inkrementieren
178
185
  // damit die Audit-Felder konsistent sind (UI-clicks zaehlen auch
179
186
  // als Use). Lookup via jobId — UNIQUE-Index garantiert max 1 Row.
180
- const tokenRow = await fetchOne<TokenRow>(ctx.db.raw, exportDownloadTokensTable, {
187
+ const tokenRow = await fetchOne<TokenRow>(runner, exportDownloadTokensTable, {
181
188
  jobId,
182
189
  });
183
190
 
184
191
  if (tokenRow) {
185
192
  await recordDownloadUse({
186
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
193
+ db: runner,
187
194
  tokenId: tokenRow.id,
188
195
  tokenVersion: tokenRow.version,
189
196
  tokenUseCount: tokenRow.useCount ?? 0,
@@ -45,6 +45,9 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
45
45
 
46
46
  const SIGNED_URL_TTL_SECONDS = 300; // 5 min — kurz genug fuer Replay-Schutz, lang genug fuer slow connections
47
47
 
48
+ const DOWNLOAD_BY_TOKEN_REASON =
49
+ "the download token, its job and audit rows are keyed by token hash / job id, not the caller's tenant — the anonymous magic-link path has no tenant context at all";
50
+
48
51
  interface TokenRow {
49
52
  readonly id: string;
50
53
  readonly version: number;
@@ -82,15 +85,17 @@ export const downloadByTokenQuery = defineQueryHandler({
82
85
  // Connection-Abbruch); blockiert automatisierte Probing-Loops.
83
86
  // Memory `feedback_security_default_on`.
84
87
  rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
88
+ escapeHatch: {
89
+ reason: DOWNLOAD_BY_TOKEN_REASON,
90
+ },
85
91
  handler: async (query, ctx) => {
86
92
  const T = getTemporal();
87
93
  const now = T.Now.instant();
94
+ const runner = ctx.db.unsafeRaw(DOWNLOAD_BY_TOKEN_REASON);
88
95
 
89
96
  // Step 1: hash + lookup
90
97
  const hash = await hashDownloadToken(query.payload.token);
91
- // ctx.db.raw weil Token+Job tenant-agnostisch — anonymous-pfad hat
92
- // keinen tenant-context im query.user.
93
- const tokenRow = await fetchOne<TokenRow>(ctx.db.raw, exportDownloadTokensTable, {
98
+ const tokenRow = await fetchOne<TokenRow>(runner, exportDownloadTokensTable, {
94
99
  tokenHash: hash,
95
100
  });
96
101
 
@@ -118,13 +123,15 @@ export const downloadByTokenQuery = defineQueryHandler({
118
123
  // tenantId unbekannt. Wir laden den Job hier noch fuer Audit-Context
119
124
  // (best-effort — wenn Job auch fehlt, audit-skip ist akzeptabel).
120
125
  const jobForAudit = await fetchOne<{ requestedFromTenantId: string }>(
121
- ctx.db.raw,
126
+ runner,
122
127
  exportJobsTable,
123
- { id: tokenRow.jobId },
128
+ {
129
+ id: tokenRow.jobId,
130
+ },
124
131
  );
125
132
  if (jobForAudit) {
126
133
  await recordInvalidAttempt({
127
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
134
+ db: runner,
128
135
  tenantId: jobForAudit.requestedFromTenantId,
129
136
  now,
130
137
  result: "expired",
@@ -142,7 +149,7 @@ export const downloadByTokenQuery = defineQueryHandler({
142
149
  }
143
150
 
144
151
  // Step 3-4: job-checks
145
- const jobRow = await fetchOne<JobRow>(ctx.db.raw, exportJobsTable, {
152
+ const jobRow = await fetchOne<JobRow>(runner, exportJobsTable, {
146
153
  id: tokenRow.jobId,
147
154
  });
148
155
 
@@ -153,7 +160,7 @@ export const downloadByTokenQuery = defineQueryHandler({
153
160
  }
154
161
  if (jobRow.status !== EXPORT_JOB_STATUS.Done) {
155
162
  await recordInvalidAttempt({
156
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
163
+ db: runner,
157
164
  tenantId: jobRow.requestedFromTenantId,
158
165
  now,
159
166
  result: "failed",
@@ -170,7 +177,7 @@ export const downloadByTokenQuery = defineQueryHandler({
170
177
  }
171
178
  if (!jobRow.downloadStorageKey) {
172
179
  await recordInvalidAttempt({
173
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
180
+ db: runner,
174
181
  tenantId: jobRow.requestedFromTenantId,
175
182
  now,
176
183
  result: "expired",
@@ -193,13 +200,15 @@ export const downloadByTokenQuery = defineQueryHandler({
193
200
  registry: ctx.registry,
194
201
  configResolver: ctx.configResolver,
195
202
  secrets: ctx.secrets,
196
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
203
+ // @cast-boundary db-runner — helpers use only the query API that
204
+ // DbConnection and DbTx share.
205
+ db: runner as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
197
206
  userId: SYSTEM_USER_ID,
198
207
  handlerName: "user-data-rights:query:download-by-token",
199
208
  })(jobRow.requestedFromTenantId as TenantId); // @cast-boundary engine-payload: TenantId brand
200
209
  if (!provider.getSignedUrl) {
201
210
  await recordInvalidAttempt({
202
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
211
+ db: runner,
203
212
  tenantId: jobRow.requestedFromTenantId,
204
213
  now,
205
214
  result: "signedUrlNotSupported",
@@ -230,7 +239,7 @@ export const downloadByTokenQuery = defineQueryHandler({
230
239
  // Wrapper (trusted-source). Direct-API-caller koennen luegen, aber
231
240
  // Audit ist nicht security-relevant.
232
241
  await recordDownloadUse({
233
- db: ctx.db.raw as import("@cosmicdrift/kumiko-types/db-connection").DbConnection,
242
+ db: runner,
234
243
  tokenId: tokenRow.id,
235
244
  tokenVersion: tokenRow.version,
236
245
  tokenUseCount: tokenRow.useCount ?? 0,
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // **Cross-User-Isolation:** Filter ist `userId === query.user.id` — kein
7
7
  // User kann fremde Job-Status sehen, auch nicht via ID-Guess. Pre-Check
8
- // nutzt ctx.db.raw weil ExportJob tenant-agnostisch ist (Plan-Doc-
8
+ // uses ctx.db.unsafeRaw — ExportJob is tenant-agnostic (see plan doc,
9
9
  // "Cross-Tenant-Semantik").
10
10
  //
11
11
  // **Read-Only-Endpoint:** Pollt nur, kein State-Flip. Idempotent + cache-
@@ -19,6 +19,9 @@ import { exportJobsTable } from "../schema/export-job";
19
19
 
20
20
  type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
21
21
 
22
+ const EXPORT_STATUS_REASON =
23
+ "export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
24
+
22
25
  type ExportJobRow = {
23
26
  readonly id: string;
24
27
  readonly status: string;
@@ -35,11 +38,12 @@ export const exportStatusQuery = defineQueryHandler({
35
38
  access: { openToAll: true },
36
39
  description:
37
40
  "Returns the calling user's own most recent data-export job with its status, expiry and error, or hasJob false, for polling after a request-export while the job is still running.",
41
+ escapeHatch: {
42
+ reason: EXPORT_STATUS_REASON,
43
+ },
38
44
  handler: async (query, ctx) => {
39
- // ctx.db.raw weil tenant-agnostisch — ein User der aus Tenant B
40
- // pollt, sieht den aus Tenant A erstellten Job.
41
45
  const rows = await selectMany<ExportJobRow>(
42
- ctx.db.raw,
46
+ ctx.db.unsafeRaw(EXPORT_STATUS_REASON),
43
47
  exportJobsTable,
44
48
  { userId: query.user.id },
45
49
  { limit: 1, orderBy: { col: "requestedAt", direction: "desc" } },
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
3
  import { z } from "zod";
@@ -6,6 +5,13 @@ import { USER_STATUS, userTable } from "../../user";
6
5
  import { denyIfTargetOutsideAdminTenant } from "../lib/deny-if-target-outside-admin-tenant";
7
6
  import { updateUserLifecycle } from "../lib/update-user-lifecycle";
8
7
 
8
+ const LIFT_RESTRICTION_ESCAPE_HATCH_REASON =
9
+ "checks the target user's membership in the admin's tenant and appends the user lifecycle status change on the SYSTEM_TENANT_ID user stream, both via DbRunner helpers outside the admin's own tenant scope.";
10
+ const CHECK_TARGET_MEMBERSHIP_REASON =
11
+ "checks the target user's membership in the admin's tenant via the DbRunner helper";
12
+ const APPEND_LIFECYCLE_EVENT_REASON =
13
+ "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
14
+
9
15
  // POST /api/user/lift-restriction (S2.U6) — DSGVO Art. 18 Reverse.
10
16
  //
11
17
  // Operator-only. A Restricted user's own session is unconditionally
@@ -14,11 +20,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
14
20
  // endpoint. There is no self-service path today, so lifting a restriction
15
21
  // always targets someone else's account by id.
16
22
  //
17
- // Tenant scope: `access.admin` includes TenantAdmin, which is tenant-
18
- // scoped even though the User-entity lookup below uses `ctx.db.raw`
19
- // (bypasses the auto-tenant-filter, same as restrict-account.write.ts —
20
- // User status is intentionally global, see user-data-rights.md
21
- // "Cross-Tenant-Semantik"). Without a membership check, a TenantAdmin
23
+ // Tenant scope: `access.admin` includes TenantAdmin (tenant-scoped) even
24
+ // though the User-entity is global — see user-data-rights.md "Cross-Tenant-Semantik". Without a membership check, a TenantAdmin
22
25
  // from tenant A could unrestrict/reactivate a user who has never been a
23
26
  // member of tenant A. Only SystemAdmin (platform-wide) skips the check;
24
27
  // the target must have a membership row in the acting admin's
@@ -35,13 +38,20 @@ export const liftRestrictionWrite = defineWriteHandler({
35
38
  access: { roles: access.admin },
36
39
  description:
37
40
  "Lifts a GDPR Art. 18 processing restriction on the named user and returns the account to active; operator-only, because a restricted user's own session is rejected and cannot reach this endpoint.",
41
+ escapeHatch: {
42
+ reason: LIFT_RESTRICTION_ESCAPE_HATCH_REASON,
43
+ },
38
44
  handler: async (event, ctx) => {
39
45
  const targetUserId = event.payload.userId;
40
46
 
41
- const outside = await denyIfTargetOutsideAdminTenant(ctx.db.raw, event.user, targetUserId);
47
+ const outside = await denyIfTargetOutsideAdminTenant(
48
+ ctx.db.unsafeRaw(CHECK_TARGET_MEMBERSHIP_REASON),
49
+ event.user,
50
+ targetUserId,
51
+ );
42
52
  if (outside) return outside;
43
53
 
44
- const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
54
+ const userRow = await ctx.db.global(userTable).fetchOne<{ status: string }>({
45
55
  id: targetUserId,
46
56
  });
47
57
 
@@ -62,7 +72,9 @@ export const liftRestrictionWrite = defineWriteHandler({
62
72
  );
63
73
  }
64
74
 
65
- await updateUserLifecycle(ctx.db.raw, targetUserId, { status: USER_STATUS.Active });
75
+ await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), targetUserId, {
76
+ status: USER_STATUS.Active,
77
+ });
66
78
 
67
79
  return {
68
80
  isSuccess: true as const,
@@ -10,6 +10,9 @@ import { z } from "zod";
10
10
  // auf account-weite Sicht ueber alle Memberships — analog Forget-Pfad.
11
11
  const MAX_LIMIT = 100;
12
12
 
13
+ const MY_AUDIT_LOG_REASON =
14
+ "reads the caller's own events across all tenant memberships, bounded by createdBy = caller";
15
+
13
16
  export const myAuditLogQuery = defineQueryHandler({
14
17
  name: "my-audit-log",
15
18
  schema: z
@@ -28,12 +31,12 @@ export const myAuditLogQuery = defineQueryHandler({
28
31
  access: { openToAll: true },
29
32
  description:
30
33
  "Returns the calling user's own event-store entries across all their tenant memberships, paged and filterable by aggregate type, event type and time range, for the GDPR Art. 15 self-disclosure; it can never read another user's history.",
34
+ escapeHatch: {
35
+ reason: MY_AUDIT_LOG_REASON,
36
+ },
31
37
  handler: async (query, ctx) => {
32
38
  const p = query.payload;
33
39
 
34
- // ctx.db.raw weil events-table tenantId-Spalte hat und TenantDb
35
- // sonst auto-filtert auf currentTenant. Account-weite Sicht ist
36
- // hier explizit gewollt; Sicherung erfolgt via createdBy-Filter.
37
40
  const where: WhereObject = { createdBy: query.user.id };
38
41
  if (p.aggregateType) where["aggregateType"] = p.aggregateType;
39
42
  if (p.eventType) where["type"] = p.eventType;
@@ -53,7 +56,7 @@ export const myAuditLogQuery = defineQueryHandler({
53
56
  type: string;
54
57
  payload: Record<string, unknown>;
55
58
  created_at: unknown;
56
- }>(ctx.db.raw, eventsTable, where, {
59
+ }>(ctx.db.unsafeRaw(MY_AUDIT_LOG_REASON), eventsTable, where, {
57
60
  orderBy: { col: "id", direction: "desc" },
58
61
  limit: p.limit,
59
62
  });
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import { z } from "zod";
4
3
  import { USER_STATUS, userTable } from "../../user";
@@ -41,6 +40,9 @@ export type RequestDeletionByEmailOptions = {
41
40
  // logs, unlike `?token=`). Same convention as the export-download link
42
41
  // (feature.ts, issue #1271). Preserves any existing query params on `base`
43
42
  // (`?lang=de` stays a query param; only the token is a fragment).
43
+ const APPEND_LIFECYCLE_EVENT_REASON =
44
+ "appends the user lifecycle event on the SYSTEM_TENANT_ID user stream";
45
+
44
46
  export function buildDeletionVerifyUrl(base: string, token: string): string {
45
47
  const url = new URL(base);
46
48
  url.hash = `token=${encodeURIComponent(token)}`;
@@ -66,18 +68,21 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
66
68
  agent: { risk: "high" },
67
69
  // Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
68
70
  rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
71
+ escapeHatch: {
72
+ reason: APPEND_LIFECYCLE_EVENT_REASON,
73
+ },
69
74
  handler: async (event, ctx) => {
70
75
  const success = { isSuccess: true as const, data: { kind: "requested" as const } };
71
76
 
72
77
  // not-configured-safe: ohne Secret/URL kein Link, aber gleiche Antwort.
73
78
  if (!opts.deletionTokenSecret || !opts.deletionVerifyUrl) return success;
74
79
 
75
- // userTable ist tenant-agnostisch (Account-weite Löschung) → ctx.db.raw.
76
- const userRow = await fetchOne<{ id: string; status: string; email: string }>(
77
- ctx.db.raw,
78
- userTable,
79
- { email: event.payload.email, isDeleted: false },
80
- );
80
+ const userRow = await ctx.db
81
+ .global(userTable)
82
+ .fetchOne<{ id: string; status: string; email: string }>({
83
+ email: event.payload.email,
84
+ isDeleted: false,
85
+ });
81
86
  if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
82
87
  return success;
83
88
  }
@@ -86,7 +91,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
86
91
  // user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
87
92
  // nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
88
93
  const requestId = crypto.randomUUID();
89
- await updateUserLifecycle(ctx.db.raw, userRow["id"], {
94
+ await updateUserLifecycle(ctx.db.unsafeRaw(APPEND_LIFECYCLE_EVENT_REASON), userRow["id"], {
90
95
  pendingDeletionRequestId: requestId,
91
96
  });
92
97
 
@@ -48,7 +48,8 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
48
48
  reason:
49
49
  "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
50
50
  "...) to compute the grace period end — the calling user's own identity has no read " +
51
- "access to that tenant-config projection.",
51
+ "access to that tenant-config projection. It also appends the user lifecycle event on " +
52
+ "the SYSTEM_TENANT_ID user stream.",
52
53
  },
53
54
  description:
54
55
  "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
@@ -14,10 +14,8 @@
14
14
  // Catch + re-fetch + return existing als isExisting=true. Race-
15
15
  // Window <1ms, in Production extrem selten.
16
16
  //
17
- // **Cross-Tenant-Semantik:** ExportJob ist tenant-agnostisch (1 Job pro
18
- // userId ueber alle Memberships). Pre-Check nutzt ctx.db.raw (kein
19
- // TenantDb-Filter) — Alice klickt aus Tenant A, klickt dann aus Tenant
20
- // B → Pre-Check aus B findet den A-Job, kein 2. Job entsteht.
17
+ // **Cross-Tenant-Semantik:** ExportJob is tenant-agnostic (1 job per userId
18
+ // across all memberships) — the pre-check uses ctx.db.unsafeRaw (no TenantDb filter) so a click from tenant B still finds the job from tenant A.
21
19
  // `requestedFromTenantId` persistiert den Initial-Tenant aus dem
22
20
  // 1. Klick — Worker liest sein Compliance-Profile aus DIESEM Tenant
23
21
  // fuer Job-TTL/Stale/Cleanup.
@@ -39,6 +37,9 @@ const crud = createEventStoreExecutor(exportJobsTable, exportJobEntity, {
39
37
  entityName: "export-job",
40
38
  });
41
39
 
40
+ const REQUEST_EXPORT_REASON =
41
+ "export jobs are keyed by userId across all of the user's tenant memberships, not the caller's current tenant";
42
+
42
43
  /**
43
44
  * Race-Loss-Detection: createEventStoreExecutor.create catched 23505
44
45
  * intern + returnt `WriteFailure(UniqueViolationError)`, **kein throw**.
@@ -62,14 +63,18 @@ export const requestExportWrite = defineWriteHandler({
62
63
  access: { openToAll: true },
63
64
  description:
64
65
  "Queues a GDPR Art. 15 and 20 data export for the calling user and returns its job id, handing back the running job with isExisting true instead of a second one when an export is already pending.",
66
+ escapeHatch: {
67
+ reason: REQUEST_EXPORT_REASON,
68
+ },
65
69
  handler: async (event, ctx) => {
66
70
  const userId = event.user.id;
67
71
  const T = getTemporal();
68
72
  const now = T.Now.instant();
73
+ const exportJobRunner = ctx.db.unsafeRaw(REQUEST_EXPORT_REASON);
69
74
 
70
- // Pre-Check: ctx.db.raw weil ExportJob tenant-agnostisch ist —
71
- // der TenantDb-Wrapper wuerde Cross-Tenant-Jobs ausblenden.
72
- const existing = await findActiveJob(ctx.db.raw, userId);
75
+ // Pre-Check: exportJobRunner bypasses the TenantDb-Filter — the
76
+ // TenantDb-Wrapper would hide cross-tenant jobs.
77
+ const existing = await findActiveJob(exportJobRunner, userId);
73
78
  if (existing) {
74
79
  // Snapshot-Status (kann zwischen fetchOne + Response stale werden
75
80
  // wenn Worker parallel den State flippt; window minimal). User
@@ -104,7 +109,7 @@ export const requestExportWrite = defineWriteHandler({
104
109
  // Constraint. Re-fetch + return existing als isExisting=true.
105
110
  // Andere Failures (validation, version-conflict, ...) propagieren.
106
111
  if (isActiveJobConflict(result)) {
107
- const winner = await findActiveJob(ctx.db.raw, userId);
112
+ const winner = await findActiveJob(exportJobRunner, userId);
108
113
  if (!winner) {
109
114
  // Sollte nie passieren — Constraint-Violation ohne existing
110
115
  // active Job hiesse der Constraint matcht etwas anderes.
@@ -1,4 +1,3 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
1
  import { createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
2
  import {
4
3
  AccessDeniedError,
@@ -27,12 +26,8 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle";
27
26
  // sessionChecker and they can no longer call this (or any) endpoint
28
27
  // themselves; only an operator path reaches it at that point.
29
28
  //
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
29
+ // Tenant scope: `isAdminActor` accepts access.admin, which includes TenantAdmin
30
+ // (tenant-scoped) even though the User-entity is global — see user-data-rights.md "Cross-Tenant-Semantik". Without a membership check, a TenantAdmin from tenant A could restrict/unrestrict a user who
36
31
  // has never been a member of tenant A. Only SystemAdmin (platform-wide)
37
32
  // skips the check; the target must have a membership row in the acting
38
33
  // admin's `event.user.tenantId` (row existence only — no active-status field).
@@ -50,7 +45,9 @@ export const restrictAccountWrite = defineWriteHandler({
50
45
  reason:
51
46
  "Revokes all live sessions of the (possibly self-)restricted user via ctx.writeAs(SYSTEM, " +
52
47
  "sessions:write:user-session:revoke-all-for-user) — that handler's own access gate needs " +
53
- "privileged roles the acting caller doesn't necessarily have.",
48
+ "privileged roles the acting caller doesn't necessarily have. It also checks the target " +
49
+ "user's membership in the admin's tenant and appends the user lifecycle status change on " +
50
+ "the SYSTEM_TENANT_ID user stream, both via DbRunner helpers.",
54
51
  },
55
52
  description:
56
53
  "Freezes an account under GDPR Art. 18 by flipping it to restricted and revoking all of its live sessions, locking the user out until an admin calls lift-restriction; targets the caller by default, an admin may name another userId.",
@@ -65,13 +62,17 @@ export const restrictAccountWrite = defineWriteHandler({
65
62
  }),
66
63
  );
67
64
  }
68
- const outside = await denyIfTargetOutsideAdminTenant(ctx.db.raw, event.user, targetUserId);
65
+ const outside = await denyIfTargetOutsideAdminTenant(
66
+ ctx.db.unsafeRaw(
67
+ "checks the target user's membership in the admin's tenant via the DbRunner helper",
68
+ ),
69
+ event.user,
70
+ targetUserId,
71
+ );
69
72
  if (outside) return outside;
70
73
  }
71
74
 
72
- // ctx.db.raw weil User-Entity tenant-agnostisch ist (analog
73
- // request-deletion.write.ts Cross-Tenant-Section).
74
- const userRow = await fetchOne<{ status: string }>(ctx.db.raw, userTable, {
75
+ const userRow = await ctx.db.global(userTable).fetchOne<{ status: string }>({
75
76
  id: targetUserId,
76
77
  });
77
78
 
@@ -99,7 +100,11 @@ export const restrictAccountWrite = defineWriteHandler({
99
100
  );
100
101
  }
101
102
 
102
- await updateUserLifecycle(ctx.db.raw, targetUserId, { status: USER_STATUS.Restricted });
103
+ await updateUserLifecycle(
104
+ ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"),
105
+ targetUserId,
106
+ { status: USER_STATUS.Restricted },
107
+ );
103
108
 
104
109
  // Cross-Feature: alle live sessions revoken — sonst koennte der User
105
110
  // mit existierendem JWT bis zur Token-Expiry weiter schreiben.
@@ -23,12 +23,18 @@ export type RunForgetCleanupOptions = {
23
23
  readonly sendDeletionExecutedEmail?: SendDeletionExecutedEmailFn;
24
24
  };
25
25
 
26
+ const RUN_FORGET_CLEANUP_REASON =
27
+ "operator forget cleanup iterates users across tenants in per-user sub-transactions";
28
+
26
29
  export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}) {
27
30
  return defineWriteHandler({
28
31
  name: "run-forget-cleanup",
29
32
  schema: z.object({}),
30
33
  access: { roles: access.privileged },
31
34
  agent: { expose: false },
35
+ escapeHatch: {
36
+ reason: RUN_FORGET_CLEANUP_REASON,
37
+ },
32
38
  handler: async (_event, ctx) => {
33
39
  if (!ctx.registry) {
34
40
  return writeFailure(
@@ -38,15 +44,13 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
38
44
  );
39
45
  }
40
46
 
41
- // ctx.db.raw ist DbRunner. runForgetCleanup oeffnet pro User eine
42
- // Sub-Tx (SAVEPOINT wenn Outer-Dispatcher-Tx aktiv) — siehe
43
- // run-forget-cleanup.ts Header.
44
47
  const T = getTemporal();
48
+ const runner = ctx.db.unsafeRaw(RUN_FORGET_CLEANUP_REASON);
45
49
  // Operator-triggered forget must also erase binaries, not just rows —
46
50
  // it flips users to Deleted, after which the cron never re-processes
47
51
  // them, so a row-only delete here would permanently leak the binaries.
48
52
  // Resolve through the same file-foundation path the cron uses.
49
- const forgetDb = ctx.db.raw as DbConnection; // @cast-boundary db-operator: config reads tolerate the outer tx
53
+ const forgetDb = runner as DbConnection; // @cast-boundary db-runner — DbRunner narrows to DbConnection, config reads tolerate the outer tx
50
54
  const tenantModel = await resolveAppTenantModel({
51
55
  registry: ctx.registry,
52
56
  configResolver: ctx.configResolver,
@@ -54,7 +58,7 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
54
58
  userId: ctx._userId ?? SYSTEM_USER_ID,
55
59
  });
56
60
  const result = await runForgetCleanup({
57
- db: ctx.db.raw,
61
+ db: runner,
58
62
  registry: ctx.registry,
59
63
  now: T.Now.instant(),
60
64
  tenantModel,
@@ -21,8 +21,8 @@ import { USER_STATUS, userEntity, userTable } from "../../user";
21
21
  // kann abweichen).
22
22
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
23
23
 
24
- // `conn` ist ctx.db.raw (regulaere Handler) ODER die offene tx (forget-cleanup
25
- // Sub-Tx) — so bleibt der Event-Append atomar mit dem umgebenden Write.
24
+ // `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
25
+ // (forget-cleanup sub-tx) — keeps the event append atomic with the write.
26
26
  export async function updateUserLifecycle(
27
27
  conn: DbRunner,
28
28
  userId: string,
@@ -510,8 +510,8 @@ async function runInSubTransaction(
510
510
  db: DbRunner,
511
511
  fn: (tx: DbRunner) => Promise<void>,
512
512
  ): Promise<void> {
513
- // `db` is already the raw runner (the handler passes ctx.db.raw, the tests a
514
- // top-level connection) — cast to read the transaction surface directly,
513
+ // `db` is already the raw runner (the handler passes ctx.db.unsafeRaw(...),
514
+ // the tests a top-level connection) — cast to read the transaction surface directly,
515
515
  // without asRawClient (a test-only escape hatch). A top-level connection
516
516
  // exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
517
517
  // exclusive, so prefer whichever is present.
@@ -45,10 +45,8 @@ const EXPORT_JOB_STATUS_OPTIONS = [
45
45
  EXPORT_JOB_STATUS.Failed,
46
46
  ] as const;
47
47
 
48
- // **Tenant-agnostisch** wie userTable — 1 Job pro {userId} ueber alle
49
- // Memberships. Die Framework-Auto-`tenant_id`-Spalte ist da, wird aber
50
- // in der Domain ignoriert (Cross-Tenant-Iteration laeuft im Worker via
51
- // ctx.db.raw, gleiches Pattern wie runForgetCleanup).
48
+ // **Tenant-agnostic** like userTable — one job per userId across all
49
+ // memberships; the worker iterates cross-tenant via ctx.db.unsafeRaw (same pattern as runForgetCleanup).
52
50
  //
53
51
  // **Idempotency:** Partial-UNIQUE-Index auf `(userId)` WHERE
54
52
  // `status IN ('pending', 'running')`. User mit pending-Job kann keinen