@cosmicdrift/kumiko-bundled-features 0.64.0 → 0.66.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 +6 -6
- package/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/src/config/__tests__/write-helpers.test.ts +152 -0
- package/src/config/handlers/readiness.query.ts +1 -0
- package/src/config/read-redaction.ts +0 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +195 -1
- package/src/custom-fields/__tests__/feature.test.ts +1 -4
- package/src/custom-fields/__tests__/field-write-access.test.ts +34 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +45 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +17 -0
- package/src/custom-fields/db/queries/quota.ts +3 -1
- package/src/custom-fields/entity.ts +10 -3
- package/src/custom-fields/events.ts +4 -1
- package/src/custom-fields/feature.ts +1 -5
- package/src/custom-fields/handlers/define-system-field.write.ts +4 -3
- package/src/custom-fields/handlers/define-tenant-field.write.ts +4 -3
- package/src/custom-fields/handlers/set-custom-field.write.ts +44 -2
- package/src/custom-fields/handlers/update-tenant-field.write.ts +17 -0
- package/src/custom-fields/lib/define-or-resurrect.ts +52 -0
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +6 -4
- package/src/custom-fields/wire-for-entity.ts +7 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +7 -8
- package/src/files-provider-s3/s3-provider.ts +2 -4
- package/src/legal-pages/web/__tests__/client-plugin.test.ts +53 -0
- package/src/legal-pages/web/client-plugin.ts +9 -10
- package/src/managed-pages/handlers/set.write.ts +4 -11
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +97 -0
- package/src/sessions/feature.ts +16 -3
- package/src/tags/__tests__/tags.integration.test.ts +30 -1
- package/src/tags/entity.ts +8 -0
- package/src/tags/handlers/assign-tag.write.ts +20 -5
- package/src/tags/web/__tests__/tag-section.test.tsx +26 -27
- package/src/tags/web/i18n.ts +6 -2
- package/src/tags/web/tag-section.tsx +87 -76
- package/src/text-content/web/__tests__/client-plugin.test.tsx +65 -0
- package/src/text-content/web/client-plugin.tsx +16 -13
- package/src/tier-engine/__tests__/resolver.integration.test.ts +67 -0
- package/src/tier-engine/__tests__/trial.test.ts +27 -0
- package/src/tier-engine/entity.ts +8 -0
- package/src/tier-engine/feature.ts +49 -9
- package/src/tier-engine/handlers/get-tenant-tier.query.ts +1 -9
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -9
- package/src/tier-engine/index.ts +1 -0
- package/src/tier-engine/trial.ts +26 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +233 -0
- package/src/user-data-rights/constants.ts +48 -0
- package/src/user-data-rights/feature.ts +15 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +10 -14
- package/src/user-data-rights/handlers/deletion-grace-period.ts +6 -7
- package/src/user-data-rights/handlers/lift-restriction.write.ts +3 -2
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +5 -7
- package/src/user-data-rights/handlers/restrict-account.write.ts +3 -7
- package/src/user-data-rights/index.ts +3 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +100 -0
- package/src/user-data-rights/run-forget-cleanup.ts +3 -2
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +256 -0
- package/src/user-data-rights/web/client-plugin.tsx +30 -0
- package/src/user-data-rights/web/i18n.ts +95 -0
- package/src/user-data-rights/web/index.ts +2 -0
- package/src/user-data-rights/web/privacy-center-screen.tsx +403 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.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>",
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
88
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
89
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
90
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
91
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
87
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.66.0",
|
|
88
|
+
"@cosmicdrift/kumiko-framework": "0.66.0",
|
|
89
|
+
"@cosmicdrift/kumiko-headless": "0.66.0",
|
|
90
|
+
"@cosmicdrift/kumiko-renderer": "0.66.0",
|
|
91
|
+
"@cosmicdrift/kumiko-renderer-web": "0.66.0",
|
|
92
92
|
"@mollie/api-client": "^4.5.0",
|
|
93
93
|
"@node-rs/argon2": "^2.0.2",
|
|
94
94
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -98,6 +98,7 @@ export function createTokenRequestHandler<TName extends string, TSuccessKind ext
|
|
|
98
98
|
// client can observe it through the HTTP surface.
|
|
99
99
|
if (!user || user.isDeleted || !user.email || spec.extraSilentSkip(user)) {
|
|
100
100
|
const data: TokenRequestData<TSuccessKind> = { kind: "no-op" };
|
|
101
|
+
// skip: silent no-op — uniform response prevents user-enumeration probing
|
|
101
102
|
return { isSuccess: true, data };
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ConfigScopes,
|
|
4
|
+
createTenantConfig,
|
|
5
|
+
SYSTEM_ROLE,
|
|
6
|
+
SYSTEM_TENANT_ID,
|
|
7
|
+
type TenantId,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
|
+
import type { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
|
|
10
|
+
import {
|
|
11
|
+
checkScopeWriteAccess,
|
|
12
|
+
hasConfigAccess,
|
|
13
|
+
resolveScopeIds,
|
|
14
|
+
validatePattern,
|
|
15
|
+
validateScope,
|
|
16
|
+
validateType,
|
|
17
|
+
} from "../write-helpers";
|
|
18
|
+
|
|
19
|
+
// Reading the field-level code at a test boundary — KumikoError.details is
|
|
20
|
+
// per-error `unknown`, so one documented cast beats per-assertion narrowing.
|
|
21
|
+
function fieldCode(err: KumikoError | null): string | undefined {
|
|
22
|
+
const d = err?.details as { fields?: ReadonlyArray<{ code: string }> } | undefined;
|
|
23
|
+
return d?.fields?.[0]?.code;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("hasConfigAccess", () => {
|
|
27
|
+
test('"all" grants every caller, including one with no roles', () => {
|
|
28
|
+
expect(hasConfigAccess(["all"], [])).toBe(true);
|
|
29
|
+
expect(hasConfigAccess(["all"], ["whatever"])).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("grants when a user role intersects the access list", () => {
|
|
33
|
+
expect(hasConfigAccess(["Admin", "Editor"], ["Viewer", "Editor"])).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('denies when no role intersects and "all" is absent', () => {
|
|
37
|
+
expect(hasConfigAccess(["Admin"], ["Viewer"])).toBe(false);
|
|
38
|
+
expect(hasConfigAccess([], ["Admin"])).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("checkScopeWriteAccess", () => {
|
|
43
|
+
test("non-system scope is always allowed (no level gate)", () => {
|
|
44
|
+
expect(checkScopeWriteAccess(ConfigScopes.tenant, [])).toBeNull();
|
|
45
|
+
expect(checkScopeWriteAccess(ConfigScopes.user, ["Viewer"])).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("system scope allows the machine actor (SYSTEM_ROLE)", () => {
|
|
49
|
+
expect(checkScopeWriteAccess(ConfigScopes.system, [SYSTEM_ROLE])).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("system scope allows SystemAdmin", () => {
|
|
53
|
+
expect(checkScopeWriteAccess(ConfigScopes.system, ["SystemAdmin"])).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("system scope denies a TenantAdmin", () => {
|
|
57
|
+
const err = checkScopeWriteAccess(ConfigScopes.system, ["TenantAdmin"]);
|
|
58
|
+
expect(err?.code).toBe("access_denied");
|
|
59
|
+
expect(err?.i18nKey).toBe("config.errors.systemScopeWriteDenied");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("validateScope", () => {
|
|
64
|
+
test("a scope at or below the defined level is allowed", () => {
|
|
65
|
+
// defined = user (most specific): system + tenant + user all fit under it.
|
|
66
|
+
expect(validateScope(ConfigScopes.system, ConfigScopes.user, "k")).toBeNull();
|
|
67
|
+
expect(validateScope(ConfigScopes.tenant, ConfigScopes.user, "k")).toBeNull();
|
|
68
|
+
expect(validateScope(ConfigScopes.user, ConfigScopes.user, "k")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("requesting a more specific scope than defined is rejected", () => {
|
|
72
|
+
// defined = tenant, requested = user (more specific) -> reject.
|
|
73
|
+
const err = validateScope(ConfigScopes.user, ConfigScopes.tenant, "my:key");
|
|
74
|
+
expect(err?.code).toBe("unprocessable");
|
|
75
|
+
expect(err?.i18nKey).toBe("config.errors.invalidScope");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("resolveScopeIds", () => {
|
|
80
|
+
const tenant = "tenant-9" as TenantId;
|
|
81
|
+
|
|
82
|
+
test("system scope pins SYSTEM_TENANT_ID and drops the user", () => {
|
|
83
|
+
expect(resolveScopeIds(ConfigScopes.system, tenant, "user-1")).toEqual({
|
|
84
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
85
|
+
userId: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("tenant scope keeps the tenant, drops the user", () => {
|
|
90
|
+
expect(resolveScopeIds(ConfigScopes.tenant, tenant, "user-1")).toEqual({
|
|
91
|
+
tenantId: tenant,
|
|
92
|
+
userId: null,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("user scope keeps both tenant and user", () => {
|
|
97
|
+
expect(resolveScopeIds(ConfigScopes.user, tenant, "user-1")).toEqual({
|
|
98
|
+
tenantId: tenant,
|
|
99
|
+
userId: "user-1",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("validateType", () => {
|
|
105
|
+
const numberKey = createTenantConfig("number", {});
|
|
106
|
+
const boolKey = createTenantConfig("boolean", {});
|
|
107
|
+
const textKey = createTenantConfig("text", {});
|
|
108
|
+
const selectKey = createTenantConfig("select", { options: ["a", "b"] });
|
|
109
|
+
|
|
110
|
+
test("accepts a matching primitive for each type", () => {
|
|
111
|
+
expect(validateType(5, numberKey)).toBeNull();
|
|
112
|
+
expect(validateType(true, boolKey)).toBeNull();
|
|
113
|
+
expect(validateType("x", textKey)).toBeNull();
|
|
114
|
+
expect(validateType("a", selectKey)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("rejects a mismatching primitive with invalid_type", () => {
|
|
118
|
+
const err = validateType("5", numberKey);
|
|
119
|
+
expect(err?.code).toBe("validation_error");
|
|
120
|
+
expect(fieldCode(err)).toBe("invalid_type");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("select rejects a value outside its options with invalid_option", () => {
|
|
124
|
+
const err = validateType("c", selectKey);
|
|
125
|
+
expect(err?.code).toBe("validation_error");
|
|
126
|
+
expect(fieldCode(err)).toBe("invalid_option");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("validatePattern", () => {
|
|
131
|
+
const textKey = createTenantConfig("text", { pattern: { regex: "^[a-z]+$" } });
|
|
132
|
+
|
|
133
|
+
test("returns null when the value matches the pattern", () => {
|
|
134
|
+
expect(validatePattern("abc", textKey)).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rejects a non-matching value with invalid_format", () => {
|
|
138
|
+
const err = validatePattern("AB1", textKey);
|
|
139
|
+
expect(err?.code).toBe("validation_error");
|
|
140
|
+
expect(fieldCode(err)).toBe("invalid_format");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a malformed author regex surfaces as InternalError, not a throw", () => {
|
|
144
|
+
const badKey = createTenantConfig("text", { pattern: { regex: "(" } });
|
|
145
|
+
const err = validatePattern("abc", badKey);
|
|
146
|
+
expect(err?.code).toBe("internal_error");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("non-text keys (no pattern applicable) are skipped", () => {
|
|
150
|
+
expect(validatePattern(5, createTenantConfig("number", {}))).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -87,6 +87,7 @@ export async function collectMissingRequiredConfig(
|
|
|
87
87
|
if (keyDef.required !== true) continue;
|
|
88
88
|
if (!effectiveGate(qualifiedKey)) continue;
|
|
89
89
|
if (options?.skipAccessFilter !== true && !hasConfigAccess(keyDef.access.read, user.roles)) {
|
|
90
|
+
// skip: key not visible to this user's roles (access-filtered listing)
|
|
90
91
|
continue;
|
|
91
92
|
}
|
|
92
93
|
candidates.set(qualifiedKey, keyDef);
|
|
@@ -24,7 +24,6 @@ const OWN_SOURCES: ReadonlySet<ConfigValueSource> = new Set(["user-row", "tenant
|
|
|
24
24
|
// flagging a working key as missing would nag tenants to set already-
|
|
25
25
|
// functioning config. So "nor that it is set" holds for the value queries, not
|
|
26
26
|
// for the functional readiness rollup. See readiness.query.ts.
|
|
27
|
-
// Shared mask for redacted config values across the read handlers (cascade + values).
|
|
28
27
|
export const MASKED = "••••••";
|
|
29
28
|
|
|
30
29
|
export function mayViewInheritedValue(roles: readonly string[]): boolean {
|
|
@@ -105,9 +105,11 @@ beforeEach(async () => {
|
|
|
105
105
|
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
106
106
|
const systemAdmin = createTestUser({ roles: ["SystemAdmin"] });
|
|
107
107
|
|
|
108
|
+
// Active definitions only — delete soft-deletes (the deterministic stream is
|
|
109
|
+
// kept so a re-define can restore it), so isDeleted rows must not count.
|
|
108
110
|
async function countDefinitions(tenantId: string, fieldKey: string): Promise<number> {
|
|
109
111
|
const rows = await asRawClient(stack.db).unsafe(
|
|
110
|
-
"SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2",
|
|
112
|
+
"SELECT count(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND field_key = $2 AND is_deleted = FALSE",
|
|
111
113
|
[tenantId, fieldKey],
|
|
112
114
|
);
|
|
113
115
|
return (rows as ReadonlyArray<{ n: number }>)[0]?.n ?? 0;
|
|
@@ -124,6 +126,22 @@ async function fetchDefinitionRow(
|
|
|
124
126
|
return (rows as ReadonlyArray<Record<string, unknown>>)[0];
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
// The persisted customField.set event payload for a (host aggregate, fieldKey),
|
|
130
|
+
// or undefined if none. Used to assert what did / didn't land in kumiko_events.
|
|
131
|
+
async function fetchSetEventPayload(
|
|
132
|
+
aggregateId: string,
|
|
133
|
+
fieldKey: string,
|
|
134
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
135
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
136
|
+
"SELECT payload FROM kumiko_events WHERE aggregate_id = $1",
|
|
137
|
+
[aggregateId],
|
|
138
|
+
);
|
|
139
|
+
const payloads = (rows as ReadonlyArray<{ payload: unknown }>).map((r) =>
|
|
140
|
+
typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
141
|
+
) as Array<Record<string, unknown>>;
|
|
142
|
+
return payloads.find((p) => p?.["fieldKey"] === fieldKey);
|
|
143
|
+
}
|
|
144
|
+
|
|
127
145
|
async function defineField(entityName: string, fieldKey: string, type = "text") {
|
|
128
146
|
return stack.http.writeOk(
|
|
129
147
|
"custom-fields:write:define-tenant-field",
|
|
@@ -359,6 +377,182 @@ describe("custom-fields integration — define/delete handler coverage (B1)", ()
|
|
|
359
377
|
});
|
|
360
378
|
});
|
|
361
379
|
|
|
380
|
+
describe("custom-fields integration — define resurrection (B1)", () => {
|
|
381
|
+
test("tenant: define → delete → re-define same fieldKey succeeds with the new payload", async () => {
|
|
382
|
+
await defineField("property", "recur", "text");
|
|
383
|
+
expect(await countDefinitions(admin.tenantId, "recur")).toBe(1);
|
|
384
|
+
|
|
385
|
+
await stack.http.writeOk(
|
|
386
|
+
"custom-fields:write:delete-tenant-field",
|
|
387
|
+
{ entityName: "property", fieldKey: "recur" },
|
|
388
|
+
admin,
|
|
389
|
+
);
|
|
390
|
+
expect(await countDefinitions(admin.tenantId, "recur")).toBe(0);
|
|
391
|
+
|
|
392
|
+
// Re-defining the same (entity, fieldKey) — deterministic id, the stream was
|
|
393
|
+
// deleted — must succeed (resurrect) AND reflect the new definition payload.
|
|
394
|
+
await stack.http.writeOk(
|
|
395
|
+
"custom-fields:write:define-tenant-field",
|
|
396
|
+
{
|
|
397
|
+
entityName: "property",
|
|
398
|
+
fieldKey: "recur",
|
|
399
|
+
serializedField: { type: "number" },
|
|
400
|
+
required: false,
|
|
401
|
+
searchable: false,
|
|
402
|
+
displayOrder: 0,
|
|
403
|
+
},
|
|
404
|
+
admin,
|
|
405
|
+
);
|
|
406
|
+
expect(await countDefinitions(admin.tenantId, "recur")).toBe(1);
|
|
407
|
+
const row = await fetchDefinitionRow(admin.tenantId, "recur");
|
|
408
|
+
expect(row?.["type"]).toBe("number");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("system: define → delete → re-define same fieldKey succeeds", async () => {
|
|
412
|
+
const def = {
|
|
413
|
+
entityName: "property",
|
|
414
|
+
fieldKey: "vendorRecur",
|
|
415
|
+
serializedField: { type: "text" },
|
|
416
|
+
required: false,
|
|
417
|
+
searchable: false,
|
|
418
|
+
displayOrder: 0,
|
|
419
|
+
};
|
|
420
|
+
await stack.http.writeOk("custom-fields:write:define-system-field", def, systemAdmin);
|
|
421
|
+
await stack.http.writeOk(
|
|
422
|
+
"custom-fields:write:delete-system-field",
|
|
423
|
+
{ entityName: "property", fieldKey: "vendorRecur" },
|
|
424
|
+
systemAdmin,
|
|
425
|
+
);
|
|
426
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorRecur")).toBe(0);
|
|
427
|
+
|
|
428
|
+
await stack.http.writeOk("custom-fields:write:define-system-field", def, systemAdmin);
|
|
429
|
+
expect(await countDefinitions(SYSTEM_TENANT_ID, "vendorRecur")).toBe(1);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe("custom-fields integration — sensitive value self-projected, kept out of the event log (#2)", () => {
|
|
434
|
+
test("sensitive field: value reaches the projection but NOT kumiko_events", async () => {
|
|
435
|
+
await stack.http.writeOk(
|
|
436
|
+
"custom-fields:write:define-tenant-field",
|
|
437
|
+
{
|
|
438
|
+
entityName: "property",
|
|
439
|
+
fieldKey: "taxId",
|
|
440
|
+
serializedField: { type: "text", sensitive: true },
|
|
441
|
+
required: false,
|
|
442
|
+
searchable: false,
|
|
443
|
+
displayOrder: 0,
|
|
444
|
+
},
|
|
445
|
+
admin,
|
|
446
|
+
);
|
|
447
|
+
const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a1";
|
|
448
|
+
await createProperty(propId, "Sensitive Prop");
|
|
449
|
+
await setCustomField("property", propId, "taxId", "DE-TAX-999");
|
|
450
|
+
|
|
451
|
+
// Read model HAS the value — self-projected directly into the host row.
|
|
452
|
+
const props = await listProperties();
|
|
453
|
+
const row = props.rows.find((r) => r["id"] === propId);
|
|
454
|
+
expect(row?.["taxId"]).toBe("DE-TAX-999");
|
|
455
|
+
|
|
456
|
+
// The immutable event log does NOT carry the value (erasable by design).
|
|
457
|
+
const payload = await fetchSetEventPayload(propId, "taxId");
|
|
458
|
+
expect(payload).toBeDefined();
|
|
459
|
+
expect(payload?.["fieldKey"]).toBe("taxId");
|
|
460
|
+
expect(payload && "value" in payload).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("non-sensitive field: value IS in the event log (control)", async () => {
|
|
464
|
+
const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a2";
|
|
465
|
+
await defineField("property", "publicNote", "text");
|
|
466
|
+
await createProperty(propId, "Public Prop");
|
|
467
|
+
await setCustomField("property", propId, "publicNote", "visible");
|
|
468
|
+
|
|
469
|
+
const payload = await fetchSetEventPayload(propId, "publicNote");
|
|
470
|
+
expect(payload?.["value"]).toBe("visible");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("rebuild replay: re-applying logged events restores non-sensitive, not sensitive", async () => {
|
|
474
|
+
await defineField("property", "publicTag", "text");
|
|
475
|
+
await stack.http.writeOk(
|
|
476
|
+
"custom-fields:write:define-tenant-field",
|
|
477
|
+
{
|
|
478
|
+
entityName: "property",
|
|
479
|
+
fieldKey: "secret",
|
|
480
|
+
serializedField: { type: "text", sensitive: true },
|
|
481
|
+
required: false,
|
|
482
|
+
searchable: false,
|
|
483
|
+
displayOrder: 0,
|
|
484
|
+
},
|
|
485
|
+
admin,
|
|
486
|
+
);
|
|
487
|
+
const propId = "aaaaaaaa-aaaa-4000-8000-0000000000a3";
|
|
488
|
+
await createProperty(propId, "Rebuild Prop");
|
|
489
|
+
await setCustomField("property", propId, "publicTag", "keepme");
|
|
490
|
+
await setCustomField("property", propId, "secret", "DE-TAX-777");
|
|
491
|
+
await stack.eventDispatcher?.runOnce();
|
|
492
|
+
|
|
493
|
+
// Wipe the read model, then replay the logged events through the REAL MSP
|
|
494
|
+
// apply fn — exactly the derivation a rebuild performs — without mutating
|
|
495
|
+
// consumer state (which would pollute the shared stack).
|
|
496
|
+
await asRawClient(stack.db).unsafe(
|
|
497
|
+
"UPDATE read_t1_properties SET custom_fields = '{}'::jsonb WHERE id = $1",
|
|
498
|
+
[propId],
|
|
499
|
+
);
|
|
500
|
+
const mspEntry = [...stack.registry.getAllMultiStreamProjections().entries()].find(([name]) =>
|
|
501
|
+
name.includes("property"),
|
|
502
|
+
);
|
|
503
|
+
expect(mspEntry).toBeDefined();
|
|
504
|
+
const apply = mspEntry?.[1].apply ?? {};
|
|
505
|
+
const events = (await asRawClient(stack.db).unsafe(
|
|
506
|
+
"SELECT type, payload FROM kumiko_events WHERE aggregate_id = $1 ORDER BY id ASC",
|
|
507
|
+
[propId],
|
|
508
|
+
)) as ReadonlyArray<{ type: string; payload: unknown }>;
|
|
509
|
+
// The MSP apply value-type declares a 3rd rebuild-context arg; custom-fields'
|
|
510
|
+
// apply only reads (event, tx), so narrow the call to those two for replay.
|
|
511
|
+
type ReplayApplyFn = (event: Record<string, unknown>, tx: unknown) => Promise<void>;
|
|
512
|
+
for (const e of events) {
|
|
513
|
+
const fn = apply[e.type] as ReplayApplyFn | undefined;
|
|
514
|
+
if (!fn) continue;
|
|
515
|
+
const payload = typeof e.payload === "string" ? JSON.parse(e.payload) : e.payload;
|
|
516
|
+
await fn(
|
|
517
|
+
{
|
|
518
|
+
type: e.type,
|
|
519
|
+
payload,
|
|
520
|
+
aggregateId: propId,
|
|
521
|
+
aggregateType: "property",
|
|
522
|
+
tenantId: admin.tenantId,
|
|
523
|
+
},
|
|
524
|
+
asRawClient(stack.db),
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const row = (await listProperties()).rows.find((r) => r["id"] === propId);
|
|
529
|
+
// Non-sensitive: restored from its value-bearing event.
|
|
530
|
+
expect(row?.["publicTag"]).toBe("keepme");
|
|
531
|
+
// Sensitive: the logged event carried no value → gone. The accepted, DURABLE
|
|
532
|
+
// rebuild-loss — and why a forget can't be undone by a rebuild.
|
|
533
|
+
expect(row?.["secret"]).toBeUndefined();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("update-tenant-field rejects flipping `sensitive` (would orphan logged PII)", async () => {
|
|
537
|
+
await defineField("property", "maybePii", "text"); // non-sensitive at definition
|
|
538
|
+
const err = await stack.http.writeErr(
|
|
539
|
+
"custom-fields:write:update-tenant-field",
|
|
540
|
+
{
|
|
541
|
+
entityName: "property",
|
|
542
|
+
fieldKey: "maybePii",
|
|
543
|
+
serializedField: { type: "text", sensitive: true }, // attempt the flip
|
|
544
|
+
required: false,
|
|
545
|
+
searchable: false,
|
|
546
|
+
displayOrder: 0,
|
|
547
|
+
},
|
|
548
|
+
admin,
|
|
549
|
+
);
|
|
550
|
+
expect(err.httpStatus).toBe(422);
|
|
551
|
+
expect(err.code).toBe("unprocessable");
|
|
552
|
+
expect(err.details).toMatchObject({ reason: "field_sensitive_immutable" });
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
362
556
|
describe("custom-fields integration — value validation (Builder-Reuse)", () => {
|
|
363
557
|
async function setErr(entityId: string, fieldKey: string, value: unknown) {
|
|
364
558
|
return stack.http.writeErr(
|
|
@@ -171,10 +171,7 @@ describe("createCustomFieldsFeature access-options", () => {
|
|
|
171
171
|
expect(listAccess(feature)).toEqual(["Admin", "Editor"]);
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
//
|
|
175
|
-
// Save offen für App-Rollen, aber der List-Lade-Pfad blieb ["TenantAdmin"] →
|
|
176
|
-
// App-User bekamen access_denied, die FormSection lud nie. Die Value-Rollen
|
|
177
|
-
// erben jetzt in den List-Default (Union mit dem Default).
|
|
174
|
+
// valueWriteRoles without fieldDefinitionListRoles broke asymmetrically (save open, list closed) — value roles now inherit into the list default.
|
|
178
175
|
test("valueWriteRoles erbt in den List-Default wenn fieldDefinitionListRoles fehlt", () => {
|
|
179
176
|
const feature = createCustomFieldsFeature({ valueWriteRoles: ["Admin", "Editor"] });
|
|
180
177
|
const roles = listAccess(feature);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fieldWriteAccessDeniedRoles } from "../lib/field-access";
|
|
3
|
+
import type { SerializedFieldShape } from "../lib/parse-serialized-field";
|
|
4
|
+
|
|
5
|
+
function field(write?: ReadonlyArray<string>): SerializedFieldShape {
|
|
6
|
+
return { type: "text", ...(write ? { fieldAccess: { write } } : {}) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("fieldWriteAccessDeniedRoles", () => {
|
|
10
|
+
test("allows (null) when the definition is absent", () => {
|
|
11
|
+
expect(fieldWriteAccessDeniedRoles(null, ["Viewer"])).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("allows (null) when no write restriction is declared", () => {
|
|
15
|
+
expect(fieldWriteAccessDeniedRoles(field(), ["Viewer"])).toBeNull();
|
|
16
|
+
expect(fieldWriteAccessDeniedRoles(field([]), ["Viewer"])).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("allows when a user role intersects the required roles", () => {
|
|
20
|
+
expect(
|
|
21
|
+
fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Viewer", "TenantAdmin"]),
|
|
22
|
+
).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns the required roles when the user lacks them", () => {
|
|
26
|
+
expect(fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Viewer"])).toEqual([
|
|
27
|
+
"TenantAdmin",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("match is exact — a drifted role name (Admin vs TenantAdmin) denies", () => {
|
|
32
|
+
expect(fieldWriteAccessDeniedRoles(field(["TenantAdmin"]), ["Admin"])).toEqual(["TenantAdmin"]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isFieldDefinitionRow, parseSerializedField } from "../lib/parse-serialized-field";
|
|
3
|
+
|
|
4
|
+
describe("parseSerializedField", () => {
|
|
5
|
+
test("parses a valid JSON string into the typed shape", () => {
|
|
6
|
+
const parsed = parseSerializedField('{"type":"text","sensitive":true}');
|
|
7
|
+
expect(parsed).toEqual({ type: "text", sensitive: true });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("accepts an already-parsed object (jsonb-tolerant driver path)", () => {
|
|
11
|
+
const obj = { type: "select", fieldAccess: { write: ["TenantAdmin"] } };
|
|
12
|
+
expect(parseSerializedField(obj)).toBe(obj);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns null for a corrupt JSON string", () => {
|
|
16
|
+
expect(parseSerializedField("{not json")).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns null when the shape lacks a string type", () => {
|
|
20
|
+
expect(parseSerializedField('{"sensitive":true}')).toBeNull();
|
|
21
|
+
expect(parseSerializedField({ type: 42 })).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("returns null for non-object inputs", () => {
|
|
25
|
+
expect(parseSerializedField(null)).toBeNull();
|
|
26
|
+
expect(parseSerializedField(undefined)).toBeNull();
|
|
27
|
+
expect(parseSerializedField(7)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("isFieldDefinitionRow", () => {
|
|
32
|
+
test("true for a row with a string field_key", () => {
|
|
33
|
+
expect(isFieldDefinitionRow({ field_key: "code", serialized_field: "{}" })).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("false when field_key is missing or not a string", () => {
|
|
37
|
+
expect(isFieldDefinitionRow({ serialized_field: "{}" })).toBe(false);
|
|
38
|
+
expect(isFieldDefinitionRow({ field_key: 1 })).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("false for non-object inputs", () => {
|
|
42
|
+
expect(isFieldDefinitionRow(null)).toBe(false);
|
|
43
|
+
expect(isFieldDefinitionRow("field_key")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -355,6 +355,23 @@ describe("T1.5c: custom-fields user-data-rights through the real runners", () =>
|
|
|
355
355
|
expect(customFields).toBeDefined();
|
|
356
356
|
expect(customFields).not.toHaveProperty("email");
|
|
357
357
|
expect(customFields).toMatchObject({ vipFlag: true });
|
|
358
|
+
|
|
359
|
+
// The other half of erasure: the sensitive value was self-projected and the
|
|
360
|
+
// customField.set event was persisted value-less — so PII never entered the
|
|
361
|
+
// immutable log. Without this, the strip above would be undone by a rebuild.
|
|
362
|
+
const eventRows = await asRawClient(stack.db).unsafe(
|
|
363
|
+
"SELECT payload FROM kumiko_events WHERE aggregate_id = $1",
|
|
364
|
+
[propertyId],
|
|
365
|
+
);
|
|
366
|
+
const setPayloads = (eventRows as ReadonlyArray<{ payload: unknown }>).map((r) =>
|
|
367
|
+
typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
|
|
368
|
+
) as Array<Record<string, unknown>>;
|
|
369
|
+
const emailSet = setPayloads.find((p) => p?.["fieldKey"] === "email");
|
|
370
|
+
expect(emailSet).toBeDefined();
|
|
371
|
+
expect(emailSet && "value" in emailSet).toBe(false);
|
|
372
|
+
// Control: the non-sensitive value DID go through the log (normal path).
|
|
373
|
+
const vipSet = setPayloads.find((p) => p?.["fieldKey"] === "vipFlag");
|
|
374
|
+
expect(vipSet?.["value"]).toBe(true);
|
|
358
375
|
});
|
|
359
376
|
|
|
360
377
|
test("forget delete (no override → strategy delete): host removes the row, strip is a no-op", async () => {
|
|
@@ -2,8 +2,10 @@ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
|
|
4
4
|
export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
|
|
5
|
+
// Active definitions only — delete soft-deletes (the deterministic stream is
|
|
6
|
+
// kept so a re-define can restore it), so isDeleted rows must not consume quota.
|
|
5
7
|
const rowsResult = await asRawClient(db.raw).unsafe(
|
|
6
|
-
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1",
|
|
8
|
+
"SELECT COUNT(*)::int AS n FROM read_custom_field_definitions WHERE tenant_id = $1 AND is_deleted = FALSE",
|
|
7
9
|
[tenantId],
|
|
8
10
|
);
|
|
9
11
|
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
|
|
@@ -36,11 +36,18 @@ import {
|
|
|
36
36
|
// columns + events.
|
|
37
37
|
export const fieldDefinitionEntity = createEntity({
|
|
38
38
|
table: "read_custom_field_definitions",
|
|
39
|
+
// softDelete is required, NOT cosmetic: the aggregate-id is deterministic
|
|
40
|
+
// (uuidv5(tenantId|entityName|fieldKey)), so deleting a definition leaves a
|
|
41
|
+
// (created+deleted) event stream under that id. A hard delete would force the
|
|
42
|
+
// next define to create() at version 0 onto that stream → version_conflict —
|
|
43
|
+
// a deleted (entity, fieldKey) could never be re-defined. With softDelete the
|
|
44
|
+
// define handlers resurrect via restore()+update() (see define-or-resurrect).
|
|
45
|
+
// NB: `retention.strategy` below is the data-retention purge policy, a
|
|
46
|
+
// SEPARATE knob from this executor flag — it does not drive executor.delete.
|
|
47
|
+
softDelete: true,
|
|
39
48
|
// B1.5 retention-policy — fieldDefinitions sind tenant-Schema-Metadaten,
|
|
40
49
|
// keine PII-Daten. Lange Retention für Audit (Compliance kann "wann hat
|
|
41
|
-
// Tenant das Feld definiert / geändert / gelöscht" fragen).
|
|
42
|
-
// softDelete: row bleibt als marker, value-cleanup (in B2's MSP) macht
|
|
43
|
-
// die eigentliche Anonymisierung wenn customFields PII enthielten.
|
|
50
|
+
// Tenant das Feld definiert / geändert / gelöscht" fragen).
|
|
44
51
|
//
|
|
45
52
|
// 10-Jahre keepFor ist konservativer Default; per-Tenant kann via
|
|
46
53
|
// tenantRetentionOverride für eigene Edge-Cases gesetzt werden
|
|
@@ -11,7 +11,10 @@ import { z } from "zod";
|
|
|
11
11
|
|
|
12
12
|
export const customFieldSetSchema = z.object({
|
|
13
13
|
fieldKey: z.string().min(1).max(64),
|
|
14
|
-
|
|
14
|
+
// Optional: a `sensitive`-field set persists a VALUE-LESS event — the value is
|
|
15
|
+
// self-projected into the host row by the write handler and must never enter
|
|
16
|
+
// the immutable log. Non-sensitive sets always carry the value.
|
|
17
|
+
value: z.unknown().optional(),
|
|
15
18
|
});
|
|
16
19
|
export type CustomFieldSetPayload = z.infer<typeof customFieldSetSchema>;
|
|
17
20
|
|
|
@@ -176,14 +176,10 @@ export type CustomFieldsFeatureOptions = {
|
|
|
176
176
|
* gesetzt, dies aber NICHT, erben die Value-Rollen hier hinein (Union mit
|
|
177
177
|
* dem Default, damit Admins den List-Zugriff behalten) — sonst lädt die
|
|
178
178
|
* FormSection für Value-Writer nie (access_denied), während der Save-Pfad
|
|
179
|
-
* offen wäre (
|
|
179
|
+
* offen wäre (asymmetrischer Bruch). */
|
|
180
180
|
readonly fieldDefinitionListRoles?: readonly string[];
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
// Der List-Pfad muss jeden abdecken, der Values schreiben darf — sonst lädt die
|
|
184
|
-
// FormSection nie. Explizite fieldDefinitionListRoles gewinnen; sonst: gesetzte
|
|
185
|
-
// valueWriteRoles erben in den List-Default (Union mit dem Default), ungesetzte
|
|
186
|
-
// → reiner Default.
|
|
187
183
|
export function resolveFieldDefinitionListRoles(
|
|
188
184
|
opts: Pick<CustomFieldsFeatureOptions, "valueWriteRoles" | "fieldDefinitionListRoles">,
|
|
189
185
|
): readonly string[] {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SYSTEM_TENANT_ID, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
3
|
-
import {
|
|
3
|
+
import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
|
|
4
4
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
5
5
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
6
6
|
|
|
@@ -33,8 +33,9 @@ export const defineSystemFieldHandler: WriteHandlerDef = {
|
|
|
33
33
|
// — the row lives in the system-scope-stream.
|
|
34
34
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
35
35
|
|
|
36
|
-
return
|
|
37
|
-
|
|
36
|
+
return defineOrResurrectFieldDefinition(
|
|
37
|
+
aggregateId,
|
|
38
|
+
buildFieldDefinitionColumns(payload),
|
|
38
39
|
systemUser,
|
|
39
40
|
ctx.db,
|
|
40
41
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isSystemTenant, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
4
|
-
import {
|
|
4
|
+
import { defineOrResurrectFieldDefinition } from "../lib/define-or-resurrect";
|
|
5
5
|
import { buildFieldDefinitionColumns } from "../lib/field-definition-row";
|
|
6
6
|
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
7
7
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
@@ -75,8 +75,9 @@ export function createDefineTenantFieldHandler(
|
|
|
75
75
|
payload.fieldKey,
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
-
return
|
|
79
|
-
|
|
78
|
+
return defineOrResurrectFieldDefinition(
|
|
79
|
+
aggregateId,
|
|
80
|
+
buildFieldDefinitionColumns(payload),
|
|
80
81
|
event.user,
|
|
81
82
|
ctx.db,
|
|
82
83
|
);
|