@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
@@ -7,15 +7,22 @@
7
7
  // + `resolveRetentionPolicy`, also kein Drift-Risiko.
8
8
 
9
9
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
10
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
10
+ import type { DbRunner, TenantDb } from "@cosmicdrift/kumiko-framework/db";
11
11
  import type { Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
12
  import { parseRetentionOverrideOrNull } from "./_internal/parse-override";
13
13
  import type { RetentionPresetKey } from "./presets";
14
14
  import { type EffectiveRetentionPolicy, resolveRetentionPolicy } from "./resolver";
15
15
  import { tenantRetentionOverrideTable } from "./schema/tenant-retention-override";
16
16
 
17
+ function isTenantDb(db: DbRunner | TenantDb): db is TenantDb {
18
+ return typeof (db as Partial<TenantDb>).unsafeRaw === "function";
19
+ }
20
+
17
21
  export interface ResolveForTenantArgs {
18
- readonly db: DbRunner;
22
+ // fw#2914 — a EXT_USER_DATA hook's ctx.db is a TenantDb (method-form);
23
+ // the cleanup cron's own db is still a raw DbRunner. Both are valid here —
24
+ // this lookup is a plain tenant-scoped read either way.
25
+ readonly db: DbRunner | TenantDb;
19
26
  readonly registry: Registry;
20
27
  readonly tenantId: TenantId;
21
28
  readonly entityName: string;
@@ -43,10 +50,15 @@ export async function resolveRetentionPolicyForTenant(
43
50
  const overrideRow =
44
51
  args.preloadedOverride !== undefined
45
52
  ? args.preloadedOverride
46
- : ((await fetchOne(args.db, tenantRetentionOverrideTable, {
47
- tenantId: args.tenantId,
48
- entityName: args.entityName,
49
- })) as { config: string | null } | null); // @cast-boundary db-runner
53
+ : isTenantDb(args.db)
54
+ ? ((await args.db.fetchOne<{ config: string | null }>(tenantRetentionOverrideTable, {
55
+ tenantId: args.tenantId,
56
+ entityName: args.entityName,
57
+ })) ?? null)
58
+ : ((await fetchOne(args.db, tenantRetentionOverrideTable, {
59
+ tenantId: args.tenantId,
60
+ entityName: args.entityName,
61
+ })) as { config: string | null } | null); // @cast-boundary db-runner
50
62
 
51
63
  const tenantOverride = parseRetentionOverrideOrNull(
52
64
  overrideRow?.config ?? null,
@@ -8,6 +8,7 @@ export type NotificationPreferenceRow = {
8
8
  readonly enabled: boolean;
9
9
  };
10
10
 
11
+ // @wrapper-known semantic-alias
11
12
  export async function selectNotificationPreferences(
12
13
  db: DbConnection,
13
14
  tenantId: TenantId,
@@ -18,8 +18,7 @@
18
18
  // cross into another tenant's keys (the provider's own list() prefix
19
19
  // already scopes it).
20
20
 
21
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
22
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
21
+ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
23
22
  import {
24
23
  createSystemUser,
25
24
  type StorageProviderDestroyTenantHook,
@@ -34,13 +33,12 @@ import {
34
33
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
35
34
 
36
35
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
37
- const rows = await selectMany<{ id: string }>(ctx.db, fileRefsTable, {
36
+ const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
38
37
  tenantId: ctx.tenantId,
39
38
  });
40
39
  const user = createSystemUser(ctx.tenantId);
41
- const db = createTenantDb(ctx.db, ctx.tenantId, "system");
42
40
  for (const row of rows) {
43
- const result = await crud.forget({ id: row.id }, user, db);
41
+ const result = await crud.forget({ id: row.id }, user, ctx.db);
44
42
  // Executor writes return {isSuccess:false} instead of throwing — a
45
43
  // discarded result would report this destroy stage "succeeded" while the
46
44
  // row (and its PII fileName) survives. Throw so the pipeline's
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
19
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
20
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
20
21
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
22
23
  import {
@@ -402,7 +403,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
402
403
  test("multi-user tenant: no-op, rows survive", async () => {
403
404
  await seedOneFolderWithAssignment();
404
405
  const ctx = {
405
- db: stack.db,
406
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
406
407
  registry: stack.registry,
407
408
  tenantId: admin.tenantId,
408
409
  userId: admin.id,
@@ -417,7 +418,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
417
418
  test("anonymize strategy: no-op even on a single-user tenant", async () => {
418
419
  await seedOneFolderWithAssignment();
419
420
  const ctx = {
420
- db: stack.db,
421
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
421
422
  registry: stack.registry,
422
423
  tenantId: admin.tenantId,
423
424
  userId: admin.id,
@@ -432,7 +433,7 @@ describe("folders-user-data — tenantScopedDelete hooks", () => {
432
433
  test("single-user tenant + delete: rows are purged", async () => {
433
434
  await seedOneFolderWithAssignment();
434
435
  const ctx = {
435
- db: stack.db,
436
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
436
437
  registry: stack.registry,
437
438
  tenantId: admin.tenantId,
438
439
  userId: admin.id,
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
15
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
15
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
17
  import {
@@ -87,7 +88,7 @@ describe("folderAssignmentExportHook", () => {
87
88
  );
88
89
 
89
90
  const snippet = await folderAssignmentExportHook({
90
- db: stack.db,
91
+ db: createTenantDb(stack.db, admin.tenantId, "tenant"),
91
92
  registry: stack.registry,
92
93
  tenantId: admin.tenantId,
93
94
  userId: admin.id,
@@ -4,8 +4,7 @@
4
4
  // Mirrors credit-user-data — standard tenant-scoped pattern, no name-stripping
5
5
  // (a folder name is tenant data, not per-user PII).
6
6
 
7
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
- import { createTenantDb, type EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
9
8
  import {
10
9
  createEntityExecutor,
11
10
  createSystemUser,
@@ -27,9 +26,7 @@ const { table: folderAssignmentTable, executor: folderAssignmentExecutor } = cre
27
26
  // all tenant folders in-app, so bundling them into the user's export is no new
28
27
  // exposure — it gives the data subject the organisation of the loans they work with.
29
28
  export const folderExportHook: UserDataExportHook = async (ctx) => {
30
- const rows = await selectMany<Record<string, unknown>>(ctx.db, folderTable, {
31
- tenantId: ctx.tenantId,
32
- });
29
+ const rows = await ctx.db.selectMany(folderTable, { tenantId: ctx.tenantId });
33
30
  if (rows.length === 0) return null;
34
31
  return { entity: "folder", rows };
35
32
  };
@@ -38,7 +35,7 @@ export const folderAssignmentExportHook: UserDataExportHook = async (ctx) => {
38
35
  // folderAssignmentEntity is softDelete: true — a cleared assignment
39
36
  // (isDeleted: true) is a removed folder membership, not something the GDPR
40
37
  // export should still surface as current data.
41
- const rows = await selectMany<Record<string, unknown>>(ctx.db, folderAssignmentTable, {
38
+ const rows = await ctx.db.selectMany(folderAssignmentTable, {
42
39
  tenantId: ctx.tenantId,
43
40
  isDeleted: false,
44
41
  });
@@ -65,11 +62,9 @@ function tenantScopedDelete(
65
62
  // eventless, so a projection rebuild resurrects the rows. Bounded — forget
66
63
  // only fires for single-user tenants.
67
64
  const systemUser = createSystemUser(ctx.tenantId);
68
- // The executor needs a TenantDb (loadById → db.fetchOne), not the raw ctx.db.
69
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
70
- const rows = await selectMany<{ id: string }>(ctx.db, table, { tenantId: ctx.tenantId });
65
+ const rows = await ctx.db.selectMany<{ id: string }>(table, { tenantId: ctx.tenantId });
71
66
  for (const row of rows) {
72
- await executor.delete({ id: row.id }, systemUser, tdb);
67
+ await executor.delete({ id: row.id }, systemUser, ctx.db);
73
68
  }
74
69
  };
75
70
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
9
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
10
11
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
12
  import {
12
13
  createTestUser,
@@ -50,7 +51,12 @@ describe("formDraftDeleteHook — delete failure handling (#review-batch-fw-1)",
50
51
 
51
52
  await expect(
52
53
  formDraftDeleteHook(
53
- { db: stack.db, registry: stack.registry, tenantId: bob.tenantId, userId: bob.id },
54
+ {
55
+ db: createTenantDb(stack.db, bob.tenantId, "tenant"),
56
+ registry: stack.registry,
57
+ tenantId: bob.tenantId,
58
+ userId: bob.id,
59
+ },
54
60
  "delete",
55
61
  ),
56
62
  ).resolves.toBeUndefined();
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
10
  import {
10
11
  createTestUser,
@@ -52,7 +53,7 @@ describe("formDraftExportHook", () => {
52
53
  );
53
54
 
54
55
  const snippet = await formDraftExportHook({
55
- db: stack.db,
56
+ db: createTenantDb(stack.db, owner.tenantId, "tenant"),
56
57
  registry: stack.registry,
57
58
  tenantId: owner.tenantId,
58
59
  userId: owner.id,
@@ -88,7 +89,7 @@ describe("formDraftExportHook", () => {
88
89
  );
89
90
 
90
91
  const snippet = await formDraftExportHook({
91
- db: stack.db,
92
+ db: createTenantDb(stack.db, tenantATestTenantId, "tenant"),
92
93
  registry: stack.registry,
93
94
  tenantId: tenantATestTenantId,
94
95
  userId: sameIdTenantA.id,
@@ -101,7 +102,7 @@ describe("formDraftExportHook", () => {
101
102
  test("returns null when the user saved no drafts", async () => {
102
103
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
103
104
  const snippet = await formDraftExportHook({
104
- db: stack.db,
105
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
105
106
  registry: stack.registry,
106
107
  tenantId: lurker.tenantId,
107
108
  userId: lurker.id,
@@ -126,7 +127,12 @@ describe("formDraftDeleteHook", () => {
126
127
  );
127
128
 
128
129
  await formDraftDeleteHook(
129
- { db: stack.db, registry: stack.registry, tenantId: alice.tenantId, userId: alice.id },
130
+ {
131
+ db: createTenantDb(stack.db, alice.tenantId, "tenant"),
132
+ registry: stack.registry,
133
+ tenantId: alice.tenantId,
134
+ userId: alice.id,
135
+ },
130
136
  "delete",
131
137
  );
132
138
 
@@ -138,7 +144,12 @@ describe("formDraftDeleteHook", () => {
138
144
  const lurker = createTestUser({ id: 13, roles: ["TenantMember"] });
139
145
  await expect(
140
146
  formDraftDeleteHook(
141
- { db: stack.db, registry: stack.registry, tenantId: lurker.tenantId, userId: lurker.id },
147
+ {
148
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
149
+ registry: stack.registry,
150
+ tenantId: lurker.tenantId,
151
+ userId: lurker.id,
152
+ },
142
153
  "delete",
143
154
  ),
144
155
  ).resolves.toBeUndefined();
@@ -3,8 +3,6 @@
3
3
  // consumers without the user-data-rights pipeline don't pull a hard
4
4
  // dependency.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
- import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
6
  import {
9
7
  createSystemUser,
10
8
  type UserDataDeleteHook,
@@ -13,7 +11,7 @@ import {
13
11
  import { formDraftExecutor, formDraftTable } from "../form-draft";
14
12
 
15
13
  export const formDraftExportHook: UserDataExportHook = async (ctx) => {
16
- const rows = await selectMany<Record<string, unknown>>(ctx.db, formDraftTable, {
14
+ const rows = await ctx.db.selectMany(formDraftTable, {
17
15
  tenantId: ctx.tenantId,
18
16
  ownerId: ctx.userId,
19
17
  });
@@ -37,15 +35,14 @@ export const formDraftExportHook: UserDataExportHook = async (ctx) => {
37
35
  // owners' drafts, tenant model doesn't matter here (unlike folders' single-
38
36
  // user gating: a form-draft row always has exactly one owner already).
39
37
  export const formDraftDeleteHook: UserDataDeleteHook = async (ctx) => {
40
- const rows = await selectMany<{ id: string }>(ctx.db, formDraftTable, {
38
+ const rows = await ctx.db.selectMany<{ id: string }>(formDraftTable, {
41
39
  tenantId: ctx.tenantId,
42
40
  ownerId: ctx.userId,
43
41
  });
44
42
  const systemUser = createSystemUser(ctx.tenantId);
45
- const tdb = createTenantDb(ctx.db, ctx.tenantId, "system");
46
43
  const failures: string[] = [];
47
44
  for (const row of rows) {
48
- const result = await formDraftExecutor.delete({ id: row.id }, systemUser, tdb);
45
+ const result = await formDraftExecutor.delete({ id: row.id }, systemUser, ctx.db);
49
46
  if (!result.isSuccess) {
50
47
  failures.push(`${row.id}: ${result.error.message}`);
51
48
  }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
7
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
8
9
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
11
  import {
@@ -78,7 +79,7 @@ describe("noteEntryExportHook", () => {
78
79
  );
79
80
 
80
81
  const snippet = await noteEntryExportHook({
81
- db: stack.db,
82
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
82
83
  registry: stack.registry,
83
84
  tenantId: author.tenantId,
84
85
  userId: author.id,
@@ -95,7 +96,7 @@ describe("noteEntryExportHook", () => {
95
96
  test("returns null when the user authored no notes", async () => {
96
97
  const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
97
98
  const snippet = await noteEntryExportHook({
98
- db: stack.db,
99
+ db: createTenantDb(stack.db, lurker.tenantId, "tenant"),
99
100
  registry: stack.registry,
100
101
  tenantId: lurker.tenantId,
101
102
  userId: lurker.id,
@@ -107,7 +108,12 @@ describe("noteEntryExportHook", () => {
107
108
  describe("noteEntryDeleteHook", () => {
108
109
  test("is a no-op — resolves without throwing", async () => {
109
110
  const result = await noteEntryDeleteHook(
110
- { db: stack.db, registry: stack.registry, tenantId: author.tenantId, userId: author.id },
111
+ {
112
+ db: createTenantDb(stack.db, author.tenantId, "tenant"),
113
+ registry: stack.registry,
114
+ tenantId: author.tenantId,
115
+ userId: author.id,
116
+ },
111
117
  "delete",
112
118
  );
113
119
  expect(result).toBeUndefined();
@@ -4,7 +4,6 @@
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
6
 
7
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
7
  import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
9
8
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
10
9
  import { resolveRetentionPolicyForTenant } from "../data-retention";
@@ -14,7 +13,7 @@ import { policyToStrategy } from "../user-data-rights";
14
13
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
15
14
  // per-user content, so the export filters by authorId directly.
16
15
  export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
17
- const rows = await selectMany<Record<string, unknown>>(ctx.db, noteEntryTable, {
16
+ const rows = await ctx.db.selectMany(noteEntryTable, {
18
17
  authorId: ctx.userId,
19
18
  });
20
19
  if (rows.length === 0) return null;
@@ -55,7 +54,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
55
54
  // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
56
55
  if (!kms) return;
57
56
 
58
- const mentions = await selectMany<{ noteId: string }>(ctx.db, noteMentionTable, {
57
+ const mentions = await ctx.db.selectMany<{ noteId: string }>(noteMentionTable, {
59
58
  subjectId: ctx.userId,
60
59
  });
61
60
  // skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
@@ -63,7 +62,7 @@ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
63
62
 
64
63
  const noteIds = new Set(mentions.map((m) => m.noteId));
65
64
  for (const noteId of noteIds) {
66
- const note = await fetchOne<{ entityType: string }>(ctx.db, noteEntryTable, { id: noteId });
65
+ const note = await ctx.db.fetchOne<{ entityType: string }>(noteEntryTable, { id: noteId });
67
66
  // Defensive: append-only rows are never hard-deleted, so this shouldn't
68
67
  // happen — but a missing host means no retention policy to consult.
69
68
  if (!note) continue;
@@ -30,6 +30,7 @@ export type CreatePatOptions = {
30
30
  ) => Promise<PatMfaVerifyResult>;
31
31
  };
32
32
 
33
+ // @wrapper-known error-helper
33
34
  function reauthFailed() {
34
35
  return writeFailure(
35
36
  new UnprocessableError(PatErrors.reauthRequired, {
@@ -3,7 +3,6 @@
3
3
  // so apps without the user-data-rights pipeline don't pull a hard dependency.
4
4
  // Mirrors notes-history-user-data: export-only, erasure via crypto-shredding.
5
5
 
6
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
6
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
8
7
  import { userContentEntriesTable } from "../template-resolver";
9
8
 
@@ -11,7 +10,7 @@ import { userContentEntriesTable } from "../template-resolver";
11
10
  // scoping comes from the tenant-scoped ctx.db; a user who belongs to two
12
11
  // tenants gets each tenant's entries from that tenant's forget run.
13
12
  export const userContentExportHook: UserDataExportHook = async (ctx) => {
14
- const rows = await selectMany<Record<string, unknown>>(ctx.db, userContentEntriesTable, {
13
+ const rows = await ctx.db.selectMany(userContentEntriesTable, {
15
14
  ownerId: ctx.userId,
16
15
  });
17
16
  if (rows.length === 0) return null;
@@ -132,6 +132,8 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
132
132
  now: T.Now.instant(),
133
133
  log: (message) => ctx.log?.warn(message),
134
134
  fileProviderResolver: ctx._fileProviderResolver,
135
+ escapeHatchAuditSink: ctx._escapeHatchAuditSink,
136
+ actor: ctx.systemUser.id,
135
137
  });
136
138
  },
137
139
  });
@@ -3,6 +3,7 @@ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
4
4
  import {
5
5
  createSystemUser,
6
+ type EscapeHatchAuditSink,
6
7
  type Registry,
7
8
  type TenantId,
8
9
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -216,6 +217,8 @@ export async function runNextDestructionStage(args: {
216
217
  readonly tenantId: TenantId;
217
218
  readonly log?: (message: string) => void;
218
219
  readonly fileProviderResolver?: FileProviderResolver;
220
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
221
+ readonly actor?: string;
219
222
  }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
220
223
  const events = await loadAggregate(args.db, args.tenantId, args.tenantId);
221
224
  const { completed, abandoned, attemptsByStage } = replayStageState(events);
@@ -238,6 +241,8 @@ export async function runNextDestructionStage(args: {
238
241
  tenantId: args.tenantId,
239
242
  log: args.log,
240
243
  fileProviderResolver: args.fileProviderResolver,
244
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
245
+ actor: args.actor,
241
246
  };
242
247
 
243
248
  let version = lastEventVersion(events);
@@ -304,6 +309,8 @@ export async function runTenantDestructionSweep(args: {
304
309
  readonly now?: Temporal.Instant;
305
310
  readonly log?: (message: string) => void;
306
311
  readonly fileProviderResolver?: FileProviderResolver;
312
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
313
+ readonly actor?: string;
307
314
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
308
315
  const T = getTemporal();
309
316
  const now = args.now ?? T.Now.instant();
@@ -353,6 +360,8 @@ export async function runTenantDestructionSweep(args: {
353
360
  tenantId: row.id as TenantId,
354
361
  log: args.log,
355
362
  fileProviderResolver: args.fileProviderResolver,
363
+ escapeHatchAuditSink: args.escapeHatchAuditSink,
364
+ actor: args.actor,
356
365
  });
357
366
  if (!result.error && !result.halted) advanced++;
358
367
  } catch (err) {
@@ -7,15 +7,19 @@ import {
7
7
  } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  createSystemUser,
10
+ type EscapeHatchAuditSink,
10
11
  EXT_EXTERNAL_RESOURCE,
11
12
  EXT_INFRA_RESOURCE,
12
13
  EXT_SEARCH_ADAPTER,
13
14
  EXT_STORAGE_PROVIDER,
14
15
  EXT_TENANT_DATA,
16
+ extensionUsageEscapeHatchReason,
15
17
  type Registry,
18
+ type TenantDataHookCtx,
16
19
  type TenantId,
17
20
  } from "@cosmicdrift/kumiko-framework/engine";
18
21
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
22
+ import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
19
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
20
24
  import {
21
25
  tenantEntity,
@@ -35,6 +39,11 @@ export type DestructionStageCtx = {
35
39
  // "destroyTenant" hooks) — undefined when no file-provider is wired,
36
40
  // which those hooks must treat as "nothing to clean up", not an error.
37
41
  readonly fileProviderResolver?: FileProviderResolver;
42
+ // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
43
+ // systemUser.id); runTenantDataHooks uses them to attribute+audit any
44
+ // EXT_TENANT_DATA usage's declared escapeHatch.
45
+ readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
46
+ readonly actor?: string;
38
47
  };
39
48
 
40
49
  export type DestructionStage = {
@@ -73,10 +82,25 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
73
82
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
74
83
  for (const usage of usages) {
75
84
  const destroy = usage.options?.["destroy"] as
76
- | ((ctx: DestructionStageCtx) => Promise<void>)
85
+ | ((hookCtx: TenantDataHookCtx) => Promise<void>)
77
86
  | undefined;
78
87
  if (!destroy) continue;
79
- await destroy(ctx);
88
+ const reason = extensionUsageEscapeHatchReason(usage);
89
+ const report = createEscapeHatchReporter({
90
+ handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
91
+ tenantId: ctx.tenantId,
92
+ actor: ctx.actor ?? "system",
93
+ sink: ctx.escapeHatchAuditSink,
94
+ });
95
+ const hookCtx: TenantDataHookCtx = {
96
+ db: createTenantDb(ctx.db, ctx.tenantId, "tenant", undefined, undefined, undefined, {
97
+ unsafeRaw: reason !== undefined ? { reason } : undefined,
98
+ report,
99
+ }),
100
+ registry: ctx.registry,
101
+ tenantId: ctx.tenantId,
102
+ };
103
+ await destroy(hookCtx);
80
104
  }
81
105
  }
82
106
 
@@ -19,6 +19,7 @@ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
19
19
  import {
20
20
  defineFeature,
21
21
  EXT_USER_DATA,
22
+ SYSTEM_TENANT_ID,
22
23
  type UserDataDeleteHook,
23
24
  type UserDataExportHook,
24
25
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -49,7 +50,7 @@ let stack: TestStack;
49
50
 
50
51
  const TENANT_A = "00000000-0000-4000-8000-00000000000a";
51
52
  const TENANT_B = "00000000-0000-4000-8000-00000000000b";
52
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
53
+ const TENANT_SYSTEM = SYSTEM_TENANT_ID;
53
54
 
54
55
  function uuid(suffix: number): string {
55
56
  return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
@@ -77,20 +78,10 @@ const testNotesTable = defineUnmanagedTable({
77
78
  // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
78
79
  // etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
79
80
  const exportNotes: UserDataExportHook = async (ctx) => {
80
- const result = await asRawClient(ctx.db).unsafe(
81
- `
82
- SELECT id, title, body
83
- FROM test_notes
84
- WHERE tenant_id = $1 AND author_id = $2
85
- `,
86
- [ctx.tenantId, ctx.userId],
81
+ const rows = await ctx.db.selectMany<{ id: string; title: string; body: string }>(
82
+ testNotesTable,
83
+ { tenantId: ctx.tenantId, authorId: ctx.userId },
87
84
  );
88
- // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
89
- const rows = ((result as any).rows ?? result) as Array<{
90
- id: string;
91
- title: string;
92
- body: string;
93
- }>;
94
85
  if (rows.length === 0) return null;
95
86
  return {
96
87
  entity: "note",