@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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { customFieldsFeature } from "../feature";
|
|
5
|
+
import { checkFieldAccessForWrite } from "../lib/field-access";
|
|
6
|
+
|
|
7
|
+
export const clearCustomFieldPayloadSchema = z.object({
|
|
8
|
+
entityName: z.string().min(1).max(64),
|
|
9
|
+
entityId: z.string().min(1),
|
|
10
|
+
fieldKey: z.string().min(1).max(64),
|
|
11
|
+
});
|
|
12
|
+
export type ClearCustomFieldPayload = z.infer<typeof clearCustomFieldPayloadSchema>;
|
|
13
|
+
|
|
14
|
+
// clear-custom-field — entfernt einen Custom-Field-Wert von einer host-
|
|
15
|
+
// entity. Emittiert customField.cleared-Event; MSP entfernt key aus
|
|
16
|
+
// jsonb-column (key-removal, nicht null-set).
|
|
17
|
+
//
|
|
18
|
+
// T1.5b: per-field fieldAccess.write gate — caller muss eine der definierten
|
|
19
|
+
// Rollen halten falls fieldDefinition.serializedField.fieldAccess.write
|
|
20
|
+
// gesetzt ist. Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
|
|
21
|
+
export const clearCustomFieldHandler: WriteHandlerDef = {
|
|
22
|
+
name: "clear-custom-field",
|
|
23
|
+
schema: clearCustomFieldPayloadSchema,
|
|
24
|
+
access: { roles: ["TenantAdmin", "TenantMember"] },
|
|
25
|
+
handler: async (event, ctx) => {
|
|
26
|
+
const payload = event.payload as ClearCustomFieldPayload; // @cast-boundary engine-payload
|
|
27
|
+
|
|
28
|
+
const accessCheck = await checkFieldAccessForWrite(
|
|
29
|
+
ctx.db,
|
|
30
|
+
event.user.tenantId,
|
|
31
|
+
payload.entityName,
|
|
32
|
+
payload.fieldKey,
|
|
33
|
+
event.user.roles,
|
|
34
|
+
);
|
|
35
|
+
if (!accessCheck.ok) {
|
|
36
|
+
if (accessCheck.reason === "field_definition_not_found") {
|
|
37
|
+
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
38
|
+
}
|
|
39
|
+
return failUnprocessable("field_access_denied", {
|
|
40
|
+
fieldKey: payload.fieldKey,
|
|
41
|
+
requiredRoles: accessCheck.requiredRoles ?? [],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await ctx.unsafeAppendEvent({
|
|
46
|
+
aggregateId: payload.entityId,
|
|
47
|
+
aggregateType: payload.entityName,
|
|
48
|
+
type: customFieldsFeature.exports.clearedEvent.name,
|
|
49
|
+
payload: { fieldKey: payload.fieldKey },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
isSuccess: true as const,
|
|
54
|
+
data: { entityName: payload.entityName, entityId: payload.entityId },
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
isSystemTenant,
|
|
4
4
|
type WriteHandlerDef,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
7
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
8
|
import { fieldDefinitionEntity } from "../entity";
|
|
9
|
+
import { countTenantFieldDefinitions } from "../lib/quota";
|
|
8
10
|
import { type DefineFieldPayload, defineFieldPayloadSchema } from "../schemas";
|
|
9
11
|
|
|
10
12
|
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
@@ -24,39 +26,74 @@ const { executor } = createEntityExecutor("field-definition", fieldDefinitionEnt
|
|
|
24
26
|
// durch. Resolution beim Read (B2) zeigt dann den system-scope-Wert. v2
|
|
25
27
|
// kann r.systemScope-Sub-Handler einführen um cross-scope-conflict am Write
|
|
26
28
|
// abzulehnen.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
29
|
+
//
|
|
30
|
+
// **Quota (T1.5e)**: optional `fieldDefinitionLimitPerTenant` gate. When the
|
|
31
|
+
// tenant already has ≥ limit definitions, the handler rejects with
|
|
32
|
+
// `unprocessable` + reason `cap_exceeded` BEFORE attempting the insert.
|
|
33
|
+
// The factory below closes over the limit; the legacy const-export keeps
|
|
34
|
+
// behavior unchanged for callers who didn't opt into a limit.
|
|
35
|
+
export interface DefineTenantFieldOptions {
|
|
36
|
+
/** Hard quota — `>= limit` definitions per tenant rejects further defines. */
|
|
37
|
+
readonly fieldDefinitionLimitPerTenant?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createDefineTenantFieldHandler(
|
|
41
|
+
opts: DefineTenantFieldOptions = {},
|
|
42
|
+
): WriteHandlerDef {
|
|
43
|
+
const limit = opts.fieldDefinitionLimitPerTenant;
|
|
44
|
+
return {
|
|
45
|
+
name: "define-tenant-field",
|
|
46
|
+
schema: defineFieldPayloadSchema,
|
|
47
|
+
access: { roles: ["TenantAdmin"] },
|
|
48
|
+
handler: async (event, ctx) => {
|
|
49
|
+
const payload = event.payload as DefineFieldPayload; // @cast-boundary engine-payload
|
|
50
|
+
const tenantId = event.user.tenantId;
|
|
51
|
+
|
|
52
|
+
// TenantAdmin darf NICHT system-scope schreiben — strict-guard.
|
|
53
|
+
if (isSystemTenant(tenantId)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"define-tenant-field: tenantId is SYSTEM_TENANT_ID — use define-system-field for system-scope definitions",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (limit !== undefined) {
|
|
60
|
+
const current = await countTenantFieldDefinitions(ctx.db, tenantId);
|
|
61
|
+
if (current >= limit) {
|
|
62
|
+
return failUnprocessable("cap_exceeded", {
|
|
63
|
+
capName: "customFields.fieldDefinition.count",
|
|
64
|
+
limit,
|
|
65
|
+
current,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const aggregateId = fieldDefinitionAggregateId(
|
|
71
|
+
tenantId,
|
|
72
|
+
payload.entityName,
|
|
73
|
+
payload.fieldKey,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return executor.create(
|
|
77
|
+
{
|
|
78
|
+
id: aggregateId,
|
|
79
|
+
entityName: payload.entityName,
|
|
80
|
+
fieldKey: payload.fieldKey,
|
|
81
|
+
type: payload.serializedField.type,
|
|
82
|
+
required: payload.required,
|
|
83
|
+
searchable: payload.searchable,
|
|
84
|
+
displayOrder: payload.displayOrder,
|
|
85
|
+
serializedField: JSON.stringify({
|
|
86
|
+
...payload.serializedField,
|
|
87
|
+
label: payload.label,
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
event.user,
|
|
91
|
+
ctx.db,
|
|
39
92
|
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
entityName: payload.entityName,
|
|
48
|
-
fieldKey: payload.fieldKey,
|
|
49
|
-
type: payload.serializedField.type,
|
|
50
|
-
required: payload.required,
|
|
51
|
-
searchable: payload.searchable,
|
|
52
|
-
displayOrder: payload.displayOrder,
|
|
53
|
-
serializedField: JSON.stringify({
|
|
54
|
-
...payload.serializedField,
|
|
55
|
-
label: payload.label,
|
|
56
|
-
}),
|
|
57
|
-
},
|
|
58
|
-
event.user,
|
|
59
|
-
ctx.db,
|
|
60
|
-
);
|
|
61
|
-
},
|
|
62
|
-
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Backwards-compat: existing imports of `defineTenantFieldHandler` keep
|
|
98
|
+
// working — the handler has no quota, identical to pre-T1.5e behavior.
|
|
99
|
+
export const defineTenantFieldHandler: WriteHandlerDef = createDefineTenantFieldHandler();
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
7
|
import { fieldDefinitionEntity } from "../entity";
|
|
8
|
+
import { customFieldsFeature } from "../feature";
|
|
8
9
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
9
10
|
|
|
10
11
|
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
@@ -28,6 +29,19 @@ export const deleteSystemFieldHandler: WriteHandlerDef = {
|
|
|
28
29
|
);
|
|
29
30
|
|
|
30
31
|
const systemUser = { ...event.user, tenantId: SYSTEM_TENANT_ID };
|
|
31
|
-
|
|
32
|
+
const result = await executor.delete({ id: aggregateId }, systemUser, ctx.db);
|
|
33
|
+
|
|
34
|
+
// Cascade-cleanup-Event — host-entity-MSPs entfernen orphan values aus
|
|
35
|
+
// ihrer customFields jsonb. Im selben TX = atomic.
|
|
36
|
+
if (result.isSuccess) {
|
|
37
|
+
await ctx.unsafeAppendEvent({
|
|
38
|
+
aggregateId,
|
|
39
|
+
aggregateType: "field-definition",
|
|
40
|
+
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
41
|
+
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
32
46
|
},
|
|
33
47
|
};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import { fieldDefinitionAggregateId } from "../aggregate-id";
|
|
7
7
|
import { fieldDefinitionEntity } from "../entity";
|
|
8
|
+
import { customFieldsFeature } from "../feature";
|
|
8
9
|
import { type DeleteFieldPayload, deleteFieldPayloadSchema } from "../schemas";
|
|
9
10
|
|
|
10
11
|
const { executor } = createEntityExecutor("field-definition", fieldDefinitionEntity);
|
|
@@ -35,6 +36,20 @@ export const deleteTenantFieldHandler: WriteHandlerDef = {
|
|
|
35
36
|
|
|
36
37
|
const aggregateId = fieldDefinitionAggregateId(tenantId, payload.entityName, payload.fieldKey);
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
const result = await executor.delete({ id: aggregateId }, event.user, ctx.db);
|
|
40
|
+
|
|
41
|
+
// Emit cascade-cleanup-Event NACH erfolgreichem Delete. host-entity-MSPs
|
|
42
|
+
// (registriert via wireCustomFieldsFor) konsumieren das + entfernen orphan
|
|
43
|
+
// values aus ihrer customFields jsonb. Im selben TX = atomic cleanup.
|
|
44
|
+
if (result.isSuccess) {
|
|
45
|
+
await ctx.unsafeAppendEvent({
|
|
46
|
+
aggregateId,
|
|
47
|
+
aggregateType: "field-definition",
|
|
48
|
+
type: customFieldsFeature.exports.fieldDefinitionDeletedEvent.name,
|
|
49
|
+
payload: { entityName: payload.entityName, fieldKey: payload.fieldKey },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
39
54
|
},
|
|
40
55
|
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { customFieldsFeature } from "../feature";
|
|
5
|
+
import { checkFieldAccessForWrite } from "../lib/field-access";
|
|
6
|
+
|
|
7
|
+
export const setCustomFieldPayloadSchema = z.object({
|
|
8
|
+
entityName: z.string().min(1).max(64),
|
|
9
|
+
entityId: z.string().min(1),
|
|
10
|
+
fieldKey: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(64)
|
|
14
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/),
|
|
15
|
+
value: z.unknown(),
|
|
16
|
+
});
|
|
17
|
+
export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
18
|
+
|
|
19
|
+
// set-custom-field — schreibt einen Custom-Field-Wert auf eine host-entity.
|
|
20
|
+
//
|
|
21
|
+
// **ES-Option-B**: emittiert customField.set-Event auf dem host-aggregate
|
|
22
|
+
// stream (aggregateType = host-entity-name, aggregateId = host-entity-id).
|
|
23
|
+
// Last-Wins-Semantik: customField.set wird OHNE expectedVersion appended,
|
|
24
|
+
// concurrent writes auf gleiches Field gehen beide durch (Plan-Doc v2
|
|
25
|
+
// Concurrency-Tabelle).
|
|
26
|
+
//
|
|
27
|
+
// **WAS DIESER HANDLER NICHT MACHT (yet)**:
|
|
28
|
+
// - Validation des Werts gegen fieldDefinition-type (B2-todo: rehydriere
|
|
29
|
+
// r.field.X() aus serializedField, .schema.safeParse(value))
|
|
30
|
+
// - cap-counter-quota-Check (T1.5e)
|
|
31
|
+
// → Diese Aspekte kommen als Folgekommits oder durch consumer-side hooks.
|
|
32
|
+
//
|
|
33
|
+
// **WAS NEU IST (T1.5b)**:
|
|
34
|
+
// - field-access-check: wenn fieldDefinition.serializedField.fieldAccess.write
|
|
35
|
+
// gesetzt ist, muss der calling user mindestens eine der Rollen halten.
|
|
36
|
+
// Handler-level RBAC (TenantAdmin/Member) bleibt zusätzlich.
|
|
37
|
+
export const setCustomFieldHandler: WriteHandlerDef = {
|
|
38
|
+
name: "set-custom-field",
|
|
39
|
+
schema: setCustomFieldPayloadSchema,
|
|
40
|
+
access: { roles: ["TenantAdmin", "TenantMember"] },
|
|
41
|
+
handler: async (event, ctx) => {
|
|
42
|
+
const payload = event.payload as SetCustomFieldPayload; // @cast-boundary engine-payload
|
|
43
|
+
|
|
44
|
+
const accessCheck = await checkFieldAccessForWrite(
|
|
45
|
+
ctx.db,
|
|
46
|
+
event.user.tenantId,
|
|
47
|
+
payload.entityName,
|
|
48
|
+
payload.fieldKey,
|
|
49
|
+
event.user.roles,
|
|
50
|
+
);
|
|
51
|
+
if (!accessCheck.ok) {
|
|
52
|
+
if (accessCheck.reason === "field_definition_not_found") {
|
|
53
|
+
return failNotFound("fieldDefinition", payload.fieldKey);
|
|
54
|
+
}
|
|
55
|
+
return failUnprocessable("field_access_denied", {
|
|
56
|
+
fieldKey: payload.fieldKey,
|
|
57
|
+
requiredRoles: accessCheck.requiredRoles ?? [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Emit customField.set on host-aggregate stream. unsafeAppendEvent
|
|
62
|
+
// (statt strict appendEvent) weil event-type-map keine cross-feature-
|
|
63
|
+
// augmentation für diesen event-typ hat — wir nutzen den qualified
|
|
64
|
+
// string-namen direkt.
|
|
65
|
+
await ctx.unsafeAppendEvent({
|
|
66
|
+
aggregateId: payload.entityId,
|
|
67
|
+
aggregateType: payload.entityName,
|
|
68
|
+
type: customFieldsFeature.exports.setEvent.name,
|
|
69
|
+
payload: { fieldKey: payload.fieldKey, value: payload.value },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
isSuccess: true as const,
|
|
74
|
+
data: { entityName: payload.entityName, entityId: payload.entityId },
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
export { fieldDefinitionAggregateId } from "./aggregate-id";
|
|
2
2
|
export {
|
|
3
|
+
CUSTOM_FIELDS_EXTENSION,
|
|
3
4
|
CUSTOM_FIELDS_FEATURE_NAME,
|
|
4
5
|
FIELD_DEFINITION_CREATED_EVENT,
|
|
5
|
-
FIELD_DEFINITION_DELETED_EVENT,
|
|
6
6
|
FIELD_DEFINITION_UPDATED_EVENT,
|
|
7
7
|
SUPPORTED_FIELD_TYPES,
|
|
8
8
|
type SupportedFieldType,
|
|
9
9
|
} from "./constants";
|
|
10
10
|
export { fieldDefinitionEntity } from "./entity";
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
type CustomFieldClearedPayload,
|
|
13
|
+
type CustomFieldSetPayload,
|
|
14
|
+
customFieldClearedSchema,
|
|
15
|
+
customFieldSetSchema,
|
|
16
|
+
} from "./events";
|
|
17
|
+
export { createCustomFieldsFeature, customFieldsFeature } from "./feature";
|
|
18
|
+
export {
|
|
19
|
+
type ClearCustomFieldPayload,
|
|
20
|
+
clearCustomFieldPayloadSchema,
|
|
21
|
+
} from "./handlers/clear-custom-field.write";
|
|
22
|
+
export {
|
|
23
|
+
type SetCustomFieldPayload,
|
|
24
|
+
setCustomFieldPayloadSchema,
|
|
25
|
+
} from "./handlers/set-custom-field.write";
|
|
12
26
|
export {
|
|
13
27
|
type DefineFieldPayload,
|
|
14
28
|
type DeleteFieldPayload,
|
|
15
29
|
defineFieldPayloadSchema,
|
|
16
30
|
deleteFieldPayloadSchema,
|
|
17
31
|
} from "./schemas";
|
|
32
|
+
export { customFieldsField, wireCustomFieldsFor } from "./wire-for-entity";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// T1.5b — per-field write access-check for the set/clear handlers.
|
|
2
|
+
//
|
|
3
|
+
// Loads a fieldDefinition by (tenantId, entityName, fieldKey), reads its
|
|
4
|
+
// `serializedField.fieldAccess.write` array, and verifies the calling user
|
|
5
|
+
// holds at least one of the listed roles. When `fieldAccess.write` is
|
|
6
|
+
// absent or empty the handler-level RBAC is the only gate.
|
|
7
|
+
|
|
8
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { sql } from "drizzle-orm";
|
|
10
|
+
import { parseSerializedField } from "./parse-serialized-field";
|
|
11
|
+
|
|
12
|
+
export type FieldAccessCheckResult =
|
|
13
|
+
| { ok: true }
|
|
14
|
+
| {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: "field_definition_not_found" | "field_access_denied";
|
|
17
|
+
requiredRoles?: ReadonlyArray<string>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Resolution mirrors the Plan-Doc v2 system+tenant UNION: the active
|
|
21
|
+
// definition for a fieldKey on an entity is either system-scope or
|
|
22
|
+
// tenant-scope, never both (B1 conflict-rule). The tenant-scoped row sits
|
|
23
|
+
// in the caller's tenantId; system-scoped rows would sit under
|
|
24
|
+
// SYSTEM_TENANT_ID. B1 only ships the tenant-scoped pipeline, so we only
|
|
25
|
+
// query the caller's tenant — system-scope lookup will land in B2.
|
|
26
|
+
async function loadSerializedField(
|
|
27
|
+
db: TenantDb,
|
|
28
|
+
tenantId: string,
|
|
29
|
+
entityName: string,
|
|
30
|
+
fieldKey: string,
|
|
31
|
+
): Promise<unknown | null> {
|
|
32
|
+
// TenantDb's tenant-filtered API doesn't expose raw SQL — for this
|
|
33
|
+
// single-row lookup we drop down to the underlying DbRunner. tenantId
|
|
34
|
+
// is still pinned in the WHERE clause so we don't lose isolation.
|
|
35
|
+
const rows = await db.raw.execute(sql`
|
|
36
|
+
SELECT serialized_field
|
|
37
|
+
FROM read_custom_field_definitions
|
|
38
|
+
WHERE entity_name = ${entityName}
|
|
39
|
+
AND field_key = ${fieldKey}
|
|
40
|
+
AND tenant_id = ${tenantId}
|
|
41
|
+
LIMIT 1
|
|
42
|
+
`);
|
|
43
|
+
const first = (rows as ReadonlyArray<Record<string, unknown>>)[0]; // @cast-boundary db-row
|
|
44
|
+
return first ? (first["serialized_field"] ?? null) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Per Plan-Doc T1.5b: an empty / undefined `write` array means the field
|
|
48
|
+
// inherits the handler-level RBAC unchanged. Only an explicit non-empty
|
|
49
|
+
// list constrains. Intersection is role-name equality (case-sensitive).
|
|
50
|
+
export async function checkFieldAccessForWrite(
|
|
51
|
+
db: TenantDb,
|
|
52
|
+
tenantId: string,
|
|
53
|
+
entityName: string,
|
|
54
|
+
fieldKey: string,
|
|
55
|
+
userRoles: ReadonlyArray<string>,
|
|
56
|
+
): Promise<FieldAccessCheckResult> {
|
|
57
|
+
const serialized = await loadSerializedField(db, tenantId, entityName, fieldKey);
|
|
58
|
+
if (serialized === null) {
|
|
59
|
+
return { ok: false, reason: "field_definition_not_found" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parsed = parseSerializedField(serialized);
|
|
63
|
+
// skip: corrupt serialized_field on disk → treat as no-access-restriction
|
|
64
|
+
// rather than 500. Loader already returned null on missing row, so a
|
|
65
|
+
// null here means parse-failure on a present row; behave like an open
|
|
66
|
+
// field (next gate is the handler-level RBAC).
|
|
67
|
+
if (!parsed) return { ok: true };
|
|
68
|
+
|
|
69
|
+
const required = parsed.fieldAccess?.write;
|
|
70
|
+
if (!required || required.length === 0) {
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
const hit = userRoles.some((role) => required.includes(role));
|
|
74
|
+
return hit ? { ok: true } : { ok: false, reason: "field_access_denied", requiredRoles: required };
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Centralised parser for `fieldDefinition.serializedField`.
|
|
2
|
+
//
|
|
3
|
+
// The column is stored as `text` in B1 (entity.ts) — what the db row hands
|
|
4
|
+
// back depends on the driver: postgres-js returns text as a `string`, but
|
|
5
|
+
// jsonb-tolerant drivers and middleware can deliver an already-parsed
|
|
6
|
+
// object. This helper normalises both, validates the shape, and centralises
|
|
7
|
+
// the single boundary cast so callers don't sprinkle `as { … }`-narrowings
|
|
8
|
+
// across the bundle.
|
|
9
|
+
//
|
|
10
|
+
// All structured `serializedField`-keys recognised today (`fieldAccess`,
|
|
11
|
+
// `sensitive`, `retention`) live on this shape. New keys go here so the
|
|
12
|
+
// other call-sites can read them via the typed result instead of re-parsing.
|
|
13
|
+
|
|
14
|
+
import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
15
|
+
|
|
16
|
+
export interface SerializedFieldShape {
|
|
17
|
+
readonly type: string;
|
|
18
|
+
readonly fieldAccess?: {
|
|
19
|
+
readonly read?: ReadonlyArray<string>;
|
|
20
|
+
readonly write?: ReadonlyArray<string>;
|
|
21
|
+
};
|
|
22
|
+
readonly sensitive?: boolean;
|
|
23
|
+
readonly retention?: {
|
|
24
|
+
readonly keepFor: string;
|
|
25
|
+
readonly strategy: "delete" | "anonymize";
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isShape(v: unknown): v is SerializedFieldShape {
|
|
30
|
+
if (!v || typeof v !== "object") return false;
|
|
31
|
+
// `in` narrows v's type from `object` to `object & { type: unknown }` so
|
|
32
|
+
// the property access below does not need a cast.
|
|
33
|
+
if (!("type" in v)) return false;
|
|
34
|
+
return typeof v.type === "string";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalises the row's `serialized_field` column. Returns `null` for
|
|
39
|
+
* absent/corrupt rows so callers can short-circuit cleanly without
|
|
40
|
+
* having to mirror the safe-json fallback semantics themselves.
|
|
41
|
+
*/
|
|
42
|
+
export function parseSerializedField(raw: unknown): SerializedFieldShape | null {
|
|
43
|
+
const parsed = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
|
|
44
|
+
return isShape(parsed) ? parsed : null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// T1.5e — per-tenant fieldDefinition quota.
|
|
2
|
+
//
|
|
3
|
+
// `countTenantFieldDefinitions(db, tenantId)` runs a single COUNT(*) against
|
|
4
|
+
// `read_custom_field_definitions` scoped to the caller's tenant. The
|
|
5
|
+
// `define-tenant-field` handler consults this before insert and rejects
|
|
6
|
+
// with `cap_exceeded` once a configurable per-tenant ceiling is reached.
|
|
7
|
+
//
|
|
8
|
+
// This is a simple projection-count rather than a `cap-counter`-bundle
|
|
9
|
+
// counter, because the read-projection is the authoritative source
|
|
10
|
+
// (soft-deleted rows already drop out) and we don't need rolling-window
|
|
11
|
+
// semantics. A future iteration can swap to `cap-counter` if pricing
|
|
12
|
+
// wants e.g. monthly-roll definition allowances.
|
|
13
|
+
|
|
14
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { sql } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
export async function countTenantFieldDefinitions(db: TenantDb, tenantId: string): Promise<number> {
|
|
18
|
+
const rowsResult = await db.raw.execute(sql`
|
|
19
|
+
SELECT COUNT(*)::int AS n
|
|
20
|
+
FROM read_custom_field_definitions
|
|
21
|
+
WHERE tenant_id = ${tenantId}
|
|
22
|
+
`);
|
|
23
|
+
const rows = rowsResult as ReadonlyArray<Record<string, unknown>>; // @cast-boundary db-row
|
|
24
|
+
const first = rows[0];
|
|
25
|
+
if (!first) return 0;
|
|
26
|
+
const n = first["n"];
|
|
27
|
+
return typeof n === "number" ? n : Number.parseInt(String(n ?? 0), 10);
|
|
28
|
+
}
|