@cosmicdrift/kumiko-bundled-features 0.116.1 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +283 -0
  3. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +5 -1
  4. package/src/auth-email-password/handlers/invite-accept.write.ts +5 -2
  5. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +5 -1
  6. package/src/auth-email-password/handlers/token-request-handler.ts +4 -2
  7. package/src/channel-email/__tests__/pii-guard.test.ts +56 -0
  8. package/src/channel-email/email-channel.ts +2 -1
  9. package/src/channel-email/index.ts +1 -0
  10. package/src/channel-email/pii-guard.ts +36 -0
  11. package/src/config/table.ts +1 -1
  12. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +148 -0
  13. package/src/crypto-shredding/constants.ts +5 -0
  14. package/src/crypto-shredding/feature.ts +18 -0
  15. package/src/crypto-shredding/handlers/forget-subject.write.ts +91 -0
  16. package/src/crypto-shredding/index.ts +11 -0
  17. package/src/delivery/tables.ts +1 -1
  18. package/src/mail-foundation/feature.ts +5 -2
  19. package/src/personal-access-tokens/__tests__/feature.test.ts +24 -0
  20. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +39 -1
  21. package/src/personal-access-tokens/feature.ts +11 -1
  22. package/src/personal-access-tokens/handlers/create.write.ts +21 -15
  23. package/src/personal-access-tokens/handlers/list.query.ts +12 -9
  24. package/src/personal-access-tokens/schema/api-token.ts +1 -1
  25. package/src/sessions/__tests__/sessions.integration.test.ts +49 -0
  26. package/src/sessions/feature.ts +2 -0
  27. package/src/sessions/handlers/list.query.ts +12 -9
  28. package/src/sessions/handlers/mine.query.ts +11 -8
  29. package/src/sessions/schema/user-session.ts +4 -2
  30. package/src/sessions/session-callbacks.ts +19 -10
  31. package/src/shared/decrypt-stored-pii.ts +19 -0
  32. package/src/shared/encrypt-for-direct-write.ts +23 -0
  33. package/src/shared/index.ts +2 -0
  34. package/src/tenant/handlers/invitations.query.ts +10 -2
  35. package/src/tenant/invitation-table.ts +3 -2
  36. package/src/user/schema/user.ts +5 -2
  37. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +143 -0
  38. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +79 -0
  39. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +73 -0
  40. package/src/user-data-rights/handlers/deletion-grace-period.ts +4 -1
  41. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +3 -1
  42. package/src/user-data-rights/run-export-jobs.ts +5 -1
  43. package/src/user-data-rights/run-forget-cleanup.ts +51 -3
  44. package/src/user-data-rights/run-user-export.ts +39 -12
  45. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +6 -1
  46. package/src/user-profile/handlers/change-email.write.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.116.1",
3
+ "version": "0.119.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>",
@@ -21,6 +21,7 @@
21
21
  "./audit": "./src/audit/index.ts",
22
22
  "./compliance-profiles": "./src/compliance-profiles/index.ts",
23
23
  "./config": "./src/config/index.ts",
24
+ "./crypto-shredding": "./src/crypto-shredding/index.ts",
24
25
  "./config/web": "./src/config/web/index.ts",
25
26
  "./data-retention": "./src/data-retention/index.ts",
26
27
  "./readiness": "./src/readiness/index.ts",
@@ -95,11 +96,11 @@
95
96
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
96
97
  },
97
98
  "dependencies": {
98
- "@cosmicdrift/kumiko-dispatcher-live": "0.116.1",
99
- "@cosmicdrift/kumiko-framework": "0.116.1",
100
- "@cosmicdrift/kumiko-headless": "0.116.1",
101
- "@cosmicdrift/kumiko-renderer": "0.116.1",
102
- "@cosmicdrift/kumiko-renderer-web": "0.116.1",
99
+ "@cosmicdrift/kumiko-dispatcher-live": "0.119.0",
100
+ "@cosmicdrift/kumiko-framework": "0.119.0",
101
+ "@cosmicdrift/kumiko-headless": "0.119.0",
102
+ "@cosmicdrift/kumiko-renderer": "0.119.0",
103
+ "@cosmicdrift/kumiko-renderer-web": "0.119.0",
103
104
  "@mollie/api-client": "^4.5.0",
104
105
  "@node-rs/argon2": "^2.0.2",
105
106
  "@types/nodemailer": "^8.0.0",
@@ -0,0 +1,283 @@
1
+ // Auth-Flows mit aktivem PII-KMS + Blind-Index (#818 PR 2): user.email und
2
+ // invitation.email liegen als Ciphertext in der DB. Login und alle drei
3
+ // Invite-Accept-Branches müssen über die generierte email_bidx-Spalte
4
+ // (Equality-Lookup) bzw. decryptStoredEmail (Vergleich/Weiterverwendung)
5
+ // unverändert funktionieren — vor diesem PR war jeder dieser Pfade mit
6
+ // KMS silently broken.
7
+
8
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import {
11
+ configureBlindIndexKey,
12
+ configurePiiSubjectKms,
13
+ decryptPiiFieldValues,
14
+ InMemoryKmsAdapter,
15
+ isPiiCiphertext,
16
+ resetBlindIndexKeyForTests,
17
+ resetPiiSubjectKmsForTests,
18
+ } from "@cosmicdrift/kumiko-framework/crypto";
19
+ import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
20
+ import {
21
+ setupTestStack,
22
+ type TestStack,
23
+ unsafeCreateEntityTable,
24
+ unsafePushTables,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
27
+ import { createConfigFeature } from "../../config";
28
+ import { createConfigResolver } from "../../config/resolver";
29
+ import { configValuesTable } from "../../config/table";
30
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
31
+ import { notificationPreferencesTable } from "../../delivery/tables";
32
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
33
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
34
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
35
+ import { createTenantFeature } from "../../tenant";
36
+ import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
37
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
38
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
39
+ import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
40
+ import { createUserFeature } from "../../user/feature";
41
+ import { userEntity, userTable } from "../../user/schema/user";
42
+ import { AuthHandlers } from "../constants";
43
+ import { createAuthEmailPasswordFeature } from "../feature";
44
+ import { hashPassword } from "../password-hashing";
45
+ import { seedUser } from "../seeding";
46
+
47
+ const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
48
+ const BOB_EMAIL = "bob.kms@example.com";
49
+ const BOB_PASSWORD = "bob-existing-pw-1234";
50
+ const CAROL_EMAIL = "carol.kms@example.com";
51
+ const CAROL_PASSWORD = "carol-new-pw-1234";
52
+ const BIDX_KEY = Buffer.alloc(32, 7).toString("base64");
53
+
54
+ const emailTransport = createInMemoryTransport();
55
+
56
+ let stack: TestStack;
57
+ let kms: InMemoryKmsAdapter;
58
+ let aliceId: string;
59
+ let bobId: string;
60
+ let TENANT_A_ID: TenantId;
61
+ let TENANT_B_ID: TenantId;
62
+
63
+ function extractTokenFromMail(html: string): string {
64
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
65
+ if (!match?.[1]) throw new Error(`No token in invite mail html: ${html.slice(0, 200)}`);
66
+ return decodeURIComponent(match[1]);
67
+ }
68
+
69
+ beforeAll(async () => {
70
+ stack = await setupTestStack({
71
+ features: [
72
+ createConfigFeature(),
73
+ createUserFeature(),
74
+ createTenantFeature(),
75
+ createTemplateResolverFeature(),
76
+ createRendererFoundationFeature(),
77
+ createDeliveryFeature(),
78
+ createRendererSimpleFeature(),
79
+ createChannelEmailFeature({
80
+ transport: emailTransport,
81
+ renderer: simpleRenderer,
82
+ resolveEmail: async () => "unused@test.local",
83
+ }),
84
+ createAuthEmailPasswordFeature({
85
+ invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
86
+ }),
87
+ ],
88
+ extraContext: (deps) => ({
89
+ ...createDeliveryTestContext(deps),
90
+ configResolver: createConfigResolver(),
91
+ }),
92
+ authConfig: {
93
+ membershipQuery: "tenant:query:memberships",
94
+ loginHandler: AuthHandlers.login,
95
+ invite: {
96
+ acceptHandler: AuthHandlers.inviteAccept,
97
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
98
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
99
+ },
100
+ },
101
+ });
102
+
103
+ await unsafeCreateEntityTable(stack.db, userEntity);
104
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
105
+ await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
106
+ await unsafePushTables(stack.db, {
107
+ configValuesTable,
108
+ tenantMembershipsTable,
109
+ notificationPreferencesTable,
110
+ });
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await stack.cleanup();
115
+ });
116
+
117
+ beforeEach(async () => {
118
+ // KMS + bidx-Key MUESSEN vor den Seeds stehen — seedUser läuft über den
119
+ // Executor, die Rows sollen den verschlüsselten Prod-Zustand abbilden.
120
+ kms = new InMemoryKmsAdapter();
121
+ configurePiiSubjectKms(kms);
122
+ configureBlindIndexKey(BIDX_KEY);
123
+
124
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
125
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
126
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
127
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
128
+ emailTransport.sent.length = 0;
129
+ const allKeys = await stack.redis.redis.keys("invite:*");
130
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
131
+
132
+ TENANT_A_ID = crypto.randomUUID() as TenantId;
133
+ TENANT_B_ID = crypto.randomUUID() as TenantId;
134
+ await seedTenant(stack.db, {
135
+ id: TENANT_A_ID,
136
+ key: `kms-a-${TENANT_A_ID.slice(0, 8)}`,
137
+ name: "KMS Tenant A",
138
+ });
139
+ await seedTenant(stack.db, {
140
+ id: TENANT_B_ID,
141
+ key: `kms-b-${TENANT_B_ID.slice(0, 8)}`,
142
+ name: "KMS Tenant B",
143
+ });
144
+
145
+ ({ id: aliceId } = await seedUser(stack.db, {
146
+ email: "alice.kms@example.com",
147
+ displayName: "Alice",
148
+ passwordHash: await hashPassword("alice-pw-1234"),
149
+ emailVerified: true,
150
+ }));
151
+ await seedTenantMembership(stack.db, {
152
+ userId: aliceId,
153
+ tenantId: TENANT_A_ID,
154
+ roles: ["Admin"],
155
+ });
156
+
157
+ ({ id: bobId } = await seedUser(stack.db, {
158
+ email: BOB_EMAIL,
159
+ displayName: "Bob",
160
+ passwordHash: await hashPassword(BOB_PASSWORD),
161
+ emailVerified: true,
162
+ }));
163
+ await seedTenantMembership(stack.db, {
164
+ userId: bobId,
165
+ tenantId: TENANT_B_ID,
166
+ roles: ["User"],
167
+ });
168
+ });
169
+
170
+ afterEach(() => {
171
+ resetPiiSubjectKmsForTests();
172
+ resetBlindIndexKeyForTests();
173
+ });
174
+
175
+ function aliceSession(): SessionUser {
176
+ return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
177
+ }
178
+
179
+ function bobSession(): SessionUser {
180
+ return { id: bobId, tenantId: TENANT_B_ID, roles: ["User"] };
181
+ }
182
+
183
+ async function inviteEmail(email: string, role: string): Promise<string> {
184
+ await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
185
+ const sent = emailTransport.sent.at(-1);
186
+ if (!sent) throw new Error("invite-create didn't send a mail");
187
+ return extractTokenFromMail(sent.html);
188
+ }
189
+
190
+ async function rawUserRow(id: string): Promise<Record<string, unknown>> {
191
+ const rows = await asRawClient(stack.db).unsafe<Record<string, unknown>>(
192
+ `SELECT * FROM "${userTable.tableName}" WHERE id = $1`,
193
+ [id],
194
+ );
195
+ const row = rows[0];
196
+ if (!row) throw new Error(`no user row for ${id}`);
197
+ return row;
198
+ }
199
+
200
+ describe("auth flows with active KMS + blind index", () => {
201
+ test("seeded user row: ciphertext email + populated bidx column", async () => {
202
+ const row = await rawUserRow(bobId);
203
+ expect(isPiiCiphertext(row["email"])).toBe(true);
204
+ expect(String(row["email_bidx"])).toStartWith("kumiko-bidx:v1:");
205
+ });
206
+
207
+ test("login with plaintext email finds the encrypted row via bidx", async () => {
208
+ const res = await stack.http.raw("POST", "/api/auth/login", {
209
+ email: BOB_EMAIL,
210
+ password: BOB_PASSWORD,
211
+ });
212
+ expect(res.status).toBe(200);
213
+ const body = (await res.json()) as { isSuccess: boolean; token?: string };
214
+ expect(body.isSuccess).toBe(true);
215
+ expect(body.token).toBeTruthy();
216
+ });
217
+
218
+ test("Branch 1: logged-in accept matches emails across two ciphertexts", async () => {
219
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
220
+
221
+ const invitations = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
222
+ expect(invitations).toHaveLength(1);
223
+
224
+ const result = (await stack.http.writeOk(
225
+ AuthHandlers.inviteAccept,
226
+ { token },
227
+ bobSession(),
228
+ )) as {
229
+ tenantId: string;
230
+ alreadyMember: boolean;
231
+ };
232
+ expect(result.tenantId).toBe(TENANT_A_ID);
233
+ expect(result.alreadyMember).toBe(false);
234
+ });
235
+
236
+ test("Branch 2: invite-accept-with-login (anon + password)", async () => {
237
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
238
+
239
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
240
+ token,
241
+ email: BOB_EMAIL,
242
+ password: BOB_PASSWORD,
243
+ });
244
+ expect(res.status).toBe(200);
245
+ const body = (await res.json()) as { isSuccess: boolean; tenantId: string };
246
+ expect(body.isSuccess).toBe(true);
247
+ expect(body.tenantId).toBe(TENANT_A_ID);
248
+
249
+ const memberships = await selectMany(stack.db, tenantMembershipsTable, { userId: bobId });
250
+ expect(memberships).toHaveLength(2);
251
+ });
252
+
253
+ test("Branch 3: invite-signup-complete creates the user from the DECRYPTED invitation email", async () => {
254
+ const token = await inviteEmail(CAROL_EMAIL, "Admin");
255
+
256
+ const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
257
+ token,
258
+ password: CAROL_PASSWORD,
259
+ });
260
+ expect(res.status).toBe(200);
261
+ const body = (await res.json()) as { isSuccess: boolean; user: { id: string } };
262
+ expect(body.isSuccess).toBe(true);
263
+
264
+ // Carol-Row: eigener user-Subject-Ciphertext (nicht der invitation-
265
+ // Ciphertext durchgereicht) + Klartext-abgeleiteter displayName.
266
+ const row = await rawUserRow(body.user.id);
267
+ expect(isPiiCiphertext(row["email"])).toBe(true);
268
+ expect(String(row["email"])).toContain(body.user.id);
269
+ const decrypted = await decryptPiiFieldValues(
270
+ { displayName: row["display_name"] },
271
+ ["displayName"],
272
+ kms,
273
+ { requestId: "test" },
274
+ );
275
+ expect(decrypted["displayName"]).toBe("carol.kms");
276
+
277
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
278
+ email: CAROL_EMAIL,
279
+ password: CAROL_PASSWORD,
280
+ });
281
+ expect(loginRes.status).toBe(200);
282
+ });
283
+ });
@@ -28,6 +28,7 @@ import {
28
28
  } from "@cosmicdrift/kumiko-framework/engine";
29
29
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
30
30
  import { z } from "zod";
31
+ import { decryptStoredPii } from "../../shared";
31
32
  // kumiko-lint-ignore cross-feature-import invite-flow
32
33
  import {
33
34
  INVITATION_STATUS,
@@ -106,7 +107,10 @@ export function createInviteAcceptWithLoginHandler() {
106
107
  return invalidInviteToken();
107
108
 
108
109
  const invitationTenantId = invitation.tenantId;
109
- const invitationEmail = invitation.email;
110
+ const invitationEmail = await decryptStoredPii(
111
+ invitation.email,
112
+ "auth:invite-accept-with-login",
113
+ );
110
114
  const invitationRole = invitation.role;
111
115
  const invitationVersion = invitation.version;
112
116
 
@@ -24,6 +24,7 @@ import {
24
24
  } from "@cosmicdrift/kumiko-framework/engine";
25
25
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
26
26
  import { z } from "zod";
27
+ import { decryptStoredPii } from "../../shared";
27
28
  // kumiko-lint-ignore cross-feature-import invite-flow lebt in auth-email-password (Magic-Link), DB-row-owner ist tenant-feature
28
29
  import {
29
30
  INVITATION_STATUS,
@@ -100,7 +101,7 @@ export function createInviteAcceptHandler() {
100
101
  return invalidInviteToken();
101
102
 
102
103
  const invitationTenantId = invitation.tenantId;
103
- const invitationEmail = invitation.email;
104
+ const invitationEmail = await decryptStoredPii(invitation.email, "auth:invite-accept");
104
105
  const invitationRole = invitation.role;
105
106
  const invitationVersion = invitation.version;
106
107
 
@@ -110,7 +111,9 @@ export function createInviteAcceptHandler() {
110
111
  const userRow = await fetchOne<UserEmailRow>(ctx.db.raw, userTable, {
111
112
  id: event.user.id,
112
113
  });
113
- const userEmail = userRow?.email;
114
+ const userEmail = userRow?.email
115
+ ? await decryptStoredPii(userRow.email, "auth:invite-accept")
116
+ : undefined;
114
117
  if (!userRow || !userEmail || userEmail.toLowerCase() !== invitationEmail) {
115
118
  return inviteEmailMismatch();
116
119
  }
@@ -32,6 +32,7 @@ import {
32
32
  } from "@cosmicdrift/kumiko-framework/engine";
33
33
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
34
34
  import { z } from "zod";
35
+ import { decryptStoredPii } from "../../shared";
35
36
  // kumiko-lint-ignore cross-feature-import invite-flow
36
37
  import {
37
38
  INVITATION_STATUS,
@@ -109,7 +110,10 @@ export function createInviteSignupCompleteHandler() {
109
110
  return invalidInviteToken();
110
111
 
111
112
  const invitationTenantId = invitation.tenantId;
112
- const invitationEmail = invitation.email;
113
+ const invitationEmail = await decryptStoredPii(
114
+ invitation.email,
115
+ "auth:invite-signup-complete",
116
+ );
113
117
  const invitationRole = invitation.role;
114
118
  const invitationVersion = invitation.version;
115
119
 
@@ -125,7 +125,9 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
125
125
  renderContent: spec.renderContent,
126
126
  },
127
127
  {
128
- email: user.email,
128
+ // Row value is ciphertext with an active KMS; the byte-exact
129
+ // lookup above guarantees the payload IS the stored plaintext.
130
+ email: event.payload.email,
129
131
  appUrl: opts.appUrl,
130
132
  token,
131
133
  expiresAt: expiresAt.toString(),
@@ -136,7 +138,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
136
138
 
137
139
  const data: TokenRequestData<TSuccessKind> = {
138
140
  kind: spec.successKind,
139
- email: user.email,
141
+ email: event.payload.email,
140
142
  token,
141
143
  expiresAt: expiresAt.toString(),
142
144
  };
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { guardEmailMessage, withPiiCiphertextGuard } from "../pii-guard";
3
+ import { createInMemoryTransport } from "../types";
4
+
5
+ const CIPHERTEXT = "kumiko-pii:v1:user:6b2f4a0e-1c9d-4f3a-9d2e-00000000000a:8e2Rkjj+ww==";
6
+ const originalNodeEnv = process.env["NODE_ENV"];
7
+
8
+ afterEach(() => {
9
+ if (originalNodeEnv === undefined) delete process.env["NODE_ENV"];
10
+ else process.env["NODE_ENV"] = originalNodeEnv;
11
+ });
12
+
13
+ describe("guardEmailMessage", () => {
14
+ test("clean message passes through untouched", () => {
15
+ const msg = { to: "marc@example.com", subject: "Hi", html: "<p>Hi</p>" };
16
+ expect(guardEmailMessage(msg)).toBe(msg);
17
+ });
18
+
19
+ test("ciphertext recipient is always refused — even in production", () => {
20
+ process.env["NODE_ENV"] = "production";
21
+ expect(() => guardEmailMessage({ to: CIPHERTEXT, subject: "Hi", html: "x" })).toThrow(
22
+ /recipient address is a PII ciphertext/,
23
+ );
24
+ });
25
+
26
+ test("ciphertext in body fails loud outside production", () => {
27
+ expect(() =>
28
+ guardEmailMessage({ to: "marc@example.com", subject: "Hi", html: `<p>${CIPHERTEXT}</p>` }),
29
+ ).toThrow(/subject\/body contains a PII ciphertext/);
30
+ });
31
+
32
+ test("production redacts body/subject instead of throwing", () => {
33
+ process.env["NODE_ENV"] = "production";
34
+ const out = guardEmailMessage({
35
+ to: "marc@example.com",
36
+ subject: `Re: ${CIPHERTEXT}`,
37
+ html: `<p>${CIPHERTEXT}</p>`,
38
+ });
39
+ expect(out.subject).toBe("Re: [pii-redacted]");
40
+ expect(out.html).toBe("<p>[pii-redacted]</p>");
41
+ });
42
+ });
43
+
44
+ describe("withPiiCiphertextGuard", () => {
45
+ test("wrapped transport refuses a ciphertext recipient before sending", async () => {
46
+ const inner = createInMemoryTransport();
47
+ const guarded = withPiiCiphertextGuard(inner);
48
+ await expect(guarded.send({ to: CIPHERTEXT, subject: "x", html: "y" })).rejects.toThrow(
49
+ /recipient address is a PII ciphertext/,
50
+ );
51
+ expect(inner.sent).toHaveLength(0);
52
+
53
+ await guarded.send({ to: "ok@example.com", subject: "x", html: "y" });
54
+ expect(inner.sent).toHaveLength(1);
55
+ });
56
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  NotificationRenderer,
7
7
  RenderedMessage,
8
8
  } from "../delivery";
9
+ import { guardEmailMessage } from "./pii-guard";
9
10
  import type { EmailTransport } from "./types";
10
11
 
11
12
  export type EmailChannelOptions = {
@@ -53,7 +54,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
53
54
 
54
55
  async send(address, message, _ctx, rendered) {
55
56
  const { html, subject } = rendered ?? (await renderMessage(message));
56
- await transport.send({ to: address, subject, html });
57
+ await transport.send(guardEmailMessage({ to: address, subject, html }));
57
58
  return { status: "sent", address };
58
59
  },
59
60
  };
@@ -1,5 +1,6 @@
1
1
  export { createEmailChannel, type EmailChannelOptions } from "./email-channel";
2
2
  export { createChannelEmailFeature } from "./feature";
3
+ export { guardEmailMessage, withPiiCiphertextGuard } from "./pii-guard";
3
4
  export {
4
5
  createSmtpTransport,
5
6
  createSmtpTransportFromEnv,
@@ -0,0 +1,36 @@
1
+ import { PII_CIPHERTEXT_PREFIX } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import type { EmailMessage, EmailTransport } from "./types";
3
+
4
+ const isProductionEnv = () => process.env["NODE_ENV"] === "production";
5
+ const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
6
+
7
+ // A PII ciphertext never belongs in an outgoing mail. A ciphertext RECIPIENT
8
+ // is always refused (the address is garbage — better no mail than a leaked
9
+ // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
10
+ // in prod.
11
+ export function guardEmailMessage(message: EmailMessage): EmailMessage {
12
+ if (message.to.includes(PII_CIPHERTEXT_PREFIX)) {
13
+ throw new Error(
14
+ "[channel-email] refusing to send: recipient address is a PII ciphertext " +
15
+ `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
16
+ );
17
+ }
18
+ const leaking =
19
+ message.subject.includes(PII_CIPHERTEXT_PREFIX) || message.html.includes(PII_CIPHERTEXT_PREFIX);
20
+ if (!leaking) return message;
21
+ const detail =
22
+ "[channel-email] mail subject/body contains a PII ciphertext " +
23
+ `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before rendering.`;
24
+ if (!isProductionEnv()) throw new Error(detail);
25
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for a redacted prod leak
26
+ console.error(detail);
27
+ return {
28
+ ...message,
29
+ subject: message.subject.replace(CIPHERTEXT_RE, "[pii-redacted]"),
30
+ html: message.html.replace(CIPHERTEXT_RE, "[pii-redacted]"),
31
+ };
32
+ }
33
+
34
+ export function withPiiCiphertextGuard(transport: EmailTransport): EmailTransport {
35
+ return { ...transport, send: async (message) => transport.send(guardEmailMessage(message)) };
36
+ }
@@ -25,7 +25,7 @@ export const configValueEntity = createEntity({
25
25
  // breaking the null-vs-missing distinction the resolver already draws.
26
26
  value: createTextField({}),
27
27
  // user-scope row: userId populated. tenant- / system-scope: null.
28
- userId: createTextField({ pii: true }),
28
+ userId: createTextField({ allowPlaintext: "pseudonymous-fk" }),
29
29
  },
30
30
  indexes: [
31
31
  { unique: true, columns: ["key", "tenantId", "userId"], name: "read_config_values_unique" },