@cosmicdrift/kumiko-bundled-features 0.215.1 → 0.215.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.215.1",
3
+ "version": "0.215.3",
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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.215.1",
130
- "@cosmicdrift/kumiko-framework": "0.215.1",
131
- "@cosmicdrift/kumiko-headless": "0.215.1",
132
- "@cosmicdrift/kumiko-renderer": "0.215.1",
133
- "@cosmicdrift/kumiko-renderer-web": "0.215.1",
134
- "@cosmicdrift/kumiko-types": "0.215.1",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.215.3",
130
+ "@cosmicdrift/kumiko-framework": "0.215.3",
131
+ "@cosmicdrift/kumiko-headless": "0.215.3",
132
+ "@cosmicdrift/kumiko-renderer": "0.215.3",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.215.3",
134
+ "@cosmicdrift/kumiko-types": "0.215.3",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -160,6 +160,6 @@
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
162
  "@types/qrcode": "^1.5.5",
163
- "@cosmicdrift/kumiko-locale-de": "0.215.1"
163
+ "@cosmicdrift/kumiko-locale-de": "0.215.3"
164
164
  }
165
165
  }
@@ -4,6 +4,6 @@ type LocalizedString = { readonly en: string };
4
4
  export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
5
5
  "config.settings.title": { en: "Settings" },
6
6
  "config.settings.system": { en: "Platform" },
7
- "config.settings.tenant": { en: "Organization" },
7
+ "config.settings.tenant": { en: "Tenant" },
8
8
  "config.settings.user": { en: "Personal" },
9
9
  };
@@ -13,7 +13,7 @@ export const defaultTranslations: TranslationsByLocale = {
13
13
  en: {
14
14
  "config.settings.title": "Settings",
15
15
  "config.settings.system": "Platform",
16
- "config.settings.tenant": "Organization",
16
+ "config.settings.tenant": "Tenant",
17
17
  "config.settings.user": "Personal",
18
18
  "config.errors.systemOnly": "This value can only be set by the system.",
19
19
  "config.errors.invalidScope": "This scope is not allowed for this key.",
@@ -40,6 +40,7 @@ import { apiTokenEntity, apiTokenTable } from "../../personal-access-tokens/sche
40
40
  import { createTenantFeature } from "../../tenant";
41
41
  import { tenantInvitationEntity } from "../../tenant/invitation-table";
42
42
  import { tenantMembershipsTable } from "../../tenant/membership-table";
43
+ import { seedTenantMembership } from "../../tenant/seeding";
43
44
  import { USER_STATUS, userEntity, userTable } from "../../user";
44
45
  import { createUserFeature } from "../../user/feature";
45
46
  import { seedUser } from "../../user/seeding";
@@ -116,20 +117,47 @@ describe("crypto-shredding :: forget-subject", () => {
116
117
  });
117
118
  });
118
119
 
119
- test("tenant subject shreds the same way", async () => {
120
- const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
121
- await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
120
+ test("tenant subject shreds the same way (DPO's own tenant)", async () => {
121
+ const subject = { kind: "tenant", tenantId: TENANT } as const;
122
+ await kms.createKey({ kind: "tenant", tenantId: TENANT });
122
123
 
123
124
  const result = await stack.http.writeOk<{ subjectKey: string }>(
124
125
  FORGET,
125
126
  { subject, reason: REASON },
126
127
  dpoUser,
127
128
  );
128
- expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
129
+ expect(result.subjectKey).toBe(`tenant:${TENANT}`);
130
+
131
+ await expect(kms.getKey({ kind: "tenant", tenantId: TENANT })).rejects.toThrow(
132
+ "Subject key erased",
133
+ );
134
+ });
135
+
136
+ // mh#349: DataProtectionOfficer is tenant-scoped — a DPO who learns a
137
+ // foreign tenant's id (export, support ticket, log line) must not be able
138
+ // to destroy that tenant's subject key.
139
+ test("DPO cannot forget a different tenant's subject → 403", async () => {
140
+ const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
141
+ await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
142
+
143
+ const err = await stack.http.writeErr(FORGET, { subject, reason: REASON }, dpoUser);
144
+ expect(err.httpStatus).toBe(403);
129
145
 
130
146
  await expect(
131
147
  kms.getKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId }),
132
- ).rejects.toThrow("Subject key erased");
148
+ ).resolves.toBeTruthy();
149
+ });
150
+
151
+ test("SystemAdmin bypasses the tenant-scope guard", async () => {
152
+ const subject = { kind: "tenant", tenantId: TARGET_TENANT_ID } as const;
153
+ await kms.createKey({ kind: "tenant", tenantId: TARGET_TENANT_ID as TenantId });
154
+
155
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
156
+ FORGET,
157
+ { subject, reason: REASON },
158
+ TestUsers.systemAdmin,
159
+ );
160
+ expect(result.subjectKey).toBe(`tenant:${TARGET_TENANT_ID}`);
133
161
  });
134
162
 
135
163
  test("repeat forget: erase is a no-op but each attempt is audited", async () => {
@@ -307,6 +335,31 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
307
335
  let kms: InMemoryKmsAdapter;
308
336
  const TENANT_B = testTenantId(3);
309
337
 
338
+ // mh#349: a "user"-kind subject isn't always a real user — a share-token
339
+ // recipient or email subscriber self-owns its PII the same way
340
+ // (personal: "self", its own row id is the subject) but has no tenant-
341
+ // membership row. The guard must fall back to the subject row's own
342
+ // tenant_id for these.
343
+ const shareLikeEntity = createEntity({
344
+ table: "read_forget_subject_share_like_probe",
345
+ fields: {
346
+ recipientName: createTextField({
347
+ required: true,
348
+ maxLength: 100,
349
+ personal: "self",
350
+ find: "exact",
351
+ }),
352
+ },
353
+ });
354
+ const shareLikeTable = buildEntityTable("forgetSubjectShareLikeProbe", shareLikeEntity);
355
+ const shareLikeFeature = defineFeature("forget-subject-share-like-probe", (r) => {
356
+ r.entity("probe", shareLikeEntity);
357
+ });
358
+
359
+ function shareLikeProbeExecutor() {
360
+ return createEventStoreExecutor(shareLikeTable, shareLikeEntity, { entityName: "probe" });
361
+ }
362
+
310
363
  beforeAll(async () => {
311
364
  stack = await setupTestStack({
312
365
  features: [
@@ -316,6 +369,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
316
369
  createConfigFeature(),
317
370
  authFoundationFeature,
318
371
  createPersonalAccessTokensFeature({ scopes: {} }),
372
+ shareLikeFeature,
319
373
  ],
320
374
  });
321
375
  await unsafeCreateEntityTable(stack.db, userEntity);
@@ -323,6 +377,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
323
377
  // tenant feature's only lookupable entity — the blind-index sweep
324
378
  // touches it; without the table the handler 500s on `read_tenant_invitations`.
325
379
  await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
380
+ await unsafeCreateEntityTable(stack.db, shareLikeEntity, "probe");
326
381
  await unsafePushTables(stack.db, { tenantMembershipsTable });
327
382
  await createEventsTable(stack.db);
328
383
  });
@@ -336,6 +391,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
336
391
  userTable,
337
392
  apiTokenTable,
338
393
  tenantMembershipsTable,
394
+ shareLikeTable,
339
395
  eventsTable,
340
396
  ]);
341
397
  kms = new InMemoryKmsAdapter();
@@ -352,6 +408,7 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
352
408
  displayName: "Forgotten User",
353
409
  emailVerified: true,
354
410
  });
411
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT, roles: ["Member"] });
355
412
  // No explicit createKey: seedUser's PII-encrypt (email) already created
356
413
  // the subject key implicitly via getOrCreateDek.
357
414
  await insertOne(stack.db, apiTokenTable, {
@@ -378,4 +435,84 @@ describe("crypto-shredding :: forget-subject closes the login door (user feature
378
435
  expect(tokens).toHaveLength(1);
379
436
  expect(tokens[0]?.["revokedAt"]).not.toBeNull();
380
437
  });
438
+
439
+ // mh#349: a DPO must not be able to forget a user who has no membership in
440
+ // their own tenant (e.g. a Tenant-A DPO who learned a Tenant-B user id).
441
+ test("DPO cannot forget a user with no membership in their tenant → 403", async () => {
442
+ const { id: userId } = await seedUser(stack.db, {
443
+ email: "foreign-tenant-user@example.com",
444
+ displayName: "Foreign Tenant User",
445
+ emailVerified: true,
446
+ });
447
+ await seedTenantMembership(stack.db, { userId, tenantId: TENANT_B, roles: ["Member"] });
448
+
449
+ const err = await stack.http.writeErr(
450
+ FORGET,
451
+ { subject: { kind: "user", userId }, reason: REASON },
452
+ dpoUser,
453
+ );
454
+ expect(err.httpStatus).toBe(403);
455
+
456
+ const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
457
+ expect(userRow?.["status"]).not.toBe(USER_STATUS.Deleted);
458
+ });
459
+
460
+ test("SystemAdmin can forget a user with no membership in any tenant", async () => {
461
+ const { id: userId } = await seedUser(stack.db, {
462
+ email: "system-admin-forgets@example.com",
463
+ displayName: "System Admin Forgets",
464
+ emailVerified: true,
465
+ });
466
+
467
+ await stack.http.writeOk(
468
+ FORGET,
469
+ { subject: { kind: "user", userId }, reason: REASON },
470
+ TestUsers.systemAdmin,
471
+ );
472
+
473
+ const userRow = await fetchOne<Record<string, unknown>>(stack.db, userTable, { id: userId });
474
+ expect(userRow?.["status"]).toBe(USER_STATUS.Deleted);
475
+ });
476
+
477
+ test("DPO can forget a self-owned PII subject (share-token-like) in their own tenant", async () => {
478
+ const created = await shareLikeProbeExecutor().create(
479
+ { recipientName: "Tommy Recipient" },
480
+ dpoUser,
481
+ createTenantDb(stack.db, TENANT, "system"),
482
+ );
483
+ if (!created.isSuccess) throw new Error("create failed");
484
+ const subjectId = String(created.data.id);
485
+
486
+ const result = await stack.http.writeOk<{ subjectKey: string }>(
487
+ FORGET,
488
+ { subject: { kind: "user", userId: subjectId }, reason: REASON },
489
+ dpoUser,
490
+ );
491
+ expect(result.subjectKey).toBe(`user:${subjectId}`);
492
+ });
493
+
494
+ test("DPO cannot forget another tenant's self-owned PII subject → 403", async () => {
495
+ // create()'s event stream is tenant-scoped to the AUTHOR, not the
496
+ // tenantDb override — an actor whose own tenantId is TENANT_B is
497
+ // required to actually seed the row under the foreign tenant.
498
+ const foreignTenantAuthor = {
499
+ id: "aaaaaaaa-aaaa-4aaa-8aaa-0000000000f2",
500
+ tenantId: TENANT_B,
501
+ roles: ["Member"],
502
+ };
503
+ const created = await shareLikeProbeExecutor().create(
504
+ { recipientName: "Peter Recipient" },
505
+ foreignTenantAuthor,
506
+ createTenantDb(stack.db, TENANT_B, "system"),
507
+ );
508
+ if (!created.isSuccess) throw new Error("create failed");
509
+ const subjectId = String(created.data.id);
510
+
511
+ const err = await stack.http.writeErr(
512
+ FORGET,
513
+ { subject: { kind: "user", userId: subjectId }, reason: REASON },
514
+ dpoUser,
515
+ );
516
+ expect(err.httpStatus).toBe(403);
517
+ });
381
518
  });
@@ -3,3 +3,5 @@ export const CRYPTO_SHREDDING_FEATURE_NAME = "crypto-shredding";
3
3
  export const SUBJECT_FORGOTTEN_EVENT_NAME = "crypto-shredding:event:subject-forgotten";
4
4
 
5
5
  export const CRYPTO_SHREDDING_AGGREGATE_TYPE = "crypto-shredding-subject";
6
+
7
+ export const TARGET_TENANT_NOT_ADMIN_TENANT = "target_tenant_not_admin_tenant" as const;
@@ -5,15 +5,37 @@ import {
5
5
  type SubjectId,
6
6
  subjectIdToKey,
7
7
  } from "@cosmicdrift/kumiko-framework/crypto";
8
- import { nullBlindIndexesForSubject } from "@cosmicdrift/kumiko-framework/db";
9
- import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
10
- import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
8
+ import {
9
+ type DbRunner,
10
+ nullBlindIndexesForSubject,
11
+ subjectRowExistsInTenant,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ defineWriteHandler,
15
+ type FeatureDefinition,
16
+ type SessionUser,
17
+ type TenantId,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import {
20
+ AccessDeniedError,
21
+ InternalError,
22
+ type WriteFailure,
23
+ writeFailure,
24
+ } from "@cosmicdrift/kumiko-framework/errors";
11
25
  import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
12
26
  import { z } from "zod";
13
27
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens";
14
28
  import { USER_STATUS } from "../../user";
15
- import { updateUserLifecycle } from "../../user-data-rights";
16
- import { CRYPTO_SHREDDING_AGGREGATE_TYPE, SUBJECT_FORGOTTEN_EVENT_NAME } from "../constants";
29
+ import {
30
+ denyIfTargetOutsideAdminTenant,
31
+ isSystemAdminActor,
32
+ updateUserLifecycle,
33
+ } from "../../user-data-rights";
34
+ import {
35
+ CRYPTO_SHREDDING_AGGREGATE_TYPE,
36
+ SUBJECT_FORGOTTEN_EVENT_NAME,
37
+ TARGET_TENANT_NOT_ADMIN_TENANT,
38
+ } from "../constants";
17
39
 
18
40
  export const subjectIdSchema = z.discriminatedUnion("kind", [
19
41
  z.object({ kind: z.literal("user"), userId: z.uuid() }),
@@ -31,6 +53,43 @@ export const subjectForgottenSchema = z.object({
31
53
  forgottenBy: z.string().min(1),
32
54
  });
33
55
 
56
+ type SubjectIdInput = z.infer<typeof subjectIdSchema>;
57
+
58
+ // Tenant-scope guard (mh#349): DataProtectionOfficer is a tenant-scoped role,
59
+ // but the handler otherwise erases ANY subject cross-tenant on a raw
60
+ // (un-scoped) client — a Tenant-A DPO who learns a Tenant-B subject id
61
+ // (export, support ticket, log line) could destroy it. SystemAdmin
62
+ // (platform-wide) always bypasses.
63
+ async function resolveTenantScopeDenial(
64
+ db: DbRunner,
65
+ features: ReadonlyMap<string, FeatureDefinition>,
66
+ user: SessionUser,
67
+ raw: SubjectIdInput,
68
+ ): Promise<WriteFailure | undefined> {
69
+ if (isSystemAdminActor(user)) return undefined;
70
+
71
+ if (raw.kind === "tenant") {
72
+ if (raw.tenantId === user.tenantId) return undefined;
73
+ return writeFailure(
74
+ new AccessDeniedError({ details: { reason: TARGET_TENANT_NOT_ADMIN_TENANT } }),
75
+ );
76
+ }
77
+
78
+ // Without the tenant feature there's no membership table to check against —
79
+ // no scoping concept exists to enforce (single/no-tenant apps only;
80
+ // ponytail: fail-open here, not a gap in multi-tenant apps).
81
+ if (!features.has("tenant")) return undefined;
82
+
83
+ const memberDenied = await denyIfTargetOutsideAdminTenant(db, user, raw.userId);
84
+ if (!memberDenied) return undefined;
85
+
86
+ // Not a real user in the actor's tenant — raw.userId may still be a
87
+ // self-owned PII subject (share-token recipient, email subscriber, ...)
88
+ // whose own row carries a real tenant_id.
89
+ const ownedInTenant = await subjectRowExistsInTenant(db, features, raw.userId, user.tenantId);
90
+ return ownedInTenant ? undefined : memberDenied;
91
+ }
92
+
34
93
  // Manual crypto-shred for a DPO / platform operator: erases the subject's
35
94
  // DEK immediately (all its PII ciphertext becomes unreadable, reads render
36
95
  // "[[erased]]") and appends the audit event. Forget is final — the adapter
@@ -63,6 +122,14 @@ export const forgetSubjectWrite = defineWriteHandler({
63
122
  : { kind: "tenant", tenantId: raw.tenantId as TenantId }; // @cast-boundary uuid-validated command payload → branded id
64
123
  const subjectKey = subjectIdToKey(subject);
65
124
 
125
+ const tenantScopeDenial = await resolveTenantScopeDenial(
126
+ ctx.db.raw,
127
+ ctx.registry.features,
128
+ event.user,
129
+ raw,
130
+ );
131
+ if (tenantScopeDenial) return tenantScopeDenial;
132
+
66
133
  // Erase BEFORE the audit append: if the append throws, the key is gone
67
134
  // but no event exists — a retry is a no-op erase plus the event. The
68
135
  // reverse order could leave an audit trail claiming a shred that never
@@ -6,7 +6,7 @@
6
6
  // referenzieren den Namen im Screen-Schema:
7
7
  //
8
8
  // layout: { sections: [
9
- // { kind: "extension", title: "...", component: {
9
+ // { kind: "extension", title: "...", contributesToFormSubmit: true, component: {
10
10
  // react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME }
11
11
  // } },
12
12
  // ]}
@@ -0,0 +1,89 @@
1
+ // add-note's authorName stamping (see add-note.write.ts): the handler reads
2
+ // the caller's own read_users row (ctx.db.raw, tenant-agnostic self-lookup —
3
+ // same pattern as user-data-rights/handlers/cancel-deletion.write.ts) and
4
+ // decrypts displayName before writing it as authorName. Pinned with a real
5
+ // KMS adapter so the round-trip is proven end-to-end, not assumed from a
6
+ // no-op-encrypt test env — a later refactor that silently reintroduces the
7
+ // raw UUID or gets stuck on the placeholder would fail this.
8
+
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import {
12
+ configurePiiSubjectKms,
13
+ InMemoryKmsAdapter,
14
+ isPiiCiphertext,
15
+ } from "@cosmicdrift/kumiko-framework/crypto";
16
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ testTenantId,
21
+ unsafeCreateEntityTable,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
24
+ import { userEntity, userTable } from "../../user/schema/user";
25
+ import { seedUser } from "../../user/seeding";
26
+ import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
27
+ import { noteEntryEntity } from "../entity";
28
+ import { createNotesHistoryFeature } from "../feature";
29
+
30
+ const notesHistoryFeature = createNotesHistoryFeature();
31
+ const tenantId = testTenantId(1);
32
+
33
+ let stack: TestStack;
34
+
35
+ function memberUser(userId: string): SessionUser {
36
+ return { id: userId, tenantId, roles: ["TenantMember"] };
37
+ }
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({ features: [notesHistoryFeature] });
41
+ await unsafeCreateEntityTable(stack.db, noteEntryEntity);
42
+ await unsafeCreateEntityTable(stack.db, userEntity);
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await stack.cleanup();
47
+ });
48
+
49
+ afterEach(() => {
50
+ resetPiiSubjectKmsForTests();
51
+ });
52
+
53
+ describe("add-note — authorName stamped from the writer's own user row", () => {
54
+ test("with an active KMS, the written note carries the writer's decrypted displayName — not the UUID, not the placeholder", async () => {
55
+ // KMS active BEFORE the seed — seedUser runs through the executor, so
56
+ // the row reflects the encrypted prod state.
57
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
58
+ const { id: userId } = await seedUser(stack.db, {
59
+ email: "notes-author@example.com",
60
+ displayName: "Priya Natarajan",
61
+ emailVerified: true,
62
+ });
63
+
64
+ // Prove the row is actually ciphertext at rest — otherwise a no-op
65
+ // encrypt path would make this test pass while proving nothing.
66
+ const rawUserRows = await selectMany<{ displayName: unknown }>(stack.db, userTable, {
67
+ id: userId,
68
+ });
69
+ expect(isPiiCiphertext(rawUserRows[0]?.displayName)).toBe(true);
70
+
71
+ const { id: noteId } = await stack.http.writeOk<{ id: string }>(
72
+ NotesHistoryHandlers.addNote,
73
+ { entityType: "contact", entityId: "contact-kms-author", body: "Called back." },
74
+ memberUser(userId),
75
+ );
76
+
77
+ const { rows } = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
78
+ NotesHistoryQueries.noteList,
79
+ { filter: { field: "entityId", op: "eq", value: "contact-kms-author" } },
80
+ memberUser(userId),
81
+ );
82
+
83
+ expect(rows).toHaveLength(1);
84
+ expect(rows[0]?.["id"]).toBe(noteId);
85
+ expect(rows[0]?.["authorName"]).toBe("Priya Natarajan");
86
+ expect(rows[0]?.["authorName"]).not.toBe(userId);
87
+ expect(rows[0]?.["authorName"]).not.toBe("Unknown author");
88
+ });
89
+ });
@@ -87,6 +87,11 @@ describe("notes-history integration — add + list", () => {
87
87
  expect(rows[0]?.["body"]).toBe("Called about renewal");
88
88
  expect(rows[0]?.["authorId"]).toBe(member.id);
89
89
  expect(rows[0]?.["insertedAt"]).toBeTruthy();
90
+ // member is a synthetic SessionUser (createTestUser) with no backing
91
+ // read_users row, so add-note's self-lookup finds nothing to stamp — see
92
+ // add-note-author-name-kms.integration.test.ts for the seeded-user path
93
+ // where a real displayName does land in authorName.
94
+ expect(rows[0]?.["authorName"]).toBeNull();
90
95
  });
91
96
 
92
97
  test("a client-supplied authorId in the payload is ignored", async () => {
@@ -98,6 +103,15 @@ describe("notes-history integration — add + list", () => {
98
103
  expect(rows[0]?.["authorId"]).toBe(member.id);
99
104
  });
100
105
 
106
+ test("a client-supplied authorName in the payload is ignored", async () => {
107
+ await addNote("contact", "contact-2b", "note", member, { authorName: "Fake Display Name" });
108
+ const rows = await listNotes({ field: "entityId", op: "eq", value: "contact-2b" });
109
+ // Same guard as authorId above: addNotePayloadSchema doesn't accept an
110
+ // authorName field, so a smuggled value never survives to the write —
111
+ // the name (once stamped) can only come from the caller's own session.
112
+ expect(rows[0]?.["authorName"]).not.toBe("Fake Display Name");
113
+ });
114
+
101
115
  test("multiple notes on the same entity accumulate — nothing overwrites", async () => {
102
116
  await addNote("contact", "contact-3", "first");
103
117
  await addNote("contact", "contact-3", "second");
@@ -0,0 +1,171 @@
1
+ // @runtime client
2
+ // DOM test for the notes-history entry display: body/meta must render as two
3
+ // visually separate lines, the author must never be the raw authorId, and the
4
+ // timestamp must be human-formatted — not the concatenated single-line dump
5
+ // this test guards against (see notes-section.tsx for the fix).
6
+ // Provider wrapper is local — renderer-web → bundled-features forbids importing
7
+ // renderer-web test-utils from here.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
11
+ import {
12
+ createStaticLocaleResolver,
13
+ DispatcherProvider,
14
+ formatWhen,
15
+ kumikoDefaultTranslations,
16
+ type LiveEventSubscriber,
17
+ LiveEventsProvider,
18
+ LocaleProvider,
19
+ PrimitivesProvider,
20
+ TokensProvider,
21
+ } from "@cosmicdrift/kumiko-renderer";
22
+ import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
23
+ import { render, waitFor } from "@testing-library/react";
24
+ import type { ReactNode } from "react";
25
+ import { NotesHistoryQueries } from "../constants";
26
+ import { defaultTranslations } from "../web/i18n";
27
+ import { NotesSection } from "../web/notes-section";
28
+
29
+ if (typeof globalThis.ResizeObserver === "undefined") {
30
+ globalThis.ResizeObserver = class {
31
+ observe(): void {}
32
+ unobserve(): void {}
33
+ disconnect(): void {}
34
+ } as unknown as typeof ResizeObserver;
35
+ }
36
+
37
+ const stubLiveEvents: LiveEventSubscriber = () => () => {};
38
+ const stubTokens = {
39
+ tokens: defaultTokens,
40
+ mode: "light" as const,
41
+ setMode: () => {},
42
+ toggleMode: () => {},
43
+ };
44
+ const stubResolver = createStaticLocaleResolver();
45
+
46
+ type NoteRowFixture = {
47
+ readonly id: string;
48
+ readonly entityType: string;
49
+ readonly entityId: string;
50
+ readonly authorId: string;
51
+ readonly authorName: string | null;
52
+ readonly body: string;
53
+ readonly insertedAt: string;
54
+ };
55
+
56
+ const AUTHOR_ID = "01a0244b-71f2-76e4-854f-e360584f01e9";
57
+ const AUTHOR_NAME = "Jamie Vance";
58
+ const INSERTED_AT = "2026-08-21T12:28:41.580Z";
59
+ const NOTE_BODY = "Vertragsunterlagen per Post verschickt.";
60
+
61
+ const NOTE_ROWS: readonly NoteRowFixture[] = [
62
+ {
63
+ id: "note-1",
64
+ entityType: "contact",
65
+ entityId: "contact-1",
66
+ authorId: AUTHOR_ID,
67
+ authorName: AUTHOR_NAME,
68
+ body: NOTE_BODY,
69
+ insertedAt: INSERTED_AT,
70
+ },
71
+ {
72
+ id: "note-2",
73
+ entityType: "contact",
74
+ entityId: "contact-1",
75
+ authorId: "02b1355c-8203-87f5-9650-f471695002f0",
76
+ authorName: null,
77
+ body: "Legacy entry predating authorName.",
78
+ insertedAt: "2026-07-01T09:00:00.000Z",
79
+ },
80
+ ];
81
+
82
+ function makeDispatcher(): Dispatcher {
83
+ const statusStore = createStore<DispatcherStatus>("online");
84
+ const query = (async (type: string) => {
85
+ if (type === NotesHistoryQueries.noteList) {
86
+ return { isSuccess: true, data: { rows: NOTE_ROWS } };
87
+ }
88
+ return { isSuccess: true, data: null };
89
+ }) as unknown as Dispatcher["query"];
90
+ return {
91
+ write: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["write"],
92
+ query,
93
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
94
+ statusStore,
95
+ async *stream() {},
96
+ pendingWrites: () => [],
97
+ pendingFiles: () => [],
98
+ } as unknown as Dispatcher; // @cast-boundary test-stub
99
+ }
100
+
101
+ function renderSection(): ReturnType<typeof render> {
102
+ const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
103
+ <TokensProvider value={stubTokens}>
104
+ <LocaleProvider
105
+ resolver={stubResolver}
106
+ fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
107
+ >
108
+ <PrimitivesProvider value={defaultPrimitives}>
109
+ <LiveEventsProvider value={stubLiveEvents}>
110
+ <DispatcherProvider dispatcher={makeDispatcher()}>{children}</DispatcherProvider>
111
+ </LiveEventsProvider>
112
+ </PrimitivesProvider>
113
+ </LocaleProvider>
114
+ </TokensProvider>
115
+ );
116
+ return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
117
+ }
118
+
119
+ describe("NotesSection — entry display", () => {
120
+ test("note body and meta line render as two distinct nodes, not one run-on line", async () => {
121
+ const view = renderSection();
122
+ await waitFor(() => {
123
+ if (view.queryByTestId("notes-section-row-note-1-body") === null) {
124
+ throw new Error("row not rendered yet");
125
+ }
126
+ });
127
+ const bodyEl = view.getByTestId("notes-section-row-note-1-body");
128
+ const metaEl = view.getByTestId("notes-section-row-note-1-meta");
129
+ expect(bodyEl).not.toBe(metaEl);
130
+ expect(bodyEl.textContent).toBe(NOTE_BODY);
131
+ expect(metaEl.textContent).not.toContain(NOTE_BODY);
132
+
133
+ const row = view.getByTestId("notes-section-row-note-1");
134
+ expect(row.className).toContain("flex-col");
135
+ });
136
+
137
+ test("a stamped authorName renders as the author, never the raw authorId", async () => {
138
+ const view = renderSection();
139
+ await waitFor(() => {
140
+ if (view.queryByTestId("notes-section-row-note-1-meta") === null) {
141
+ throw new Error("row not rendered yet");
142
+ }
143
+ });
144
+ const metaEl = view.getByTestId("notes-section-row-note-1-meta");
145
+ expect(metaEl.textContent).not.toContain(AUTHOR_ID);
146
+ expect(metaEl.textContent).toContain(AUTHOR_NAME);
147
+ });
148
+
149
+ test("a null authorName (legacy row, or shredded author) renders the placeholder", async () => {
150
+ const view = renderSection();
151
+ await waitFor(() => {
152
+ if (view.queryByTestId("notes-section-row-note-2-meta") === null) {
153
+ throw new Error("row not rendered yet");
154
+ }
155
+ });
156
+ const metaEl = view.getByTestId("notes-section-row-note-2-meta");
157
+ expect(metaEl.textContent).toContain("Unknown author");
158
+ });
159
+
160
+ test("date renders formatted, never the raw ISO timestamp", async () => {
161
+ const view = renderSection();
162
+ await waitFor(() => {
163
+ if (view.queryByTestId("notes-section-row-note-1-meta") === null) {
164
+ throw new Error("row not rendered yet");
165
+ }
166
+ });
167
+ const metaEl = view.getByTestId("notes-section-row-note-1-meta");
168
+ expect(metaEl.textContent).not.toContain(INSERTED_AT);
169
+ expect(metaEl.textContent).toContain(formatWhen(INSERTED_AT));
170
+ });
171
+ });
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.215.3",
4
+ "type": "improvement",
5
+ "title": "note-entry gains authorName, stamped at write time",
6
+ "detail": "The entry display previously had no readable author — only the internal authorId (UUID). note-entry now carries an `authorName` field (personal: { of: \"authorId\" }, same crypto-shredding subject as `body`), stamped once at add-note write time from the writer's own read_users row (self-lookup by id, decrypted displayName) so the append-only history keeps the name as it was when the note was written, not whatever the account is called later. A failed lookup, a missing displayName, and pre-field/shredded rows all read `authorName: null` — the UI falls back to a translated placeholder, never the raw id; the write itself never fails over a missing name.",
7
+ "migration": "Run `kumiko-schema generate` and apply — the migration adds `read_note_entries.authorName`. No backfill: existing rows read null and show the placeholder, same as before this change from the user's point of view."
8
+ }
9
+ ]
@@ -31,6 +31,13 @@ export const noteEntryEntity = createEntity({
31
31
  authorId: createTextField({
32
32
  personal: "ref",
33
33
  }),
34
+ // Stamped once at write time, never re-resolved later — the history
35
+ // needs the name as it was then, not whatever the account is called now.
36
+ authorName: createTextField({
37
+ maxLength: 200,
38
+ personal: { of: "authorId" },
39
+ find: "none",
40
+ }),
34
41
  body: createLongTextField({
35
42
  required: true,
36
43
  maxLength: 20_000,
@@ -1,4 +1,7 @@
1
+ import { fetchOne, runInSavepoint } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { decryptStoredPii } from "../../shared";
4
+ import { userTable } from "../../user";
2
5
  import { DEFAULT_NOTES_HISTORY_ACCESS } from "../constants";
3
6
  import { noteEntryExecutor } from "../executor";
4
7
  import { type AddNotePayload, addNotePayloadSchema } from "../schemas";
@@ -17,7 +20,28 @@ export function createAddNoteHandler(
17
20
  access,
18
21
  handler: async (event, ctx) => {
19
22
  const payload = event.payload as AddNotePayload; // @cast-boundary engine-payload
20
- return noteEntryExecutor.create({ ...payload, authorId: event.user.id }, event.user, ctx.db);
23
+
24
+ let authorName: string | null = null;
25
+ try {
26
+ // read_users is tenant-agnostic → ctx.db.raw, not the tenant-scoped ctx.db.
27
+ // Bun.SQL poisons the whole tx after any error inside it, even one that's
28
+ // caught — a bare try/catch here would take the note write down with it.
29
+ authorName = await runInSavepoint(ctx.db.raw, async (sp) => {
30
+ const userRow = await fetchOne<{ displayName: string | null }>(sp, userTable, {
31
+ id: event.user.id,
32
+ });
33
+ if (!userRow?.displayName) return null;
34
+ return decryptStoredPii(userRow.displayName, "displayName", "notes-history:add-note");
35
+ });
36
+ } catch {
37
+ authorName = null;
38
+ }
39
+
40
+ return noteEntryExecutor.create(
41
+ { ...payload, authorId: event.user.id, authorName },
42
+ event.user,
43
+ ctx.db,
44
+ );
21
45
  },
22
46
  };
23
47
  }
@@ -14,5 +14,6 @@ export const defaultTranslations: TranslationsByLocale = {
14
14
  "notesHistory.section.working": "Saving…",
15
15
  "notesHistory.section.add": "Add note",
16
16
  "notesHistory.section.meta": "{author} · {date}",
17
+ "notesHistory.section.authorUnknown": "Unknown author",
17
18
  },
18
19
  };
@@ -12,6 +12,7 @@
12
12
  // (RenderEdit passes { entityName, entityId }).
13
13
 
14
14
  import {
15
+ formatWhen,
15
16
  useDispatcher,
16
17
  usePrimitives,
17
18
  useQuery,
@@ -25,6 +26,8 @@ type NoteRow = {
25
26
  readonly entityType: string;
26
27
  readonly entityId: string;
27
28
  readonly authorId: string;
29
+ // Null for pre-authorName history and for shredded authors.
30
+ readonly authorName: string | null;
28
31
  readonly body: string;
29
32
  readonly insertedAt: string;
30
33
  };
@@ -136,11 +139,14 @@ export function NotesSection({
136
139
  <div
137
140
  key={n.id}
138
141
  data-testid={`notes-section-row-${n.id}`}
139
- className="rounded-md border p-2"
142
+ className="flex flex-col gap-1 rounded-md border p-2"
140
143
  >
141
- <Text>{n.body}</Text>
142
- <Text variant="small">
143
- {t("notesHistory.section.meta", { author: n.authorId, date: n.insertedAt })}
144
+ <Text testId={`notes-section-row-${n.id}-body`}>{n.body}</Text>
145
+ <Text variant="small" testId={`notes-section-row-${n.id}-meta`}>
146
+ {t("notesHistory.section.meta", {
147
+ author: n.authorName ?? t("notesHistory.section.authorUnknown"),
148
+ date: formatWhen(n.insertedAt),
149
+ })}
144
150
  </Text>
145
151
  </div>
146
152
  ))}
@@ -54,6 +54,9 @@ describe("noteEntryExportHook", () => {
54
54
  expect(snippet).not.toBeNull();
55
55
  const bodies = (snippet?.rows ?? []).map((r) => r["body"]);
56
56
  expect(bodies).toEqual(["by author"]);
57
+ // No read_users row is seeded in this stack, so add-note's self-lookup
58
+ // stamps authorName null — export must still carry the key.
59
+ expect(Object.keys(snippet?.rows?.[0] ?? {})).toContain("authorName");
57
60
  });
58
61
 
59
62
  test("returns null when the user authored no notes", async () => {
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.215.3",
4
+ "type": "improvement",
5
+ "title": "note-entry GDPR export now includes authorName",
6
+ "detail": "The Art. 20 export for `note-entry` (noteEntryExportHook) enumerates fields explicitly and did not know about the new `authorName` field added alongside `body`. The export row now also carries the stamped `authorName` (null on legacy/shredded rows, same as the read path)."
7
+ }
8
+ ]
@@ -21,12 +21,13 @@ export const noteEntryExportHook: UserDataExportHook = async (ctx) => {
21
21
  entityType: r["entityType"],
22
22
  entityId: r["entityId"],
23
23
  body: r["body"],
24
+ authorName: r["authorName"],
24
25
  insertedAt: r["insertedAt"],
25
26
  })),
26
27
  };
27
28
  };
28
29
 
29
- // Deliberate no-op: `body` is annotated `personal: { of: "authorId" }`
30
+ // Deliberate no-op: `body` and `authorName` are annotated `personal: { of: "authorId" }`
30
31
  // (entity.ts), which relies on crypto-shredding (mounted KMS) for erasure —
31
32
  // destroying the author's subject key makes every note-entry event AND
32
33
  // projected row unreadable at once, without needing a physical delete
@@ -55,4 +55,42 @@ describe("tenant-settings — Settings-Hub nav/section labels", () => {
55
55
  if (sectionTitle === undefined) throw new Error("unreachable");
56
56
  expect(translate(translations, sectionTitle)).not.toBe(sectionTitle);
57
57
  });
58
+
59
+ test("every Settings-Hub label the schema renders resolves in es, distinct from en", () => {
60
+ const registry = createRegistry([createConfigFeature(), createTenantSettingsFeature()]);
61
+ const schema = buildConfigFeatureSchema(registry);
62
+ const translations = registry.getFeature("tenant-settings")?.translations ?? {};
63
+
64
+ const renderedKeys: string[] = [];
65
+ for (const screenId of ["tenant-settings-system", "tenant-settings-tenant"]) {
66
+ const nav = schema.navs.find((n) => n.id === screenId);
67
+ const screen = schema.screens.find((s) => s.id === screenId);
68
+ expect(nav).toBeDefined();
69
+ expect(screen).toBeDefined();
70
+ if (nav === undefined || screen === undefined || screen.type !== "configEdit") {
71
+ throw new Error("unreachable");
72
+ }
73
+ const section = screen.layout.sections[0];
74
+ const sectionTitle = section !== undefined && "title" in section ? section.title : undefined;
75
+ expect(sectionTitle).toBeDefined();
76
+ if (sectionTitle === undefined) throw new Error("unreachable");
77
+
78
+ renderedKeys.push(nav.label, `screen:${screenId}.title`, sectionTitle);
79
+ renderedKeys.push(...Object.values(screen.fieldLabels ?? {}));
80
+ }
81
+
82
+ // The Settings-Hub renders exactly these label surfaces per scope: nav
83
+ // entry, screen title, section heading and one label per masked config
84
+ // key (currency + locale) — six distinct keys across both scopes.
85
+ expect(new Set(renderedKeys).size).toBe(6);
86
+
87
+ for (const key of renderedKeys) {
88
+ expect(translate(translations, key, "es")).not.toBe(key);
89
+ expect(translate(translations, key, "es")).not.toBe(translate(translations, key, "en"));
90
+ }
91
+
92
+ const systemLabel = translate(translations, "tenant-settings.settings.system", "es");
93
+ const tenantLabel = translate(translations, "tenant-settings.settings", "es");
94
+ expect(systemLabel).not.toBe(tenantLabel);
95
+ });
58
96
  });
@@ -29,21 +29,35 @@ export function createTenantSettingsFeature(
29
29
  r.config({ keys: buildTenantSettingsKeys(opts) });
30
30
  r.translations({
31
31
  keys: {
32
- "tenant-settings.currency": { de: "Standard-Währung", en: "Default Currency" },
33
- "tenant-settings.locale": { de: "Standard-Sprache", en: "Default Locale" },
32
+ "tenant-settings.currency": {
33
+ de: "Standard-Währung",
34
+ en: "Default Currency",
35
+ es: "Moneda predeterminada",
36
+ },
37
+ "tenant-settings.locale": {
38
+ de: "Standard-Sprache",
39
+ en: "Default Locale",
40
+ es: "Idioma predeterminado",
41
+ },
34
42
  // Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
35
43
  // required by the i18n boot-validator whenever a feature masks config keys.
36
44
  "screen:tenant-settings-system.title": {
37
45
  de: "Tenant-Einstellungen",
38
46
  en: "Tenant Settings",
47
+ es: "Ajustes de la organización",
39
48
  },
40
49
  "screen:tenant-settings-tenant.title": {
41
50
  de: "Tenant-Einstellungen",
42
51
  en: "Tenant Settings",
52
+ es: "Ajustes de la organización",
43
53
  },
44
54
  // Settings-Hub nav label + configEdit section heading (same key, both
45
55
  // scopes fall back here — see buildConfigFeatureSchema).
46
- "tenant-settings.settings": { de: "Tenant-Einstellungen", en: "Tenant Settings" },
56
+ "tenant-settings.settings": {
57
+ de: "Tenant-Einstellungen",
58
+ en: "Tenant Settings",
59
+ es: "Ajustes de la organización",
60
+ },
47
61
  // Both currency/locale are tenant-home with admin write access, which
48
62
  // cascades a SystemAdmin-only "set the platform default" screen up to
49
63
  // the system audience — scoped override so its nav entry reads
@@ -52,6 +66,7 @@ export function createTenantSettingsFeature(
52
66
  "tenant-settings.settings.system": {
53
67
  de: "Tenant-Einstellungen (Plattform-Standard)",
54
68
  en: "Tenant Settings (Platform Default)",
69
+ es: "Ajustes de la organización (valor predeterminado de la plataforma)",
55
70
  },
56
71
  },
57
72
  });
@@ -1,5 +1,10 @@
1
1
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature";
2
2
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write";
3
+ export {
4
+ denyIfTargetOutsideAdminTenant,
5
+ TARGET_USER_NOT_IN_ADMIN_TENANT,
6
+ } from "./lib/deny-if-target-outside-admin-tenant";
7
+ export { isSystemAdminActor } from "./lib/is-admin-actor";
3
8
  // #494 Bestandsdaten-Reconcile — Apps rufen das einmalig vor dem Re-Enable
4
9
  // von read_users-Rebuilds (siehe lib-Doc).
5
10
  export { backfillUserLifecycleEvents, updateUserLifecycle } from "./lib/update-user-lifecycle";