@jskit-ai/kernel 0.1.22 → 0.1.24
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 +1 -1
- package/server/http/lib/kernel.test.js +14 -4
- package/server/http/lib/requestActionExecutor.js +7 -0
- package/server/runtime/fastifyBootstrap.js +5 -1
- package/server/runtime/fastifyBootstrap.test.js +22 -0
- package/server/runtime/serviceAuthorization.test.js +2 -2
- package/server/support/shellOutlets.js +150 -18
- package/server/support/shellOutlets.test.js +46 -0
- package/shared/actions/executionContext.test.js +4 -4
- package/shared/support/crudLookup.js +61 -3
- package/shared/support/crudLookup.test.js +141 -1
- package/shared/support/normalize.js +38 -1
- package/shared/support/normalize.test.js +37 -0
- package/shared/validators/cursorPaginationQueryValidator.js +2 -1
- package/shared/validators/cursorPaginationQueryValidator.test.js +9 -7
- package/shared/validators/htmlTimeSchemas.js +13 -0
- package/shared/validators/index.js +4 -0
package/package.json
CHANGED
|
@@ -96,17 +96,26 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
96
96
|
|
|
97
97
|
registerActionContextContributor(app, "test.auth.actionContextContributor", () => ({
|
|
98
98
|
contributorId: "test.auth",
|
|
99
|
-
contribute({ request }) {
|
|
99
|
+
contribute({ request, definition }) {
|
|
100
100
|
return {
|
|
101
101
|
actor: request?.user || null,
|
|
102
102
|
permissions: Array.isArray(request?.permissions) ? request.permissions.slice() : [],
|
|
103
103
|
workspace: request?.workspace || null,
|
|
104
|
-
membership: request?.membership || null
|
|
104
|
+
membership: request?.membership || null,
|
|
105
|
+
requestMeta: {
|
|
106
|
+
definitionId: definition?.id || ""
|
|
107
|
+
}
|
|
105
108
|
};
|
|
106
109
|
}
|
|
107
110
|
}));
|
|
108
111
|
|
|
109
112
|
app.instance("actionExecutor", {
|
|
113
|
+
getDefinition(actionId) {
|
|
114
|
+
return {
|
|
115
|
+
id: actionId,
|
|
116
|
+
surfaces: ["coffie"]
|
|
117
|
+
};
|
|
118
|
+
},
|
|
110
119
|
async execute(payload) {
|
|
111
120
|
observed.push(payload);
|
|
112
121
|
return {
|
|
@@ -134,7 +143,7 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
134
143
|
slug: "main"
|
|
135
144
|
};
|
|
136
145
|
request.membership = {
|
|
137
|
-
|
|
146
|
+
roleSid: "owner"
|
|
138
147
|
};
|
|
139
148
|
}
|
|
140
149
|
],
|
|
@@ -191,10 +200,11 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
191
200
|
assert.equal(observed[0].context.actor?.id, 7);
|
|
192
201
|
assert.deepEqual(observed[0].context.permissions, ["settings.read"]);
|
|
193
202
|
assert.equal(observed[0].context.workspace?.id, 10);
|
|
194
|
-
assert.equal(observed[0].context.membership?.
|
|
203
|
+
assert.equal(observed[0].context.membership?.roleSid, "owner");
|
|
195
204
|
assert.equal(observed[0].context.surface, "coffie");
|
|
196
205
|
assert.equal(observed[0].context.channel, "api");
|
|
197
206
|
assert.equal(observed[0].context.requestMeta.commandId, "cmd-1");
|
|
207
|
+
assert.equal(observed[0].context.requestMeta.definitionId, "settings.read");
|
|
198
208
|
assert.equal(observed[0].context.requestMeta.request, request);
|
|
199
209
|
|
|
200
210
|
assert.equal(observed[1].actionId, "settings.override");
|
|
@@ -83,6 +83,7 @@ async function enrichActionExecutionContext({
|
|
|
83
83
|
request = null,
|
|
84
84
|
actionId = "",
|
|
85
85
|
version = null,
|
|
86
|
+
definition = null,
|
|
86
87
|
input = {},
|
|
87
88
|
deps = {},
|
|
88
89
|
channel = "api",
|
|
@@ -108,6 +109,7 @@ async function enrichActionExecutionContext({
|
|
|
108
109
|
request,
|
|
109
110
|
actionId: normalizedActionId,
|
|
110
111
|
version: version == null ? null : version,
|
|
112
|
+
definition,
|
|
111
113
|
input: normalizedInput,
|
|
112
114
|
deps: normalizedDeps,
|
|
113
115
|
channel: normalizedChannel,
|
|
@@ -201,6 +203,10 @@ function attachRequestActionExecutor({
|
|
|
201
203
|
if (!actionExecutor || typeof actionExecutor.execute !== "function") {
|
|
202
204
|
throw new RouteRegistrationError(`"${normalizedActionExecutorToken}" must provide execute().`);
|
|
203
205
|
}
|
|
206
|
+
const definition =
|
|
207
|
+
typeof actionExecutor.getDefinition === "function"
|
|
208
|
+
? actionExecutor.getDefinition(source.actionId, source.version == null ? null : source.version)
|
|
209
|
+
: null;
|
|
204
210
|
|
|
205
211
|
const baseContext = buildActionExecutionContext({
|
|
206
212
|
request,
|
|
@@ -213,6 +219,7 @@ function attachRequestActionExecutor({
|
|
|
213
219
|
request,
|
|
214
220
|
actionId: source.actionId,
|
|
215
221
|
version: source.version == null ? null : source.version,
|
|
222
|
+
definition,
|
|
216
223
|
input: normalizedInput,
|
|
217
224
|
deps: normalizedDeps,
|
|
218
225
|
channel: normalizedChannel,
|
|
@@ -144,6 +144,10 @@ function registerApiErrorHandler(
|
|
|
144
144
|
const recordDbError = typeof onRecordDbError === "function" ? onRecordDbError : () => {};
|
|
145
145
|
const captureServerError = typeof onCaptureServerError === "function" ? onCaptureServerError : () => {};
|
|
146
146
|
|
|
147
|
+
function shouldExposeAppErrorDetails(errorCode = "") {
|
|
148
|
+
return String(errorCode || "").trim() !== "ACTION_PERMISSION_DENIED";
|
|
149
|
+
}
|
|
150
|
+
|
|
147
151
|
app.setErrorHandler((error, request, reply) => {
|
|
148
152
|
const normalizedErrorCode = String(error?.code || "").trim();
|
|
149
153
|
const isCsrfErrorCode = normalizedErrorCode.startsWith("FST_CSRF_");
|
|
@@ -177,7 +181,7 @@ function registerApiErrorHandler(
|
|
|
177
181
|
error: error.message,
|
|
178
182
|
code: appErrorCode
|
|
179
183
|
};
|
|
180
|
-
if (error.details) {
|
|
184
|
+
if (error.details && shouldExposeAppErrorDetails(appErrorCode)) {
|
|
181
185
|
payload.details = error.details;
|
|
182
186
|
if (error.details.fieldErrors) {
|
|
183
187
|
payload.fieldErrors = error.details.fieldErrors;
|
|
@@ -103,6 +103,28 @@ test("registerApiErrorHandler falls back to app_error code for AppError without
|
|
|
103
103
|
assert.equal(reply.payload.code, "app_error");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("registerApiErrorHandler hides internal permission details for action permission denials", () => {
|
|
107
|
+
const fastify = createFastifyStub();
|
|
108
|
+
registerApiErrorHandler(fastify, { isAppError });
|
|
109
|
+
|
|
110
|
+
const reply = createReplyStub();
|
|
111
|
+
const error = new AppError(403, "Forbidden.", {
|
|
112
|
+
code: "ACTION_PERMISSION_DENIED",
|
|
113
|
+
details: {
|
|
114
|
+
actionId: "crud.breeds.list",
|
|
115
|
+
permission: "crud.breeds.list"
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
fastify.errorHandler(error, {}, reply);
|
|
120
|
+
|
|
121
|
+
assert.equal(reply.statusCode, 403);
|
|
122
|
+
assert.deepEqual(reply.payload, {
|
|
123
|
+
error: "Forbidden.",
|
|
124
|
+
code: "ACTION_PERMISSION_DENIED"
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
106
128
|
test("registerApiErrorHandler includes internal_server_error code for unhandled 500 errors", () => {
|
|
107
129
|
const fastify = createFastifyStub();
|
|
108
130
|
registerApiErrorHandler(fastify, { isAppError });
|
|
@@ -88,12 +88,12 @@ test("requireAuth allows namespace wildcard permissions", () => {
|
|
|
88
88
|
{
|
|
89
89
|
context: {
|
|
90
90
|
actor: { id: 1 },
|
|
91
|
-
permissions: ["
|
|
91
|
+
permissions: ["crud.contacts.*"]
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
require: "all",
|
|
96
|
-
permissions: ["
|
|
96
|
+
permissions: ["crud.contacts.update"]
|
|
97
97
|
}
|
|
98
98
|
)
|
|
99
99
|
);
|
|
@@ -12,6 +12,125 @@ import {
|
|
|
12
12
|
|
|
13
13
|
const VUE_DISCOVERY_IGNORED_ERROR_CODES = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"]);
|
|
14
14
|
const LOCK_FILE_RELATIVE_PATH = ".jskit/lock.json";
|
|
15
|
+
const ROUTE_TAG_PATTERN = /<route\b([^>]*)>([\s\S]*?)<\/route>/g;
|
|
16
|
+
const ATTRIBUTE_PATTERN = /([:@]?[A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
17
|
+
|
|
18
|
+
function parseTagAttributes(attributesSource = "") {
|
|
19
|
+
const attributes = {};
|
|
20
|
+
const source = String(attributesSource || "");
|
|
21
|
+
for (const match of source.matchAll(ATTRIBUTE_PATTERN)) {
|
|
22
|
+
const attributeName = normalizeText(match[1]);
|
|
23
|
+
if (!attributeName) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasValue = match[2] != null || match[3] != null;
|
|
28
|
+
const attributeValue = hasValue ? String(match[2] ?? match[3] ?? "") : true;
|
|
29
|
+
attributes[attributeName] = attributeValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return attributes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isDefaultEnabled(value) {
|
|
36
|
+
if (value === true) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (value === false || value == null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const normalized = normalizeText(value).toLowerCase();
|
|
44
|
+
if (!normalized) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeAppRouteOutletTarget({
|
|
52
|
+
outlet = {},
|
|
53
|
+
sourcePath = ""
|
|
54
|
+
} = {}) {
|
|
55
|
+
const outletRecord = normalizeObject(outlet);
|
|
56
|
+
const outletTargetId = normalizeShellOutletTargetId(
|
|
57
|
+
`${normalizeText(outletRecord.host)}:${normalizeText(outletRecord.position)}`
|
|
58
|
+
);
|
|
59
|
+
if (!outletTargetId) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const separatorIndex = outletTargetId.indexOf(":");
|
|
64
|
+
const host = outletTargetId.slice(0, separatorIndex);
|
|
65
|
+
const position = outletTargetId.slice(separatorIndex + 1);
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
id: outletTargetId,
|
|
68
|
+
host,
|
|
69
|
+
position,
|
|
70
|
+
default: isDefaultEnabled(outletRecord.default),
|
|
71
|
+
sourcePath
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function discoverRouteMetaOutletTargetsFromVueSource(source = "", { context = "shell layout" } = {}) {
|
|
76
|
+
const sourceText = String(source || "");
|
|
77
|
+
const resolvedContext = normalizeText(context) || "shell layout";
|
|
78
|
+
const targetById = new Map();
|
|
79
|
+
let defaultTargetId = "";
|
|
80
|
+
|
|
81
|
+
for (const routeTagMatch of sourceText.matchAll(ROUTE_TAG_PATTERN)) {
|
|
82
|
+
const routeTagAttributes = parseTagAttributes(routeTagMatch[1]);
|
|
83
|
+
const routeTagLanguage = normalizeText(routeTagAttributes.lang).toLowerCase();
|
|
84
|
+
if (routeTagLanguage !== "json") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const routeMetaSource = String(routeTagMatch[2] || "").trim();
|
|
89
|
+
if (!routeMetaSource) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let routeMetaRecord = null;
|
|
94
|
+
try {
|
|
95
|
+
routeMetaRecord = JSON.parse(routeMetaSource);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`${resolvedContext} contains invalid <route lang="json"> block: ${String(error?.message || error || "unknown error")}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const routeMeta = normalizeObject(normalizeObject(routeMetaRecord).meta);
|
|
103
|
+
const jskitMeta = normalizeObject(routeMeta.jskit);
|
|
104
|
+
const placementsMeta = normalizeObject(jskitMeta.placements);
|
|
105
|
+
const outlets = Array.isArray(placementsMeta.outlets) ? placementsMeta.outlets : [];
|
|
106
|
+
for (const outlet of outlets) {
|
|
107
|
+
const normalizedTarget = normalizeAppRouteOutletTarget({
|
|
108
|
+
outlet,
|
|
109
|
+
sourcePath: resolvedContext
|
|
110
|
+
});
|
|
111
|
+
if (!normalizedTarget) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (targetById.has(normalizedTarget.id)) {
|
|
115
|
+
throw new Error(`${resolvedContext} contains duplicate route meta placement target "${normalizedTarget.id}".`);
|
|
116
|
+
}
|
|
117
|
+
if (normalizedTarget.default === true) {
|
|
118
|
+
if (defaultTargetId && defaultTargetId !== normalizedTarget.id) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${resolvedContext} defines multiple default route meta placement targets: "${defaultTargetId}" and "${normalizedTarget.id}".`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
defaultTargetId = normalizedTarget.id;
|
|
124
|
+
}
|
|
125
|
+
targetById.set(normalizedTarget.id, normalizedTarget);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
targets: Object.freeze([...targetById.values()]),
|
|
131
|
+
defaultTargetId
|
|
132
|
+
});
|
|
133
|
+
}
|
|
15
134
|
|
|
16
135
|
async function collectVueFilePaths(rootDirectoryPath) {
|
|
17
136
|
const absoluteRoot = path.resolve(String(rootDirectoryPath || ""));
|
|
@@ -154,15 +273,27 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
154
273
|
for (const absoluteFilePath of vueFiles) {
|
|
155
274
|
const relativePath = toPosixPath(path.relative(resolvedAppRoot, absoluteFilePath));
|
|
156
275
|
const source = await readFile(absoluteFilePath, "utf8");
|
|
157
|
-
if (!source.includes("<ShellOutlet")) {
|
|
276
|
+
if (!source.includes("<ShellOutlet") && !source.includes("<route")) {
|
|
158
277
|
continue;
|
|
159
278
|
}
|
|
160
279
|
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
280
|
+
const discoveredShellOutlets = source.includes("<ShellOutlet")
|
|
281
|
+
? discoverShellOutletTargetsFromVueSource(source, {
|
|
282
|
+
context: relativePath
|
|
283
|
+
})
|
|
284
|
+
: { targets: [], defaultTargetId: "" };
|
|
285
|
+
const discoveredRouteMetaOutlets = source.includes("<route")
|
|
286
|
+
? discoverRouteMetaOutletTargetsFromVueSource(source, {
|
|
287
|
+
context: relativePath
|
|
288
|
+
})
|
|
289
|
+
: { targets: [], defaultTargetId: "" };
|
|
290
|
+
const discoveredTargets = [
|
|
291
|
+
...(Array.isArray(discoveredShellOutlets.targets) ? discoveredShellOutlets.targets : []),
|
|
292
|
+
...(Array.isArray(discoveredRouteMetaOutlets.targets)
|
|
293
|
+
? discoveredRouteMetaOutlets.targets
|
|
294
|
+
: [])
|
|
295
|
+
];
|
|
296
|
+
for (const target of discoveredTargets) {
|
|
166
297
|
if (!targetById.has(target.id)) {
|
|
167
298
|
targetById.set(
|
|
168
299
|
target.id,
|
|
@@ -174,20 +305,21 @@ async function discoverShellOutletTargetsFromApp({ appRoot, sourceRoot = "src" }
|
|
|
174
305
|
}
|
|
175
306
|
}
|
|
176
307
|
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
308
|
+
const discoveredDefaultTargetIds = [
|
|
309
|
+
normalizeShellOutletTargetId(discoveredShellOutlets.defaultTargetId),
|
|
310
|
+
normalizeShellOutletTargetId(discoveredRouteMetaOutlets.defaultTargetId)
|
|
311
|
+
].filter(Boolean);
|
|
312
|
+
for (const discoveredDefaultTargetId of discoveredDefaultTargetIds) {
|
|
313
|
+
if (defaultTargetId && discoveredDefaultTargetId !== defaultTargetId) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`Multiple default ShellOutlet targets found in app source: "${defaultTargetId}" (${defaultTargetSource}) and ` +
|
|
316
|
+
`"${discoveredDefaultTargetId}" (${relativePath}).`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
181
319
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`Multiple default ShellOutlet targets found in app source: "${defaultTargetId}" (${defaultTargetSource}) and ` +
|
|
185
|
-
`"${discoveredDefaultTargetId}" (${relativePath}).`
|
|
186
|
-
);
|
|
320
|
+
defaultTargetId = discoveredDefaultTargetId;
|
|
321
|
+
defaultTargetSource = relativePath;
|
|
187
322
|
}
|
|
188
|
-
|
|
189
|
-
defaultTargetId = discoveredDefaultTargetId;
|
|
190
|
-
defaultTargetSource = relativePath;
|
|
191
323
|
}
|
|
192
324
|
|
|
193
325
|
const packageTargets = await collectInstalledPackageOutletTargets(resolvedAppRoot);
|
|
@@ -174,6 +174,52 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
test("discoverShellOutletTargetsFromApp discovers route meta placement outlets", async () => {
|
|
178
|
+
await withTempApp(async (appRoot) => {
|
|
179
|
+
await writeFileInApp(
|
|
180
|
+
appRoot,
|
|
181
|
+
"src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue",
|
|
182
|
+
`<template><section /></template>
|
|
183
|
+
|
|
184
|
+
<route lang="json">
|
|
185
|
+
{
|
|
186
|
+
"meta": {
|
|
187
|
+
"jskit": {
|
|
188
|
+
"placements": {
|
|
189
|
+
"outlets": [
|
|
190
|
+
{
|
|
191
|
+
"host": "contact-tools",
|
|
192
|
+
"position": "sub-pages"
|
|
193
|
+
}
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
</route>
|
|
200
|
+
`
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
|
|
204
|
+
assert.deepEqual(discovered.targets, [
|
|
205
|
+
{
|
|
206
|
+
id: "contact-tools:sub-pages",
|
|
207
|
+
host: "contact-tools",
|
|
208
|
+
position: "sub-pages",
|
|
209
|
+
default: false,
|
|
210
|
+
sourcePath: "src/pages/w/[workspaceSlug]/admin/contacts/[contactId]/contact-tools.vue"
|
|
211
|
+
}
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const target = await resolveShellOutletPlacementTargetFromApp({
|
|
215
|
+
appRoot,
|
|
216
|
+
placement: "contact-tools:sub-pages",
|
|
217
|
+
context: "ui-generator"
|
|
218
|
+
});
|
|
219
|
+
assert.equal(target.id, "contact-tools:sub-pages");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
177
223
|
test("resolveShellOutletPlacementTargetFromApp supports explicit placement override", async () => {
|
|
178
224
|
await withTempApp(async (appRoot) => {
|
|
179
225
|
await writeFileInApp(
|
|
@@ -42,11 +42,11 @@ test("normalizeExecutionContext keeps actor payload generic", () => {
|
|
|
42
42
|
actor: {
|
|
43
43
|
id: "user_1",
|
|
44
44
|
email: "UPPER@EXAMPLE.COM",
|
|
45
|
-
|
|
45
|
+
roleSid: "OWNER",
|
|
46
46
|
customFlag: true
|
|
47
47
|
},
|
|
48
48
|
membership: {
|
|
49
|
-
|
|
49
|
+
roleSid: "OWNER",
|
|
50
50
|
status: "ACTIVE",
|
|
51
51
|
extra: "x"
|
|
52
52
|
}
|
|
@@ -55,11 +55,11 @@ test("normalizeExecutionContext keeps actor payload generic", () => {
|
|
|
55
55
|
assert.deepEqual(context.actor, {
|
|
56
56
|
id: "user_1",
|
|
57
57
|
email: "UPPER@EXAMPLE.COM",
|
|
58
|
-
|
|
58
|
+
roleSid: "OWNER",
|
|
59
59
|
customFlag: true
|
|
60
60
|
});
|
|
61
61
|
assert.deepEqual(context.membership, {
|
|
62
|
-
|
|
62
|
+
roleSid: "OWNER",
|
|
63
63
|
status: "ACTIVE",
|
|
64
64
|
extra: "x"
|
|
65
65
|
});
|
|
@@ -64,7 +64,7 @@ function resolveCrudLookupContainerKey(resource = {}, options = {}) {
|
|
|
64
64
|
return normalizeCrudLookupContainerKey(lookup?.containerKey, options);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function
|
|
67
|
+
function resolveCrudLookupFieldEntries(resource = {}, { allowKeys = [] } = {}) {
|
|
68
68
|
const source = resource && typeof resource === "object" && !Array.isArray(resource) ? resource : {};
|
|
69
69
|
const entries = Array.isArray(source.fieldMeta) ? source.fieldMeta : [];
|
|
70
70
|
const allowedKeySet = new Set(
|
|
@@ -97,12 +97,67 @@ function resolveCrudLookupFieldKeys(resource = {}, { allowKeys = [] } = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
seenKeys.add(key);
|
|
100
|
-
keys.push(
|
|
100
|
+
keys.push(
|
|
101
|
+
Object.freeze({
|
|
102
|
+
key,
|
|
103
|
+
parentRouteParamKey: normalizeText(entry.parentRouteParamKey)
|
|
104
|
+
})
|
|
105
|
+
);
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
return Object.freeze(keys);
|
|
104
109
|
}
|
|
105
110
|
|
|
111
|
+
function resolveCrudLookupCreateSchemaKeys(resource = {}) {
|
|
112
|
+
const createSchemaProperties = resource?.operations?.create?.bodyValidator?.schema?.properties;
|
|
113
|
+
if (!createSchemaProperties || typeof createSchemaProperties !== "object" || Array.isArray(createSchemaProperties)) {
|
|
114
|
+
return Object.freeze([]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return Object.freeze(Object.keys(createSchemaProperties));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function resolveCrudLookupFieldKeys(resource = {}, { allowKeys = [] } = {}) {
|
|
121
|
+
return Object.freeze(
|
|
122
|
+
resolveCrudLookupFieldEntries(resource, { allowKeys })
|
|
123
|
+
.map(({ key }) => key)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveCrudParentFilterKeys(resource = {}) {
|
|
128
|
+
return resolveCrudLookupFieldKeys(resource, {
|
|
129
|
+
allowKeys: resolveCrudLookupCreateSchemaKeys(resource)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveCrudLookupFieldKeyFromRouteParam(resource = {}, routeParamKey = "", { allowKeys = [] } = {}) {
|
|
134
|
+
const normalizedRouteParamKey = normalizeText(routeParamKey);
|
|
135
|
+
if (!normalizedRouteParamKey) {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entries = resolveCrudLookupFieldEntries(resource, { allowKeys });
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (entry.key === normalizedRouteParamKey) {
|
|
142
|
+
return entry.key;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (entry.parentRouteParamKey === normalizedRouteParamKey) {
|
|
148
|
+
return entry.key;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function resolveCrudParentFilterFieldKeyFromRouteParam(resource = {}, routeParamKey = "") {
|
|
156
|
+
return resolveCrudLookupFieldKeyFromRouteParam(resource, routeParamKey, {
|
|
157
|
+
allowKeys: resolveCrudLookupCreateSchemaKeys(resource)
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
106
161
|
export {
|
|
107
162
|
DEFAULT_CRUD_LOOKUP_CONTAINER_KEY,
|
|
108
163
|
normalizeCrudLookupApiPath,
|
|
@@ -110,5 +165,8 @@ export {
|
|
|
110
165
|
resolveCrudLookupApiPathFromNamespace,
|
|
111
166
|
normalizeCrudLookupContainerKey,
|
|
112
167
|
resolveCrudLookupContainerKey,
|
|
113
|
-
resolveCrudLookupFieldKeys
|
|
168
|
+
resolveCrudLookupFieldKeys,
|
|
169
|
+
resolveCrudParentFilterKeys,
|
|
170
|
+
resolveCrudLookupFieldKeyFromRouteParam,
|
|
171
|
+
resolveCrudParentFilterFieldKeyFromRouteParam
|
|
114
172
|
};
|
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
resolveCrudLookupApiPathFromNamespace,
|
|
8
8
|
normalizeCrudLookupContainerKey,
|
|
9
9
|
resolveCrudLookupContainerKey,
|
|
10
|
-
|
|
10
|
+
resolveCrudParentFilterKeys,
|
|
11
|
+
resolveCrudLookupFieldKeys,
|
|
12
|
+
resolveCrudLookupFieldKeyFromRouteParam,
|
|
13
|
+
resolveCrudParentFilterFieldKeyFromRouteParam
|
|
11
14
|
} from "./crudLookup.js";
|
|
12
15
|
|
|
13
16
|
test("normalizeCrudLookupApiPath normalizes and rejects root", () => {
|
|
@@ -69,6 +72,7 @@ test("resolveCrudLookupFieldKeys returns lookup field keys with optional allow-l
|
|
|
69
72
|
},
|
|
70
73
|
{
|
|
71
74
|
key: "vetId",
|
|
75
|
+
parentRouteParamKey: "primaryVetId",
|
|
72
76
|
relation: {
|
|
73
77
|
kind: "lookup",
|
|
74
78
|
apiPath: "/vets",
|
|
@@ -81,3 +85,139 @@ test("resolveCrudLookupFieldKeys returns lookup field keys with optional allow-l
|
|
|
81
85
|
assert.deepEqual(resolveCrudLookupFieldKeys(resource), ["contactId", "vetId"]);
|
|
82
86
|
assert.deepEqual(resolveCrudLookupFieldKeys(resource, { allowKeys: ["vetId", "missing"] }), ["vetId"]);
|
|
83
87
|
});
|
|
88
|
+
|
|
89
|
+
test("resolveCrudLookupFieldKeyFromRouteParam matches parent route param aliases to canonical lookup field keys", () => {
|
|
90
|
+
const resource = {
|
|
91
|
+
fieldMeta: [
|
|
92
|
+
{
|
|
93
|
+
key: "staffContactId",
|
|
94
|
+
parentRouteParamKey: "contactId",
|
|
95
|
+
relation: {
|
|
96
|
+
kind: "lookup",
|
|
97
|
+
apiPath: "/contacts",
|
|
98
|
+
valueKey: "id"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: "serviceId",
|
|
103
|
+
relation: {
|
|
104
|
+
kind: "lookup",
|
|
105
|
+
apiPath: "/services",
|
|
106
|
+
valueKey: "id"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "contactId"), "staffContactId");
|
|
113
|
+
assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "staffContactId"), "staffContactId");
|
|
114
|
+
assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "serviceId"), "serviceId");
|
|
115
|
+
assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "unknown"), "");
|
|
116
|
+
assert.equal(
|
|
117
|
+
resolveCrudLookupFieldKeyFromRouteParam(resource, "contactId", { allowKeys: ["serviceId"] }),
|
|
118
|
+
""
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("resolveCrudLookupFieldKeyFromRouteParam prefers exact field keys before alias matches", () => {
|
|
123
|
+
const resource = {
|
|
124
|
+
fieldMeta: [
|
|
125
|
+
{
|
|
126
|
+
key: "staffContactId",
|
|
127
|
+
parentRouteParamKey: "contactId",
|
|
128
|
+
relation: {
|
|
129
|
+
kind: "lookup",
|
|
130
|
+
apiPath: "/contacts",
|
|
131
|
+
valueKey: "id"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "contactId",
|
|
136
|
+
relation: {
|
|
137
|
+
kind: "lookup",
|
|
138
|
+
apiPath: "/contacts",
|
|
139
|
+
valueKey: "id"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
assert.equal(resolveCrudLookupFieldKeyFromRouteParam(resource, "contactId"), "contactId");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("resolveCrudParentFilterKeys keeps only lookup keys that are writable through create", () => {
|
|
149
|
+
const resource = {
|
|
150
|
+
operations: {
|
|
151
|
+
create: {
|
|
152
|
+
bodyValidator: {
|
|
153
|
+
schema: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
serviceId: { type: "integer" }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
fieldMeta: [
|
|
163
|
+
{
|
|
164
|
+
key: "staffContactId",
|
|
165
|
+
parentRouteParamKey: "contactId",
|
|
166
|
+
relation: {
|
|
167
|
+
kind: "lookup",
|
|
168
|
+
apiPath: "/contacts",
|
|
169
|
+
valueKey: "id"
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
key: "serviceId",
|
|
174
|
+
relation: {
|
|
175
|
+
kind: "lookup",
|
|
176
|
+
apiPath: "/services",
|
|
177
|
+
valueKey: "id"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(resolveCrudParentFilterKeys(resource), ["serviceId"]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("resolveCrudParentFilterFieldKeyFromRouteParam uses the same allowed keys as server parent filters", () => {
|
|
187
|
+
const resource = {
|
|
188
|
+
operations: {
|
|
189
|
+
create: {
|
|
190
|
+
bodyValidator: {
|
|
191
|
+
schema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
serviceId: { type: "integer" }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
fieldMeta: [
|
|
201
|
+
{
|
|
202
|
+
key: "staffContactId",
|
|
203
|
+
parentRouteParamKey: "contactId",
|
|
204
|
+
relation: {
|
|
205
|
+
kind: "lookup",
|
|
206
|
+
apiPath: "/contacts",
|
|
207
|
+
valueKey: "id"
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
key: "serviceId",
|
|
212
|
+
relation: {
|
|
213
|
+
kind: "lookup",
|
|
214
|
+
apiPath: "/services",
|
|
215
|
+
valueKey: "id"
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
]
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
assert.equal(resolveCrudParentFilterFieldKeyFromRouteParam(resource, "contactId"), "");
|
|
222
|
+
assert.equal(resolveCrudParentFilterFieldKeyFromRouteParam(resource, "serviceId"), "serviceId");
|
|
223
|
+
});
|
|
@@ -3,6 +3,18 @@ function normalizeText(value, { fallback = "" } = {}) {
|
|
|
3
3
|
return normalized || fallback;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
function hasValue(value) {
|
|
7
|
+
if (value == null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
return normalizeText(value).length > 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
function normalizeBoolean(value) {
|
|
7
19
|
if (typeof value === "boolean") {
|
|
8
20
|
return value;
|
|
@@ -88,7 +100,31 @@ function normalizeIfInSource(source, normalized, fieldName, normalizer = (value)
|
|
|
88
100
|
return normalized;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
|
|
103
|
+
try {
|
|
104
|
+
normalized[normalizedFieldName] = normalizer(sourceValue);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const normalizedMessage = normalizeText(error?.message, {
|
|
107
|
+
fallback: `${normalizedFieldName} is invalid.`
|
|
108
|
+
});
|
|
109
|
+
const sourceDetails = isRecord(error?.details) ? error.details : {};
|
|
110
|
+
const sourceFieldErrors = isRecord(error?.fieldErrors)
|
|
111
|
+
? { ...error.fieldErrors }
|
|
112
|
+
: isRecord(sourceDetails.fieldErrors)
|
|
113
|
+
? { ...sourceDetails.fieldErrors }
|
|
114
|
+
: {};
|
|
115
|
+
|
|
116
|
+
if (!normalizeText(sourceFieldErrors[normalizedFieldName])) {
|
|
117
|
+
sourceFieldErrors[normalizedFieldName] = normalizedMessage;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const normalizedError = error instanceof Error ? error : new Error(normalizedMessage);
|
|
121
|
+
normalizedError.fieldErrors = sourceFieldErrors;
|
|
122
|
+
normalizedError.details = {
|
|
123
|
+
...sourceDetails,
|
|
124
|
+
fieldErrors: sourceFieldErrors
|
|
125
|
+
};
|
|
126
|
+
throw normalizedError;
|
|
127
|
+
}
|
|
92
128
|
return normalized;
|
|
93
129
|
}
|
|
94
130
|
|
|
@@ -193,6 +229,7 @@ function ensureNonEmptyText(value, label = "value") {
|
|
|
193
229
|
|
|
194
230
|
export {
|
|
195
231
|
normalizeText,
|
|
232
|
+
hasValue,
|
|
196
233
|
normalizeBoolean,
|
|
197
234
|
normalizeFiniteNumber,
|
|
198
235
|
normalizeFiniteInteger,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
|
+
hasValue,
|
|
4
5
|
normalizeBoolean,
|
|
5
6
|
normalizeFiniteInteger,
|
|
6
7
|
normalizeFiniteNumber,
|
|
@@ -15,6 +16,19 @@ import {
|
|
|
15
16
|
normalizeUniqueTextList
|
|
16
17
|
} from "./normalize.js";
|
|
17
18
|
|
|
19
|
+
test("hasValue returns false for nullish and blank text, true otherwise", () => {
|
|
20
|
+
assert.equal(hasValue(null), false);
|
|
21
|
+
assert.equal(hasValue(undefined), false);
|
|
22
|
+
assert.equal(hasValue(""), false);
|
|
23
|
+
assert.equal(hasValue(" "), false);
|
|
24
|
+
assert.equal(hasValue("0"), true);
|
|
25
|
+
assert.equal(hasValue("-"), true);
|
|
26
|
+
assert.equal(hasValue(0), true);
|
|
27
|
+
assert.equal(hasValue(false), true);
|
|
28
|
+
assert.equal(hasValue([]), true);
|
|
29
|
+
assert.equal(hasValue({}), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
18
32
|
test("normalizeQueryToken trims, lowercases, and falls back when empty", () => {
|
|
19
33
|
assert.equal(normalizeQueryToken(" Admin "), "admin");
|
|
20
34
|
assert.equal(normalizeQueryToken(""), "__none__");
|
|
@@ -100,6 +114,29 @@ test("normalizeIfInSource passes through nullish values without normalizer", ()
|
|
|
100
114
|
});
|
|
101
115
|
});
|
|
102
116
|
|
|
117
|
+
test("normalizeIfInSource annotates thrown normalizer errors with fieldErrors", () => {
|
|
118
|
+
const source = {
|
|
119
|
+
temperament: "unknowne"
|
|
120
|
+
};
|
|
121
|
+
const normalized = {};
|
|
122
|
+
|
|
123
|
+
assert.throws(
|
|
124
|
+
() =>
|
|
125
|
+
normalizeIfInSource(source, normalized, "temperament", () => {
|
|
126
|
+
throw new Error("Invalid pet temperament \"unknowne\".");
|
|
127
|
+
}),
|
|
128
|
+
(error) => {
|
|
129
|
+
assert.deepEqual(error?.fieldErrors, {
|
|
130
|
+
temperament: "Invalid pet temperament \"unknowne\"."
|
|
131
|
+
});
|
|
132
|
+
assert.deepEqual(error?.details?.fieldErrors, {
|
|
133
|
+
temperament: "Invalid pet temperament \"unknowne\"."
|
|
134
|
+
});
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
103
140
|
test("normalizeIfInSource validates target and function arguments", () => {
|
|
104
141
|
assert.throws(
|
|
105
142
|
() => normalizeIfInSource({ firstName: "Ada" }, null, "firstName", normalizeText),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { normalizeText } from "../support/normalize.js";
|
|
2
3
|
import { normalizeObjectInput } from "./inputNormalization.js";
|
|
3
4
|
import { positiveIntegerValidator } from "./recordIdParamsValidator.js";
|
|
4
5
|
|
|
@@ -7,7 +8,7 @@ function normalizeCursorPaginationQuery(input = {}) {
|
|
|
7
8
|
const normalized = {};
|
|
8
9
|
|
|
9
10
|
if (Object.hasOwn(source, "cursor")) {
|
|
10
|
-
normalized.cursor =
|
|
11
|
+
normalized.cursor = normalizeText(source.cursor);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
if (Object.hasOwn(source, "limit")) {
|
|
@@ -2,18 +2,20 @@ import test from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { cursorPaginationQueryValidator } from "./cursorPaginationQueryValidator.js";
|
|
4
4
|
|
|
5
|
-
test("cursorPaginationQueryValidator normalizes numeric strings", () => {
|
|
5
|
+
test("cursorPaginationQueryValidator normalizes numeric strings as cursor text", () => {
|
|
6
6
|
assert.deepEqual(cursorPaginationQueryValidator.normalize({ cursor: "12", limit: "25" }), {
|
|
7
|
-
cursor: 12,
|
|
7
|
+
cursor: "12",
|
|
8
8
|
limit: 25
|
|
9
9
|
});
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
test("cursorPaginationQueryValidator
|
|
13
|
-
assert.
|
|
14
|
-
cursor
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
test("cursorPaginationQueryValidator schema rejects opaque cursor strings", () => {
|
|
13
|
+
assert.equal(
|
|
14
|
+
cursorPaginationQueryValidator.schema.properties.cursor.anyOf.some(
|
|
15
|
+
(entry) => entry.type === "string" && entry.pattern === "^[1-9][0-9]*$"
|
|
16
|
+
),
|
|
17
|
+
true
|
|
18
|
+
);
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
test("cursorPaginationQueryValidator keeps absent keys absent", () => {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
const HTML_TIME_STRING_SCHEMA = Type.String({
|
|
4
|
+
pattern: "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d)?$",
|
|
5
|
+
minLength: 5
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const NULLABLE_HTML_TIME_STRING_SCHEMA = Type.Union([HTML_TIME_STRING_SCHEMA, Type.Null()]);
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
HTML_TIME_STRING_SCHEMA,
|
|
12
|
+
NULLABLE_HTML_TIME_STRING_SCHEMA
|
|
13
|
+
};
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { normalizeObjectInput } from "./inputNormalization.js";
|
|
2
2
|
export { createCursorListValidator } from "./createCursorListValidator.js";
|
|
3
3
|
export { cursorPaginationQueryValidator } from "./cursorPaginationQueryValidator.js";
|
|
4
|
+
export {
|
|
5
|
+
HTML_TIME_STRING_SCHEMA,
|
|
6
|
+
NULLABLE_HTML_TIME_STRING_SCHEMA
|
|
7
|
+
} from "./htmlTimeSchemas.js";
|
|
4
8
|
export { mergeObjectSchemas } from "./mergeObjectSchemas.js";
|
|
5
9
|
export { mergeValidators } from "./mergeValidators.js";
|
|
6
10
|
export { nestValidator } from "./nestValidator.js";
|