@cosmicdrift/kumiko-bundled-features 0.288.0 → 0.290.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 (39) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +125 -5
  3. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
  4. package/src/auth-mfa/__tests__/verify.integration.test.ts +6 -2
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -0
  6. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +2 -0
  7. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  8. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +8 -1
  9. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  10. package/src/files-tenant-data/hooks.ts +101 -3
  11. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +2 -0
  12. package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +2 -0
  13. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +2 -0
  14. package/src/managed-pages/handlers/branding.query.ts +1 -0
  15. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  16. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  17. package/src/seo/handlers/seo-config.query.ts +1 -0
  18. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +3 -0
  19. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +4 -0
  20. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  21. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  22. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  23. package/src/tenant-handover/changes.json +8 -0
  24. package/src/tenant-handover/events.ts +26 -0
  25. package/src/tenant-handover/feature.ts +35 -0
  26. package/src/tenant-handover/grant.ts +43 -0
  27. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  28. package/src/tenant-handover/index.ts +9 -0
  29. package/src/tenant-handover/move-entity-graph.ts +228 -0
  30. package/src/tenant-handover/transfer-graph.ts +44 -0
  31. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +108 -0
  32. package/src/tenant-lifecycle/stages.ts +2 -0
  33. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  34. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  35. package/src/user-data-rights/changes.json +6 -0
  36. package/src/user-data-rights/deletion-token.ts +9 -11
  37. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  38. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  39. package/src/user-data-rights/lib/update-user-lifecycle.ts +44 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.288.0",
3
+ "version": "0.290.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>",
@@ -82,6 +82,7 @@
82
82
  "./tenant/web": "./src/tenant/web/index.ts",
83
83
  "./tenant/seeding": "./src/tenant/seeding.ts",
84
84
  "./tenant-lifecycle": "./src/tenant-lifecycle/index.ts",
85
+ "./tenant-handover": "./src/tenant-handover/index.ts",
85
86
  "./tenant/testing": "./src/tenant/testing.ts",
86
87
  "./user": "./src/user/index.ts",
87
88
  "./user/seeding": "./src/user/seeding.ts",
@@ -132,12 +133,12 @@
132
133
  "./workflow-runner": "./src/workflow-runner/index.ts"
133
134
  },
134
135
  "dependencies": {
135
- "@cosmicdrift/kumiko-dispatcher-live": "0.288.0",
136
- "@cosmicdrift/kumiko-framework": "0.288.0",
137
- "@cosmicdrift/kumiko-headless": "0.288.0",
138
- "@cosmicdrift/kumiko-renderer": "0.288.0",
139
- "@cosmicdrift/kumiko-renderer-web": "0.288.0",
140
- "@cosmicdrift/kumiko-types": "0.288.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.290.0",
137
+ "@cosmicdrift/kumiko-framework": "0.290.0",
138
+ "@cosmicdrift/kumiko-headless": "0.290.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.290.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.290.0",
141
+ "@cosmicdrift/kumiko-types": "0.290.0",
141
142
  "@mollie/api-client": "^4.5.0",
142
143
  "@node-rs/argon2": "^2.0.2",
143
144
  "@types/mailparser": "^3.4.6",
@@ -166,8 +167,8 @@
166
167
  ],
167
168
  "devDependencies": {
168
169
  "@testing-library/user-event": "^14.6.1",
169
- "@cosmicdrift/kumiko-locale-de": "0.288.0",
170
- "@cosmicdrift/kumiko-locale-es": "0.288.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.290.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.290.0",
171
172
  "jsqr": "^1.4.0"
172
173
  }
173
174
  }
@@ -104,6 +104,9 @@ const TRIES_UNSAFE_RAW_WRITE_QN = "queryasmemberprobe:query:tries-unsafe-raw-wri
104
104
  const TRIES_READ_WRITE_RESET_QN = "queryasmemberprobe:query:tries-read-write-reset";
105
105
  const NESTED_DB_WRITE_QN = "queryasmemberprobe:query:nested-db-write";
106
106
  const TRIES_SELECT_FOR_UPDATE_QN = "queryasmemberprobe:query:tries-select-for-update";
107
+ const TRIES_COMMIT_THEN_WRITE_QN = "queryasmemberprobe:query:tries-commit-then-write";
108
+ const TRIES_RAW_SELECT_QN = "queryasmemberprobe:query:tries-raw-select";
109
+ const NESTED_RAW_SELECT_QN = "queryasmemberprobe:query:nested-raw-select";
107
110
  const TRIES_QUERY_AS_QN = "queryasmemberprobe:query:tries-query-as";
108
111
  const READ_AS_MEMBER_QUERY_QN = "queryasmemberprobe:query:read-as-member-query";
109
112
  const SYSTEM_SCOPE_READ_AS_MEMBER_QN = "queryasmembersystemscope:write:read-as-member";
@@ -240,6 +243,42 @@ const probeFeature = defineFeature("queryasmemberprobe", (r) => {
240
243
  escapeHatch: { reason: "test: select-for-update probe" },
241
244
  },
242
245
  );
246
+ // Transaction control, not a write: COMMIT ends the read-only scope, the
247
+ // UPDATE afterwards runs in a fresh, writable implicit transaction.
248
+ r.queryHandler(
249
+ "tries-commit-then-write",
250
+ z.object({}),
251
+ async (_query, ctx) => {
252
+ const raw = asRawClient(ctx.db.unsafeRaw("test: commit-then-write probe"));
253
+ await raw.unsafe("COMMIT");
254
+ await raw.unsafe("UPDATE qam_notes SET body = 'tampered'");
255
+ return { ok: true };
256
+ },
257
+ {
258
+ access: { roles: ["Admin", "User"] },
259
+ escapeHatch: { reason: "test: commit-then-write probe" },
260
+ },
261
+ );
262
+ r.queryHandler(
263
+ "tries-raw-select",
264
+ z.object({}),
265
+ async (_query, ctx) => {
266
+ const rows = await asRawClient(ctx.db.unsafeRaw("test: raw select probe")).unsafe(
267
+ "SELECT 1 AS one",
268
+ );
269
+ return { ok: true, count: Array.isArray(rows) ? rows.length : 0 };
270
+ },
271
+ {
272
+ access: { roles: ["Admin", "User"] },
273
+ escapeHatch: { reason: "test: raw select probe" },
274
+ },
275
+ );
276
+ r.queryHandler(
277
+ "nested-raw-select",
278
+ z.object({}),
279
+ async (_query, ctx) => ctx.query(TRIES_RAW_SELECT_QN, {}),
280
+ { access: { roles: ["Admin", "User"] } },
281
+ );
243
282
  r.queryHandler(
244
283
  "tries-query-as",
245
284
  z.object({}),
@@ -524,6 +563,19 @@ async function readAsMemberErr(
524
563
  );
525
564
  }
526
565
 
566
+ // The request-level teardown gate (auth-middleware) answers 410 before a
567
+ // handler runs, so a teardown tenant can only be probed for the dispatcher's
568
+ // own active-membership rejection through the stack's dispatcher.
569
+ async function readAsMemberErrViaDispatcher(userId: string, targetQn: string) {
570
+ const result = await stack.dispatcher.write(
571
+ "queryasmemberprobe:write:read-as-member",
572
+ { userId, targetQn, payload: {} },
573
+ admin,
574
+ );
575
+ if (result.isSuccess) throw new Error("expected read-as-member to fail");
576
+ return result.error;
577
+ }
578
+
527
579
  async function createNote(ownerId: string, body: string): Promise<void> {
528
580
  await stack.http.writeOk("queryasmemberprobe:write:note:create", { ownerId, body }, admin);
529
581
  }
@@ -654,7 +706,7 @@ describe("ctx.queryAsMember — AccessDenied, same generic error for every rejec
654
706
  await addMembership(userId, TENANT_A);
655
707
  await setTenantStatus(TENANT_A, "destroying");
656
708
 
657
- const err = await readAsMemberErr(userId, WHOAMI_QN);
709
+ const err = await readAsMemberErrViaDispatcher(userId, WHOAMI_QN);
658
710
  expect(err.code).toBe("access_denied");
659
711
  expect(err.message).toBe(GENERIC_MESSAGE);
660
712
  expect(err.details).toEqual(GENERIC_DETAILS);
@@ -668,7 +720,7 @@ describe("ctx.queryAsMember — AccessDenied, same generic error for every rejec
668
720
  await addMembership(userId, TENANT_A);
669
721
  await setTenantStatus(TENANT_A, "destroyRequested");
670
722
 
671
- const err = await readAsMemberErr(userId, WHOAMI_QN);
723
+ const err = await readAsMemberErrViaDispatcher(userId, WHOAMI_QN);
672
724
  expect(err.code).toBe("access_denied");
673
725
  expect(err.message).toBe(GENERIC_MESSAGE);
674
726
  expect(err.details).toEqual(GENERIC_DETAILS);
@@ -951,9 +1003,10 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
951
1003
  await addMembership(userId, TENANT_A);
952
1004
  await createNote(userId, "note-1");
953
1005
 
954
- // SQLSTATE 25001, not 25006 — surfaces as internal_error rather than member_resolution_read_only.
1006
+ // Denied at ctx.db.unsafeRaw, before the SET TRANSACTION READ WRITE reaches Postgres.
955
1007
  const err = await readAsMemberErr(userId, TRIES_READ_WRITE_RESET_QN);
956
- expect(err.code).toBe("internal_error");
1008
+ expect(err.code).toBe("access_denied");
1009
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
957
1010
  expect(await readNoteBodies()).toEqual(["note-1"]);
958
1011
  });
959
1012
 
@@ -964,7 +1017,8 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
964
1017
  await createNote(userId, "note-1");
965
1018
 
966
1019
  const err = await readAsMemberViaQueryErr(userId, TRIES_READ_WRITE_RESET_QN);
967
- expect(err.code).toBe("internal_error");
1020
+ expect(err.code).toBe("access_denied");
1021
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
968
1022
  expect(await readNoteBodies()).toEqual(["note-1"]);
969
1023
  });
970
1024
 
@@ -1047,4 +1101,70 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
1047
1101
  expect(errorReason(err.details)).toBe("member_resolution_read_only");
1048
1102
  expect(await readNoteBodies()).toEqual(["note-1"]);
1049
1103
  });
1104
+
1105
+ test("j1) tries-commit-then-write via a write-caller (savepoint path) is denied, notes unchanged", async () => {
1106
+ await createTenant(TENANT_A);
1107
+ const userId = await createUser("committhenwrite-write@example.com", "pw-long-enough-36");
1108
+ await addMembership(userId, TENANT_A);
1109
+ await createNote(userId, "note-1");
1110
+
1111
+ const err = await readAsMemberErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
1112
+ expect(err.code).toBe("access_denied");
1113
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1114
+ expect(await readNoteBodies()).toEqual(["note-1"]);
1115
+ });
1116
+
1117
+ test("j2) tries-commit-then-write via a query-caller (pool path) is denied, notes unchanged", async () => {
1118
+ await createTenant(TENANT_A);
1119
+ const userId = await createUser("committhenwrite-query@example.com", "pw-long-enough-37");
1120
+ await addMembership(userId, TENANT_A);
1121
+ await createNote(userId, "note-1");
1122
+
1123
+ const err = await readAsMemberViaQueryErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
1124
+ expect(err.code).toBe("access_denied");
1125
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1126
+ expect(await readNoteBodies()).toEqual(["note-1"]);
1127
+ });
1128
+
1129
+ test("k1) tries-raw-select via a write-caller is denied — no raw handle, not even for reads", async () => {
1130
+ await createTenant(TENANT_A);
1131
+ const userId = await createUser("rawselect-write@example.com", "pw-long-enough-38");
1132
+ await addMembership(userId, TENANT_A);
1133
+
1134
+ const err = await readAsMemberErr(userId, TRIES_RAW_SELECT_QN);
1135
+ expect(err.code).toBe("access_denied");
1136
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1137
+ });
1138
+
1139
+ test("k2) tries-raw-select via a query-caller (pool path) is denied", async () => {
1140
+ await createTenant(TENANT_A);
1141
+ const userId = await createUser("rawselect-query@example.com", "pw-long-enough-39");
1142
+ await addMembership(userId, TENANT_A);
1143
+
1144
+ const err = await readAsMemberViaQueryErr(userId, TRIES_RAW_SELECT_QN);
1145
+ expect(err.code).toBe("access_denied");
1146
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1147
+ });
1148
+
1149
+ test("l) nested-raw-select: a ctx.query hop inside a member read stays without a raw handle", async () => {
1150
+ await createTenant(TENANT_A);
1151
+ const userId = await createUser("nestedrawselect@example.com", "pw-long-enough-40");
1152
+ await addMembership(userId, TENANT_A);
1153
+
1154
+ const err = await readAsMemberErr(userId, NESTED_RAW_SELECT_QN);
1155
+ expect(err.code).toBe("access_denied");
1156
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1157
+ });
1158
+
1159
+ test("m) tries-raw-select via the normal dispatch path (no queryAsMember) still works", async () => {
1160
+ await createTenant(TENANT_A);
1161
+
1162
+ const result = await stack.http.queryOk<{ ok: boolean; count: number }>(
1163
+ TRIES_RAW_SELECT_QN,
1164
+ {},
1165
+ admin,
1166
+ );
1167
+ expect(result.ok).toBe(true);
1168
+ expect(result.count).toBe(1);
1169
+ });
1050
1170
  });
@@ -14,6 +14,7 @@ export const selfRegistrationStatusQuery = defineQueryHandler({
14
14
  name: "signup-registration-status",
15
15
  schema: z.object({}),
16
16
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
17
+ rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
17
18
  description:
18
19
  "Reports whether self-registration is currently switched on, readable without signing in, so a signup page can hide its own form instead of collecting an address that would be silently ignored.",
19
20
  handler: async (_query, ctx) => {
@@ -375,12 +375,16 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
375
375
  resetTenantLifecycleGateCacheForTests();
376
376
 
377
377
  try {
378
- const err = await stack.http.writeErr(
378
+ // Via the dispatcher, not HTTP: the request-level teardown gate answers
379
+ // 410 before /api/write reaches the handler, and this test asserts the
380
+ // handler's own challenge-token gate.
381
+ const result = await stack.dispatcher.write(
379
382
  AuthMfaHandlers.verify,
380
383
  { challengeToken, code: currentTotpCode(secret) },
381
384
  GUEST,
382
385
  );
383
- expectErrorIncludes(err, "invalid_challenge_token");
386
+ if (result.isSuccess) throw new Error("expected mfa verify to fail");
387
+ expectErrorIncludes(result.error, "invalid_challenge_token");
384
388
  } finally {
385
389
  await asRawClient(stack.db).unsafe(
386
390
  `UPDATE "${tenantTable.tableName}" SET status = $1 WHERE id = $2`,
@@ -44,6 +44,7 @@ import {
44
44
  } from "../../compliance-profiles";
45
45
  import { createConfigFeature } from "../../config";
46
46
  import { createTenantFeature } from "../../tenant/feature";
47
+ import { tenantEntity } from "../../tenant/schema/tenant";
47
48
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
48
49
  import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id";
49
50
  import {
@@ -147,6 +148,7 @@ beforeAll(async () => {
147
148
  // subscriptionsProjectionTable wird von setupTestStack automatisch
148
149
  // gepusht (r.projection mit `table`-Property → auto-push).
149
150
  await createEventsTable(db);
151
+ await unsafeCreateEntityTable(db, tenantEntity);
150
152
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
151
153
  // providerCustomerId/providerSubscriptionId are `personal: "tenant"`
152
154
  // PII-subject fields — no executor wires the KMS automatically here (raw r.projection,
@@ -30,6 +30,7 @@ import {
30
30
  } from "../../compliance-profiles";
31
31
  import { createConfigFeature } from "../../config";
32
32
  import { createTenantFeature } from "../../tenant/feature";
33
+ import { tenantEntity } from "../../tenant/schema/tenant";
33
34
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
35
  import type { TierMap } from "../../tier-engine/compose-app";
35
36
  import { TierEngineHandlers } from "../../tier-engine/constants";
@@ -112,6 +113,7 @@ beforeAll(async () => {
112
113
  ],
113
114
  });
114
115
  await createEventsTable(stack.db);
116
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
115
117
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
116
118
  await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
117
119
  configurePiiSubjectKms(new InMemoryKmsAdapter());
@@ -25,6 +25,7 @@ export const subProcessorsQuery = defineQueryHandler({
25
25
  name: "sub-processors",
26
26
  schema: z.object({}),
27
27
  access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
28
+ rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
28
29
  description:
29
30
  "Returns the publicly readable Kumiko sub-processor list split into active and planned entries, for the GDPR Art. 28(2) disclosure a privacy policy or data-processing agreement links to.",
30
31
  handler: async (): Promise<SubProcessorListResponse> => {
@@ -10,10 +10,16 @@ import {
10
10
  createInMemoryFileProvider,
11
11
  type InMemoryFileProvider,
12
12
  } from "@cosmicdrift/kumiko-framework/files";
13
- import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
13
+ import {
14
+ setupTestStack,
15
+ type TestStack,
16
+ TestUsers,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
14
19
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
15
20
  import { createConfigFeature } from "../../config";
16
21
  import { createTenantFeature } from "../../tenant/feature";
22
+ import { tenantEntity } from "../../tenant/schema/tenant";
17
23
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
18
24
  import { documentIngestFoundationFeature } from "../feature";
19
25
 
@@ -48,6 +54,7 @@ beforeAll(async () => {
48
54
  ],
49
55
  files: { storageProvider: provider },
50
56
  });
57
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
51
58
  });
52
59
 
53
60
  afterAll(async () => {
@@ -12,7 +12,11 @@ import { randomBytes } from "node:crypto";
12
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
13
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
14
  import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
15
- import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createSystemUser,
17
+ type EntityId,
18
+ type TenantId,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
16
20
  import {
17
21
  append,
18
22
  createEventsTable,
@@ -133,6 +137,12 @@ async function seedFileRef(tenantId: TenantId, storageKey: string) {
133
137
  return { id: result.data.id };
134
138
  }
135
139
 
140
+ // Stands in for a tenant-handover claim (kumiko-framework#3035): the row's
141
+ // tenantId moves, the storageKey — and the bytes behind it — do not.
142
+ async function handOverFileRef(fileRefId: EntityId, toTenantId: TenantId): Promise<void> {
143
+ await updateRows(db, fileRefsTable, { tenantId: toTenantId }, { id: String(fileRefId) });
144
+ }
145
+
136
146
  // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
137
147
  // features wired) by seeding the same "destroying" state it would produce —
138
148
  // same pattern as tenant-lifecycle's own poison-pill destroy tests.
@@ -279,4 +289,81 @@ describe("files-tenant-data :: tenant destroy", () => {
279
289
  // forget hook's resolution-failure stance.
280
290
  expect(await provider.exists(originalKeyA)).toBe(true);
281
291
  });
292
+
293
+ // kumiko-framework#3035 — tenant-handover leaves a fileRef row's tenantId
294
+ // pointing at a NEW tenant while its storageKey (and the bytes behind it)
295
+ // stay under the tenant that originally uploaded it.
296
+ describe("after a tenant-handover", () => {
297
+ test("the destination tenant's destroy deletes the handed-over binary even though it lives outside its own prefix", async () => {
298
+ await seedTenant(tenantA);
299
+ await seedTenant(tenantB);
300
+
301
+ const handedOverKey = buildStorageKey(
302
+ tenantA.tenantId,
303
+ "fileRef",
304
+ 1,
305
+ "attachment",
306
+ "photo.jpg",
307
+ "u1",
308
+ );
309
+ const { id: fileRefId } = await seedFileRef(tenantA.tenantId, handedOverKey);
310
+ await provider.write(handedOverKey, new Uint8Array([1]));
311
+
312
+ await handOverFileRef(fileRefId, tenantB.tenantId);
313
+
314
+ await seedDestroyingTenant(tenantB.tenantId);
315
+ const finalStatus = await driveDestructionToCompletion(
316
+ tenantB.tenantId,
317
+ async () => provider,
318
+ );
319
+ expect(finalStatus).toBe("destroyed");
320
+
321
+ const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
322
+ expect(rowsB).toHaveLength(0);
323
+ expect(await provider.exists(handedOverKey)).toBe(false);
324
+ });
325
+
326
+ test("the SOURCE tenant's destroy does not delete a file — or its derivative — a foreign tenant claimed, but still sweeps a true orphan", async () => {
327
+ await seedTenant(tenantA);
328
+ await seedTenant(tenantB);
329
+
330
+ const originalKey = buildStorageKey(
331
+ tenantA.tenantId,
332
+ "fileRef",
333
+ 1,
334
+ "attachment",
335
+ "photo.jpg",
336
+ "u1",
337
+ );
338
+ const derivativeKey = `${originalKey.replace(/\.jpg$/, "")}.medium.jpg`;
339
+ const orphanKey = `${tenantA.tenantId}/some-other-file.bin`;
340
+ const { id: fileRefId } = await seedFileRef(tenantA.tenantId, originalKey);
341
+ await provider.write(originalKey, new Uint8Array([1]));
342
+ await provider.write(derivativeKey, new Uint8Array([2]));
343
+ await provider.write(orphanKey, new Uint8Array([3]));
344
+
345
+ await handOverFileRef(fileRefId, tenantB.tenantId);
346
+
347
+ await seedDestroyingTenant(tenantA.tenantId);
348
+ const finalStatus = await driveDestructionToCompletion(
349
+ tenantA.tenantId,
350
+ async () => provider,
351
+ );
352
+ expect(finalStatus).toBe("destroyed");
353
+
354
+ // A's own app-data stage never even sees this row — by the time it ran,
355
+ // the handover had already moved it to B.
356
+ const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
357
+ expect(rowsB).toHaveLength(1);
358
+
359
+ // The silent-data-loss case the whole handover design exists to
360
+ // prevent: A's storage-prefix sweep must not delete bytes a foreign
361
+ // tenant's surviving row still references — original AND derivative.
362
+ expect(await provider.exists(originalKey)).toBe(true);
363
+ expect(await provider.exists(derivativeKey)).toBe(true);
364
+ // A true orphan (no fileRef row anywhere) is still swept — the
365
+ // exception above is narrow, not a general retreat from the sweep.
366
+ expect(await provider.exists(orphanKey)).toBe(false);
367
+ });
368
+ });
282
369
  });
@@ -8,7 +8,20 @@
8
8
  // every fileRef ROW for the tenant via forget() — rebuild-safe, mirrors
9
9
  // document-ingest-foundation's documentExtractTenantDestroyHook. No
10
10
  // isDeleted filter: tenant-destroy purges trashed rows too, a destroyed
11
- // tenant has no "restore from trash" future.
11
+ // tenant has no "restore from trash" future. ALSO sweeps, per row, any
12
+ // storageKey that does NOT sit under one of this tenant's own
13
+ // tenantStoragePrefixes() — the shape a tenant-handover (kumiko-
14
+ // framework#3035) leaves behind: the fileRef row's tenantId moved to
15
+ // this tenant, but the bytes stayed under the SOURCE tenant's prefix
16
+ // (moving them would mean copying bytes, which the handover design
17
+ // explicitly rejects). Must run here, not in the "files" stage below —
18
+ // that stage's own prefix sweep only reaches keys under ITS tenant's
19
+ // prefixes by construction, and by the time it runs this hook has
20
+ // already forgotten (hard-deleted) the rows it would need to read the
21
+ // storageKey off. Deleted by STEM prefix (storageKeyStemPrefix), not the
22
+ // exact key: a derived variant (thumbnail, resized render) has no
23
+ // `file_refs` row of its own, so only the stem reaches original +
24
+ // derivatives together.
12
25
  // - fileRefStorageDestroyHook ("files" stage, EXT_STORAGE_PROVIDER): wipes
13
26
  // every BINARY (original + derivatives + anything else) under the
14
27
  // tenant's storage prefixes. A full prefix sweep per tenantStoragePrefixes()
@@ -16,29 +29,67 @@
16
29
  // first (see tenantExportPrefix), so this must sweep every known prefix,
17
30
  // stays correct even though the "files" stage runs after "app-data"
18
31
  // already forgot the rows, and can never cross into another tenant's keys
19
- // (the provider's own list() prefix already scopes it).
32
+ // (the provider's own list() prefix already scopes it). Still catches a
33
+ // blob with no fileRef row at all (the orphan case the row-driven sweep
34
+ // above cannot see) — REFINED, not replaced, to skip a key a FOREIGN
35
+ // tenant's fileRef row still references (a tenant-handover, kumiko-
36
+ // framework#3035, leaves the row's tenantId moved but the storageKey
37
+ // under THIS tenant's prefix unchanged). By the time this stage runs,
38
+ // the "app-data" stage (stages.ts DESTRUCTION_STAGES: "app-data" before
39
+ // "files") has already hard-deleted every row THIS tenant owned, so any
40
+ // surviving row a listed key still matches is, by construction, a
41
+ // foreign tenant's — no tenant comparison needed, existence is enough.
42
+ // The exception is computed from the SURVIVING row's storageKeyStemPrefix
43
+ // (mirroring the deletion side above), not the listed key's exact match:
44
+ // a derivative has no fileRef row of its own, so excluding only exact
45
+ // matches would still delete it out from under the tenant that claimed
46
+ // its original. Batched: one existence query per chunk of listed keys,
47
+ // not one query per key.
20
48
 
49
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
21
50
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
22
51
  import {
23
52
  createSystemUser,
24
53
  type StorageProviderDestroyTenantHook,
54
+ type StorageProviderHookCtx,
25
55
  type TenantDataDestroyHook,
56
+ type TenantDataHookCtx,
26
57
  } from "@cosmicdrift/kumiko-framework/engine";
27
58
  import {
28
59
  assertSafeStorageKey,
29
60
  fileRefEntity,
30
61
  fileRefsTable,
62
+ storageKeyStemPrefix,
31
63
  tenantStoragePrefixes,
32
64
  } from "@cosmicdrift/kumiko-framework/files";
33
65
 
34
66
  const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
35
67
 
68
+ // Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
69
+ // — bounds each query instead of one `storage_key = ANY(...)` per listed key,
70
+ // or a single unbounded array for a tenant with a huge prefix.
71
+ const SURVIVOR_CHECK_BATCH_SIZE = 500;
72
+
73
+ function chunk<T>(items: readonly T[], size: number): T[][] {
74
+ const chunks: T[][] = [];
75
+ for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
76
+ return chunks;
77
+ }
78
+
79
+ function isUnderOwnPrefix(storageKey: string, prefixes: readonly string[]): boolean {
80
+ return prefixes.some((prefix) => storageKey.startsWith(prefix));
81
+ }
82
+
36
83
  export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
37
- const rows = await ctx.db.selectMany<{ id: string }>(fileRefsTable, {
84
+ const rows = await ctx.db.selectMany<{ id: string; storageKey: string }>(fileRefsTable, {
38
85
  tenantId: ctx.tenantId,
39
86
  });
87
+ const ownPrefixes = tenantStoragePrefixes(ctx.tenantId);
40
88
  const user = createSystemUser(ctx.tenantId);
41
89
  for (const row of rows) {
90
+ if (!isUnderOwnPrefix(row.storageKey, ownPrefixes)) {
91
+ await deleteHandedOverBinary(ctx, row.storageKey);
92
+ }
42
93
  const result = await crud.forget({ id: row.id }, user, ctx.db);
43
94
  // Executor writes return {isSuccess:false} instead of throwing — a
44
95
  // discarded result would report this destroy stage "succeeded" while the
@@ -52,6 +103,32 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
52
103
  }
53
104
  };
54
105
 
106
+ async function deleteHandedOverBinary(ctx: TenantDataHookCtx, storageKey: string): Promise<void> {
107
+ if (!ctx.fileProviderResolver) {
108
+ // skip: same graceful-degradation stance as fileRefStorageDestroyHook —
109
+ // no provider wired means the binary sweep is skipped, not fail-closed.
110
+ ctx.log?.(
111
+ `[files-tenant-data] no fileProviderResolver wired — handed-over binary for ${storageKey} is NOT deleted`,
112
+ );
113
+ // skip: no provider wired, so there is no store to delete from — logged above.
114
+ return;
115
+ }
116
+ let provider: Awaited<ReturnType<NonNullable<TenantDataHookCtx["fileProviderResolver"]>>>;
117
+ try {
118
+ provider = await ctx.fileProviderResolver(ctx.tenantId);
119
+ } catch (err) {
120
+ ctx.log?.(
121
+ `[files-tenant-data] no file provider resolvable for tenant ${ctx.tenantId}: ${err instanceof Error ? err.message : String(err)} — handed-over binary for ${storageKey} NOT deleted`,
122
+ );
123
+ // skip: provider resolution failed, so there is nothing this call can delete — logged above.
124
+ return;
125
+ }
126
+ for (const key of await provider.list(storageKeyStemPrefix(storageKey))) {
127
+ assertSafeStorageKey(key);
128
+ await provider.delete(key);
129
+ }
130
+ }
131
+
55
132
  export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async (
56
133
  tenantId,
57
134
  ctx,
@@ -86,9 +163,30 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
86
163
  // converges rather than double-deleting or erroring on a missing key).
87
164
  for (const prefix of tenantStoragePrefixes(tenantId)) {
88
165
  const keys = await provider.list(prefix);
166
+ const keptStemPrefixes = await survivorStemPrefixes(ctx.db, keys);
89
167
  for (const key of keys) {
168
+ if (keptStemPrefixes.some((stemPrefix) => key.startsWith(stemPrefix))) continue;
90
169
  assertSafeStorageKey(key);
91
170
  await provider.delete(key);
92
171
  }
93
172
  }
94
173
  };
174
+
175
+ // Any listed key that is itself a surviving row's exact storageKey means a
176
+ // foreign tenant still owns it (see header comment) — its stem prefix then
177
+ // also protects any derivative of that same original sitting in the same
178
+ // listed batch, none of which have their own fileRef row.
179
+ async function survivorStemPrefixes(
180
+ db: StorageProviderHookCtx["db"],
181
+ keys: readonly string[],
182
+ ): Promise<readonly string[]> {
183
+ const stemPrefixes: string[] = [];
184
+ for (const batch of chunk(keys, SURVIVOR_CHECK_BATCH_SIZE)) {
185
+ if (batch.length === 0) continue;
186
+ const survivors = await selectMany<{ storageKey: string }>(db, fileRefsTable, {
187
+ storageKey: batch,
188
+ });
189
+ for (const survivor of survivors) stemPrefixes.push(storageKeyStemPrefix(survivor.storageKey));
190
+ }
191
+ return stemPrefixes;
192
+ }
@@ -46,6 +46,7 @@ import {
46
46
  import { createConfigFeature } from "../../config";
47
47
  import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
48
48
  import { createTenantFeature } from "../../tenant/feature";
49
+ import { tenantEntity } from "../../tenant/schema/tenant";
49
50
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
50
51
  import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id";
51
52
  import { InboundMailFoundationHandlers, InboundMailFoundationQueries } from "../constants";
@@ -83,6 +84,7 @@ beforeAll(async () => {
83
84
  });
84
85
  db = stack.db;
85
86
  await createEventsTable(db);
87
+ await unsafeCreateEntityTable(db, tenantEntity);
86
88
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
87
89
  // Unmanaged direct-write stores — kein r.entity, kein Auto-Push.
88
90
  await unsafeCreateEntityTable(db, syncCursorEntity);
@@ -27,6 +27,7 @@ import {
27
27
  import { createConfigFeature } from "../../config";
28
28
  import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
29
29
  import { createTenantFeature } from "../../tenant/feature";
30
+ import { tenantEntity } from "../../tenant/schema/tenant";
30
31
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
31
32
  import { inboundMessageAggregateId } from "../aggregate-id";
32
33
  import { InboundMailFoundationHandlers } from "../constants";
@@ -51,6 +52,7 @@ beforeAll(async () => {
51
52
  });
52
53
  db = stack.db;
53
54
  await createEventsTable(db);
55
+ await unsafeCreateEntityTable(db, tenantEntity);
54
56
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
55
57
  await unsafeCreateEntityTable(db, syncCursorEntity);
56
58
  await unsafeCreateEntityTable(db, seenMessageEntity);
@@ -33,6 +33,7 @@ import {
33
33
  seedInboundMessage,
34
34
  } from "../../inbound-provider-inmemory/feature";
35
35
  import { createTenantFeature } from "../../tenant/feature";
36
+ import { tenantEntity } from "../../tenant/schema/tenant";
36
37
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
37
38
  import {
38
39
  createInboundMailSupervisor,
@@ -65,6 +66,7 @@ beforeAll(async () => {
65
66
  });
66
67
  db = stack.db;
67
68
  await createEventsTable(db);
69
+ await unsafeCreateEntityTable(db, tenantEntity);
68
70
  await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
69
71
  await unsafeCreateEntityTable(db, syncCursorEntity);
70
72
  await unsafeCreateEntityTable(db, seenMessageEntity);
@@ -19,6 +19,7 @@ export function createBrandingQuery(opts: { readonly allowCustomCss: boolean })
19
19
  name: "branding",
20
20
  schema: z.object({}),
21
21
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
22
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
22
23
  description:
23
24
  "Reads the tenant's public branding values for the rendered page shell, adding the raw custom CSS only when the app opted in and the per-tenant CSS toggle is on; anonymous callers may read it because it dresses public pages.",
24
25
  handler: async (_query, ctx) => {
@@ -16,6 +16,7 @@ export const bySlugQuery = defineQueryHandler({
16
16
  lang: z.string().min(2).max(8),
17
17
  }),
18
18
  access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
19
+ rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
19
20
  description:
20
21
  "Reads the body and metadata of one published managed page of the caller's tenant by slug and language, returning null for drafts and bodyless pages; use it to render a public page, not to load one for editing.",
21
22
  handler: async (query, ctx) => {