@cosmicdrift/kumiko-bundled-features 0.287.0 → 0.289.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 (32) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
  3. package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
  4. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  5. package/src/billing-foundation/changes.json +6 -0
  6. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  7. package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
  8. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
  9. package/src/files-tenant-data/hooks.ts +101 -3
  10. package/src/managed-pages/handlers/branding.query.ts +1 -0
  11. package/src/managed-pages/handlers/by-slug.query.ts +1 -0
  12. package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
  13. package/src/seo/handlers/seo-config.query.ts +1 -0
  14. package/src/template-resolver/handlers/by-slug.query.ts +1 -0
  15. package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
  16. package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
  17. package/src/tenant-handover/changes.json +8 -0
  18. package/src/tenant-handover/events.ts +26 -0
  19. package/src/tenant-handover/feature.ts +35 -0
  20. package/src/tenant-handover/grant.ts +43 -0
  21. package/src/tenant-handover/handlers/claim.write.ts +143 -0
  22. package/src/tenant-handover/index.ts +9 -0
  23. package/src/tenant-handover/move-entity-graph.ts +228 -0
  24. package/src/tenant-handover/transfer-graph.ts +44 -0
  25. package/src/tenant-lifecycle/stages.ts +2 -0
  26. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
  27. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
  28. package/src/user-data-rights/changes.json +6 -0
  29. package/src/user-data-rights/deletion-token.ts +9 -11
  30. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
  31. package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
  32. 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.287.0",
3
+ "version": "0.289.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.287.0",
136
- "@cosmicdrift/kumiko-framework": "0.287.0",
137
- "@cosmicdrift/kumiko-headless": "0.287.0",
138
- "@cosmicdrift/kumiko-renderer": "0.287.0",
139
- "@cosmicdrift/kumiko-renderer-web": "0.287.0",
140
- "@cosmicdrift/kumiko-types": "0.287.0",
136
+ "@cosmicdrift/kumiko-dispatcher-live": "0.289.0",
137
+ "@cosmicdrift/kumiko-framework": "0.289.0",
138
+ "@cosmicdrift/kumiko-headless": "0.289.0",
139
+ "@cosmicdrift/kumiko-renderer": "0.289.0",
140
+ "@cosmicdrift/kumiko-renderer-web": "0.289.0",
141
+ "@cosmicdrift/kumiko-types": "0.289.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.287.0",
170
- "@cosmicdrift/kumiko-locale-es": "0.287.0",
170
+ "@cosmicdrift/kumiko-locale-de": "0.289.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.289.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({}),
@@ -951,9 +990,10 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
951
990
  await addMembership(userId, TENANT_A);
952
991
  await createNote(userId, "note-1");
953
992
 
954
- // SQLSTATE 25001, not 25006 — surfaces as internal_error rather than member_resolution_read_only.
993
+ // Denied at ctx.db.unsafeRaw, before the SET TRANSACTION READ WRITE reaches Postgres.
955
994
  const err = await readAsMemberErr(userId, TRIES_READ_WRITE_RESET_QN);
956
- expect(err.code).toBe("internal_error");
995
+ expect(err.code).toBe("access_denied");
996
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
957
997
  expect(await readNoteBodies()).toEqual(["note-1"]);
958
998
  });
959
999
 
@@ -964,7 +1004,8 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
964
1004
  await createNote(userId, "note-1");
965
1005
 
966
1006
  const err = await readAsMemberViaQueryErr(userId, TRIES_READ_WRITE_RESET_QN);
967
- expect(err.code).toBe("internal_error");
1007
+ expect(err.code).toBe("access_denied");
1008
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
968
1009
  expect(await readNoteBodies()).toEqual(["note-1"]);
969
1010
  });
970
1011
 
@@ -1047,4 +1088,70 @@ describe("ctx.queryAsMember — database-level read-only (READ ONLY transaction)
1047
1088
  expect(errorReason(err.details)).toBe("member_resolution_read_only");
1048
1089
  expect(await readNoteBodies()).toEqual(["note-1"]);
1049
1090
  });
1091
+
1092
+ test("j1) tries-commit-then-write via a write-caller (savepoint path) is denied, notes unchanged", async () => {
1093
+ await createTenant(TENANT_A);
1094
+ const userId = await createUser("committhenwrite-write@example.com", "pw-long-enough-36");
1095
+ await addMembership(userId, TENANT_A);
1096
+ await createNote(userId, "note-1");
1097
+
1098
+ const err = await readAsMemberErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
1099
+ expect(err.code).toBe("access_denied");
1100
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1101
+ expect(await readNoteBodies()).toEqual(["note-1"]);
1102
+ });
1103
+
1104
+ test("j2) tries-commit-then-write via a query-caller (pool path) is denied, notes unchanged", async () => {
1105
+ await createTenant(TENANT_A);
1106
+ const userId = await createUser("committhenwrite-query@example.com", "pw-long-enough-37");
1107
+ await addMembership(userId, TENANT_A);
1108
+ await createNote(userId, "note-1");
1109
+
1110
+ const err = await readAsMemberViaQueryErr(userId, TRIES_COMMIT_THEN_WRITE_QN);
1111
+ expect(err.code).toBe("access_denied");
1112
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1113
+ expect(await readNoteBodies()).toEqual(["note-1"]);
1114
+ });
1115
+
1116
+ test("k1) tries-raw-select via a write-caller is denied — no raw handle, not even for reads", async () => {
1117
+ await createTenant(TENANT_A);
1118
+ const userId = await createUser("rawselect-write@example.com", "pw-long-enough-38");
1119
+ await addMembership(userId, TENANT_A);
1120
+
1121
+ const err = await readAsMemberErr(userId, TRIES_RAW_SELECT_QN);
1122
+ expect(err.code).toBe("access_denied");
1123
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1124
+ });
1125
+
1126
+ test("k2) tries-raw-select via a query-caller (pool path) is denied", async () => {
1127
+ await createTenant(TENANT_A);
1128
+ const userId = await createUser("rawselect-query@example.com", "pw-long-enough-39");
1129
+ await addMembership(userId, TENANT_A);
1130
+
1131
+ const err = await readAsMemberViaQueryErr(userId, TRIES_RAW_SELECT_QN);
1132
+ expect(err.code).toBe("access_denied");
1133
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1134
+ });
1135
+
1136
+ test("l) nested-raw-select: a ctx.query hop inside a member read stays without a raw handle", async () => {
1137
+ await createTenant(TENANT_A);
1138
+ const userId = await createUser("nestedrawselect@example.com", "pw-long-enough-40");
1139
+ await addMembership(userId, TENANT_A);
1140
+
1141
+ const err = await readAsMemberErr(userId, NESTED_RAW_SELECT_QN);
1142
+ expect(err.code).toBe("access_denied");
1143
+ expect(errorReason(err.details)).toBe("member_resolution_read_only");
1144
+ });
1145
+
1146
+ test("m) tries-raw-select via the normal dispatch path (no queryAsMember) still works", async () => {
1147
+ await createTenant(TENANT_A);
1148
+
1149
+ const result = await stack.http.queryOk<{ ok: boolean; count: number }>(
1150
+ TRIES_RAW_SELECT_QN,
1151
+ {},
1152
+ admin,
1153
+ );
1154
+ expect(result.ok).toBe(true);
1155
+ expect(result.count).toBe(1);
1156
+ });
1050
1157
  });
@@ -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) => {
@@ -0,0 +1,186 @@
1
+ // Integration test for createSubscriptionTierSync()'s onSyncError option
2
+ // (#3027). Forces a sync failure via a dispatchSystemWrite stub that fails
3
+ // only the tier-engine handlers (the primary billing-foundation writes run
4
+ // for real against the test DB) — proving that "log" (default) still
5
+ // answers the webhook successfully, while "fail-webhook" surfaces the sync
6
+ // error all the way into the HTTP response.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
10
+ import { buildEntityTable, type EntityTable } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ defineFeature,
13
+ type EntityDefinition,
14
+ type TenantId,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import {
18
+ createTestUser,
19
+ setupTestStack,
20
+ type TestStack,
21
+ testTenantId,
22
+ unsafeCreateEntityTable,
23
+ unsafePushTables,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
26
+ import { Hono } from "hono";
27
+ import {
28
+ createComplianceProfilesFeature,
29
+ tenantComplianceProfileEntity,
30
+ } from "../../compliance-profiles";
31
+ import { createConfigFeature } from "../../config";
32
+ import { createTenantFeature } from "../../tenant/feature";
33
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
34
+ import type { TierMap } from "../../tier-engine/compose-app";
35
+ import { TierEngineHandlers } from "../../tier-engine/constants";
36
+ import { tierAssignmentEntity } from "../../tier-engine/entity";
37
+ import { createTierEngineFeature } from "../../tier-engine/feature";
38
+ import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
39
+ import { billingFoundationFeature } from "../feature";
40
+ import {
41
+ createSubscriptionTierSync,
42
+ SUBSCRIPTION_WEBHOOK_PATH,
43
+ type SystemWriteResult,
44
+ } from "../subscription-tier-sync";
45
+ import type { SubscriptionEvent, SubscriptionProviderPlugin } from "../types";
46
+
47
+ type TestTier = "free" | "pro";
48
+ const isTierName = (v: string): v is TestTier => v === "free" || v === "pro";
49
+ const TEST_TIER_MAP: TierMap<{ readonly maxItems: number }> = {
50
+ free: { features: [], caps: { maxItems: 1 } },
51
+ pro: { features: [], caps: { maxItems: 100 } },
52
+ };
53
+
54
+ const mockProviderFeature = defineFeature("test-mock-tier-sync-provider", (r) => {
55
+ r.requires("billing-foundation");
56
+ const plugin: SubscriptionProviderPlugin = {
57
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody) as SubscriptionEvent,
58
+ };
59
+ r.useExtension("subscriptionProvider", "mock", plugin);
60
+ });
61
+
62
+ // Boundary-cast: SubscriptionTierSyncDeps deliberately erases the entity's
63
+ // concrete field types to EntityDefinition (generic factory), which TS
64
+ // can't structurally widen to on its own.
65
+ const tierAssignmentTable = buildEntityTable(
66
+ "tier-assignment",
67
+ tierAssignmentEntity,
68
+ ) as unknown as EntityTable<EntityDefinition>;
69
+
70
+ let stack: TestStack;
71
+ let forceTierSyncFailure = false;
72
+
73
+ async function dispatchSystemWrite(args: {
74
+ readonly handlerQn: string;
75
+ readonly payload: unknown;
76
+ readonly tenantId: TenantId;
77
+ }): Promise<SystemWriteResult> {
78
+ if (
79
+ forceTierSyncFailure &&
80
+ (args.handlerQn === TierEngineHandlers.create || args.handlerQn === TierEngineHandlers.update)
81
+ ) {
82
+ return {
83
+ isSuccess: false,
84
+ error: { code: "test_forced_failure", message: "forced tier-sync failure" },
85
+ };
86
+ }
87
+ const systemUser = createTestUser({ id: 1, tenantId: args.tenantId, roles: ["SystemAdmin"] });
88
+ const res = await stack.http.write(args.handlerQn, args.payload, systemUser);
89
+ const body = (await res.json()) as {
90
+ isSuccess: boolean;
91
+ data?: unknown;
92
+ error?: { readonly code?: string; readonly message?: string };
93
+ };
94
+ return body.isSuccess
95
+ ? { isSuccess: true, data: body.data }
96
+ : { isSuccess: false, error: body.error };
97
+ }
98
+
99
+ let logApp: Hono;
100
+ let failApp: Hono;
101
+
102
+ beforeAll(async () => {
103
+ stack = await setupTestStack({
104
+ features: [
105
+ createConfigFeature(),
106
+ createTenantFeature(),
107
+ createComplianceProfilesFeature(),
108
+ createTenantLifecycleFeature(),
109
+ billingFoundationFeature,
110
+ createTierEngineFeature({ defaultTier: "free", tierMap: TEST_TIER_MAP }),
111
+ mockProviderFeature,
112
+ ],
113
+ });
114
+ await createEventsTable(stack.db);
115
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
116
+ await unsafePushTables(stack.db, { tier_assignments: tierAssignmentTable });
117
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
118
+
119
+ const sharedDeps = {
120
+ db: stack.db,
121
+ registry: stack.registry,
122
+ dispatchSystemWrite,
123
+ tierAssignmentTable,
124
+ isTierName,
125
+ defaultTier: "free" as const,
126
+ };
127
+
128
+ logApp = new Hono();
129
+ createSubscriptionTierSync<TestTier>(sharedDeps).wireSubscriptionWebhookRoute(logApp);
130
+
131
+ failApp = new Hono();
132
+ createSubscriptionTierSync<TestTier>({
133
+ ...sharedDeps,
134
+ onSyncError: "fail-webhook",
135
+ }).wireSubscriptionWebhookRoute(failApp);
136
+ });
137
+
138
+ afterAll(async () => {
139
+ await stack.cleanup();
140
+ resetPiiSubjectKmsForTests();
141
+ });
142
+
143
+ const webhookPath = SUBSCRIPTION_WEBHOOK_PATH.replace(":providerName", "mock");
144
+
145
+ function buildEvent(tenantId: string, providerEventId: string): SubscriptionEvent {
146
+ return {
147
+ providerEventId,
148
+ providerName: "mock",
149
+ type: SubscriptionEventTypes.created,
150
+ tenantId,
151
+ providerCustomerId: `cus_${providerEventId}`,
152
+ providerSubscriptionId: `sub_${providerEventId}`,
153
+ status: SubscriptionStatuses.active,
154
+ tier: "pro",
155
+ currentPeriodEnd: "2026-06-01T00:00:00Z",
156
+ rawPayload: "{}",
157
+ };
158
+ }
159
+
160
+ describe("createSubscriptionTierSync onSyncError", () => {
161
+ test("default ('log'): a tier-sync failure still returns a successful webhook response", async () => {
162
+ forceTierSyncFailure = true;
163
+ const tenantId = testTenantId(9001);
164
+ const res = await logApp.request(webhookPath, {
165
+ method: "POST",
166
+ body: JSON.stringify(buildEvent(tenantId, "evt_log_1")),
167
+ });
168
+ forceTierSyncFailure = false;
169
+
170
+ expect(res.status).toBe(200);
171
+ });
172
+
173
+ test("'fail-webhook': a tier-sync failure also fails the webhook response", async () => {
174
+ forceTierSyncFailure = true;
175
+ const tenantId = testTenantId(9002);
176
+ const res = await failApp.request(webhookPath, {
177
+ method: "POST",
178
+ body: JSON.stringify(buildEvent(tenantId, "evt_fail_1")),
179
+ });
180
+ forceTierSyncFailure = false;
181
+
182
+ expect(res.status).toBe(500);
183
+ const body = (await res.json()) as { error: { code: string } };
184
+ expect(body.error.code).toBe("subscription_webhook_processing_failed");
185
+ });
186
+ });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.288.0",
4
+ "type": "improvement",
5
+ "title": "add onSyncError option to createSubscriptionTierSync",
6
+ "detail": "`SubscriptionTierSyncDeps<TTier>` gets an optional `onSyncError?: \"log\" | \"fail-webhook\"`\n(default `\"log\"`, matching prior behavior exactly). `\"fail-webhook\"` makes a tier-sync\nfailure also fail the webhook response, so an idempotent-retry caller (whose retry\nno-ops the already-committed primary write) gets another attempt at the sync step."
7
+ },
2
8
  {
3
9
  "version": "0.286.0",
4
10
  "type": "improvement",
@@ -35,6 +35,12 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
35
35
  readonly tierAssignmentTable: EntityTable<EntityDefinition>;
36
36
  readonly isTierName: (value: string) => value is TTier;
37
37
  readonly defaultTier: TTier;
38
+ // "log" (default): sync failure only warns, the webhook still reports
39
+ // success — right when the caller has no idempotent retry to lean on.
40
+ // "fail-webhook": sync failure fails the webhook response too, so an
41
+ // idempotent caller (e.g. Stripe, whose retry re-runs the already-committed
42
+ // primary write as a no-op) gets a second attempt at the sync step itself.
43
+ readonly onSyncError?: "log" | "fail-webhook";
38
44
  };
39
45
 
40
46
  export function effectiveTierFromSubscription<TTier extends string>(
@@ -116,16 +122,15 @@ export function createSubscriptionTierSync<TTier extends string>(
116
122
  tenantId: targetTenantId,
117
123
  });
118
124
  if (!result.isSuccess) return result;
119
- // The primary write already committed — a webhook caller (Stripe/
120
- // PayPal) that sees isSuccess:false here retries the whole event,
121
- // re-running an already-succeeded side effect. Log the tier-sync
122
- // failure instead of masking the primary write's success.
123
125
  const syncError = await syncTierFromSubscription(targetTenantId);
124
126
  if (syncError) {
125
127
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
126
128
  console.warn(
127
129
  `[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
128
130
  );
131
+ if (deps.onSyncError === "fail-webhook") {
132
+ return { isSuccess: false, error: syncError };
133
+ }
129
134
  }
130
135
  return result;
131
136
  },
@@ -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> => {
@@ -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
  });