@cosmicdrift/kumiko-bundled-features 0.285.0 → 0.285.2
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/file-provider-s3-env/feature.ts +2 -2
- package/src/files-tenant-data/__tests__/hooks.integration.test.ts +9 -0
- package/src/files-tenant-data/hooks.ts +13 -10
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/download.integration.test.ts +2 -2
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -12
- package/src/user-data-rights/changes.json +6 -0
- package/src/user-data-rights/run-export-jobs.ts +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.285.
|
|
3
|
+
"version": "0.285.2",
|
|
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>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.285.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.285.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.285.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.285.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.285.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.285.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.285.2",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.285.2",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.285.2",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.285.2",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.285.2",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.285.2",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.285.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.285.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.285.2",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.285.2"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// buckets, admin-seeded). This `"s3-env"` provider reads one credential set
|
|
11
11
|
// from env and serves every tenant from one bucket — the
|
|
12
12
|
// Hetzner-Object-Storage / single-bucket deploy case. Tenant isolation
|
|
13
|
-
// still holds: file keys
|
|
14
|
-
//
|
|
13
|
+
// still holds: file keys carry the tenant (export ZIPs:
|
|
14
|
+
// `exports/<tenantId>/<jobId>.zip`) or are globally-unique UUIDs (fileRefs).
|
|
15
15
|
//
|
|
16
16
|
// **Pattern-Vorbild:** mirrors file-provider-inmemory (zero admin seeding,
|
|
17
17
|
// only the file-foundation plugin-point required).
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
fileRefEntity,
|
|
25
25
|
fileRefsTable,
|
|
26
26
|
type InMemoryFileProvider,
|
|
27
|
+
tenantExportPrefix,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
28
29
|
import {
|
|
29
30
|
setupTestStack,
|
|
@@ -203,10 +204,15 @@ describe("files-tenant-data :: tenant destroy", () => {
|
|
|
203
204
|
);
|
|
204
205
|
const derivativeKeyA = `${originalKeyA.replace(/\.jpg$/, "")}.thumb-0123456789abcdef.jpg`;
|
|
205
206
|
const unrelatedKeyA = `${tenantA.tenantId}/some-other-file.bin`;
|
|
207
|
+
// Export ZIPs live under exports/{tenantId}/, not {tenantId}/ — a
|
|
208
|
+
// regression here would leave a tenant's decrypted GDPR export bundle
|
|
209
|
+
// behind after destroy (#3003 review finding).
|
|
210
|
+
const exportKeyA = `${tenantExportPrefix(tenantA.tenantId)}job-1.zip`;
|
|
206
211
|
const { id: fileRefIdA } = await seedFileRef(tenantA.tenantId, originalKeyA);
|
|
207
212
|
await provider.write(originalKeyA, new Uint8Array([1]));
|
|
208
213
|
await provider.write(derivativeKeyA, new Uint8Array([2]));
|
|
209
214
|
await provider.write(unrelatedKeyA, new Uint8Array([3]));
|
|
215
|
+
await provider.write(exportKeyA, new Uint8Array([5]));
|
|
210
216
|
|
|
211
217
|
const originalKeyB = buildStorageKey(
|
|
212
218
|
tenantB.tenantId,
|
|
@@ -233,6 +239,9 @@ describe("files-tenant-data :: tenant destroy", () => {
|
|
|
233
239
|
// Storage wipe (EXT_STORAGE_PROVIDER "files" stage) — every key under the
|
|
234
240
|
// tenant's prefix is gone, including the unrelated non-fileRef-tracked key.
|
|
235
241
|
expect(await provider.list(`${tenantA.tenantId}/`)).toHaveLength(0);
|
|
242
|
+
// The export prefix is a separate top-level sweep, not covered by
|
|
243
|
+
// `${tenantId}/` — proves fileRefStorageDestroyHook sweeps it too.
|
|
244
|
+
expect(await provider.exists(exportKeyA)).toBe(false);
|
|
236
245
|
|
|
237
246
|
// Tenant B's fileRef row and storage key are completely untouched.
|
|
238
247
|
const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
// tenant has no "restore from trash" future.
|
|
12
12
|
// - fileRefStorageDestroyHook ("files" stage, EXT_STORAGE_PROVIDER): wipes
|
|
13
13
|
// every BINARY (original + derivatives + anything else) under the
|
|
14
|
-
// tenant's storage
|
|
15
|
-
// per-row derivative lookup —
|
|
16
|
-
//
|
|
17
|
-
// stage runs after "app-data"
|
|
18
|
-
// cross into another tenant's keys
|
|
19
|
-
// already scopes it).
|
|
14
|
+
// tenant's storage prefixes. A full prefix sweep per tenantStoragePrefixes()
|
|
15
|
+
// entry, not a per-row derivative lookup — not every layout puts tenantId
|
|
16
|
+
// first (see tenantExportPrefix), so this must sweep every known prefix,
|
|
17
|
+
// stays correct even though the "files" stage runs after "app-data"
|
|
18
|
+
// already forgot the rows, and can never cross into another tenant's keys
|
|
19
|
+
// (the provider's own list() prefix already scopes it).
|
|
20
20
|
|
|
21
21
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
22
22
|
import {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
assertSafeStorageKey,
|
|
29
29
|
fileRefEntity,
|
|
30
30
|
fileRefsTable,
|
|
31
|
+
tenantStoragePrefixes,
|
|
31
32
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
32
33
|
|
|
33
34
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
@@ -83,9 +84,11 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
|
|
|
83
84
|
// the "files" stage throws, tenant-lifecycle's retry/abandon handling sees
|
|
84
85
|
// it, and the next sweep tick retries (list+delete are idempotent, so this
|
|
85
86
|
// converges rather than double-deleting or erroring on a missing key).
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
for (const prefix of tenantStoragePrefixes(tenantId)) {
|
|
88
|
+
const keys = await provider.list(prefix);
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
assertSafeStorageKey(key);
|
|
91
|
+
await provider.delete(key);
|
|
92
|
+
}
|
|
90
93
|
}
|
|
91
94
|
};
|
|
@@ -141,7 +141,7 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
141
141
|
);
|
|
142
142
|
const jobId = requestRes.jobId;
|
|
143
143
|
const provider = await buildProvider(jobTenant);
|
|
144
|
-
await provider.write(
|
|
144
|
+
await provider.write(`exports/${jobTenant}/${jobId}.zip`, new Uint8Array([1, 2, 3]));
|
|
145
145
|
|
|
146
146
|
const result = await runExportJobs({
|
|
147
147
|
db: stack.db,
|
|
@@ -164,6 +164,6 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
164
164
|
expect(res.status).toBe(200);
|
|
165
165
|
const body = (await res.json()) as { data?: { url?: string } };
|
|
166
166
|
expect(body.data?.url).toMatch(/^memory:\/\//);
|
|
167
|
-
expect(body.data?.url).toContain(
|
|
167
|
+
expect(body.data?.url).toContain(`exports/${jobTenant}/${jobId}.zip`);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
@@ -211,7 +211,7 @@ async function seedDoneJobWithToken(): Promise<{ jobId: string; plainToken: stri
|
|
|
211
211
|
|
|
212
212
|
// 2. seed in-memory ZIP file at path that worker would write
|
|
213
213
|
const provider = await buildProvider(tenantA);
|
|
214
|
-
const storageKey =
|
|
214
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
215
215
|
await provider.write(storageKey, new Uint8Array([1, 2, 3]));
|
|
216
216
|
|
|
217
217
|
// 3. Run worker → done-flip + Token-Create
|
|
@@ -250,7 +250,7 @@ describe("download-by-token :: happy path", () => {
|
|
|
250
250
|
const result = body.data;
|
|
251
251
|
|
|
252
252
|
expect(result.url).toMatch(/^memory:\/\//);
|
|
253
|
-
expect(result.url).toContain(
|
|
253
|
+
expect(result.url).toContain(`exports/${tenantA}/${jobId}.zip`);
|
|
254
254
|
expect(result.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
255
255
|
|
|
256
256
|
// Audit-Update: useCount=1, lastUsedAt set, IP+UA persistiert
|
|
@@ -183,13 +183,13 @@ describe("runExportJobs :: happy path", () => {
|
|
|
183
183
|
bytesWritten: number | null;
|
|
184
184
|
}>;
|
|
185
185
|
expect(row?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
186
|
-
expect(row?.downloadStorageKey).toBe(
|
|
186
|
+
expect(row?.downloadStorageKey).toBe(`exports/${tenantA}/${jobId}.zip`);
|
|
187
187
|
expect(row?.expiresAt).not.toBeNull();
|
|
188
188
|
expect(row?.bytesWritten).toBeGreaterThan(0);
|
|
189
189
|
|
|
190
190
|
// ZIP wirklich im Storage
|
|
191
191
|
const provider = await buildProvider(tenantA);
|
|
192
|
-
expect(await provider.exists(
|
|
192
|
+
expect(await provider.exists(`exports/${tenantA}/${jobId}.zip`)).toBe(true);
|
|
193
193
|
});
|
|
194
194
|
|
|
195
195
|
test("ZIP ist real entpackbar via Info-ZIP + enthaelt bundle.json", async () => {
|
|
@@ -203,7 +203,7 @@ describe("runExportJobs :: happy path", () => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
const provider = await buildProvider(tenantA);
|
|
206
|
-
const zipBytes = await provider.read(
|
|
206
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
207
207
|
|
|
208
208
|
// Real-Decoder-Roundtrip via Info-ZIP unzip
|
|
209
209
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-worker-test-"));
|
|
@@ -291,7 +291,7 @@ describe("runExportJobs :: stale-detection", () => {
|
|
|
291
291
|
const jobId = await seedPendingJob();
|
|
292
292
|
const T = getTemporal();
|
|
293
293
|
const twoHoursAgo = T.Instant.fromEpochMilliseconds(Date.now() - 2 * 60 * 60 * 1000);
|
|
294
|
-
const storageKey =
|
|
294
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
295
295
|
|
|
296
296
|
// Simuliert real-Pfad: claim-update hatte status=running + storageKey
|
|
297
297
|
// gesetzt + ZIP geschrieben. Worker dann gecrashed (kein done-flip).
|
|
@@ -335,7 +335,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
335
335
|
const longAgo = T.Instant.fromEpochMilliseconds(
|
|
336
336
|
Date.now() - 365 * 24 * 60 * 60 * 1000, // 1 Jahr ago
|
|
337
337
|
);
|
|
338
|
-
const storageKey =
|
|
338
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
339
339
|
const provider = await buildProvider(tenantA);
|
|
340
340
|
await provider.write(storageKey, new Uint8Array([1, 2, 3]));
|
|
341
341
|
|
|
@@ -374,7 +374,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
374
374
|
const jobId = await seedPendingJob();
|
|
375
375
|
const T = getTemporal();
|
|
376
376
|
const oneHourAgo = T.Instant.fromEpochMilliseconds(Date.now() - 60 * 60 * 1000);
|
|
377
|
-
const storageKey =
|
|
377
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
378
378
|
const provider = await buildProvider(tenantA);
|
|
379
379
|
await provider.write(storageKey, new Uint8Array([4, 5, 6]));
|
|
380
380
|
|
|
@@ -406,7 +406,7 @@ describe("runExportJobs :: storage-cleanup", () => {
|
|
|
406
406
|
const jobId = await seedPendingJob();
|
|
407
407
|
const T = getTemporal();
|
|
408
408
|
const longAgo = T.Instant.fromEpochMilliseconds(Date.now() - 365 * 24 * 60 * 60 * 1000);
|
|
409
|
-
const storageKey =
|
|
409
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
410
410
|
const provider = await buildProvider(tenantA);
|
|
411
411
|
await provider.write(storageKey, new Uint8Array([7, 8, 9]));
|
|
412
412
|
|
|
@@ -505,11 +505,11 @@ describe("runExportJobs :: concurrency", () => {
|
|
|
505
505
|
}>;
|
|
506
506
|
expect(rows).toHaveLength(1);
|
|
507
507
|
expect(rows[0]?.status).toBe(EXPORT_JOB_STATUS.Done);
|
|
508
|
-
expect(rows[0]?.downloadStorageKey).toBe(
|
|
508
|
+
expect(rows[0]?.downloadStorageKey).toBe(`exports/${tenantA}/${jobId}.zip`);
|
|
509
509
|
|
|
510
510
|
// Storage hat genau ein ZIP — kein Race-induziertes Doppel-Schreiben
|
|
511
511
|
const provider = await buildProvider(tenantA);
|
|
512
|
-
expect(await provider.exists(
|
|
512
|
+
expect(await provider.exists(`exports/${tenantA}/${jobId}.zip`)).toBe(true);
|
|
513
513
|
});
|
|
514
514
|
});
|
|
515
515
|
|
|
@@ -875,7 +875,7 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
875
875
|
// Storage-Cleanup-Pass fuer failed-Jobs: sofort ZIP loeschen (kein
|
|
876
876
|
// Grace), DB-Spalte nullen.
|
|
877
877
|
const jobId = await seedPendingJob();
|
|
878
|
-
const storageKey =
|
|
878
|
+
const storageKey = `exports/${tenantA}/${jobId}.zip`;
|
|
879
879
|
|
|
880
880
|
// ZIP in storage seeden + Job manuell auf failed mit storageKey
|
|
881
881
|
// (simuliert orphan-state nach Worker-crash).
|
|
@@ -1050,7 +1050,7 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
|
|
|
1050
1050
|
|
|
1051
1051
|
// ZIP entpacken + file-bytes verifizieren
|
|
1052
1052
|
const provider = await buildProvider(tenantA);
|
|
1053
|
-
const zipBytes = await provider.read(
|
|
1053
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
1054
1054
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-test-"));
|
|
1055
1055
|
try {
|
|
1056
1056
|
const zipPath = join(dir, "out.zip");
|
|
@@ -1155,7 +1155,7 @@ describe("runExportJobs :: Atom 3c file-binaries", () => {
|
|
|
1155
1155
|
expect(result.completedJobIds).toContain(jobId);
|
|
1156
1156
|
|
|
1157
1157
|
const provider = await buildProvider(tenantA);
|
|
1158
|
-
const zipBytes = await provider.read(
|
|
1158
|
+
const zipBytes = await provider.read(`exports/${tenantA}/${jobId}.zip`);
|
|
1159
1159
|
const dir = await mkdtemp(join(tmpdir(), "kumiko-3c-malicious-"));
|
|
1160
1160
|
try {
|
|
1161
1161
|
const zipPath = join(dir, "out.zip");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.285.2",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "export ZIPs write to exports/{tenantId}/{jobId}.zip, enabling one bucket-wide S3 lifecycle rule",
|
|
6
|
+
"detail": "buildExportStorageKey previously put the tenant first ({tenantId}/exports/{jobId}.zip), so no S3 prefix rule could target \"all tenants, then /exports/\" without also matching normal user uploads in the same bucket. The key now starts with the fixed exports/ segment via the new @cosmicdrift/kumiko-framework/files tenantExportPrefix() helper, tenant stays as the second segment. downloadStorageKey is stored per job and never recomputed, so existing rows keep resolving through their old key — no migration needed. tenantStoragePrefixes() (also new in framework/files) lists every prefix a tenant's binaries can live under; files-tenant-data's fileRefStorageDestroyHook now sweeps all of them on tenant-destroy instead of only {tenantId}/, closing a gap where a destroyed tenant's decrypted GDPR export bundles would have survived under the new exports/ prefix."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.241.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -58,6 +58,7 @@ import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
58
58
|
import {
|
|
59
59
|
createZipStream,
|
|
60
60
|
type FileStorageProvider,
|
|
61
|
+
tenantExportPrefix,
|
|
61
62
|
type ZipEntry,
|
|
62
63
|
} from "@cosmicdrift/kumiko-framework/files";
|
|
63
64
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -709,9 +710,7 @@ function systemTenantDb(db: DbConnection, tenantId: TenantId) {
|
|
|
709
710
|
}
|
|
710
711
|
|
|
711
712
|
function buildExportStorageKey(job: JobRow): string {
|
|
712
|
-
|
|
713
|
-
// job.id ist UUID → URL-safe.
|
|
714
|
-
return `${job.requestedFromTenantId}/exports/${job.id}.zip`;
|
|
713
|
+
return `${tenantExportPrefix(job.requestedFromTenantId)}${job.id}.zip`;
|
|
715
714
|
}
|
|
716
715
|
|
|
717
716
|
/**
|