@cosmicdrift/kumiko-bundled-features 0.253.0 → 0.254.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.253.0",
3
+ "version": "0.254.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>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.253.0",
134
- "@cosmicdrift/kumiko-framework": "0.253.0",
135
- "@cosmicdrift/kumiko-headless": "0.253.0",
136
- "@cosmicdrift/kumiko-renderer": "0.253.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.253.0",
138
- "@cosmicdrift/kumiko-types": "0.253.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.254.0",
134
+ "@cosmicdrift/kumiko-framework": "0.254.0",
135
+ "@cosmicdrift/kumiko-headless": "0.254.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.254.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.254.0",
138
+ "@cosmicdrift/kumiko-types": "0.254.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.253.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.253.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.254.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.254.0"
169
169
  }
170
170
  }
@@ -155,6 +155,7 @@ export const processEventHandler: WriteHandlerDef = {
155
155
  SUBSCRIPTION_PII_FIELDS,
156
156
  piiKms,
157
157
  { requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
158
+ { tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
158
159
  )
159
160
  : {
160
161
  providerCustomerId: payload.providerCustomerId,
@@ -84,6 +84,7 @@ export const processPaymentEventHandler: WriteHandlerDef = {
84
84
  requestId: `billing-foundation:process-payment-event:${payload.providerEventId}`,
85
85
  tenantId,
86
86
  },
87
+ { tenantId, entityName: PAYMENT_AGGREGATE_TYPE },
87
88
  )
88
89
  : { providerCustomerId: payload.providerCustomerId };
89
90
 
@@ -80,10 +80,14 @@ export const connectAccountHandler: WriteHandlerDef = {
80
80
  const piiKms = configuredPiiSubjectKms();
81
81
  const plainPii = { tenantId, address: payload.address };
82
82
  const encryptedFields = piiKms
83
- ? await encryptPiiFieldValues(plainPii, mailAccountEntity, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
84
- requestId: `inbound-mail-foundation:connect-account:${accountId}`,
85
- tenantId,
86
- })
83
+ ? await encryptPiiFieldValues(
84
+ plainPii,
85
+ mailAccountEntity,
86
+ MAIL_ACCOUNT_PII_FIELDS,
87
+ piiKms,
88
+ { requestId: `inbound-mail-foundation:connect-account:${accountId}`, tenantId },
89
+ { tenantId, entityName: MAIL_ACCOUNT_AGGREGATE_TYPE },
90
+ )
87
91
  : plainPii;
88
92
 
89
93
  const eventPayload: MailAccountEventPayload = {
@@ -179,10 +179,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
179
179
  inboundMessageEntity,
180
180
  INBOUND_MESSAGE_PII_FIELDS,
181
181
  piiKms,
182
- {
183
- requestId: `inbound-mail-foundation:ingest-message:${messageAggId}`,
184
- tenantId,
185
- },
182
+ { requestId: `inbound-mail-foundation:ingest-message:${messageAggId}`, tenantId },
183
+ { tenantId, entityName: INBOUND_MESSAGE_AGGREGATE_TYPE },
186
184
  )
187
185
  : messagePlainPii;
188
186
 
@@ -265,6 +263,7 @@ export const ingestMessageHandler: WriteHandlerDef = {
265
263
  requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
266
264
  tenantId,
267
265
  },
266
+ { tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE },
268
267
  )
269
268
  : threadPlainPii;
270
269
 
@@ -17,18 +17,26 @@ import {
17
17
  // that is the whole point of a note *history* instead of the single
18
18
  // overwritable textarea this bundle replaces (solon#13).
19
19
  //
20
- // `body` is about the HOST entity (entityType/entityId), not the author —
21
- // the author is never the data subject of a note's content, so it is
22
- // deliberately NOT `personal: { of: "authorId" }`. That axis would
23
- // crypto-shred every note's content the moment its author's data-rights key
24
- // is destroyed (an employee leaving triggers a forget), garbling unrelated
25
- // business notes about customers/projects/etc. — data loss, not privacy.
26
- // `body` stays plaintext (`personal: false`), same call as tags' catalog
27
- // `name` field. GDPR erasure of the AUTHOR still shreds `authorName`
28
- // (`personal: { of: "authorId" }`, the name as stamped at write time)
29
- // `body` and the note itself are unaffected. If a note's body incidentally
30
- // names or describes a third party, that isn't tracked by field annotation
31
- // today; no PII hooks exist for note content.
20
+ // `body` is about the HOST entity (entityType/entityId), not the author — the
21
+ // author is never the data subject of a note's content, so it is deliberately
22
+ // NOT `personal: { of: "authorId" }`. That axis would crypto-shred every
23
+ // note's content the moment its author's data-rights key is destroyed (an
24
+ // employee leaving triggers a forget), garbling unrelated business notes
25
+ // about customers/projects/etc. — data loss, not privacy.
26
+ //
27
+ // `body` is instead `personal: { of: "id" }` (Row-Subject, fw#2596/#2786):
28
+ // the note's OWN row is its content's subject, so `body` is encrypted under a
29
+ // per-note key that nothing else shares. That key gets erased only when
30
+ // `note-mention` (below) names this note as reached by a forgotten subject
31
+ // (see `notes-history-user-data/hooks.ts`) a note with no mention on the
32
+ // forgotten subject is untouched. GDPR erasure of the AUTHOR still separately
33
+ // shreds `authorName` (`personal: { of: "authorId" }`, the name as stamped at
34
+ // write time); the two erasures are independent.
35
+ //
36
+ // Known gap (fw#2596, not fixed by this entity): prose that names a third
37
+ // party without a structured @-mention is not tracked — regex/NLP over
38
+ // free text is unreliable. The operator's manual `forget-subject` path
39
+ // covers what this structured trigger misses.
32
40
  export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
33
41
  return createEntity({
34
42
  table: "read_note_entries",
@@ -66,14 +74,40 @@ export function createNoteEntryEntity(access?: EntityDefinition["access"]) {
66
74
  body: createLongTextField({
67
75
  required: true,
68
76
  maxLength: 20_000,
69
- // Describes the host entity, not the author no per-user subject to
70
- // crypto-shred against. `personal: false` silences the
71
- // user-content-name heuristic (`body` is on PII_USER_OWNED_NAME_HINTS).
72
- personal: false,
73
- reason: "note_content_not_owned_by_author",
77
+ // Row-Subject (fw#2596/#2786): the note itself is the subject, so its
78
+ // key is erased per-note via `note-mention` lookups, not tied to any
79
+ // user's own forget.
80
+ personal: { of: "id" },
81
+ find: "none",
74
82
  }),
75
83
  },
76
84
  });
77
85
  }
78
86
 
79
87
  export const noteEntryEntity = createNoteEntryEntity();
88
+
89
+ // note-mention — join row recording that a note names a subject via a
90
+ // structured @-mention in the editor (never parsed out of `body` text — see
91
+ // the header comment above). One row per (note, mentioned subject); a note
92
+ // with several mentions gets several rows.
93
+ //
94
+ // `subjectId` is the plain FK to the mentioned user's id — `personal: "ref"`
95
+ // marks it as a subject reference for the GDPR-hook-coverage boot guard
96
+ // (same role as `authorId` on note-entry above), not itself encrypted.
97
+ // `notes-history-user-data/hooks.ts` looks this table up by `subjectId` when
98
+ // a user is forgotten, to find every note whose row-subject key must be
99
+ // erased; see that file for the cascade and the retention-consult step it
100
+ // runs first.
101
+ export function createNoteMentionEntity() {
102
+ return createEntity({
103
+ table: "read_note_mentions",
104
+ description:
105
+ "One row recording that a note names a subject via a structured @-mention, keyed by noteId and the mentioned subject's id.",
106
+ fields: {
107
+ noteId: createTextField({ required: true, maxLength: 64 }),
108
+ subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
109
+ },
110
+ });
111
+ }
112
+
113
+ export const noteMentionEntity = createNoteMentionEntity();
@@ -1,7 +1,12 @@
1
1
  import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
- import { noteEntryEntity } from "./entity";
2
+ import { noteEntryEntity, noteMentionEntity } from "./entity";
3
3
 
4
4
  export const { executor: noteEntryExecutor, table: noteEntryTable } = createEntityExecutor(
5
5
  "note-entry",
6
6
  noteEntryEntity,
7
7
  );
8
+
9
+ export const { executor: noteMentionExecutor, table: noteMentionTable } = createEntityExecutor(
10
+ "note-mention",
11
+ noteMentionEntity,
12
+ );
@@ -22,7 +22,7 @@ import {
22
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
23
  import { hasWhereRule } from "../shared";
24
24
  import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
25
- import { createNoteEntryEntity } from "./entity";
25
+ import { createNoteEntryEntity, noteMentionEntity } from "./entity";
26
26
  import { createAddNoteHandler } from "./handlers/add-note.write";
27
27
  import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
28
28
 
@@ -43,6 +43,12 @@ function registerNotesHistory(
43
43
 
44
44
  const entity = createNoteEntryEntity(ownership);
45
45
  r.entity("note-entry", entity);
46
+ // No write/query handler of its own — populated only as a side effect of
47
+ // add-note (see handlers/add-note.write.ts), looked up by
48
+ // notes-history-user-data's forget cascade. Registering the entity (not
49
+ // just building it inline in the handler) is what makes it visible to the
50
+ // registry-wide GDPR boot guards and to executor.ts's table/projection setup.
51
+ r.entity("note-mention", noteMentionEntity);
46
52
 
47
53
  r.writeHandler(createAddNoteHandler(access, parents));
48
54
  r.queryHandler(
@@ -4,7 +4,7 @@ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/error
4
4
  import { decryptStoredPii, parentRowIsVisible } from "../../shared";
5
5
  import { userTable } from "../../user";
6
6
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
7
- import { noteEntryExecutor } from "../executor";
7
+ import { noteEntryExecutor, noteMentionExecutor } from "../executor";
8
8
  import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
9
9
 
10
10
  // add-note — appends a note-entry to (entityType, entityId). authorId is
@@ -69,11 +69,27 @@ export function createAddNoteHandler(
69
69
  authorName = null;
70
70
  }
71
71
 
72
- return noteEntryExecutor.create(
73
- { ...payload, authorId: event.user.id, authorName },
72
+ const { mentions, ...notePayload } = payload;
73
+ const created = await noteEntryExecutor.create(
74
+ { ...notePayload, authorId: event.user.id, authorName },
74
75
  event.user,
75
76
  ctx.db,
76
77
  );
78
+ if (!created.isSuccess) return created;
79
+
80
+ // Same tx as the note create above (ctx.db) — a mention-row failure
81
+ // rolls the note back with it, same as the framework's own nested-write
82
+ // parent+child pattern (dispatch-write.ts).
83
+ for (const subjectId of new Set(mentions ?? [])) {
84
+ const mentionResult = await noteMentionExecutor.create(
85
+ { noteId: created.data.id, subjectId },
86
+ event.user,
87
+ ctx.db,
88
+ );
89
+ if (!mentionResult.isSuccess) return mentionResult;
90
+ }
91
+
92
+ return created;
77
93
  },
78
94
  };
79
95
  }
@@ -6,8 +6,13 @@ export {
6
6
  NotesHistoryHandlers,
7
7
  NotesHistoryQueries,
8
8
  } from "./constants";
9
- export { noteEntryEntity } from "./entity";
10
- export { noteEntryExecutor, noteEntryTable } from "./executor";
9
+ export { noteEntryEntity, noteMentionEntity } from "./entity";
10
+ export {
11
+ noteEntryExecutor,
12
+ noteEntryTable,
13
+ noteMentionExecutor,
14
+ noteMentionTable,
15
+ } from "./executor";
11
16
  export {
12
17
  createNotesHistoryFeature,
13
18
  type NotesHistoryFeatureOptions,
@@ -4,9 +4,14 @@ import { z } from "zod";
4
4
  // needed here since note-entry has no deterministic aggregate-id derived
5
5
  // from these fields (see aggregate-id note in entity.ts): a literal value
6
6
  // can't shift stream-id tuple boundaries when there is no such tuple.
7
+ // `mentions` carries structured @-mentions from the editor as user ids —
8
+ // never parsed out of `body` text (regex/NLP over free text is unreliable,
9
+ // see entity.ts). Capped well above any realistic note; a client sending more
10
+ // is a bug, not a use case worth accommodating.
7
11
  export const addNotePayloadSchema = z.object({
8
12
  entityType: z.string().min(1).max(64),
9
13
  entityId: z.string().min(1).max(128),
10
14
  body: z.string().trim().min(1).max(20_000),
15
+ mentions: z.array(z.uuid()).max(50).optional(),
11
16
  });
12
17
  export type AddNotePayload = z.infer<typeof addNotePayloadSchema>;
@@ -0,0 +1,244 @@
1
+ // notes-history mention-forget cascade (fw#2787):
2
+ //
3
+ // - a note carrying a structured @-mention on a forgotten user S has its
4
+ // `body` (Row-Subject, personal: { of: "id" }) crypto-shredded when S's
5
+ // Art. 17 forget runs (runForgetCleanup → noteEntryDeleteHook →
6
+ // note-mention lookup → kms.eraseKey on the note's own record subject)
7
+ // - a sibling note on the SAME host with NO mention on S stays fully
8
+ // readable — proves the cascade doesn't collaterally shred every note
9
+ // - the host entity's retention strategy is consulted BEFORE shredding:
10
+ // an `anonymize` override on the host blocks the erase entirely
11
+
12
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
14
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import {
16
+ configurePiiSubjectKms,
17
+ InMemoryKmsAdapter,
18
+ PII_ERASED_SENTINEL,
19
+ } from "@cosmicdrift/kumiko-framework/crypto";
20
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
21
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
23
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
24
+ import {
25
+ createTestUser,
26
+ setupTestStack,
27
+ type TestStack,
28
+ TestUsers,
29
+ unsafeCreateEntityTable,
30
+ unsafePushTables,
31
+ } from "@cosmicdrift/kumiko-framework/stack";
32
+ import {
33
+ resetPiiSubjectKmsForTests,
34
+ resetTestTables,
35
+ seedRow,
36
+ } from "@cosmicdrift/kumiko-framework/testing";
37
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
38
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
39
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
40
+ import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
41
+ import { createFilesFeature } from "../../files";
42
+ import {
43
+ createNotesHistoryFeature,
44
+ NotesHistoryHandlers,
45
+ noteEntryEntity,
46
+ noteEntryExecutor,
47
+ noteMentionEntity,
48
+ } from "../../notes-history";
49
+ import { createSessionsFeature, userSessionEntity } from "../../sessions";
50
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
51
+ import { createUserDataRightsFeature, runForgetCleanup } from "../../user-data-rights";
52
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
53
+ import { notesHistoryUserDataFeature } from "..";
54
+
55
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
56
+ function pastInstant(): Instant {
57
+ return getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
58
+ }
59
+
60
+ const CONTACT_TABLE = "notes_mention_forget_test_contacts";
61
+ const contactEntity = createEntity({
62
+ table: CONTACT_TABLE,
63
+ fields: { name: createTextField({ required: true, maxLength: 64 }) },
64
+ });
65
+ const contactFixtureFeature = defineFeature("notes-mention-forget-test-contact-fixture", (r) => {
66
+ r.entity("contact", contactEntity);
67
+ });
68
+
69
+ const author = createTestUser({ id: 1, roles: ["TenantMember"] });
70
+ const CONTACT_1 = "40000000-0000-4000-8000-000000000001";
71
+ const SUBJECT_S = "40000000-0000-4000-8000-0000000000ff";
72
+
73
+ let stack: TestStack;
74
+ let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
75
+
76
+ beforeAll(async () => {
77
+ stack = await setupTestStack({
78
+ features: [
79
+ createUserFeature(),
80
+ authFoundationFeature,
81
+ createSessionsFeature(),
82
+ createDataRetentionFeature(),
83
+ createComplianceProfilesFeature(),
84
+ createFilesFeature(),
85
+ createUserDataRightsFeature(),
86
+ createUserDataRightsDefaultsFeature(),
87
+ createNotesHistoryFeature(),
88
+ notesHistoryUserDataFeature,
89
+ contactFixtureFeature,
90
+ ],
91
+ });
92
+
93
+ await unsafeCreateEntityTable(stack.db, userEntity);
94
+ await unsafeCreateEntityTable(stack.db, userSessionEntity);
95
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
96
+ await unsafeCreateEntityTable(stack.db, noteEntryEntity, "note-entry");
97
+ await unsafeCreateEntityTable(stack.db, noteMentionEntity, "note-mention");
98
+ await unsafeCreateEntityTable(stack.db, contactEntity);
99
+ await unsafePushTables(stack.db, { fileRefsTable });
100
+ await createEventsTable(stack.db);
101
+ // tenant-membership table (from the tenant feature) manually created — same
102
+ // minimal setup as run-forget-cleanup.integration.test.ts, no tenant feature
103
+ // mounted here.
104
+ await asRawClient(stack.db).unsafe(`
105
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
106
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
107
+ tenant_id UUID NOT NULL,
108
+ user_id TEXT NOT NULL,
109
+ version INTEGER NOT NULL DEFAULT 0,
110
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
111
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
112
+ inserted_by_id TEXT,
113
+ modified_by_id TEXT,
114
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
115
+ deleted_at TIMESTAMPTZ,
116
+ deleted_by_id TEXT,
117
+ roles TEXT NOT NULL DEFAULT '[]',
118
+ UNIQUE(user_id, tenant_id)
119
+ )
120
+ `);
121
+ await asRawClient(stack.db).unsafe(
122
+ `INSERT INTO ${CONTACT_TABLE} (id, tenant_id, name) VALUES ($1, $2, $3)
123
+ ON CONFLICT (id) DO NOTHING`,
124
+ [CONTACT_1, author.tenantId, "Contact 1"],
125
+ );
126
+
127
+ overrideExecutor = createEventStoreExecutor(
128
+ tenantRetentionOverrideTable,
129
+ tenantRetentionOverrideEntity,
130
+ { entityName: "tenant-retention-override" },
131
+ );
132
+ });
133
+
134
+ afterAll(async () => {
135
+ await stack.cleanup();
136
+ });
137
+
138
+ beforeEach(async () => {
139
+ await resetTestTables(stack.db, [
140
+ userTable,
141
+ "read_tenant_memberships",
142
+ tenantRetentionOverrideTable,
143
+ ]);
144
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
145
+ });
146
+
147
+ afterEach(() => {
148
+ resetPiiSubjectKmsForTests();
149
+ });
150
+
151
+ async function seedForgottenSubject(): Promise<void> {
152
+ await seedRow(stack.db, userTable, {
153
+ id: SUBJECT_S,
154
+ tenantId: author.tenantId,
155
+ email: "subject-s@example.com",
156
+ passwordHash: "hashed",
157
+ displayName: "Subject S",
158
+ locale: "de",
159
+ emailVerified: true,
160
+ roles: '["Member"]',
161
+ status: USER_STATUS.DeletionRequested,
162
+ gracePeriodEnd: pastInstant(),
163
+ });
164
+ await asRawClient(stack.db).unsafe(
165
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
166
+ VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
167
+ [author.tenantId, SUBJECT_S],
168
+ );
169
+ }
170
+
171
+ async function seedContactRetentionOverride(strategy: "anonymize" | "delete"): Promise<void> {
172
+ const by = { ...TestUsers.systemAdmin, tenantId: author.tenantId };
173
+ const result = await overrideExecutor.create(
174
+ {
175
+ entityName: "contact",
176
+ config: JSON.stringify({ keepFor: "1y", strategy }),
177
+ reason: "test",
178
+ tenantId: author.tenantId,
179
+ },
180
+ by,
181
+ createTenantDb(stack.db, author.tenantId, "system"),
182
+ );
183
+ if (!result.isSuccess)
184
+ throw new Error(`seedContactRetentionOverride failed: ${JSON.stringify(result)}`);
185
+ }
186
+
187
+ describe("notes-history mention-forget cascade", () => {
188
+ test("shreds only the note mentioning the forgotten subject, leaves the unmentioned note readable", async () => {
189
+ await seedForgottenSubject();
190
+ const tenantDb = createTenantDb(stack.db, author.tenantId, "system");
191
+
192
+ const mentioning = await stack.http.writeOk<{ id: string }>(
193
+ NotesHistoryHandlers.addNote,
194
+ { entityType: "contact", entityId: CONTACT_1, body: "about S", mentions: [SUBJECT_S] },
195
+ author,
196
+ );
197
+ const unrelated = await stack.http.writeOk<{ id: string }>(
198
+ NotesHistoryHandlers.addNote,
199
+ { entityType: "contact", entityId: CONTACT_1, body: "unrelated note" },
200
+ author,
201
+ );
202
+
203
+ const result = await runForgetCleanup({
204
+ db: stack.db,
205
+ registry: stack.registry,
206
+ now: getTemporal().Now.instant(),
207
+ });
208
+ expect(result.processedUserIds).toContain(SUBJECT_S);
209
+ expect(result.errors).toEqual([]);
210
+
211
+ const shredded = await noteEntryExecutor.detail({ id: mentioning.id }, author, tenantDb);
212
+ expect(shredded?.["body"]).toBe(PII_ERASED_SENTINEL);
213
+
214
+ const untouched = await noteEntryExecutor.detail({ id: unrelated.id }, author, tenantDb);
215
+ expect(untouched?.["body"]).toBe("unrelated note");
216
+ });
217
+
218
+ test("host entity retention override (anonymize) blocks the shred", async () => {
219
+ await seedForgottenSubject();
220
+ await seedContactRetentionOverride("anonymize");
221
+ const tenantDb = createTenantDb(stack.db, author.tenantId, "system");
222
+
223
+ const mentioning = await stack.http.writeOk<{ id: string }>(
224
+ NotesHistoryHandlers.addNote,
225
+ {
226
+ entityType: "contact",
227
+ entityId: CONTACT_1,
228
+ body: "kept under retention",
229
+ mentions: [SUBJECT_S],
230
+ },
231
+ author,
232
+ );
233
+
234
+ const result = await runForgetCleanup({
235
+ db: stack.db,
236
+ registry: stack.registry,
237
+ now: getTemporal().Now.instant(),
238
+ });
239
+ expect(result.processedUserIds).toContain(SUBJECT_S);
240
+
241
+ const stillReadable = await noteEntryExecutor.detail({ id: mentioning.id }, author, tenantDb);
242
+ expect(stillReadable?.["body"]).toBe("kept under retention");
243
+ });
244
+ });
@@ -4,9 +4,12 @@
4
4
  // dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
5
5
  // export-only, erasure via crypto-shredding.
6
6
 
7
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
8
9
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
9
- import { noteEntryTable } from "../notes-history";
10
+ import { resolveRetentionPolicyForTenant } from "../data-retention";
11
+ import { noteEntryTable, noteMentionTable } from "../notes-history";
12
+ import { policyToStrategy } from "../user-data-rights";
10
13
 
11
14
  // note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
12
15
  // per-user content, so the export filters by authorId directly.
@@ -27,19 +30,69 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
27
30
  };
28
31
  };
29
32
 
30
- // Deliberate no-op: only `authorName` is annotated `personal: { of: "authorId" }`
31
- // (entity.ts) `body` is deliberately plaintext (`personal: false`), since it
32
- // describes the HOST entity the note is attached to, not the author. So a
33
- // forget here crypto-shreds (mounted KMS) just `authorName`: after erasing
34
- // the author, who wrote a note is no longer visible, but the note's content
35
- // stays intact and readable the note is about the host entity, and other
36
- // readers of that entity's history still need it. No physical delete is
37
- // needed for either outcome.
38
- // Same tradeoff as job-run/delivery-attempt (user-data-rights-defaults).
33
+ // `authorName` is separately annotated `personal: { of: "authorId" }`
34
+ // (entity.ts), so forgetting the AUTHOR already crypto-shreds that field on
35
+ // its own unrelated to this hook. `body` is Row-Subject (`personal: { of:
36
+ // "id" }`): its key is per-note, not per-user, so forgetting a user only
37
+ // reaches a note's body when that note structurally @-mentions the user via
38
+ // a `note-mention` row (fw#2787). Free-text mentions are NOT detected
39
+ // that's the documented gap in entity.ts, not a bug here.
40
+ //
41
+ // Per reached note: consult the HOST entity's (entityType, not note-entry's
42
+ // own) retention strategy first — blockDelete/anonymize must win over
43
+ // erasure, same rule the automated forget-cleanup pipeline applies to every
44
+ // other entity. The `strategy` this hook itself is called with is resolved
45
+ // for note-entry, not the host, so it is deliberately unused here.
46
+ //
39
47
  // Precondition: this ONLY erases anything if the app mounts a KMS adapter —
40
- // without one, userOwned fields fall back to plaintext storage framework-wide
41
- // (see pii-field-encryption.ts) and forget is a true no-op for `authorName`
42
- // too. That gap is a property of the framework's crypto-shredding design,
43
- // not specific to this hook; apps that need Art.17 coverage without KMS must
48
+ // without one, record-owned fields fall back to plaintext storage
49
+ // framework-wide (see pii-field-encryption.ts) and forget is a true no-op.
50
+ // That gap is a property of the framework's crypto-shredding design, not
51
+ // specific to this hook; apps that need Art.17 coverage without KMS must
44
52
  // mount one.
45
- export const noteEntryDeleteHook: UserDataDeleteHook = async () => {};
53
+ export const noteEntryDeleteHook: UserDataDeleteHook = async (ctx) => {
54
+ const kms = configuredPiiSubjectKms();
55
+ // skip: no KMS adapter mounted — record-owned fields stay plaintext framework-wide, forget is a true no-op
56
+ if (!kms) return;
57
+
58
+ const mentions = await selectMany<{ noteId: string }>(ctx.db, noteMentionTable, {
59
+ subjectId: ctx.userId,
60
+ });
61
+ // skip: no note-mention rows for this user — nothing structurally reaches this user's data to shred
62
+ if (mentions.length === 0) return;
63
+
64
+ const noteIds = new Set(mentions.map((m) => m.noteId));
65
+ for (const noteId of noteIds) {
66
+ const note = await fetchOne<{ entityType: string }>(ctx.db, noteEntryTable, { id: noteId });
67
+ // Defensive: append-only rows are never hard-deleted, so this shouldn't
68
+ // happen — but a missing host means no retention policy to consult.
69
+ if (!note) continue;
70
+
71
+ const hostPolicy = await resolveRetentionPolicyForTenant({
72
+ db: ctx.db,
73
+ registry: ctx.registry,
74
+ tenantId: ctx.tenantId,
75
+ entityName: note.entityType,
76
+ });
77
+ if (policyToStrategy(hostPolicy.policy?.strategy ?? null) === "anonymize") continue;
78
+
79
+ await kms.eraseKey(
80
+ { kind: "record", entity: "note-entry", id: noteId },
81
+ {
82
+ requestId: "notes-history-user-data:forget-mentioned-notes",
83
+ userId: ctx.userId,
84
+ eraseReason: "notes-history:mention-forget",
85
+ },
86
+ );
87
+ }
88
+ };
89
+
90
+ // note-mention rows are a plain (noteId, subjectId) pointer — like authorId
91
+ // on note-entry, `subjectId`'s `personal: "ref"` annotation exists for the
92
+ // GDPR-hook-coverage boot guard, not because the row itself holds separately
93
+ // exportable content or needs its own physical erasure: forgetting the
94
+ // mentioned user's data is already handled by noteEntryDeleteHook shredding
95
+ // the mentioned NOTE's row-subject key above. Registered here only so the V3
96
+ // boot guard (validateGdprPiiHookCoverage) sees a hook for "note-mention".
97
+ export const noteMentionExportHook: UserDataExportHook = async () => null;
98
+ export const noteMentionDeleteHook: UserDataDeleteHook = async () => {};
@@ -6,11 +6,16 @@
6
6
  // user-data-rights stack. Mirrors folders-user-data.
7
7
 
8
8
  import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
- import { noteEntryDeleteHook, noteEntryExportHook } from "./hooks";
9
+ import {
10
+ noteEntryDeleteHook,
11
+ noteEntryExportHook,
12
+ noteMentionDeleteHook,
13
+ noteMentionExportHook,
14
+ } from "./hooks";
10
15
 
11
16
  export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
12
17
  r.describe(
13
- "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` entity. Mounts the EXT_USER_DATA export hook so a user's authored notes are included in the user-data export bundle; the delete hook is a deliberate no-op because `body` is annotated `userOwned` on the entity erasure runs via crypto-shredding (destroying the author's subject key) instead of a physical delete, keeping the append-only note history intact for entities other co-authors still read. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
18
+ "GDPR (Art. 20 export / Art. 17 erasure) coverage for the `notes-history` feature's `note-entry` and `note-mention` entities. Mounts the export hook so a user's authored notes are included in the user-data export bundle; the note-entry delete hook looks up `note-mention` for notes that structurally @-mention the forgotten user and crypto-shreds each reached note's row-subject key (after consulting that note's host entity's retention strategy — blockDelete/anonymize win over erasure), keeping the append-only history intact for notes without such a mention. `note-mention`'s own hooks are a no-op — it is a plain FK pointer, not separately exportable content. Kept separate from `notes-history` so notes consumers without the user-data-rights pipeline don't pull a hard dependency — requires `user-data-rights`, optionalRequires `notes-history`.",
14
19
  );
15
20
  // user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
16
21
  // is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
@@ -22,4 +27,8 @@ export const notesHistoryUserDataFeature = defineFeature("notes-history-user-dat
22
27
  export: noteEntryExportHook,
23
28
  delete: noteEntryDeleteHook,
24
29
  });
30
+ r.useExtension(EXT_USER_DATA, "note-mention", {
31
+ export: noteMentionExportHook,
32
+ delete: noteMentionDeleteHook,
33
+ });
25
34
  });
@@ -87,6 +87,7 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
87
87
  const id = generateId();
88
88
  const row = await encryptForDirectWrite(
89
89
  apiTokenEntity,
90
+ "api-token",
90
91
  {
91
92
  id,
92
93
  userId: event.user.id,
@@ -85,6 +85,7 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
85
85
  userSessionTable,
86
86
  await encryptForDirectWrite(
87
87
  userSessionEntity,
88
+ "user-session",
88
89
  {
89
90
  id: sid,
90
91
  tenantId: user.tenantId,
@@ -12,12 +12,18 @@ import type { EntityDefinition } from "@cosmicdrift/kumiko-framework/engine";
12
12
  // true } at the registration site (enforced by the registry, #820).
13
13
  export async function encryptForDirectWrite(
14
14
  entity: EntityDefinition,
15
+ entityName: string,
15
16
  row: Record<string, unknown>,
16
17
  fallbackRequestId: string,
17
18
  ): Promise<Record<string, unknown>> {
18
19
  const kms = configuredPiiSubjectKms();
19
20
  if (!kms) return row;
20
- return encryptPiiFieldValues(row, entity, collectPiiSubjectFields(entity), kms, {
21
- requestId: requestContext.get()?.requestId ?? fallbackRequestId,
22
- });
21
+ return encryptPiiFieldValues(
22
+ row,
23
+ entity,
24
+ collectPiiSubjectFields(entity),
25
+ kms,
26
+ { requestId: requestContext.get()?.requestId ?? fallbackRequestId },
27
+ { entityName },
28
+ );
23
29
  }
@@ -333,6 +333,7 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
333
333
  ["email", "displayName"],
334
334
  kms,
335
335
  { requestId: "test" },
336
+ { entityName: "user" },
336
337
  );
337
338
  await asRawClient(stack.db).unsafe(
338
339
  `UPDATE read_users SET email = $1, display_name = $2 WHERE id = $3`,
@@ -366,6 +367,7 @@ describe("runUserExport :: PII-Subject-Ciphertexte (#820)", () => {
366
367
  ["email"],
367
368
  kms,
368
369
  { requestId: "test" },
370
+ { entityName: "user" },
369
371
  );
370
372
  await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
371
373
  String(encrypted["email"]),
@@ -414,6 +416,7 @@ describe("runUserExport :: tenant-invitation PII export (#1937)", () => {
414
416
  ["email"],
415
417
  kms,
416
418
  { requestId: "test" },
419
+ { entityName: "user" },
417
420
  );
418
421
  await asRawClient(stack.db).unsafe(`UPDATE read_users SET email = $1 WHERE id = $2`, [
419
422
  String(encryptedUser["email"]),
@@ -550,6 +553,7 @@ describe("runUserExport :: fileRef PII export via the fileRefs side-channel (#19
550
553
  ["fileName"],
551
554
  kms,
552
555
  { requestId: "test" },
556
+ { entityName: "fileRef" },
553
557
  );
554
558
  await asRawClient(stack.db).unsafe(
555
559
  `
@@ -16,7 +16,12 @@ export type { SendDeletionExecutedEmailFn } from "./run-forget-cleanup";
16
16
  // Runner-Exports — App-Tests dürfen export/forget deterministisch laufen
17
17
  // lassen, statt über den Job-Cron zu warten (siehe sample
18
18
  // user-data-rights-demo).
19
- export { runForgetCleanup } from "./run-forget-cleanup";
19
+ // Shared retention→strategy mapping (single source of truth per its own
20
+ // doc comment) — other EXT_USER_DATA delete hooks that must independently
21
+ // consult a DIFFERENT entity's retention (e.g. notes-history-user-data's
22
+ // host-entity check) reuse this instead of re-deriving the anonymize/delete
23
+ // split.
24
+ export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup";
20
25
  export type { UserExportBundle } from "./run-user-export";
21
26
  export { runUserExport } from "./run-user-export";
22
27
  export {