@cosmicdrift/kumiko-framework 0.259.0 → 0.261.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 +13 -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 +41 -16
- 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__/projection-detail-tabs.test.ts +43 -0
- package/src/engine/__tests__/registry.test.ts +16 -5
- package/src/engine/__tests__/screen.test.ts +20 -0
- package/src/engine/access.ts +2 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -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 +2 -0
- package/src/engine/boot-validator/screens.ts +53 -38
- 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 +4 -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,200 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { createEntity, createTextField } from "../../factories";
|
|
5
|
+
import type { AccessRule } from "../../types";
|
|
6
|
+
import { validateAccessDeclarations } from "../access-declarations";
|
|
7
|
+
|
|
8
|
+
const noteEntity = createEntity({
|
|
9
|
+
table: "fw2855_guard_notes",
|
|
10
|
+
fields: {
|
|
11
|
+
email: createTextField({ personal: "self", find: "none" }),
|
|
12
|
+
title: createTextField({ personal: false, reason: "test_fixture" }),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("validateAccessDeclarations", () => {
|
|
17
|
+
// 1. openToAll reason must be non-empty.
|
|
18
|
+
test("openToAll with an empty reason throws, naming the handler", () => {
|
|
19
|
+
const feature = defineFeature("notes", (r) => {
|
|
20
|
+
r.entity("note", noteEntity);
|
|
21
|
+
r.writeHandler(
|
|
22
|
+
"note:create",
|
|
23
|
+
z.object({ title: z.string() }),
|
|
24
|
+
async () => ({
|
|
25
|
+
isSuccess: true as const,
|
|
26
|
+
data: {},
|
|
27
|
+
}),
|
|
28
|
+
{
|
|
29
|
+
access: { openToAll: { reason: "" } },
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
34
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("openToAll with a non-empty reason boots fine", () => {
|
|
38
|
+
const feature = defineFeature("notes", (r) => {
|
|
39
|
+
r.entity("note", noteEntity);
|
|
40
|
+
r.writeHandler(
|
|
41
|
+
"note:create",
|
|
42
|
+
z.object({ title: z.string() }),
|
|
43
|
+
async () => ({
|
|
44
|
+
isSuccess: true as const,
|
|
45
|
+
data: {},
|
|
46
|
+
}),
|
|
47
|
+
{
|
|
48
|
+
access: { openToAll: { reason: "internal signup form" } },
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// 1b. malformed openToAll (from untyped sources) counts as invalid, not a crash.
|
|
56
|
+
test.each([{ openToAll: false }, { openToAll: {} }])(
|
|
57
|
+
"malformed openToAll %p throws, naming the handler",
|
|
58
|
+
(access) => {
|
|
59
|
+
const feature = defineFeature("notes", (r) => {
|
|
60
|
+
r.entity("note", noteEntity);
|
|
61
|
+
r.writeHandler(
|
|
62
|
+
"note:create",
|
|
63
|
+
z.object({ title: z.string() }),
|
|
64
|
+
async () => ({
|
|
65
|
+
isSuccess: true as const,
|
|
66
|
+
data: {},
|
|
67
|
+
}),
|
|
68
|
+
{
|
|
69
|
+
// @cast-boundary test — simulates JSON/Designer input that doesn't match the static union
|
|
70
|
+
access: access as unknown as AccessRule,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
75
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 2. escapeHatch reason must be non-empty.
|
|
80
|
+
test("escapeHatch with an empty reason throws, naming the handler", () => {
|
|
81
|
+
const feature = defineFeature("notes", (r) => {
|
|
82
|
+
r.entity("note", noteEntity);
|
|
83
|
+
r.writeHandler(
|
|
84
|
+
"note:create",
|
|
85
|
+
z.object({ title: z.string() }),
|
|
86
|
+
async () => ({
|
|
87
|
+
isSuccess: true as const,
|
|
88
|
+
data: {},
|
|
89
|
+
}),
|
|
90
|
+
{
|
|
91
|
+
access: { roles: ["Admin"] },
|
|
92
|
+
escapeHatch: { reason: "" },
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
97
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("escapeHatch with a non-empty reason boots fine", () => {
|
|
101
|
+
const feature = defineFeature("notes", (r) => {
|
|
102
|
+
r.entity("note", noteEntity);
|
|
103
|
+
r.writeHandler(
|
|
104
|
+
"note:create",
|
|
105
|
+
z.object({ title: z.string() }),
|
|
106
|
+
async () => ({
|
|
107
|
+
isSuccess: true as const,
|
|
108
|
+
data: {},
|
|
109
|
+
}),
|
|
110
|
+
{
|
|
111
|
+
access: { roles: ["Admin"] },
|
|
112
|
+
escapeHatch: { reason: "cross-tenant note import job" },
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 3. openToAll write handler accepting a personal-data field needs publicIntake.
|
|
120
|
+
test("openToAll write handler accepting a personal-data field without publicIntake throws", () => {
|
|
121
|
+
const feature = defineFeature("notes", (r) => {
|
|
122
|
+
r.entity("note", noteEntity);
|
|
123
|
+
r.writeHandler(
|
|
124
|
+
"note:create",
|
|
125
|
+
z.object({ email: z.string() }),
|
|
126
|
+
async () => ({
|
|
127
|
+
isSuccess: true as const,
|
|
128
|
+
data: {},
|
|
129
|
+
}),
|
|
130
|
+
{
|
|
131
|
+
access: { openToAll: { reason: "public signup" } },
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
136
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:create"/);
|
|
137
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"email"/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("openToAll write handler accepting a personal-data field WITH publicIntake boots fine", () => {
|
|
141
|
+
const feature = defineFeature("notes", (r) => {
|
|
142
|
+
r.entity("note", noteEntity);
|
|
143
|
+
r.writeHandler(
|
|
144
|
+
"note:create",
|
|
145
|
+
z.object({ email: z.string() }),
|
|
146
|
+
async () => ({
|
|
147
|
+
isSuccess: true as const,
|
|
148
|
+
data: {},
|
|
149
|
+
}),
|
|
150
|
+
{
|
|
151
|
+
access: { openToAll: { reason: "public signup" }, publicIntake: true },
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 4. publicIntake is only meaningful on a write handler.
|
|
159
|
+
test("publicIntake on a query handler throws", () => {
|
|
160
|
+
const feature = defineFeature("notes", (r) => {
|
|
161
|
+
r.entity("note", noteEntity);
|
|
162
|
+
r.queryHandler("note:list", z.object({}), async () => [], {
|
|
163
|
+
access: { openToAll: { reason: "public listing" }, publicIntake: true },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
167
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:list"/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("query handler without publicIntake boots fine", () => {
|
|
171
|
+
const feature = defineFeature("notes", (r) => {
|
|
172
|
+
r.entity("note", noteEntity);
|
|
173
|
+
r.queryHandler("note:list", z.object({}), async () => [], {
|
|
174
|
+
access: { openToAll: { reason: "public listing" } },
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("publicIntake on a stream handler throws", () => {
|
|
181
|
+
const feature = defineFeature("notes", (r) => {
|
|
182
|
+
r.entity("note", noteEntity);
|
|
183
|
+
r.streamHandler("note:watch", z.object({}), async function* () {}, {
|
|
184
|
+
access: { openToAll: { reason: "public stream" }, publicIntake: true },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/Feature notes/);
|
|
188
|
+
expect(() => validateAccessDeclarations(feature)).toThrow(/"note:watch"/);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("stream handler without publicIntake boots fine", () => {
|
|
192
|
+
const feature = defineFeature("notes", (r) => {
|
|
193
|
+
r.entity("note", noteEntity);
|
|
194
|
+
r.streamHandler("note:watch", z.object({}), async function* () {}, {
|
|
195
|
+
access: { openToAll: { reason: "public stream" } },
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
expect(() => validateAccessDeclarations(feature)).not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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";
|
|
@@ -187,6 +188,7 @@ export function validateBoot(
|
|
|
187
188
|
validateExtendSchemaCollisions(feature);
|
|
188
189
|
validateDerivedFieldCollisions(feature);
|
|
189
190
|
validateHandlerAccess(feature);
|
|
191
|
+
validateAccessDeclarations(feature);
|
|
190
192
|
validateLocatedTimestamps(feature);
|
|
191
193
|
validateEntityIndexes(feature);
|
|
192
194
|
validateConfigKeyBounds(feature);
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
19
19
|
import { metricField } from "../types";
|
|
20
20
|
import type {
|
|
21
|
+
ActionFormRedirect,
|
|
21
22
|
ActionFormScreenDefinition,
|
|
22
23
|
DashboardCustomPanel,
|
|
23
24
|
DashboardFilterDefinition,
|
|
@@ -631,6 +632,42 @@ function validateFormLayoutSections(
|
|
|
631
632
|
}
|
|
632
633
|
}
|
|
633
634
|
|
|
635
|
+
// redirect is either a short screen id (same-feature, e.g. "item-list") or
|
|
636
|
+
// a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
637
|
+
// renderer strips the latter to the short id (lastSegment) when
|
|
638
|
+
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
639
|
+
// form (fw#2670) carries the same target under `screen` plus the payload
|
|
640
|
+
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
641
|
+
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
642
|
+
function validateRedirectTarget(
|
|
643
|
+
feature: FeatureDefinition,
|
|
644
|
+
screenId: string,
|
|
645
|
+
screenKind: "actionForm" | "secretMint" | "entityEdit",
|
|
646
|
+
redirect: string | ActionFormRedirect,
|
|
647
|
+
allScreenQns: ReadonlySet<string>,
|
|
648
|
+
): void {
|
|
649
|
+
const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
|
|
650
|
+
if (
|
|
651
|
+
typeof redirect !== "string" &&
|
|
652
|
+
(typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")
|
|
653
|
+
) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom is empty or not a string — ` +
|
|
656
|
+
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
657
|
+
`redirect form to navigate with the handler's own "id".`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
validateScreenNavTarget(
|
|
661
|
+
feature.name,
|
|
662
|
+
screenId,
|
|
663
|
+
screenKind,
|
|
664
|
+
"redirect",
|
|
665
|
+
redirectTarget,
|
|
666
|
+
allScreenQns,
|
|
667
|
+
feature.screens,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
634
671
|
function validateInlineFormNavTargets(
|
|
635
672
|
feature: FeatureDefinition,
|
|
636
673
|
screenId: string,
|
|
@@ -639,33 +676,7 @@ function validateInlineFormNavTargets(
|
|
|
639
676
|
allScreenQns: ReadonlySet<string>,
|
|
640
677
|
): void {
|
|
641
678
|
if (screen.redirect !== undefined) {
|
|
642
|
-
|
|
643
|
-
// or a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
644
|
-
// renderer strips the latter to the short id (lastSegment) when
|
|
645
|
-
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
646
|
-
// form (fw#2670, actionForm only) carries the same target under `screen`
|
|
647
|
-
// plus the payload field `idFrom`.
|
|
648
|
-
const redirectTarget =
|
|
649
|
-
typeof screen.redirect === "string" ? screen.redirect : screen.redirect.screen;
|
|
650
|
-
if (
|
|
651
|
-
typeof screen.redirect !== "string" &&
|
|
652
|
-
(typeof screen.redirect.idFrom !== "string" || screen.redirect.idFrom.trim() === "")
|
|
653
|
-
) {
|
|
654
|
-
throw new Error(
|
|
655
|
-
`[Feature ${feature.name}] Screen "${screenId}" (${kind}) redirect.idFrom is empty or not a string — ` +
|
|
656
|
-
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
657
|
-
`redirect form to navigate with the handler's own "id".`,
|
|
658
|
-
);
|
|
659
|
-
}
|
|
660
|
-
validateScreenNavTarget(
|
|
661
|
-
feature.name,
|
|
662
|
-
screenId,
|
|
663
|
-
kind,
|
|
664
|
-
"redirect",
|
|
665
|
-
redirectTarget,
|
|
666
|
-
allScreenQns,
|
|
667
|
-
feature.screens,
|
|
668
|
-
);
|
|
679
|
+
validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
|
|
669
680
|
}
|
|
670
681
|
if (typeof screen.cancelTarget === "string") {
|
|
671
682
|
// Same rule as redirect — `false` (no Cancel button) needs no validation.
|
|
@@ -986,6 +997,20 @@ export function validateScreens(
|
|
|
986
997
|
`there is no fallback to the raw column name.`,
|
|
987
998
|
);
|
|
988
999
|
}
|
|
1000
|
+
const navigate = typeof metric === "string" ? undefined : metric.navigate;
|
|
1001
|
+
// Cross-screen navigate+tab isn't checked here — the target screen
|
|
1002
|
+
// validates its own section ids when the loop reaches it.
|
|
1003
|
+
if (
|
|
1004
|
+
navigate?.tab !== undefined &&
|
|
1005
|
+
navigate.screen === undefined &&
|
|
1006
|
+
navigate.entity === undefined &&
|
|
1007
|
+
!screen.layout.sections.some((section) => section.id === navigate.tab)
|
|
1008
|
+
) {
|
|
1009
|
+
throw new Error(
|
|
1010
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
|
|
1011
|
+
`navigates to tab "${navigate.tab}", which is not a section id on this screen.`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
989
1014
|
}
|
|
990
1015
|
}
|
|
991
1016
|
for (const section of screen.layout.sections) {
|
|
@@ -1785,17 +1810,7 @@ export function validateScreens(
|
|
|
1785
1810
|
}
|
|
1786
1811
|
}
|
|
1787
1812
|
if (screen.redirect !== undefined) {
|
|
1788
|
-
|
|
1789
|
-
// or a fully-qualified cross-feature QN (#1946).
|
|
1790
|
-
validateScreenNavTarget(
|
|
1791
|
-
feature.name,
|
|
1792
|
-
screenId,
|
|
1793
|
-
"entityEdit",
|
|
1794
|
-
"redirect",
|
|
1795
|
-
screen.redirect,
|
|
1796
|
-
allScreenQns,
|
|
1797
|
-
feature.screens,
|
|
1798
|
-
);
|
|
1813
|
+
validateRedirectTarget(feature, screenId, "entityEdit", screen.redirect, allScreenQns);
|
|
1799
1814
|
}
|
|
1800
1815
|
}
|
|
1801
1816
|
}
|
|
@@ -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
|
}
|