@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
  4. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
  5. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
  6. package/src/auth-email-password/handlers/change-password.write.ts +12 -2
  7. package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
  8. package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
  9. package/src/compliance-profiles/_internal/parse-override.ts +8 -7
  10. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  11. package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
  12. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  13. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  14. package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
  15. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  16. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  17. package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
  18. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  19. package/src/custom-fields/constants.ts +8 -7
  20. package/src/custom-fields/db/queries/projection.ts +19 -7
  21. package/src/custom-fields/db/queries/retention.ts +20 -6
  22. package/src/custom-fields/executor.ts +10 -0
  23. package/src/custom-fields/feature.ts +32 -39
  24. package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
  25. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  26. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  27. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  28. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  29. package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
  30. package/src/custom-fields/lib/field-access.ts +9 -4
  31. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  32. package/src/custom-fields/lib/value-schema.ts +14 -2
  33. package/src/custom-fields/run-retention.ts +6 -5
  34. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  35. package/src/custom-fields/web/client-plugin.tsx +2 -0
  36. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  37. package/src/custom-fields/web/i18n.ts +30 -0
  38. package/src/custom-fields/wire-for-entity.ts +9 -2
  39. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  40. package/src/feature-toggles/handlers/set.write.ts +13 -8
  41. package/src/secrets/feature.ts +4 -11
  42. package/src/subscription-stripe/feature.ts +2 -2
  43. package/src/template-resolver/handlers/list.query.ts +12 -10
  44. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  45. package/src/tenant/seeding.ts +3 -3
  46. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.24.0",
3
+ "version": "0.25.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>",
@@ -1,10 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { randomBytes } from "node:crypto";
3
- import {
4
- composeEnvSchema,
5
- type KumikoBootError,
6
- parseEnv,
7
- } from "@cosmicdrift/kumiko-framework/env";
3
+ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko-framework/env";
8
4
  import { authEmailPasswordEnvSchema, createAuthEmailPasswordFeature } from "../auth-email-password";
9
5
  import { createSecretsFeature, secretsEnvSchema } from "../secrets";
10
6
  import {
@@ -18,6 +14,13 @@ import {
18
14
 
19
15
  const validKek = randomBytes(32).toString("base64");
20
16
 
17
+ function asBootError(err: unknown): KumikoBootError {
18
+ if (!(err instanceof KumikoBootError)) {
19
+ throw err instanceof Error ? err : new Error(String(err));
20
+ }
21
+ return err;
22
+ }
23
+
21
24
  describe("secretsEnvSchema", () => {
22
25
  it("accepts a base64-32 KEK and defaults CURRENT_VERSION to '1'", () => {
23
26
  const env = parseEnv(secretsEnvSchema, {
@@ -32,7 +35,7 @@ describe("secretsEnvSchema", () => {
32
35
  parseEnv(secretsEnvSchema, { KUMIKO_SECRETS_MASTER_KEY_V1: "dGVzdA==" });
33
36
  throw new Error("should have thrown");
34
37
  } catch (err) {
35
- const boot = err as KumikoBootError;
38
+ const boot = asBootError(err);
36
39
  const v1 = boot.errors.find((e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_V1");
37
40
  expect(v1?.kind).toBe("invalid");
38
41
  expect(v1?.message).toContain("32 bytes");
@@ -47,13 +50,36 @@ describe("secretsEnvSchema", () => {
47
50
  });
48
51
  throw new Error("should have thrown");
49
52
  } catch (err) {
50
- const cur = (err as KumikoBootError).errors.find(
53
+ const cur = asBootError(err).errors.find(
54
+ (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
55
+ );
56
+ expect(cur?.kind).toBe("invalid");
57
+ }
58
+ });
59
+
60
+ it("rejects CURRENT_VERSION '0' (V0 never exists, selector starts at V1)", () => {
61
+ try {
62
+ parseEnv(secretsEnvSchema, {
63
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
64
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "0",
65
+ });
66
+ throw new Error("should have thrown");
67
+ } catch (err) {
68
+ const cur = asBootError(err).errors.find(
51
69
  (e) => e.name === "KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION",
52
70
  );
53
71
  expect(cur?.kind).toBe("invalid");
54
72
  }
55
73
  });
56
74
 
75
+ it("accepts CURRENT_VERSION '2' (positive version selector)", () => {
76
+ const env = parseEnv(secretsEnvSchema, {
77
+ KUMIKO_SECRETS_MASTER_KEY_V1: validKek,
78
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
79
+ });
80
+ expect(env.KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION).toBe("2");
81
+ });
82
+
57
83
  it("attaches the schema via r.envSchema() on createSecretsFeature()", () => {
58
84
  const f = createSecretsFeature();
59
85
  expect(f.envSchema).toBe(secretsEnvSchema);
@@ -74,7 +100,7 @@ describe("authEmailPasswordEnvSchema", () => {
74
100
  parseEnv(authEmailPasswordEnvSchema, { JWT_SECRET: "short" });
75
101
  throw new Error("should have thrown");
76
102
  } catch (err) {
77
- const jwt = (err as KumikoBootError).errors.find((e) => e.name === "JWT_SECRET");
103
+ const jwt = asBootError(err).errors.find((e) => e.name === "JWT_SECRET");
78
104
  expect(jwt?.kind).toBe("invalid");
79
105
  }
80
106
  });
@@ -103,11 +129,27 @@ describe("subscriptionStripeEnvSchema", () => {
103
129
  });
104
130
  throw new Error("should have thrown");
105
131
  } catch (err) {
106
- const boot = err as KumikoBootError;
132
+ const boot = asBootError(err);
107
133
  expect(boot.errors.length).toBe(2);
108
134
  }
109
135
  });
110
136
 
137
+ it("rejects a publishable key and a non-whsec webhook secret", () => {
138
+ try {
139
+ parseEnv(subscriptionStripeEnvSchema, {
140
+ STRIPE_WEBHOOK_SECRET: "wrong_abc",
141
+ STRIPE_API_KEY: "pk_live_xyz",
142
+ });
143
+ throw new Error("should have thrown");
144
+ } catch (err) {
145
+ const boot = asBootError(err);
146
+ const api = boot.errors.find((e) => e.name === "STRIPE_API_KEY");
147
+ const hook = boot.errors.find((e) => e.name === "STRIPE_WEBHOOK_SECRET");
148
+ expect(api?.kind).toBe("invalid");
149
+ expect(hook?.kind).toBe("invalid");
150
+ }
151
+ });
152
+
111
153
  it("attaches the schema via r.envSchema() on the factory", () => {
112
154
  const f = createSubscriptionStripeFeature({
113
155
  webhookSecret: "whsec_x",
@@ -129,7 +171,7 @@ describe("subscriptionMollieEnvSchema", () => {
129
171
  parseEnv(subscriptionMollieEnvSchema, { MOLLIE_API_KEY: "no-prefix" });
130
172
  throw new Error("should have thrown");
131
173
  } catch (err) {
132
- const k = (err as KumikoBootError).errors.find((e) => e.name === "MOLLIE_API_KEY");
174
+ const k = asBootError(err).errors.find((e) => e.name === "MOLLIE_API_KEY");
133
175
  expect(k?.kind).toBe("invalid");
134
176
  }
135
177
  });
@@ -200,7 +242,7 @@ describe("compose across all Phase-2 features", () => {
200
242
  parseEnv(composed.schema, {}, { sources: composed.sources });
201
243
  throw new Error("should have thrown");
202
244
  } catch (err) {
203
- const out = (err as KumikoBootError).format();
245
+ const out = asBootError(err).format();
204
246
  expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
205
247
  expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
206
248
  expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
@@ -236,6 +236,43 @@ describe("scenario 5: change-password success", () => {
236
236
  });
237
237
  });
238
238
 
239
+ describe("scenario 5b: change-password when the aggregate stream tenant != session tenant (sysadmin pattern)", () => {
240
+ test("user whose stream lives in a non-session tenant still changes password", async () => {
241
+ // Sysadmin pattern: created via systemAdmin (stream = systemAdmin.tenantId),
242
+ // only membership on a different tenant → the session/membership tenant
243
+ // diverges from where the aggregate actually lives. change-password writes
244
+ // as the session tenant; without recovering the real stream tenant it
245
+ // version_conflicts against an empty stream and the operator is locked out
246
+ // of rotating their own password.
247
+ const membershipTenant = testTenantId(2);
248
+ const seed = await seedLoginUser({
249
+ email: "sysadmin-cp@example.com",
250
+ password: "old-sysadmin-pw",
251
+ tenantId: membershipTenant,
252
+ });
253
+
254
+ const streamRows = (await asRawClient(stack.db).unsafe(
255
+ `SELECT "tenant_id" AS t FROM "kumiko_events" WHERE "aggregate_id" = $1 AND "aggregate_type" = $2 ORDER BY "version" LIMIT 1`,
256
+ [seed.id, "user"],
257
+ )) as ReadonlyArray<{ t: string }>;
258
+ expect(streamRows[0]?.t).toBe(systemAdmin.tenantId);
259
+ expect(streamRows[0]?.t).not.toBe(membershipTenant);
260
+
261
+ const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
262
+ await stack.http.writeOk(
263
+ AuthHandlers.changePassword,
264
+ { oldPassword: "old-sysadmin-pw", newPassword: "new-sysadmin-pw-long" },
265
+ signedIn,
266
+ );
267
+
268
+ const newRes = await stack.http.raw("POST", "/api/auth/login", {
269
+ email: "sysadmin-cp@example.com",
270
+ password: "new-sysadmin-pw-long",
271
+ });
272
+ expect(newRes.status).toBe(200);
273
+ });
274
+ });
275
+
239
276
  // --- Scenario 6: logout is reachable for authenticated users ---
240
277
 
241
278
  describe("scenario 6: logout", () => {
@@ -322,3 +322,35 @@ describe("login with strict email-verification", () => {
322
322
  expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
323
323
  });
324
324
  });
325
+
326
+ describe("verify-email — aggregate stream in a non-membership tenant (sysadmin pattern)", () => {
327
+ test("user whose aggregate stream lives in a tenant they are NOT a member of still verifies", async () => {
328
+ // Prod sysadmin repro: the user aggregate is created via `systemAdmin`,
329
+ // so its event stream lives in systemAdmin.tenantId (…0001), while the
330
+ // user's ONLY membership is on a different tenant (…0002). resolveStream-
331
+ // Tenants must discover the real stream tenant from the event log — if it
332
+ // only tried membership tenants, every write would target …0002, get a
333
+ // version_conflict, collapse to all_conflicts → invalid_verification_token.
334
+ const membershipTenant = "00000000-0000-4000-8000-000000000002" as TenantId;
335
+ const seed = await seedUser({
336
+ email: "sysadmin-pattern@example.com",
337
+ password: "pw-sysadmin-pat-1234",
338
+ tenantId: membershipTenant,
339
+ });
340
+
341
+ const streamRows = (await asRawClient(stack.db).unsafe(
342
+ `SELECT "tenant_id", "aggregate_type" FROM "kumiko_events" WHERE "aggregate_id" = $1 ORDER BY "version" LIMIT 1`,
343
+ [seed.id],
344
+ )) as ReadonlyArray<{ tenant_id: string; aggregate_type: string }>;
345
+ expect(streamRows[0]?.aggregate_type).toBe("user");
346
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
347
+ expect(streamRows[0]?.tenant_id).not.toBe(membershipTenant);
348
+
349
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
350
+ const res = await post("/api/auth/verify-email", { token });
351
+ expect(res.status).toBe(200);
352
+
353
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
354
+ expect(row?.["emailVerified"]).toBe(true);
355
+ });
356
+ });
@@ -303,6 +303,37 @@ describe("POST /auth/reset-password", () => {
303
303
  const body = await second.json();
304
304
  expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
305
305
  });
306
+
307
+ test("user whose aggregate stream lives in a tenant they are NOT a member of can still reset", async () => {
308
+ // Mirror of the verify-email sysadmin repro: aggregate stream in …0001
309
+ // (created via systemAdmin), only membership on …0002. The reset must
310
+ // target the real stream tenant, not the membership tenant.
311
+ const membershipTenant = "00000000-0000-4000-8000-000000000002" as TenantId;
312
+ const seed = await seedUser({
313
+ email: "sysadmin-reset@example.com",
314
+ password: "pw-old-sysadmin-1234",
315
+ tenantId: membershipTenant,
316
+ });
317
+
318
+ const streamRows = (await asRawClient(stack.db).unsafe(
319
+ `SELECT "tenant_id", "aggregate_type" FROM "kumiko_events" WHERE "aggregate_id" = $1 ORDER BY "version" LIMIT 1`,
320
+ [seed.id],
321
+ )) as ReadonlyArray<{ tenant_id: string; aggregate_type: string }>;
322
+ expect(streamRows[0]?.aggregate_type).toBe("user");
323
+ expect(streamRows[0]?.tenant_id).toBe(systemAdmin.tenantId);
324
+ expect(streamRows[0]?.tenant_id).not.toBe(membershipTenant);
325
+
326
+ const { token } = signResetToken(seed.id, 15, resetSecret);
327
+ const res = await post("/api/auth/reset-password", {
328
+ token,
329
+ newPassword: "pw-new-sysadmin-1234",
330
+ });
331
+ expect(res.status).toBe(200);
332
+
333
+ const row = (await selectMany(stack.db, userTable)).find((r) => r["id"] === seed.id);
334
+ const valid = await verifyPassword(row?.["passwordHash"] as string, "pw-new-sysadmin-1234");
335
+ expect(valid).toBe(true);
336
+ });
306
337
  });
307
338
 
308
339
  // --- session auto-revoke (H.3 cross-feature hook) -------------------------
@@ -1,7 +1,8 @@
1
1
  import { access, createSystemUser, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
3
4
  import { z } from "zod";
4
- import { UserHandlers, UserQueries } from "../../user";
5
+ import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
5
6
  import { AuthErrors } from "../constants";
6
7
  import { hashPassword, verifyPassword } from "../password-hashing";
7
8
 
@@ -43,10 +44,19 @@ export const changePasswordWrite = defineWriteHandler({
43
44
 
44
45
  const newHash = await hashPassword(event.payload.newPassword);
45
46
 
47
+ // The user aggregate is r.systemScope(): its event stream can live in a
48
+ // tenant that isn't the session tenant (a platform operator's stream sits
49
+ // in the seed executor's tenant, not their membership). Write against the
50
+ // real stream tenant so optimistic locking targets the right stream instead
51
+ // of version_conflict-ing against an empty one. Falls back to the session
52
+ // tenant when the lookup finds nothing (fresh/unknown stream).
53
+ const streamTenant = await getAggregateStreamTenant(ctx.db.raw, event.user.id, USER_FEATURE);
54
+ const writer = createSystemUser(streamTenant ?? event.user.tenantId);
55
+
46
56
  // Apply via user feature's update handler — writeAs(system) satisfies
47
57
  // the privileged-only write rule on passwordHash. Pass the current version
48
58
  // through so optimistic locking still applies end-to-end.
49
- const writeRes = await ctx.writeAs(systemUser, UserHandlers.update, {
59
+ const writeRes = await ctx.writeAs(writer, UserHandlers.update, {
50
60
  id: me.id,
51
61
  version: me.version,
52
62
  changes: { passwordHash: newHash },
@@ -29,8 +29,9 @@ import {
29
29
  type WriteFailure,
30
30
  writeFailure,
31
31
  } from "@cosmicdrift/kumiko-framework/errors";
32
+ import { getAggregateStreamTenant } from "@cosmicdrift/kumiko-framework/event-store";
32
33
  import type Redis from "ioredis";
33
- import { UserHandlers, UserQueries } from "../../user";
34
+ import { USER_FEATURE, UserHandlers, UserQueries } from "../../user";
34
35
  import type { AuthUserRow } from "../auth-user-row";
35
36
  import { parseAuthUserRow } from "../auth-user-row";
36
37
  import { orderTenantsByPreference } from "../stream-tenant";
@@ -153,7 +154,21 @@ async function resolveStreamTenants(
153
154
  const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
154
155
  userId: me.id,
155
156
  })) as Array<{ tenantId: TenantId }>; // @cast-boundary db-runner
156
- return orderTenantsByPreference(memberships, me.lastActiveTenantId);
157
+ const ordered = orderTenantsByPreference(memberships, me.lastActiveTenantId);
158
+ if (ordered.length === 0) return [];
159
+
160
+ // The user aggregate is r.systemScope(): its event stream lives in whichever
161
+ // tenant the creating executor used, which need NOT be a membership tenant.
162
+ // A platform operator seeded under a fixture/platform tenant is the live case
163
+ // — its stream tenant is absent from `ordered`, so a membership-only search
164
+ // rejects every write and collapses to invalid_token. Recover the real stream
165
+ // tenant from the event log and try it first; memberships stay as fallback,
166
+ // and an empty/unknown lookup degrades to the prior membership-only behaviour.
167
+ const streamTenant = await getAggregateStreamTenant(ctx.db.raw, me.id, USER_FEATURE);
168
+ if (streamTenant && !ordered.includes(streamTenant)) {
169
+ return [streamTenant, ...ordered];
170
+ }
171
+ return ordered;
157
172
  }
158
173
 
159
174
  // Discriminated result for the write-across-tenants loop.
@@ -0,0 +1,53 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { parseComplianceProfileOverride } from "../_internal/parse-override";
3
+
4
+ describe("parseComplianceProfileOverride", () => {
5
+ test("empty / whitespace / null → undefined, no warning", () => {
6
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
7
+ try {
8
+ expect(parseComplianceProfileOverride(null, "t1", "seed")).toBeUndefined();
9
+ expect(parseComplianceProfileOverride("", "t1", "seed")).toBeUndefined();
10
+ expect(parseComplianceProfileOverride(" ", "t1", "seed")).toBeUndefined();
11
+ expect(warn).not.toHaveBeenCalled();
12
+ } finally {
13
+ warn.mockRestore();
14
+ }
15
+ });
16
+
17
+ test("valid JSON object is returned verbatim", () => {
18
+ expect(parseComplianceProfileOverride('{"region":"eu"}', "t1", "seed")).toEqual({
19
+ region: "eu",
20
+ });
21
+ });
22
+
23
+ test("literal JSON null → undefined (no override), no warning", () => {
24
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
25
+ try {
26
+ expect(parseComplianceProfileOverride("null", "t1", "seed")).toBeUndefined();
27
+ expect(warn).not.toHaveBeenCalled();
28
+ } finally {
29
+ warn.mockRestore();
30
+ }
31
+ });
32
+
33
+ test("corrupt JSON warns WITH the parser reason and returns undefined (not a silent swallow)", () => {
34
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
35
+ try {
36
+ const result = parseComplianceProfileOverride(
37
+ "{not valid json",
38
+ "tenant-9",
39
+ "resolve-for-tenant",
40
+ );
41
+ expect(result).toBeUndefined();
42
+ expect(warn).toHaveBeenCalledTimes(1);
43
+ const msg = String(warn.mock.calls[0]?.[0]);
44
+ expect(msg).toContain("tenant-9");
45
+ expect(msg).toContain("resolve-for-tenant");
46
+ // The point of the fix: the parser's own failure reason is preserved,
47
+ // not flattened to a generic "is not valid JSON".
48
+ expect(msg).toContain("Reason:");
49
+ } finally {
50
+ warn.mockRestore();
51
+ }
52
+ });
53
+ });
@@ -1,5 +1,5 @@
1
1
  import type { ComplianceProfileOverride } from "@cosmicdrift/kumiko-framework/compliance";
2
- import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
2
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
3
3
 
4
4
  export function parseComplianceProfileOverride(
5
5
  raw: string | null,
@@ -7,13 +7,14 @@ export function parseComplianceProfileOverride(
7
7
  callerLabel: string,
8
8
  ): ComplianceProfileOverride | undefined {
9
9
  if (!raw || raw.trim() === "") return undefined;
10
- const parsed = parseJsonSafe<ComplianceProfileOverride | null>(raw, null);
11
- if (parsed === null) {
10
+ let parsed: ComplianceProfileOverride | null;
11
+ try {
12
+ parsed = parseJsonOrThrow<ComplianceProfileOverride | null>(raw, "compliance override");
13
+ } catch (err) {
14
+ const reason = err instanceof Error ? err.message : String(err);
12
15
  // biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
13
- console.warn(
14
- `[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring.`,
15
- );
16
+ console.warn(`[${callerLabel}] tenant ${tenantId}: stored override ignored. Reason: ${reason}`);
16
17
  return undefined;
17
18
  }
18
- return parsed;
19
+ return parsed ?? undefined;
19
20
  }
@@ -265,13 +265,40 @@ describe("T1.5a: custom-fields events are visible in the audit log", () => {
265
265
  adminWithAudit,
266
266
  );
267
267
 
268
+ // Tenant-2 defines its OWN field. Without this, an audit query that
269
+ // returned zero rows for ANY reason (e.g. a broken filter) would still
270
+ // pass the "doesn't see leakyField" assertion — a false-positive that
271
+ // reads "isolated" but actually means "blind". Asserting tenant-2 sees its
272
+ // own event proves the query genuinely returns tenant-2's data.
273
+ const otherTenantDefiner = createTestUser({
274
+ id: 11,
275
+ roles: ["TenantAdmin"],
276
+ tenantId: otherTenantAdmin.tenantId,
277
+ });
278
+ await stack.http.writeOk(
279
+ "custom-fields:write:define-tenant-field",
280
+ {
281
+ entityName: "property",
282
+ fieldKey: "ownField",
283
+ serializedField: { type: "text" },
284
+ required: false,
285
+ searchable: false,
286
+ displayOrder: 0,
287
+ },
288
+ otherTenantDefiner,
289
+ );
290
+
268
291
  const res = await stack.http.queryOk<AuditResponse>(
269
292
  AuditQueries.list,
270
293
  { aggregateType: "field-definition" },
271
294
  otherTenantAdmin,
272
295
  );
273
296
 
274
- const leak = res.rows.find((r) => (r.payload["fieldKey"] as string) === "leakyField");
297
+ // Tenant-2 sees its own event ...
298
+ const own = res.rows.find((r) => r.payload["fieldKey"] === "ownField");
299
+ expect(own).toBeDefined();
300
+ // ... but never tenant-1's.
301
+ const leak = res.rows.find((r) => r.payload["fieldKey"] === "leakyField");
275
302
  expect(leak).toBeUndefined();
276
303
  });
277
304
  });
@@ -0,0 +1,178 @@
1
+ // Regression: a custom-field set/clear must only touch the calling tenant's own
2
+ // row. aggregateId is a globally-unique row UUID, so without a tenant_id filter
3
+ // on the projection UPDATE, tenant A could overwrite or clear tenant B's
4
+ // customFields just by passing B's known row UUID as entityId. The set/clear
5
+ // projection writes now scope to the event's own tenant (same guard the
6
+ // fieldDefinition-delete path already uses).
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
+ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
11
+ import {
12
+ createEntity,
13
+ createEntityExecutor,
14
+ createTextField,
15
+ defineEntityListHandler,
16
+ defineFeature,
17
+ type SessionUser,
18
+ } from "@cosmicdrift/kumiko-framework/engine";
19
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
20
+ import {
21
+ createTestUser,
22
+ setupTestStack,
23
+ type TestStack,
24
+ testTenantId,
25
+ testUserId,
26
+ unsafeCreateEntityTable,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import { z } from "zod";
29
+ import { fieldDefinitionEntity } from "../entity";
30
+ import { createCustomFieldsFeature } from "../feature";
31
+ import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
32
+
33
+ const propertyEntity = createEntity({
34
+ table: "read_xt_set_properties",
35
+ fields: {
36
+ name: createTextField({ required: true }),
37
+ customFields: customFieldsField(),
38
+ },
39
+ });
40
+ const propertyTable = buildEntityTable("property", propertyEntity);
41
+
42
+ const propertyFeature = defineFeature("property-xt-set", (r) => {
43
+ r.entity("property", propertyEntity);
44
+ r.requires("custom-fields");
45
+ wireCustomFieldsFor(r, "property", propertyTable);
46
+
47
+ const { executor: propertyExecutor } = createEntityExecutor("property", propertyEntity);
48
+ r.writeHandler({
49
+ name: "property:create",
50
+ schema: z.object({ id: z.string(), name: z.string() }),
51
+ access: { roles: ["TenantAdmin"] },
52
+ handler: async (event, ctx) => {
53
+ const payload = event.payload as { id: string; name: string };
54
+ return propertyExecutor.create(
55
+ { id: payload.id, name: payload.name, customFields: {} },
56
+ event.user,
57
+ ctx.db,
58
+ );
59
+ },
60
+ });
61
+
62
+ r.queryHandler(
63
+ defineEntityListHandler("property", propertyEntity, { access: { roles: ["TenantAdmin"] } }),
64
+ );
65
+ });
66
+
67
+ const customFieldsFeature = createCustomFieldsFeature();
68
+
69
+ let stack: TestStack;
70
+
71
+ const adminA = createTestUser({
72
+ id: testUserId(1),
73
+ tenantId: testTenantId(1),
74
+ roles: ["TenantAdmin"],
75
+ });
76
+ const adminB = createTestUser({
77
+ id: testUserId(10),
78
+ tenantId: testTenantId(2),
79
+ roles: ["TenantAdmin"],
80
+ });
81
+
82
+ const PROP_A = "aaaaaaaa-aaaa-4000-8000-000000000001";
83
+ const PROP_B = "bbbbbbbb-bbbb-4000-8000-000000000002";
84
+
85
+ beforeAll(async () => {
86
+ stack = await setupTestStack({ features: [customFieldsFeature, propertyFeature] });
87
+ await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
88
+ await unsafeCreateEntityTable(stack.db, propertyEntity);
89
+ await createEventsTable(stack.db);
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await stack.cleanup();
94
+ });
95
+
96
+ async function defineField(user: SessionUser, fieldKey: string) {
97
+ return stack.http.writeOk(
98
+ "custom-fields:write:define-tenant-field",
99
+ {
100
+ entityName: "property",
101
+ fieldKey,
102
+ serializedField: { type: "text" },
103
+ required: false,
104
+ searchable: false,
105
+ displayOrder: 0,
106
+ },
107
+ user,
108
+ );
109
+ }
110
+
111
+ async function setField(user: SessionUser, entityId: string, fieldKey: string, value: unknown) {
112
+ return stack.http.writeOk(
113
+ "custom-fields:write:set-custom-field",
114
+ { entityName: "property", entityId, fieldKey, value },
115
+ user,
116
+ );
117
+ }
118
+
119
+ async function clearField(user: SessionUser, entityId: string, fieldKey: string) {
120
+ return stack.http.writeOk(
121
+ "custom-fields:write:clear-custom-field",
122
+ { entityName: "property", entityId, fieldKey },
123
+ user,
124
+ );
125
+ }
126
+
127
+ async function createProperty(user: SessionUser, id: string, name: string) {
128
+ return stack.http.writeOk("property-xt-set:write:property:create", { id, name }, user);
129
+ }
130
+
131
+ async function priorityOf(user: SessionUser, id: string): Promise<unknown> {
132
+ const { rows } = (await stack.http.queryOk("property-xt-set:query:property:list", {}, user)) as {
133
+ rows: Array<Record<string, unknown>>;
134
+ };
135
+ return rows.find((r) => r["id"] === id)?.["priority"];
136
+ }
137
+
138
+ describe("custom-fields cross-tenant isolation — set/clear write", () => {
139
+ beforeEach(async () => {
140
+ await asRawClient(stack.db).unsafe(`DELETE FROM kumiko_events`);
141
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_xt_set_properties`);
142
+ await asRawClient(stack.db).unsafe(`DELETE FROM read_custom_field_definitions`);
143
+
144
+ // Both tenants independently define their own "priority" field and own a row.
145
+ await defineField(adminA, "priority");
146
+ await defineField(adminB, "priority");
147
+ await createProperty(adminA, PROP_A, "A-Prop");
148
+ await createProperty(adminB, PROP_B, "B-Prop");
149
+ await setField(adminA, PROP_A, "priority", "A-value");
150
+ await setField(adminB, PROP_B, "priority", "B-value");
151
+ await stack.eventDispatcher?.runOnce();
152
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
153
+ });
154
+
155
+ test("tenant A cannot overwrite tenant B's row via a known entityId (set)", async () => {
156
+ // The set-handler runs as A (its own field-definition + RBAC pass) and emits
157
+ // on A's stream; the projection's tenant filter must keep it off B's row.
158
+ await setField(adminA, PROP_B, "priority", "HACKED");
159
+ await stack.eventDispatcher?.runOnce();
160
+
161
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
162
+ });
163
+
164
+ test("tenant A cannot clear tenant B's row via a known entityId (clear)", async () => {
165
+ await clearField(adminA, PROP_B, "priority");
166
+ await stack.eventDispatcher?.runOnce();
167
+
168
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
169
+ });
170
+
171
+ test("a tenant's own set still applies (filter does not block the legitimate path)", async () => {
172
+ await setField(adminA, PROP_A, "priority", "A-updated");
173
+ await stack.eventDispatcher?.runOnce();
174
+
175
+ expect(await priorityOf(adminA, PROP_A)).toBe("A-updated");
176
+ expect(await priorityOf(adminB, PROP_B)).toBe("B-value");
177
+ });
178
+ });