@cosmicdrift/kumiko-bundled-features 0.12.2 → 0.13.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/CHANGELOG.md +106 -0
- package/package.json +5 -5
- package/src/channel-email/feature.ts +1 -1
- package/src/channel-in-app/constants.ts +1 -1
- package/src/channel-in-app/feature.ts +1 -1
- package/src/channel-push/feature.ts +1 -1
- package/src/custom-fields/__tests__/audit-integration.integration.ts +277 -0
- package/src/custom-fields/__tests__/custom-fields.integration.ts +261 -0
- package/src/custom-fields/__tests__/feature.test.ts +8 -1
- package/src/custom-fields/__tests__/field-access.integration.ts +268 -0
- package/src/custom-fields/__tests__/quota.integration.ts +162 -0
- package/src/custom-fields/__tests__/retention.integration.ts +262 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.ts +290 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +123 -0
- package/src/custom-fields/constants.ts +19 -4
- package/src/custom-fields/events.ts +21 -0
- package/src/custom-fields/feature.ts +135 -29
- package/src/custom-fields/handlers/clear-custom-field.write.ts +57 -0
- package/src/custom-fields/handlers/define-tenant-field.write.ts +72 -35
- package/src/custom-fields/handlers/delete-system-field.write.ts +15 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +16 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +77 -0
- package/src/custom-fields/index.ts +17 -2
- package/src/custom-fields/lib/field-access.ts +75 -0
- package/src/custom-fields/lib/parse-serialized-field.ts +45 -0
- package/src/custom-fields/lib/quota.ts +28 -0
- package/src/custom-fields/run-retention.ts +215 -0
- package/src/custom-fields/schemas.ts +37 -4
- package/src/custom-fields/wire-for-entity.ts +162 -0
- package/src/custom-fields/wire-user-data-rights.ts +169 -0
- package/src/rate-limiting/constants.ts +1 -1
- package/src/rate-limiting/feature.ts +1 -1
- package/src/renderer-simple/feature.ts +1 -1
- package/src/template-resolver/table.ts +3 -1
- package/src/tenant/invitation-table.ts +2 -1
- package/src/text-content/table.ts +3 -1
- package/src/user/schema/user.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,111 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-bundled-features
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7f56b2f: **Framework**: add `JsonbFieldDef` + `createJsonbField()` primitive. Schema-less jsonb-Spalte (default `{}`, NOT NULL) für tenant-defined extension-data, AI-inferred metadata, free-form config-blobs. Vs. `embedded` (typed sub-schema): jsonb akzeptiert beliebige keys. Table-builder + schema-builder + e2e-generator alle aktualisiert.
|
|
8
|
+
|
|
9
|
+
**custom-fields-Bundle (B2)**: ergänzt B1 um Custom-Field-VALUES:
|
|
10
|
+
|
|
11
|
+
- `customField.set` + `customField.cleared` Event-Types (auf host-aggregate stream)
|
|
12
|
+
- `set-custom-field` + `clear-custom-field` write-handlers (emit events)
|
|
13
|
+
- `r.extendsRegistrar("customFields")` für consumer opt-in via `useExtension`
|
|
14
|
+
- `customFieldsField()` helper für entity-fields-definition
|
|
15
|
+
- `wireCustomFieldsFor(r, entityName, entityTable)` consumer-side-API registriert:
|
|
16
|
+
- `r.useExtension("customFields", entity)` opt-in marker
|
|
17
|
+
- MultiStreamProjection: customField.set/.cleared/fieldDefinition.deleted → UPDATE entityTable.customFields jsonb (jsonb_set / minus-operator)
|
|
18
|
+
- `r.entityHook("postQuery", entity, ...)` — flatten row.customFields auf API-root (Spec-Promise "indistinguishable von Stammfeldern")
|
|
19
|
+
- `r.searchPayloadExtension(entity, ...)` — customFields-keys flach ins Meilisearch-Index (F3 wiring)
|
|
20
|
+
|
|
21
|
+
**Out-of-B2** (future iterations): cross-scope-conflict (tenant override system fieldKey), cap-counter quota, user-data-rights anonymization, value-validation gegen fieldDefinition.serializedField, system+tenant UNION-read.
|
|
22
|
+
|
|
23
|
+
Part of custom-fields-bundle Sprint Phase B2 (Plan-Doc: kumiko-platform/docs/plans/custom-fields-sprint.md).
|
|
24
|
+
|
|
25
|
+
- 9121928: T1 — integration tests for custom-fields bundle. 6 full-stack scenarios via setupTestStack:
|
|
26
|
+
|
|
27
|
+
- Define field → set value → query: customField lands flat in entity-response (postQuery hook + MSP)
|
|
28
|
+
- Clear: fieldKey gone from response after clear-custom-field
|
|
29
|
+
- Multiple fields on same entity: all merge flat
|
|
30
|
+
- Entity without customField values: still queryable
|
|
31
|
+
- fieldDefinition-delete cascade: orphan values removed from all entity-rows via MSP
|
|
32
|
+
- Last-Wins on concurrent set: last value wins (unsafeAppendEvent without expectedVersion)
|
|
33
|
+
|
|
34
|
+
Plus bugfix: Event-short-name-constants haben jetzt kebab-dashes statt Punkten (toKebab collapsed dots → Registry-Drift bei type-string-templates).
|
|
35
|
+
|
|
36
|
+
- 72518fa: custom-fields: per-field `fieldAccess.write` enforcement (T1.5b).
|
|
37
|
+
|
|
38
|
+
`set-custom-field` and `clear-custom-field` handlers now read `fieldDefinition.serializedField.fieldAccess.write[]` and reject with `unprocessable` + `reason: "field_access_denied"` when the caller's roles do not intersect. Handler-level RBAC (TenantAdmin/Member) keeps applying on top.
|
|
39
|
+
|
|
40
|
+
When `fieldAccess.write` is absent or empty, behavior is unchanged — existing consumers stay green without code changes.
|
|
41
|
+
|
|
42
|
+
`serializedField` schema gains the optional `fieldAccess: { read?: string[], write?: string[] }` shape (read is reserved for T1.5c).
|
|
43
|
+
|
|
44
|
+
- 0a00e7b: custom-fields: user-data-rights wiring (T1.5c).
|
|
45
|
+
|
|
46
|
+
New `wireCustomFieldsUserDataRightsFor(r, { entityName, entityTable, userIdColumn })` opt-in helper. Registers a second `r.useExtension(EXT_USER_DATA, ...)` for the host entity whose hooks handle the customFields jsonb under DSGVO Art. 15+17+20:
|
|
47
|
+
|
|
48
|
+
- **Export**: every row owned by the user contributes its customFields jsonb into the export bundle under `<entity>.customFields`.
|
|
49
|
+
- **Forget anonymize**: sensitive customFields keys (declared via `serializedField.sensitive: true`) are stripped from the jsonb. Non-sensitive keys stay.
|
|
50
|
+
- **Forget delete**: no-op — the host entity's own user-data-rights hook removes the row, jsonb travels with it.
|
|
51
|
+
|
|
52
|
+
`serializedField` gains optional `sensitive: boolean` alongside `fieldAccess` (T1.5b).
|
|
53
|
+
|
|
54
|
+
- aca1443: custom-fields: per-field retention sweep (T1.5d).
|
|
55
|
+
|
|
56
|
+
New `runCustomFieldsRetention(opts)` walks one host entity's rows and strips/nulls customField values whose host-row `modified_at` is older than the per-field `retention.keepFor` policy. Strategy `delete` removes the key; `anonymize` sets it to `null`.
|
|
57
|
+
|
|
58
|
+
`serializedField` gains optional `retention: { keepFor: string; strategy: "delete" | "anonymize" }`.
|
|
59
|
+
|
|
60
|
+
Designed to run alongside (or inside) the data-retention bundle's daily cron. No auto-registration — the consumer chooses the schedule and which host entities to sweep.
|
|
61
|
+
|
|
62
|
+
- c6cb96c: custom-fields: per-tenant fieldDefinition quota (T1.5e).
|
|
63
|
+
|
|
64
|
+
`createCustomFieldsFeature({ fieldDefinitionLimitPerTenant: N })` installs a quota-aware `define-tenant-field` handler. The handler runs a `COUNT(*)` on `read_custom_field_definitions` per tenant before insert and rejects with `unprocessable` + `reason: cap_exceeded` once the limit is reached.
|
|
65
|
+
|
|
66
|
+
Cap is per-tenant total (across all entity-names), not per entity-name — the natural unit for tier-pricing.
|
|
67
|
+
|
|
68
|
+
Without the option, behavior is unchanged: the singleton feature and its handler retain pre-T1.5e semantics.
|
|
69
|
+
|
|
70
|
+
### Patch Changes
|
|
71
|
+
|
|
72
|
+
- 68b8118: custom-fields: typed `eventDef.name` pattern statt Template-Literal-Konstruktion.
|
|
73
|
+
|
|
74
|
+
`createCustomFieldsFeature()` returnt jetzt typed `exports` (`setEvent`, `clearedEvent`, `fieldDefinitionDeletedEvent`). Handler + `wireCustomFieldsFor` nutzen `customFieldsFeature.exports.<event>.name` als compile-time literal-typed qualified-string — keine hand-gebauten `${FEATURE}:event:${SHORT}`-Strings mehr.
|
|
75
|
+
|
|
76
|
+
Rationale: T1 hat den toKebab-collapse-Bug aufgedeckt (Dots in short-names kollabieren zu Dashes → Registry-Mismatch bei hand-gebauten Strings). Mit dem refactor wird die Drift compile-time-strukturell unmöglich (siehe Memory feedback_event_def_exports_pattern).
|
|
77
|
+
|
|
78
|
+
Kein API-Change für consumers: `createCustomFieldsFeature()` bleibt unverändert; zusätzlicher named export `customFieldsFeature` (Singleton) ist additiv.
|
|
79
|
+
|
|
80
|
+
- 3d5e9ef: `kumiko-schema-check` CLI — Empfehlung 3 aus Sprint-9.8-Retro
|
|
81
|
+
(`luminous-watching-moler.md`). Diff't APP_FEATURES (runtime, aus
|
|
82
|
+
`src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
|
|
83
|
+
`drizzle/generate.ts`). Fängt Studio's 9.8-Drama: registry 18 features
|
|
84
|
+
hinter APP_FEATURES → migrations fehlten für mounted features.
|
|
85
|
+
|
|
86
|
+
Usage (im app-workspace):
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
bunx kumiko-schema-check
|
|
90
|
+
# or with custom paths:
|
|
91
|
+
bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Plus: 5 bundled-features hatten camelCase feature-names statt kebab-case
|
|
95
|
+
(Memory `feedback_kebab_aggregates`) — aufgedeckt durch den schema-check
|
|
96
|
+
gegen use-all-bundled. Fix: `channelEmail` → `channel-email`,
|
|
97
|
+
`channelInApp` → `channel-in-app`, `channelPush` → `channel-push`,
|
|
98
|
+
`rateLimiting` → `rate-limiting`, `rendererSimple` → `renderer-simple`.
|
|
99
|
+
|
|
100
|
+
Plus `CHANNEL_IN_APP_FEATURE` und `RATE_LIMITING_FEATURE` Konstanten
|
|
101
|
+
angepasst (waren intern auf camelCase, jetzt kebab-case).
|
|
102
|
+
|
|
103
|
+
- Updated dependencies [7f56b2f]
|
|
104
|
+
- @cosmicdrift/kumiko-framework@0.13.0
|
|
105
|
+
- @cosmicdrift/kumiko-renderer@0.13.0
|
|
106
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.13.0
|
|
107
|
+
- @cosmicdrift/kumiko-renderer-web@0.13.0
|
|
108
|
+
|
|
3
109
|
## 0.12.2
|
|
4
110
|
|
|
5
111
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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>",
|
|
@@ -75,10 +75,10 @@
|
|
|
75
75
|
"@aws-sdk/client-s3": "^3.1045.0",
|
|
76
76
|
"@aws-sdk/lib-storage": "^3.1045.0",
|
|
77
77
|
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
|
78
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
79
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
80
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
78
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.13.0",
|
|
79
|
+
"@cosmicdrift/kumiko-framework": "0.13.0",
|
|
80
|
+
"@cosmicdrift/kumiko-renderer": "0.13.0",
|
|
81
|
+
"@cosmicdrift/kumiko-renderer-web": "0.13.0",
|
|
82
82
|
"@mollie/api-client": "^4.5.0",
|
|
83
83
|
"@node-rs/argon2": "^2.0.2",
|
|
84
84
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -4,7 +4,7 @@ import { createEmailChannel, type EmailChannelOptions } from "./email-channel";
|
|
|
4
4
|
export function createChannelEmailFeature(options: EmailChannelOptions): FeatureDefinition {
|
|
5
5
|
const channel = createEmailChannel(options);
|
|
6
6
|
|
|
7
|
-
return defineFeature("
|
|
7
|
+
return defineFeature("channel-email", (r) => {
|
|
8
8
|
r.requires("delivery");
|
|
9
9
|
|
|
10
10
|
r.useExtension("deliveryChannel", "email", {
|
|
@@ -6,7 +6,7 @@ import { unreadCountQuery } from "./handlers/unread-count.query";
|
|
|
6
6
|
import { inAppChannel } from "./in-app-channel";
|
|
7
7
|
|
|
8
8
|
export function createChannelInAppFeature(): FeatureDefinition {
|
|
9
|
-
return defineFeature("
|
|
9
|
+
return defineFeature("channel-in-app", (r) => {
|
|
10
10
|
r.requires("delivery");
|
|
11
11
|
|
|
12
12
|
// Register as delivery channel via extension system
|
|
@@ -4,7 +4,7 @@ import { createPushChannel, type PushChannelOptions } from "./push-channel";
|
|
|
4
4
|
export function createChannelPushFeature(options: PushChannelOptions): FeatureDefinition {
|
|
5
5
|
const channel = createPushChannel(options);
|
|
6
6
|
|
|
7
|
-
return defineFeature("
|
|
7
|
+
return defineFeature("channel-push", (r) => {
|
|
8
8
|
r.requires("delivery");
|
|
9
9
|
|
|
10
10
|
r.useExtension("deliveryChannel", "push", {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// T1.5a — Audit cross-feature integration for custom-fields.
|
|
2
|
+
//
|
|
3
|
+
// Verifies that all custom-field write-actions (define-tenant-field,
|
|
4
|
+
// set-custom-field, clear-custom-field, delete-tenant-field) emit events
|
|
5
|
+
// that are visible via the `audit:query:list` handler — without any extra
|
|
6
|
+
// wiring between the bundles.
|
|
7
|
+
//
|
|
8
|
+
// The promise: customField writes go through the event-store like any
|
|
9
|
+
// other entity write, so the audit-bundle (which queries the events table
|
|
10
|
+
// directly) picks them up automatically. This suite is the evidence that
|
|
11
|
+
// the promise holds end-to-end.
|
|
12
|
+
|
|
13
|
+
import { buildDrizzleTable } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import {
|
|
15
|
+
createEntity,
|
|
16
|
+
createEntityExecutor,
|
|
17
|
+
createTextField,
|
|
18
|
+
defineFeature,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createTestUser,
|
|
23
|
+
resetEventStore,
|
|
24
|
+
setupTestStack,
|
|
25
|
+
type TestStack,
|
|
26
|
+
TestUsers,
|
|
27
|
+
unsafeCreateEntityTable,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { sql } from "drizzle-orm";
|
|
30
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { AuditQueries } from "../../audit/constants";
|
|
33
|
+
import { createAuditFeature } from "../../audit/feature";
|
|
34
|
+
import { fieldDefinitionEntity } from "../entity";
|
|
35
|
+
import { createCustomFieldsFeature } from "../feature";
|
|
36
|
+
import { customFieldsField, wireCustomFieldsFor } from "../wire-for-entity";
|
|
37
|
+
|
|
38
|
+
const propertyEntity = createEntity({
|
|
39
|
+
table: "read_t15a_properties",
|
|
40
|
+
fields: {
|
|
41
|
+
name: createTextField({ required: true }),
|
|
42
|
+
customFields: customFieldsField(),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const propertyTable = buildDrizzleTable("property", propertyEntity);
|
|
46
|
+
|
|
47
|
+
const propertyFeature = defineFeature("property-t15a", (r) => {
|
|
48
|
+
r.entity("property", propertyEntity);
|
|
49
|
+
r.requires("custom-fields");
|
|
50
|
+
wireCustomFieldsFor(r, "property", propertyTable);
|
|
51
|
+
|
|
52
|
+
const { executor } = createEntityExecutor("property", propertyEntity);
|
|
53
|
+
r.writeHandler({
|
|
54
|
+
name: "property:create",
|
|
55
|
+
schema: z.object({ id: z.string(), name: z.string() }),
|
|
56
|
+
access: { roles: ["TenantAdmin"] },
|
|
57
|
+
handler: async (event, ctx) =>
|
|
58
|
+
executor.create(
|
|
59
|
+
{ id: event.payload.id, name: event.payload.name, customFields: {} },
|
|
60
|
+
event.user,
|
|
61
|
+
ctx.db,
|
|
62
|
+
),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const customFieldsFeature = createCustomFieldsFeature();
|
|
67
|
+
|
|
68
|
+
// Tenant-admin role for custom-fields handlers + audit. Audit's listQuery
|
|
69
|
+
// allows `Admin` and `SystemAdmin`; we add `TenantAdmin` to a single user
|
|
70
|
+
// because rotating user-identities mid-test would split the audit trail.
|
|
71
|
+
const adminWithAudit = createTestUser({ roles: ["TenantAdmin", "Admin"] });
|
|
72
|
+
|
|
73
|
+
// Distinct-tenant user for the isolation test — same tenant as `adminWithAudit`
|
|
74
|
+
// would defeat the purpose. `TestUsers.otherTenant` is `testTenantId(2)`,
|
|
75
|
+
// `adminWithAudit` defaults to `testTenantId(1)` via `TestUsers.admin`.
|
|
76
|
+
const otherTenantAdmin = TestUsers.otherTenant;
|
|
77
|
+
|
|
78
|
+
let stack: TestStack;
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
stack = await setupTestStack({
|
|
82
|
+
features: [customFieldsFeature, propertyFeature, createAuditFeature()],
|
|
83
|
+
});
|
|
84
|
+
await unsafeCreateEntityTable(stack.db, fieldDefinitionEntity);
|
|
85
|
+
await unsafeCreateEntityTable(stack.db, propertyEntity);
|
|
86
|
+
await createEventsTable(stack.db);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterAll(async () => {
|
|
90
|
+
await stack.cleanup();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
await resetEventStore(stack);
|
|
95
|
+
await stack.db.execute(sql`DELETE FROM read_t15a_properties`);
|
|
96
|
+
await stack.db.execute(sql`DELETE FROM read_custom_field_definitions`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
type AuditRow = {
|
|
100
|
+
id: string;
|
|
101
|
+
aggregateId: string;
|
|
102
|
+
aggregateType: string;
|
|
103
|
+
type: string;
|
|
104
|
+
createdBy: string;
|
|
105
|
+
payload: Record<string, unknown>;
|
|
106
|
+
};
|
|
107
|
+
type AuditResponse = { rows: AuditRow[]; nextBefore: string | null };
|
|
108
|
+
|
|
109
|
+
async function listAudit(filter: { eventType?: string; aggregateType?: string } = {}) {
|
|
110
|
+
return stack.http.queryOk<AuditResponse>(AuditQueries.list, filter, adminWithAudit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("T1.5a: custom-fields events are visible in the audit log", () => {
|
|
114
|
+
test("define-tenant-field emits an event the audit query returns", async () => {
|
|
115
|
+
await stack.http.writeOk(
|
|
116
|
+
"custom-fields:write:define-tenant-field",
|
|
117
|
+
{
|
|
118
|
+
entityName: "property",
|
|
119
|
+
fieldKey: "internalNumber",
|
|
120
|
+
serializedField: { type: "text" },
|
|
121
|
+
required: false,
|
|
122
|
+
searchable: false,
|
|
123
|
+
displayOrder: 0,
|
|
124
|
+
},
|
|
125
|
+
adminWithAudit,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const res = await listAudit({ aggregateType: "field-definition" });
|
|
129
|
+
|
|
130
|
+
// The fieldDefinition is created via r.entity + r.crud, so the event-type
|
|
131
|
+
// follows the entity-CRUD convention `<entity>.created` (with a dot),
|
|
132
|
+
// not the feature-emit-via-defineEvent convention used by set/cleared
|
|
133
|
+
// (`custom-fields:event:<short>`).
|
|
134
|
+
const created = res.rows.find((r) => r.type === "field-definition.created");
|
|
135
|
+
expect(created).toBeDefined();
|
|
136
|
+
expect(created?.createdBy).toBe(String(adminWithAudit.id));
|
|
137
|
+
expect(created?.payload["fieldKey"]).toBe("internalNumber");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("set-custom-field emits a customField.set event on the host-aggregate stream", async () => {
|
|
141
|
+
const propertyId = "11111111-1111-4000-8000-000000000001";
|
|
142
|
+
|
|
143
|
+
await stack.http.writeOk(
|
|
144
|
+
"custom-fields:write:define-tenant-field",
|
|
145
|
+
{
|
|
146
|
+
entityName: "property",
|
|
147
|
+
fieldKey: "internalNumber",
|
|
148
|
+
serializedField: { type: "text" },
|
|
149
|
+
required: false,
|
|
150
|
+
searchable: false,
|
|
151
|
+
displayOrder: 0,
|
|
152
|
+
},
|
|
153
|
+
adminWithAudit,
|
|
154
|
+
);
|
|
155
|
+
await stack.http.writeOk(
|
|
156
|
+
"property-t15a:write:property:create",
|
|
157
|
+
{ id: propertyId, name: "Hofgarten 12" },
|
|
158
|
+
adminWithAudit,
|
|
159
|
+
);
|
|
160
|
+
await stack.http.writeOk(
|
|
161
|
+
"custom-fields:write:set-custom-field",
|
|
162
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "internalNumber", value: "X-42" },
|
|
163
|
+
adminWithAudit,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const res = await listAudit({ aggregateType: "property" });
|
|
167
|
+
|
|
168
|
+
const setEvent = res.rows.find((r) => r.type === "custom-fields:event:custom-field-set");
|
|
169
|
+
expect(setEvent).toBeDefined();
|
|
170
|
+
expect(setEvent?.aggregateId).toBe(propertyId);
|
|
171
|
+
expect(setEvent?.payload["fieldKey"]).toBe("internalNumber");
|
|
172
|
+
expect(setEvent?.payload["value"]).toBe("X-42");
|
|
173
|
+
expect(setEvent?.createdBy).toBe(String(adminWithAudit.id));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("clear-custom-field emits a customField.cleared event with the fieldKey", async () => {
|
|
177
|
+
const propertyId = "22222222-2222-4000-8000-000000000002";
|
|
178
|
+
|
|
179
|
+
await stack.http.writeOk(
|
|
180
|
+
"custom-fields:write:define-tenant-field",
|
|
181
|
+
{
|
|
182
|
+
entityName: "property",
|
|
183
|
+
fieldKey: "vipFlag",
|
|
184
|
+
serializedField: { type: "boolean" },
|
|
185
|
+
required: false,
|
|
186
|
+
searchable: false,
|
|
187
|
+
displayOrder: 0,
|
|
188
|
+
},
|
|
189
|
+
adminWithAudit,
|
|
190
|
+
);
|
|
191
|
+
await stack.http.writeOk(
|
|
192
|
+
"property-t15a:write:property:create",
|
|
193
|
+
{ id: propertyId, name: "BookStore" },
|
|
194
|
+
adminWithAudit,
|
|
195
|
+
);
|
|
196
|
+
await stack.http.writeOk(
|
|
197
|
+
"custom-fields:write:set-custom-field",
|
|
198
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "vipFlag", value: true },
|
|
199
|
+
adminWithAudit,
|
|
200
|
+
);
|
|
201
|
+
await stack.http.writeOk(
|
|
202
|
+
"custom-fields:write:clear-custom-field",
|
|
203
|
+
{ entityName: "property", entityId: propertyId, fieldKey: "vipFlag" },
|
|
204
|
+
adminWithAudit,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const res = await listAudit({ aggregateType: "property" });
|
|
208
|
+
|
|
209
|
+
const clearedEvent = res.rows.find(
|
|
210
|
+
(r) => r.type === "custom-fields:event:custom-field-cleared",
|
|
211
|
+
);
|
|
212
|
+
expect(clearedEvent).toBeDefined();
|
|
213
|
+
expect(clearedEvent?.aggregateId).toBe(propertyId);
|
|
214
|
+
expect(clearedEvent?.payload["fieldKey"]).toBe("vipFlag");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("delete-tenant-field emits a fieldDefinition.deleted event", async () => {
|
|
218
|
+
await stack.http.writeOk(
|
|
219
|
+
"custom-fields:write:define-tenant-field",
|
|
220
|
+
{
|
|
221
|
+
entityName: "property",
|
|
222
|
+
fieldKey: "ephemeral",
|
|
223
|
+
serializedField: { type: "text" },
|
|
224
|
+
required: false,
|
|
225
|
+
searchable: false,
|
|
226
|
+
displayOrder: 0,
|
|
227
|
+
},
|
|
228
|
+
adminWithAudit,
|
|
229
|
+
);
|
|
230
|
+
await stack.http.writeOk(
|
|
231
|
+
"custom-fields:write:delete-tenant-field",
|
|
232
|
+
{ entityName: "property", fieldKey: "ephemeral" },
|
|
233
|
+
adminWithAudit,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const res = await listAudit({ aggregateType: "field-definition" });
|
|
237
|
+
|
|
238
|
+
const deletedEvent = res.rows.find(
|
|
239
|
+
(r) => r.type === "custom-fields:event:field-definition-deleted",
|
|
240
|
+
);
|
|
241
|
+
expect(deletedEvent).toBeDefined();
|
|
242
|
+
expect(deletedEvent?.payload["fieldKey"]).toBe("ephemeral");
|
|
243
|
+
expect(deletedEvent?.payload["entityName"]).toBe("property");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("tenant isolation: audit list never returns custom-field events from other tenants", async () => {
|
|
247
|
+
// adminWithAudit is on testTenantId(1), otherTenantAdmin is on
|
|
248
|
+
// testTenantId(2). The field define lands on tenant-1; querying audit
|
|
249
|
+
// as tenant-2's admin must surface zero rows for it. Proves that the
|
|
250
|
+
// existing tenant-isolation in the audit query
|
|
251
|
+
// (`eq(eventsTable.tenantId, query.user.tenantId)`) covers custom-field
|
|
252
|
+
// events too — no extra wiring required.
|
|
253
|
+
expect(adminWithAudit.tenantId).not.toBe(otherTenantAdmin.tenantId);
|
|
254
|
+
|
|
255
|
+
await stack.http.writeOk(
|
|
256
|
+
"custom-fields:write:define-tenant-field",
|
|
257
|
+
{
|
|
258
|
+
entityName: "property",
|
|
259
|
+
fieldKey: "leakyField",
|
|
260
|
+
serializedField: { type: "text" },
|
|
261
|
+
required: false,
|
|
262
|
+
searchable: false,
|
|
263
|
+
displayOrder: 0,
|
|
264
|
+
},
|
|
265
|
+
adminWithAudit,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const res = await stack.http.queryOk<AuditResponse>(
|
|
269
|
+
AuditQueries.list,
|
|
270
|
+
{ aggregateType: "field-definition" },
|
|
271
|
+
otherTenantAdmin,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const leak = res.rows.find((r) => (r.payload["fieldKey"] as string) === "leakyField");
|
|
275
|
+
expect(leak).toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
});
|