@cosmicdrift/kumiko-bundled-features 0.285.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.285.1",
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.1",
133
- "@cosmicdrift/kumiko-framework": "0.285.1",
134
- "@cosmicdrift/kumiko-headless": "0.285.1",
135
- "@cosmicdrift/kumiko-renderer": "0.285.1",
136
- "@cosmicdrift/kumiko-renderer-web": "0.285.1",
137
- "@cosmicdrift/kumiko-types": "0.285.1",
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.1",
167
- "@cosmicdrift/kumiko-locale-es": "0.285.1"
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 are tenant-prefixed (export ZIPs:
14
- // `<tenantId>/exports/<jobId>.zip`) or globally-unique UUIDs (fileRefs).
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 prefix. A full `${tenantId}/` prefix sweep, not a
15
- // per-row derivative lookup — buildStorageKey always puts tenantId first,
16
- // so this needs only the tenantId, stays correct even though the "files"
17
- // stage runs after "app-data" already forgot the rows, and can never
18
- // cross into another tenant's keys (the provider's own list() prefix
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 keys = await provider.list(`${tenantId}/`);
87
- for (const key of keys) {
88
- assertSafeStorageKey(key);
89
- await provider.delete(key);
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(`${jobTenant}/exports/${jobId}.zip`, new Uint8Array([1, 2, 3]));
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(`${jobTenant}/exports/${jobId}.zip`);
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
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(`${tenantA}/exports/${jobId}.zip`);
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`)).toBe(true);
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 = `${tenantA}/exports/${jobId}.zip`;
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(`${tenantA}/exports/${jobId}.zip`);
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(`${tenantA}/exports/${jobId}.zip`);
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
- // Tenant-prefix damit der Storage-Layout pro-Tenant separat liegt.
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
  /**