@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.
- package/package.json +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- 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__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +19 -7
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
+
});
|