@cosmicdrift/kumiko-bundled-features 0.288.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.
- package/package.json +10 -9
- package/src/auth-email-password/__tests__/query-as-member.integration.test.ts +110 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +1 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +1 -0
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +88 -1
- package/src/files-tenant-data/hooks.ts +101 -3
- package/src/managed-pages/handlers/branding.query.ts +1 -0
- package/src/managed-pages/handlers/by-slug.query.ts +1 -0
- package/src/managed-pages/handlers/by-tenant-published.query.ts +1 -0
- package/src/seo/handlers/seo-config.query.ts +1 -0
- package/src/template-resolver/handlers/by-slug.query.ts +1 -0
- package/src/template-resolver/handlers/by-tenant.query.ts +1 -0
- package/src/tenant-handover/__tests__/claim.integration.test.ts +337 -0
- package/src/tenant-handover/changes.json +8 -0
- package/src/tenant-handover/events.ts +26 -0
- package/src/tenant-handover/feature.ts +35 -0
- package/src/tenant-handover/grant.ts +43 -0
- package/src/tenant-handover/handlers/claim.write.ts +143 -0
- package/src/tenant-handover/index.ts +9 -0
- package/src/tenant-handover/move-entity-graph.ts +228 -0
- package/src/tenant-handover/transfer-graph.ts +44 -0
- package/src/tenant-lifecycle/stages.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +39 -4
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +1 -0
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/deletion-token.ts +9 -11
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +45 -35
- package/src/user-data-rights/handlers/deletion-grace-period.ts +31 -9
- 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.
|
|
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.
|
|
136
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
170
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
-
//
|
|
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("
|
|
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("
|
|
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) => {
|
|
@@ -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 {
|
|
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
|
+
}
|
|
@@ -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) => {
|
|
@@ -28,6 +28,7 @@ export const byTenantPublishedQuery = defineQueryHandler({
|
|
|
28
28
|
tenantIdOverride: z.string().min(1).optional(),
|
|
29
29
|
}),
|
|
30
30
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
31
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
31
32
|
description:
|
|
32
33
|
"Lists every published page of a tenant with slug, language, title and last-change time but no body; use it to enumerate the public pages for sitemap.xml or llms.txt, rather than by-slug which fetches one page's content.",
|
|
33
34
|
handler: async (query, ctx) => {
|
|
@@ -18,6 +18,7 @@ export const seoConfigQuery = defineQueryHandler({
|
|
|
18
18
|
name: "config",
|
|
19
19
|
schema: z.object({}),
|
|
20
20
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
21
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
21
22
|
description:
|
|
22
23
|
"Reads the tenant's SEO metadata settings — organization name and logo URL, Twitter site handle, LLMs summary and default OG image — as plain strings; use it to inspect what the sitemap, llms.txt and page meta tags will publish.",
|
|
23
24
|
handler: async (_query, ctx) => {
|
|
@@ -28,6 +28,7 @@ export const bySlugQuery = defineQueryHandler({
|
|
|
28
28
|
tenantIdOverride: z.string().min(1).optional(),
|
|
29
29
|
}),
|
|
30
30
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
31
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
31
32
|
description:
|
|
32
33
|
"Reads one text-block of a tenant by slug and locale together with its body and format; it is pinned to the text-block kind so mail templates and AI prompts in the same table stay unreachable through this anonymous-capable path.",
|
|
33
34
|
handler: async (query, ctx) => {
|
|
@@ -33,6 +33,7 @@ export const byTenantQuery = defineQueryHandler({
|
|
|
33
33
|
tenantIdOverride: z.string().min(1).optional(),
|
|
34
34
|
}),
|
|
35
35
|
access: { roles: ["anonymous", "User", "TenantAdmin", "SystemAdmin"] },
|
|
36
|
+
rateLimit: { per: "ip", limit: 60, windowSeconds: 60 },
|
|
36
37
|
description:
|
|
37
38
|
"Lists every text-block of a tenant with slug, locale, title and body so a public content tree can be rendered in one call; use it for the whole sidebar, and by-slug when only one block is needed.",
|
|
38
39
|
handler: async (query, ctx) => {
|