@cosmicdrift/kumiko-bundled-features 0.248.0 → 0.250.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 (34) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/invite-accept-with-login-gates.integration.test.ts +69 -0
  3. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -0
  4. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +3 -0
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +37 -0
  6. package/src/billing-foundation/feature.ts +14 -5
  7. package/src/billing-foundation/handlers/create-checkout-session.write.ts +5 -1
  8. package/src/billing-foundation/types.ts +5 -0
  9. package/src/cap-counter/feature.ts +1 -1
  10. package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +49 -0
  11. package/src/cap-overview/__tests__/cap-overview.boot.test.ts +26 -1
  12. package/src/cap-overview/access.ts +9 -0
  13. package/src/cap-overview/feature.ts +3 -3
  14. package/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  15. package/src/cap-overview/index.ts +1 -0
  16. package/src/cap-overview/screens.ts +3 -2
  17. package/src/crypto-shredding/feature.ts +4 -2
  18. package/src/custom-fields/feature.ts +9 -2
  19. package/src/document-ingest-foundation/feature.ts +8 -2
  20. package/src/feature-toggles/feature.ts +1 -1
  21. package/src/inbound-mail-foundation/feature.ts +19 -5
  22. package/src/secrets/feature.ts +1 -1
  23. package/src/sessions/feature.ts +1 -1
  24. package/src/subscription-mollie/__tests__/plugin-methods.test.ts +86 -0
  25. package/src/subscription-mollie/plugin-methods.ts +9 -1
  26. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +49 -0
  27. package/src/subscription-stripe/plugin-methods.ts +11 -9
  28. package/src/tenant-lifecycle/feature.ts +23 -6
  29. package/src/user/__tests__/email-unique-plaintext-soft-delete.integration.test.ts +139 -0
  30. package/src/user/seeding.ts +1 -1
  31. package/src/user-data-rights/__tests__/boot-checks.test.ts +4 -2
  32. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +111 -1
  33. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +1 -1
  34. package/src/user-data-rights/run-forget-cleanup.ts +14 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.248.0",
3
+ "version": "0.250.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.248.0",
134
- "@cosmicdrift/kumiko-framework": "0.248.0",
135
- "@cosmicdrift/kumiko-headless": "0.248.0",
136
- "@cosmicdrift/kumiko-renderer": "0.248.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.248.0",
138
- "@cosmicdrift/kumiko-types": "0.248.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.250.0",
134
+ "@cosmicdrift/kumiko-framework": "0.250.0",
135
+ "@cosmicdrift/kumiko-headless": "0.250.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.250.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.250.0",
138
+ "@cosmicdrift/kumiko-types": "0.250.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.248.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.248.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.250.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.250.0"
169
169
  }
170
170
  }
@@ -353,3 +353,72 @@ describe("invite-accept-with-login: account-status gate", () => {
353
353
  expect(memberships).toHaveLength(0);
354
354
  });
355
355
  });
356
+
357
+ describe("invite-accept-with-login: soft-deleted email predecessor (fw#2593)", () => {
358
+ // read_users_email_unique is now a partial index (WHERE is_deleted = false),
359
+ // so a soft-deleted row and a live row can share an email. The password
360
+ // lookup must resolve the live row, never the soft-deleted one.
361
+ test("logs in as the live user, not the soft-deleted predecessor with the same email", async () => {
362
+ const email = "reused-invitee@example.com";
363
+ const deletedUserId = crypto.randomUUID();
364
+ const liveUserId = crypto.randomUUID();
365
+ const deletedPassword = "deleted-predecessor-pw-1234";
366
+ const livePassword = "live-successor-pw-5678";
367
+
368
+ await seedRow(stack.db, userTable, {
369
+ id: deletedUserId,
370
+ tenantId: TENANT_A_ID,
371
+ email,
372
+ passwordHash: await hashPassword(deletedPassword),
373
+ displayName: "Deleted Predecessor",
374
+ locale: "de",
375
+ emailVerified: true,
376
+ roles: "[]",
377
+ status: USER_STATUS.Active,
378
+ });
379
+ await asRawClient(stack.db).unsafe(
380
+ `UPDATE "${userTable.tableName}" SET is_deleted = true WHERE id = $1`,
381
+ [deletedUserId],
382
+ );
383
+
384
+ await seedRow(stack.db, userTable, {
385
+ id: liveUserId,
386
+ tenantId: TENANT_A_ID,
387
+ email,
388
+ passwordHash: await hashPassword(livePassword),
389
+ displayName: "Live Successor",
390
+ locale: "de",
391
+ emailVerified: true,
392
+ roles: "[]",
393
+ status: USER_STATUS.Active,
394
+ });
395
+
396
+ const token = await inviteEmail(email, "Editor");
397
+
398
+ // The deleted predecessor's password must never authenticate this
399
+ // invite, even though it shares the email — proves the lookup didn't
400
+ // just happen to land on the live row by insertion order. A failed
401
+ // password check unburns the token (see the handler's `finally`), so
402
+ // the same token is still usable for the real attempt below.
403
+ const deletedAttempt = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
404
+ token,
405
+ email,
406
+ password: deletedPassword,
407
+ });
408
+ expect(deletedAttempt.status).not.toBe(200);
409
+
410
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
411
+ token,
412
+ email,
413
+ password: livePassword,
414
+ });
415
+
416
+ expect(res.status).toBe(200);
417
+ const body = (await res.json()) as {
418
+ isSuccess: boolean;
419
+ user?: { id: string };
420
+ };
421
+ expect(body.isSuccess).toBe(true);
422
+ expect(body.user?.id).toBe(liveUserId);
423
+ });
424
+ });
@@ -171,8 +171,11 @@ export function createInviteAcceptWithLoginHandler(opts: InviteAcceptWithLoginOp
171
171
 
172
172
  // Password check against userTable, through the same gates
173
173
  // login.write.ts runs (see login-gates.test.ts for the gate contracts).
174
+ // Email uniqueness is partial on live rows (framework#2593) — without
175
+ // this filter the lookup can resolve a soft-deleted row.
174
176
  const userRow = await fetchOne<UserAuthRow>(ctx.db.raw, userTable, {
175
177
  email: invitationEmail,
178
+ isDeleted: false,
176
179
  });
177
180
  if (!userRow?.passwordHash) return invalidInviteToken();
178
181
 
@@ -129,8 +129,11 @@ export function createInviteSignupCompleteHandler() {
129
129
  // muss der User Branch 2 (acceptWithLogin) nutzen. Hier ist
130
130
  // explizit "neue Email" — sonst hätten wir zwei Wege ein
131
131
  // Password zu setzen für denselben User.
132
+ // Email uniqueness is partial on live rows (framework#2593) — without
133
+ // this filter the lookup can resolve a soft-deleted row.
132
134
  const existingUser = await fetchOne(ctx.db.raw, userTable, {
133
135
  email: invitationEmail,
136
+ isDeleted: false,
134
137
  });
135
138
  if (existingUser) return invalidInviteToken();
136
139
 
@@ -68,6 +68,7 @@ const mockCheckoutCalls: Array<{
68
68
  successUrl: string;
69
69
  cancelUrl: string;
70
70
  providerCustomerId?: string;
71
+ mode?: "subscription" | "payment";
71
72
  }> = [];
72
73
  const mockPortalCalls: Array<{ providerCustomerId: string; returnUrl: string }> = [];
73
74
 
@@ -82,6 +83,7 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
82
83
  successUrl: options.successUrl,
83
84
  cancelUrl: options.cancelUrl,
84
85
  ...(options.providerCustomerId && { providerCustomerId: options.providerCustomerId }),
86
+ ...(options.mode && { mode: options.mode }),
85
87
  });
86
88
  return { url: `https://mock.example/checkout/${options.priceId}` };
87
89
  },
@@ -513,6 +515,41 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
513
515
  );
514
516
  expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
515
517
  });
518
+
519
+ test("optional mode wird durchgereicht (One-off-Payment-Flow, fw#2755)", async () => {
520
+ mockCheckoutCalls.length = 0;
521
+ const admin = adminFor(3014);
522
+ await stack.http.writeOk(
523
+ "billing-foundation:write:create-checkout-session",
524
+ {
525
+ providerName: "mock",
526
+ priceId: "price_topup_test",
527
+ successUrl: "https://example.com/s",
528
+ cancelUrl: "https://example.com/c",
529
+ mode: "payment",
530
+ },
531
+ admin,
532
+ );
533
+ expect(mockCheckoutCalls).toHaveLength(1);
534
+ expect(mockCheckoutCalls[0]?.mode).toBe("payment");
535
+ });
536
+
537
+ test("mode omitted → plugin receives no mode field (Regression-Pin für bestehende Aufrufer)", async () => {
538
+ mockCheckoutCalls.length = 0;
539
+ const admin = adminFor(3015);
540
+ await stack.http.writeOk(
541
+ "billing-foundation:write:create-checkout-session",
542
+ {
543
+ providerName: "mock",
544
+ priceId: "price_pro_test",
545
+ successUrl: "https://example.com/s",
546
+ cancelUrl: "https://example.com/c",
547
+ },
548
+ admin,
549
+ );
550
+ expect(mockCheckoutCalls).toHaveLength(1);
551
+ expect(mockCheckoutCalls[0]?.mode).toBeUndefined();
552
+ });
516
553
  });
517
554
 
518
555
  describe("scenario 7: create-portal-session — Plugin-routing", () => {
@@ -86,11 +86,20 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
86
86
  // shape (= subscription-state-snapshot); der event-type taggt was
87
87
  // passiert ist. Future-consumer (billing-history, accounting)
88
88
  // listenen direkt auf den event-type ohne payload-discriminator.
89
- r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema);
90
- r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema);
91
- r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema);
92
- r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema);
93
- r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema);
89
+ // piiFields: "none" — provider ids are tenantOwned ciphertext, not plaintext personal data.
90
+ r.defineEvent(SUBSCRIPTION_CREATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
91
+ piiFields: "none",
92
+ });
93
+ r.defineEvent(SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, {
94
+ piiFields: "none",
95
+ });
96
+ r.defineEvent(SUBSCRIPTION_CANCELED_EVENT_SHORT, subscriptionEventPayloadSchema, {
97
+ piiFields: "none",
98
+ });
99
+ r.defineEvent(INVOICE_PAID_EVENT_SHORT, subscriptionEventPayloadSchema, { piiFields: "none" });
100
+ r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema, {
101
+ piiFields: "none",
102
+ });
94
103
 
95
104
  // Inline projection: materialized current state in `read_subscriptions`.
96
105
  // Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
@@ -32,13 +32,16 @@ const createCheckoutSessionSchema = z.object({
32
32
  /** Optional: existierender provider-customer wenn der Tenant schon
33
33
  * einen account beim Provider hat (= Plan-Wechsel statt Neuregistrierung). */
34
34
  providerCustomerId: z.string().max(200).optional(),
35
+ /** Optional: `"payment"` for a one-off checkout (e.g. credit top-up)
36
+ * instead of a recurring subscription. Defaults to `"subscription"`. */
37
+ mode: z.enum(["subscription", "payment"]).optional(),
35
38
  });
36
39
  type CreateCheckoutSessionPayload = z.infer<typeof createCheckoutSessionSchema>;
37
40
 
38
41
  export const createCheckoutSessionHandler: WriteHandlerDef = {
39
42
  name: "create-checkout-session",
40
43
  description:
41
- "Opens a hosted checkout page at the named subscription provider for the caller's tenant and returns its URL; use it when a tenant admin wants to subscribe or switch to a paid plan.",
44
+ 'Opens a hosted checkout page at the named subscription provider for the caller\'s tenant and returns its URL; use it when a tenant admin wants to subscribe, switch plans, or make a one-off payment (mode: "payment").',
42
45
  schema: createCheckoutSessionSchema,
43
46
  // Tenant-Admin-only — der Tenant muss bewusst seine Subscription
44
47
  // konfigurieren. SystemAdmin als Fallback für Operator-Initiated-Flows.
@@ -71,6 +74,7 @@ export const createCheckoutSessionHandler: WriteHandlerDef = {
71
74
  successUrl: payload.successUrl,
72
75
  cancelUrl: payload.cancelUrl,
73
76
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
77
+ ...(payload.mode && { mode: payload.mode }),
74
78
  });
75
79
 
76
80
  return {
@@ -129,6 +129,11 @@ export type SubscriptionProviderPlugin = {
129
129
  * schon einen Account beim Provider hat. Sonst legt der Provider
130
130
  * beim checkout einen neuen customer an. */
131
131
  readonly providerCustomerId?: string;
132
+ /** Checkout mode. `"subscription"` (default) starts a recurring
133
+ * billing flow; `"payment"` a one-off checkout (e.g. pay-per-use
134
+ * credit top-ups). Optional with default `"subscription"` — existing
135
+ * callers are unaffected. */
136
+ readonly mode?: "subscription" | "payment";
132
137
  },
133
138
  ) => Promise<{ readonly url: string }>;
134
139
 
@@ -84,7 +84,7 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
84
84
  // dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
85
85
  // "cap-counter:event:rolling-incremented" (siehe
86
86
  // ROLLING_INCREMENTED_EVENT_QN).
87
- r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema);
87
+ r.defineEvent(ROLLING_INCREMENTED_EVENT_SHORT, rollingIncrementedSchema, { piiFields: "none" });
88
88
 
89
89
  // Custom write-handlers.
90
90
  // - increment: Calendar-Period (CRUD via projection-row).
@@ -158,6 +158,55 @@ describe("cap-overview tenant isolation", () => {
158
158
  });
159
159
  });
160
160
 
161
+ describe("cap-overview my-caps for regular tenant members", () => {
162
+ test("a regular User(A) reads A's own usage, not a 403", async () => {
163
+ const memberA = createTestUser({ id: 90041, tenantId: TENANT_A, roles: ["User"] });
164
+ const result = await stack.http.queryOk<{
165
+ rows: readonly { id: string; used: number | null }[];
166
+ }>(CapOverviewQueries.capsUsage, {}, memberA);
167
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
168
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_A]);
169
+ });
170
+
171
+ test("an Editor(B) reads B's own usage, not A's", async () => {
172
+ const editorB = createTestUser({ id: 90042, tenantId: TENANT_B, roles: ["Editor"] });
173
+ const result = await stack.http.queryOk<{
174
+ rows: readonly { id: string; used: number | null }[];
175
+ }>(CapOverviewQueries.capsUsage, {}, editorB);
176
+ const widgetsRow = result.rows.find((row) => row.id === testCap.id);
177
+ expect(widgetsRow?.used).toBe(USAGE_BY_TENANT[TENANT_B]);
178
+ expect(widgetsRow?.used).not.toBe(USAGE_BY_TENANT[TENANT_A]);
179
+ });
180
+
181
+ test("a regular User(A) cannot read tenant B's usage via override", async () => {
182
+ const memberA = createTestUser({ id: 90043, tenantId: TENANT_A, roles: ["User"] });
183
+ const res = await stack.http.query(
184
+ CapOverviewQueries.capsUsage,
185
+ { tenantId: TENANT_B },
186
+ memberA,
187
+ );
188
+ expect(res.status).toBe(403);
189
+ });
190
+
191
+ test("a regular User(A) cannot list all tenants", async () => {
192
+ const memberA = createTestUser({ id: 90044, tenantId: TENANT_A, roles: ["User"] });
193
+ const res = await stack.http.query(CapOverviewQueries.tenantCapsList, {}, memberA);
194
+ expect(res.status).toBe(403);
195
+ });
196
+
197
+ test("a regular User(A) cannot enumerate tenant names via the platform picker", async () => {
198
+ const memberA = createTestUser({ id: 90045, tenantId: TENANT_A, roles: ["User"] });
199
+ const res = await stack.http.query(CapOverviewQueries.tenantOptions, {}, memberA);
200
+ expect(res.status).toBe(403);
201
+ });
202
+
203
+ test("an unranked app role still cannot reach my-caps", async () => {
204
+ const viewerA = createTestUser({ id: 90046, tenantId: TENANT_A, roles: ["Viewer"] });
205
+ const res = await stack.http.query(CapOverviewQueries.capsUsage, {}, viewerA);
206
+ expect(res.status).toBe(403);
207
+ });
208
+ });
209
+
161
210
  describe("cap-overview tier filter operators", () => {
162
211
  test("op:ne excludes tenants matching the value, not just those matching it", async () => {
163
212
  const sysAdmin = createTestUser({ id: 90017, tenantId: TENANT_A, roles: ["SystemAdmin"] });
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
3
4
  import { billingFoundationFeature } from "../../billing-foundation";
4
5
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
5
6
  import { createConfigFeature } from "../../config/feature";
6
7
  import { createTenantFeature } from "../../tenant";
7
8
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
8
9
  import { tierEngineFeature } from "../../tier-engine";
10
+ import { MY_CAPS_ACCESS_ROLES } from "../access";
9
11
  import {
10
12
  MY_CAPS_SCREEN_ID,
11
13
  PLATFORM_TENANT_CAPS_SCREEN_ID,
@@ -58,6 +60,29 @@ describe("cap-overview boot", () => {
58
60
  expect(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.type).toBe("dashboard");
59
61
  });
60
62
 
63
+ test("my-caps screen and the query filling its cards carry the SAME rule", () => {
64
+ const feature = createCapOverviewFeature({ caps: [testCap] });
65
+ expect(rolesOf(feature.screens[MY_CAPS_SCREEN_ID]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
66
+ expect(rolesOf(feature.queryHandlers["caps:usage"]?.access)).toEqual([...MY_CAPS_ACCESS_ROLES]);
67
+ });
68
+
69
+ test("my-caps stays reachable for every role that reached it before", () => {
70
+ for (const role of access.admin) {
71
+ expect(MY_CAPS_ACCESS_ROLES).toContain(role);
72
+ }
73
+ expect(MY_CAPS_ACCESS_ROLES).toContain("User");
74
+ });
75
+
76
+ test("the platform-wide screens and their queries stay SystemAdmin-only", () => {
77
+ const feature = createCapOverviewFeature({ caps: [testCap] });
78
+ expect(rolesOf(feature.screens[TENANT_CAP_LIST_SCREEN_ID]?.access)).toEqual(["SystemAdmin"]);
79
+ expect(rolesOf(feature.screens[PLATFORM_TENANT_CAPS_SCREEN_ID]?.access)).toEqual([
80
+ "SystemAdmin",
81
+ ]);
82
+ expect(rolesOf(feature.queryHandlers["tenant-caps:list"]?.access)).toEqual(["SystemAdmin"]);
83
+ expect(rolesOf(feature.queryHandlers["tenant-options"]?.access)).toEqual(["SystemAdmin"]);
84
+ });
85
+
61
86
  test("tenant-cap-list facet field is a declared column and the list handler schema accepts filters", () => {
62
87
  const feature = createCapOverviewFeature({ caps: [testCap], tiers: ["free", "pro"] });
63
88
  const screen = feature.screens[TENANT_CAP_LIST_SCREEN_ID];
@@ -0,0 +1,9 @@
1
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ // Every built-in membership rank (role-assignment.ts: User < Editor < Admin <
4
+ // TenantAdmin < SystemAdmin), so a regular team member can see their own
5
+ // tenant's usage. Deliberately not `access.authenticated` — that preset omits
6
+ // TenantAdmin and would revoke the access TenantAdmins already have. The
7
+ // screen and the query that fills its cards must carry the SAME rule, or the
8
+ // dashboard renders and every card 403s.
9
+ export const MY_CAPS_ACCESS_ROLES = access.roles("User", "Editor", ...access.admin);
@@ -5,8 +5,8 @@
5
5
  // platform-wide table of every tenant's tier, billing status, and
6
6
  // usage against a configurable set of caps.
7
7
  // 2. caps:usage query + my-caps / platform-tenant-caps dashboards —
8
- // per-tenant usage cards. TenantAdmin sees their own tenant;
9
- // SystemAdmin can additionally view any tenant via `tenantId`.
8
+ // per-tenant usage cards. Every member of a tenant sees their own
9
+ // tenant; SystemAdmin can additionally view any tenant via `tenantId`.
10
10
  //
11
11
  // **What this feature does NOT do:**
12
12
  // - No writes. Reads tier-engine's read_tier_assignments, billing-
@@ -52,7 +52,7 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
52
52
 
53
53
  return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
54
54
  r.describe(
55
- "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; TenantAdmin gets their own usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
55
+ "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; every member of a tenant gets their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
56
56
  );
57
57
  r.uiHints({
58
58
  displayLabel: "Cap Overview · Tier & Usage Visibility",
@@ -1,7 +1,6 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
3
3
  import {
4
- access,
5
4
  crossTenantOverrideDenied,
6
5
  defineQueryHandler,
7
6
  type QueryHandlerDefinition,
@@ -9,6 +8,7 @@ import {
9
8
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
9
  import { z } from "zod";
11
10
  import { tierAssignmentEntity } from "../../tier-engine";
11
+ import { MY_CAPS_ACCESS_ROLES } from "../access";
12
12
  import type { CapSpec, CapUsageWithMeta } from "../types";
13
13
  import { computeFraction, computeTone, computeUnclampedFraction } from "../usage-math";
14
14
 
@@ -20,9 +20,9 @@ export function createCapsUsageQuery(caps: readonly CapSpec[]): QueryHandlerDefi
20
20
  return defineQueryHandler({
21
21
  name: "caps:usage",
22
22
  description:
23
- "Returns one tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (SystemAdmin may target another tenant via tenantId).",
23
+ "Returns the calling user's own tenant's configured caps with used amount, tier limit and utilisation fraction; use it to answer how close a tenant is to its quota (only SystemAdmin may target another tenant via tenantId).",
24
24
  schema: z.object({ tenantId: z.string().min(1).optional() }),
25
- access: { roles: access.admin },
25
+ access: { roles: MY_CAPS_ACCESS_ROLES },
26
26
  handler: async (query, ctx) => {
27
27
  if (!ctx.systemDb) {
28
28
  throw new InternalError({
@@ -1,5 +1,6 @@
1
1
  // Public API of the cap-overview bundled-feature.
2
2
 
3
+ export { MY_CAPS_ACCESS_ROLES } from "./access";
3
4
  export {
4
5
  CAP_CARDS_PANEL_COMPONENT,
5
6
  CAP_OVERVIEW_FEATURE,
@@ -1,4 +1,5 @@
1
- import { access, type ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { MY_CAPS_ACCESS_ROLES } from "./access";
2
3
  import {
3
4
  CAP_CARDS_PANEL_COMPONENT,
4
5
  CAP_USAGE_CELL_COMPONENT,
@@ -78,7 +79,7 @@ export const myCapsScreen: ScreenDefinition = {
78
79
  component: { react: { __component: CAP_CARDS_PANEL_COMPONENT } },
79
80
  },
80
81
  ],
81
- access: { roles: access.admin },
82
+ access: { roles: MY_CAPS_ACCESS_ROLES },
82
83
  };
83
84
 
84
85
  export const platformTenantCapsScreen: ScreenDefinition = {
@@ -16,8 +16,10 @@ export function createCryptoShreddingFeature(): FeatureDefinition {
16
16
  category: "compliance",
17
17
  });
18
18
 
19
- r.defineEvent("subject-forgotten", subjectForgottenSchema);
20
- r.defineEvent("forget-denied", subjectForgetDeniedSchema);
19
+ // `reason` is operator free text and may name the subject; encrypting it under
20
+ // the subject key is pointless (that key is being erased) — see #2776.
21
+ r.defineEvent("subject-forgotten", subjectForgottenSchema, { piiFields: "none" });
22
+ r.defineEvent("forget-denied", subjectForgetDeniedSchema, { piiFields: "none" });
21
23
  r.writeHandler(forgetSubjectWrite);
22
24
  });
23
25
  }
@@ -128,11 +128,18 @@ function registerCustomFields(
128
128
  // Event-types — qualified als "custom-fields:event:<short-name>".
129
129
  // Returned EventDefs liefern .name als compile-time literal-typed string,
130
130
  // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
131
- const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema);
132
- const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema);
131
+ // `value: z.unknown()` has no field-level schema to catalog under piiFields;
132
+ // "no PII" is a naming convention (#972), not an enforced guarantee — see #2776.
133
+ const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
134
+ piiFields: "none",
135
+ });
136
+ const clearedEvent = r.defineEvent(CUSTOM_FIELD_CLEARED_EVENT, customFieldClearedSchema, {
137
+ piiFields: "none",
138
+ });
133
139
  const fieldDefinitionDeletedEvent = r.defineEvent(
134
140
  FIELD_DEFINITION_DELETED_EVENT,
135
141
  fieldDefinitionDeletedSchema,
142
+ { piiFields: "none" },
136
143
  );
137
144
 
138
145
  // Extension-Registrar — registriert dass diese Extension existiert.
@@ -102,8 +102,14 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
102
102
  }),
103
103
  );
104
104
 
105
- r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema);
106
- r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema);
105
+ // "fileName" can carry a real person's name; the payload has no user-subject
106
+ // field to encrypt it under, so piiFields stays "none" pending #2776.
107
+ r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema, {
108
+ piiFields: "none",
109
+ });
110
+ r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema, {
111
+ piiFields: "none",
112
+ });
107
113
 
108
114
  r.multiStreamProjection({
109
115
  name: "request-ingest",
@@ -61,7 +61,7 @@ export function createFeatureTogglesFeature(
61
61
  // alongside every other write — audit.list picks it up automatically,
62
62
  // no dedicated projection needed. Qualified name after prefixing:
63
63
  // "feature-toggles:event:toggle-set" (see constants.FEATURE_TOGGLE_SET_EVENT_NAME).
64
- r.defineEvent("toggle-set", featureToggleSetSchema);
64
+ r.defineEvent("toggle-set", featureToggleSetSchema, { piiFields: "none" });
65
65
 
66
66
  const handlers = {
67
67
  set: r.writeHandler(createSetWriteHandler(options.getRuntime)),
@@ -109,11 +109,25 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
109
109
  r.requires("tenant-lifecycle");
110
110
 
111
111
  // 5 fine-grained domain-events (Payload-Schemas: events.ts).
112
- r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
113
- r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema);
114
- r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema);
115
- r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema);
116
- r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema);
112
+ // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
113
+ // "displayName" stays plaintext (often the mailbox owner's real name for
114
+ // personal accounts); encrypting it would break the entity-projection read
115
+ // path that carries it as `personal: false` (#2776).
116
+ r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
117
+ piiFields: "none",
118
+ });
119
+ r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema, {
120
+ piiFields: "none",
121
+ });
122
+ r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
123
+ piiFields: "none",
124
+ });
125
+ r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema, {
126
+ piiFields: "none",
127
+ });
128
+ r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema, {
129
+ piiFields: "none",
130
+ });
117
131
 
118
132
  // Inline projections — apply in derselben TX wie der Append.
119
133
  r.projection({
@@ -153,7 +153,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
153
153
  // discover the type; secrets-context.get parses payloads against
154
154
  // `secretReadSchema` at write time because the low-level append() path
155
155
  // skips ctx.appendEvent's schema-validation guard.
156
- r.defineEvent("read", secretReadSchema);
156
+ r.defineEvent("read", secretReadSchema, { piiFields: "none" });
157
157
 
158
158
  // Per-tenant handlers (set/delete/list) run in the default tenant-scope,
159
159
  // giving them the automatic ctx.db tenant-filter as extra defense.
@@ -146,7 +146,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
146
146
  // sessionRevokedSchema explicitly, same guarantee via a different path.
147
147
  // No projection: the payload IS the read, consumed directly off the
148
148
  // event-store NOTIFY (#1560).
149
- r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema);
149
+ r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema, { piiFields: "none" });
150
150
 
151
151
  const handlers = {
152
152
  revoke: r.writeHandler(revokeWrite),
@@ -0,0 +1,86 @@
1
+ // Unit tests for the Mollie plugin methods (createCheckoutSession only —
2
+ // createPortalSession/cancelSubscription are not implemented, see
3
+ // plugin-methods.ts). Mollie-SDK calls are mocked via spyOn — we test our
4
+ // sequenceType/metadata mapping, NOT Mollie itself.
5
+
6
+ import { describe, expect, spyOn, test } from "bun:test";
7
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
8
+ import type { MollieClient } from "@mollie/api-client";
9
+ import { createMollieCheckoutSession, type MolliePriceConfig } from "../plugin-methods";
10
+
11
+ const PRICE_CONFIG: Readonly<Record<string, MolliePriceConfig>> = {
12
+ plan_pro: {
13
+ amountValue: "9.99",
14
+ amountCurrency: "EUR",
15
+ interval: "1 month",
16
+ description: "Pro Plan",
17
+ },
18
+ };
19
+
20
+ const WEBHOOK_URL = "https://app.example.com/api/subscription/webhook/mollie";
21
+ const stubCtx = {} as HandlerContext;
22
+
23
+ // No `: MollieClient` return-type annotation — keeping the inferred narrow
24
+ // shape lets spyOn resolve a non-overloaded signature for `payments.create`
25
+ // (MollieClient's real signature is Promise|void-overloaded, which makes
26
+ // spyOn(...).mockResolvedValue infer `never`, see plugin-methods.ts's own
27
+ // `@cast-boundary` comment on the same overload).
28
+ function buildClient() {
29
+ return {
30
+ customers: {
31
+ create: async () => ({ id: "cus_mock" }),
32
+ },
33
+ payments: {
34
+ create: async () => ({ getCheckoutUrl: () => "https://www.mollie.com/checkout/mock" }),
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("createMollieCheckoutSession", () => {
40
+ test("mode omitted defaults to sequenceType 'first' (mandate-setup, Regression-Pin für bestehende Aufrufer)", async () => {
41
+ const client = buildClient();
42
+ const createMock = spyOn(client.payments, "create").mockResolvedValue({
43
+ getCheckoutUrl: () => "https://www.mollie.com/checkout/mock",
44
+ });
45
+
46
+ const checkout = createMollieCheckoutSession(
47
+ client as unknown as MollieClient,
48
+ PRICE_CONFIG,
49
+ WEBHOOK_URL,
50
+ );
51
+ const result = await checkout(stubCtx, {
52
+ priceId: "plan_pro",
53
+ tenantId: "tenant-001",
54
+ successUrl: "https://example.com/success",
55
+ cancelUrl: "https://example.com/cancel",
56
+ providerCustomerId: "cus_existing",
57
+ });
58
+
59
+ expect(result).toEqual({ url: "https://www.mollie.com/checkout/mock" });
60
+ expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "first" }));
61
+ });
62
+
63
+ test("mode='payment': sequenceType wird zu 'oneoff' (One-off-Top-up, kein Mandate-setup)", async () => {
64
+ const client = buildClient();
65
+ const createMock = spyOn(client.payments, "create").mockResolvedValue({
66
+ getCheckoutUrl: () => "https://www.mollie.com/checkout/topup",
67
+ });
68
+
69
+ const checkout = createMollieCheckoutSession(
70
+ client as unknown as MollieClient,
71
+ PRICE_CONFIG,
72
+ WEBHOOK_URL,
73
+ );
74
+ const result = await checkout(stubCtx, {
75
+ priceId: "plan_pro",
76
+ tenantId: "tenant-002",
77
+ successUrl: "https://example.com/success",
78
+ cancelUrl: "https://example.com/cancel",
79
+ providerCustomerId: "cus_existing",
80
+ mode: "payment",
81
+ });
82
+
83
+ expect(result).toEqual({ url: "https://www.mollie.com/checkout/topup" });
84
+ expect(createMock).toHaveBeenCalledWith(expect.objectContaining({ sequenceType: "oneoff" }));
85
+ });
86
+ });
@@ -52,12 +52,20 @@ export function createMollieCheckoutSession(
52
52
  customerId = customer.id;
53
53
  }
54
54
 
55
+ // mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
56
+ // setup. verify-webhook.ts's ensureSubscriptionForMandate already
57
+ // gates on sequenceType === "first" — a oneoff payment falls into
58
+ // its "not our domain" branch and creates NO Mollie subscription.
59
+ // Webhook resolution for oneoff payments is separate follow-up
60
+ // work (offlot#109), not part of this contract.
61
+ const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
62
+
55
63
  // payments.create ist overloaded (Promise OR void mit callback);
56
64
  // explicit cast auf Promise<Payment>-overload.
57
65
  const payment = (await (client.payments.create({
58
66
  amount: { currency: priceCfg.amountCurrency, value: priceCfg.amountValue },
59
67
  description: priceCfg.description,
60
- sequenceType: SequenceType.first,
68
+ sequenceType,
61
69
  customerId,
62
70
  redirectUrl: options.successUrl,
63
71
  cancelUrl: options.cancelUrl,
@@ -116,6 +116,55 @@ describe("createStripeCheckoutSession", () => {
116
116
  );
117
117
  });
118
118
 
119
+ test("mode='payment': ruft stripe.checkout.sessions.create mit mode=payment + payment_intent_data.metadata (KEIN subscription_data)", async () => {
120
+ const stripe = buildStripe();
121
+ const createMock = spyOn(stripe.checkout.sessions, "create")
122
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
123
+ .mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/topup" } as any);
124
+
125
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
126
+ const result = await checkout(stubCtx, {
127
+ priceId: "price_credits_topup",
128
+ tenantId: "tenant-003",
129
+ successUrl: "https://example.com/success",
130
+ cancelUrl: "https://example.com/cancel",
131
+ mode: "payment",
132
+ });
133
+
134
+ expect(result).toEqual({ url: "https://checkout.stripe.com/c/pay/topup" });
135
+ expect(createMock).toHaveBeenCalledWith({
136
+ mode: "payment",
137
+ line_items: [{ price: "price_credits_topup", quantity: 1 }],
138
+ success_url: "https://example.com/success",
139
+ cancel_url: "https://example.com/cancel",
140
+ // Drift-Pin: payment-mode carries tenantId via payment_intent_data,
141
+ // NOT subscription_data — Stripe rejects subscription_data outside
142
+ // subscription-mode.
143
+ payment_intent_data: {
144
+ metadata: { tenantId: "tenant-003" },
145
+ },
146
+ });
147
+ });
148
+
149
+ test("mode omitted defaults to 'subscription' (Regression-Pin für bestehende Aufrufer)", async () => {
150
+ const stripe = buildStripe();
151
+ const createMock = spyOn(stripe.checkout.sessions, "create")
152
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
153
+ .mockResolvedValue({ url: "https://x" } as any);
154
+
155
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
156
+ await checkout(stubCtx, {
157
+ priceId: "price_x",
158
+ tenantId: "tenant-004",
159
+ successUrl: "https://x/s",
160
+ cancelUrl: "https://x/c",
161
+ });
162
+
163
+ expect(createMock).toHaveBeenCalledWith(
164
+ expect.objectContaining({ mode: "subscription", subscription_data: expect.anything() }),
165
+ );
166
+ });
167
+
119
168
  test("throws wenn Stripe keine url returnt (defensive — sollte nie passieren bei mode=subscription)", async () => {
120
169
  const stripe = buildStripe();
121
170
  spyOn(stripe.checkout.sessions, "create")
@@ -41,24 +41,26 @@ export function createStripeCheckoutSession(runtime: StripeCtxRuntime) {
41
41
  // billing-live-config-key write-side.
42
42
  await runtime.assertBillingLive(ctx);
43
43
  const stripe = await runtime.clientForCtx(ctx);
44
+ const mode = options.mode ?? "subscription";
44
45
 
45
46
  const session = await stripe.checkout.sessions.create({
46
- mode: "subscription",
47
+ mode,
47
48
  line_items: [{ price: options.priceId, quantity: 1 }],
48
49
  success_url: options.successUrl,
49
50
  cancel_url: options.cancelUrl,
50
- // metadata.tenantId landet auf der subscription die durch diese
51
- // checkout-session entsteht — beim subsequent webhook lesen wir's
52
- // aus subscription.metadata.tenantId zurück.
53
- subscription_data: {
54
- metadata: { tenantId: options.tenantId },
55
- },
51
+ // subscription_data is subscription-mode-only (Stripe rejects it in
52
+ // payment-mode) — the subsequent webhook reads metadata.tenantId off
53
+ // the subscription it creates. payment-mode has no subscription, so
54
+ // tenantId travels via payment_intent_data.metadata instead.
55
+ ...(mode === "subscription"
56
+ ? { subscription_data: { metadata: { tenantId: options.tenantId } } }
57
+ : { payment_intent_data: { metadata: { tenantId: options.tenantId } } }),
56
58
  ...(options.providerCustomerId && { customer: options.providerCustomerId }),
57
59
  });
58
60
 
59
61
  if (!session.url) {
60
- // Stripe garantiert url für mode: subscription. Defensive für
61
- // zukünftige API-Drift.
62
+ // Defensive: Stripe returns a hosted url for both modes today —
63
+ // guards against future API drift.
62
64
  throw new Error("subscription-stripe: checkout.sessions.create returned no url");
63
65
  }
64
66
  return { url: session.url };
@@ -60,24 +60,41 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
60
60
  // guard's "tenant-lifecycle mounted" gate exactly.
61
61
  r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
62
62
 
63
- r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema);
64
- r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema);
65
- r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema);
63
+ r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
64
+ piiFields: "none",
65
+ });
66
+ r.defineEvent(DESTRUCTION_CANCELLED_EVENT_SHORT, destructionCancelledSchema, {
67
+ piiFields: "none",
68
+ });
69
+ r.defineEvent(TENANT_DESTRUCTION_STARTED_EVENT_SHORT, tenantDestructionStartedSchema, {
70
+ piiFields: "none",
71
+ });
66
72
  r.defineEvent(
67
73
  TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT,
68
74
  tenantDestructionStageStartedSchema,
75
+ {
76
+ piiFields: "none",
77
+ },
69
78
  );
70
79
  r.defineEvent(
71
80
  TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT,
72
81
  tenantDestructionStageSucceededSchema,
82
+ { piiFields: "none" },
73
83
  );
74
- r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema);
84
+ r.defineEvent(TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, tenantDestructionStageFailedSchema, {
85
+ piiFields: "none",
86
+ });
75
87
  r.defineEvent(
76
88
  TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT,
77
89
  tenantDestructionStageAbandonedSchema,
90
+ { piiFields: "none" },
78
91
  );
79
- r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema);
80
- r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema);
92
+ r.defineEvent(TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, tenantDestructionCompletedSchema, {
93
+ piiFields: "none",
94
+ });
95
+ r.defineEvent(TENANT_DESTRUCTION_FAILED_EVENT_SHORT, tenantDestructionFailedSchema, {
96
+ piiFields: "none",
97
+ });
81
98
 
82
99
  r.writeHandler(requestDestructionWrite);
83
100
  r.writeHandler(cancelDestructionWrite);
@@ -0,0 +1,139 @@
1
+ // fw#2593 — the plaintext `read_users_email_unique` index used to be a full
2
+ // unique index regardless of softDelete: a soft-deleted user permanently
3
+ // blocked reuse of its email whenever PII/blind-index wasn't configured
4
+ // (the *_bidx partial index from fw#2464 only helps once a blind-index key
5
+ // is actually configured). buildEntityTable / deriveEntityTableMeta now
6
+ // attach `WHERE "is_deleted" = false` to any explicitly declared unique
7
+ // index on a softDelete entity that doesn't carry an author-provided
8
+ // `where` of its own — this proves the plaintext path directly, with no
9
+ // blind-index key configured at all.
10
+
11
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
14
+ import { createSystemUser, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createTestDb,
18
+ type TestDb,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import {
22
+ resetBlindIndexKeyForTests,
23
+ resetPiiSubjectKmsForTests,
24
+ } from "@cosmicdrift/kumiko-framework/testing";
25
+ import { userEntity, userTable } from "../schema/user";
26
+
27
+ let testDb: TestDb;
28
+ const executor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
29
+
30
+ beforeAll(async () => {
31
+ testDb = await createTestDb();
32
+ await unsafeCreateEntityTable(testDb.db, userEntity, "user");
33
+ await createEventsTable(testDb.db);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ await testDb.cleanup();
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ await asRawClient(testDb.db).unsafe(
42
+ `TRUNCATE kumiko_events, read_users RESTART IDENTITY CASCADE`,
43
+ );
44
+ // The premise of this file is "nothing configured" — global crypto state
45
+ // from another test file running first in the same process must not leak
46
+ // in and accidentally exercise the bidx path instead of the plaintext one.
47
+ resetPiiSubjectKmsForTests();
48
+ resetBlindIndexKeyForTests();
49
+ });
50
+
51
+ afterEach(() => {
52
+ resetPiiSubjectKmsForTests();
53
+ resetBlindIndexKeyForTests();
54
+ });
55
+
56
+ describe("fw#2593 — email unique constraint on the plaintext column (no blind index configured)", () => {
57
+ test("a soft-deleted user's email is reusable when no blind index is configured", async () => {
58
+ const email = "plaintext-soft-deleted@example.com";
59
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
60
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
61
+
62
+ const created = await executor.create(
63
+ { email, displayName: "Departing Plaintext User" },
64
+ systemUser,
65
+ tdb,
66
+ );
67
+ if (!created.isSuccess) throw new Error("expected create to succeed");
68
+
69
+ const deleted = await executor.delete({ id: created.data.id }, systemUser, tdb);
70
+ if (!deleted.isSuccess) throw new Error("expected soft-delete to succeed");
71
+
72
+ const reCreated = await executor.create(
73
+ { email, displayName: "Plaintext Squatter" },
74
+ systemUser,
75
+ tdb,
76
+ );
77
+ if (!reCreated.isSuccess) {
78
+ throw new Error(
79
+ "expected re-create with the same email to succeed once the original row is soft-deleted",
80
+ );
81
+ }
82
+ expect(reCreated.data.id).not.toBe(created.data.id);
83
+
84
+ // DB-proof: both rows survive under the plaintext email — the
85
+ // soft-deleted original stays untouched, the new live row now also
86
+ // carries the email, since the partial index only enforces uniqueness
87
+ // among non-deleted rows.
88
+ const rows = (await asRawClient(testDb.db).unsafe(
89
+ `SELECT "id", "is_deleted" FROM "read_users" WHERE "email" = $1 ORDER BY "is_deleted"`,
90
+ [email],
91
+ )) as ReadonlyArray<{ id: string; is_deleted: boolean }>;
92
+ expect(rows).toHaveLength(2);
93
+ expect(rows[0]).toEqual({ id: String(reCreated.data.id), is_deleted: false });
94
+ expect(rows[1]).toEqual({ id: String(created.data.id), is_deleted: true });
95
+
96
+ // Proves the plaintext path was actually exercised, not the bidx one:
97
+ // no blind-index key is configured in this file, so email_bidx stays NULL.
98
+ const plaintextRows = (await asRawClient(testDb.db).unsafe(
99
+ `SELECT "email", "email_bidx" FROM "read_users" WHERE "id" = $1::uuid`,
100
+ [reCreated.data.id],
101
+ )) as ReadonlyArray<{ email: string; email_bidx: string | null }>;
102
+ expect(plaintextRows[0]?.email).toBe(email);
103
+ expect(plaintextRows[0]?.email_bidx).toBeNull();
104
+ });
105
+
106
+ test("a second live user with the same email is still rejected", async () => {
107
+ const email = "plaintext-live-collision@example.com";
108
+ const tdb = createTenantDb(testDb.db, SYSTEM_TENANT_ID, "system");
109
+ const systemUser = createSystemUser(SYSTEM_TENANT_ID);
110
+
111
+ const first = await executor.create(
112
+ { email, displayName: "First Plaintext User" },
113
+ systemUser,
114
+ tdb,
115
+ );
116
+ if (!first.isSuccess) throw new Error("expected first create to succeed");
117
+
118
+ const second = await executor.create(
119
+ { email, displayName: "Second Plaintext User" },
120
+ systemUser,
121
+ tdb,
122
+ );
123
+ if (second.isSuccess) {
124
+ throw new Error("expected second create with the same live email to fail");
125
+ }
126
+
127
+ expect(second.error.code).toBe("unique_violation");
128
+ expect(second.error.httpStatus).toBe(409);
129
+ const details = second.error.details as { constraintName?: string };
130
+ expect(details.constraintName).toBe("read_users_email_unique");
131
+
132
+ // DB-proof: only the first row survives under that email.
133
+ const rows = (await asRawClient(testDb.db).unsafe(
134
+ `SELECT count(*)::int AS n FROM "read_users" WHERE "email" = $1`,
135
+ [email],
136
+ )) as ReadonlyArray<{ n: number }>;
137
+ expect(rows[0]?.n).toBe(1);
138
+ });
139
+ });
@@ -66,7 +66,7 @@ export async function seedUser(
66
66
  // Interface braucht den Wrap.
67
67
  const tdb = createTenantDb(db, by.tenantId, "system");
68
68
 
69
- const existing = await fetchOne(db, userTable, { email: options.email });
69
+ const existing = await fetchOne(db, userTable, { email: options.email, isDeleted: false });
70
70
  // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
71
71
  // liefert die Projection-Row als Record<string, unknown>.
72
72
  if (existing) {
@@ -114,7 +114,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
114
114
  test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
115
115
  const projectionTable = projectionProbeTable("crm_contact_summary");
116
116
  const bad = defineFeature("crm", (r) => {
117
- const contactCreated = r.defineEvent("contact-created", z.unknown());
117
+ const contactCreated = r.defineEvent("contact-created", z.unknown(), { piiFields: "none" });
118
118
  r.projection({
119
119
  name: "contact-summary",
120
120
  source: "contact",
@@ -171,7 +171,9 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
171
171
  const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
172
172
  const hooked = defineFeature("crm-hooked", (r) => {
173
173
  r.requires("user-data-rights");
174
- const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
174
+ const contactCreated = r.defineEvent("contact-hooked-created", z.unknown(), {
175
+ piiFields: "none",
176
+ });
175
177
  r.projection({
176
178
  name: "contact-summary",
177
179
  source: "contact",
@@ -15,7 +15,11 @@
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
16
  import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
17
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
18
- import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ createEventStoreExecutor,
20
+ createTenantDb,
21
+ entityEventName,
22
+ } from "@cosmicdrift/kumiko-framework/db";
19
23
  import {
20
24
  createEntity,
21
25
  createSystemUser,
@@ -337,6 +341,112 @@ describe("forget pipeline honours the effective tenant model", () => {
337
341
  // Payload-tenant latch must still see 2 historical members.
338
342
  expect(await rowCount()).toBe(1);
339
343
  });
344
+
345
+ test("single-user, sole member removed and re-added → rows still erased (#2608)", async () => {
346
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c7");
347
+ await seed(stack.db).seedForgetUser(FORGET_USER);
348
+
349
+ // Remove + re-add of the SAME person: removeMemberWrite only drops the
350
+ // projection row, so two `tenant-membership.created` events survive for a
351
+ // tenant that never had a co-member. Counting rows instead of identities
352
+ // latched such a tenant to multi-user forever, and every tenantScopedOnly
353
+ // erase hook skipped on the "co-members keep shared data" grounds.
354
+ const first = await seedTenantMembership(stack.db, {
355
+ userId: FORGET_USER,
356
+ tenantId: TENANT,
357
+ roles: ["Member"],
358
+ });
359
+ const deleteResult = await membershipExecutor.delete(
360
+ { id: first.id },
361
+ createSystemUser(TENANT),
362
+ createTenantDb(stack.db, TENANT, "system"),
363
+ );
364
+ if (!deleteResult.isSuccess) {
365
+ throw new Error(`test setup: member removal failed: ${deleteResult.error.code}`);
366
+ }
367
+ await seedTenantMembership(stack.db, {
368
+ userId: FORGET_USER,
369
+ tenantId: TENANT,
370
+ roles: ["Member"],
371
+ });
372
+
373
+ // Pins the premise: 2 created-events, 1 distinct userId, 1 live member.
374
+ const createdEvents = await asRawClient(stack.db).unsafe(
375
+ `SELECT payload->>'userId' AS user_id FROM kumiko_events
376
+ WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
377
+ [entityEventName("tenant-membership", "created"), TENANT],
378
+ );
379
+ expect(createdEvents.length).toBe(2);
380
+ const liveMemberships = await asRawClient(stack.db).unsafe(
381
+ "SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
382
+ [TENANT],
383
+ );
384
+ expect(liveMemberships.length).toBe(1);
385
+
386
+ const result = await runForgetCleanup({
387
+ db: stack.db,
388
+ registry: stack.registry,
389
+ now: nowInstant(),
390
+ tenantModel: "single-user",
391
+ });
392
+
393
+ expect(result.errors).toHaveLength(0);
394
+ expect(result.processedUserIds).toContain(FORGET_USER);
395
+ expect(await rowCount()).toBe(0);
396
+ });
397
+
398
+ test("single-user, sole member, but a created-event with unreadable userId → rows preserved", async () => {
399
+ await seedScopedRow("dddddddd-dddd-4ddd-8ddd-0000000000c8");
400
+ await seed(stack.db).seedForgetUser(FORGET_USER);
401
+ await seedTenantMembership(stack.db, {
402
+ userId: FORGET_USER,
403
+ tenantId: TENANT,
404
+ roles: ["Member"],
405
+ });
406
+
407
+ // A created-event whose userId payload cannot be read may name a second
408
+ // member — the fail-safe branch must survive the #2608 fix.
409
+ await asRawClient(stack.db).unsafe(
410
+ `INSERT INTO kumiko_events
411
+ (aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
412
+ VALUES ($1, 'tenant-membership', $2, 1, $3, $4, '{}'::jsonb, 'system')`,
413
+ [
414
+ "eeeeeeee-eeee-4eee-8eee-0000000000c8",
415
+ TENANT,
416
+ entityEventName("tenant-membership", "created"),
417
+ // Object, not JSON.stringify: a string param into a jsonb column is
418
+ // encoded a second time and lands as a JSON scalar, which no
419
+ // payload->>'...' lookup would ever match.
420
+ { tenantId: TENANT },
421
+ ],
422
+ );
423
+
424
+ const createdEvents = await asRawClient(stack.db).unsafe(
425
+ `SELECT payload->>'userId' AS user_id FROM kumiko_events
426
+ WHERE aggregate_type = 'tenant-membership' AND type = $1 AND payload->>'tenantId' = $2`,
427
+ [entityEventName("tenant-membership", "created"), TENANT],
428
+ );
429
+ expect(createdEvents.length).toBe(2);
430
+
431
+ // Without exactly one live member the live gate would answer multi-user
432
+ // before the history latch runs, and this test would prove nothing.
433
+ const liveMemberships = await asRawClient(stack.db).unsafe(
434
+ "SELECT id FROM read_tenant_memberships WHERE tenant_id = $1",
435
+ [TENANT],
436
+ );
437
+ expect(liveMemberships.length).toBe(1);
438
+
439
+ const result = await runForgetCleanup({
440
+ db: stack.db,
441
+ registry: stack.registry,
442
+ now: nowInstant(),
443
+ tenantModel: "single-user",
444
+ });
445
+
446
+ expect(result.errors).toHaveLength(0);
447
+ expect(result.processedUserIds).toContain(FORGET_USER);
448
+ expect(await rowCount()).toBe(1);
449
+ });
340
450
  });
341
451
 
342
452
  describe("run-forget-cleanup job — real glue-code, not a hand-set tenantModel", () => {
@@ -76,7 +76,7 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
76
76
  const userRow = await fetchOne<{ id: string; status: string; email: string }>(
77
77
  ctx.db.raw,
78
78
  userTable,
79
- { email: event.payload.email },
79
+ { email: event.payload.email, isDeleted: false },
80
80
  );
81
81
  if (!userRow || userRow["status"] !== USER_STATUS.Active || !userRow["email"]) {
82
82
  return success;
@@ -573,19 +573,26 @@ async function resolveEffectiveTenantModel(
573
573
  // rows.
574
574
  //
575
575
  // Filter by payload->>'tenantId' (not the event column tenant_id): cross-
576
- // tenant SystemAdmin adds land under the actor's tenant_id (#2347). SQL
577
- // aggregation stays fail-safe when userId payloads are unreadable: more
578
- // created-rows than distinct userIds ⇒ treat as multi-user.
576
+ // tenant SystemAdmin adds land under the actor's tenant_id (#2347).
577
+ //
578
+ // Counting created-ROWS against distinct userIds latched a tenant to
579
+ // multi-user as soon as its sole member was removed and re-added (#2608) —
580
+ // two created-events, one identity, no co-member ever. The fail-safe only
581
+ // needs to cover payloads whose userId cannot be READ, so the unreadable
582
+ // remainder is measured directly instead of inferred from the row count.
583
+ // NULLIF folds an empty-string userId into that remainder (it names nobody).
579
584
  async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise<boolean> {
580
585
  const eventType = entityEventName("tenant-membership", "created");
581
586
  const rows = await executeRawQuery<{
582
587
  event_count: number;
588
+ readable_users: number;
583
589
  distinct_users: number;
584
590
  }>(
585
591
  db,
586
592
  `SELECT
587
593
  COUNT(*)::int AS event_count,
588
- COUNT(DISTINCT payload->>'userId')::int AS distinct_users
594
+ COUNT(NULLIF(payload->>'userId', ''))::int AS readable_users,
595
+ COUNT(DISTINCT NULLIF(payload->>'userId', ''))::int AS distinct_users
589
596
  FROM kumiko_events
590
597
  WHERE aggregate_type = 'tenant-membership'
591
598
  AND type = $1
@@ -595,11 +602,9 @@ async function everHadMultipleMembers(db: DbRunner, tenantId: TenantId): Promise
595
602
  const row = rows[0];
596
603
  if (!row) return false;
597
604
  if (row.distinct_users > 1) return true;
598
- // Unreadable / missing userId payloads: fail-safe to multi-user
599
- // (including a lone created-event with no readable userId).
600
- if (row.event_count >= 1 && row.distinct_users < 1) return true;
601
- if (row.event_count > 1 && row.distinct_users <= 1) return true;
602
- return false;
605
+ // Unreadable / missing userId payloads: fail-safe to multi-user, each one
606
+ // could name a second member.
607
+ return row.event_count > row.readable_users;
603
608
  }
604
609
 
605
610
  // Mapping retention.strategy → user-data-rights.UserDataDeleteStrategy.