@cosmicdrift/kumiko-framework 0.260.0 → 0.262.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 +3 -3
- package/src/changes.json +20 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
- package/src/db/__tests__/tenant-db-global.test.ts +170 -0
- package/src/db/entity-table-meta.ts +8 -2
- package/src/db/table-builder.ts +37 -2
- package/src/db/tenant-db.ts +86 -4
- package/src/engine/__tests__/boot-validator.test.ts +16 -15
- package/src/engine/__tests__/build-app-schema.test.ts +14 -5
- package/src/engine/__tests__/engine.test.ts +10 -0
- package/src/engine/__tests__/entity-handlers.test.ts +48 -36
- package/src/engine/__tests__/extends-registrar.test.ts +27 -12
- package/src/engine/__tests__/registry.test.ts +16 -5
- package/src/engine/access.ts +2 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
- package/src/engine/boot-validator/__tests__/security-baseline.test.ts +107 -0
- package/src/engine/boot-validator/access-declarations.ts +141 -0
- package/src/engine/boot-validator/entity-handler.ts +1 -1
- package/src/engine/boot-validator/index.ts +5 -0
- package/src/engine/boot-validator/security-baseline.ts +26 -0
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +29 -19
- package/src/engine/factories.ts +5 -3
- package/src/engine/feature-ast/extractors/hooks.ts +5 -1
- package/src/engine/feature-entity-handlers.ts +40 -27
- package/src/engine/index.ts +5 -0
- package/src/engine/types/handlers.ts +1 -0
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
- package/src/pipeline/dispatch-shared.ts +3 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -0
- package/src/testing/__tests__/e2e-generator.test.ts +11 -5
- package/src/ui-types/index.ts +6 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../../define-feature";
|
|
3
|
+
import type { FeatureDefinition } from "../../types";
|
|
4
|
+
import { validateBoot } from "../index";
|
|
5
|
+
import {
|
|
6
|
+
SECURITY_BASELINE_FEATURE_NAMES,
|
|
7
|
+
warnOnMissingSecurityBaseline,
|
|
8
|
+
} from "../security-baseline";
|
|
9
|
+
|
|
10
|
+
function stubFeature(name: string): FeatureDefinition {
|
|
11
|
+
return defineFeature(name, () => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("warnOnMissingSecurityBaseline", () => {
|
|
15
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
warnSpy.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("production + no baseline features → warns once, naming all four", () => {
|
|
26
|
+
warnOnMissingSecurityBaseline([], "production");
|
|
27
|
+
|
|
28
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
29
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
30
|
+
for (const name of SECURITY_BASELINE_FEATURE_NAMES) {
|
|
31
|
+
expect(msg).toContain(name);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("production + only sessions/audit mounted → warns about the missing two only", () => {
|
|
36
|
+
const features = [stubFeature("sessions"), stubFeature("audit")];
|
|
37
|
+
|
|
38
|
+
warnOnMissingSecurityBaseline(features, "production");
|
|
39
|
+
|
|
40
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
41
|
+
const msg = String(warnSpy.mock.calls[0]?.[0]);
|
|
42
|
+
expect(msg).toContain("crypto-shredding");
|
|
43
|
+
expect(msg).toContain("rate-limiting");
|
|
44
|
+
expect(msg).not.toContain("missing feature(s): sessions");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("production + all four baseline features mounted → no warning", () => {
|
|
48
|
+
const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
|
|
49
|
+
|
|
50
|
+
warnOnMissingSecurityBaseline(features, "production");
|
|
51
|
+
|
|
52
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test.each(["development", "test", undefined])(
|
|
56
|
+
"nodeEnv=%p + no baseline features → no warning",
|
|
57
|
+
(nodeEnv) => {
|
|
58
|
+
warnOnMissingSecurityBaseline([], nodeEnv);
|
|
59
|
+
|
|
60
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
test("never throws, regardless of nodeEnv or mounted features", () => {
|
|
65
|
+
expect(() => warnOnMissingSecurityBaseline([], "production")).not.toThrow();
|
|
66
|
+
expect(() => warnOnMissingSecurityBaseline([], "development")).not.toThrow();
|
|
67
|
+
expect(() =>
|
|
68
|
+
warnOnMissingSecurityBaseline(
|
|
69
|
+
SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name)),
|
|
70
|
+
"production",
|
|
71
|
+
),
|
|
72
|
+
).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("validateBoot + security baseline warning", () => {
|
|
77
|
+
const originalNodeEnv = process.env["NODE_ENV"];
|
|
78
|
+
let warnSpy: ReturnType<typeof spyOn<Console, "warn">>;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
warnSpy.mockRestore();
|
|
86
|
+
if (originalNodeEnv === undefined) {
|
|
87
|
+
delete process.env["NODE_ENV"];
|
|
88
|
+
} else {
|
|
89
|
+
process.env["NODE_ENV"] = originalNodeEnv;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("production boot with all four baseline stubs mounted does not throw and does not warn", () => {
|
|
94
|
+
process.env["NODE_ENV"] = "production";
|
|
95
|
+
const features = SECURITY_BASELINE_FEATURE_NAMES.map((name) => stubFeature(name));
|
|
96
|
+
|
|
97
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
98
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("production boot with no baseline features mounted warns but does not throw", () => {
|
|
102
|
+
process.env["NODE_ENV"] = "production";
|
|
103
|
+
|
|
104
|
+
expect(() => validateBoot([])).not.toThrow();
|
|
105
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AccessRule, FeatureDefinition, WriteHandlerDef } from "../types";
|
|
2
|
+
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
3
|
+
import { getZodObjectShape } from "./zod-shape";
|
|
4
|
+
|
|
5
|
+
type HandlerKind = "write" | "query" | "stream";
|
|
6
|
+
|
|
7
|
+
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
8
|
+
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
9
|
+
// sense openToAll+publicIntake is guarding against.
|
|
10
|
+
function isPersonalDataField(field: unknown): boolean {
|
|
11
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
12
|
+
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function personalFieldNames(entity: EntityDefinition): ReadonlySet<string> {
|
|
16
|
+
const names = new Set<string>();
|
|
17
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
18
|
+
if (isPersonalDataField(field)) names.add(fieldName);
|
|
19
|
+
}
|
|
20
|
+
return names;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function candidatePersonalFieldNames(
|
|
24
|
+
feature: FeatureDefinition,
|
|
25
|
+
handlerName: string,
|
|
26
|
+
): ReadonlySet<string> {
|
|
27
|
+
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
28
|
+
const entities = feature.entities ?? {};
|
|
29
|
+
if (mappedEntityName) {
|
|
30
|
+
const entity = entities[mappedEntityName];
|
|
31
|
+
return entity ? personalFieldNames(entity) : new Set();
|
|
32
|
+
}
|
|
33
|
+
const names = new Set<string>();
|
|
34
|
+
for (const entity of Object.values(entities)) {
|
|
35
|
+
for (const name of personalFieldNames(entity)) names.add(name);
|
|
36
|
+
}
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A value that is neither `true` nor an object with a non-empty string `reason`
|
|
41
|
+
// counts as "empty" — catches malformed openToAll from untyped sources too.
|
|
42
|
+
function openToAllReasonIsEmpty(access: AccessRule): boolean {
|
|
43
|
+
if (!("openToAll" in access)) return false;
|
|
44
|
+
const openToAll: unknown = access.openToAll;
|
|
45
|
+
if (openToAll === true) return false;
|
|
46
|
+
if (typeof openToAll !== "object" || openToAll === null) return true;
|
|
47
|
+
if (!("reason" in openToAll) || typeof openToAll.reason !== "string") return true;
|
|
48
|
+
return openToAll.reason.trim().length === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasOpenToAll(access: AccessRule): boolean {
|
|
52
|
+
return "openToAll" in access;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasPublicIntake(access: AccessRule): boolean {
|
|
56
|
+
return "publicIntake" in access && access.publicIntake === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateOpenToAllReason(
|
|
60
|
+
feature: FeatureDefinition,
|
|
61
|
+
kind: HandlerKind,
|
|
62
|
+
handlerName: string,
|
|
63
|
+
access: AccessRule,
|
|
64
|
+
): void {
|
|
65
|
+
// skip: reason is well-formed, nothing to validate
|
|
66
|
+
if (!openToAllReasonIsEmpty(access)) return;
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
|
|
69
|
+
'{ openToAll: ... } — must be `true` or { reason: "<non-empty explanation>" } ' +
|
|
70
|
+
"for why any authenticated user may call this handler.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateEscapeHatchReason(
|
|
75
|
+
feature: FeatureDefinition,
|
|
76
|
+
handlerName: string,
|
|
77
|
+
escapeHatch: WriteHandlerDef["escapeHatch"],
|
|
78
|
+
): void {
|
|
79
|
+
// skip: no escapeHatch declared, or its reason is already non-empty
|
|
80
|
+
if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
83
|
+
`{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
|
|
84
|
+
"explaining why this handler needs db.global() write access.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validatePublicIntakeOnlyOnWrite(
|
|
89
|
+
feature: FeatureDefinition,
|
|
90
|
+
kind: HandlerKind,
|
|
91
|
+
handlerName: string,
|
|
92
|
+
access: AccessRule,
|
|
93
|
+
): void {
|
|
94
|
+
// skip: write handlers may declare publicIntake, or it wasn't declared here
|
|
95
|
+
if (kind === "write" || !hasPublicIntake(access)) return;
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
98
|
+
"{ publicIntake: true } — publicIntake is only meaningful on a write handler " +
|
|
99
|
+
"(it silences the personal-data + openToAll boot check for the fields a write " +
|
|
100
|
+
"handler's input accepts).",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateOpenToAllPersonalData(
|
|
105
|
+
feature: FeatureDefinition,
|
|
106
|
+
handlerName: string,
|
|
107
|
+
handler: WriteHandlerDef,
|
|
108
|
+
): void {
|
|
109
|
+
const access = handler.access;
|
|
110
|
+
// skip: no openToAll declared, or publicIntake already covers personal data
|
|
111
|
+
if (!hasOpenToAll(access) || hasPublicIntake(access)) return;
|
|
112
|
+
const shape = getZodObjectShape(handler.schema);
|
|
113
|
+
// skip: handler schema isn't a zod object — nothing to inspect
|
|
114
|
+
if (!shape) return;
|
|
115
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName);
|
|
116
|
+
const offending = Object.keys(shape).filter((key) => personalNames.has(key));
|
|
117
|
+
// skip: no personal-data fields in the handler's input
|
|
118
|
+
if (offending.length === 0) return;
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
|
|
121
|
+
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} without ` +
|
|
122
|
+
"{ publicIntake: true }. Restrict access to roles, or declare " +
|
|
123
|
+
"{ publicIntake: true } if any authenticated user may submit this personal data.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
128
|
+
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
129
|
+
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
130
|
+
validateEscapeHatchReason(feature, handlerName, handler.escapeHatch);
|
|
131
|
+
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
132
|
+
}
|
|
133
|
+
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
134
|
+
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
135
|
+
validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
|
|
136
|
+
}
|
|
137
|
+
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
138
|
+
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
139
|
+
validatePublicIntakeOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -143,7 +143,7 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
143
143
|
if (!handler.access) {
|
|
144
144
|
throw new Error(
|
|
145
145
|
`${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
|
|
146
|
-
`Set { roles: [...] } for role-based access, or { openToAll:
|
|
146
|
+
`Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`,
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { FeatureDefinition } from "../types";
|
|
4
|
+
import { validateAccessDeclarations } from "./access-declarations";
|
|
4
5
|
import { warnOnUniqueAccessRoles } from "./access-roles";
|
|
5
6
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
6
7
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
validateScreenShortIdCollisions,
|
|
61
62
|
validateScreens,
|
|
62
63
|
} from "./screens";
|
|
64
|
+
import { warnOnMissingSecurityBaseline } from "./security-baseline";
|
|
63
65
|
import {
|
|
64
66
|
collectWorkspaceQns,
|
|
65
67
|
validateDefaultWorkspaceUniqueness,
|
|
@@ -71,6 +73,7 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
|
71
73
|
// an den Codegen zu übergeben. Nicht Teil von validateBoot, aber
|
|
72
74
|
// dieselbe Extraktionslogik.
|
|
73
75
|
export { collectWriteHandlerQns } from "./nav";
|
|
76
|
+
export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline";
|
|
74
77
|
|
|
75
78
|
export type ValidateBootOptions = {
|
|
76
79
|
/** Warn when an access role is used by exactly one handler/config-key/
|
|
@@ -187,6 +190,7 @@ export function validateBoot(
|
|
|
187
190
|
validateExtendSchemaCollisions(feature);
|
|
188
191
|
validateDerivedFieldCollisions(feature);
|
|
189
192
|
validateHandlerAccess(feature);
|
|
193
|
+
validateAccessDeclarations(feature);
|
|
190
194
|
validateLocatedTimestamps(feature);
|
|
191
195
|
validateEntityIndexes(feature);
|
|
192
196
|
validateConfigKeyBounds(feature);
|
|
@@ -252,6 +256,7 @@ export function validateBoot(
|
|
|
252
256
|
|
|
253
257
|
validateConfigReads(features, allConfigKeys);
|
|
254
258
|
warnOnToggleableDependencies(features, featureMap);
|
|
259
|
+
warnOnMissingSecurityBaseline(features);
|
|
255
260
|
if (options?.warnOnUniqueAccessRoles === true) {
|
|
256
261
|
warnOnUniqueAccessRoles(features);
|
|
257
262
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
export const SECURITY_BASELINE_FEATURE_NAMES = [
|
|
4
|
+
"sessions",
|
|
5
|
+
"crypto-shredding",
|
|
6
|
+
"rate-limiting",
|
|
7
|
+
"audit",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export function warnOnMissingSecurityBaseline(
|
|
11
|
+
features: readonly FeatureDefinition[],
|
|
12
|
+
nodeEnv: string | undefined = process.env["NODE_ENV"],
|
|
13
|
+
): void {
|
|
14
|
+
// skip: baseline is only enforced for production boots
|
|
15
|
+
if (nodeEnv !== "production") return;
|
|
16
|
+
|
|
17
|
+
const mountedNames = new Set(features.map((f) => f.name));
|
|
18
|
+
const missing = SECURITY_BASELINE_FEATURE_NAMES.filter((name) => !mountedNames.has(name));
|
|
19
|
+
// skip: every baseline feature is mounted
|
|
20
|
+
if (missing.length === 0) return;
|
|
21
|
+
|
|
22
|
+
// biome-ignore lint/suspicious/noConsole: boot-time hint, no logger available yet
|
|
23
|
+
console.warn(
|
|
24
|
+
`[kumiko:boot] NODE_ENV=production but the security baseline is incomplete — missing feature(s): ${missing.join(", ")}. Mount securityBaselineFeatures() from @cosmicdrift/kumiko-bundled-features/presets (or the missing features individually).`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -85,6 +85,7 @@ export function defineWriteHandler<
|
|
|
85
85
|
unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
|
|
86
86
|
}),
|
|
87
87
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
88
|
+
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
if ("perform" in def && def.perform !== undefined) {
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
} from "./types/entity-handlers";
|
|
31
31
|
|
|
32
32
|
export type {
|
|
33
|
+
EntityCrudHandlerDefaults,
|
|
33
34
|
EntityCrudRegistrar,
|
|
34
35
|
EntityCrudVerb,
|
|
35
36
|
EntityHandlerOptions,
|
|
@@ -174,7 +175,7 @@ function parseHandlerName<TVerb extends string>(
|
|
|
174
175
|
export function defineEntityWriteHandler(
|
|
175
176
|
name: string,
|
|
176
177
|
entity: EntityDefinition,
|
|
177
|
-
options
|
|
178
|
+
options: EntityHandlerOptions,
|
|
178
179
|
): WriteHandlerDef {
|
|
179
180
|
const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
|
|
180
181
|
if (verb === "restore" && !entity.softDelete) {
|
|
@@ -291,9 +292,9 @@ export function defineEntityWriteHandler(
|
|
|
291
292
|
name,
|
|
292
293
|
schema,
|
|
293
294
|
handler,
|
|
294
|
-
|
|
295
|
-
...(options
|
|
296
|
-
...(options
|
|
295
|
+
access: options.access,
|
|
296
|
+
...(options.description !== undefined && { description: options.description }),
|
|
297
|
+
...(options.agent !== undefined && { agent: options.agent }),
|
|
297
298
|
};
|
|
298
299
|
}
|
|
299
300
|
|
|
@@ -323,7 +324,7 @@ function augmentDerivedFields(
|
|
|
323
324
|
export function defineEntityQueryHandler(
|
|
324
325
|
name: string,
|
|
325
326
|
entity: EntityDefinition,
|
|
326
|
-
options
|
|
327
|
+
options: EntityQueryHandlerOptions,
|
|
327
328
|
): QueryHandlerDef {
|
|
328
329
|
const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
|
|
329
330
|
|
|
@@ -421,9 +422,9 @@ export function defineEntityQueryHandler(
|
|
|
421
422
|
name,
|
|
422
423
|
schema,
|
|
423
424
|
handler,
|
|
424
|
-
|
|
425
|
-
...(options
|
|
426
|
-
...(options
|
|
425
|
+
access: options.access,
|
|
426
|
+
...(options.description !== undefined && { description: options.description }),
|
|
427
|
+
...(options.agent !== undefined && { agent: options.agent }),
|
|
427
428
|
// The "list" verb's executor.list() always returns { rows, nextCursor,
|
|
428
429
|
// total? } (see the handler body above) — brand it so the definition
|
|
429
430
|
// site documents the PagedRows contract without needing
|
|
@@ -458,7 +459,7 @@ export function defineEntityQueryHandler(
|
|
|
458
459
|
export function defineEntityCreateHandler(
|
|
459
460
|
entityName: string,
|
|
460
461
|
entity: EntityDefinition,
|
|
461
|
-
options
|
|
462
|
+
options: EntityHandlerOptions,
|
|
462
463
|
): WriteHandlerDef {
|
|
463
464
|
return defineEntityWriteHandler(`${entityName}:create`, entity, options);
|
|
464
465
|
}
|
|
@@ -467,7 +468,7 @@ export function defineEntityCreateHandler(
|
|
|
467
468
|
export function defineEntityUpdateHandler(
|
|
468
469
|
entityName: string,
|
|
469
470
|
entity: EntityDefinition,
|
|
470
|
-
options
|
|
471
|
+
options: EntityHandlerOptions,
|
|
471
472
|
): WriteHandlerDef {
|
|
472
473
|
return defineEntityWriteHandler(`${entityName}:update`, entity, options);
|
|
473
474
|
}
|
|
@@ -476,7 +477,7 @@ export function defineEntityUpdateHandler(
|
|
|
476
477
|
export function defineEntityDeleteHandler(
|
|
477
478
|
entityName: string,
|
|
478
479
|
entity: EntityDefinition,
|
|
479
|
-
options
|
|
480
|
+
options: EntityHandlerOptions,
|
|
480
481
|
): WriteHandlerDef {
|
|
481
482
|
return defineEntityWriteHandler(`${entityName}:delete`, entity, options);
|
|
482
483
|
}
|
|
@@ -485,7 +486,7 @@ export function defineEntityDeleteHandler(
|
|
|
485
486
|
export function defineEntityRestoreHandler(
|
|
486
487
|
entityName: string,
|
|
487
488
|
entity: EntityDefinition,
|
|
488
|
-
options
|
|
489
|
+
options: EntityHandlerOptions,
|
|
489
490
|
): WriteHandlerDef {
|
|
490
491
|
return defineEntityWriteHandler(`${entityName}:restore`, entity, options);
|
|
491
492
|
}
|
|
@@ -494,7 +495,7 @@ export function defineEntityRestoreHandler(
|
|
|
494
495
|
export function defineEntityListHandler(
|
|
495
496
|
entityName: string,
|
|
496
497
|
entity: EntityDefinition,
|
|
497
|
-
options
|
|
498
|
+
options: EntityQueryHandlerOptions,
|
|
498
499
|
): QueryHandlerDef {
|
|
499
500
|
return defineEntityQueryHandler(`${entityName}:list`, entity, options);
|
|
500
501
|
}
|
|
@@ -503,7 +504,7 @@ export function defineEntityListHandler(
|
|
|
503
504
|
export function defineEntityDetailHandler(
|
|
504
505
|
entityName: string,
|
|
505
506
|
entity: EntityDefinition,
|
|
506
|
-
options
|
|
507
|
+
options: EntityQueryHandlerOptions,
|
|
507
508
|
): QueryHandlerDef {
|
|
508
509
|
return defineEntityQueryHandler(`${entityName}:detail`, entity, options);
|
|
509
510
|
}
|
|
@@ -542,7 +543,7 @@ export function createEntityExecutor(
|
|
|
542
543
|
export function defineProjectionQueryHandler(
|
|
543
544
|
name: string,
|
|
544
545
|
projectionQualifiedName: string,
|
|
545
|
-
options
|
|
546
|
+
options: { access: AccessRule; unsafeAllTenants?: boolean },
|
|
546
547
|
): QueryHandlerDef {
|
|
547
548
|
return {
|
|
548
549
|
name,
|
|
@@ -554,9 +555,9 @@ export function defineProjectionQueryHandler(
|
|
|
554
555
|
handler: async (_query, ctx) =>
|
|
555
556
|
ctx.queryProjection(
|
|
556
557
|
projectionQualifiedName,
|
|
557
|
-
options
|
|
558
|
+
options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
|
|
558
559
|
), // @wrapper-known semantic-alias
|
|
559
|
-
|
|
560
|
+
access: options.access,
|
|
560
561
|
};
|
|
561
562
|
}
|
|
562
563
|
|
|
@@ -588,14 +589,23 @@ export function registerEntityCrud(
|
|
|
588
589
|
}
|
|
589
590
|
const writeOpts = options?.write;
|
|
590
591
|
const readOpts = options?.read;
|
|
592
|
+
const requireAccess = (verb: EntityCrudVerb, access: AccessRule | undefined): AccessRule => {
|
|
593
|
+
if (!access) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`registerEntityCrud("${entityName}", verb: "${verb}"): no access rule resolved. ` +
|
|
596
|
+
`Set write.access/read.access on registerEntityCrud's options, or verbAccess.${verb}.`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return access;
|
|
600
|
+
};
|
|
591
601
|
const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
|
|
592
602
|
...writeOpts,
|
|
593
|
-
access: options?.verbAccess?.[verb] ?? writeOpts?.access,
|
|
603
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
594
604
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
595
605
|
});
|
|
596
606
|
const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
|
|
597
607
|
...readOpts,
|
|
598
|
-
access: options?.verbAccess?.[verb] ?? readOpts?.access,
|
|
608
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
599
609
|
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
600
610
|
});
|
|
601
611
|
|
package/src/engine/factories.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
1
2
|
import type {
|
|
2
3
|
BigIntFieldDef,
|
|
3
4
|
BooleanFieldDef,
|
|
@@ -442,7 +443,7 @@ export function createImagesField(
|
|
|
442
443
|
// Issue um Constraint-Inferenz auf strukturellen Records. Stattdessen
|
|
443
444
|
// validieren wir die Conformance via conditional return type: wenn F
|
|
444
445
|
// nicht FieldsMap-compat ist, kollabiert der Return zu `never`.
|
|
445
|
-
export function createEntity<F>(def: {
|
|
446
|
+
export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
|
|
446
447
|
readonly table?: string;
|
|
447
448
|
readonly fields: F;
|
|
448
449
|
readonly description?: string;
|
|
@@ -460,7 +461,8 @@ export function createEntity<F>(def: {
|
|
|
460
461
|
readonly parentRef?: EntityDefinition["parentRef"];
|
|
461
462
|
readonly retention?: RetentionDef;
|
|
462
463
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
463
|
-
|
|
464
|
+
readonly tenancy?: T;
|
|
465
|
+
}): F extends FieldsMap ? EntityDefinition<F, T> : never {
|
|
464
466
|
return {
|
|
465
467
|
softDelete: false,
|
|
466
468
|
searchWeight: 1,
|
|
@@ -468,5 +470,5 @@ export function createEntity<F>(def: {
|
|
|
468
470
|
// aggregate-ids are UUID. Opt-out with `idType: "serial"` for pre-ES
|
|
469
471
|
// legacy tables (should be rare).
|
|
470
472
|
...def,
|
|
471
|
-
} as F extends FieldsMap ? EntityDefinition<F> : never; // @cast-boundary engine-payload
|
|
473
|
+
} as F extends FieldsMap ? EntityDefinition<F, T> : never; // @cast-boundary engine-payload
|
|
472
474
|
}
|
|
@@ -42,8 +42,12 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
|
|
|
42
42
|
if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
|
|
43
43
|
return { roles: value["roles"] as readonly string[] };
|
|
44
44
|
}
|
|
45
|
+
const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
|
|
45
46
|
if (value["openToAll"] === true) {
|
|
46
|
-
return { openToAll: true };
|
|
47
|
+
return { openToAll: true, ...publicIntake };
|
|
48
|
+
}
|
|
49
|
+
if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
|
|
50
|
+
return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
|
|
47
51
|
}
|
|
48
52
|
return undefined;
|
|
49
53
|
}
|