@cosmicdrift/kumiko-bundled-features 0.278.0 → 0.281.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 (63) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +8 -2
  3. package/src/auth-email-password/handlers/confirm-token-flow.ts +9 -0
  4. package/src/auth-email-password/handlers/login.write.ts +12 -17
  5. package/src/auth-mfa/mfa-code-verifier.ts +9 -1
  6. package/src/auth-mfa/mfa-status-checker.ts +5 -0
  7. package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  8. package/src/auth-mfa-user-data/hooks.ts +4 -6
  9. package/src/crypto-shredding/handlers/forget-subject.write.ts +24 -23
  10. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  11. package/src/custom-fields/db/queries/__tests__/unsafe-read-retrying.test.ts +5 -10
  12. package/src/custom-fields/db/queries/field-access.ts +3 -5
  13. package/src/custom-fields/db/queries/quota.ts +6 -5
  14. package/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  15. package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -1
  16. package/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  17. package/src/custom-fields/lib/field-access.ts +5 -5
  18. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  19. package/src/data-retention/resolve-for-tenant.ts +18 -6
  20. package/src/delivery/db/queries/preferences.ts +1 -0
  21. package/src/files-tenant-data/hooks.ts +3 -5
  22. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  23. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  24. package/src/folders-user-data/hooks.ts +5 -10
  25. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  26. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  27. package/src/form-draft-user-data/hooks.ts +3 -6
  28. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  29. package/src/notes-history-user-data/hooks.ts +3 -4
  30. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  31. package/src/template-resolver-user-data/hooks.ts +1 -2
  32. package/src/tenant-lifecycle/feature.ts +2 -0
  33. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  34. package/src/tenant-lifecycle/stages.ts +26 -2
  35. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  36. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  37. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  38. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  39. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  40. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  41. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  42. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  43. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  44. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  45. package/src/user-data-rights/feature.ts +4 -0
  46. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +19 -2
  47. package/src/user-data-rights/handlers/deletion-grace-period.ts +19 -27
  48. package/src/user-data-rights/handlers/request-deletion.write.ts +15 -2
  49. package/src/user-data-rights/run-export-jobs.ts +16 -1
  50. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  51. package/src/user-data-rights/run-user-export.ts +43 -3
  52. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  53. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  54. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  55. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  56. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  57. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  58. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  59. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  60. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  61. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  62. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  63. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.278.0",
3
+ "version": "0.281.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -129,12 +129,12 @@
129
129
  "./workflow-runner": "./src/workflow-runner/index.ts"
130
130
  },
131
131
  "dependencies": {
132
- "@cosmicdrift/kumiko-dispatcher-live": "0.278.0",
133
- "@cosmicdrift/kumiko-framework": "0.278.0",
134
- "@cosmicdrift/kumiko-headless": "0.278.0",
135
- "@cosmicdrift/kumiko-renderer": "0.278.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.278.0",
137
- "@cosmicdrift/kumiko-types": "0.278.0",
132
+ "@cosmicdrift/kumiko-dispatcher-live": "0.281.0",
133
+ "@cosmicdrift/kumiko-framework": "0.281.0",
134
+ "@cosmicdrift/kumiko-headless": "0.281.0",
135
+ "@cosmicdrift/kumiko-renderer": "0.281.0",
136
+ "@cosmicdrift/kumiko-renderer-web": "0.281.0",
137
+ "@cosmicdrift/kumiko-types": "0.281.0",
138
138
  "@mollie/api-client": "^4.5.0",
139
139
  "@node-rs/argon2": "^2.0.2",
140
140
  "@types/mailparser": "^3.4.6",
@@ -163,7 +163,7 @@
163
163
  ],
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
- "@cosmicdrift/kumiko-locale-de": "0.278.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.278.0"
166
+ "@cosmicdrift/kumiko-locale-de": "0.281.0",
167
+ "@cosmicdrift/kumiko-locale-es": "0.281.0"
168
168
  }
169
169
  }
@@ -14,6 +14,7 @@ import {
14
14
  InMemoryKmsAdapter,
15
15
  isPiiCiphertext,
16
16
  } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
17
18
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
19
20
  setupTestStack,
@@ -319,7 +320,7 @@ describe("auth flows with active KMS + blind index", () => {
319
320
  expect(isPiiCiphertext(rawInvitation["email"])).toBe(true);
320
321
 
321
322
  const result = await tenantInvitationExportHook({
322
- db: stack.db,
323
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
323
324
  registry: stack.registry,
324
325
  tenantId: TENANT_A_ID,
325
326
  userId: bobId,
@@ -347,7 +348,12 @@ describe("auth flows with active KMS + blind index", () => {
347
348
  expect(isPiiCiphertext(rawBefore["invited_by"])).toBe(true);
348
349
 
349
350
  await tenantInvitationDeleteHook(
350
- { db: stack.db, registry: stack.registry, tenantId: TENANT_A_ID, userId: aliceId },
351
+ {
352
+ db: createTenantDb(stack.db, TENANT_A_ID, "tenant"),
353
+ registry: stack.registry,
354
+ tenantId: TENANT_A_ID,
355
+ userId: aliceId,
356
+ },
351
357
  "delete",
352
358
  );
353
359
 
@@ -24,6 +24,7 @@
24
24
 
25
25
  import {
26
26
  createSystemUser,
27
+ declareEscapeHatch,
27
28
  type HandlerContext,
28
29
  type SessionUser,
29
30
  SYSTEM_TENANT_ID,
@@ -69,6 +70,10 @@ export async function runConfirmTokenFlow<TSuccessData>(
69
70
  expiresAtMs: number,
70
71
  spec: ConfirmTokenFlowSpec<TSuccessData>,
71
72
  ): Promise<WriteResult<TSuccessData>> {
73
+ declareEscapeHatch({
74
+ reason:
75
+ "writes the confirming user's row via UserHandlers.update on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
76
+ });
72
77
  if (!ctx.redis) {
73
78
  return writeFailure(new InternalError({ message: spec.redisRequiredMessage }));
74
79
  }
@@ -131,6 +136,10 @@ async function loadValidatedUser(
131
136
  systemUser: SessionUser,
132
137
  userId: string,
133
138
  ): Promise<(AuthUserRow & { version: number }) | null> {
139
+ declareEscapeHatch({
140
+ reason:
141
+ "reads the confirming user's row via UserQueries.findForAuth on the caller's handler context, using a SYSTEM_TENANT-scoped identity; the calling handler declares its own escapeHatch",
142
+ });
134
143
  const me = parseAuthUserRow(
135
144
  await ctx.queryAs(systemUser, UserQueries.findForAuth, { id: userId }),
136
145
  );
@@ -95,12 +95,9 @@ type AuthenticatableUserRow = AuthUserRow & { readonly passwordHash: string };
95
95
 
96
96
  /** Uniform response on any credential miss — burns argon2 cost (#774). */
97
97
  export async function gateResolveAuthUser(
98
- ctx: HandlerContext,
99
- systemUser: SessionUser,
100
- email: string,
98
+ found: AuthUserRow | null,
101
99
  password: string,
102
100
  ): Promise<GateOutcome<AuthenticatableUserRow>> {
103
- const found = parseAuthUserRow(await ctx.queryAs(systemUser, UserQueries.findForAuth, { email }));
104
101
  if (!found?.passwordHash || found.isDeleted) {
105
102
  await verifyDummyPassword(password);
106
103
  return reject(invalidCredentials());
@@ -176,15 +173,9 @@ export function gateEnforceAccountStatus(found: AuthUserRow): GateOutcome<undefi
176
173
  /** Pick membership (last-active preferred); merge global + tenant roles. */
177
174
  export async function gateResolveMembership(
178
175
  ctx: HandlerContext,
179
- systemUser: SessionUser,
180
176
  found: AuthUserRow,
177
+ memberships: ReadonlyArray<Membership>,
181
178
  ): Promise<GateOutcome<{ readonly chosen: Membership; readonly mergedRoles: readonly string[] }>> {
182
- // Still needed for candidate ORDER (preferred tenant first) — the actual
183
- // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
184
- const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
185
- userId: found.id,
186
- })) as Array<Membership>; // @cast-boundary db-runner
187
-
188
179
  if (memberships.length === 0) {
189
180
  return reject(noMembership());
190
181
  }
@@ -296,12 +287,10 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
296
287
  handler: async (event, ctx): Promise<WriteResult<LoginResult>> => {
297
288
  const systemUser = createSystemUser(SYSTEM_USER_ID);
298
289
 
299
- const userGate = await gateResolveAuthUser(
300
- ctx,
301
- systemUser,
302
- event.payload.email,
303
- event.payload.password,
290
+ const foundRow = parseAuthUserRow(
291
+ await ctx.queryAs(systemUser, UserQueries.findForAuth, { email: event.payload.email }),
304
292
  );
293
+ const userGate = await gateResolveAuthUser(foundRow, event.payload.password);
305
294
  if (!userGate.ok) return userGate.result;
306
295
  const found = userGate.value;
307
296
 
@@ -323,7 +312,13 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
323
312
  const statusGate = gateEnforceAccountStatus(found);
324
313
  if (!statusGate.ok) return statusGate.result;
325
314
 
326
- const membershipGate = await gateResolveMembership(ctx, systemUser, found);
315
+ // Still needed for candidate ORDER (preferred tenant first) — the actual
316
+ // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
317
+ const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
318
+ userId: found.id,
319
+ })) as Array<Membership>; // @cast-boundary db-runner
320
+
321
+ const membershipGate = await gateResolveMembership(ctx, found, memberships);
327
322
  if (!membershipGate.ok) return membershipGate.result;
328
323
  const { chosen, mergedRoles } = membershipGate.value;
329
324
 
@@ -1,5 +1,9 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import {
3
+ declareEscapeHatch,
4
+ type HandlerContext,
5
+ type TenantId,
6
+ } from "@cosmicdrift/kumiko-framework/engine";
3
7
  import { findUserMfaRow } from "./db/queries";
4
8
  import { verifyMfaFactor } from "./verify-factor";
5
9
 
@@ -21,6 +25,10 @@ export type MfaCodeVerifier = (
21
25
  // codes exist for.
22
26
  export function createMfaCodeVerifier(): MfaCodeVerifier {
23
27
  return async (ctx, userId, tenantId, code) => {
28
+ declareEscapeHatch({
29
+ reason:
30
+ "reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
31
+ });
24
32
  const scopedDb = createTenantDb(
25
33
  ctx.db.unsafeRaw("reads the MFA enrollment of the user being re-authenticated"),
26
34
  tenantId,
@@ -1,6 +1,7 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  access,
4
+ declareEscapeHatch,
4
5
  type HandlerContext,
5
6
  type SessionUser,
6
7
  type TenantId,
@@ -38,6 +39,10 @@ export function createMfaStatusChecker(opts: {
38
39
  readonly challengeTokenSecret: string;
39
40
  }): MfaStatusChecker {
40
41
  return async (ctx, userId, tenantId, roles) => {
42
+ declareEscapeHatch({
43
+ reason:
44
+ "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
45
+ });
41
46
  const scopedDb = createTenantDb(
42
47
  ctx.db.unsafeRaw(
43
48
  "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant",
@@ -3,7 +3,7 @@
3
3
  // (not just leave it dangling for a future projection rebuild to resurrect).
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
6
+ import { configureEntityFieldEncryption, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
7
7
  import {
8
8
  createTestUser,
9
9
  setupTestStack,
@@ -77,7 +77,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
77
77
  const user = await enrollUser("export-1");
78
78
 
79
79
  const snippet = await userMfaExportHook({
80
- db: stack.db,
80
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
81
81
  registry: stack.registry,
82
82
  tenantId: user.tenantId,
83
83
  userId: user.id,
@@ -94,7 +94,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
94
94
  test("export returns null for a user who never enrolled", async () => {
95
95
  const user = createTestUser({ id: "never-enrolled", roles: ["User"] });
96
96
  const snippet = await userMfaExportHook({
97
- db: stack.db,
97
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
98
98
  registry: stack.registry,
99
99
  tenantId: user.tenantId,
100
100
  userId: user.id,
@@ -106,7 +106,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
106
106
  const user = await enrollUser("delete-1");
107
107
 
108
108
  const before = await userMfaExportHook({
109
- db: stack.db,
109
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
110
110
  registry: stack.registry,
111
111
  tenantId: user.tenantId,
112
112
  userId: user.id,
@@ -115,7 +115,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
115
115
 
116
116
  await userMfaDeleteHook(
117
117
  {
118
- db: stack.db,
118
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
119
119
  registry: stack.registry,
120
120
  tenantId: user.tenantId,
121
121
  userId: user.id,
@@ -124,7 +124,7 @@ describe("userMfaExportHook / userMfaDeleteHook", () => {
124
124
  );
125
125
 
126
126
  const after = await userMfaExportHook({
127
- db: stack.db,
127
+ db: createTenantDb(stack.db, user.tenantId, "tenant"),
128
128
  registry: stack.registry,
129
129
  tenantId: user.tenantId,
130
130
  userId: user.id,
@@ -7,8 +7,7 @@
7
7
  // replays via the event, a raw DELETE would be resurrected by a
8
8
  // projection rebuild).
9
9
 
10
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
12
11
  import {
13
12
  createSystemUser,
14
13
  type UserDataDeleteHook,
@@ -26,7 +25,7 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
26
25
  // tokenHash exclusion in user-data-rights-defaults). Just confirms
27
26
  // enrollment + when, which is what a data subject actually needs to see.
28
27
  export const userMfaExportHook: UserDataExportHook = async (ctx) => {
29
- const rows = await selectMany<{ id: string; enabledAt: unknown }>(ctx.db, userMfaTable, {
28
+ const rows = await ctx.db.selectMany<{ id: string; enabledAt: unknown }>(userMfaTable, {
30
29
  userId: ctx.userId,
31
30
  tenantId: ctx.tenantId,
32
31
  });
@@ -41,15 +40,14 @@ export const userMfaExportHook: UserDataExportHook = async (ctx) => {
41
40
  // secret can't be anonymized, and `personal: { of: "userId" }` already
42
41
  // means this row belongs to exactly one user (never shared tenant data).
43
42
  export const userMfaDeleteHook: UserDataDeleteHook = async (ctx) => {
44
- const rows = await selectMany<{ id: string }>(ctx.db, userMfaTable, {
43
+ const rows = await ctx.db.selectMany<{ id: string }>(userMfaTable, {
45
44
  userId: ctx.userId,
46
45
  tenantId: ctx.tenantId,
47
46
  });
48
47
  // skip: nothing enrolled for this user — no row to erase
49
48
  if (rows.length === 0) return;
50
49
  const systemUser = createSystemUser(ctx.tenantId);
51
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
52
50
  for (const row of rows) {
53
- assertErased(await executor.forget({ id: row.id }, systemUser, tdb), "user-mfa", row.id);
51
+ assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
54
52
  }
55
53
  };
@@ -192,9 +192,9 @@ async function appendDenialAuditEvent(
192
192
  subjectKey: string,
193
193
  subjectKind: SubjectIdInput["kind"],
194
194
  denialCode: string,
195
+ denialAuditRunner: DbRunner | undefined,
195
196
  ): Promise<WriteFailure | null> {
196
- const outsideTx = ctx.dbOutsideTransaction;
197
- if (!outsideTx) {
197
+ if (!denialAuditRunner) {
198
198
  return writeFailure(
199
199
  new InternalError({
200
200
  message:
@@ -230,28 +230,23 @@ 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(
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
- },
233
+ await append(denialAuditRunner, {
234
+ aggregateId: generateId(),
235
+ aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
236
+ // MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
237
+ // bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
238
+ tenantId: event.user.tenantId,
239
+ expectedVersion: 0,
240
+ type: SUBJECT_FORGET_DENIED_EVENT_NAME,
241
+ eventVersion: eventDef.version,
242
+ payload,
243
+ metadata: {
244
+ userId: event.user.id,
245
+ ...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
246
+ ...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
247
+ ...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
253
248
  },
254
- );
249
+ });
255
250
  return null;
256
251
  }
257
252
 
@@ -316,6 +311,9 @@ export const forgetSubjectWrite = defineWriteHandler({
316
311
  subjectKey,
317
312
  raw.kind,
318
313
  tenantScopeDenial.error.code,
314
+ ctx.dbOutsideTransaction?.unsafeRaw(
315
+ "denial audit append: names the prober's own tenant stream on the outside-transaction db",
316
+ ),
319
317
  );
320
318
  return auditFailure ?? tenantScopeDenial;
321
319
  }
@@ -335,6 +333,9 @@ export const forgetSubjectWrite = defineWriteHandler({
335
333
  subjectKey,
336
334
  raw.kind,
337
335
  TARGET_RECORD_RETENTION_BLOCK_DELETE,
336
+ ctx.dbOutsideTransaction?.unsafeRaw(
337
+ "denial audit append: names the prober's own tenant stream on the outside-transaction db",
338
+ ),
338
339
  );
339
340
  return auditFailure ?? retentionDenial;
340
341
  }
@@ -71,8 +71,14 @@ const propertyTable = buildEntityTable("property", propertyEntity);
71
71
  // Host entity gets its own EXT_USER_DATA-registration too — that's the
72
72
  // canonical setup. The host's anonymize hook NULLS inserted_by_id; the
73
73
  // custom-fields wiring contributes export only (#972).
74
+ // fw#2914 — "property" is a managed r.entity table (EXECUTOR_ONLY-branded), so
75
+ // TenantDb's typed write methods reject it; these host hooks need raw SQL,
76
+ // still filtered by inserted_by_id + tenant_id.
77
+ const HOST_HOOK_REASON =
78
+ "fw#2914 test fixture: host export/delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
79
+
74
80
  const hostExportHook: UserDataExportHook = async (ctx) => {
75
- const rows = await asRawClient(ctx.db).unsafe(
81
+ const rows = await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
76
82
  `SELECT id, name FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
77
83
  [ctx.userId, ctx.tenantId],
78
84
  );
@@ -86,13 +92,13 @@ const hostExportHook: UserDataExportHook = async (ctx) => {
86
92
 
87
93
  const hostDeleteHook: UserDataDeleteHook = async (ctx, strategy) => {
88
94
  if (strategy === "delete") {
89
- await asRawClient(ctx.db).unsafe(
95
+ await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
90
96
  `DELETE FROM read_t15c_properties WHERE inserted_by_id = $1 AND tenant_id = $2`,
91
97
  [ctx.userId, ctx.tenantId],
92
98
  );
93
99
  } else {
94
100
  // anonymize: clear owner, keep row + customFields.
95
- await asRawClient(ctx.db).unsafe(
101
+ await asRawClient(ctx.db.unsafeRaw(HOST_HOOK_REASON)).unsafe(
96
102
  `UPDATE read_t15c_properties SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
97
103
  [ctx.userId, ctx.tenantId],
98
104
  );
@@ -111,6 +117,7 @@ const propertyFeature = defineFeature("property-t15c", (r) => {
111
117
  r.useExtension(EXT_USER_DATA, "property", {
112
118
  export: hostExportHook,
113
119
  delete: hostDeleteHook,
120
+ escapeHatch: { reason: HOST_HOOK_REASON },
114
121
  });
115
122
 
116
123
  const { executor } = createEntityExecutor("property", propertyEntity);
@@ -43,19 +43,14 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
43
43
  describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
44
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(
47
- { unsafeRaw: () => raw } as never,
48
- "t1",
49
- "entity",
50
- "field",
51
- );
46
+ const result = await selectSerializedFieldDefinition(raw, "t1", "entity", "field");
52
47
  expect(result).toBe("sf1");
53
48
  expect(raw.calls).toBe(2);
54
49
  });
55
50
 
56
51
  test("countTenantFieldDefinitions retries once through db.unsafeRaw and returns the count", async () => {
57
52
  const raw = fakeClient([closedConnectionError()], { n: 3 });
58
- const result = await countTenantFieldDefinitions({ unsafeRaw: () => raw } as never, "t1");
53
+ const result = await countTenantFieldDefinitions(raw, "t1");
59
54
  expect(result).toBe(3);
60
55
  expect(raw.calls).toBe(2);
61
56
  });
@@ -90,9 +85,9 @@ describe("custom-fields db/queries — closed-connection retry (#2323)", () => {
90
85
  const raw = fakeClient([closedConnectionError(), closedConnectionError()], {
91
86
  serialized_field: "sf1",
92
87
  });
93
- await expect(
94
- selectSerializedFieldDefinition({ unsafeRaw: () => raw } as never, "t1", "entity", "field"),
95
- ).rejects.toThrow("connection was closed");
88
+ await expect(selectSerializedFieldDefinition(raw, "t1", "entity", "field")).rejects.toThrow(
89
+ "connection was closed",
90
+ );
96
91
  expect(raw.calls).toBe(2);
97
92
  });
98
93
  });
@@ -1,16 +1,14 @@
1
1
  import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function selectSerializedFieldDefinition(
5
- db: TenantDb,
5
+ runner: DbRunner,
6
6
  tenantId: string,
7
7
  entityName: string,
8
8
  fieldKey: string,
9
9
  ): Promise<unknown | null> {
10
10
  const rows = await unsafeReadRetrying(
11
- db.unsafeRaw(
12
- "reads custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
13
- ),
11
+ runner,
14
12
  "SELECT serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND field_key = $2 AND tenant_id = $3 LIMIT 1",
15
13
  [entityName, fieldKey, tenantId],
16
14
  );
@@ -1,13 +1,14 @@
1
1
  import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
- export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
4
+ export async function countTenantFieldDefinitions(
5
+ runner: DbRunner,
6
+ tenantId: string,
7
+ ): Promise<number> {
5
8
  // Active definitions only — delete soft-deletes (the deterministic stream is
6
9
  // kept so a re-define can restore it), so isDeleted rows must not consume quota.
7
10
  const rowsResult = await unsafeReadRetrying(
8
- db.unsafeRaw(
9
- "counts custom field definitions with an explicit tenant_id = caller tenant via raw SQL with read retry",
10
- ),
11
+ runner,
11
12
  "SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
12
13
  [tenantId],
13
14
  );
@@ -39,7 +39,7 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
39
39
  const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
40
40
 
41
41
  const accessCheck = await checkFieldAccessForWrite(
42
- ctx.db,
42
+ ctx.db.unsafeRaw(CLEAR_CUSTOM_FIELD_REASON),
43
43
  event.user.tenantId,
44
44
  payload.entityName,
45
45
  payload.fieldKey,
@@ -73,7 +73,10 @@ export function createDefineTenantFieldHandler(
73
73
  }
74
74
 
75
75
  if (limit !== undefined) {
76
- const current = await countTenantFieldDefinitions(ctx.db, tenantId);
76
+ const current = await countTenantFieldDefinitions(
77
+ ctx.db.unsafeRaw(DEFINE_TENANT_FIELD_REASON),
78
+ tenantId,
79
+ );
77
80
  if (current >= limit) {
78
81
  return failUnprocessable("cap_exceeded", {
79
82
  capName: "customFields.fieldDefinition.count",
@@ -62,7 +62,7 @@ export const setCustomFieldHandler: WriteHandlerDef = {
62
62
  const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
63
63
 
64
64
  const loaded = await loadFieldDefinition(
65
- ctx.db,
65
+ ctx.db.unsafeRaw(SET_CUSTOM_FIELD_REASON),
66
66
  event.user.tenantId,
67
67
  payload.entityName,
68
68
  payload.fieldKey,
@@ -2,7 +2,7 @@
2
2
  // Plus single-fetch loader so set-custom-field can run access-check AND
3
3
  // value-validation off one DB read (no double fetch).
4
4
 
5
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
5
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
6
6
  import { selectSerializedFieldDefinition } from "../db/queries/field-access";
7
7
  import { parseSerializedField, type SerializedFieldShape } from "./parse-serialized-field";
8
8
 
@@ -22,12 +22,12 @@ export type LoadedFieldDefinition =
22
22
  | { found: true; field: SerializedFieldShape | null };
23
23
 
24
24
  export async function loadFieldDefinition(
25
- db: TenantDb,
25
+ runner: DbRunner,
26
26
  tenantId: string,
27
27
  entityName: string,
28
28
  fieldKey: string,
29
29
  ): Promise<LoadedFieldDefinition> {
30
- const serialized = await selectSerializedFieldDefinition(db, tenantId, entityName, fieldKey);
30
+ const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
31
31
  if (serialized === null) return { found: false };
32
32
  // parseSerializedField throws on a #972 legacy `sensitive` definition —
33
33
  // that's a corrupt/unprocessable row from this write-gate's perspective,
@@ -57,13 +57,13 @@ export function fieldWriteAccessDeniedRoles(
57
57
  // Convenience wrapper retained for clear-custom-field (no value-validation
58
58
  // needed there) — does the load + access-check in one call.
59
59
  export async function checkFieldAccessForWrite(
60
- db: TenantDb,
60
+ runner: DbRunner,
61
61
  tenantId: string,
62
62
  entityName: string,
63
63
  fieldKey: string,
64
64
  userRoles: ReadonlyArray<string>,
65
65
  ): Promise<FieldAccessCheckResult> {
66
- const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
66
+ const loaded = await loadFieldDefinition(runner, tenantId, entityName, fieldKey);
67
67
  if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
68
68
  if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
69
69
 
@@ -1,7 +1,7 @@
1
1
  // T1.5c — user-data-rights wiring for custom-fields.
2
2
 
3
- import { extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
- import type { UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights";
7
7
 
@@ -27,6 +27,41 @@ function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
27
27
  return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
28
28
  }
29
29
 
30
+ // fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
31
+ // table name (opts.entityTable varies per call site), so it can't go through
32
+ // TenantDb's typed method API (table param must be a known SchemaTable). The
33
+ // SQL text itself still filters `tenant_id = ctx.tenantId`, so this stays a
34
+ // single-tenant read — the escapeHatch is only for the raw-SQL mechanism, not
35
+ // for cross-tenant access.
36
+ const CUSTOM_FIELDS_EXPORT_REASON =
37
+ "custom-fields export reads a generic customFields column via raw SQL against a dynamic host table name, not expressible through TenantDb's typed method API; the query still filters tenant_id = ctx.tenantId";
38
+
39
+ async function exportCustomFields(
40
+ db: DbRunner,
41
+ ctx: UserDataHookCtx,
42
+ tableName: string,
43
+ opts: WireCustomFieldsUserDataRightsOptions,
44
+ ): Promise<UserDataExportSnippet | null> {
45
+ const rows = await selectCustomFieldsHostRows(
46
+ db,
47
+ tableName,
48
+ opts.userIdColumn,
49
+ ctx.userId,
50
+ ctx.tenantId,
51
+ );
52
+ const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
53
+ for (const raw of rows) {
54
+ const row = asCustomFieldsHostRow(raw);
55
+ if (!row) continue;
56
+ const customFields = row.customFields;
57
+ if (customFields && Object.keys(customFields).length > 0) {
58
+ snippetRows.push({ id: row.id, customFields });
59
+ }
60
+ }
61
+ if (snippetRows.length === 0) return null;
62
+ return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
63
+ }
64
+
30
65
  // Export-only wiring: custom fields hold supplemental business data, not PII
31
66
  // (#972) — there is nothing to redact on user-forget, only Art. 20 export.
32
67
  export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<string>>(
@@ -35,29 +70,10 @@ export function wireCustomFieldsUserDataRightsFor<TReg extends FeatureRegistrar<
35
70
  ): void {
36
71
  const tableName = extractTableName(opts.entityTable, "custom-fields/wire-user-data-rights");
37
72
 
38
- const exportHook: UserDataExportHook = async (ctx) => {
39
- const rows = await selectCustomFieldsHostRows(
40
- ctx.db,
41
- tableName,
42
- opts.userIdColumn,
43
- ctx.userId,
44
- ctx.tenantId,
45
- );
46
- const snippetRows: Array<{ id: string; customFields: Record<string, unknown> }> = [];
47
- for (const raw of rows) {
48
- const row = asCustomFieldsHostRow(raw);
49
- if (!row) continue;
50
- const customFields = row.customFields;
51
- if (customFields && Object.keys(customFields).length > 0) {
52
- snippetRows.push({ id: row.id, customFields });
53
- }
54
- }
55
- if (snippetRows.length === 0) return null;
56
- return { entity: `${opts.entityName}.customFields`, rows: snippetRows };
57
- };
58
-
59
73
  // biome-ignore lint/correctness/useHookAtTopLevel: r.useExtension is a registrar API, not a React hook.
60
74
  r.useExtension(EXT_USER_DATA, opts.entityName, {
61
- export: exportHook,
75
+ export: async (ctx: UserDataHookCtx) =>
76
+ exportCustomFields(ctx.db.unsafeRaw(CUSTOM_FIELDS_EXPORT_REASON), ctx, tableName, opts),
77
+ escapeHatch: { reason: CUSTOM_FIELDS_EXPORT_REASON },
62
78
  });
63
79
  }