@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
@@ -119,7 +119,11 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
119
119
  escapeHatch: {
120
120
  reason:
121
121
  "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
122
- "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant.",
122
+ "membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
123
+ "Also reads the pending invitation by id (the invitee is not yet a member of the " +
124
+ "invitation's tenant), adds the membership and accepts the invitation in the " +
125
+ "invitation's tenant, which differs from the caller's tenant, and reads the MFA " +
126
+ "enrollment of that tenant via the mfaStatusChecker callback.",
123
127
  },
124
128
  agent: { expose: false },
125
129
  // kumiko-lint-ignore complexity-budget reuses login.write.ts's gate chain (lockout/password/email/status/membership/mfa) plus invite-specific branches (email match, already-member check, invitation update, unburn-on-failure) — splitting would scatter gate order across functions without reducing risk
@@ -154,9 +158,13 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
154
158
 
155
159
  let committed = false;
156
160
  try {
157
- const invitation = await fetchOne<InvitationRow>(ctx.db.raw, tenantInvitationsTable, {
158
- id: invitationId,
159
- });
161
+ const invitation = await fetchOne<InvitationRow>(
162
+ ctx.db.unsafeRaw(
163
+ "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant",
164
+ ),
165
+ tenantInvitationsTable,
166
+ { id: invitationId },
167
+ );
160
168
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
161
169
  return invalidInviteToken();
162
170
 
@@ -178,7 +186,7 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
178
186
  // login.write.ts runs (see login-gates.test.ts for the gate contracts).
179
187
  // Email uniqueness is partial on live rows (framework#2593) — without
180
188
  // this filter the lookup can resolve a soft-deleted row.
181
- const userRow = await fetchOne<UserAuthRow>(ctx.db.raw, userTable, {
189
+ const userRow = await ctx.db.global(userTable).fetchOne<UserAuthRow>({
182
190
  email: invitationEmail,
183
191
  isDeleted: false,
184
192
  });
@@ -215,7 +223,9 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
215
223
  )) as Array<{ tenantId: string }>; // @cast-boundary db-row
216
224
  const alreadyMember = memberships.some((m) => m.tenantId === invitationTenantId);
217
225
 
218
- const dbConn = ctx.db.raw;
226
+ const dbConn = ctx.db.unsafeRaw(
227
+ "adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant",
228
+ );
219
229
 
220
230
  if (!alreadyMember) {
221
231
  const forbiddenInviteRole = findForbiddenMembershipRole([invitationRole]);
@@ -67,6 +67,13 @@ const invitationExecutor = createEventStoreExecutor(
67
67
  { entityName: "tenant-invitation" },
68
68
  );
69
69
 
70
+ const INVITE_ACCEPT_ESCAPE_HATCH_REASON =
71
+ "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant.";
72
+ const READ_PENDING_INVITATION_REASON =
73
+ "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
74
+ const ADD_MEMBERSHIP_INVITATION_TENANT_REASON =
75
+ "adds the membership and accepts the invitation in the invitation's tenant, which differs from the caller's tenant";
76
+
70
77
  export function createInviteAcceptHandler() {
71
78
  return defineWriteHandler<"invite-accept", typeof InviteAcceptSchema, InviteAcceptData>({
72
79
  name: "invite-accept",
@@ -76,6 +83,9 @@ export function createInviteAcceptHandler() {
76
83
  // dispatched wird.
77
84
  access: { openToAll: true },
78
85
  agent: { expose: false },
86
+ escapeHatch: {
87
+ reason: INVITE_ACCEPT_ESCAPE_HATCH_REASON,
88
+ },
79
89
  // kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
80
90
  handler: async (event, ctx) => {
81
91
  if (!ctx.redis) {
@@ -101,9 +111,11 @@ export function createInviteAcceptHandler() {
101
111
 
102
112
  let committed = false;
103
113
  try {
104
- const invitation = await fetchOne<InvitationRow>(ctx.db.raw, tenantInvitationsTable, {
105
- id: invitationId,
106
- });
114
+ const invitation = await fetchOne<InvitationRow>(
115
+ ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON),
116
+ tenantInvitationsTable,
117
+ { id: invitationId },
118
+ );
107
119
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
108
120
  return invalidInviteToken();
109
121
 
@@ -119,7 +131,7 @@ export function createInviteAcceptHandler() {
119
131
  // Email-Match: User muss mit der eingeladenen Email matchen.
120
132
  // Sonst kann ein Angreifer mit Zugriff zur invitee-Mail seinen
121
133
  // eigenen Account dem Tenant zuschlagen.
122
- const userRow = await fetchOne<UserEmailRow>(ctx.db.raw, userTable, {
134
+ const userRow = await ctx.db.global(userTable).fetchOne<UserEmailRow>({
123
135
  id: event.user.id,
124
136
  });
125
137
  const userEmail = userRow?.email
@@ -134,13 +146,14 @@ export function createInviteAcceptHandler() {
134
146
  // ein Re-Invite in einen (vorübergehend) disabled Tenant würde dort
135
147
  // alreadyMember=false sehen und am Unique-Constraint scheitern.
136
148
  // Idempotenz: schon Member → no-op + 200 mit alreadyMember=true.
137
- const membershipRow = await fetchOne(ctx.db.raw, tenantMembershipsTable, {
149
+ const invitationTenantRunner = ctx.db.unsafeRaw(ADD_MEMBERSHIP_INVITATION_TENANT_REASON);
150
+ const membershipRow = await fetchOne(invitationTenantRunner, tenantMembershipsTable, {
138
151
  userId: event.user.id,
139
152
  tenantId: invitationTenantId,
140
153
  });
141
154
  const alreadyMember = membershipRow !== undefined;
142
155
 
143
- const dbConn = ctx.db.raw;
156
+ const dbConn = invitationTenantRunner;
144
157
 
145
158
  if (!alreadyMember) {
146
159
  // Membership-Add via seedTenantMembership-helper (event-store-
@@ -18,7 +18,6 @@
18
18
  // invite-create.
19
19
 
20
20
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
21
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
22
21
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
22
  import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
24
23
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
@@ -132,7 +131,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
132
131
  // max. eine Row. Status egal (cancelled/accepted/expired/pending);
133
132
  // wir setzen sie auf pending zurück und vergeben einen frischen
134
133
  // Token wenn der bisherige nicht mehr lebt.
135
- const existing = await fetchOne(ctx.db.raw, tenantInvitationsTable, { tenantId, email });
134
+ const existing = await ctx.db.fetchOne(tenantInvitationsTable, { tenantId, email });
136
135
 
137
136
  let invitationId: string;
138
137
  let token: string;
@@ -77,6 +77,13 @@ const invitationExecutor = createEventStoreExecutor(
77
77
  { entityName: "tenant-invitation" },
78
78
  );
79
79
 
80
+ const INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON =
81
+ "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant. Creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists.";
82
+ const READ_PENDING_INVITATION_REASON =
83
+ "reads the pending invitation by id; the invitee is not yet a member of the invitation's tenant";
84
+ const CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON =
85
+ "creates the user and adds the membership and accepts the invitation in the invitation's tenant, before any caller tenant context exists";
86
+
80
87
  export function createInviteSignupCompleteHandler() {
81
88
  return defineWriteHandler<
82
89
  "invite-signup-complete",
@@ -87,6 +94,9 @@ export function createInviteSignupCompleteHandler() {
87
94
  schema: InviteSignupCompleteSchema,
88
95
  access: { roles: ["all"] },
89
96
  agent: { expose: false },
97
+ escapeHatch: {
98
+ reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
99
+ },
90
100
  handler: async (event, ctx) => {
91
101
  if (!ctx.redis) {
92
102
  return writeFailure(
@@ -110,9 +120,11 @@ export function createInviteSignupCompleteHandler() {
110
120
 
111
121
  let committed = false;
112
122
  try {
113
- const invitation = await fetchOne<InvitationRow>(ctx.db.raw, tenantInvitationsTable, {
114
- id: invitationId,
115
- });
123
+ const invitation = await fetchOne<InvitationRow>(
124
+ ctx.db.unsafeRaw(READ_PENDING_INVITATION_REASON),
125
+ tenantInvitationsTable,
126
+ { id: invitationId },
127
+ );
116
128
  if (!invitation || invitation.status !== INVITATION_STATUS.pending)
117
129
  return invalidInviteToken();
118
130
 
@@ -131,7 +143,7 @@ export function createInviteSignupCompleteHandler() {
131
143
  // Password zu setzen für denselben User.
132
144
  // Email uniqueness is partial on live rows (framework#2593) — without
133
145
  // this filter the lookup can resolve a soft-deleted row.
134
- const existingUser = await fetchOne(ctx.db.raw, userTable, {
146
+ const existingUser = await ctx.db.global(userTable).fetchOne({
135
147
  email: invitationEmail,
136
148
  isDeleted: false,
137
149
  });
@@ -139,9 +151,11 @@ export function createInviteSignupCompleteHandler() {
139
151
 
140
152
  // User anlegen via seedUserWithPassword (gleiches Pattern wie
141
153
  // signup-confirm), emailVerified=true wegen Magic-Link.
142
- // @cast-boundary db-runner — TenantDb.raw is DbRunner; seed-helpers
143
- // operate on plain drizzle-API which both shapes expose identically.
144
- const dbConn = ctx.db.raw as DbConnection;
154
+ // @cast-boundary db-runner — helpers use only the query API that
155
+ // DbConnection and DbTx share.
156
+ const dbConn = ctx.db.unsafeRaw(
157
+ CREATE_USER_AND_MEMBERSHIP_INVITATION_TENANT_REASON,
158
+ ) as DbConnection;
145
159
  const { id: userId } = await seedUserWithPassword(dbConn, {
146
160
  email: invitationEmail,
147
161
  password: event.payload.password,
@@ -287,7 +287,9 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
287
287
  reason:
288
288
  "Unauthenticated login has no caller identity yet — it looks up the user row by " +
289
289
  "email via ctx.queryAs(SYSTEM, ...) and resolves tenant membership via " +
290
- "ctx.resolveActiveMembership before a session exists.",
290
+ "ctx.resolveActiveMembership before a session exists. It also reads the MFA " +
291
+ "enrollment of the tenant named in the signed login/setup token, not the guest " +
292
+ "dispatch tenant, via the mfaStatusChecker callback.",
291
293
  },
292
294
  description:
293
295
  "Signs a user in with email and password, running the lockout, email-verification, account-status, tenant-membership and MFA gates, and answering with a session or with an MFA challenge or setup requirement.",
@@ -60,12 +60,18 @@ export type SignupConfirmData = {
60
60
  readonly tenantKey: string;
61
61
  };
62
62
 
63
+ const SIGNUP_CONFIRM_PROVISION_REASON =
64
+ "provisions a new tenant, its first user and membership before any tenant context exists";
65
+
63
66
  export function createSignupConfirmHandler() {
64
67
  return defineWriteHandler<"signup-confirm", typeof SignupConfirmSchema, SignupConfirmData>({
65
68
  name: "signup-confirm",
66
69
  schema: SignupConfirmSchema,
67
70
  access: { roles: ["all"] },
68
71
  agent: { expose: false },
72
+ escapeHatch: {
73
+ reason: SIGNUP_CONFIRM_PROVISION_REASON,
74
+ },
69
75
  handler: async (event, ctx) => {
70
76
  if (!ctx.redis) {
71
77
  return writeFailure(
@@ -90,11 +96,9 @@ export function createSignupConfirmHandler() {
90
96
  // Tenant-Key: 2-Wort-Slug aus framework/random, mit DB-Conflict-
91
97
  // Check gegen tenants.key. 22.500 Default-Combos + Suffix-
92
98
  // Fallback bei Kollision (siehe generateUniqueName).
93
- // @cast-boundary db-runner — TenantDb.raw is DbRunner (Connection|Tx);
94
- // provisioning helpers operate on plain drizzle-API that both shapes
95
- // expose identically. Inside an event-store transaction the cast lands
96
- // on the Tx flavor — same drizzle calls, same behavior.
97
- const dbConn = ctx.db.raw as DbConnection;
99
+ // @cast-boundary db-runner — helpers use only the query API that
100
+ // DbConnection and DbTx share.
101
+ const dbConn = ctx.db.unsafeRaw(SIGNUP_CONFIRM_PROVISION_REASON) as DbConnection;
98
102
 
99
103
  const tenantKey = await generateUniqueName({
100
104
  isAvailable: async (slug) => {
@@ -67,7 +67,9 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
67
67
  escapeHatch: {
68
68
  reason:
69
69
  "Pre-auth MFA enrollment step has no session yet — re-checks status via ctx.queryAs(SYSTEM, " +
70
- "user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user.",
70
+ "user:findForAuth) and membership via ctx.resolveActiveMembership for the setup token's user. " +
71
+ "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
72
+ "guest dispatch tenant.",
71
73
  },
72
74
  description:
73
75
  "Completes the enrollment that unblocks a sign-in forced into two-factor setup: verifies the code against the pre-auth setup token, stores the factor and derives the session the blocked login never got.",
@@ -125,7 +127,13 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
125
127
 
126
128
  // "system" mode: no session exists yet, tenantId comes from the
127
129
  // verified token, mirroring enable-start-preauth.write.ts.
128
- const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
130
+ const scopedDb = createTenantDb(
131
+ ctx.db.unsafeRaw(
132
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
133
+ ),
134
+ tenantId,
135
+ "system",
136
+ );
129
137
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
130
138
  const existing = await findUserMfaRow(scopedDb, scopedUser);
131
139
  if (existing) return mfaAlreadyEnabled();
@@ -11,6 +11,9 @@ import { buildOtpauthUri } from "../otpauth-uri";
11
11
  import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
12
12
  import { generateTotpSecret } from "../totp";
13
13
 
14
+ const ENABLE_START_PREAUTH_TENANT_REASON =
15
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant";
16
+
14
17
  export type EnableStartPreauthOptions = {
15
18
  // Verifies the incoming preauthSetupToken — must match login.write.ts's
16
19
  // mfaStatusChecker (challengeTokenSecret), NOT setupTokenSecret.
@@ -39,6 +42,9 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
39
42
  "Begins TOTP enrollment for a user whose sign-in was blocked because the tenant requires two-factor authentication, taking identity from the pre-auth token login issued instead of from a session.",
40
43
  // Same secret-bearing result as enable-start.
41
44
  agent: { expose: false },
45
+ escapeHatch: {
46
+ reason: ENABLE_START_PREAUTH_TENANT_REASON,
47
+ },
42
48
  handler: async (event, ctx) => {
43
49
  const verified = verifyMfaPreauthSetupToken(
44
50
  event.payload.preauthSetupToken,
@@ -53,7 +59,11 @@ export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions)
53
59
  // "system" mode: the guest dispatch identity's own tenantId is
54
60
  // meaningless here — the preauthSetupToken is the source of truth
55
61
  // for which tenant's row to read, mirroring verify.write.ts.
56
- const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
62
+ const scopedDb = createTenantDb(
63
+ ctx.db.unsafeRaw(ENABLE_START_PREAUTH_TENANT_REASON),
64
+ tenantId,
65
+ "system",
66
+ );
57
67
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
58
68
  const existing = await findUserMfaRow(scopedDb, scopedUser);
59
69
  if (existing) return mfaAlreadyEnabled();
@@ -49,7 +49,9 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
49
49
  escapeHatch: {
50
50
  reason:
51
51
  "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
52
- "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names.",
52
+ "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names. " +
53
+ "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
54
+ "guest dispatch tenant.",
53
55
  },
54
56
  description:
55
57
  "Finishes a two-step sign-in by checking a TOTP or recovery code against the challenge token that login handed back, under a per-account attempt cap, and derives the resulting session.",
@@ -92,7 +94,13 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
92
94
  // "system" mode: this handler runs with a guest identity whose own
93
95
  // tenantId is meaningless here — the challenge token is the source
94
96
  // of truth for which tenant's row to read.
95
- const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
97
+ const scopedDb = createTenantDb(
98
+ ctx.db.unsafeRaw(
99
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
100
+ ),
101
+ tenantId,
102
+ "system",
103
+ );
96
104
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
97
105
  const row = await findUserMfaRow(scopedDb, scopedUser);
98
106
  // MFA got disabled between login and verify (race, or a stale
@@ -21,7 +21,11 @@ export type MfaCodeVerifier = (
21
21
  // codes exist for.
22
22
  export function createMfaCodeVerifier(): MfaCodeVerifier {
23
23
  return async (ctx, userId, tenantId, code) => {
24
- const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
24
+ const scopedDb = createTenantDb(
25
+ ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
26
+ tenantId,
27
+ "system",
28
+ );
25
29
  const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
26
30
  if (!row) return { enrolled: false, ok: false };
27
31
  // Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
@@ -38,7 +38,13 @@ export function createMfaStatusChecker(opts: {
38
38
  readonly challengeTokenSecret: string;
39
39
  }): MfaStatusChecker {
40
40
  return async (ctx, userId, tenantId, roles) => {
41
- const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
41
+ const scopedDb = createTenantDb(
42
+ ctx.db.unsafeRaw(
43
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
44
+ ),
45
+ tenantId,
46
+ "system",
47
+ );
42
48
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
43
49
  const row = await findUserMfaRow(scopedDb, scopedUser);
44
50
 
@@ -2,8 +2,6 @@
2
2
  // `read_subscriptions`-projection. Tenant-Admins lesen ihre eigene
3
3
  // subscription via getSubscriptionForTenant-helper (= ctx.db ist
4
4
  // tenant-scoped, gibt automatisch nur die row des Callers zurück).
5
-
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
5
  import {
8
6
  configuredPiiSubjectKms,
9
7
  decryptPiiFieldValues,
@@ -22,7 +20,7 @@ export const listSubscriptionsQuery: QueryHandlerDef = {
22
20
  schema: listSchema,
23
21
  access: { roles: ["SystemAdmin", "TenantAdmin"] },
24
22
  handler: async (_query, ctx) => {
25
- const rows = await selectMany(ctx.db.raw, subscriptionsProjectionTable, {
23
+ const rows = await ctx.db.selectMany(subscriptionsProjectionTable, {
26
24
  tenantId: ctx.user.tenantId,
27
25
  });
28
26
  const piiKms = configuredPiiSubjectKms();
@@ -55,10 +55,15 @@ export function createStockCapGuard<TCaps>(
55
55
  }
56
56
 
57
57
  function withStockCap(handler: WriteHandlerDef, spec: StockCapSpec<TCaps>): WriteHandlerDef {
58
+ const capReason =
59
+ "counts the caller tenant's rows and resolves its tier caps through the raw-runner cap API";
58
60
  return {
59
61
  ...handler,
62
+ escapeHatch: handler.escapeHatch
63
+ ? { reason: `${handler.escapeHatch.reason} ${capReason}` }
64
+ : { reason: capReason },
60
65
  handler: async (event, ctx) => {
61
- const failure = await checkStockCap(ctx.db.raw, event.user.tenantId, spec);
66
+ const failure = await checkStockCap(ctx.db.unsafeRaw(capReason), event.user.tenantId, spec);
62
67
  return failure ?? handler.handler(event, ctx);
63
68
  },
64
69
  };
@@ -230,25 +230,28 @@ async function appendDenialAuditEvent(
230
230
  // This event is the only proof the denial happened. Skipping
231
231
  // runProjectionsForEvent here is fine — nothing projects
232
232
  // crypto-shredding:event:forget-denied.
233
- await append(outsideTx.raw, {
234
- aggregateId: generateId(),
235
- aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
236
- // MUST be event.user.tenantId (the prober's own tenant), never
237
- // SYSTEM_TENANT_ID — .raw bypasses TenantDb's scoping wrapper, so this
238
- // is the only thing keeping the denial event out of the foreign
239
- // subject's tenant (fw#2452).
240
- tenantId: event.user.tenantId,
241
- expectedVersion: 0,
242
- type: SUBJECT_FORGET_DENIED_EVENT_NAME,
243
- eventVersion: eventDef.version,
244
- payload,
245
- metadata: {
246
- userId: event.user.id,
247
- ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
248
- ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
249
- ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
233
+ await append(
234
+ outsideTx.unsafeRaw(
235
+ "denial audit append: names the prober's own tenant stream on the outside-transaction db",
236
+ ),
237
+ {
238
+ aggregateId: generateId(),
239
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
240
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
241
+ // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
242
+ tenantId: event.user.tenantId,
243
+ expectedVersion: 0,
244
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
245
+ eventVersion: eventDef.version,
246
+ payload,
247
+ metadata: {
248
+ userId: event.user.id,
249
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
250
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
251
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
252
+ },
250
253
  },
251
- });
254
+ );
252
255
  return null;
253
256
  }
254
257
 
@@ -270,6 +273,13 @@ export const forgetSubjectWrite = defineWriteHandler({
270
273
  // Erasing the subject key is irreversible: there is no undo, so an agent must
271
274
  // not be able to reach it at all.
272
275
  agent: { expose: false },
276
+ escapeHatch: {
277
+ reason:
278
+ "denial audit append names the prober's own tenant stream on the outside-transaction db; " +
279
+ "the tenant-scope check runs against the subject's tenant, not necessarily the caller's; " +
280
+ "the blind-index sweep and search purge address the subject across tenants; the user " +
281
+ "lifecycle update and PAT revoke run on the SYSTEM user stream.",
282
+ },
273
283
  handler: async (event, ctx) => {
274
284
  const kms = configuredPiiSubjectKms();
275
285
  if (!kms) {
@@ -292,7 +302,9 @@ export const forgetSubjectWrite = defineWriteHandler({
292
302
  const subjectKey = subjectIdToKey(subject);
293
303
 
294
304
  const tenantScopeDenial = await resolveTenantScopeDenial(
295
- ctx.db.raw,
305
+ ctx.db.unsafeRaw(
306
+ "tenant-scope check runs against the subject's tenant, not necessarily the caller's",
307
+ ),
296
308
  ctx.registry.features,
297
309
  event.user,
298
310
  raw,
@@ -337,17 +349,18 @@ export const forgetSubjectWrite = defineWriteHandler({
337
349
  eraseReason: event.payload.reason,
338
350
  });
339
351
 
340
- // Blind-index sweep (#818): null the erased subject's bidx columns now —
341
- // otherwise the deterministic HMAC stays equality-matchable until the next
342
- // rebuild. Deliberately ctx.db.raw: the ciphertext prefix addresses the
343
- // subject across tenants.
344
- await nullBlindIndexesForSubject(ctx.db.raw, ctx.registry.features, subjectKey);
352
+ // Blind-index sweep (#818): nulls bidx columns now so the deterministic
353
+ // HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
354
+ const crossTenantSubjectRunner = ctx.db.unsafeRaw(
355
+ "blind-index sweep and search purge address the subject across tenants",
356
+ );
357
+ await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
345
358
 
346
359
  // Derived search index still holds plaintext (#1610) — purge next to the
347
360
  // blind-index sweep. No adapter → no-op (apps without search).
348
361
  if (ctx.searchAdapter) {
349
362
  await purgeSearchDocumentsForSubject(
350
- ctx.db.raw,
363
+ crossTenantSubjectRunner,
351
364
  ctx.registry.features,
352
365
  ctx.searchAdapter,
353
366
  subjectKey,
@@ -376,9 +389,12 @@ export const forgetSubjectWrite = defineWriteHandler({
376
389
  // update is best-effort for real users.
377
390
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
378
391
  try {
379
- await updateUserLifecycle(ctx.db.raw, raw.userId, { status: USER_STATUS.Deleted });
392
+ const userLifecycleRunner = ctx.db.unsafeRaw(
393
+ "user lifecycle update and PAT revoke run on the SYSTEM user stream",
394
+ );
395
+ await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
380
396
  if (ctx.registry.features.has("personal-access-tokens")) {
381
- await revokeAllPatTokensForUser(ctx.db.raw, raw.userId);
397
+ await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
382
398
  }
383
399
  } catch {
384
400
  // User row may not exist (e.g. email subscribers with user-style
@@ -41,16 +41,21 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
41
41
  }
42
42
 
43
43
  describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
44
- test("selectSerializedFieldDefinition retries once through db.raw and returns the row", async () => {
44
+ test("selectSerializedFieldDefinition retries once through db.unsafeRaw and returns the row", async () => {
45
45
  const raw = fakeClient([closedConnectionError()], { serialized_field: "sf1" });
46
- const result = await selectSerializedFieldDefinition({ raw } as never, "t1", "entity", "field");
46
+ const result = await selectSerializedFieldDefinition(
47
+ { unsafeRaw: () => raw } as never,
48
+ "t1",
49
+ "entity",
50
+ "field",
51
+ );
47
52
  expect(result).toBe("sf1");
48
53
  expect(raw.calls).toBe(2);
49
54
  });
50
55
 
51
- test("countTenantFieldDefinitions retries once through db.raw and returns the count", async () => {
56
+ test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
52
57
  const raw = fakeClient([closedConnectionError()], { n: 3 });
53
- const result = await countTenantFieldDefinitions({ raw } as never, "t1");
58
+ const result = await countTenantFieldDefinitions({ unsafeRaw: () => raw } as never, "t1");
54
59
  expect(result).toBe(3);
55
60
  expect(raw.calls).toBe(2);
56
61
  });
@@ -86,7 +91,7 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
86
91
  serialized_field: "sf1",
87
92
  });
88
93
  await expect(
89
- selectSerializedFieldDefinition({ raw } as never, "t1", "entity", "field"),
94
+ selectSerializedFieldDefinition({ unsafeRaw: () => raw } as never, "t1", "entity", "field"),
90
95
  ).rejects.toThrow("connection was closed");
91
96
  expect(raw.calls).toBe(2);
92
97
  });
@@ -8,7 +8,9 @@ export async function selectSerializedFieldDefinition(
8
8
  fieldKey: string,
9
9
  ): Promise<unknown | null> {
10
10
  const rows = await unsafeReadRetrying(
11
- db.raw,
11
+ db.unsafeRaw(
12
+ "reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
13
+ ),
12
14
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
13
15
  [entityName, fieldKey, tenantId],
14
16
  );
@@ -5,7 +5,9 @@ export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string
5
5
  // Active definitions only — delete soft-deletes (the deterministic stream is
6
6
  // kept so a re-define can restore it), so isDeleted rows must not consume quota.
7
7
  const rowsResult = await unsafeReadRetrying(
8
- db.raw,
8
+ db.unsafeRaw(
9
+ "counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
10
+ ),
9
11
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
10
12
  [tenantId],
11
13
  );
@@ -16,6 +16,9 @@ export const clearCustomFieldPayloadSchema = z.object({
16
16
  });
17
17
  export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
18
18
 
19
+ const CLEAR_CUSTOM_FIELD_REASON =
20
+ "reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
21
+
19
22
  // clear-custom-field — entfernt einen Custom-Field-Wert von einer host-
20
23
  // entity. Emittiert customField.cleared-Event; MSP entfernt key aus
21
24
  // jsonb-column (key-removal, nicht null-set).
@@ -29,6 +32,9 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
29
32
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
30
33
  description:
31
34
  "Removes the stored value of one custom field from a single host entity row after re-checking that field's per-field write roles; use it to blank a field the user emptied instead of writing a null value.",
35
+ escapeHatch: {
36
+ reason: CLEAR_CUSTOM_FIELD_REASON,
37
+ },
32
38
  handler: async (event, ctx) => {
33
39
  const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
34
40
 
@@ -45,6 +45,9 @@ export interface DefineTenantFieldOptions {
45
45
  readonly roles?: readonly string[];
46
46
  }
47
47
 
48
+ const DEFINE_TENANT_FIELD_REASON =
49
+ "counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
50
+
48
51
  export function createDefineTenantFieldHandler(
49
52
  opts: DefineTenantFieldOptions = {},
50
53
  ): WriteHandlerDef {
@@ -55,6 +58,9 @@ export function createDefineTenantFieldHandler(
55
58
  access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
56
59
  description:
57
60
  "Creates a custom-field definition owned by the caller's own tenant on the named entity, rejecting the write once the tenant's definition quota is reached; use it when one tenant needs an extra field the other tenants must not see.",
61
+ escapeHatch: {
62
+ reason: DEFINE_TENANT_FIELD_REASON,
63
+ },
58
64
  handler: async (event, ctx) => {
59
65
  const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
60
66
  const tenantId = event.user.tenantId;
@@ -22,6 +22,9 @@ export const setCustomFieldPayloadSchema = z.object({
22
22
  });
23
23
  export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
24
24
 
25
+ const SET_CUSTOM_FIELD_REASON =
26
+ "reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry";
27
+
25
28
  // set-custom-field — schreibt einen Custom-Field-Wert auf eine host-entity.
26
29
  //
27
30
  // **ES-Option-B**: emittiert customField.set-Event auf dem host-aggregate
@@ -52,6 +55,9 @@ export const setCustomFieldHandler: WriteHandlerDef = {
52
55
  access: { roles: DEFAULT_VALUE_WRITE_ROLES },
53
56
  description:
54
57
  "Stores one custom-field value on a single host entity row after validating it against the field definition's declared type and per-field write roles; use it to save what a user entered into a custom field.",
58
+ escapeHatch: {
59
+ reason: SET_CUSTOM_FIELD_REASON,
60
+ },
55
61
  handler: async (event, ctx) => {
56
62
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
57
63