@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
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>",
@@ -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
  }
@@ -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
+ });
@@ -266,3 +266,62 @@ describe("T1.5b: per-field fieldAccess.write rejects users without required role
266
266
  expect(err.code).toBe("not_found");
267
267
  });
268
268
  });
269
+
270
+ // A row whose serialized_field is corrupt (DB corruption / partial write)
271
+ // must NOT silently drop the per-field write gate. Before the fix the access
272
+ // check fell open ({ ok: true }) and the write went through unvalidated; now
273
+ // it fails closed with a distinct reason.
274
+ describe("fail-closed on corrupt serialized_field", () => {
275
+ async function corruptStoredDefinition(fieldKey: string) {
276
+ await asRawClient(stack.db).unsafe(
277
+ `UPDATE read_custom_field_definitions SET serialized_field = '{not valid json' WHERE field_key = $1`,
278
+ [fieldKey],
279
+ );
280
+ }
281
+
282
+ test("set: corrupt definition row → unprocessable field_definition_corrupt (not silent allow)", async () => {
283
+ const propertyId = "77777777-7777-4000-8000-000000000007";
284
+ await defineField("corruptField", { type: "text", fieldAccess: { write: ["TenantAdmin"] } });
285
+ await corruptStoredDefinition("corruptField");
286
+ await stack.http.writeOk(
287
+ "property-t15b:write:property:create",
288
+ { id: propertyId, name: "Corrupt" },
289
+ tenantAdmin,
290
+ );
291
+
292
+ const err = await stack.http.writeErr(
293
+ "custom-fields:write:set-custom-field",
294
+ { entityName: "property", entityId: propertyId, fieldKey: "corruptField", value: "X-42" },
295
+ tenantAdmin,
296
+ );
297
+
298
+ expect(err.code).toBe("unprocessable");
299
+ expect(err.details).toMatchObject({
300
+ reason: "field_definition_corrupt",
301
+ fieldKey: "corruptField",
302
+ });
303
+ });
304
+
305
+ test("clear: corrupt definition row → unprocessable field_definition_corrupt", async () => {
306
+ const propertyId = "88888888-8888-4000-8000-000000000008";
307
+ await defineField("corruptField", { type: "boolean" });
308
+ await corruptStoredDefinition("corruptField");
309
+ await stack.http.writeOk(
310
+ "property-t15b:write:property:create",
311
+ { id: propertyId, name: "Corrupt2" },
312
+ tenantAdmin,
313
+ );
314
+
315
+ const err = await stack.http.writeErr(
316
+ "custom-fields:write:clear-custom-field",
317
+ { entityName: "property", entityId: propertyId, fieldKey: "corruptField" },
318
+ tenantAdmin,
319
+ );
320
+
321
+ expect(err.code).toBe("unprocessable");
322
+ expect(err.details).toMatchObject({
323
+ reason: "field_definition_corrupt",
324
+ fieldKey: "corruptField",
325
+ });
326
+ });
327
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildCustomFieldValueSchema } from "../lib/value-schema";
3
+
4
+ describe("buildCustomFieldValueSchema — type-shape only", () => {
5
+ test("strips top-level constraint keys (required/maxLength/format)", () => {
6
+ const schema = buildCustomFieldValueSchema({
7
+ type: "text",
8
+ required: true,
9
+ maxLength: 5,
10
+ format: "email",
11
+ });
12
+ expect(schema).not.toBeNull();
13
+ expect(schema?.safeParse("").success).toBe(true);
14
+ expect(schema?.safeParse("not-an-email-and-way-too-long").success).toBe(true);
15
+ expect(schema?.safeParse(42).success).toBe(false);
16
+ });
17
+
18
+ test("embedded validates sub-field TYPE only — sub-field `required` is stripped", () => {
19
+ const schema = buildCustomFieldValueSchema({
20
+ type: "embedded",
21
+ schema: { city: { type: "text", required: true } },
22
+ });
23
+ expect(schema).not.toBeNull();
24
+ // type-valid objects pass, regardless of the sub-field `required` flag
25
+ expect(schema?.safeParse({ city: "Bonn" }).success).toBe(true);
26
+ expect(schema?.safeParse({}).success).toBe(true);
27
+ expect(schema?.safeParse({ city: "" }).success).toBe(true);
28
+ // type-mismatches still rejected: non-object, and wrong sub-field type
29
+ expect(schema?.safeParse("not-an-object").success).toBe(false);
30
+ expect(schema?.safeParse({ city: 123 }).success).toBe(false);
31
+ });
32
+
33
+ test("embedded strip applies to non-text sub-types too (number)", () => {
34
+ const schema = buildCustomFieldValueSchema({
35
+ type: "embedded",
36
+ schema: { age: { type: "number", required: true } },
37
+ });
38
+ expect(schema).not.toBeNull();
39
+ expect(schema?.safeParse({ age: 7 }).success).toBe(true);
40
+ // required stripped → missing key passes (pre-fix the non-optional number
41
+ // sub-field rejected it)
42
+ expect(schema?.safeParse({}).success).toBe(true);
43
+ // type-mismatch still rejected
44
+ expect(schema?.safeParse({ age: "not-a-number" }).success).toBe(false);
45
+ });
46
+
47
+ test("embedded with an unsupported sub-type → null (skip validation)", () => {
48
+ const schema = buildCustomFieldValueSchema({
49
+ type: "embedded",
50
+ schema: { blob: { type: "json" } },
51
+ });
52
+ expect(schema).toBeNull();
53
+ });
54
+ });
@@ -13,19 +13,22 @@ function bindJsonbParam(value: unknown): { sql: string; bound: unknown } {
13
13
  return { sql: "$1::jsonb", bound: value };
14
14
  }
15
15
 
16
+ // Security invariant: aggregateId is a global row UUID, so without the tenant_id
17
+ // filter tenant A could mutate tenant B's row by its UUID (cf. removeCustomFieldKeyForTenant).
16
18
  export async function setCustomFieldValue(
17
19
  db: DbRunner,
18
20
  tableName: string,
19
21
  fieldKey: string,
20
22
  value: unknown,
21
23
  aggregateId: string,
24
+ tenantId: string,
22
25
  ): Promise<void> {
23
26
  const tbl = quoteTable(tableName);
24
27
  const escapedKey = fieldKey.replace(/'/g, "''");
25
28
  const jsonb = bindJsonbParam(value);
26
29
  await asRawClient(db).unsafe(
27
- `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2`,
28
- [jsonb.bound, aggregateId],
30
+ `UPDATE ${tbl} SET custom_fields = jsonb_set(custom_fields, '{${escapedKey}}', ${jsonb.sql}, true) WHERE id = $2 AND tenant_id = $3`,
31
+ [jsonb.bound, aggregateId, tenantId],
29
32
  );
30
33
  }
31
34
 
@@ -34,11 +37,12 @@ export async function clearCustomFieldKey(
34
37
  tableName: string,
35
38
  fieldKey: string,
36
39
  aggregateId: string,
40
+ tenantId: string,
37
41
  ): Promise<void> {
38
42
  const tbl = quoteTable(tableName);
39
43
  await asRawClient(db).unsafe(
40
- `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2`,
41
- [fieldKey, aggregateId],
44
+ `UPDATE ${tbl} SET custom_fields = custom_fields - $1 WHERE id = $2 AND tenant_id = $3`,
45
+ [fieldKey, aggregateId, tenantId],
42
46
  );
43
47
  }
44
48
 
@@ -36,6 +36,9 @@ export const clearCustomFieldHandler: WriteHandlerDef = {
36
36
  if (accessCheck.reason === "field_definition_not_found") {
37
37
  return failNotFound("fieldDefinition", payload.fieldKey);
38
38
  }
39
+ if (accessCheck.reason === "field_definition_corrupt") {
40
+ return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
41
+ }
39
42
  return failUnprocessable("field_access_denied", {
40
43
  fieldKey: payload.fieldKey,
41
44
  requiredRoles: accessCheck.requiredRoles ?? [],
@@ -13,7 +13,11 @@ export const setCustomFieldPayloadSchema = z.object({
13
13
  .min(1)
14
14
  .max(64)
15
15
  .regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
16
- value: z.unknown(),
16
+ // z.unknown() is implicitly optional; reject a missing value here (clearing is
17
+ // clear-custom-field's job) so the projection never binds JSON.stringify(undefined).
18
+ value: z
19
+ .unknown()
20
+ .refine((v) => v !== undefined, "value is required (use clear-custom-field to remove a value)"),
17
21
  });
18
22
  export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
19
23
 
@@ -57,6 +61,9 @@ export const setCustomFieldHandler: WriteHandlerDef = {
57
61
  if (!loaded.found) {
58
62
  return failNotFound("fieldDefinition", payload.fieldKey);
59
63
  }
64
+ if (loaded.field === null) {
65
+ return failUnprocessable("field_definition_corrupt", { fieldKey: payload.fieldKey });
66
+ }
60
67
 
61
68
  const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, event.user.roles);
62
69
  if (deniedRoles) {
@@ -10,15 +10,15 @@ export type FieldAccessCheckResult =
10
10
  | { ok: true }
11
11
  | {
12
12
  ok: false;
13
- reason: "field_definition_not_found" | "field_access_denied";
13
+ reason: "field_definition_not_found" | "field_definition_corrupt" | "field_access_denied";
14
14
  requiredRoles?: ReadonlyArray<string>;
15
15
  };
16
16
 
17
17
  export type LoadedFieldDefinition =
18
18
  | { found: false }
19
- // `field` is null when the row exists but its serialized_field is corrupt
20
- // callers treat that as "no restriction / no schema" (lenient), distinct
21
- // from `found: false` (no definition 404).
19
+ // `field` is null when the row exists but its serialized_field is corrupt.
20
+ // A write access-gate treats this fail-closed (secure-by-default): a corrupt
21
+ // definition must not silently drop a per-field write restriction.
22
22
  | { found: true; field: SerializedFieldShape | null };
23
23
 
24
24
  export async function loadFieldDefinition(
@@ -54,6 +54,7 @@ export async function checkFieldAccessForWrite(
54
54
  ): Promise<FieldAccessCheckResult> {
55
55
  const loaded = await loadFieldDefinition(db, tenantId, entityName, fieldKey);
56
56
  if (!loaded.found) return { ok: false, reason: "field_definition_not_found" };
57
+ if (loaded.field === null) return { ok: false, reason: "field_definition_corrupt" };
57
58
 
58
59
  const deniedRoles = fieldWriteAccessDeniedRoles(loaded.field, userRoles);
59
60
  if (!deniedRoles) return { ok: true };
@@ -47,20 +47,32 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
47
47
  // Embedded sub-fields: pre-check the sub-type set so we surface unknown
48
48
  // sub-types as "skip validation" (return null) rather than letting
49
49
  // fieldToZod's assertUnreachable throw and the catch swallow real bugs.
50
+ // Build a constraint-stripped copy of each sub-field in the same pass —
51
+ // symmetric with the top-level strip below. Without it a sub-field's
52
+ // `required` folds into z.string().min(1) + non-optional, re-enforcing a
53
+ // constraint the contract ("type-mismatches and ONLY type-mismatches")
54
+ // drops everywhere else.
55
+ let strippedSubSchema: Record<string, Record<string, unknown>> | undefined;
50
56
  if (rawType === "embedded") {
51
57
  const schema = obj["schema"];
52
58
  if (!schema || typeof schema !== "object") return null;
53
- for (const sub of Object.values(schema)) {
59
+ strippedSubSchema = {};
60
+ for (const [subKey, sub] of Object.entries(schema)) {
54
61
  if (!sub || typeof sub !== "object") return null;
55
- const subType = (sub as Record<string, unknown>)["type"];
62
+ const subObj = sub as Record<string, unknown>;
63
+ const subType = subObj["type"];
56
64
  if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
57
65
  return null;
58
66
  }
67
+ const strippedSub = { ...subObj };
68
+ for (const k of CONSTRAINT_KEYS) delete strippedSub[k];
69
+ strippedSubSchema[subKey] = strippedSub;
59
70
  }
60
71
  }
61
72
 
62
73
  const fieldDef: Record<string, unknown> = { ...obj };
63
74
  for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
75
+ if (strippedSubSchema !== undefined) fieldDef["schema"] = strippedSubSchema;
64
76
  if (rawType === "enum") {
65
77
  fieldDef["type"] = "select";
66
78
  fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
@@ -114,6 +114,7 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
114
114
  payload.fieldKey,
115
115
  payload.value,
116
116
  event.aggregateId,
117
+ event.tenantId,
117
118
  );
118
119
  },
119
120
  [clearedEventType]: async (event, tx) => {
@@ -124,7 +125,13 @@ export function wireCustomFieldsFor<TReg extends FeatureRegistrar<string>>(
124
125
 
125
126
  // jsonb minus operator (`-`) entfernt key aus jsonb-object.
126
127
  const tableName = getTableName(entityTable);
127
- await clearCustomFieldKey(tx, tableName, payload.fieldKey, event.aggregateId);
128
+ await clearCustomFieldKey(
129
+ tx,
130
+ tableName,
131
+ payload.fieldKey,
132
+ event.aggregateId,
133
+ event.tenantId,
134
+ );
128
135
  },
129
136
  [fieldDefDeletedType]: async (event, tx) => {
130
137
  // fieldDefinition.deleted fires nur einmal pro fieldDef-delete
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
16
  import { asRawClient, deleteMany, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
17
+ import { defineUnmanagedTable } from "@cosmicdrift/kumiko-framework/db";
17
18
  import {
18
19
  defineFeature,
19
20
  EXT_USER_DATA,
@@ -60,17 +61,16 @@ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
60
61
  const NOW = (): Instant => getTemporal().Now.instant();
61
62
  const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
62
63
 
63
- const KUMIKO_NAME = Symbol.for("kumiko:schema:Name");
64
- const KUMIKO_COLUMNS = Symbol.for("kumiko:schema:Columns");
65
-
66
- /** Minimal bun-db table descriptor for the synthetic test_notes table. */
67
- const testNotesTable = {
68
- [KUMIKO_NAME]: "test_notes",
69
- [KUMIKO_COLUMNS]: {
70
- tenantId: { name: "tenant_id", getSQLType: () => "uuid" },
71
- authorId: { name: "author_id", getSQLType: () => "text" },
72
- },
73
- };
64
+ // Synthetic third-party "note" table — unmanaged (no entity-system base
65
+ // columns). deleteMany only filters on tenant_id + author_id, so those are the
66
+ // only columns the query layer needs to know about.
67
+ const testNotesTable = defineUnmanagedTable({
68
+ tableName: "test_notes",
69
+ columns: [
70
+ { name: "tenant_id", pgType: "uuid", notNull: true },
71
+ { name: "author_id", pgType: "text", notNull: true },
72
+ ],
73
+ });
74
74
 
75
75
  // Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
76
76
  // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post