@cosmicdrift/kumiko-bundled-features 0.277.0 → 0.280.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 (62) 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-mfa-user-data/__tests__/hooks.integration.test.ts +6 -6
  4. package/src/auth-mfa-user-data/hooks.ts +4 -6
  5. package/src/cap-counter/__tests__/enforce-cap.test.ts +7 -10
  6. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +123 -6
  7. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +135 -1
  8. package/src/cap-counter/book-cap-usage.ts +158 -0
  9. package/src/cap-counter/changes.json +16 -1
  10. package/src/cap-counter/enforce-cap.ts +5 -37
  11. package/src/cap-counter/handlers/increment.write.ts +7 -47
  12. package/src/cap-counter/handlers/mark-soft-warned.write.ts +6 -33
  13. package/src/cap-counter/index.ts +8 -0
  14. package/src/cap-counter/stock-cap-guard.ts +14 -19
  15. package/src/cap-counter/with-cap-enforcement.ts +9 -11
  16. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +10 -3
  17. package/src/custom-fields/wire-user-data-rights.ts +40 -24
  18. package/src/data-retention/resolve-for-tenant.ts +18 -6
  19. package/src/delivery/db/queries/preferences.ts +1 -0
  20. package/src/files-tenant-data/hooks.ts +3 -5
  21. package/src/folders/__tests__/folders.integration.test.ts +4 -3
  22. package/src/folders-user-data/__tests__/hooks.integration.test.ts +2 -1
  23. package/src/folders-user-data/hooks.ts +5 -10
  24. package/src/form-draft-user-data/__tests__/hooks-delete-failure.integration.test.ts +7 -1
  25. package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +16 -5
  26. package/src/form-draft-user-data/hooks.ts +3 -6
  27. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +9 -3
  28. package/src/notes-history-user-data/hooks.ts +3 -4
  29. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  30. package/src/template-resolver-user-data/hooks.ts +1 -2
  31. package/src/tenant-lifecycle/feature.ts +2 -0
  32. package/src/tenant-lifecycle/run-tenant-destroy.ts +9 -0
  33. package/src/tenant-lifecycle/stages.ts +26 -2
  34. package/src/tier-engine/__tests__/tier-resolver.integration.test.ts +138 -0
  35. package/src/tier-engine/changes.json +7 -0
  36. package/src/tier-engine/tier-resolver.ts +7 -6
  37. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +5 -14
  38. package/src/user-data-rights/__tests__/escape-hatch-audit.integration.test.ts +133 -0
  39. package/src/user-data-rights/__tests__/escape-hatch-declaration.integration.test.ts +198 -0
  40. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +15 -3
  41. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +10 -2
  42. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -1
  43. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -3
  44. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +8 -2
  45. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +8 -4
  46. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +15 -5
  47. package/src/user-data-rights/feature.ts +4 -0
  48. package/src/user-data-rights/run-export-jobs.ts +16 -1
  49. package/src/user-data-rights/run-forget-cleanup.ts +44 -2
  50. package/src/user-data-rights/run-user-export.ts +43 -3
  51. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +7 -6
  52. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +64 -18
  53. package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +2 -3
  54. package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +4 -6
  55. package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +1 -2
  56. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +5 -11
  57. package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +2 -3
  58. package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +1 -2
  59. package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +4 -6
  60. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +8 -10
  61. package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +2 -3
  62. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +9 -10
@@ -0,0 +1,133 @@
1
+ // fw#2914 — proves the escape-hatch audit trail is wired end-to-end on the
2
+ // REAL production path: the registered "run-forget-cleanup" cron job (not
3
+ // runForgetCleanup called directly, not a hand-fed sink) forwards its
4
+ // JobContext's `_escapeHatchAuditSink` + `systemUser.id` down through
5
+ // runForgetCleanup → the per-entity `createEscapeHatchReporter` →
6
+ // ctx.db.unsafeRaw(reason) inside a declared-escapeHatch hook, so a
7
+ // declared raw-SQL use is actually attributed and audited instead of
8
+ // landing on the anonymous "<unattributed>" fallback.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import {
15
+ createSystemUser,
16
+ defineFeature,
17
+ type EscapeHatchUseEvent,
18
+ EXT_USER_DATA,
19
+ type JobContext,
20
+ type UserDataDeleteHook,
21
+ } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
23
+ import {
24
+ setupTestStack,
25
+ type TestStack,
26
+ unsafeCreateEntityTable,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
29
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
30
+ import { createSessionsFeature } from "../../sessions";
31
+ import { createUserFeature, userEntity } from "../../user";
32
+ import { createUserDataRightsFeature } from "../feature";
33
+ import { createForgetSeeders, READ_TENANT_MEMBERSHIPS_DDL } from "./forget-test-helpers";
34
+
35
+ const FORGET_USER = "ffffffff-ffff-4fff-8fff-000000000001";
36
+ const TENANT_A = "00000000-0000-4000-8000-0000000000f9";
37
+
38
+ const AUDITED_REASON =
39
+ "fw#2914 test: declared escapeHatch on the real run-forget-cleanup job path — must be audited, not <unattributed>";
40
+
41
+ const auditedHook: UserDataDeleteHook = async (ctx) => {
42
+ await asRawClient(ctx.db.unsafeRaw(AUDITED_REASON)).unsafe(
43
+ `DELETE FROM test_audited_entity WHERE tenant_id = $1`,
44
+ [ctx.tenantId],
45
+ );
46
+ };
47
+
48
+ const auditedFeature = defineFeature("test-audited-entity", (r) => {
49
+ r.useExtension(EXT_USER_DATA, "audited-entity", {
50
+ export: async () => null,
51
+ delete: auditedHook,
52
+ escapeHatch: { reason: AUDITED_REASON },
53
+ });
54
+ });
55
+
56
+ const seed = (db: unknown) =>
57
+ // biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
58
+ createForgetSeeders(db as any, { write: async () => {} });
59
+
60
+ describe("escape-hatch audit :: real run-forget-cleanup job forwards _escapeHatchAuditSink + systemUser.id", () => {
61
+ let stack: TestStack;
62
+
63
+ beforeAll(async () => {
64
+ stack = await setupTestStack({
65
+ features: [
66
+ createUserFeature(),
67
+ createDataRetentionFeature(),
68
+ createComplianceProfilesFeature(),
69
+ authFoundationFeature,
70
+ createSessionsFeature(),
71
+ createUserDataRightsFeature(),
72
+ auditedFeature,
73
+ ],
74
+ });
75
+ await unsafeCreateEntityTable(stack.db, userEntity);
76
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
77
+ await createEventsTable(stack.db);
78
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
79
+ await asRawClient(stack.db).unsafe(`
80
+ CREATE TABLE IF NOT EXISTS test_audited_entity (
81
+ id UUID PRIMARY KEY,
82
+ tenant_id UUID NOT NULL
83
+ )
84
+ `);
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await stack.cleanup();
89
+ });
90
+
91
+ test("declared hook's unsafeRaw use is reported with a qualified handler + the job's systemUser as actor", async () => {
92
+ const job = stack.registry.getJob("user-data-rights:job:run-forget-cleanup");
93
+ expect(job).toBeTruthy();
94
+
95
+ await seed(stack.db).seedForgetUser(FORGET_USER);
96
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
97
+
98
+ const events: EscapeHatchUseEvent[] = [];
99
+ const systemUser = createSystemUser(TENANT_A);
100
+ const noopLog: JobContext["log"] = {
101
+ info() {},
102
+ warn() {},
103
+ error() {},
104
+ debug() {},
105
+ child(): JobContext["log"] {
106
+ return this;
107
+ },
108
+ };
109
+
110
+ await job?.handler({}, {
111
+ db: createTenantDb(stack.db, TENANT_A, "tenant", undefined, undefined, undefined, {
112
+ unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
113
+ }),
114
+ registry: stack.registry,
115
+ systemUser,
116
+ log: noopLog,
117
+ _escapeHatchAuditSink: async (event: EscapeHatchUseEvent) => {
118
+ events.push(event);
119
+ },
120
+ } as unknown as JobContext);
121
+
122
+ const hit = events.find((e) => e.reason === AUDITED_REASON);
123
+ expect(hit).toBeDefined();
124
+ expect(hit?.handler).toBe("userData:audited-entity");
125
+ expect(hit?.kind).toBe("unsafe-raw");
126
+ expect(hit?.tenantId).toBe(TENANT_A);
127
+ // The job's OWN systemUser.id, not "system"/"<unattributed>" — proves
128
+ // ctx.systemUser.id really travels from the JobContext down to the
129
+ // per-hook reporter instead of a hardcoded/default actor.
130
+ expect(hit?.actor).toBe(systemUser.id);
131
+ expect(hit?.actor).not.toBe("<unattributed>");
132
+ });
133
+ });
@@ -0,0 +1,198 @@
1
+ // fw#2914 — proves the escapeHatch declaration gate, not just its type.
2
+ //
3
+ // TenantDataHookCtx/UserDataHookCtx.db is a tenant-filtered TenantDb. A hook
4
+ // that declares `escapeHatch: { reason }` on its `r.useExtension(...)`
5
+ // registration can call `ctx.db.unsafeRaw(reason)` to get a REAL unfiltered
6
+ // DbRunner — the two tests below use it with a deliberately filter-less
7
+ // `DELETE FROM <table>` (no WHERE at all) to prove the grant is genuinely
8
+ // unfiltered, not merely present. A sibling hook that forgot to declare
9
+ // escapeHatch gets `AccessDeniedError` from that same call instead, which
10
+ // `runForgetCleanup` catches into `result.errors` — the row is left
11
+ // untouched rather than silently wiped/leaked across tenants.
12
+ //
13
+ // Pre-fix (TenantDataHookCtx/UserDataHookCtx.db was a raw, unfiltered
14
+ // DbRunner) BOTH hooks below — declared or not — could run this exact
15
+ // unfiltered DELETE with no gate at all. That's the regression this test
16
+ // pins shut.
17
+
18
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
19
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
20
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
+ import {
22
+ defineFeature,
23
+ EXT_USER_DATA,
24
+ type UserDataDeleteHook,
25
+ } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
27
+ import {
28
+ setupTestStack,
29
+ type TestStack,
30
+ unsafeCreateEntityTable,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
33
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
34
+ import { createSessionsFeature } from "../../sessions";
35
+ import { createUserFeature, userEntity } from "../../user";
36
+ import { createUserDataRightsFeature } from "../feature";
37
+ import { runForgetCleanup } from "../run-forget-cleanup";
38
+ import {
39
+ createForgetSeeders,
40
+ nowInstant,
41
+ READ_TENANT_MEMBERSHIPS_DDL,
42
+ } from "./forget-test-helpers";
43
+
44
+ const FORGET_USER = "eeeeeeee-eeee-4eee-8eee-000000000001";
45
+ const TENANT_A = "00000000-0000-4000-8000-0000000000e1";
46
+ const TENANT_B_FOREIGN = "00000000-0000-4000-8000-0000000000e2";
47
+
48
+ const seed = (db: unknown) =>
49
+ // biome-ignore lint/suspicious/noExplicitAny: dummy file-writer; these seeders never write binaries.
50
+ createForgetSeeders(db as any, { write: async () => {} });
51
+
52
+ function baseFeatures(): NonNullable<Parameters<typeof setupTestStack>[0]["features"]> {
53
+ return [
54
+ createUserFeature(),
55
+ createDataRetentionFeature(),
56
+ createComplianceProfilesFeature(),
57
+ authFoundationFeature,
58
+ createSessionsFeature(),
59
+ createUserDataRightsFeature(),
60
+ ];
61
+ }
62
+
63
+ async function bootBaseTables(stack: TestStack): Promise<void> {
64
+ await unsafeCreateEntityTable(stack.db, userEntity);
65
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
66
+ await createEventsTable(stack.db);
67
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
68
+ }
69
+
70
+ describe("escape-hatch declaration :: declared hook gets a real unfiltered DbRunner", () => {
71
+ const DECLARED_REASON =
72
+ "fw#2914 test: escape-hatch declared on r.useExtension — ctx.db.unsafeRaw(reason) must grant a real unfiltered DbRunner";
73
+
74
+ const declaredLeakHook: UserDataDeleteHook = async (ctx) => {
75
+ // No WHERE clause at all — only possible with a genuinely unfiltered runner.
76
+ await asRawClient(ctx.db.unsafeRaw(DECLARED_REASON)).unsafe(`DELETE FROM test_leaky_declared`);
77
+ };
78
+
79
+ const declaredFeature = defineFeature("test-leaky-declared", (r) => {
80
+ r.useExtension(EXT_USER_DATA, "leaky-declared", {
81
+ export: async () => null,
82
+ delete: declaredLeakHook,
83
+ escapeHatch: { reason: DECLARED_REASON },
84
+ });
85
+ });
86
+
87
+ let stack: TestStack;
88
+
89
+ beforeAll(async () => {
90
+ stack = await setupTestStack({ features: [...baseFeatures(), declaredFeature] });
91
+ await bootBaseTables(stack);
92
+ await asRawClient(stack.db).unsafe(`
93
+ CREATE TABLE IF NOT EXISTS test_leaky_declared (
94
+ id UUID PRIMARY KEY,
95
+ tenant_id UUID NOT NULL,
96
+ secret TEXT NOT NULL
97
+ )
98
+ `);
99
+ await asRawClient(stack.db).unsafe(
100
+ `INSERT INTO test_leaky_declared (id, tenant_id, secret) VALUES
101
+ ('11111111-1111-4111-8111-000000000001', $1, 'tenant-a-secret'),
102
+ ('11111111-1111-4111-8111-000000000002', $2, 'tenant-b-secret')`,
103
+ [TENANT_A, TENANT_B_FOREIGN],
104
+ );
105
+ });
106
+
107
+ afterAll(async () => {
108
+ await stack.cleanup();
109
+ });
110
+
111
+ test("declared unsafeRaw wipes rows across every tenant, not just the forgotten user's own", async () => {
112
+ await seed(stack.db).seedForgetUser(FORGET_USER);
113
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
114
+
115
+ const result = await runForgetCleanup({
116
+ db: stack.db,
117
+ registry: stack.registry,
118
+ now: nowInstant(),
119
+ });
120
+
121
+ expect(result.errors).toHaveLength(0);
122
+ expect(result.processedUserIds).toContain(FORGET_USER);
123
+
124
+ const remaining = await asRawClient(stack.db).unsafe(`SELECT id FROM test_leaky_declared`);
125
+ // Both Tenant A's own row AND the foreign Tenant B's row are gone —
126
+ // proves ctx.db.unsafeRaw(reason) really is unfiltered, not just granted.
127
+ expect(remaining).toHaveLength(0);
128
+ });
129
+ });
130
+
131
+ describe("escape-hatch declaration :: undeclared hook is denied, not silently unfiltered", () => {
132
+ const UNDECLARED_REASON =
133
+ "fw#2914 test: escape-hatch NOT declared on r.useExtension — ctx.db.unsafeRaw(reason) must be denied";
134
+
135
+ const undeclaredLeakHook: UserDataDeleteHook = async (ctx) => {
136
+ await asRawClient(ctx.db.unsafeRaw(UNDECLARED_REASON)).unsafe(
137
+ `DELETE FROM test_leaky_undeclared`,
138
+ );
139
+ };
140
+
141
+ const undeclaredFeature = defineFeature("test-leaky-undeclared", (r) => {
142
+ r.useExtension(EXT_USER_DATA, "leaky-undeclared", {
143
+ export: async () => null,
144
+ delete: undeclaredLeakHook,
145
+ // fw#2914 — deliberately NOT declared: this is the case under test.
146
+ });
147
+ });
148
+
149
+ let stack: TestStack;
150
+
151
+ beforeAll(async () => {
152
+ stack = await setupTestStack({ features: [...baseFeatures(), undeclaredFeature] });
153
+ await bootBaseTables(stack);
154
+ await asRawClient(stack.db).unsafe(`
155
+ CREATE TABLE IF NOT EXISTS test_leaky_undeclared (
156
+ id UUID PRIMARY KEY,
157
+ tenant_id UUID NOT NULL,
158
+ secret TEXT NOT NULL
159
+ )
160
+ `);
161
+ await asRawClient(stack.db).unsafe(
162
+ `INSERT INTO test_leaky_undeclared (id, tenant_id, secret) VALUES
163
+ ('22222222-2222-4222-8222-000000000001', $1, 'tenant-a-secret'),
164
+ ('22222222-2222-4222-8222-000000000002', $2, 'tenant-b-secret')`,
165
+ [TENANT_A, TENANT_B_FOREIGN],
166
+ );
167
+ });
168
+
169
+ afterAll(async () => {
170
+ await stack.cleanup();
171
+ });
172
+
173
+ test("undeclared unsafeRaw is denied and surfaces via result.errors, rows untouched", async () => {
174
+ await seed(stack.db).seedForgetUser(FORGET_USER);
175
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_A);
176
+
177
+ const result = await runForgetCleanup({
178
+ db: stack.db,
179
+ registry: stack.registry,
180
+ now: nowInstant(),
181
+ });
182
+
183
+ // The hook throws AccessDeniedError inside processUser's sub-tx — that
184
+ // rolls the sub-tx back, so this user is NOT flipped to Deleted.
185
+ expect(result.processedUserIds).not.toContain(FORGET_USER);
186
+ expect(result.errors).toHaveLength(1);
187
+ expect(result.errors[0]?.entityName).toBe("leaky-undeclared");
188
+ expect(result.errors[0]?.userId).toBe(FORGET_USER);
189
+ expect(result.errors[0]?.message).toContain("escapeHatch");
190
+
191
+ const remaining = await asRawClient(stack.db).unsafe(
192
+ `SELECT tenant_id FROM test_leaky_undeclared ORDER BY tenant_id`,
193
+ );
194
+ // Both rows survive — the denial happened before any SQL executed, no
195
+ // cross-tenant leak (pre-fix, this exact hook body would have wiped both).
196
+ expect(remaining).toHaveLength(2);
197
+ });
198
+ });
@@ -48,18 +48,26 @@ import {
48
48
 
49
49
  const TENANT = "00000000-0000-4000-8000-0000000000aa";
50
50
 
51
+ // fw#2914 — both host entities are managed r.entity tables (EXECUTOR_ONLY-
52
+ // branded), so TenantDb's typed write methods reject them; these hooks need
53
+ // raw SQL, still filtered by inserted_by_id + tenant_id.
54
+ const HOST_DELETE_REASON =
55
+ "fw#2914 test fixture: owner-nulling delete/anonymize against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by inserted_by_id + tenant_id";
56
+ const REDACT_REASON =
57
+ "fw#2914 test fixture: jsonb key removal (custom_fields - 'ssn') is a raw SQL expression TenantDb's typed updateMany can't express; filtered by inserted_by_id + tenant_id";
58
+
51
59
  // Owner-nulling host anonymize hook (the canonical "anonymize keeps the row,
52
60
  // clears the owner" pattern — same shape as file-ref/user host hooks).
53
61
  function makeHostDeleteHook(tableName: string): UserDataDeleteHook {
54
62
  return async (ctx, strategy) => {
55
63
  if (strategy === "delete") {
56
- await asRawClient(ctx.db).unsafe(
64
+ await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
57
65
  `DELETE FROM ${tableName} WHERE inserted_by_id = $1 AND tenant_id = $2`,
58
66
  [ctx.userId, ctx.tenantId],
59
67
  );
60
68
  return;
61
69
  }
62
- await asRawClient(ctx.db).unsafe(
70
+ await asRawClient(ctx.db.unsafeRaw(HOST_DELETE_REASON)).unsafe(
63
71
  `UPDATE ${tableName} SET inserted_by_id = NULL WHERE inserted_by_id = $1 AND tenant_id = $2`,
64
72
  [ctx.userId, ctx.tenantId],
65
73
  );
@@ -72,7 +80,7 @@ function makeRedactHook(tableName: string): UserDataDeleteHook {
72
80
  return async (ctx, strategy) => {
73
81
  // skip: delete strategy removes rows wholesale — redaction N/A.
74
82
  if (strategy === "delete") return;
75
- await asRawClient(ctx.db).unsafe(
83
+ await asRawClient(ctx.db.unsafeRaw(REDACT_REASON)).unsafe(
76
84
  `UPDATE ${tableName} SET custom_fields = custom_fields - 'ssn'
77
85
  WHERE inserted_by_id = $1 AND tenant_id = $2`,
78
86
  [ctx.userId, ctx.tenantId],
@@ -119,10 +127,12 @@ const cfFirstFeature = defineFeature(CF_FIRST.featureName, (r) => {
119
127
  export: async () => null,
120
128
  delete: makeRedactHook(CF_FIRST.tableName),
121
129
  order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
130
+ escapeHatch: { reason: REDACT_REASON },
122
131
  });
123
132
  r.useExtension(EXT_USER_DATA, CF_FIRST.entityName, {
124
133
  export: async () => null,
125
134
  delete: makeHostDeleteHook(CF_FIRST.tableName),
135
+ escapeHatch: { reason: HOST_DELETE_REASON },
126
136
  });
127
137
  });
128
138
 
@@ -131,11 +141,13 @@ const hostFirstFeature = defineFeature(HOST_FIRST.featureName, (r) => {
131
141
  r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
132
142
  export: async () => null,
133
143
  delete: makeHostDeleteHook(HOST_FIRST.tableName),
144
+ escapeHatch: { reason: HOST_DELETE_REASON },
134
145
  });
135
146
  r.useExtension(EXT_USER_DATA, HOST_FIRST.entityName, {
136
147
  export: async () => null,
137
148
  delete: makeRedactHook(HOST_FIRST.tableName),
138
149
  order: EXT_USER_DATA_ORDER.REDACT_BEFORE_OWNER,
150
+ escapeHatch: { reason: REDACT_REASON },
139
151
  });
140
152
  });
141
153
 
@@ -15,6 +15,7 @@ import {
15
15
  createTextField,
16
16
  defineFeature,
17
17
  EXT_USER_DATA,
18
+ SYSTEM_TENANT_ID,
18
19
  type UserDataDeleteHook,
19
20
  } from "@cosmicdrift/kumiko-framework/engine";
20
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
@@ -32,7 +33,7 @@ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../use
32
33
  import { createUserDataRightsFeature } from "../feature";
33
34
  import { runForgetCleanup } from "../run-forget-cleanup";
34
35
 
35
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
36
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
36
37
  const TENANT_A = "00000000-0000-4000-8000-0000000000f1";
37
38
  const ALICE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f1";
38
39
  const NOTE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-0000000000f1";
@@ -53,9 +54,15 @@ const noteEntity = createEntity({
53
54
  });
54
55
  const noteTable = buildEntityTable("note", noteEntity);
55
56
 
57
+ // fw#2914 — "note" is a managed r.entity table (EXECUTOR_ONLY-branded), so
58
+ // TenantDb's typed write methods reject it; this delete is raw SQL, still
59
+ // filtered by author_id + tenant_id.
60
+ const HARD_DELETE_NOTE_REASON =
61
+ "fw#2914 test fixture: hard delete against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by author_id + tenant_id";
62
+
56
63
  const hardDeleteNoteHook: UserDataDeleteHook = async (ctx, strategy) => {
57
64
  if (strategy !== "delete") return;
58
- await asRawClient(ctx.db).unsafe(
65
+ await asRawClient(ctx.db.unsafeRaw(HARD_DELETE_NOTE_REASON)).unsafe(
59
66
  `DELETE FROM read_forget_purge_notes WHERE author_id = $1 AND tenant_id = $2`,
60
67
  [ctx.userId, ctx.tenantId],
61
68
  );
@@ -66,6 +73,7 @@ const noteFeature = defineFeature("forget-purge-notes", (r) => {
66
73
  r.useExtension(EXT_USER_DATA, "note", {
67
74
  export: async () => null,
68
75
  delete: hardDeleteNoteHook,
76
+ escapeHatch: { reason: HARD_DELETE_NOTE_REASON },
69
77
  });
70
78
  });
71
79
 
@@ -13,6 +13,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
+ import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
16
17
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
17
18
  import {
18
19
  setupTestStack,
@@ -35,7 +36,7 @@ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defa
35
36
  import { createUserDataRightsFeature } from "../feature";
36
37
 
37
38
  const TENANT_A = "00000000-0000-4000-8000-0000000006a1";
38
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
39
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
39
40
  const USER_ID = "00000000-0000-4000-8000-0000000006b1";
40
41
  const ORIGINAL_EMAIL = "bridge-delete@example.test";
41
42
  const FORGET_JOB = "user-data-rights:job:run-forget-cleanup";
@@ -133,6 +134,7 @@ describe("C6 default mail bridge :: forget cron sends deletion-executed without
133
134
  }),
134
135
  registry: stack.registry,
135
136
  configResolver,
137
+ systemUser: createSystemUser(TENANT_A),
136
138
  log: {
137
139
  info() {},
138
140
  warn() {},
@@ -13,7 +13,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
13
13
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
14
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
16
- import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ createSystemUser,
18
+ type JobContext,
19
+ SYSTEM_USER_ID,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
17
21
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
22
  import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
19
23
  import {
@@ -153,12 +157,13 @@ describe("run-export-jobs cron-context", () => {
153
157
  registry: stack.registry,
154
158
  configResolver,
155
159
  _userId: SYSTEM_USER_ID,
160
+ systemUser: createSystemUser(TENANT),
156
161
  now: getTemporal().Now.instant(),
157
162
  };
158
163
  // Vor dem Fix wirft der Wrapper hier "ctx.config is missing".
159
164
  // jobCtx is a deliberately minimal prod-context replica (missing
160
- // systemUser/log/triggeredBy) — double-cast at this test boundary since
161
- // the handler under test never touches those fields.
165
+ // log/triggeredBy) — double-cast at this test boundary since the
166
+ // handler under test never touches those fields.
162
167
  await job?.handler({}, jobCtx as unknown as JobContext);
163
168
 
164
169
  const [row] = (await selectMany(stack.db, exportJobsTable, { id: jobId })) as Array<{
@@ -20,7 +20,11 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
20
20
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
21
21
  import { InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
22
22
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
23
- import type { JobContext } from "@cosmicdrift/kumiko-framework/engine";
23
+ import {
24
+ createSystemUser,
25
+ type JobContext,
26
+ SYSTEM_TENANT_ID,
27
+ } from "@cosmicdrift/kumiko-framework/engine";
24
28
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
25
29
  import {
26
30
  setupTestStack,
@@ -50,7 +54,7 @@ let stack: TestStack;
50
54
 
51
55
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
52
56
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
53
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
57
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
54
58
 
55
59
  // Deterministische UUIDs fuer Tests — gleiche Helper wie in
56
60
  // user-data-rights-defaults.
@@ -318,6 +322,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
318
322
  }),
319
323
  registry: stack.registry,
320
324
  log,
325
+ systemUser: createSystemUser(TENANT_A),
321
326
  };
322
327
  await job?.handler({}, jobCtx as unknown as JobContext);
323
328
 
@@ -359,6 +364,7 @@ describe("run-forget-cleanup :: registered cron (autonomous Art.17)", () => {
359
364
  unsafeRaw: { reason: "executes overdue Art.17 forget requests across every tenant" },
360
365
  }),
361
366
  registry: stack.registry,
367
+ systemUser: createSystemUser(TENANT_A),
362
368
  log,
363
369
  } as unknown as JobContext);
364
370
  } finally {
@@ -25,7 +25,11 @@ import {
25
25
  isPiiCiphertext,
26
26
  } from "@cosmicdrift/kumiko-framework/crypto";
27
27
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
28
- import { createSystemUser, type UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
28
+ import {
29
+ createSystemUser,
30
+ SYSTEM_TENANT_ID,
31
+ type UserDataHookCtx,
32
+ } from "@cosmicdrift/kumiko-framework/engine";
29
33
  import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
30
34
  import {
31
35
  setupTestStack,
@@ -58,7 +62,7 @@ let stack: TestStack;
58
62
 
59
63
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
60
64
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
61
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
65
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
62
66
 
63
67
  function uuid(suffix: number): string {
64
68
  return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
@@ -498,7 +502,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
498
502
  // in the GDPR bundle. The hook decrypts locally (same as invitations
499
503
  // query) — assert plaintext here so a regression to raw ciphertext fails.
500
504
  const hookCtx: UserDataHookCtx = {
501
- db: stack.db,
505
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
502
506
  registry: stack.registry,
503
507
  tenantId: TENANT_A,
504
508
  userId: ALICE_ID,
@@ -571,7 +575,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
571
575
  // assertion would catch it even though the runUserExport() assertions
572
576
  // below would stay green either way.
573
577
  const hookCtx: UserDataHookCtx = {
574
- db: stack.db,
578
+ db: createTenantDb(stack.db, TENANT_A, "tenant"),
575
579
  registry: stack.registry,
576
580
  tenantId: TENANT_A,
577
581
  userId: ALICE_ID,
@@ -65,11 +65,6 @@ const TABLE = "read_dsgvo_tenant_scoped";
65
65
 
66
66
  // Tenant-scoped contributor with NO per-user column — deletes by tenant only,
67
67
  // and ONLY when this tenant is effectively single-user (mirrors credit).
68
- const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
69
- if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
70
- await asRawClient(ctx.db).unsafe(`DELETE FROM ${TABLE} WHERE tenant_id = $1`, [ctx.tenantId]);
71
- };
72
-
73
68
  const scopedEntity = createEntity({
74
69
  table: TABLE,
75
70
  fields: {
@@ -77,11 +72,26 @@ const scopedEntity = createEntity({
77
72
  },
78
73
  });
79
74
 
75
+ // fw#2914 — "tenant-scoped" is a managed r.entity table (EXECUTOR_ONLY-branded),
76
+ // so TenantDb's typed write methods reject it; this delete is raw SQL, still
77
+ // filtered by tenant_id.
78
+ const TENANT_SCOPED_DELETE_REASON =
79
+ "fw#2914 test fixture: tenant-scoped erasure against a managed entity table needs raw SQL (TenantDb's typed write API rejects EXECUTOR_ONLY tables); filtered by tenant_id";
80
+
81
+ const tenantScopedDeleteHook: UserDataDeleteHook = async (ctx) => {
82
+ if (ctx.tenantModel !== "single-user") return; // shared tenant: erasing would hit co-members
83
+ await asRawClient(ctx.db.unsafeRaw(TENANT_SCOPED_DELETE_REASON)).unsafe(
84
+ `DELETE FROM ${TABLE} WHERE tenant_id = $1`,
85
+ [ctx.tenantId],
86
+ );
87
+ };
88
+
80
89
  const contributorFeature = defineFeature("dsgvo-tenant-scoped", (r) => {
81
90
  r.entity("tenant-scoped", scopedEntity);
82
91
  r.useExtension(EXT_USER_DATA, "tenant-scoped", {
83
92
  export: async () => null,
84
93
  delete: tenantScopedDeleteHook,
94
+ escapeHatch: { reason: TENANT_SCOPED_DELETE_REASON },
85
95
  });
86
96
  });
87
97
 
@@ -610,6 +610,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
610
610
  ...(opts.appExportDownloadUrl !== undefined && {
611
611
  appExportDownloadUrl: opts.appExportDownloadUrl,
612
612
  }),
613
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
614
+ actor: ctx.systemUser.id,
613
615
  });
614
616
  },
615
617
  });
@@ -677,6 +679,8 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
677
679
  handlerName: "user-data-rights:run-forget-cleanup",
678
680
  }),
679
681
  ...(sendDeletionExecutedEmail && { sendDeletionExecutedEmail }),
682
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
683
+ actor: ctx.systemUser.id,
680
684
  });
681
685
 
682
686
  // Persist each incomplete report on the job-run log (#1572). The write