@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 +1 -1
- package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/db/queries/projection.ts +8 -4
- package/src/custom-fields/handlers/clear-custom-field.write.ts +3 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +5 -4
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/wire-for-entity.ts +8 -1
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.24.
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
59
|
+
strippedSubSchema = {};
|
|
60
|
+
for (const [subKey, sub] of Object.entries(schema)) {
|
|
54
61
|
if (!sub || typeof sub !== "object") return null;
|
|
55
|
-
const
|
|
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(
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|