@cosmicdrift/kumiko-bundled-features 0.119.0 → 0.121.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.119.0",
3
+ "version": "0.121.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>",
@@ -96,11 +96,11 @@
96
96
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
97
97
  },
98
98
  "dependencies": {
99
- "@cosmicdrift/kumiko-dispatcher-live": "0.119.0",
100
- "@cosmicdrift/kumiko-framework": "0.119.0",
101
- "@cosmicdrift/kumiko-headless": "0.119.0",
102
- "@cosmicdrift/kumiko-renderer": "0.119.0",
103
- "@cosmicdrift/kumiko-renderer-web": "0.119.0",
99
+ "@cosmicdrift/kumiko-dispatcher-live": "0.121.0",
100
+ "@cosmicdrift/kumiko-framework": "0.121.0",
101
+ "@cosmicdrift/kumiko-headless": "0.121.0",
102
+ "@cosmicdrift/kumiko-renderer": "0.121.0",
103
+ "@cosmicdrift/kumiko-renderer-web": "0.121.0",
104
104
  "@mollie/api-client": "^4.5.0",
105
105
  "@node-rs/argon2": "^2.0.2",
106
106
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,171 @@
1
+ // Delivery-attempt log under KMS (#799): recipientAddress is written via
2
+ // LOW-LEVEL append() in attempt-log.ts — the event-PII catalog must cover
3
+ // it. Stored event + projected row carry ciphertext under the recipient's
4
+ // DEK, the outgoing mail still reaches the PLAINTEXT address, the admin
5
+ // log.query decrypts for display, and erasing the recipient's key flips
6
+ // the log entry to [[erased]] without touching the append-only stream.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ configurePiiSubjectKms,
12
+ InMemoryKmsAdapter,
13
+ isPiiCiphertext,
14
+ PII_ERASED_SENTINEL,
15
+ resetPiiSubjectKmsForTests,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import {
18
+ defineFeature,
19
+ defineWriteHandler,
20
+ type NotifyFn,
21
+ qn,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
24
+ import {
25
+ createTestUser,
26
+ setupTestStack,
27
+ type TestStack,
28
+ TestUsers,
29
+ unsafePushTables,
30
+ } from "@cosmicdrift/kumiko-framework/stack";
31
+ import { z } from "zod";
32
+ import { createChannelEmailFeature } from "../../channel-email/feature";
33
+ import { createInMemoryTransport } from "../../channel-email/types";
34
+ import { createConfigFeature } from "../../config/feature";
35
+ import { configValuesTable } from "../../config/table";
36
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
37
+ import { createRendererSimpleFeature } from "../../renderer-simple/feature";
38
+ import { simpleRenderer } from "../../renderer-simple/simple-renderer";
39
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
40
+ import { createTenantFeature } from "../../tenant/feature";
41
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
42
+ import { DELIVERY_ATTEMPT_EVENT } from "../constants";
43
+ import { createDeliveryFeature } from "../feature";
44
+ import { deliveryAttemptsTable, notificationPreferencesTable } from "../tables";
45
+ import { createDeliveryTestContext } from "../testing";
46
+
47
+ const emailTransport = createInMemoryTransport();
48
+ const testEmail = (userId: string | number) => `user-${userId}@test.com`;
49
+
50
+ const admin = TestUsers.admin;
51
+ const recipient = createTestUser({ id: 7, roles: ["User"] });
52
+
53
+ const appFeature = defineFeature("app", (r) => {
54
+ r.requires("delivery");
55
+
56
+ r.writeHandler(
57
+ defineWriteHandler({
58
+ name: "ping",
59
+ schema: z.object({ toUserId: z.string() }),
60
+ handler: async (event, ctx) => {
61
+ const notify = ctx.notify as NotifyFn;
62
+ await notify(qn("app", "notify", "pinged"), {
63
+ to: event.payload.toUserId,
64
+ data: { title: "Ping", body: "Du wurdest angepingt" },
65
+ });
66
+ return { isSuccess: true, data: { sent: true } };
67
+ },
68
+ access: { openToAll: true },
69
+ }),
70
+ );
71
+ });
72
+
73
+ let stack: TestStack;
74
+ let kms: InMemoryKmsAdapter;
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({
78
+ features: [
79
+ createConfigFeature(),
80
+ createTenantFeature(),
81
+ createTemplateResolverFeature(),
82
+ createRendererFoundationFeature(),
83
+ createDeliveryFeature(),
84
+ createRendererSimpleFeature(),
85
+ createChannelEmailFeature({
86
+ transport: emailTransport,
87
+ renderer: simpleRenderer,
88
+ resolveEmail: async (userId) => testEmail(userId),
89
+ }),
90
+ appFeature,
91
+ ],
92
+ extraContext: (deps) => createDeliveryTestContext(deps),
93
+ });
94
+
95
+ await unsafePushTables(stack.db, {
96
+ configValuesTable,
97
+ tenantMembershipsTable,
98
+ notificationPreferencesTable,
99
+ });
100
+ });
101
+
102
+ afterAll(async () => {
103
+ await stack.cleanup();
104
+ });
105
+
106
+ // ONE kms for the whole file: rows persist across tests, so a fresh adapter
107
+ // per test would orphan earlier ciphertext (decrypt fails loud on KeyNotFound).
108
+ beforeEach(() => {
109
+ kms ??= new InMemoryKmsAdapter();
110
+ configurePiiSubjectKms(kms);
111
+ emailTransport.sent.length = 0;
112
+ });
113
+
114
+ afterAll(() => {
115
+ resetPiiSubjectKmsForTests();
116
+ });
117
+
118
+ async function pingRecipient(): Promise<void> {
119
+ await stack.http.writeOk(qn("app", "write", "ping"), { toUserId: recipient.id }, admin);
120
+ }
121
+
122
+ describe("delivery attempt log under KMS", () => {
123
+ test("mail goes to the plaintext address; event + row store ciphertext", async () => {
124
+ await pingRecipient();
125
+
126
+ expect(emailTransport.sent.length).toBe(1);
127
+ expect(emailTransport.sent[0]?.to).toBe(testEmail(recipient.id));
128
+
129
+ const events = await selectMany(stack.db, eventsTable, { type: DELIVERY_ATTEMPT_EVENT });
130
+ const sent = events
131
+ .map((e) => e.payload as Record<string, unknown>)
132
+ .find((p) => p["channel"] === "email" && p["status"] === "sent");
133
+ expect(sent).toBeDefined();
134
+ expect(isPiiCiphertext(sent?.["recipientAddress"])).toBe(true);
135
+ expect(String(sent?.["recipientAddress"])).toContain(`user:${recipient.id}`);
136
+ expect(sent?.["recipientId"]).toBe(recipient.id);
137
+
138
+ const rows = await selectMany(stack.db, deliveryAttemptsTable, {
139
+ recipientId: recipient.id,
140
+ channel: "email",
141
+ });
142
+ expect(rows.length).toBeGreaterThan(0);
143
+ expect(isPiiCiphertext(rows[0]?.["recipientAddress"])).toBe(true);
144
+ });
145
+
146
+ test("admin log.query decrypts for display; after key-erase it shows [[erased]]", async () => {
147
+ await pingRecipient();
148
+
149
+ const before = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
150
+ "delivery:query:log",
151
+ { limit: 100 },
152
+ admin,
153
+ );
154
+ const entry = before.rows.find(
155
+ (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
156
+ );
157
+ expect(entry?.["recipientAddress"]).toBe(testEmail(recipient.id));
158
+
159
+ await kms.eraseKey({ kind: "user", userId: recipient.id });
160
+
161
+ const after = await stack.http.queryOk<{ rows: Record<string, unknown>[] }>(
162
+ "delivery:query:log",
163
+ { limit: 100 },
164
+ admin,
165
+ );
166
+ const erased = after.rows.find(
167
+ (r) => r["recipientId"] === recipient.id && r["channel"] === "email",
168
+ );
169
+ expect(erased?.["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
170
+ });
171
+ });
@@ -40,7 +40,11 @@ export function createDeliveryFeature(): FeatureDefinition {
40
40
  // attempt is a fresh stream, no CRUD lifecycle. Framework's
41
41
  // boot-validator accepts the projection below because at least one
42
42
  // apply-key is a registered domain-event (DELIVERY_ATTEMPT_EVENT).
43
- r.defineEvent("attempt", deliveryAttemptSchema);
43
+ // recipientAddress is the real PII (email address); recipientId stays
44
+ // plaintext — pseudonymous fk, same line as config.userId (#821).
45
+ r.defineEvent("attempt", deliveryAttemptSchema, {
46
+ piiFields: { recipientAddress: { subjectField: "recipientId" } },
47
+ });
44
48
 
45
49
  // Inline projection that materialises every delivery attempt into
46
50
  // deliveryAttemptsTable. Runs in the SAME transaction as the low-level
@@ -1,6 +1,7 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { decryptStoredPii } from "../../shared";
4
5
  import { deliveryAttemptsTable } from "../tables";
5
6
 
6
7
  export const logQuery = defineQueryHandler({
@@ -14,6 +15,18 @@ export const logQuery = defineQueryHandler({
14
15
  orderBy: { col: "createdAt", direction: "desc" },
15
16
  limit: query.payload.limit,
16
17
  });
17
- return { rows };
18
+ // recipientAddress is stored encrypted under the recipient's DEK (#799)
19
+ // — decrypt for the admin log view; forgotten subjects show [[erased]].
20
+ return {
21
+ rows: await Promise.all(
22
+ rows.map(async (row) => ({
23
+ ...row,
24
+ recipientAddress:
25
+ typeof row["recipientAddress"] === "string"
26
+ ? await decryptStoredPii(row["recipientAddress"], "delivery-log")
27
+ : row["recipientAddress"],
28
+ })),
29
+ ),
30
+ };
18
31
  },
19
32
  });
@@ -0,0 +1,120 @@
1
+ // Event-PII on the jobs run-logger (#799): runStarted.payload can carry
2
+ // arbitrary user data and is written via LOW-LEVEL append() (not
3
+ // ctx.appendEvent) — exactly the path the event-PII catalog must cover.
4
+ // With a KMS active the stored event AND the projected read-row carry
5
+ // ciphertext under the triggering user's DEK; erasing that key makes the
6
+ // payload unreadable ([[erased]]) without touching the append-only stream.
7
+
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ configurePiiSubjectKms,
12
+ decryptPiiFieldValues,
13
+ InMemoryKmsAdapter,
14
+ isPiiCiphertext,
15
+ PII_ERASED_SENTINEL,
16
+ resetPiiSubjectKmsForTests,
17
+ } from "@cosmicdrift/kumiko-framework/crypto";
18
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createTestDb,
22
+ createTestRedis,
23
+ type TestDb,
24
+ type TestRedis,
25
+ unsafePushTables,
26
+ } from "@cosmicdrift/kumiko-framework/stack";
27
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
28
+ import { createJobsFeature } from "../feature";
29
+ import { createJobRunLogger, JOB_RUN_STARTED_EVENT } from "../job-run-logger";
30
+ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
31
+
32
+ let testDb: TestDb;
33
+ let testRedis: TestRedis;
34
+ let logger: ReturnType<typeof createJobRunLogger>;
35
+ let kms: InMemoryKmsAdapter;
36
+
37
+ const USER_ID = "u-pii-9";
38
+ const SECRET_PAYLOAD = JSON.stringify({ iban: "DE89370400440532013000" });
39
+
40
+ beforeAll(async () => {
41
+ testDb = await createTestDb();
42
+ testRedis = await createTestRedis();
43
+ // createRegistry publishes the event-PII catalog as a module singleton —
44
+ // the logger's low-level append() picks it up without further wiring.
45
+ const registry = createRegistry([createJobsFeature()]);
46
+ await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
47
+ await createEventsTable(testDb.db);
48
+ logger = createJobRunLogger({ db: testDb.db, registry });
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await testDb.cleanup();
53
+ await testRedis.cleanup();
54
+ });
55
+
56
+ beforeEach(async () => {
57
+ await resetTestTables(testDb.db, [eventsTable, jobRunsTable, jobRunLogsTable]);
58
+ kms = new InMemoryKmsAdapter();
59
+ configurePiiSubjectKms(kms);
60
+ });
61
+
62
+ afterEach(() => {
63
+ resetPiiSubjectKmsForTests();
64
+ });
65
+
66
+ describe("jobs run-started payload under KMS", () => {
67
+ test("stored event carries ciphertext payload, plaintext triggeredById", async () => {
68
+ await logger.onJobStart?.("app:job:export", "bull-1", {
69
+ triggeredById: USER_ID,
70
+ payload: SECRET_PAYLOAD,
71
+ attempt: 1,
72
+ });
73
+
74
+ const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
75
+ expect(events.length).toBe(1);
76
+ const payload = events[0]?.payload as Record<string, unknown>;
77
+ expect(isPiiCiphertext(payload["payload"])).toBe(true);
78
+ expect(String(payload["payload"])).toContain(`user:${USER_ID}`);
79
+ expect(payload["triggeredById"]).toBe(USER_ID);
80
+
81
+ const back = await decryptPiiFieldValues(payload, ["payload"], kms, { requestId: "t" });
82
+ expect(back["payload"]).toBe(SECRET_PAYLOAD);
83
+ });
84
+
85
+ test("projected read-row carries the same ciphertext; erase → [[erased]]", async () => {
86
+ await logger.onJobStart?.("app:job:export", "bull-2", {
87
+ triggeredById: USER_ID,
88
+ payload: SECRET_PAYLOAD,
89
+ });
90
+
91
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-2" });
92
+ expect(isPiiCiphertext(row?.["payload"])).toBe(true);
93
+
94
+ await kms.eraseKey({ kind: "user", userId: USER_ID });
95
+ const after = await decryptPiiFieldValues({ payload: row?.["payload"] }, ["payload"], kms, {
96
+ requestId: "t",
97
+ });
98
+ expect(after["payload"]).toBe(PII_ERASED_SENTINEL);
99
+ });
100
+
101
+ test("system run (no triggeredById) stays plaintext — no subject to shred", async () => {
102
+ await logger.onJobStart?.("app:job:cron-sweep", "bull-3", {
103
+ payload: JSON.stringify({ scope: "all" }),
104
+ });
105
+
106
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-3" });
107
+ expect(row?.["payload"]).toBe(JSON.stringify({ scope: "all" }));
108
+ });
109
+
110
+ test("without a KMS the payload stays plaintext (rollout mode)", async () => {
111
+ resetPiiSubjectKmsForTests();
112
+ await logger.onJobStart?.("app:job:export", "bull-4", {
113
+ triggeredById: USER_ID,
114
+ payload: SECRET_PAYLOAD,
115
+ });
116
+
117
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-4" });
118
+ expect(row?.["payload"]).toBe(SECRET_PAYLOAD);
119
+ });
120
+ });
@@ -44,7 +44,12 @@ export function createJobsFeature(): FeatureDefinition {
44
44
  // (no executor, no CRUD). The boot-validator accepts the two
45
45
  // projections below because every apply-key is a registered
46
46
  // domain-event.
47
- r.defineEvent("run-started", runStartedSchema);
47
+ // payload can carry arbitrary user data; triggeredById stays plaintext
48
+ // (pseudonymous fk). System runs (triggeredById null) stay plaintext —
49
+ // no user subject to shred.
50
+ r.defineEvent("run-started", runStartedSchema, {
51
+ piiFields: { payload: { subjectField: "triggeredById" } },
52
+ });
48
53
  r.defineEvent("run-completed", runCompletedSchema);
49
54
  r.defineEvent("run-failed", runFailedSchema);
50
55
 
@@ -1,6 +1,7 @@
1
1
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { decryptStoredPii } from "../../shared";
4
5
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
5
6
 
6
7
  export const detailQuery = defineQueryHandler({
@@ -18,6 +19,11 @@ export const detailQuery = defineQueryHandler({
18
19
 
19
20
  if (!row) return null;
20
21
 
22
+ // payload is stored encrypted under the triggering user's DEK (#799).
23
+ if (typeof row["payload"] === "string") {
24
+ row["payload"] = await decryptStoredPii(row["payload"], "job-run-detail");
25
+ }
26
+
21
27
  const logs = await selectMany(
22
28
  db,
23
29
  jobRunLogsTable,
@@ -1,8 +1,14 @@
1
1
  import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
+ import { decryptStoredPii } from "../../shared";
4
5
  import { jobRunsTable } from "../job-run-table";
5
6
 
7
+ async function decryptRunPayload<T extends Record<string, unknown>>(row: T): Promise<T> {
8
+ if (typeof row["payload"] !== "string") return row;
9
+ return { ...row, payload: await decryptStoredPii(row["payload"], "job-runs") };
10
+ }
11
+
6
12
  export const listQuery = defineQueryHandler({
7
13
  name: "list",
8
14
  schema: z.object({
@@ -19,6 +25,7 @@ export const listQuery = defineQueryHandler({
19
25
  orderBy: { col: "id", direction: "desc" },
20
26
  limit: query.payload.limit ?? 50,
21
27
  });
22
- return { rows };
28
+ // payload is stored encrypted under the triggering user's DEK (#799).
29
+ return { rows: await Promise.all(rows.map(decryptRunPayload)) };
23
30
  },
24
31
  });
@@ -25,6 +25,7 @@ import { configValueEntity, createConfigFeature } from "../../config";
25
25
  import { createDataRetentionFeature } from "../../data-retention";
26
26
  import { createDeliveryFeature, notificationPreferenceEntity } from "../../delivery";
27
27
  import { createFilesFeature } from "../../files";
28
+ import { createJobsFeature, jobRunLogsTable, jobRunsTable } from "../../jobs";
28
29
  import {
29
30
  apiTokenEntity,
30
31
  createPersonalAccessTokensFeature,
@@ -41,8 +42,10 @@ import {
41
42
  apiTokenExportHook,
42
43
  configValueDeleteHook,
43
44
  configValueExportHook,
45
+ deliveryAttemptExportHook,
44
46
  inAppMessageDeleteHook,
45
47
  inAppMessageExportHook,
48
+ jobRunExportHook,
46
49
  notificationPreferenceDeleteHook,
47
50
  notificationPreferenceExportHook,
48
51
  tenantInvitationDeleteHook,
@@ -71,6 +74,7 @@ beforeAll(async () => {
71
74
  createPersonalAccessTokensFeature({ scopes: PAT_SCOPES }),
72
75
  createDeliveryFeature(),
73
76
  createChannelInAppFeature(),
77
+ createJobsFeature(),
74
78
  createUserDataRightsFeature(),
75
79
  createUserDataRightsDefaultsFeature(),
76
80
  ],
@@ -81,7 +85,7 @@ beforeAll(async () => {
81
85
  await unsafeCreateEntityTable(full.db, tenantInvitationEntity);
82
86
  await unsafeCreateEntityTable(full.db, notificationPreferenceEntity);
83
87
  await unsafeCreateEntityTable(full.db, configValueEntity);
84
- await unsafePushTables(full.db, { inAppMessagesTable });
88
+ await unsafePushTables(full.db, { inAppMessagesTable, jobRunsTable, jobRunLogsTable });
85
89
 
86
90
  minimal = await setupTestStack({
87
91
  features: [
@@ -364,6 +368,60 @@ describe("config-value userData-hooks", () => {
364
368
  });
365
369
  });
366
370
 
371
+ describe("delivery-attempt userData-hooks (#799)", () => {
372
+ async function seedAttempt(id: string, recipientId: string, tenantId: string): Promise<void> {
373
+ await asRawClient(full.db).unsafe(
374
+ `INSERT INTO read_delivery_attempts
375
+ (id, tenant_id, notification_type, channel, recipient_id, recipient_address, status, priority)
376
+ VALUES ($1, $2, 'app:notify:ping', 'email', $3, $4, 'sent', 'normal')`,
377
+ [id, tenantId, recipientId, `user-${recipientId}@example.com`],
378
+ );
379
+ }
380
+
381
+ test("export returns only the user's attempts in the export tenant", async () => {
382
+ await seedAttempt(uuid(60), "da-user-1", TENANT_A);
383
+ await seedAttempt(uuid(61), "da-user-1", TENANT_B);
384
+ await seedAttempt(uuid(62), "da-user-2", TENANT_A);
385
+
386
+ const result = await deliveryAttemptExportHook(ctx("da-user-1"));
387
+ expect(result?.entity).toBe("delivery-attempt");
388
+ expect(result?.rows).toHaveLength(1);
389
+ expect(result?.rows[0]?.["recipientAddress"]).toBe("user-da-user-1@example.com");
390
+ expect(result?.rows[0]?.["channel"]).toBe("email");
391
+ });
392
+
393
+ test("export returns null for a user without attempts", async () => {
394
+ expect(await deliveryAttemptExportHook(ctx("da-nobody"))).toBeNull();
395
+ });
396
+ });
397
+
398
+ describe("job-run userData-hooks (#799)", () => {
399
+ async function seedRun(id: string, triggeredById: string | null): Promise<void> {
400
+ await asRawClient(full.db).unsafe(
401
+ `INSERT INTO read_job_runs
402
+ (id, tenant_id, job_name, bull_job_id, status, payload, attempt, started_at, triggered_by_id)
403
+ VALUES ($1::uuid, $2, 'app:job:export', $3, 'completed', '{"scope":"mine"}', 1, now(), $4)`,
404
+ [id, TENANT_A, `bull-${id}`, triggeredById],
405
+ );
406
+ }
407
+
408
+ test("export filters by triggeredById only (system-tenant rows)", async () => {
409
+ await seedRun(uuid(70), "jr-user-1");
410
+ await seedRun(uuid(71), "jr-user-2");
411
+ await seedRun(uuid(72), null);
412
+
413
+ const result = await jobRunExportHook(ctx("jr-user-1"));
414
+ expect(result?.entity).toBe("job-run");
415
+ expect(result?.rows).toHaveLength(1);
416
+ expect(result?.rows[0]?.["jobName"]).toBe("app:job:export");
417
+ expect(result?.rows[0]?.["payload"]).toBe('{"scope":"mine"}');
418
+ });
419
+
420
+ test("export returns null for a user without runs", async () => {
421
+ expect(await jobRunExportHook(ctx("jr-nobody"))).toBeNull();
422
+ });
423
+ });
424
+
367
425
  describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt Crash", () => {
368
426
  test("alle gegateten Hooks no-open auf dem Minimal-Stack", async () => {
369
427
  const minCtx = {
@@ -382,6 +440,8 @@ describe("Presence-Gating: Source-Feature nicht gemountet → null/no-op statt C
382
440
  expect(await tenantInvitationExportHook(minCtx)).toBeNull();
383
441
  expect(await notificationPreferenceExportHook(minCtx)).toBeNull();
384
442
  expect(await configValueExportHook(minCtx)).toBeNull();
443
+ expect(await deliveryAttemptExportHook(minCtx)).toBeNull();
444
+ expect(await jobRunExportHook(minCtx)).toBeNull();
385
445
 
386
446
  await expect(apiTokenDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
387
447
  await expect(inAppMessageDeleteHook(minCtx, "delete")).resolves.toBeUndefined();
@@ -5,11 +5,16 @@ import {
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
6
  import { apiTokenDeleteHook, apiTokenExportHook } from "./hooks/api-token.userdata-hook";
7
7
  import { configValueDeleteHook, configValueExportHook } from "./hooks/config-value.userdata-hook";
8
+ import {
9
+ deliveryAttemptDeleteHook,
10
+ deliveryAttemptExportHook,
11
+ } from "./hooks/delivery-attempt.userdata-hook";
8
12
  import { fileRefDeleteHook, fileRefExportHook } from "./hooks/file-ref.userdata-hook";
9
13
  import {
10
14
  inAppMessageDeleteHook,
11
15
  inAppMessageExportHook,
12
16
  } from "./hooks/in-app-message.userdata-hook";
17
+ import { jobRunDeleteHook, jobRunExportHook } from "./hooks/job-run.userdata-hook";
13
18
  import {
14
19
  notificationPreferenceDeleteHook,
15
20
  notificationPreferenceExportHook,
@@ -46,7 +51,7 @@ import { userSessionDeleteHook, userSessionExportHook } from "./hooks/user-sessi
46
51
  export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
47
52
  return defineFeature("user-data-rights-defaults", (r) => {
48
53
  r.describe(
49
- "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb). Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
54
+ "Registers ready-made `EXT_USER_DATA` export and delete hooks for the bundled entities that hold per-user data: `user` (delete strategy sets email to `deleted-<id>@anonymized.invalid`, nulls `passwordHash`, sets status to `Deleted`; anonymize strategy sets email to `anonymized-<id>@anonymized.invalid` without touching `passwordHash`), `fileRef` (delete removes both the DB row and the storage binary), plus — gated on the source feature being mounted — `user-session` (ip/userAgent, hard-delete), `api-token` (hard-delete = revoke), `in-app-message` (hard-delete), `tenant-invitation` (invitee email forgotten/pseudonymized, inviter link severed), `notification-preference` and user-scoped `config-value` (purged via the forget verb), plus export-only hooks for the events-only aggregates `delivery-attempt` (recipientAddress) and `job-run` (payload) whose erasure runs via crypto-shredding. Mount this alongside `user-data-rights` for standard GDPR compliance; omit it only if your app needs custom anonymization logic for these entities.",
50
55
  );
51
56
  r.uiHints({
52
57
  displayLabel: "User Data Rights · Default Hooks",
@@ -65,6 +70,7 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
65
70
  "tenant",
66
71
  "delivery",
67
72
  "config",
73
+ "jobs",
68
74
  );
69
75
 
70
76
  r.useExtension(EXT_USER_DATA, "user", {
@@ -106,5 +112,19 @@ export function createUserDataRightsDefaultsFeature(): FeatureDefinition {
106
112
  export: configValueExportHook,
107
113
  delete: configValueDeleteHook,
108
114
  });
115
+
116
+ // Events-only aggregates (#799): export reads the projected rows, the
117
+ // delete hooks are deliberate no-ops — erasure happens via crypto-
118
+ // shredding (forget erases the user's DEK; recipientAddress / job
119
+ // payload become unreadable in events AND rows).
120
+ r.useExtension(EXT_USER_DATA, "delivery-attempt", {
121
+ export: deliveryAttemptExportHook,
122
+ delete: deliveryAttemptDeleteHook,
123
+ });
124
+
125
+ r.useExtension(EXT_USER_DATA, "job-run", {
126
+ export: jobRunExportHook,
127
+ delete: jobRunDeleteHook,
128
+ });
109
129
  });
110
130
  }
@@ -0,0 +1,37 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { deliveryAttemptsTable } from "../../delivery";
4
+ import { featureMounted } from "./feature-mounted";
5
+
6
+ // userData-Hooks for delivery's attempt log (deferred from #797, closed by
7
+ // #799). deliveryAttempt is an events-only aggregate — the export reads the
8
+ // projected rows; recipientAddress may be ciphertext (kumiko-pii:), which
9
+ // the export runner's central decrypt sweep resolves to plaintext (or
10
+ // [[erased]] after a forget).
11
+
12
+ export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
13
+ if (!featureMounted(ctx, "delivery")) return null;
14
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, deliveryAttemptsTable, {
15
+ tenantId: ctx.tenantId,
16
+ recipientId: ctx.userId,
17
+ });
18
+ if (rows.length === 0) return null;
19
+ return {
20
+ entity: "delivery-attempt",
21
+ rows: rows.map((r) => ({
22
+ notificationType: r["notificationType"],
23
+ channel: r["channel"],
24
+ status: r["status"],
25
+ recipientAddress: r["recipientAddress"],
26
+ priority: r["priority"],
27
+ createdAt: r["createdAt"],
28
+ })),
29
+ };
30
+ };
31
+
32
+ export const deliveryAttemptDeleteHook: UserDataDeleteHook = async () => {
33
+ // Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
34
+ // erases the recipient's DEK, which makes recipientAddress unreadable in
35
+ // BOTH the append-only events and the projected rows (#799). A read-side
36
+ // UPDATE here would be wiped on the next projection rebuild anyway.
37
+ };
@@ -0,0 +1,35 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { jobRunsTable } from "../../jobs";
4
+ import { featureMounted } from "./feature-mounted";
5
+
6
+ // userData-Hooks for the jobs run log (deferred from #797, closed by #799).
7
+ // Job runs live on the SYSTEM tenant regardless of who triggered them, so
8
+ // the export filters by triggeredById only — Art. 15 covers everything the
9
+ // user caused, across tenants. payload may be ciphertext (kumiko-pii:); the
10
+ // export runner's central decrypt sweep resolves it.
11
+
12
+ export const jobRunExportHook: UserDataExportHook = async (ctx) => {
13
+ if (!featureMounted(ctx, "jobs")) return null;
14
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, jobRunsTable, {
15
+ triggeredById: ctx.userId,
16
+ });
17
+ if (rows.length === 0) return null;
18
+ return {
19
+ entity: "job-run",
20
+ rows: rows.map((r) => ({
21
+ jobName: r["jobName"],
22
+ status: r["status"],
23
+ payload: r["payload"],
24
+ startedAt: r["startedAt"],
25
+ finishedAt: r["finishedAt"],
26
+ })),
27
+ };
28
+ };
29
+
30
+ export const jobRunDeleteHook: UserDataDeleteHook = async () => {
31
+ // Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
32
+ // erases the triggering user's DEK, which makes the payload unreadable in
33
+ // BOTH the append-only events and the projected rows (#799). triggeredById
34
+ // itself is a pseudonymous fk (plaintext by design, like config.userId).
35
+ };
@@ -13,10 +13,8 @@ import { featureMounted } from "./feature-mounted";
13
13
  // A preference without its user is meaningless, so both strategies purge via
14
14
  // the forget verb.
15
15
  //
16
- // The delivery ATTEMPTS log (read_delivery_attempts, recipientAddress) is NOT
17
- // covered here: it is an events-only aggregate whose payload lives in the
18
- // append-only event store — per-user redaction there needs the event-store
19
- // redaction epic; a read-side UPDATE would be wiped on rebuild.
16
+ // The delivery ATTEMPTS log is covered separately: export via
17
+ // delivery-attempt.userdata-hook.ts, erasure via crypto-shredding (#799).
20
18
 
21
19
  const crud = createEventStoreExecutor(notificationPreferencesTable, notificationPreferenceEntity, {
22
20
  entityName: "notification-preference",
@@ -4,6 +4,10 @@ export {
4
4
  configValueDeleteHook,
5
5
  configValueExportHook,
6
6
  } from "./hooks/config-value.userdata-hook";
7
+ export {
8
+ deliveryAttemptDeleteHook,
9
+ deliveryAttemptExportHook,
10
+ } from "./hooks/delivery-attempt.userdata-hook";
7
11
  export {
8
12
  fileRefDeleteHook,
9
13
  fileRefExportHook,
@@ -12,6 +16,7 @@ export {
12
16
  inAppMessageDeleteHook,
13
17
  inAppMessageExportHook,
14
18
  } from "./hooks/in-app-message.userdata-hook";
19
+ export { jobRunDeleteHook, jobRunExportHook } from "./hooks/job-run.userdata-hook";
15
20
  export {
16
21
  notificationPreferenceDeleteHook,
17
22
  notificationPreferenceExportHook,