@cosmicdrift/kumiko-bundled-features 0.308.0 → 0.309.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 +9 -9
- package/src/auth-email-password/__tests__/seed-admin-guarded.integration.test.ts +31 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/seeding.ts +16 -3
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +454 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -0
- package/src/data-retention/run-retention-cleanup.ts +312 -5
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +8 -24
- package/src/user/seeding.ts +29 -20
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +8 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.309.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>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.309.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.309.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.309.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.309.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.309.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.309.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.309.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.309.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -179,4 +179,35 @@ describe("seedAdminGuarded (offlot#114)", () => {
|
|
|
179
179
|
expect(await userIds()).toHaveLength(2);
|
|
180
180
|
expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
test("a KMS without a blind-index key aborts instead of seeding a blind duplicate", async () => {
|
|
184
|
+
await seedAdminGuarded(stack.db, seedOptions);
|
|
185
|
+
resetBlindIndexKeyForTests();
|
|
186
|
+
|
|
187
|
+
await expect(seedAdminGuarded(stack.db, seedOptions)).rejects.toThrow(/blind-index key/);
|
|
188
|
+
expect(await userIds()).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("existing-user branch reconciles emailVerified onto the canonical row", async () => {
|
|
192
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
193
|
+
await blankBlindIndex();
|
|
194
|
+
|
|
195
|
+
const second = await seedAdminGuarded(stack.db, { ...seedOptions, emailVerified: true });
|
|
196
|
+
|
|
197
|
+
expect(second.id).toBe(first.id);
|
|
198
|
+
expect(await userIds()).toEqual([first.id]);
|
|
199
|
+
const rows = await selectMany<{ id: string; emailVerified: boolean }>(stack.db, userTable);
|
|
200
|
+
expect(rows.find((r) => r.id === first.id)?.emailVerified).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("without a configured KMS, seeding runs the plaintext path unguarded", async () => {
|
|
204
|
+
resetPiiSubjectKmsForTests();
|
|
205
|
+
resetBlindIndexKeyForTests();
|
|
206
|
+
|
|
207
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
208
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
209
|
+
|
|
210
|
+
expect(second.id).toBe(first.id);
|
|
211
|
+
expect(await userIds()).toEqual([first.id]);
|
|
212
|
+
});
|
|
182
213
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Boot admin seed no longer duplicates the admin when the email blind-index lookup misses (fw#3101)",
|
|
6
|
+
"detail": "runProdApp and runDevApp used to call the unguarded `seedAdmin`, whose idempotency check is a plain email fetchOne that the query layer rewrites into `(email = $plaintext OR email_bidx = $hmac)` once a blind-index key is configured. Under KMS encryption `email` holds per-row ciphertext, so only the bidx arm can ever match — and it misses whenever a row's `email_bidx` is NULL (written before the key existed, or after a subject-key erase), silently inserting a second admin account for the same email. Both boot paths now call `seedAdminGuarded`, which under KMS decrypt-scans the active users for the email and reuses the oldest matching row instead of duplicating it. `seedAdminGuarded` also now aborts with a named error if a PII KMS is configured but no blind-index key is — that combination makes the lookup blind by construction — and reconciles `emailVerified: true` onto the existing row it resolves through the decrypt-scan branch, matching the existing-row reconcile `seedUser` already does on its own idempotency hit."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.307.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import {
|
|
17
|
+
configuredBlindIndexKey,
|
|
17
18
|
configuredPiiSubjectKms,
|
|
18
19
|
decryptPiiFieldValues,
|
|
19
20
|
type LocalKeyKmsAdapter,
|
|
@@ -28,7 +29,7 @@ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenan
|
|
|
28
29
|
// kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
|
|
29
30
|
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
30
31
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
31
|
-
import { seedUser } from "../user/seeding";
|
|
32
|
+
import { reconcileSeededUserEmailVerified, seedUser } from "../user/seeding";
|
|
32
33
|
|
|
33
34
|
// Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
|
|
34
35
|
// Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
|
|
@@ -257,8 +258,8 @@ export async function seedAdmin(
|
|
|
257
258
|
// seedAdminGuarded is a separate, additive boot-seed guard — NOT a change
|
|
258
259
|
// to seedUser/seedAdmin/provisionSignupAccount, which also run on every
|
|
259
260
|
// real self-signup request and can't afford a decrypt-scan fallback there.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
261
|
+
// It's what runProdApp/runDevApp call for their admin boot-seed; keep it
|
|
262
|
+
// off the login/signup hot path.
|
|
262
263
|
|
|
263
264
|
type ActiveUserRow = {
|
|
264
265
|
readonly id: string;
|
|
@@ -332,10 +333,22 @@ export async function seedAdminGuarded(
|
|
|
332
333
|
const kms = configuredPiiSubjectKms();
|
|
333
334
|
if (kms === undefined) return seedAdmin(db, options);
|
|
334
335
|
|
|
336
|
+
// Without a bidx key every row seeded here gets a NULL email_bidx, which
|
|
337
|
+
// neither seedAdmin's lookup nor the partial unique index can ever see.
|
|
338
|
+
if (configuredBlindIndexKey() === undefined) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"seedAdminGuarded: a PII KMS is configured but no blind-index key — the email lookup would be blind and the seed would insert a duplicate admin. Configure KUMIKO_BLIND_INDEX_KEY.",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
335
344
|
const existingId = await findExistingUserIdForEmail(db, kms, options.email);
|
|
336
345
|
if (existingId === undefined) return seedAdmin(db, options);
|
|
337
346
|
|
|
338
347
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
348
|
+
if (options.emailVerified === true) {
|
|
349
|
+
const existingRow = await fetchOne(db, userTable, { id: existingId });
|
|
350
|
+
if (existingRow) await reconcileSeededUserEmailVerified(db, existingRow, by);
|
|
351
|
+
}
|
|
339
352
|
for (const m of options.memberships) {
|
|
340
353
|
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
341
354
|
await seedTenantMembership(db, {
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// kumiko-framework#3089 — hardDelete deletes a row's file/image/files/images
|
|
2
|
+
// bytes (+ derivatives) and fileRef rows, not just the row itself.
|
|
3
|
+
//
|
|
4
|
+
// Verifies the negative cases where a data-loss/leak bug hides:
|
|
5
|
+
// - fresh rows + other rows' files are NEVER touched
|
|
6
|
+
// - a fileRef SHARED with another row of the same entity survives (bytes +
|
|
7
|
+
// row) even though the row that's being purged references it too
|
|
8
|
+
// - a storage-delete failure fails the row closed (row + fileRef survive,
|
|
9
|
+
// skipped "file_delete_failed") and converges on the next run
|
|
10
|
+
// - no file storage wired at all → row stays, skipped "missing_file_storage"
|
|
11
|
+
// - softDelete/anonymize entities with file fields never touch bytes
|
|
12
|
+
// - the registered cron handler forwards ctx.files, not just direct calls
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import {
|
|
18
|
+
createEntity,
|
|
19
|
+
createFilesField,
|
|
20
|
+
createImageField,
|
|
21
|
+
createSystemUser,
|
|
22
|
+
createTextField,
|
|
23
|
+
defineFeature,
|
|
24
|
+
type JobContext,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import {
|
|
27
|
+
createFileContext,
|
|
28
|
+
createInMemoryFileProvider,
|
|
29
|
+
deriveKey,
|
|
30
|
+
type FileStorageProvider,
|
|
31
|
+
fileRefEntity,
|
|
32
|
+
fileRefsTable,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
34
|
+
import {
|
|
35
|
+
setupTestStack,
|
|
36
|
+
type TestStack,
|
|
37
|
+
unsafeCreateEntityTable,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
39
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
40
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
41
|
+
import { createDataRetentionFeature } from "../feature";
|
|
42
|
+
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
43
|
+
|
|
44
|
+
// hardDelete entity with BOTH a single (image) and a plural (files) field —
|
|
45
|
+
// covers the column-value path AND the file_refs-lookup path in one entity.
|
|
46
|
+
const docEntity = createEntity({
|
|
47
|
+
table: "read_c8_doc",
|
|
48
|
+
fields: {
|
|
49
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
50
|
+
cover: createImageField(),
|
|
51
|
+
attachments: createFilesField(),
|
|
52
|
+
},
|
|
53
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// softDelete entity with a file field — the strategy's own path (mark
|
|
57
|
+
// is_deleted) must never touch bytes; only hardDelete does.
|
|
58
|
+
const softDocEntity = createEntity({
|
|
59
|
+
table: "read_c8_soft_doc",
|
|
60
|
+
softDelete: true,
|
|
61
|
+
fields: {
|
|
62
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
63
|
+
cover: createImageField(),
|
|
64
|
+
},
|
|
65
|
+
retention: { keepFor: "30d", strategy: "softDelete" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// anonymize entity with a file field — same guarantee for the anonymize path.
|
|
69
|
+
const anonDocEntity = createEntity({
|
|
70
|
+
table: "read_c8_anon_doc",
|
|
71
|
+
fields: {
|
|
72
|
+
label: {
|
|
73
|
+
...createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
74
|
+
anonymize: () => "[ANONYMIZED]",
|
|
75
|
+
},
|
|
76
|
+
cover: createImageField(),
|
|
77
|
+
},
|
|
78
|
+
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const c8Feature = defineFeature("c8-retention-file-fixtures", (r) => {
|
|
82
|
+
r.entity("c8-doc", docEntity);
|
|
83
|
+
r.entity("c8-soft-doc", softDocEntity);
|
|
84
|
+
r.entity("c8-anon-doc", anonDocEntity);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const noopLogger: JobContext["log"] = {
|
|
88
|
+
info() {},
|
|
89
|
+
warn() {},
|
|
90
|
+
error() {},
|
|
91
|
+
debug() {},
|
|
92
|
+
child() {
|
|
93
|
+
return noopLogger;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const T1 = "33333333-3333-3333-3333-333333333333";
|
|
98
|
+
const T2 = "44444444-4444-4444-4444-444444444444";
|
|
99
|
+
|
|
100
|
+
let stack: TestStack;
|
|
101
|
+
let provider: ReturnType<typeof createInMemoryFileProvider>;
|
|
102
|
+
let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
|
|
103
|
+
let pastIso: string;
|
|
104
|
+
let withinIso: string;
|
|
105
|
+
let fileRefCrud: ReturnType<typeof createEventStoreExecutor>;
|
|
106
|
+
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
provider = createInMemoryFileProvider();
|
|
109
|
+
stack = await setupTestStack({
|
|
110
|
+
features: [createDataRetentionFeature(), c8Feature],
|
|
111
|
+
files: { storageProvider: provider },
|
|
112
|
+
});
|
|
113
|
+
for (const e of [docEntity, softDocEntity, anonDocEntity]) {
|
|
114
|
+
await unsafeCreateEntityTable(stack.db, e);
|
|
115
|
+
}
|
|
116
|
+
fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
117
|
+
now = getTemporal().Now.instant();
|
|
118
|
+
pastIso = now.subtract({ hours: 60 * 24 }).toString();
|
|
119
|
+
withinIso = now.subtract({ hours: 10 * 24 }).toString();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterAll(async () => {
|
|
123
|
+
await stack.cleanup();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function filesCtx(p: FileStorageProvider = provider) {
|
|
127
|
+
return createFileContext(() => Promise.resolve(p));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function seedDoc(
|
|
131
|
+
table: string,
|
|
132
|
+
tenantId: string,
|
|
133
|
+
label: string,
|
|
134
|
+
insertedAtIso: string,
|
|
135
|
+
): Promise<string> {
|
|
136
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
137
|
+
`INSERT INTO ${table} (tenant_id, label, inserted_at) VALUES ($1, $2, $3::timestamptz) RETURNING id`,
|
|
138
|
+
[tenantId, label, insertedAtIso],
|
|
139
|
+
)) as { id: string | number }[];
|
|
140
|
+
return String(rows[0]?.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function setCover(table: string, rowId: string, fileRefId: string | null): Promise<void> {
|
|
144
|
+
await asRawClient(stack.db).unsafe(`UPDATE ${table} SET cover = $1 WHERE id = $2`, [
|
|
145
|
+
fileRefId,
|
|
146
|
+
rowId,
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function seedFileRef(args: {
|
|
151
|
+
readonly tenantId: string;
|
|
152
|
+
readonly entityName: string | null;
|
|
153
|
+
readonly entityId: string | null;
|
|
154
|
+
readonly fieldName: string | null;
|
|
155
|
+
readonly key: string;
|
|
156
|
+
}): Promise<string> {
|
|
157
|
+
await provider.write(args.key, new Uint8Array([1, 2, 3]), "application/octet-stream");
|
|
158
|
+
const tdb = createTenantDb(stack.db, args.tenantId, "system");
|
|
159
|
+
const systemUser = createSystemUser(args.tenantId);
|
|
160
|
+
const id = crypto.randomUUID();
|
|
161
|
+
const res = await fileRefCrud.create(
|
|
162
|
+
{
|
|
163
|
+
id,
|
|
164
|
+
storageKey: args.key,
|
|
165
|
+
fileName: "f.bin",
|
|
166
|
+
mimeType: "application/octet-stream",
|
|
167
|
+
size: 3,
|
|
168
|
+
entityType: args.entityName,
|
|
169
|
+
entityId: args.entityId,
|
|
170
|
+
fieldName: args.fieldName,
|
|
171
|
+
},
|
|
172
|
+
systemUser,
|
|
173
|
+
tdb,
|
|
174
|
+
);
|
|
175
|
+
expect(res.isSuccess).toBe(true);
|
|
176
|
+
return id;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function fileRefExists(id: string): Promise<boolean> {
|
|
180
|
+
const rows = await asRawClient(stack.db).unsafe(`SELECT id FROM file_refs WHERE id = $1`, [id]);
|
|
181
|
+
return (rows as unknown[]).length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function labels(table: string, tenantId: string): Promise<string[]> {
|
|
185
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
186
|
+
`SELECT label FROM ${table} WHERE tenant_id = $1 ORDER BY label`,
|
|
187
|
+
[tenantId],
|
|
188
|
+
)) as { label: string }[];
|
|
189
|
+
return rows.map((r) => r.label);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
beforeEach(async () => {
|
|
193
|
+
for (const t of ["read_c8_doc", "read_c8_soft_doc", "read_c8_anon_doc"]) {
|
|
194
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
195
|
+
}
|
|
196
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM file_refs`);
|
|
197
|
+
provider.clear();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fileRefs", () => {
|
|
201
|
+
test("expired row: bytes + derivatives + fileRefs (single + plural field) gone; fresh + other-tenant untouched", async () => {
|
|
202
|
+
const expiredId = await seedDoc("read_c8_doc", T1, "expired", pastIso);
|
|
203
|
+
const coverKey = "t1/expired/cover/orig.jpg";
|
|
204
|
+
const coverId = await seedFileRef({
|
|
205
|
+
tenantId: T1,
|
|
206
|
+
entityName: "c8-doc",
|
|
207
|
+
entityId: expiredId,
|
|
208
|
+
fieldName: "cover",
|
|
209
|
+
key: coverKey,
|
|
210
|
+
});
|
|
211
|
+
await setCover("read_c8_doc", expiredId, coverId);
|
|
212
|
+
// Derivative-suffix grammar is "<name>-<16 hex chars>" (variantSuffix),
|
|
213
|
+
// not a bare variant name — see isDerivativeKeyOf/DERIVATIVE_SUFFIX_PATTERN.
|
|
214
|
+
const thumbKey = deriveKey(coverKey, "thumb-0123456789abcdef");
|
|
215
|
+
await provider.write(thumbKey, new Uint8Array([9]), "image/jpeg");
|
|
216
|
+
|
|
217
|
+
const attKey = "t1/expired/attachments/a.pdf";
|
|
218
|
+
const attId = await seedFileRef({
|
|
219
|
+
tenantId: T1,
|
|
220
|
+
entityName: "c8-doc",
|
|
221
|
+
entityId: expiredId,
|
|
222
|
+
fieldName: "attachments",
|
|
223
|
+
key: attKey,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const freshId = await seedDoc("read_c8_doc", T1, "fresh", withinIso);
|
|
227
|
+
const freshCoverKey = "t1/fresh/cover/orig.jpg";
|
|
228
|
+
const freshCoverId = await seedFileRef({
|
|
229
|
+
tenantId: T1,
|
|
230
|
+
entityName: "c8-doc",
|
|
231
|
+
entityId: freshId,
|
|
232
|
+
fieldName: "cover",
|
|
233
|
+
key: freshCoverKey,
|
|
234
|
+
});
|
|
235
|
+
await setCover("read_c8_doc", freshId, freshCoverId);
|
|
236
|
+
|
|
237
|
+
const otherTenantId = await seedDoc("read_c8_doc", T2, "expired-t2", pastIso);
|
|
238
|
+
const otherTenantKey = "t2/expired/cover/orig.jpg";
|
|
239
|
+
const otherTenantCoverId = await seedFileRef({
|
|
240
|
+
tenantId: T2,
|
|
241
|
+
entityName: "c8-doc",
|
|
242
|
+
entityId: otherTenantId,
|
|
243
|
+
fieldName: "cover",
|
|
244
|
+
key: otherTenantKey,
|
|
245
|
+
});
|
|
246
|
+
await setCover("read_c8_doc", otherTenantId, otherTenantCoverId);
|
|
247
|
+
|
|
248
|
+
const result = await runRetentionCleanup({
|
|
249
|
+
db: stack.db,
|
|
250
|
+
registry: stack.registry,
|
|
251
|
+
tenantId: T1,
|
|
252
|
+
tenantPreset: null,
|
|
253
|
+
now,
|
|
254
|
+
files: filesCtx(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.hardDeleted).toBe(1);
|
|
258
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["fresh"]);
|
|
259
|
+
|
|
260
|
+
expect(await provider.exists(coverKey)).toBe(false);
|
|
261
|
+
expect(await provider.exists(thumbKey)).toBe(false);
|
|
262
|
+
expect(await provider.exists(attKey)).toBe(false);
|
|
263
|
+
expect(await fileRefExists(coverId)).toBe(false);
|
|
264
|
+
expect(await fileRefExists(attId)).toBe(false);
|
|
265
|
+
|
|
266
|
+
expect(await provider.exists(freshCoverKey)).toBe(true);
|
|
267
|
+
expect(await fileRefExists(freshCoverId)).toBe(true);
|
|
268
|
+
|
|
269
|
+
expect(await provider.exists(otherTenantKey)).toBe(true);
|
|
270
|
+
expect(await fileRefExists(otherTenantCoverId)).toBe(true);
|
|
271
|
+
expect(await labels("read_c8_doc", T2)).toEqual(["expired-t2"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("shared fileRef (same entity, second row) survives even though its owning row is purged", async () => {
|
|
275
|
+
const rowA = await seedDoc("read_c8_doc", T1, "expired-shared", pastIso);
|
|
276
|
+
const rowB = await seedDoc("read_c8_doc", T1, "fresh-shared", withinIso);
|
|
277
|
+
const sharedKey = "t1/shared/cover/orig.jpg";
|
|
278
|
+
const sharedId = await seedFileRef({
|
|
279
|
+
tenantId: T1,
|
|
280
|
+
entityName: "c8-doc",
|
|
281
|
+
entityId: rowA,
|
|
282
|
+
fieldName: "cover",
|
|
283
|
+
key: sharedKey,
|
|
284
|
+
});
|
|
285
|
+
await setCover("read_c8_doc", rowA, sharedId);
|
|
286
|
+
await setCover("read_c8_doc", rowB, sharedId);
|
|
287
|
+
|
|
288
|
+
const result = await runRetentionCleanup({
|
|
289
|
+
db: stack.db,
|
|
290
|
+
registry: stack.registry,
|
|
291
|
+
tenantId: T1,
|
|
292
|
+
tenantPreset: null,
|
|
293
|
+
now,
|
|
294
|
+
files: filesCtx(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result.hardDeleted).toBe(1);
|
|
298
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["fresh-shared"]);
|
|
299
|
+
expect(await provider.exists(sharedKey)).toBe(true);
|
|
300
|
+
expect(await fileRefExists(sharedId)).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("storage-delete failure fails the row closed; skipped + convergence on retry", async () => {
|
|
304
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-fails", pastIso);
|
|
305
|
+
const key = "t1/fails/cover/orig.jpg";
|
|
306
|
+
const fileRefId = await seedFileRef({
|
|
307
|
+
tenantId: T1,
|
|
308
|
+
entityName: "c8-doc",
|
|
309
|
+
entityId: rowId,
|
|
310
|
+
fieldName: "cover",
|
|
311
|
+
key,
|
|
312
|
+
});
|
|
313
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
314
|
+
|
|
315
|
+
const failingProvider: FileStorageProvider = {
|
|
316
|
+
...provider,
|
|
317
|
+
delete: async (k: string) => {
|
|
318
|
+
if (k === key) throw new Error("boom");
|
|
319
|
+
return provider.delete(k);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const failed = await runRetentionCleanup({
|
|
324
|
+
db: stack.db,
|
|
325
|
+
registry: stack.registry,
|
|
326
|
+
tenantId: T1,
|
|
327
|
+
tenantPreset: null,
|
|
328
|
+
now,
|
|
329
|
+
files: filesCtx(failingProvider),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(failed.hardDeleted).toBe(0);
|
|
333
|
+
expect(failed.skipped).toContainEqual({ entityName: "c8-doc", reason: "file_delete_failed" });
|
|
334
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["expired-fails"]);
|
|
335
|
+
expect(await fileRefExists(fileRefId)).toBe(true);
|
|
336
|
+
expect(await provider.exists(key)).toBe(true);
|
|
337
|
+
|
|
338
|
+
const retried = await runRetentionCleanup({
|
|
339
|
+
db: stack.db,
|
|
340
|
+
registry: stack.registry,
|
|
341
|
+
tenantId: T1,
|
|
342
|
+
tenantPreset: null,
|
|
343
|
+
now,
|
|
344
|
+
files: filesCtx(),
|
|
345
|
+
});
|
|
346
|
+
expect(retried.hardDeleted).toBe(1);
|
|
347
|
+
expect(await labels("read_c8_doc", T1)).toEqual([]);
|
|
348
|
+
expect(await fileRefExists(fileRefId)).toBe(false);
|
|
349
|
+
expect(await provider.exists(key)).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("no file storage wired → row stays, skipped missing_file_storage", async () => {
|
|
353
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-no-storage", pastIso);
|
|
354
|
+
const key = "t1/no-storage/cover/orig.jpg";
|
|
355
|
+
const fileRefId = await seedFileRef({
|
|
356
|
+
tenantId: T1,
|
|
357
|
+
entityName: "c8-doc",
|
|
358
|
+
entityId: rowId,
|
|
359
|
+
fieldName: "cover",
|
|
360
|
+
key,
|
|
361
|
+
});
|
|
362
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
363
|
+
|
|
364
|
+
const result = await runRetentionCleanup({
|
|
365
|
+
db: stack.db,
|
|
366
|
+
registry: stack.registry,
|
|
367
|
+
tenantId: T1,
|
|
368
|
+
tenantPreset: null,
|
|
369
|
+
now,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(result.hardDeleted).toBe(0);
|
|
373
|
+
expect(result.skipped).toContainEqual({
|
|
374
|
+
entityName: "c8-doc",
|
|
375
|
+
reason: "missing_file_storage",
|
|
376
|
+
});
|
|
377
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["expired-no-storage"]);
|
|
378
|
+
expect(await fileRefExists(fileRefId)).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("softDelete and anonymize entities with a file field never touch bytes", async () => {
|
|
382
|
+
const softRowId = await seedDoc("read_c8_soft_doc", T1, "soft-expired", pastIso);
|
|
383
|
+
const softKey = "t1/soft/cover/orig.jpg";
|
|
384
|
+
const softFileRefId = await seedFileRef({
|
|
385
|
+
tenantId: T1,
|
|
386
|
+
entityName: "c8-soft-doc",
|
|
387
|
+
entityId: softRowId,
|
|
388
|
+
fieldName: "cover",
|
|
389
|
+
key: softKey,
|
|
390
|
+
});
|
|
391
|
+
await setCover("read_c8_soft_doc", softRowId, softFileRefId);
|
|
392
|
+
|
|
393
|
+
const anonRowId = await seedDoc("read_c8_anon_doc", T1, "anon-expired", pastIso);
|
|
394
|
+
const anonKey = "t1/anon/cover/orig.jpg";
|
|
395
|
+
const anonFileRefId = await seedFileRef({
|
|
396
|
+
tenantId: T1,
|
|
397
|
+
entityName: "c8-anon-doc",
|
|
398
|
+
entityId: anonRowId,
|
|
399
|
+
fieldName: "cover",
|
|
400
|
+
key: anonKey,
|
|
401
|
+
});
|
|
402
|
+
await setCover("read_c8_anon_doc", anonRowId, anonFileRefId);
|
|
403
|
+
|
|
404
|
+
const result = await runRetentionCleanup({
|
|
405
|
+
db: stack.db,
|
|
406
|
+
registry: stack.registry,
|
|
407
|
+
tenantId: T1,
|
|
408
|
+
tenantPreset: null,
|
|
409
|
+
now,
|
|
410
|
+
files: filesCtx(),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(result.softDeleted).toBe(1);
|
|
414
|
+
expect(result.anonymized).toBe(1);
|
|
415
|
+
expect(await provider.exists(softKey)).toBe(true);
|
|
416
|
+
expect(await fileRefExists(softFileRefId)).toBe(true);
|
|
417
|
+
expect(await provider.exists(anonKey)).toBe(true);
|
|
418
|
+
expect(await fileRefExists(anonFileRefId)).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the registered job handler forwards ctx.files — bytes gone after the cron runs", async () => {
|
|
422
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-via-job", pastIso);
|
|
423
|
+
const key = "t1/via-job/cover/orig.jpg";
|
|
424
|
+
const fileRefId = await seedFileRef({
|
|
425
|
+
tenantId: T1,
|
|
426
|
+
entityName: "c8-doc",
|
|
427
|
+
entityId: rowId,
|
|
428
|
+
fieldName: "cover",
|
|
429
|
+
key,
|
|
430
|
+
});
|
|
431
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
432
|
+
|
|
433
|
+
const job = stack.registry.getJob("data-retention:job:retention-cleanup");
|
|
434
|
+
expect(job).toBeDefined();
|
|
435
|
+
if (!job) return;
|
|
436
|
+
|
|
437
|
+
const ctx: JobContext = {
|
|
438
|
+
db: createTenantDb(stack.db, T1, "tenant", undefined, undefined, undefined, {
|
|
439
|
+
unsafeRaw: { reason: "per-tenant fan-out cleanup using raw helpers" },
|
|
440
|
+
}),
|
|
441
|
+
registry: stack.registry,
|
|
442
|
+
systemUser: { id: "system", tenantId: T1, roles: ["all"] },
|
|
443
|
+
log: noopLogger,
|
|
444
|
+
triggeredBy: null,
|
|
445
|
+
files: filesCtx(),
|
|
446
|
+
...bridgeStub(),
|
|
447
|
+
};
|
|
448
|
+
await job.handler({}, ctx);
|
|
449
|
+
|
|
450
|
+
expect(await labels("read_c8_doc", T1)).toEqual([]);
|
|
451
|
+
expect(await fileRefExists(fileRefId)).toBe(false);
|
|
452
|
+
expect(await provider.exists(key)).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "data-retention hardDelete now purges file bytes + fileRef rows, not just the entity row (fw#3089)",
|
|
6
|
+
"migration": "hardDelete previously deleted only the entity row and left every file/image/files/images field's storage bytes and `file_refs` row behind — a DSGVO Art. 17 gap. As of this release, an expired row's un-shared fileRefs (same tenant, bound to this row, not referenced by another row of the same entity) have their storage bytes deleted (including derivatives/thumbnails), then their `file_refs` row, then the entity row itself. A fileRef still referenced by another row, or bound to a different entity/record, is left untouched. Without a resolvable file-storage provider in the retention cron's job context, an entity with pending file deletions is skipped entirely (`skipped` reason `missing_file_storage`) rather than silently dropping the row with orphaned bytes; a storage-delete failure skips the row too (`file_delete_failed`) and retries on the next run. Before upgrading, a consumer relying on hardDelete NOT touching file storage should: list entities with `retention.strategy: \"hardDelete\"` and at least one file/image/files/images field, count rows already past `keepFor` for each, and confirm the referenced files are safe to delete (not needed elsewhere) before the next cron run."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.167.1",
|
|
4
10
|
"type": "fix",
|
|
@@ -109,6 +109,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
109
109
|
tenantId,
|
|
110
110
|
tenantPreset,
|
|
111
111
|
now: T.Now.instant(),
|
|
112
|
+
files: ctx.files,
|
|
112
113
|
});
|
|
113
114
|
if (result.skipped.length > 0) {
|
|
114
115
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for skipped entities
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
// Strategy auf Rows an deren reference-Timestamp aelter als der keepFor-Cutoff
|
|
7
7
|
// ist:
|
|
8
8
|
//
|
|
9
|
-
// - hardDelete → executor.forget pro Row (Event → rebuild-safe hard purge)
|
|
9
|
+
// - hardDelete → executor.forget pro Row (Event → rebuild-safe hard purge).
|
|
10
|
+
// Entities with file/image/files/images fields: bytes first
|
|
11
|
+
// (incl. derivatives), then the fileRef row(s), then the row;
|
|
12
|
+
// shared fileRefs (other owner, other row of the same table)
|
|
13
|
+
// stay untouched.
|
|
10
14
|
// - softDelete → executor.delete pro Row (Event → rebuild-safe soft-delete),
|
|
11
15
|
// nur auf noch-nicht-geloeschte (isDeleted:false-Filter)
|
|
12
16
|
// - anonymize → executor.update pro Row mit den Werten der per-Feld
|
|
@@ -39,14 +43,22 @@ import {
|
|
|
39
43
|
createEventStoreExecutor,
|
|
40
44
|
createTenantDb,
|
|
41
45
|
type DbRunner,
|
|
46
|
+
type TenantDb,
|
|
42
47
|
type WhereObject,
|
|
43
48
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
49
|
+
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
44
50
|
import {
|
|
45
51
|
createSystemUser,
|
|
46
52
|
type EntityId,
|
|
47
53
|
type Registry,
|
|
54
|
+
type SessionUser,
|
|
48
55
|
type TenantId,
|
|
49
56
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
57
|
+
import {
|
|
58
|
+
type FileContext,
|
|
59
|
+
fileRefEntity,
|
|
60
|
+
fileRefsTable,
|
|
61
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
50
62
|
import { computeCutoff, type Instant } from "./keep-for";
|
|
51
63
|
import type { RetentionPresetKey } from "./presets";
|
|
52
64
|
import { resolveRetentionPolicyForTenant } from "./resolve-for-tenant";
|
|
@@ -76,6 +88,10 @@ export interface RunRetentionCleanupArgs {
|
|
|
76
88
|
/** Now-Injection — Tests pinnen den Wert ohne Date-Mock (Pattern keep-for.ts). */
|
|
77
89
|
readonly now: Instant;
|
|
78
90
|
readonly batchLimit?: number;
|
|
91
|
+
/** Storage access for hardDelete entities with file/image/files/images fields.
|
|
92
|
+
* Missing while fileRefs are due → the row stays (skipped "missing_file_storage"),
|
|
93
|
+
* fail-closed instead of orphaning bytes. */
|
|
94
|
+
readonly files?: FileContext;
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
export interface RetentionCleanupSkip {
|
|
@@ -83,7 +99,9 @@ export interface RetentionCleanupSkip {
|
|
|
83
99
|
readonly reason:
|
|
84
100
|
| "missing_reference_column"
|
|
85
101
|
| "missing_softdelete_columns"
|
|
86
|
-
| "missing_anonymize_fields"
|
|
102
|
+
| "missing_anonymize_fields"
|
|
103
|
+
| "missing_file_storage"
|
|
104
|
+
| "file_delete_failed";
|
|
87
105
|
}
|
|
88
106
|
|
|
89
107
|
export interface RunRetentionCleanupResult {
|
|
@@ -113,6 +131,272 @@ async function purgeMatchingRows(
|
|
|
113
131
|
return count;
|
|
114
132
|
}
|
|
115
133
|
|
|
134
|
+
// --- hardDelete + file-fields (kumiko-framework#3089) ---
|
|
135
|
+
|
|
136
|
+
type FileFieldPlan = {
|
|
137
|
+
/** type file|image — stores the fileRef-id directly in the entity column. */
|
|
138
|
+
readonly singleFields: readonly string[];
|
|
139
|
+
/** type files|images — no entity column, resolved via file_refs. */
|
|
140
|
+
readonly multiFields: readonly string[];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const NO_FILE_FIELDS: FileFieldPlan = { singleFields: [], multiFields: [] };
|
|
144
|
+
|
|
145
|
+
// file/image = single-column fields (fileRef-id lives directly in the entity
|
|
146
|
+
// row); files/images = plural, no column, resolved via file_refs. Same set
|
|
147
|
+
// as boot-validator/entity-handler.ts FILE_FIELD_TYPES and engine's
|
|
148
|
+
// isFileField — inlined here rather than importing across the package
|
|
149
|
+
// barrel since this module already needs the single/multi split, not just
|
|
150
|
+
// membership.
|
|
151
|
+
function planFileFields(fields: Readonly<Record<string, unknown>>): FileFieldPlan {
|
|
152
|
+
const singleFields: string[] = [];
|
|
153
|
+
const multiFields: string[] = [];
|
|
154
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
155
|
+
const type = (field as { readonly type?: unknown }).type; // @cast-boundary schema-walk
|
|
156
|
+
if (type === "file" || type === "image") singleFields.push(name);
|
|
157
|
+
else if (type === "files" || type === "images") multiFields.push(name);
|
|
158
|
+
}
|
|
159
|
+
return singleFields.length > 0 || multiFields.length > 0
|
|
160
|
+
? { singleFields, multiFields }
|
|
161
|
+
: NO_FILE_FIELDS;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Every fileRef-id a row's file/image/files/images-fields reference: single
|
|
165
|
+
// fields hold the id in their own column, plural fields have no column and
|
|
166
|
+
// resolve via file_refs (tenantId, entityType, entityId, fieldName).
|
|
167
|
+
async function collectFileRefIds(
|
|
168
|
+
db: DbRunner,
|
|
169
|
+
tenantId: TenantId,
|
|
170
|
+
entityName: string,
|
|
171
|
+
row: Record<string, unknown>,
|
|
172
|
+
plan: FileFieldPlan,
|
|
173
|
+
): Promise<readonly string[]> {
|
|
174
|
+
const ids = new Set<string>();
|
|
175
|
+
for (const field of plan.singleFields) {
|
|
176
|
+
const value = row[field];
|
|
177
|
+
if (typeof value === "string" && value.length > 0) ids.add(value);
|
|
178
|
+
}
|
|
179
|
+
if (plan.multiFields.length > 0) {
|
|
180
|
+
const refRows = await selectMany<{ id: string }>(db, fileRefsTable, {
|
|
181
|
+
tenantId,
|
|
182
|
+
entityType: entityName,
|
|
183
|
+
entityId: String(row["id"]),
|
|
184
|
+
fieldName: { in: plan.multiFields },
|
|
185
|
+
});
|
|
186
|
+
for (const r of refRows) ids.add(r.id);
|
|
187
|
+
}
|
|
188
|
+
return Array.from(ids);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A fileRef is bound to a DIFFERENT entity/record than the row currently
|
|
192
|
+
// being purged — its binding (entityType/entityId), not the row's own
|
|
193
|
+
// single-field column, decides ownership. A single-field value can point at
|
|
194
|
+
// a fileRef uploaded/attached elsewhere; that fileRef's bytes belong to
|
|
195
|
+
// whatever it's actually bound to, not to this row.
|
|
196
|
+
function isFileRefBoundElsewhere(
|
|
197
|
+
fileRef: Record<string, unknown>,
|
|
198
|
+
entityName: string,
|
|
199
|
+
rowId: string,
|
|
200
|
+
): boolean {
|
|
201
|
+
const entityType = fileRef["entityType"];
|
|
202
|
+
const entityId = fileRef["entityId"];
|
|
203
|
+
if (typeof entityType === "string" && entityType !== entityName) return true;
|
|
204
|
+
if (typeof entityId === "string" && entityId !== rowId) return true;
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A single-field fileRef-id can be shared by more than one row of the SAME
|
|
209
|
+
// table (e.g. a stock-photo re-picked across records). Purging its bytes
|
|
210
|
+
// would corrupt the row that isn't being deleted. Bounded by batchLimit rows
|
|
211
|
+
// per run and singleFields.length queries per fileRef — the same per-row
|
|
212
|
+
// event-cost tradeoff as the rest of this cron (see header).
|
|
213
|
+
async function isFileRefSharedByOtherRow(
|
|
214
|
+
db: DbRunner,
|
|
215
|
+
table: Parameters<typeof selectMany>[1],
|
|
216
|
+
tableHasTenantId: boolean,
|
|
217
|
+
tenantId: TenantId,
|
|
218
|
+
rowId: string,
|
|
219
|
+
singleFields: readonly string[],
|
|
220
|
+
fileRefId: string,
|
|
221
|
+
): Promise<boolean> {
|
|
222
|
+
for (const field of singleFields) {
|
|
223
|
+
const where: WhereObject = { [field]: fileRefId, id: { ne: rowId } };
|
|
224
|
+
if (tableHasTenantId) where["tenantId"] = tenantId;
|
|
225
|
+
const others = await selectMany(db, table, where, { limit: 1 });
|
|
226
|
+
if (others.length > 0) return true;
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// hardDelete for ONE row of an entity that has file/image/files/images
|
|
232
|
+
// fields: delete the un-shared fileRefs' bytes (+ derivatives) first, then
|
|
233
|
+
// their fileRef rows, then the entity row itself — bytes before rows so a
|
|
234
|
+
// crash mid-way leaves an orphaned fileRef pointing at already-gone bytes
|
|
235
|
+
// (safe: a later run's provider.delete is idempotent) rather than a gone
|
|
236
|
+
// fileRef whose bytes leak forever.
|
|
237
|
+
async function purgeHardDeleteRowWithFiles(args: {
|
|
238
|
+
readonly db: DbRunner;
|
|
239
|
+
readonly table: Parameters<typeof selectMany>[1];
|
|
240
|
+
readonly tableHasTenantId: boolean;
|
|
241
|
+
readonly tenantId: TenantId;
|
|
242
|
+
readonly entityName: string;
|
|
243
|
+
readonly row: Record<string, unknown>;
|
|
244
|
+
readonly plan: FileFieldPlan;
|
|
245
|
+
readonly files: FileContext | undefined;
|
|
246
|
+
readonly entityExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
247
|
+
readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
248
|
+
readonly systemUser: SessionUser;
|
|
249
|
+
readonly tdb: TenantDb;
|
|
250
|
+
readonly onSkip: (reason: "missing_file_storage" | "file_delete_failed") => void;
|
|
251
|
+
}): Promise<boolean> {
|
|
252
|
+
const rowId = String(args.row["id"]);
|
|
253
|
+
const fileRefIds = await collectFileRefIds(
|
|
254
|
+
args.db,
|
|
255
|
+
args.tenantId,
|
|
256
|
+
args.entityName,
|
|
257
|
+
args.row,
|
|
258
|
+
args.plan,
|
|
259
|
+
);
|
|
260
|
+
if (fileRefIds.length === 0) {
|
|
261
|
+
return (
|
|
262
|
+
await args.entityExecutor.forget(
|
|
263
|
+
{ id: args.row["id"] as EntityId },
|
|
264
|
+
args.systemUser,
|
|
265
|
+
args.tdb,
|
|
266
|
+
)
|
|
267
|
+
).isSuccess;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const fileRefRows = await selectMany<Record<string, unknown>>(args.db, fileRefsTable, {
|
|
271
|
+
id: { in: fileRefIds },
|
|
272
|
+
tenantId: args.tenantId,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const toDelete: Record<string, unknown>[] = [];
|
|
276
|
+
for (const fileRef of fileRefRows) {
|
|
277
|
+
if (isFileRefBoundElsewhere(fileRef, args.entityName, rowId)) continue;
|
|
278
|
+
const shared = await isFileRefSharedByOtherRow(
|
|
279
|
+
args.db,
|
|
280
|
+
args.table,
|
|
281
|
+
args.tableHasTenantId,
|
|
282
|
+
args.tenantId,
|
|
283
|
+
rowId,
|
|
284
|
+
args.plan.singleFields,
|
|
285
|
+
String(fileRef["id"]),
|
|
286
|
+
);
|
|
287
|
+
if (shared) continue;
|
|
288
|
+
toDelete.push(fileRef);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (toDelete.length === 0) {
|
|
292
|
+
return (
|
|
293
|
+
await args.entityExecutor.forget(
|
|
294
|
+
{ id: args.row["id"] as EntityId },
|
|
295
|
+
args.systemUser,
|
|
296
|
+
args.tdb,
|
|
297
|
+
)
|
|
298
|
+
).isSuccess;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!args.files) {
|
|
302
|
+
args.onSkip("missing_file_storage");
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
const files = args.files;
|
|
306
|
+
const store = { list: files.list, delete: (key: string) => files.ref(key).delete() };
|
|
307
|
+
|
|
308
|
+
let anyFailed = false;
|
|
309
|
+
for (const fileRef of toDelete) {
|
|
310
|
+
const key = fileRef["storageKey"];
|
|
311
|
+
if (typeof key !== "string" || key.length === 0) continue;
|
|
312
|
+
const failedKeys = await deleteStoredFileAndDerivatives(
|
|
313
|
+
key,
|
|
314
|
+
store,
|
|
315
|
+
"data-retention:hardDelete",
|
|
316
|
+
);
|
|
317
|
+
if (failedKeys.length > 0) {
|
|
318
|
+
anyFailed = true;
|
|
319
|
+
// biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
|
|
320
|
+
console.warn(
|
|
321
|
+
`[data-retention:hardDelete] tenant=${args.tenantId} entity=${args.entityName} row=${rowId} storage delete failed keys=${failedKeys.join(",")}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (anyFailed) {
|
|
326
|
+
args.onSkip("file_delete_failed");
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const fileRef of toDelete) {
|
|
331
|
+
const res = await args.fileRefExecutor.forget(
|
|
332
|
+
{ id: fileRef["id"] as EntityId },
|
|
333
|
+
args.systemUser,
|
|
334
|
+
args.tdb,
|
|
335
|
+
);
|
|
336
|
+
if (!res.isSuccess) {
|
|
337
|
+
args.onSkip("file_delete_failed");
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
await args.entityExecutor.forget({ id: args.row["id"] as EntityId }, args.systemUser, args.tdb)
|
|
344
|
+
).isSuccess;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// hardDelete page for an entity WITH file/image/files/images fields — loads
|
|
348
|
+
// full rows (not just id) so file-field values are available, and reports
|
|
349
|
+
// each skip reason at most once per entity per run (repeat skips would just
|
|
350
|
+
// be noise; the per-row failure is already warned above).
|
|
351
|
+
async function purgeHardDeleteRowsWithFiles(args: {
|
|
352
|
+
readonly db: DbRunner;
|
|
353
|
+
readonly table: Parameters<typeof selectMany>[1];
|
|
354
|
+
readonly tableHasTenantId: boolean;
|
|
355
|
+
readonly where: WhereObject;
|
|
356
|
+
readonly batchLimit: number;
|
|
357
|
+
readonly tenantId: TenantId;
|
|
358
|
+
readonly entityName: string;
|
|
359
|
+
readonly plan: FileFieldPlan;
|
|
360
|
+
readonly files: FileContext | undefined;
|
|
361
|
+
readonly entityExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
362
|
+
readonly fileRefExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
363
|
+
readonly systemUser: SessionUser;
|
|
364
|
+
readonly tdb: TenantDb;
|
|
365
|
+
readonly skipped: RetentionCleanupSkip[];
|
|
366
|
+
}): Promise<number> {
|
|
367
|
+
const rows = await selectMany<Record<string, unknown>>(args.db, args.table, args.where, {
|
|
368
|
+
limit: args.batchLimit,
|
|
369
|
+
});
|
|
370
|
+
const reasonsSeen = new Set<RetentionCleanupSkip["reason"]>();
|
|
371
|
+
const onSkip = (reason: "missing_file_storage" | "file_delete_failed") => {
|
|
372
|
+
// skip: reason already reported for this entity in this run
|
|
373
|
+
if (reasonsSeen.has(reason)) return;
|
|
374
|
+
reasonsSeen.add(reason);
|
|
375
|
+
args.skipped.push({ entityName: args.entityName, reason });
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
let count = 0;
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
const ok = await purgeHardDeleteRowWithFiles({
|
|
381
|
+
db: args.db,
|
|
382
|
+
table: args.table,
|
|
383
|
+
tableHasTenantId: args.tableHasTenantId,
|
|
384
|
+
tenantId: args.tenantId,
|
|
385
|
+
entityName: args.entityName,
|
|
386
|
+
row,
|
|
387
|
+
plan: args.plan,
|
|
388
|
+
files: args.files,
|
|
389
|
+
entityExecutor: args.entityExecutor,
|
|
390
|
+
fileRefExecutor: args.fileRefExecutor,
|
|
391
|
+
systemUser: args.systemUser,
|
|
392
|
+
tdb: args.tdb,
|
|
393
|
+
onSkip,
|
|
394
|
+
});
|
|
395
|
+
if (ok) count++;
|
|
396
|
+
}
|
|
397
|
+
return count;
|
|
398
|
+
}
|
|
399
|
+
|
|
116
400
|
// PiiAnnotations.anonymize — structural view, avoids reaching into the
|
|
117
401
|
// engine's field-type internals from here.
|
|
118
402
|
type AnonymizeCapableField = { readonly anonymize?: () => unknown | Promise<unknown> };
|
|
@@ -244,9 +528,32 @@ export async function runRetentionCleanup(
|
|
|
244
528
|
switch (policy.strategy) {
|
|
245
529
|
case "hardDelete": {
|
|
246
530
|
const executor = createEventStoreExecutor(proj.table, entity, { entityName });
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
531
|
+
const filePlan = planFileFields(entity.fields);
|
|
532
|
+
if (filePlan.singleFields.length === 0 && filePlan.multiFields.length === 0) {
|
|
533
|
+
hardDeleted += await purgeMatchingRows(db, proj.table, where, batchLimit, (id) =>
|
|
534
|
+
executor.forget({ id }, systemUser, tdb),
|
|
535
|
+
);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
const fileRefExecutor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
539
|
+
entityName: "fileRef",
|
|
540
|
+
});
|
|
541
|
+
hardDeleted += await purgeHardDeleteRowsWithFiles({
|
|
542
|
+
db,
|
|
543
|
+
table: proj.table,
|
|
544
|
+
tableHasTenantId: table["tenantId"] !== undefined,
|
|
545
|
+
where,
|
|
546
|
+
batchLimit,
|
|
547
|
+
tenantId,
|
|
548
|
+
entityName,
|
|
549
|
+
plan: filePlan,
|
|
550
|
+
files: args.files,
|
|
551
|
+
entityExecutor: executor,
|
|
552
|
+
fileRefExecutor,
|
|
553
|
+
systemUser,
|
|
554
|
+
tdb,
|
|
555
|
+
skipped,
|
|
556
|
+
});
|
|
250
557
|
break;
|
|
251
558
|
}
|
|
252
559
|
case "softDelete": {
|
|
@@ -16,8 +16,6 @@ import {
|
|
|
16
16
|
type TestStack,
|
|
17
17
|
TestUsers,
|
|
18
18
|
testTenantId,
|
|
19
|
-
unsafeCreateEntityTable,
|
|
20
|
-
unsafePushTables,
|
|
21
19
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
20
|
import {
|
|
23
21
|
createTestEnvelopeCipher,
|
|
@@ -27,18 +25,16 @@ import {
|
|
|
27
25
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
28
26
|
import {
|
|
29
27
|
createComplianceProfilesFeature,
|
|
30
|
-
tenantComplianceProfileEntity,
|
|
31
28
|
tenantComplianceProfileTable,
|
|
32
29
|
} from "../../compliance-profiles";
|
|
33
30
|
import { createConfigFeature } from "../../config";
|
|
34
31
|
import { createConfigResolver } from "../../config/resolver";
|
|
35
|
-
import { configValuesTable } from "../../config/table";
|
|
36
32
|
import { createSessionsFeature } from "../../sessions";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
33
|
+
import { userSessionTable } from "../../sessions/schema/user-session";
|
|
34
|
+
import { tenantMembershipsTable } from "../../tenant";
|
|
39
35
|
import { TenantHandlers, TenantQueries } from "../../tenant/constants";
|
|
40
36
|
import { createTenantFeature } from "../../tenant/feature";
|
|
41
|
-
import {
|
|
37
|
+
import { tenantTable } from "../../tenant/schema/tenant";
|
|
42
38
|
import { seedTenantMembership } from "../../tenant/testing";
|
|
43
39
|
import { createUserFeature } from "../../user/feature";
|
|
44
40
|
import {
|
|
@@ -85,12 +81,6 @@ beforeAll(async () => {
|
|
|
85
81
|
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
86
82
|
});
|
|
87
83
|
db = stack.db;
|
|
88
|
-
|
|
89
|
-
await unsafeCreateEntityTable(db, tenantEntity);
|
|
90
|
-
await unsafeCreateEntityTable(db, userSessionEntity);
|
|
91
|
-
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
92
|
-
await unsafeCreateEntityTable(db, tenantMembershipEntity);
|
|
93
|
-
await unsafePushTables(db, { configValuesTable });
|
|
94
84
|
});
|
|
95
85
|
|
|
96
86
|
afterAll(async () => {
|
|
@@ -175,6 +165,11 @@ describe("tenant-lifecycle :: request / cancel / 410 gate", () => {
|
|
|
175
165
|
const err = await stack.http.writeErr(REQUEST, {}, member);
|
|
176
166
|
expect(err.httpStatus).toBe(403);
|
|
177
167
|
});
|
|
168
|
+
|
|
169
|
+
test("authenticated read against an unseeded tenant doesn't 500 on a missing read_tenants table (fw#3102)", async () => {
|
|
170
|
+
const res = await stack.http.query(TenantQueries.me, {}, tenantAdmin);
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
});
|
|
178
173
|
});
|
|
179
174
|
|
|
180
175
|
function createPoisonTenantDataFeature() {
|
|
@@ -210,9 +205,6 @@ describe("tenant-lifecycle :: pipeline abandon / destroyFailed", () => {
|
|
|
210
205
|
},
|
|
211
206
|
} as import("@cosmicdrift/kumiko-framework/api").AuthRoutesConfig,
|
|
212
207
|
});
|
|
213
|
-
await unsafeCreateEntityTable(poisonStack.db, tenantEntity);
|
|
214
|
-
await unsafeCreateEntityTable(poisonStack.db, tenantComplianceProfileEntity);
|
|
215
|
-
await unsafePushTables(poisonStack.db, { configValuesTable });
|
|
216
208
|
});
|
|
217
209
|
|
|
218
210
|
afterAll(async () => {
|
|
@@ -416,9 +408,6 @@ describe("tenant-lifecycle :: sweep isolates one tenant's failure from another's
|
|
|
416
408
|
],
|
|
417
409
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
418
410
|
});
|
|
419
|
-
await unsafeCreateEntityTable(isolationStack.db, tenantEntity);
|
|
420
|
-
await unsafeCreateEntityTable(isolationStack.db, tenantComplianceProfileEntity);
|
|
421
|
-
await unsafePushTables(isolationStack.db, { configValuesTable });
|
|
422
411
|
});
|
|
423
412
|
|
|
424
413
|
afterAll(async () => {
|
|
@@ -483,11 +472,6 @@ describe("tenant-lifecycle :: 410 gate derived from the mounted provider", () =>
|
|
|
483
472
|
],
|
|
484
473
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
485
474
|
});
|
|
486
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantEntity);
|
|
487
|
-
await unsafeCreateEntityTable(derivedStack.db, userSessionEntity);
|
|
488
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantComplianceProfileEntity);
|
|
489
|
-
await unsafeCreateEntityTable(derivedStack.db, tenantMembershipEntity);
|
|
490
|
-
await unsafePushTables(derivedStack.db, { configValuesTable });
|
|
491
475
|
});
|
|
492
476
|
|
|
493
477
|
afterAll(async () => {
|
package/src/user/seeding.ts
CHANGED
|
@@ -71,26 +71,8 @@ export async function seedUser(
|
|
|
71
71
|
// liefert die Projection-Row als Record<string, unknown>.
|
|
72
72
|
if (existing) {
|
|
73
73
|
const id = existing["id"] as string;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// existed or before it flipped to true — without this, "seed with
|
|
77
|
-
// emailVerified: true" only ever takes effect on the very first insert
|
|
78
|
-
// and the flag silently does nothing on every re-run after (#1687).
|
|
79
|
-
// Goes through the executor (a real `.updated` event), never a direct
|
|
80
|
-
// write.
|
|
81
|
-
if (options.emailVerified === true && existing["emailVerified"] !== true) {
|
|
82
|
-
const result = await userExecutor.update(
|
|
83
|
-
{ id, version: existing["version"] as number, changes: { emailVerified: true } },
|
|
84
|
-
by,
|
|
85
|
-
tdb,
|
|
86
|
-
);
|
|
87
|
-
// version_conflict = a concurrent write already changed the row —
|
|
88
|
-
// fine for a seed helper, don't fail the whole seed run over it.
|
|
89
|
-
if (!result.isSuccess && result.error.code !== "version_conflict") {
|
|
90
|
-
throw new Error(
|
|
91
|
-
`seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
74
|
+
if (options.emailVerified === true) {
|
|
75
|
+
await reconcileSeededUserEmailVerified(db, existing, by);
|
|
94
76
|
}
|
|
95
77
|
return { id };
|
|
96
78
|
}
|
|
@@ -116,6 +98,33 @@ export async function seedUser(
|
|
|
116
98
|
return { id: extractId(result.data, "seedUser") };
|
|
117
99
|
}
|
|
118
100
|
|
|
101
|
+
// A re-run seed onto a row created before emailVerified flipped to true must
|
|
102
|
+
// still set it (#1687) — via the executor, so it lands as a real `.updated` event.
|
|
103
|
+
export async function reconcileSeededUserEmailVerified(
|
|
104
|
+
db: DbConnection,
|
|
105
|
+
existingRow: Record<string, unknown>,
|
|
106
|
+
by: SessionUser,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
// skip: already verified, nothing to reconcile
|
|
109
|
+
if (existingRow["emailVerified"] === true) return;
|
|
110
|
+
// @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
|
|
111
|
+
// liefert die Projection-Row als Record<string, unknown>.
|
|
112
|
+
const id = existingRow["id"] as string;
|
|
113
|
+
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
114
|
+
const result = await userExecutor.update(
|
|
115
|
+
{ id, version: existingRow["version"] as number, changes: { emailVerified: true } },
|
|
116
|
+
by,
|
|
117
|
+
tdb,
|
|
118
|
+
);
|
|
119
|
+
// version_conflict = a concurrent write already changed the row —
|
|
120
|
+
// fine for a seed helper, don't fail the whole seed run over it.
|
|
121
|
+
if (!result.isSuccess && result.error.code !== "version_conflict") {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`reconcileSeededUserEmailVerified failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
// Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
|
|
120
129
|
// Executor liefert ein Record<string, unknown> (die Projection-Row), in
|
|
121
130
|
// das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import {
|
|
2
|
+
import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
|
|
3
3
|
import {
|
|
4
4
|
createSystemUser,
|
|
5
5
|
type FieldDefinition,
|
|
@@ -10,13 +10,11 @@ import {
|
|
|
10
10
|
type UserDataHookCtx,
|
|
11
11
|
type UserDataStorageProvider,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
-
import {
|
|
14
|
-
assertSafeStorageKey,
|
|
15
|
-
fileRefEntity,
|
|
16
|
-
fileRefsTable,
|
|
17
|
-
} from "@cosmicdrift/kumiko-framework/files";
|
|
13
|
+
import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
18
14
|
import { assertErased } from "../../shared";
|
|
19
15
|
|
|
16
|
+
const ERROR_CONTEXT = "user-data-rights-defaults:fileRef";
|
|
17
|
+
|
|
20
18
|
// Forget writes go through the executor (events), not deleteMany/updateMany:
|
|
21
19
|
// a projection rebuild replays the events, so the erasure survives. Eventless
|
|
22
20
|
// writes are wiped/resurrected on rebuild — the Art.17 hole this fixes. Bounded:
|
|
@@ -174,52 +172,12 @@ function isPersonalFileRow(registry: Registry, row: Record<string, unknown>): bo
|
|
|
174
172
|
return isPersonalPiiField(fieldDef);
|
|
175
173
|
}
|
|
176
174
|
|
|
177
|
-
// Derivatives (thumbnails/resized variants — see derivatives-context.ts) are
|
|
178
|
-
// never tracked anywhere but the storage layer: they're rendered under a
|
|
179
|
-
// deterministic key (deriveKey + variantSuffix) computed from the original's
|
|
180
|
-
// key + a render spec, on demand, and nothing writes that key back onto the
|
|
181
|
-
// FileRef row. List the original's key-prefix and keep only candidates whose
|
|
182
|
-
// suffix matches the derivative grammar, anchored to the original's own
|
|
183
|
-
// basename AND extension — a same-directory sibling original (a different
|
|
184
|
-
// file, possibly another user's) shares the same list prefix but fails the
|
|
185
|
-
// extension/suffix check and is never touched.
|
|
186
|
-
async function listDerivativeKeys(
|
|
187
|
-
originalKey: string,
|
|
188
|
-
provider: UserDataStorageProvider,
|
|
189
|
-
): Promise<readonly string[]> {
|
|
190
|
-
let candidates: readonly string[];
|
|
191
|
-
try {
|
|
192
|
-
candidates = await provider.list(derivativeListPrefix(originalKey));
|
|
193
|
-
} catch (err) {
|
|
194
|
-
// Wrap rather than let a raw provider error (e.g. S3 AccessDenied) surface
|
|
195
|
-
// unexplained — list() is a NEW requirement this hook added on top of
|
|
196
|
-
// delete(); a bucket policy granting only s3:DeleteObject now fails forget
|
|
197
|
-
// every run instead of just leaving derivatives behind. Name the likely
|
|
198
|
-
// cause so an operator doesn't have to guess.
|
|
199
|
-
throw new Error(
|
|
200
|
-
`[user-data-rights-defaults:fileRef] provider.list() failed while looking up derivatives of key=${originalKey} — forget/erasure requires list permission on the storage bucket (e.g. s3:ListBucket), not just delete. Original error: ${err instanceof Error ? err.message : String(err)}`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
const derivativeKeys: string[] = [];
|
|
204
|
-
for (const candidate of candidates) {
|
|
205
|
-
if (!isDerivativeKeyOf(originalKey, candidate)) continue;
|
|
206
|
-
try {
|
|
207
|
-
assertSafeStorageKey(candidate);
|
|
208
|
-
} catch {
|
|
209
|
-
// ponytail: a listing hit that fails the storage-key grammar can't be
|
|
210
|
-
// a real derivative (every derivative is written via a validated
|
|
211
|
-
// write()) — skip rather than pass a malformed key to delete().
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
derivativeKeys.push(candidate);
|
|
215
|
-
}
|
|
216
|
-
return derivativeKeys;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
175
|
// Delete every row's binary (original + any derivatives) via the provider.
|
|
220
176
|
// Returns the keys whose delete threw — the caller fails closed on a
|
|
221
177
|
// non-empty list so the sub-tx rolls back and the next forget run retries
|
|
222
|
-
// (delete is idempotent → converges).
|
|
178
|
+
// (delete is idempotent → converges). Derivative-listing + per-key delete
|
|
179
|
+
// lives in the shared derivatives helper (also used by data-retention's
|
|
180
|
+
// hardDelete cleanup) so this hook no longer carries its own copy.
|
|
223
181
|
async function deleteBinaries(
|
|
224
182
|
rows: readonly Record<string, unknown>[],
|
|
225
183
|
provider: UserDataStorageProvider,
|
|
@@ -228,18 +186,7 @@ async function deleteBinaries(
|
|
|
228
186
|
for (const row of rows) {
|
|
229
187
|
const key = row["storageKey"]; // @cast-boundary db-row
|
|
230
188
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
231
|
-
|
|
232
|
-
for (const k of keysToDelete) {
|
|
233
|
-
try {
|
|
234
|
-
await provider.delete(k);
|
|
235
|
-
} catch (err) {
|
|
236
|
-
// biome-ignore lint/suspicious/noConsole: operator-visibility for binary-cleanup-failure
|
|
237
|
-
console.warn(
|
|
238
|
-
`[user-data-rights-defaults:fileRef] storage delete failed key=${k} err=${err instanceof Error ? err.message : String(err)}`,
|
|
239
|
-
);
|
|
240
|
-
failedKeys.push(k);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
189
|
+
failedKeys.push(...(await deleteStoredFileAndDerivatives(key, provider, ERROR_CONTEXT)));
|
|
243
190
|
}
|
|
244
191
|
return failedKeys;
|
|
245
192
|
}
|