@cosmicdrift/kumiko-bundled-features 0.173.0 → 0.174.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 +10 -7
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +27 -0
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +143 -2
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -0
- package/src/notes-history/__tests__/notes-history.integration.test.ts +138 -0
- package/src/notes-history/changes.json +1 -0
- package/src/notes-history/constants.ts +34 -0
- package/src/notes-history/entity.ts +37 -0
- package/src/notes-history/executor.ts +7 -0
- package/src/notes-history/feature.ts +77 -0
- package/src/notes-history/handlers/add-note.write.ts +25 -0
- package/src/notes-history/i18n.ts +3 -0
- package/src/notes-history/index.ts +20 -0
- package/src/notes-history/schemas.ts +12 -0
- package/src/notes-history/web/client-plugin.tsx +16 -0
- package/src/notes-history/web/i18n.ts +26 -0
- package/src/notes-history/web/index.ts +8 -0
- package/src/notes-history/web/notes-section.tsx +157 -0
- package/src/notes-history-user-data/__tests__/hooks.integration.test.ts +79 -0
- package/src/notes-history-user-data/changes.json +1 -0
- package/src/notes-history-user-data/hooks.ts +40 -0
- package/src/notes-history-user-data/index.ts +25 -0
- package/src/tenant-settings/tenant-defaults.ts +5 -2
- package/src/user/__tests__/user.integration.test.ts +17 -0
- package/src/user-data-rights/__tests__/default-mailers.test.ts +31 -0
- package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +172 -0
- package/src/user-data-rights/run-forget-cleanup.ts +18 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.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>",
|
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
"./tags/web": "./src/tags/web/index.ts",
|
|
43
43
|
"./folders": "./src/folders/index.ts",
|
|
44
44
|
"./folders/web": "./src/folders/web/index.ts",
|
|
45
|
+
"./notes-history": "./src/notes-history/index.ts",
|
|
46
|
+
"./notes-history/web": "./src/notes-history/web/index.ts",
|
|
47
|
+
"./notes-history-user-data": "./src/notes-history-user-data/index.ts",
|
|
45
48
|
"./folders-user-data": "./src/folders-user-data/index.ts",
|
|
46
49
|
"./ledger": "./src/ledger/index.ts",
|
|
47
50
|
"./ledger/web": "./src/ledger/web/index.ts",
|
|
@@ -119,12 +122,12 @@
|
|
|
119
122
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
120
123
|
},
|
|
121
124
|
"dependencies": {
|
|
122
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
124
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
125
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
|
|
126
|
+
"@cosmicdrift/kumiko-framework": "0.174.0",
|
|
127
|
+
"@cosmicdrift/kumiko-headless": "0.174.0",
|
|
128
|
+
"@cosmicdrift/kumiko-renderer": "0.174.0",
|
|
129
|
+
"@cosmicdrift/kumiko-renderer-web": "0.174.0",
|
|
130
|
+
"@cosmicdrift/kumiko-types": "0.174.0",
|
|
128
131
|
"@mollie/api-client": "^4.5.0",
|
|
129
132
|
"imapflow": "^1.3.3",
|
|
130
133
|
"mailparser": "^3.9.8",
|
|
@@ -111,6 +111,33 @@ describe("seedAdmin", () => {
|
|
|
111
111
|
expect(betaMembership[0]?.["roles"]).toBe(JSON.stringify(["User"]));
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
test("emailVerified: true wird an seedUserWithPassword durchgereicht", async () => {
|
|
115
|
+
await seedAdmin(stack.db, {
|
|
116
|
+
email: "verified@example.com",
|
|
117
|
+
password: "secret-pw",
|
|
118
|
+
displayName: "Verified Admin",
|
|
119
|
+
emailVerified: true,
|
|
120
|
+
memberships: [
|
|
121
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
const [user] = await selectMany(stack.db, userTable, { email: "verified@example.com" });
|
|
125
|
+
expect(user?.["emailVerified"]).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("emailVerified: ohne Flag bleibt der User unverifiziert (Default)", async () => {
|
|
129
|
+
await seedAdmin(stack.db, {
|
|
130
|
+
email: "unverified@example.com",
|
|
131
|
+
password: "secret-pw",
|
|
132
|
+
displayName: "Unverified Admin",
|
|
133
|
+
memberships: [
|
|
134
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
const [user] = await selectMany(stack.db, userTable, { email: "unverified@example.com" });
|
|
138
|
+
expect(user?.["emailVerified"]).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
114
141
|
test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
|
|
115
142
|
// Erstaufruf
|
|
116
143
|
const { id: userId1 } = await seedAdmin(stack.db, {
|
|
@@ -10,9 +10,25 @@
|
|
|
10
10
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
11
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
12
|
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
13
|
-
import
|
|
13
|
+
import {
|
|
14
|
+
buildEntityTable,
|
|
15
|
+
createEventStoreExecutor,
|
|
16
|
+
createTenantDb,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import {
|
|
19
|
+
createEntity,
|
|
20
|
+
createTextField,
|
|
21
|
+
defineFeature,
|
|
22
|
+
type TenantId,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
14
24
|
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
TestUsers,
|
|
29
|
+
testTenantId,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
32
|
import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
33
|
import { SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
|
|
18
34
|
import { createCryptoShreddingFeature } from "../feature";
|
|
@@ -142,3 +158,128 @@ describe("crypto-shredding :: forget-subject", () => {
|
|
|
142
158
|
expect(err.httpStatus).toBe(400);
|
|
143
159
|
});
|
|
144
160
|
});
|
|
161
|
+
|
|
162
|
+
// fw#1611: forget-subject's ctx.searchAdapter wiring (forget-subject.write.ts
|
|
163
|
+
// → purgeSearchDocumentsForSubject) had zero end-to-end coverage — a broken
|
|
164
|
+
// extension registration would silently turn the whole GDPR search purge
|
|
165
|
+
// into a no-op. Own stack (own entity feature) so it doesn't perturb the
|
|
166
|
+
// scenarios above.
|
|
167
|
+
describe("crypto-shredding :: forget-subject purges the derived search index (#1611)", () => {
|
|
168
|
+
const probeEntity = createEntity({
|
|
169
|
+
table: "read_forget_subject_search_probe",
|
|
170
|
+
fields: {
|
|
171
|
+
ownerId: createTextField({ required: true }),
|
|
172
|
+
userNote: createTextField({
|
|
173
|
+
required: true,
|
|
174
|
+
maxLength: 100,
|
|
175
|
+
userOwned: { ownerField: "ownerId" },
|
|
176
|
+
searchable: true,
|
|
177
|
+
}),
|
|
178
|
+
tenantNote: createTextField({
|
|
179
|
+
required: true,
|
|
180
|
+
maxLength: 100,
|
|
181
|
+
tenantOwned: true,
|
|
182
|
+
searchable: true,
|
|
183
|
+
}),
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
const probeTable = buildEntityTable("forgetSubjectSearchProbe", probeEntity);
|
|
187
|
+
const probeFeature = defineFeature("forget-subject-search-probe", (r) => {
|
|
188
|
+
r.entity("probe", probeEntity);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let searchStack: TestStack;
|
|
192
|
+
let searchKms: InMemoryKmsAdapter;
|
|
193
|
+
const admin = TestUsers.admin;
|
|
194
|
+
|
|
195
|
+
beforeAll(async () => {
|
|
196
|
+
searchStack = await setupTestStack({
|
|
197
|
+
features: [createCryptoShreddingFeature(), probeFeature],
|
|
198
|
+
});
|
|
199
|
+
await unsafeCreateEntityTable(searchStack.db, probeEntity, "probe");
|
|
200
|
+
await createEventsTable(searchStack.db);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterAll(async () => {
|
|
204
|
+
await searchStack.cleanup();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
searchKms = new InMemoryKmsAdapter();
|
|
209
|
+
configurePiiSubjectKms(searchKms);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
resetPiiSubjectKmsForTests();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function probeExecutor() {
|
|
217
|
+
return createEventStoreExecutor(probeTable, probeEntity, {
|
|
218
|
+
entityName: "probe",
|
|
219
|
+
searchAdapter: searchStack.search,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function probeTenantDb() {
|
|
224
|
+
return createTenantDb(searchStack.db, admin.tenantId, "system");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
test("forget-subject over HTTP purges userOwned rows from the search index", async () => {
|
|
228
|
+
const ownerId = crypto.randomUUID();
|
|
229
|
+
const userNote = "UniqueUserOwnedNote1611";
|
|
230
|
+
const created = await probeExecutor().create(
|
|
231
|
+
{ ownerId, userNote, tenantNote: "irrelevant-tenant-note" },
|
|
232
|
+
admin,
|
|
233
|
+
probeTenantDb(),
|
|
234
|
+
);
|
|
235
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
236
|
+
const id = String(created.data.id);
|
|
237
|
+
|
|
238
|
+
await searchStack.eventDispatcher?.runOnce();
|
|
239
|
+
expect(
|
|
240
|
+
(await searchStack.search.search(admin.tenantId, userNote, { filterType: "probe" })).some(
|
|
241
|
+
(h) => String(h.entityId) === id,
|
|
242
|
+
),
|
|
243
|
+
).toBe(true);
|
|
244
|
+
|
|
245
|
+
await searchStack.http.writeOk(
|
|
246
|
+
FORGET,
|
|
247
|
+
{ subject: { kind: "user", userId: ownerId }, reason: REASON },
|
|
248
|
+
dpoUser,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
const after = await searchStack.search.search(admin.tenantId, userNote, {
|
|
252
|
+
filterType: "probe",
|
|
253
|
+
});
|
|
254
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("forget-subject over HTTP purges tenantOwned rows from the search index", async () => {
|
|
258
|
+
const tenantNote = "UniqueTenantOwnedNote1611";
|
|
259
|
+
const created = await probeExecutor().create(
|
|
260
|
+
{ ownerId: crypto.randomUUID(), userNote: "irrelevant-user-note", tenantNote },
|
|
261
|
+
admin,
|
|
262
|
+
probeTenantDb(),
|
|
263
|
+
);
|
|
264
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
265
|
+
const id = String(created.data.id);
|
|
266
|
+
|
|
267
|
+
await searchStack.eventDispatcher?.runOnce();
|
|
268
|
+
expect(
|
|
269
|
+
(await searchStack.search.search(admin.tenantId, tenantNote, { filterType: "probe" })).some(
|
|
270
|
+
(h) => String(h.entityId) === id,
|
|
271
|
+
),
|
|
272
|
+
).toBe(true);
|
|
273
|
+
|
|
274
|
+
await searchStack.http.writeOk(
|
|
275
|
+
FORGET,
|
|
276
|
+
{ subject: { kind: "tenant", tenantId: admin.tenantId }, reason: REASON },
|
|
277
|
+
dpoUser,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const after = await searchStack.search.search(admin.tenantId, tenantNote, {
|
|
281
|
+
filterType: "probe",
|
|
282
|
+
});
|
|
283
|
+
expect(after.some((h) => String(h.entityId) === id)).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -225,6 +225,11 @@ describe("delivery attempt log under KMS", () => {
|
|
|
225
225
|
const otherSubjectBefore = beforeErase.rows.find(
|
|
226
226
|
(r) => r["recipientId"] === recipient.id && r["channel"] === "email",
|
|
227
227
|
)?.["recipientAddress"];
|
|
228
|
+
// Guard against a vacuous pass: if the row fell outside the `limit 100`
|
|
229
|
+
// window, both sides below would be `undefined` and the "unaffected"
|
|
230
|
+
// assertion would prove nothing.
|
|
231
|
+
expect(otherSubjectBefore).toBeDefined();
|
|
232
|
+
expect(otherSubjectBefore).not.toBe(PII_ERASED_SENTINEL);
|
|
228
233
|
|
|
229
234
|
await kms.eraseKey({ kind: "user", userId: subjectId });
|
|
230
235
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Full-stack integration for the notes-history bundle. Drives add-note → list
|
|
2
|
+
// through the real dispatcher + entity-projection + DB, proving the
|
|
3
|
+
// architecture end-to-end WITHOUT any host wiring (notes are host-agnostic —
|
|
4
|
+
// the host is just the entityType/entityId strings on the note-entry row):
|
|
5
|
+
// - add-note projects into read_note_entries with insertedAt/insertedById
|
|
6
|
+
// stamped by the framework (author + timestamp, base columns)
|
|
7
|
+
// - a client-supplied authorId in the payload is ignored — the executor
|
|
8
|
+
// always writes the authenticated caller's id
|
|
9
|
+
// - read-layer composition: list filtered on entityId
|
|
10
|
+
// - multi-tenant isolation
|
|
11
|
+
// - append-only: only add-note and list are registered handlers
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
16
|
+
import {
|
|
17
|
+
createTestUser,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
unsafeCreateEntityTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
23
|
+
import { noteEntryEntity } from "../entity";
|
|
24
|
+
import { createNotesHistoryFeature } from "../feature";
|
|
25
|
+
|
|
26
|
+
const notesHistoryFeature = createNotesHistoryFeature();
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
stack = await setupTestStack({ features: [notesHistoryFeature] });
|
|
32
|
+
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
33
|
+
await createEventsTable(stack.db);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await stack.cleanup();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
42
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_note_entries");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Distinct ids (default createTestUser() shares TestUsers.admin.id) —
|
|
46
|
+
// authorId-attribution tests need two genuinely different users.
|
|
47
|
+
const admin = createTestUser({ id: 1, roles: ["TenantAdmin"] });
|
|
48
|
+
const member = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
49
|
+
const otherTenant = createTestUser({
|
|
50
|
+
id: 10,
|
|
51
|
+
roles: ["TenantAdmin"],
|
|
52
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function addNote(
|
|
56
|
+
entityType: string,
|
|
57
|
+
entityId: string,
|
|
58
|
+
body: string,
|
|
59
|
+
user = admin,
|
|
60
|
+
extra: Record<string, unknown> = {},
|
|
61
|
+
): Promise<{ id: string }> {
|
|
62
|
+
return stack.http.writeOk<{ id: string }>(
|
|
63
|
+
NotesHistoryHandlers.addNote,
|
|
64
|
+
{ entityType, entityId, body, ...extra },
|
|
65
|
+
user,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function listNotes(
|
|
70
|
+
filter: { field: string; op: "eq"; value: unknown } | undefined,
|
|
71
|
+
user = admin,
|
|
72
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
73
|
+
const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
74
|
+
NotesHistoryQueries.noteList,
|
|
75
|
+
filter ? { filter } : {},
|
|
76
|
+
user,
|
|
77
|
+
);
|
|
78
|
+
return res.rows;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("notes-history integration — add + list", () => {
|
|
82
|
+
test("add-note lands in read_note_entries with author + timestamp stamped", async () => {
|
|
83
|
+
const { id } = await addNote("contact", "contact-1", "Called about renewal", member);
|
|
84
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-1" });
|
|
85
|
+
expect(rows).toHaveLength(1);
|
|
86
|
+
expect(rows[0]?.["id"]).toBe(id);
|
|
87
|
+
expect(rows[0]?.["body"]).toBe("Called about renewal");
|
|
88
|
+
expect(rows[0]?.["authorId"]).toBe(member.id);
|
|
89
|
+
expect(rows[0]?.["insertedAt"]).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a client-supplied authorId in the payload is ignored", async () => {
|
|
93
|
+
await addNote("contact", "contact-2", "note", member, { authorId: admin.id });
|
|
94
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-2" });
|
|
95
|
+
// The write always attributes to the authenticated caller (member), never
|
|
96
|
+
// to a value smuggled in through the payload — schema doesn't even accept
|
|
97
|
+
// an authorId field, so this proves it's silently dropped, not honoured.
|
|
98
|
+
expect(rows[0]?.["authorId"]).toBe(member.id);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
|
|
102
|
+
await addNote("contact", "contact-3", "first");
|
|
103
|
+
await addNote("contact", "contact-3", "second");
|
|
104
|
+
await addNote("contact", "contact-3", "third");
|
|
105
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-3" });
|
|
106
|
+
expect(rows.map((r) => r["body"]).sort()).toEqual(["first", "second", "third"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("read-layer composition: filtering by entityId scopes to that entity only", async () => {
|
|
110
|
+
await addNote("contact", "contact-4", "for four");
|
|
111
|
+
await addNote("contact", "contact-5", "for five");
|
|
112
|
+
const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-4" });
|
|
113
|
+
expect(rows).toHaveLength(1);
|
|
114
|
+
expect(rows[0]?.["body"]).toBe("for four");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("empty body is rejected", async () => {
|
|
118
|
+
const err = await stack.http.writeErr(
|
|
119
|
+
NotesHistoryHandlers.addNote,
|
|
120
|
+
{ entityType: "contact", entityId: "contact-6", body: " " },
|
|
121
|
+
admin,
|
|
122
|
+
);
|
|
123
|
+
expect(err.httpStatus).toBe(400);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("notes-history integration — multi-tenant isolation", () => {
|
|
128
|
+
test("tenant B sees none of tenant A's notes", async () => {
|
|
129
|
+
await addNote("contact", "contact-shared-id", "A's note", admin);
|
|
130
|
+
|
|
131
|
+
expect(
|
|
132
|
+
await listNotes({ field: "entityId", op: "eq", value: "contact-shared-id" }, otherTenant),
|
|
133
|
+
).toHaveLength(0);
|
|
134
|
+
expect(
|
|
135
|
+
await listNotes({ field: "entityId", op: "eq", value: "contact-shared-id" }, admin),
|
|
136
|
+
).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// notes-history bundle constants — feature-name + qualified handler/query names.
|
|
3
|
+
//
|
|
4
|
+
// Spec: kumiko-platform/docs/plans/notes-history-bundle.md
|
|
5
|
+
|
|
6
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
export const NOTES_HISTORY_FEATURE_NAME = "notes-history";
|
|
9
|
+
|
|
10
|
+
// Registry name for the drop-in <NotesSection> component. Apps reference it
|
|
11
|
+
// in a screen schema via `component: { react: { __component: NOTES_SECTION_EXTENSION_NAME } }`
|
|
12
|
+
// after mounting notesHistoryClient(); also importable directly from
|
|
13
|
+
// `@cosmicdrift/kumiko-bundled-features/notes-history/web` for standalone use.
|
|
14
|
+
export const NOTES_SECTION_EXTENSION_NAME = "NotesSection";
|
|
15
|
+
|
|
16
|
+
// Qualified handler/query names (QN format: scope:type:name). Clients
|
|
17
|
+
// reference the object instead of magic strings.
|
|
18
|
+
export const NotesHistoryHandlers = {
|
|
19
|
+
addNote: "notes-history:write:add-note",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const NotesHistoryQueries = {
|
|
23
|
+
noteList: "notes-history:query:note-entry:list",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
// Default RBAC: any tenant member may read and write notes on entities they
|
|
27
|
+
// can already see — same low-sensitivity-collaboration-tool default as tags.
|
|
28
|
+
// Apps with their own role vocabulary override via
|
|
29
|
+
// createNotesHistoryFeature({ roles }) / ({ access }).
|
|
30
|
+
export const DEFAULT_NOTES_HISTORY_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_NOTES_HISTORY_ACCESS: AccessRule = {
|
|
33
|
+
roles: DEFAULT_NOTES_HISTORY_ROLES,
|
|
34
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEntity,
|
|
3
|
+
createLongTextField,
|
|
4
|
+
createTextField,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
|
|
7
|
+
// note-entry — host-agnostic, append-only note attached to ANY entity via
|
|
8
|
+
// (entityType, entityId), same join-row pattern as tags/tag-assignment: no
|
|
9
|
+
// column on the host entity, cross-entity reads compose in the read-layer by
|
|
10
|
+
// filtering on entityId. Unlike tags there is no deterministic aggregate-id —
|
|
11
|
+
// an entity can carry many notes, so every create() is a fresh random-id
|
|
12
|
+
// stream (no idempotency requirement, no restore/version_conflict handling).
|
|
13
|
+
//
|
|
14
|
+
// Strictly append-only by design: no update/delete write-handler is
|
|
15
|
+
// registered (see feature.ts). A correction is a new entry, not an edit —
|
|
16
|
+
// that is the whole point of a note *history* instead of the single
|
|
17
|
+
// overwritable textarea this bundle replaces (solon#13). GDPR erasure of the
|
|
18
|
+
// author still works without a delete path: `body` is `userOwned` (crypto-
|
|
19
|
+
// shredding on the author's subject key), not `pii` on the entity itself.
|
|
20
|
+
export const noteEntryEntity = createEntity({
|
|
21
|
+
table: "read_note_entries",
|
|
22
|
+
fields: {
|
|
23
|
+
entityType: createTextField({ required: true, maxLength: 64 }),
|
|
24
|
+
// Host entity ids are uuid/text; 128 covers uuid plus non-uuid text keys.
|
|
25
|
+
entityId: createTextField({ required: true, maxLength: 128 }),
|
|
26
|
+
// Never client-supplied — stamped by the deriveAuthorId preSave hook from
|
|
27
|
+
// ctx.user.id (see feature.ts), so a note can't be authored as someone
|
|
28
|
+
// else. subjectRef feeds the GDPR-hook-coverage boot guard (it's a plain
|
|
29
|
+
// FK into `user`, not content of its own).
|
|
30
|
+
authorId: createTextField({ subjectRef: true }),
|
|
31
|
+
body: createLongTextField({
|
|
32
|
+
required: true,
|
|
33
|
+
maxLength: 20_000,
|
|
34
|
+
userOwned: { ownerField: "authorId" },
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// notes-history — generic, host-agnostic, append-only note history for ANY
|
|
2
|
+
// entity. Replaces a single overwritable textarea (e.g. solon's
|
|
3
|
+
// contact.notes) with multiple timestamped, authored entries — who wrote
|
|
4
|
+
// what, when, is reconstructable instead of lost on the next overwrite.
|
|
5
|
+
//
|
|
6
|
+
// Event-sourced, not relational, same pattern as tags/tag-assignment: the
|
|
7
|
+
// feature owns one entity, `note-entry` (read_note_entries), keyed by
|
|
8
|
+
// (entityType, entityId) — no column on the host entity, no JOIN. Unlike
|
|
9
|
+
// tags there is no deterministic aggregate-id: many notes may exist per
|
|
10
|
+
// entity, so every create() is an ordinary random-id stream.
|
|
11
|
+
//
|
|
12
|
+
// Only create + list are registered — no update, no delete. A correction is
|
|
13
|
+
// a new entry, not an edit; see entity.ts for why that still satisfies GDPR
|
|
14
|
+
// erasure without a delete path.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AccessRule,
|
|
18
|
+
defineEntityListHandler,
|
|
19
|
+
defineFeature,
|
|
20
|
+
type FeatureRegistrar,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
22
|
+
import { DEFAULT_NOTES_HISTORY_ACCESS, NOTES_HISTORY_FEATURE_NAME } from "./constants";
|
|
23
|
+
import { noteEntryEntity } from "./entity";
|
|
24
|
+
import { createAddNoteHandler } from "./handlers/add-note.write";
|
|
25
|
+
import { NOTES_HISTORY_FEATURE_I18N } from "./i18n";
|
|
26
|
+
|
|
27
|
+
function registerNotesHistory(
|
|
28
|
+
r: FeatureRegistrar<typeof NOTES_HISTORY_FEATURE_NAME>,
|
|
29
|
+
access: AccessRule,
|
|
30
|
+
): void {
|
|
31
|
+
r.describe(
|
|
32
|
+
"Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll: true } }) or pin roles with createNotesHistoryFeature({ roles }).",
|
|
33
|
+
);
|
|
34
|
+
r.uiHints({
|
|
35
|
+
displayLabel: "Notes",
|
|
36
|
+
category: "data",
|
|
37
|
+
recommended: false,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
r.entity("note-entry", noteEntryEntity);
|
|
41
|
+
|
|
42
|
+
r.writeHandler(createAddNoteHandler(access));
|
|
43
|
+
r.queryHandler(defineEntityListHandler("note-entry", noteEntryEntity, { access }));
|
|
44
|
+
|
|
45
|
+
r.translations({ keys: NOTES_HISTORY_FEATURE_I18N });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
49
|
+
registerNotesHistory(r, DEFAULT_NOTES_HISTORY_ACCESS),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export type NotesHistoryFeatureOptions = {
|
|
53
|
+
/** Access rule for the create/list paths. Default { roles: ["TenantAdmin","TenantMember"] }.
|
|
54
|
+
* Adopt the host's model — e.g. { openToAll: true } when the host lets any
|
|
55
|
+
* authenticated tenant user write (like the rest of its handlers), or
|
|
56
|
+
* { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
|
|
57
|
+
readonly access?: AccessRule;
|
|
58
|
+
/** Shorthand for { access: { roles } }. Ignored when `access` is set. */
|
|
59
|
+
readonly roles?: readonly string[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function resolveAccess(opts: NotesHistoryFeatureOptions): AccessRule {
|
|
63
|
+
if (opts.access !== undefined) return opts.access;
|
|
64
|
+
if (opts.roles !== undefined) return { roles: opts.roles };
|
|
65
|
+
return DEFAULT_NOTES_HISTORY_ACCESS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Options wrapper. Without options returns the module-level singleton (no
|
|
69
|
+
// rebuild). access/roles build a fresh feature-definition.
|
|
70
|
+
export function createNotesHistoryFeature(
|
|
71
|
+
opts: NotesHistoryFeatureOptions = {},
|
|
72
|
+
): typeof notesHistoryFeature {
|
|
73
|
+
if (opts.access === undefined && opts.roles === undefined) return notesHistoryFeature;
|
|
74
|
+
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) =>
|
|
75
|
+
registerNotesHistory(r, resolveAccess(opts)),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
|
|
3
|
+
import { noteEntryExecutor } from "../executor";
|
|
4
|
+
import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
|
|
5
|
+
|
|
6
|
+
// add-note — appends a note-entry to (entityType, entityId). authorId is
|
|
7
|
+
// NEVER read from the payload: it is always the authenticated caller
|
|
8
|
+
// (event.user.id), so a note can't be authored as someone else. No update or
|
|
9
|
+
// delete counterpart is registered — see entity.ts for why append-only is
|
|
10
|
+
// deliberate.
|
|
11
|
+
export function createAddNoteHandler(
|
|
12
|
+
access: AccessRule = DEFAULT_NOTES_HISTORY_ACCESS,
|
|
13
|
+
): WriteHandlerDef {
|
|
14
|
+
return {
|
|
15
|
+
name: "add-note",
|
|
16
|
+
schema: addNotePayloadSchema,
|
|
17
|
+
access,
|
|
18
|
+
handler: async (event, ctx) => {
|
|
19
|
+
const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
|
|
20
|
+
return noteEntryExecutor.create({ ...payload, authorId: event.user.id }, event.user, ctx.db);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const addNoteHandler: WriteHandlerDef = createAddNoteHandler();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_NOTES_HISTORY_ACCESS,
|
|
3
|
+
DEFAULT_NOTES_HISTORY_ROLES,
|
|
4
|
+
NOTES_HISTORY_FEATURE_NAME,
|
|
5
|
+
NOTES_SECTION_EXTENSION_NAME,
|
|
6
|
+
NotesHistoryHandlers,
|
|
7
|
+
NotesHistoryQueries,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
export { noteEntryEntity } from "./entity";
|
|
10
|
+
export { noteEntryExecutor, noteEntryTable } from "./executor";
|
|
11
|
+
export {
|
|
12
|
+
createNotesHistoryFeature,
|
|
13
|
+
type NotesHistoryFeatureOptions,
|
|
14
|
+
notesHistoryFeature,
|
|
15
|
+
} from "./feature";
|
|
16
|
+
export {
|
|
17
|
+
addNoteHandler,
|
|
18
|
+
createAddNoteHandler,
|
|
19
|
+
} from "./handlers/add-note.write";
|
|
20
|
+
export { type AddNotePayload, addNotePayloadSchema } from "./schemas";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// entityType/entityId are app-supplied — mirror tags' NO_PIPE guard isn't
|
|
4
|
+
// needed here since note-entry has no deterministic aggregate-id derived
|
|
5
|
+
// from these fields (see aggregate-id note in entity.ts): a literal value
|
|
6
|
+
// can't shift stream-id tuple boundaries when there is no such tuple.
|
|
7
|
+
export const addNotePayloadSchema = z.object({
|
|
8
|
+
entityType: z.string().min(1).max(64),
|
|
9
|
+
entityId: z.string().min(1).max(128),
|
|
10
|
+
body: z.string().trim().min(1).max(20_000),
|
|
11
|
+
});
|
|
12
|
+
export type AddNotePayload = z.infer<typeof addNotePayloadSchema>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
|
|
3
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
+
import { NOTES_HISTORY_FEATURE_NAME, NOTES_SECTION_EXTENSION_NAME } from "../constants";
|
|
5
|
+
import { defaultTranslations } from "./i18n";
|
|
6
|
+
import { NotesSection } from "./notes-section";
|
|
7
|
+
|
|
8
|
+
export function notesHistoryClient(): ClientFeatureDefinition {
|
|
9
|
+
return {
|
|
10
|
+
name: NOTES_HISTORY_FEATURE_NAME,
|
|
11
|
+
extensionSectionComponents: {
|
|
12
|
+
[NOTES_SECTION_EXTENSION_NAME]: NotesSection,
|
|
13
|
+
},
|
|
14
|
+
translations: defaultTranslations,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the notes-history UI. notesHistoryClient()
|
|
3
|
+
// hangs it into the LocaleProvider as a fallback bundle — apps override
|
|
4
|
+
// individual keys via notesHistoryClient({ translations: { de: { ... } } }).
|
|
5
|
+
// Keys follow `notesHistory.<area>.<slug>`.
|
|
6
|
+
|
|
7
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
|
|
9
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
10
|
+
de: {
|
|
11
|
+
"notesHistory.section.createMode": "Speichere zuerst den Eintrag, um Notizen anzulegen.",
|
|
12
|
+
"notesHistory.section.loading": "Lädt…",
|
|
13
|
+
"notesHistory.section.empty": "Noch keine Notizen.",
|
|
14
|
+
"notesHistory.section.working": "Speichert…",
|
|
15
|
+
"notesHistory.section.add": "Notiz hinzufügen",
|
|
16
|
+
"notesHistory.section.meta": "{author} · {date}",
|
|
17
|
+
},
|
|
18
|
+
en: {
|
|
19
|
+
"notesHistory.section.createMode": "Save the entity first to add notes.",
|
|
20
|
+
"notesHistory.section.loading": "Loading…",
|
|
21
|
+
"notesHistory.section.empty": "No notes yet.",
|
|
22
|
+
"notesHistory.section.working": "Saving…",
|
|
23
|
+
"notesHistory.section.add": "Add note",
|
|
24
|
+
"notesHistory.section.meta": "{author} · {date}",
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// NotesSection — drop-in note history for ANY entity. Shows every note
|
|
3
|
+
// chronologically (newest first) with author + timestamp, plus a textarea to
|
|
4
|
+
// append a new one. Append-only: there is no edit/delete affordance because
|
|
5
|
+
// the write-handler doesn't expose one (see ../entity.ts) — a correction is a
|
|
6
|
+
// new entry.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to mount (both need notesHistoryClient() registered once, for i18n):
|
|
9
|
+
// - standalone: <NotesSection entityName="contact" entityId={id} />
|
|
10
|
+
// - extension: a screen-schema section with
|
|
11
|
+
// component: { react: { __component: NOTES_SECTION_EXTENSION_NAME } }
|
|
12
|
+
// (RenderEdit passes { entityName, entityId }).
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
useDispatcher,
|
|
16
|
+
usePrimitives,
|
|
17
|
+
useQuery,
|
|
18
|
+
useTranslation,
|
|
19
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import { type ReactNode, useState } from "react";
|
|
21
|
+
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
22
|
+
|
|
23
|
+
type NoteRow = {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly entityType: string;
|
|
26
|
+
readonly entityId: string;
|
|
27
|
+
readonly authorId: string;
|
|
28
|
+
readonly body: string;
|
|
29
|
+
readonly insertedAt: string;
|
|
30
|
+
};
|
|
31
|
+
type NoteListResponse = { readonly rows: readonly NoteRow[] };
|
|
32
|
+
|
|
33
|
+
export function NotesSection({
|
|
34
|
+
entityName,
|
|
35
|
+
entityId,
|
|
36
|
+
}: {
|
|
37
|
+
readonly entityName: string;
|
|
38
|
+
readonly entityId: string | null;
|
|
39
|
+
}): ReactNode {
|
|
40
|
+
const { Banner, Button, Text, Input } = usePrimitives();
|
|
41
|
+
const t = useTranslation();
|
|
42
|
+
const dispatcher = useDispatcher();
|
|
43
|
+
const enabled = entityId !== null;
|
|
44
|
+
const notes = useQuery<NoteListResponse>(
|
|
45
|
+
NotesHistoryQueries.noteList,
|
|
46
|
+
{
|
|
47
|
+
filter: { field: "entityId", op: "eq", value: entityId },
|
|
48
|
+
sort: "insertedAt",
|
|
49
|
+
sortDirection: "desc",
|
|
50
|
+
// ponytail: fixed page, no "load more" — an entity with a note history
|
|
51
|
+
// longer than this shows only its 200 newest. Upgrade to cursor
|
|
52
|
+
// pagination (the query already supports `cursor`) if that's hit.
|
|
53
|
+
limit: 200,
|
|
54
|
+
},
|
|
55
|
+
{ enabled },
|
|
56
|
+
);
|
|
57
|
+
const [draft, setDraft] = useState("");
|
|
58
|
+
const [busy, setBusy] = useState(false);
|
|
59
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
if (entityId === null) {
|
|
62
|
+
return (
|
|
63
|
+
<Banner variant="info" testId="notes-section-create-mode">
|
|
64
|
+
<Text>{t("notesHistory.section.createMode")}</Text>
|
|
65
|
+
</Banner>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (notes.loading && notes.data === null) {
|
|
69
|
+
return (
|
|
70
|
+
<Banner variant="loading" testId="notes-section-loading">
|
|
71
|
+
<Text>{t("notesHistory.section.loading")}</Text>
|
|
72
|
+
</Banner>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (notes.error) {
|
|
76
|
+
return (
|
|
77
|
+
<Banner variant="error" testId="notes-section-error">
|
|
78
|
+
<Text>{t(notes.error.i18nKey, notes.error.i18nParams)}</Text>
|
|
79
|
+
</Banner>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rows = (notes.data?.rows ?? []).filter((n) => n.entityType === entityName);
|
|
84
|
+
|
|
85
|
+
const addNote = (): void => {
|
|
86
|
+
const body = draft.trim();
|
|
87
|
+
if (body === "") return;
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setErrorKey(null);
|
|
90
|
+
void (async () => {
|
|
91
|
+
try {
|
|
92
|
+
const result = await dispatcher.write(NotesHistoryHandlers.addNote, {
|
|
93
|
+
entityType: entityName,
|
|
94
|
+
entityId,
|
|
95
|
+
body,
|
|
96
|
+
});
|
|
97
|
+
if (!result.isSuccess) {
|
|
98
|
+
setErrorKey(result.error.i18nKey);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setDraft("");
|
|
102
|
+
await notes.refetch();
|
|
103
|
+
} finally {
|
|
104
|
+
setBusy(false);
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div data-testid="notes-section" className="flex flex-col gap-3">
|
|
111
|
+
<div className="flex flex-col gap-2">
|
|
112
|
+
<Input
|
|
113
|
+
kind="textarea"
|
|
114
|
+
id="notes-section-draft"
|
|
115
|
+
name="draft"
|
|
116
|
+
value={draft}
|
|
117
|
+
onChange={setDraft}
|
|
118
|
+
/>
|
|
119
|
+
<div>
|
|
120
|
+
<Button
|
|
121
|
+
variant="primary"
|
|
122
|
+
disabled={busy || draft.trim() === ""}
|
|
123
|
+
onClick={() => addNote()}
|
|
124
|
+
testId="notes-section-add"
|
|
125
|
+
>
|
|
126
|
+
{busy ? t("notesHistory.section.working") : t("notesHistory.section.add")}
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{rows.length === 0 ? (
|
|
132
|
+
<Text variant="small">{t("notesHistory.section.empty")}</Text>
|
|
133
|
+
) : (
|
|
134
|
+
<div className="flex flex-col gap-2">
|
|
135
|
+
{rows.map((n) => (
|
|
136
|
+
<div
|
|
137
|
+
key={n.id}
|
|
138
|
+
data-testid={`notes-section-row-${n.id}`}
|
|
139
|
+
className="rounded-md border p-2"
|
|
140
|
+
>
|
|
141
|
+
<Text>{n.body}</Text>
|
|
142
|
+
<Text variant="small">
|
|
143
|
+
{t("notesHistory.section.meta", { author: n.authorId, date: n.insertedAt })}
|
|
144
|
+
</Text>
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{errorKey !== null && (
|
|
151
|
+
<Banner variant="error" testId="notes-section-action-error">
|
|
152
|
+
<Text>{t(errorKey)}</Text>
|
|
153
|
+
</Banner>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// noteEntryExportHook — GDPR export must scope to the requesting user's own
|
|
2
|
+
// authored notes only, not every note-entry in the tenant. noteEntryDeleteHook
|
|
3
|
+
// is a deliberate no-op (erasure runs via crypto-shredding, see hooks.ts) —
|
|
4
|
+
// confirm it doesn't touch the row.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import {
|
|
9
|
+
createTestUser,
|
|
10
|
+
setupTestStack,
|
|
11
|
+
type TestStack,
|
|
12
|
+
unsafeCreateEntityTable,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
14
|
+
import { createNotesHistoryFeature, NotesHistoryHandlers } from "../../notes-history";
|
|
15
|
+
import { noteEntryEntity } from "../../notes-history/entity";
|
|
16
|
+
import { noteEntryDeleteHook, noteEntryExportHook } from "../hooks";
|
|
17
|
+
|
|
18
|
+
let stack: TestStack;
|
|
19
|
+
// Distinct ids (default createTestUser() shares TestUsers.admin.id) —
|
|
20
|
+
// this hook's whole point is per-author filtering.
|
|
21
|
+
const author = createTestUser({ id: 1, roles: ["TenantMember"] });
|
|
22
|
+
const other = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
stack = await setupTestStack({ features: [createNotesHistoryFeature()] });
|
|
26
|
+
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
27
|
+
await createEventsTable(stack.db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await stack.cleanup();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("noteEntryExportHook", () => {
|
|
35
|
+
test("includes only the requesting user's own authored notes", async () => {
|
|
36
|
+
await stack.http.writeOk(
|
|
37
|
+
NotesHistoryHandlers.addNote,
|
|
38
|
+
{ entityType: "contact", entityId: "c-1", body: "by author" },
|
|
39
|
+
author,
|
|
40
|
+
);
|
|
41
|
+
await stack.http.writeOk(
|
|
42
|
+
NotesHistoryHandlers.addNote,
|
|
43
|
+
{ entityType: "contact", entityId: "c-1", body: "by other" },
|
|
44
|
+
other,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const snippet = await noteEntryExportHook({
|
|
48
|
+
db: stack.db,
|
|
49
|
+
registry: stack.registry,
|
|
50
|
+
tenantId: author.tenantId,
|
|
51
|
+
userId: author.id,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(snippet).not.toBeNull();
|
|
55
|
+
const bodies = (snippet?.rows ?? []).map((r) => r["body"]);
|
|
56
|
+
expect(bodies).toEqual(["by author"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns null when the user authored no notes", async () => {
|
|
60
|
+
const lurker = createTestUser({ id: 3, roles: ["TenantMember"] });
|
|
61
|
+
const snippet = await noteEntryExportHook({
|
|
62
|
+
db: stack.db,
|
|
63
|
+
registry: stack.registry,
|
|
64
|
+
tenantId: lurker.tenantId,
|
|
65
|
+
userId: lurker.id,
|
|
66
|
+
});
|
|
67
|
+
expect(snippet).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("noteEntryDeleteHook", () => {
|
|
72
|
+
test("is a no-op — resolves without throwing", async () => {
|
|
73
|
+
const result = await noteEntryDeleteHook(
|
|
74
|
+
{ db: stack.db, registry: stack.registry, tenantId: author.tenantId, userId: author.id },
|
|
75
|
+
"delete",
|
|
76
|
+
);
|
|
77
|
+
expect(result).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// EXT_USER_DATA hooks for the notes-history feature's `note-entry` entity
|
|
2
|
+
// (GDPR Art. 20 export / Art. 17 erasure). Lives apart from notes-history so
|
|
3
|
+
// notes consumers without the user-data-rights pipeline don't pull a hard
|
|
4
|
+
// dependency. Mirrors job-run/delivery-attempt (user-data-rights-defaults):
|
|
5
|
+
// export-only, erasure via crypto-shredding.
|
|
6
|
+
|
|
7
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { noteEntryTable } from "../notes-history";
|
|
10
|
+
|
|
11
|
+
// note-entry has no per-tenant scope quirk (unlike folders) — it's genuinely
|
|
12
|
+
// per-user content, so the export filters by authorId directly.
|
|
13
|
+
export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
|
|
14
|
+
const rows = await selectMany<Record<string, unknown>>(ctx.db, noteEntryTable, {
|
|
15
|
+
authorId: ctx.userId,
|
|
16
|
+
});
|
|
17
|
+
if (rows.length === 0) return null;
|
|
18
|
+
return {
|
|
19
|
+
entity: "note-entry",
|
|
20
|
+
rows: rows.map((r) => ({
|
|
21
|
+
entityType: r["entityType"],
|
|
22
|
+
entityId: r["entityId"],
|
|
23
|
+
body: r["body"],
|
|
24
|
+
insertedAt: r["insertedAt"],
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Deliberate no-op: `body` is annotated `userOwned` (entity.ts), which relies
|
|
30
|
+
// on crypto-shredding (mounted KMS) for erasure — destroying the author's
|
|
31
|
+
// subject key makes every note-entry event AND projected row unreadable at
|
|
32
|
+
// once, without needing a physical delete (which would also break the
|
|
33
|
+
// bundle's append-only history for OTHER entities' co-authors reading it).
|
|
34
|
+
// Same tradeoff as job-run/delivery-attempt (user-data-rights-defaults).
|
|
35
|
+
// Precondition: this ONLY erases anything if the app mounts a KMS adapter —
|
|
36
|
+
// without one, userOwned fields fall back to plaintext storage framework-wide
|
|
37
|
+
// (see pii-field-encryption.ts) and forget is a true no-op for `body`. That
|
|
38
|
+
// gap is a property of the framework's crypto-shredding design, not specific
|
|
39
|
+
// to this hook; apps that need Art.17 coverage without KMS must mount one.
|
|
40
|
+
export const noteEntryDeleteHook: UserDataDeleteHook = async () => {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Provides the EXT_USER_DATA export/delete hooks for the notes-history
|
|
2
|
+
// feature's `note-entry` entity as a standalone feature — mount it alongside
|
|
3
|
+
// notes-history + user-data-rights when an app needs note histories in its
|
|
4
|
+
// GDPR export/forget pipeline. Kept separate from notes-history (which only
|
|
5
|
+
// requires nothing) so notes-history stays usable without the
|
|
6
|
+
// user-data-rights stack. Mirrors folders-user-data.
|
|
7
|
+
|
|
8
|
+
import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import { noteEntryDeleteHook, noteEntryExportHook } from "./hooks";
|
|
10
|
+
|
|
11
|
+
export const notesHistoryUserDataFeature = defineFeature("notes-history-user-data", (r) => {
|
|
12
|
+
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`.",
|
|
14
|
+
);
|
|
15
|
+
// user-data-rights is the hard dependency (EXT_USER_DATA host). notes-history
|
|
16
|
+
// is OPTIONAL: if it's mounted toggleable(default=false), a hard r.requires
|
|
17
|
+
// would throw an "effectively disabled" boot warning even though the
|
|
18
|
+
// note-entry entity exists and the hooks work fine.
|
|
19
|
+
r.requires("user-data-rights");
|
|
20
|
+
r.optionalRequires("notes-history");
|
|
21
|
+
r.useExtension(EXT_USER_DATA, "note-entry", {
|
|
22
|
+
export: noteEntryExportHook,
|
|
23
|
+
delete: noteEntryDeleteHook,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -24,7 +24,10 @@ export function defineCreateWithTenantDefaults(
|
|
|
24
24
|
entityName: string,
|
|
25
25
|
entity: EntityDefinition,
|
|
26
26
|
options: {
|
|
27
|
-
|
|
27
|
+
/** Required — this is a write-path helper; making it optional let a
|
|
28
|
+
* forgetful caller register a create handler with no access rule at
|
|
29
|
+
* all (fw#1581). */
|
|
30
|
+
readonly access: AccessRule;
|
|
28
31
|
/** Money field names whose `currency` is filled from tenant-settings when missing. */
|
|
29
32
|
readonly currencyFields?: readonly string[];
|
|
30
33
|
/** Field name (select/text) that receives the tenant locale when missing. */
|
|
@@ -45,7 +48,7 @@ export function defineCreateWithTenantDefaults(
|
|
|
45
48
|
return {
|
|
46
49
|
name: `${entityName}:create`,
|
|
47
50
|
schema,
|
|
48
|
-
|
|
51
|
+
access: options.access,
|
|
49
52
|
handler: async (event, ctx) => {
|
|
50
53
|
const payload = { ...(event.payload as Record<string, unknown>) };
|
|
51
54
|
|
|
@@ -69,6 +69,23 @@ describe("scenario 1: create + me", () => {
|
|
|
69
69
|
expect(me["locale"] == null).toBe(true);
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("locale sent by the client is persisted and returned via me", async () => {
|
|
73
|
+
const created = await stack.http.writeOk<{ id: number }>(
|
|
74
|
+
UserHandlers.create,
|
|
75
|
+
{
|
|
76
|
+
email: "locale@example.com",
|
|
77
|
+
displayName: "Locale User",
|
|
78
|
+
passwordHash: "seeded-hash",
|
|
79
|
+
locale: "de",
|
|
80
|
+
},
|
|
81
|
+
systemAdmin,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const signedIn = createTestUser({ id: created.id, roles: ["User"] });
|
|
85
|
+
const me = await stack.http.queryOk<Record<string, unknown>>(UserQueries.me, {}, signedIn);
|
|
86
|
+
expect(me["locale"]).toBe("de");
|
|
87
|
+
});
|
|
88
|
+
|
|
72
89
|
test("normal user cannot create another user", async () => {
|
|
73
90
|
const normal = createTestUser({ id: 42, roles: ["User"] });
|
|
74
91
|
const error = await stack.http.writeErr(
|
|
@@ -91,6 +91,37 @@ describe("default-mailers dispatch", () => {
|
|
|
91
91
|
expect(cap.sent[1]?.subject).toBe("Acme — Dein Datenexport ist bereit");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
test("null user.locale (der Normalfall seit fw#1637, kein Entity-Default mehr): mailDefaults.locale entscheidet, sonst en", async () => {
|
|
95
|
+
const cap = capturingTransport();
|
|
96
|
+
const resolve = async () => cap.transport;
|
|
97
|
+
|
|
98
|
+
// userLocale: null + mailDefaults.locale: "de" → German subject.
|
|
99
|
+
await makeDefaultExportReadyEmail(resolve, { locale: "de", appName: "Acme" })({
|
|
100
|
+
userId: "u1",
|
|
101
|
+
userEmail: "u1@example.com",
|
|
102
|
+
userLocale: null,
|
|
103
|
+
tenantId: TENANT_A,
|
|
104
|
+
jobId: "j1",
|
|
105
|
+
downloadUrl: "u",
|
|
106
|
+
expiresAt: "x",
|
|
107
|
+
bytesWritten: null,
|
|
108
|
+
});
|
|
109
|
+
expect(cap.sent[0]?.subject).toBe("Acme — Dein Datenexport ist bereit");
|
|
110
|
+
|
|
111
|
+
// userLocale: null + no mailDefaults.locale → template default "en".
|
|
112
|
+
await makeDefaultExportReadyEmail(resolve, { appName: "Acme" })({
|
|
113
|
+
userId: "u2",
|
|
114
|
+
userEmail: "u2@example.com",
|
|
115
|
+
userLocale: null,
|
|
116
|
+
tenantId: TENANT_A,
|
|
117
|
+
jobId: "j2",
|
|
118
|
+
downloadUrl: "u",
|
|
119
|
+
expiresAt: "x",
|
|
120
|
+
bytesWritten: null,
|
|
121
|
+
});
|
|
122
|
+
expect(cap.sent[1]?.subject).toBe("Acme — Your data export is ready");
|
|
123
|
+
});
|
|
124
|
+
|
|
94
125
|
test("deletion-executed: sendet ueber den ersten Membership-Tenant", async () => {
|
|
95
126
|
const cap = capturingTransport();
|
|
96
127
|
const resolved: string[] = [];
|
package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// fw#1611: purgeSearchDocumentsForSubject must run BEFORE the EXT_USER_DATA
|
|
2
|
+
// delete-hooks, not after. purge discovers candidate rows via a live SELECT
|
|
3
|
+
// on each entity's read-table; a hook with UserDataDeleteStrategy "delete"
|
|
4
|
+
// hard-deletes matching rows inside the same sub-tx. If purge ran after the
|
|
5
|
+
// hooks (the pre-fix ordering), a hard-deleted row's search doc — which
|
|
6
|
+
// holds plaintext for a searchable subject-owned field — would be invisible
|
|
7
|
+
// to that SELECT and survive the erasure permanently.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
10
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
11
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
12
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import {
|
|
14
|
+
createEntity,
|
|
15
|
+
createTextField,
|
|
16
|
+
defineFeature,
|
|
17
|
+
EXT_USER_DATA,
|
|
18
|
+
type UserDataDeleteHook,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
unsafeCreateEntityTable,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
28
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
29
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
30
|
+
import { createSessionsFeature, userSessionEntity } from "../../sessions";
|
|
31
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
32
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
33
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
34
|
+
|
|
35
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
36
|
+
const TENANT_A = "00000000-0000-4000-8000-0000000000f1";
|
|
37
|
+
const ALICE_ID = "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f1";
|
|
38
|
+
const NOTE_ID = "bbbbbbbb-bbbb-4bbb-8bbb-0000000000f1";
|
|
39
|
+
|
|
40
|
+
// A note "owned by" whoever wrote it (authorId), with a searchable body —
|
|
41
|
+
// userOwned + searchable makes `body` a subject field purge-subject.ts
|
|
42
|
+
// looks for.
|
|
43
|
+
const noteEntity = createEntity({
|
|
44
|
+
table: "read_forget_purge_notes",
|
|
45
|
+
fields: {
|
|
46
|
+
authorId: createTextField({ required: true }),
|
|
47
|
+
body: createTextField({
|
|
48
|
+
required: true,
|
|
49
|
+
searchable: true,
|
|
50
|
+
userOwned: { ownerField: "authorId" },
|
|
51
|
+
}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const noteTable = buildEntityTable("note", noteEntity);
|
|
55
|
+
|
|
56
|
+
const hardDeleteNoteHook: UserDataDeleteHook = async (ctx, strategy) => {
|
|
57
|
+
if (strategy !== "delete") return;
|
|
58
|
+
await asRawClient(ctx.db).unsafe(
|
|
59
|
+
`DELETE FROM read_forget_purge_notes WHERE author_id = $1 AND tenant_id = $2`,
|
|
60
|
+
[ctx.userId, ctx.tenantId],
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const noteFeature = defineFeature("forget-purge-notes", (r) => {
|
|
65
|
+
r.entity("note", noteEntity);
|
|
66
|
+
r.useExtension(EXT_USER_DATA, "note", {
|
|
67
|
+
export: async () => null,
|
|
68
|
+
delete: hardDeleteNoteHook,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let stack: TestStack;
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
stack = await setupTestStack({
|
|
76
|
+
features: [
|
|
77
|
+
createUserFeature(),
|
|
78
|
+
createDataRetentionFeature(),
|
|
79
|
+
createComplianceProfilesFeature(),
|
|
80
|
+
authFoundationFeature,
|
|
81
|
+
createSessionsFeature(),
|
|
82
|
+
createUserDataRightsFeature(),
|
|
83
|
+
noteFeature,
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
89
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
90
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
91
|
+
await asRawClient(stack.db).unsafe(`
|
|
92
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
93
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
94
|
+
tenant_id UUID NOT NULL,
|
|
95
|
+
user_id TEXT NOT NULL,
|
|
96
|
+
roles JSONB NOT NULL DEFAULT '[]',
|
|
97
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
99
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
100
|
+
)
|
|
101
|
+
`);
|
|
102
|
+
await createEventsTable(stack.db);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
await stack.cleanup();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("fw#1611: search purge finds + removes a row's doc even when a delete-hook hard-deletes that row in the same run", async () => {
|
|
110
|
+
const now = getTemporal().Now.instant();
|
|
111
|
+
|
|
112
|
+
await seedRow(stack.db, userTable, {
|
|
113
|
+
id: ALICE_ID,
|
|
114
|
+
tenantId: TENANT_SYSTEM,
|
|
115
|
+
email: "alice-purge-order@acme.test",
|
|
116
|
+
passwordHash: "hashed",
|
|
117
|
+
displayName: "Alice",
|
|
118
|
+
locale: "de",
|
|
119
|
+
emailVerified: true,
|
|
120
|
+
roles: '["Member"]',
|
|
121
|
+
status: USER_STATUS.DeletionRequested,
|
|
122
|
+
gracePeriodEnd: now.subtract({ hours: 1 }).toString(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await asRawClient(stack.db).unsafe(
|
|
126
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles) VALUES ($1, $2, '["Member"]')`,
|
|
127
|
+
[TENANT_A, ALICE_ID],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await seedRow(stack.db, noteTable, {
|
|
131
|
+
id: NOTE_ID,
|
|
132
|
+
tenantId: TENANT_A,
|
|
133
|
+
authorId: ALICE_ID,
|
|
134
|
+
body: "UniquePurgeOrderingBody1611",
|
|
135
|
+
});
|
|
136
|
+
await stack.search.index(TENANT_A, {
|
|
137
|
+
entityType: "note",
|
|
138
|
+
entityId: NOTE_ID,
|
|
139
|
+
weight: 1,
|
|
140
|
+
fields: { body: "UniquePurgeOrderingBody1611" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const preHits = await stack.search.search(TENANT_A, "UniquePurgeOrderingBody1611", {
|
|
144
|
+
filterType: "note",
|
|
145
|
+
});
|
|
146
|
+
expect(preHits.some((h) => String(h.entityId) === NOTE_ID)).toBe(true);
|
|
147
|
+
|
|
148
|
+
const result = await runForgetCleanup({
|
|
149
|
+
db: stack.db,
|
|
150
|
+
registry: stack.registry,
|
|
151
|
+
now,
|
|
152
|
+
searchAdapter: stack.search,
|
|
153
|
+
});
|
|
154
|
+
expect(result.errors).toHaveLength(0);
|
|
155
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
156
|
+
|
|
157
|
+
// The hook already hard-deleted the row (proves the hook ran)...
|
|
158
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
159
|
+
`SELECT id FROM read_forget_purge_notes WHERE id = $1`,
|
|
160
|
+
[NOTE_ID],
|
|
161
|
+
);
|
|
162
|
+
expect(rows).toHaveLength(0);
|
|
163
|
+
|
|
164
|
+
// ...and the search doc is gone too — only possible if purge ran BEFORE
|
|
165
|
+
// the hook deleted the row (or found it some other way). Pre-fix, purge
|
|
166
|
+
// ran after the hooks and its SELECT would find nothing, leaving this
|
|
167
|
+
// plaintext doc stranded in the index forever.
|
|
168
|
+
const postHits = await stack.search.search(TENANT_A, "UniquePurgeOrderingBody1611", {
|
|
169
|
+
filterType: "note",
|
|
170
|
+
});
|
|
171
|
+
expect(postHits.some((h) => String(h.entityId) === NOTE_ID)).toBe(false);
|
|
172
|
+
});
|
|
@@ -362,6 +362,24 @@ async function processUser(args: {
|
|
|
362
362
|
let currentEntityName: string | null = null;
|
|
363
363
|
try {
|
|
364
364
|
await runInSubTransaction(db, async (tx) => {
|
|
365
|
+
// Discover + purge search docs for this subject BEFORE any delete
|
|
366
|
+
// hook runs (fw#1611). purgeSearchDocumentsForSubject finds candidate
|
|
367
|
+
// rows via a live SELECT on each entity's read-table — a hook that
|
|
368
|
+
// hard-deletes a row (UserDataDeleteStrategy "delete") makes that row
|
|
369
|
+
// invisible to a purge running afterward, permanently stranding its
|
|
370
|
+
// plaintext PII in the search index. Running the purge first means it
|
|
371
|
+
// sees every row a hook is about to touch, delete or not.
|
|
372
|
+
if (args.searchAdapter) {
|
|
373
|
+
const subject = { kind: "user" as const, userId };
|
|
374
|
+
await purgeSearchDocumentsForSubject(
|
|
375
|
+
tx,
|
|
376
|
+
registry.features,
|
|
377
|
+
args.searchAdapter,
|
|
378
|
+
subjectIdToKey(subject),
|
|
379
|
+
subject,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
365
383
|
for (const tenantId of tenantList) {
|
|
366
384
|
currentTenantId = tenantId;
|
|
367
385
|
// Refine the app-level model to THIS tenant: "single-user" only if the
|
|
@@ -435,16 +453,6 @@ async function processUser(args: {
|
|
|
435
453
|
registry.features,
|
|
436
454
|
subjectIdToKey({ kind: "user", userId }),
|
|
437
455
|
);
|
|
438
|
-
if (args.searchAdapter) {
|
|
439
|
-
const subject = { kind: "user" as const, userId };
|
|
440
|
-
await purgeSearchDocumentsForSubject(
|
|
441
|
-
tx,
|
|
442
|
-
registry.features,
|
|
443
|
-
args.searchAdapter,
|
|
444
|
-
subjectIdToKey(subject),
|
|
445
|
-
subject,
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
456
|
}
|
|
449
457
|
|
|
450
458
|
// Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
|