@cosmicdrift/kumiko-bundled-features 0.252.1 → 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.
Files changed (93) hide show
  1. package/package.json +9 -9
  2. package/src/agent-tools/__tests__/agent-manifest.test.ts +2 -2
  3. package/src/agent-tools/__tests__/agent-optout.test.ts +22 -3
  4. package/src/agent-tools/__tests__/agent-tools.integration.test.ts +7 -1
  5. package/src/agent-tools/__tests__/tool-catalog.test.ts +11 -4
  6. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +13 -2
  7. package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
  8. package/src/audit/__tests__/audit.integration.test.ts +2 -2
  9. package/src/billing-foundation/entities.ts +30 -5
  10. package/src/billing-foundation/handlers/process-event.write.ts +1 -0
  11. package/src/billing-foundation/handlers/process-payment-event.write.ts +1 -0
  12. package/src/cap-counter/entity.ts +8 -1
  13. package/src/config/table.ts +4 -2
  14. package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +141 -1
  15. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +1 -1
  16. package/src/crypto-shredding/constants.ts +1 -0
  17. package/src/crypto-shredding/handlers/forget-subject.write.ts +42 -0
  18. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  19. package/src/custom-fields/__tests__/cross-tenant-field-delete.integration.test.ts +1 -1
  20. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +1 -1
  21. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +1 -1
  22. package/src/custom-fields/__tests__/field-access.integration.test.ts +1 -1
  23. package/src/custom-fields/__tests__/quota.integration.test.ts +1 -1
  24. package/src/custom-fields/__tests__/retention.integration.test.ts +1 -1
  25. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +1 -1
  26. package/src/custom-fields/__tests__/wire-for-entity.test.ts +1 -1
  27. package/src/custom-fields/entity.ts +24 -4
  28. package/src/data-retention/__tests__/resolver.test.ts +6 -4
  29. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  30. package/src/delivery/__tests__/delivery.integration.test.ts +3 -3
  31. package/src/delivery/tables.ts +6 -2
  32. package/src/document-ingest-foundation/entity.ts +6 -2
  33. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +12 -2
  34. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -1
  35. package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +12 -2
  36. package/src/folders/__tests__/folders.integration.test.ts +8 -1
  37. package/src/folders/entity.ts +27 -5
  38. package/src/folders-user-data/__tests__/hooks.integration.test.ts +8 -1
  39. package/src/form-draft/entity.ts +6 -1
  40. package/src/inbound-mail-foundation/entities.ts +57 -12
  41. package/src/inbound-mail-foundation/handlers/connect-account.write.ts +8 -4
  42. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +3 -4
  43. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +111 -1
  44. package/src/jobs/__tests__/projection-rebuild-job.integration.test.ts +2 -2
  45. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +6 -1
  46. package/src/jobs/db/queries/stale-run-sweep.ts +36 -14
  47. package/src/jobs/feature.ts +5 -0
  48. package/src/jobs/job-run-logger.ts +7 -2
  49. package/src/jobs/job-run-table.ts +11 -6
  50. package/src/ledger/entity.ts +27 -7
  51. package/src/managed-pages/screens/branding-screen.ts +27 -5
  52. package/src/managed-pages/table.ts +23 -4
  53. package/src/notes-history/__tests__/add-note-author-name-kms.integration.test.ts +8 -1
  54. package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +8 -1
  55. package/src/notes-history/__tests__/notes-history-parent-visibility.integration.test.ts +20 -3
  56. package/src/notes-history/__tests__/notes-history.integration.test.ts +8 -1
  57. package/src/notes-history/entity.ts +63 -19
  58. package/src/notes-history/executor.ts +6 -1
  59. package/src/notes-history/feature.ts +7 -1
  60. package/src/notes-history/handlers/add-note.write.ts +19 -3
  61. package/src/notes-history/index.ts +7 -2
  62. package/src/notes-history/schemas.ts +5 -0
  63. package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +8 -1
  64. package/src/notes-history-user-data/__tests__/mention-forget.integration.test.ts +244 -0
  65. package/src/notes-history-user-data/hooks.ts +69 -16
  66. package/src/notes-history-user-data/index.ts +11 -2
  67. package/src/personal-access-tokens/handlers/create.write.ts +1 -0
  68. package/src/personal-access-tokens/schema/api-token.ts +29 -1
  69. package/src/secrets/table.ts +1 -1
  70. package/src/sessions/schema/user-session.ts +4 -0
  71. package/src/sessions/session-callbacks.ts +1 -0
  72. package/src/shared/encrypt-for-direct-write.ts +9 -3
  73. package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +12 -2
  74. package/src/tags/__tests__/tags.integration.test.ts +8 -1
  75. package/src/tags/entity.ts +20 -5
  76. package/src/template-resolver/table.ts +8 -7
  77. package/src/template-resolver/user-content-table.ts +6 -4
  78. package/src/tenant/invitation-table.ts +6 -1
  79. package/src/tenant/membership-table.ts +2 -2
  80. package/src/tenant/schema/tenant.ts +22 -3
  81. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +1 -1
  82. package/src/tier-engine/entity.ts +12 -2
  83. package/src/user/schema/user.ts +15 -1
  84. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -2
  85. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +1 -1
  86. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +1 -1
  87. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +3 -1
  88. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -0
  89. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +3 -1
  90. package/src/user-data-rights/index.ts +6 -1
  91. package/src/user-data-rights/schema/download-attempt.ts +25 -7
  92. package/src/user-data-rights/schema/download-token.ts +4 -0
  93. package/src/user-data-rights/schema/export-job.ts +6 -0
@@ -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,
@@ -23,11 +23,15 @@ export const apiTokenEntity = createEntity({
23
23
  required: true,
24
24
  maxLength: 36,
25
25
  access: { write: access.privileged },
26
+ personal: false,
27
+ reason: "pseudonymous_fk",
26
28
  }),
27
29
  tenantId: createTextField({
28
30
  required: true,
29
31
  maxLength: 36,
30
32
  access: { write: access.privileged },
33
+ personal: false,
34
+ reason: "pseudonymous_fk",
31
35
  }),
32
36
  name: createTextField({
33
37
  required: true,
@@ -36,19 +40,43 @@ export const apiTokenEntity = createEntity({
36
40
  personal: { of: "userId" },
37
41
  find: "none",
38
42
  }),
43
+ // SHA-256 hash of the plaintext token — credential material, but kept
44
+ // Klasse 1 deliberately: resolver.ts point-reads this row with a raw
45
+ // exact-match `{ tokenHash: hashPatToken(rawToken) }` query on the hot
46
+ // auth path. A subject annotation here would make encryptForDirectWrite
47
+ // store ciphertext while the resolver keeps querying for the plaintext
48
+ // hash — every bearer-token request would 401 once a KMS is active.
49
+ // Fixing that needs a blind-index-backed lookup (like email's
50
+ // find:"exact" companion column), a resolver.ts rewrite out of scope
51
+ // here — see PR body "Offene Fragen".
39
52
  tokenHash: createTextField({
40
53
  required: true,
41
54
  maxLength: 64,
42
55
  access: { write: access.privileged },
56
+ personal: false,
57
+ reason: "credential_hash_used_as_exact_lookup_key_2809",
43
58
  }),
59
+ // Short displayable prefix ("your tokens" list) — not the secret itself,
60
+ // but still identifies which of the user's tokens this row is. Kept
61
+ // Klasse 1: list.query.ts returns `r.prefix` straight through with no
62
+ // decrypt call (unlike `name`, which goes through decryptStoredPii) — a
63
+ // subject annotation would leak ciphertext to the UI once a KMS is
64
+ // active. See PR body "Offene Fragen".
44
65
  prefix: createTextField({
45
66
  required: true,
46
67
  maxLength: 16,
47
68
  access: { write: access.privileged },
69
+ personal: false,
70
+ reason: "credential_prefix_read_without_decrypt_2809",
48
71
  }),
49
72
  // JSON-encoded string[] of granted scope names — mirrors the roles-column
50
73
  // convention (parseRoles-style text); the resolver JSON.parses it.
51
- scopes: createTextField({ required: true, access: { write: access.privileged } }),
74
+ scopes: createTextField({
75
+ required: true,
76
+ access: { write: access.privileged },
77
+ personal: false,
78
+ reason: "technical_reference",
79
+ }),
52
80
  createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
53
81
  expiresAt: createTimestampField({ access: { write: access.privileged } }),
54
82
  revokedAt: createTimestampField({ access: { write: access.privileged } }),
@@ -50,7 +50,7 @@ export const tenantSecretEntity = createEntity({
50
50
  description:
51
51
  "One encrypted secret per tenant and key, holding the sealed envelope, the key-encryption-key version it was sealed under and its last rotation time; plaintext values are never stored or returned.",
52
52
  fields: {
53
- key: createTextField({ required: true }),
53
+ key: createTextField({ required: true, personal: false, reason: "technical_reference" }),
54
54
  kekVersion: createNumberField({ required: true, integer: true }),
55
55
  },
56
56
  });
@@ -33,11 +33,15 @@ export const userSessionEntity = createEntity({
33
33
  required: true,
34
34
  maxLength: 36,
35
35
  access: { write: access.privileged },
36
+ personal: false,
37
+ reason: "pseudonymous_fk",
36
38
  }),
37
39
  tenantId: createTextField({
38
40
  required: true,
39
41
  maxLength: 36,
40
42
  access: { write: access.privileged },
43
+ personal: false,
44
+ reason: "pseudonymous_fk",
41
45
  }),
42
46
  createdAt: createTimestampField({
43
47
  required: true,
@@ -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
  }
@@ -47,8 +47,18 @@ const projectOwnership: NonNullable<EntityDefinition["access"]> = {
47
47
  const projectEntity = createEntity({
48
48
  table: PROJECT_TABLE,
49
49
  fields: {
50
- teamId: createTextField({ required: true, maxLength: 64 }),
51
- name: createTextField({ required: true, maxLength: 64 }),
50
+ teamId: createTextField({
51
+ required: true,
52
+ maxLength: 64,
53
+ personal: false,
54
+ reason: "technical_reference",
55
+ }),
56
+ name: createTextField({
57
+ required: true,
58
+ maxLength: 64,
59
+ personal: false,
60
+ reason: "technical_reference",
61
+ }),
52
62
  },
53
63
  access: projectOwnership,
54
64
  });
@@ -33,7 +33,14 @@ const tagsFeature = createTagsFeature();
33
33
 
34
34
  const CREDIT_TABLE = "tags_test_credits";
35
35
  const NOTE_TABLE = "tags_test_notes";
36
- const hostFields = { name: createTextField({ required: true, maxLength: 64 }) };
36
+ const hostFields = {
37
+ name: createTextField({
38
+ required: true,
39
+ maxLength: 64,
40
+ personal: false,
41
+ reason: "technical_reference",
42
+ }),
43
+ };
37
44
  const creditEntity = createEntity({ table: CREDIT_TABLE, fields: hostFields });
38
45
  const noteEntity = createEntity({ table: NOTE_TABLE, fields: hostFields });
39
46
  const hostFixturesFeature = defineFeature("tags-test-host-fixtures", (r) => {
@@ -28,11 +28,11 @@ export const tagEntity = createEntity({
28
28
  reason: "catalog_label",
29
29
  }),
30
30
  // Optional UI hint (hex or token). No enforcement — purely for rendering.
31
- color: createTextField({ maxLength: 32 }),
31
+ color: createTextField({ maxLength: 32, personal: false, reason: "technical_reference" }),
32
32
  // Optional entity-type scope (GitLab project-vs-group labels): empty = global
33
33
  // (offered on every entity); a value like "note" restricts the tag to that
34
34
  // entityType in the picker. No enforcement on assign — purely a picker hint.
35
- scope: createTextField({ maxLength: 64 }),
35
+ scope: createTextField({ maxLength: 64, personal: false, reason: "technical_reference" }),
36
36
  },
37
37
  });
38
38
 
@@ -63,10 +63,25 @@ export function createTagAssignmentEntity(access?: EntityDefinition["access"]) {
63
63
  softDelete: true,
64
64
  access,
65
65
  fields: {
66
- tagId: createTextField({ required: true, maxLength: 64 }),
67
- entityType: createTextField({ required: true, maxLength: 64 }),
66
+ tagId: createTextField({
67
+ required: true,
68
+ maxLength: 64,
69
+ personal: false,
70
+ reason: "technical_reference",
71
+ }),
72
+ entityType: createTextField({
73
+ required: true,
74
+ maxLength: 64,
75
+ personal: false,
76
+ reason: "technical_reference",
77
+ }),
68
78
  // Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
69
- entityId: createTextField({ required: true, maxLength: 128 }),
79
+ entityId: createTextField({
80
+ required: true,
81
+ maxLength: 128,
82
+ personal: false,
83
+ reason: "technical_reference",
84
+ }),
70
85
  },
71
86
  });
72
87
  }
@@ -18,16 +18,17 @@ import { CONTENT_FORMATS, TEMPLATE_KINDS, TEMPLATE_SCOPES, TEMPLATE_STATUSES } f
18
18
  export const templateResourceEntity = createEntity({
19
19
  table: "read_template_resources",
20
20
  fields: {
21
- slug: createTextField({ required: true }),
21
+ slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
22
22
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
23
- locale: createTextField({ required: true }),
23
+ locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
24
24
  // Human-facing heading for kind="text-block" (legal pages, marketing
25
25
  // copy). Nullable because render templates carry their subject inside
26
26
  // the body; a NOT NULL here would break every existing mail row.
27
- title: createTextField({}),
27
+ // Same authorship class as `content` below.
28
+ title: createTextField({ personal: false, reason: "is_business_data" }),
28
29
  // Folder path for the content tree, `/`-separated ("page/marketing").
29
30
  // null = root node.
30
- folder: createTextField({}),
31
+ folder: createTextField({ personal: false, reason: "technical_reference" }),
31
32
  // Template-body is authored by TenantAdmin/Operator (email-templates etc.),
32
33
  // business data — kein end-user UGC.
33
34
  content: createLongTextField({
@@ -35,10 +36,10 @@ export const templateResourceEntity = createEntity({
35
36
  reason: "is_business_data",
36
37
  }),
37
38
  contentFormat: createSelectField({ required: true, options: [...CONTENT_FORMATS] }),
38
- variableSchema: createLongTextField({}),
39
- linkedResources: createLongTextField({}),
39
+ variableSchema: createLongTextField({ personal: false, reason: "technical_reference" }),
40
+ linkedResources: createLongTextField({ personal: false, reason: "technical_reference" }),
40
41
  scope: createSelectField({ required: true, options: [...TEMPLATE_SCOPES] }),
41
- parentTemplateId: createTextField({}),
42
+ parentTemplateId: createTextField({ personal: false, reason: "technical_reference" }),
42
43
  status: createSelectField({ required: true, options: [...TEMPLATE_STATUSES] }),
43
44
  },
44
45
  indexes: [
@@ -25,11 +25,13 @@ export const userContentEntryEntity = createEntity({
25
25
  table: "read_user_content_entries",
26
26
  fields: {
27
27
  ownerId: createTextField({ required: true, personal: "ref" }),
28
- slug: createTextField({ required: true }),
28
+ slug: createTextField({ required: true, personal: false, reason: "technical_reference" }),
29
29
  kind: createSelectField({ required: true, options: [...TEMPLATE_KINDS] }),
30
- locale: createTextField({ required: true }),
31
- title: createTextField({}),
32
- folder: createTextField({}),
30
+ locale: createTextField({ required: true, personal: false, reason: "technical_reference" }),
31
+ // Organizational label for the entry (e.g. "Default signature"), not the
32
+ // owner's PII itself — that lives in `content` below.
33
+ title: createTextField({ personal: false, reason: "technical_reference" }),
34
+ folder: createTextField({ personal: false, reason: "technical_reference" }),
33
35
  content: createLongTextField({
34
36
  personal: { of: "ownerId" },
35
37
  find: "none",
@@ -61,7 +61,12 @@ export const tenantInvitationEntity = createEntity({
61
61
  email: createTextField({ required: true, maxLength: 320, personal: "self", find: "exact" }),
62
62
  // Membership-Rolle die dem User nach Accept gegeben wird. Default
63
63
  // im handler ist "Admin" (Co-Admin-Pattern für kleine Teams).
64
- role: createTextField({ required: true, maxLength: 50 }),
64
+ role: createTextField({
65
+ required: true,
66
+ maxLength: 50,
67
+ personal: false,
68
+ reason: "technical_reference",
69
+ }),
65
70
  // Lifecycle-State. Default "pending"; transitions:
66
71
  // pending → accepted | cancelled | expired
67
72
  status: createSelectField({