@cosmicdrift/kumiko-bundled-features 0.121.1 → 0.122.1
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 +6 -6
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +6 -3
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +61 -0
- package/src/user-data-rights/run-export-jobs.ts +48 -16
- package/src/user-data-rights/schema/download-token.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.122.1",
|
|
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>",
|
|
@@ -96,11 +96,11 @@
|
|
|
96
96
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
97
97
|
},
|
|
98
98
|
"dependencies": {
|
|
99
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
100
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
101
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
102
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
103
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
99
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.122.1",
|
|
100
|
+
"@cosmicdrift/kumiko-framework": "0.122.1",
|
|
101
|
+
"@cosmicdrift/kumiko-headless": "0.122.1",
|
|
102
|
+
"@cosmicdrift/kumiko-renderer": "0.122.1",
|
|
103
|
+
"@cosmicdrift/kumiko-renderer-web": "0.122.1",
|
|
104
104
|
"@mollie/api-client": "^4.5.0",
|
|
105
105
|
"@node-rs/argon2": "^2.0.2",
|
|
106
106
|
"@types/nodemailer": "^8.0.0",
|
package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts
CHANGED
|
@@ -283,8 +283,11 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
|
|
|
283
283
|
expect(backfilled).toBeGreaterThanOrEqual(1);
|
|
284
284
|
|
|
285
285
|
// Proof the loop didn't abort at the corrupt row: rebuild the projection —
|
|
286
|
-
// goodRow got its user.updated event (survives)
|
|
287
|
-
//
|
|
286
|
+
// goodRow got its user.updated event (survives). badRow's Stream ist
|
|
287
|
+
// archiviert und replayt seit fw#832 gar nicht mehr: die Row ist nach dem
|
|
288
|
+
// Rebuild WEG (nicht bloss auf Active zurueckgewischt). Genau deshalb ist
|
|
289
|
+
// der failed-Report oben der Operator-Alarm — Heilung: restoreStream +
|
|
290
|
+
// Backfill-Re-Run, DANN rebuild.
|
|
288
291
|
await rebuildProjection(USER_PROJECTION, { db: stack.db, registry });
|
|
289
292
|
const rows = (await selectMany(stack.db, userTable, {})) as Array<{
|
|
290
293
|
id: string;
|
|
@@ -293,6 +296,6 @@ describe("#494 :: read_users-Rebuild bewahrt Lifecycle-State", () => {
|
|
|
293
296
|
const good = rows.find((r) => r.id === goodRow.id);
|
|
294
297
|
const bad = rows.find((r) => r.id === badRow.id);
|
|
295
298
|
expect(good?.status).toBe(USER_STATUS.Restricted);
|
|
296
|
-
expect(bad
|
|
299
|
+
expect(bad).toBeUndefined();
|
|
297
300
|
});
|
|
298
301
|
});
|
|
@@ -574,6 +574,67 @@ describe("runExportJobs :: Atom 4a download-tokens", () => {
|
|
|
574
574
|
expect(tokenRows).toHaveLength(1);
|
|
575
575
|
});
|
|
576
576
|
|
|
577
|
+
test("Re-Run (fw#832): Job zurueck auf pending → Token in place rotiert, kein 2. created-Event", async () => {
|
|
578
|
+
const jobId = await seedPendingJob();
|
|
579
|
+
const first = await runExportJobs({
|
|
580
|
+
db: stack.db,
|
|
581
|
+
registry: stack.registry,
|
|
582
|
+
buildStorageProvider: buildProvider,
|
|
583
|
+
now: NOW(),
|
|
584
|
+
});
|
|
585
|
+
const firstPlain = first.tokenByJobId.get(jobId);
|
|
586
|
+
expect(firstPlain).toBeDefined();
|
|
587
|
+
|
|
588
|
+
const [tokenBefore] = (await selectMany(stack.db, exportDownloadTokensTable, {
|
|
589
|
+
jobId,
|
|
590
|
+
})) as Array<{ id: string }>;
|
|
591
|
+
|
|
592
|
+
// Operator-Recovery (Email-Fail, plain verloren): NUR den Job auf
|
|
593
|
+
// pending flippen — die Token-Row bleibt liegen. Vor fw#832 created
|
|
594
|
+
// der Worker dann ein ZWEITES Aggregat fuers gleiche jobId und der
|
|
595
|
+
// Projection-Rebuild kollidierte dauerhaft am one_per_job-Index.
|
|
596
|
+
await updateRows(
|
|
597
|
+
stack.db,
|
|
598
|
+
exportJobsTable,
|
|
599
|
+
{ status: EXPORT_JOB_STATUS.Pending },
|
|
600
|
+
{ id: jobId },
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
const second = await runExportJobs({
|
|
604
|
+
db: stack.db,
|
|
605
|
+
registry: stack.registry,
|
|
606
|
+
buildStorageProvider: buildProvider,
|
|
607
|
+
now: NOW(),
|
|
608
|
+
});
|
|
609
|
+
expect(second.completedJobIds).toContain(jobId);
|
|
610
|
+
const secondPlain = second.tokenByJobId.get(jobId);
|
|
611
|
+
expect(secondPlain).toBeDefined();
|
|
612
|
+
expect(secondPlain).not.toBe(firstPlain);
|
|
613
|
+
|
|
614
|
+
// Genau EINE Row, GLEICHES Aggregat, frischer Hash, Audit-Reset.
|
|
615
|
+
const tokenRows = (await selectMany(stack.db, exportDownloadTokensTable, {
|
|
616
|
+
jobId,
|
|
617
|
+
})) as Array<{
|
|
618
|
+
id: string;
|
|
619
|
+
tokenHash: string;
|
|
620
|
+
useCount: number | null;
|
|
621
|
+
lastUsedAt: unknown;
|
|
622
|
+
}>;
|
|
623
|
+
expect(tokenRows).toHaveLength(1);
|
|
624
|
+
expect(tokenRows[0]?.id).toBe(tokenBefore?.id as string);
|
|
625
|
+
expect(tokenRows[0]?.tokenHash).toBe(await hashDownloadToken(secondPlain as string));
|
|
626
|
+
expect(tokenRows[0]?.useCount).toBe(0);
|
|
627
|
+
expect(tokenRows[0]?.lastUsedAt).toBeNull();
|
|
628
|
+
|
|
629
|
+
// Rebuild-Invariante: genau EIN created-Event — der one_per_job-
|
|
630
|
+
// Index kann beim Replay nie kollidieren.
|
|
631
|
+
const events = (await asRawClient(stack.db).unsafe(
|
|
632
|
+
`SELECT type FROM kumiko_events WHERE aggregate_type = 'export-download-token'`,
|
|
633
|
+
)) as unknown as Array<{ type: string }>;
|
|
634
|
+
expect(events.filter((e) => e.type === "export-download-token.created")).toHaveLength(1);
|
|
635
|
+
expect(events.some((e) => e.type === "export-download-token.updated")).toBe(true);
|
|
636
|
+
});
|
|
637
|
+
|
|
577
638
|
test("failed-Job: kein Token wird generiert", async () => {
|
|
578
639
|
// Bewusst keinen Job — leerer Pending-Pass. Aber wir koennen den
|
|
579
640
|
// failure-Pfad pinnen via runUserExport-Fehler. Einfacher: nur
|
|
@@ -105,9 +105,10 @@ export const tokenCrud = createEventStoreExecutor(
|
|
|
105
105
|
*
|
|
106
106
|
* **Plain-Token-Recovery bei Email-Fail:** plain ist ephemeral. Wenn
|
|
107
107
|
* der Callback throwt, ist der plain verloren. Operator-Eingriff
|
|
108
|
-
* noetig
|
|
109
|
-
*
|
|
110
|
-
*
|
|
108
|
+
* noetig: Job auf pending flippen — NUR das. Die Token-Row NICHT
|
|
109
|
+
* loeschen (eventloser Read-Side-Delete → Rebuild-Kollision, fw#832);
|
|
110
|
+
* der naechste Worker-Run rotiert das Token in place auf demselben
|
|
111
|
+
* Aggregat. Atom 5b (Re-issue-handler) wird das automatisieren.
|
|
111
112
|
*/
|
|
112
113
|
export type SendExportReadyEmailFn = (args: {
|
|
113
114
|
readonly userId: string;
|
|
@@ -427,22 +428,53 @@ async function processJob(args: {
|
|
|
427
428
|
// Plain-Token bleibt im Worker-Memory + wird via RunExportJobsResult
|
|
428
429
|
// an Atom 5 (Notification) weitergegeben. NUR der hash landet in DB.
|
|
429
430
|
// ES via tokenCrud.create — kein direct-INSERT.
|
|
431
|
+
//
|
|
432
|
+
// **Re-Run (fw#832):** Flippt ein Operator einen done/failed-Job
|
|
433
|
+
// zurueck auf pending (Plain-Token verloren, Email-Fail), existiert
|
|
434
|
+
// fuers jobId schon eine Token-Row. Ein zweites create wuerde ein
|
|
435
|
+
// zweites `created`-Event auf frischem Aggregat emittieren — der
|
|
436
|
+
// Projection-Rebuild kollidiert dann dauerhaft am one_per_job-Index.
|
|
437
|
+
// Stattdessen: Token-Rotation in place auf demselben Aggregat
|
|
438
|
+
// (update mit frischem Hash + Reset der Audit-Felder).
|
|
430
439
|
const expiresAt = addDurationSpec(now, ttl);
|
|
431
440
|
const { plain: tokenPlain, hash: tokenHash } = await generateDownloadToken();
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
issuedAt: now,
|
|
437
|
-
expiresAt,
|
|
438
|
-
// Atom 4a.fix: useCount explizit 0 statt default null. 4b's
|
|
439
|
-
// Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
|
|
440
|
-
// Defensiv-Code.
|
|
441
|
-
useCount: 0,
|
|
442
|
-
},
|
|
443
|
-
executor,
|
|
444
|
-
tdb,
|
|
441
|
+
const existingToken = await fetchOne<{ id: string; version: number }>(
|
|
442
|
+
db,
|
|
443
|
+
exportDownloadTokensTable,
|
|
444
|
+
{ jobId: job.id },
|
|
445
445
|
);
|
|
446
|
+
const tokenCreateResult = existingToken
|
|
447
|
+
? await tokenCrud.update(
|
|
448
|
+
{
|
|
449
|
+
id: existingToken.id,
|
|
450
|
+
version: existingToken.version,
|
|
451
|
+
changes: {
|
|
452
|
+
tokenHash,
|
|
453
|
+
issuedAt: now,
|
|
454
|
+
expiresAt,
|
|
455
|
+
useCount: 0,
|
|
456
|
+
lastUsedAt: null,
|
|
457
|
+
lastUsedFromIp: null,
|
|
458
|
+
lastUsedUserAgent: null,
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
executor,
|
|
462
|
+
tdb,
|
|
463
|
+
)
|
|
464
|
+
: await tokenCrud.create(
|
|
465
|
+
{
|
|
466
|
+
jobId: job.id,
|
|
467
|
+
tokenHash,
|
|
468
|
+
issuedAt: now,
|
|
469
|
+
expiresAt,
|
|
470
|
+
// Atom 4a.fix: useCount explizit 0 statt default null. 4b's
|
|
471
|
+
// Verify-Pfad incrementiert via `useCount + 1` ohne COALESCE-
|
|
472
|
+
// Defensiv-Code.
|
|
473
|
+
useCount: 0,
|
|
474
|
+
},
|
|
475
|
+
executor,
|
|
476
|
+
tdb,
|
|
477
|
+
);
|
|
446
478
|
if (!tokenCreateResult.isSuccess) {
|
|
447
479
|
// Token-Creation failed VOR done-flip → Job bleibt running.
|
|
448
480
|
// Catch-Pfad flippt auf failed mit klarer Diagnose. Storage-
|
|
@@ -93,9 +93,11 @@ export const exportDownloadTokenEntity = createEntity({
|
|
|
93
93
|
}),
|
|
94
94
|
},
|
|
95
95
|
|
|
96
|
-
// 1 Token pro Job. UNIQUE auf jobId —
|
|
97
|
-
//
|
|
98
|
-
//
|
|
96
|
+
// 1 Token pro Job. UNIQUE auf jobId — Backstop fuer Worker-Idempotency
|
|
97
|
+
// (Atom 3b's "2× run done-Job → no-op"). Der Worker created NIE ein
|
|
98
|
+
// zweites Aggregat fuers gleiche jobId: Re-Runs rotieren das Token
|
|
99
|
+
// in place via update (fw#832) — sonst kollidiert der Projection-
|
|
100
|
+
// Rebuild an genau diesem Index.
|
|
99
101
|
indexes: [
|
|
100
102
|
{
|
|
101
103
|
unique: true,
|