@jskit-ai/kernel 0.1.23 → 0.1.25
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 +12 -2
- 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.test.js +12 -12
- package/shared/support/crudLookup.js +61 -3
- package/shared/support/crudLookup.test.js +141 -1
- 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 {
|
|
@@ -195,6 +204,7 @@ test("registerRoutes attaches request.executeAction and applies action context c
|
|
|
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
|
);
|
|
@@ -41,7 +41,7 @@ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue file
|
|
|
41
41
|
"src/pages/admin/workspace/settings/index.vue",
|
|
42
42
|
`<template>
|
|
43
43
|
<section>
|
|
44
|
-
<ShellOutlet host="
|
|
44
|
+
<ShellOutlet host="admin-settings" position="forms" default />
|
|
45
45
|
</section>
|
|
46
46
|
</template>
|
|
47
47
|
`
|
|
@@ -52,7 +52,7 @@ test("resolveShellOutletPlacementTargetFromApp reads outlets across app Vue file
|
|
|
52
52
|
context: "ui-generator"
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
assert.equal(target.host, "
|
|
55
|
+
assert.equal(target.host, "admin-settings");
|
|
56
56
|
assert.equal(target.position, "forms");
|
|
57
57
|
});
|
|
58
58
|
});
|
|
@@ -147,28 +147,28 @@ test("discoverShellOutletTargetsFromApp returns targets with sourcePath and defa
|
|
|
147
147
|
"src/pages/admin/workspace/settings/index.vue",
|
|
148
148
|
`<template>
|
|
149
149
|
<section>
|
|
150
|
-
<ShellOutlet host="
|
|
150
|
+
<ShellOutlet host="admin-settings" position="forms" default />
|
|
151
151
|
</section>
|
|
152
152
|
</template>
|
|
153
153
|
`
|
|
154
154
|
);
|
|
155
155
|
|
|
156
156
|
const discovered = await discoverShellOutletTargetsFromApp({ appRoot });
|
|
157
|
-
assert.equal(discovered.defaultTargetId, "
|
|
157
|
+
assert.equal(discovered.defaultTargetId, "admin-settings:forms");
|
|
158
158
|
assert.deepEqual(discovered.targets, [
|
|
159
|
+
{
|
|
160
|
+
id: "admin-settings:forms",
|
|
161
|
+
host: "admin-settings",
|
|
162
|
+
position: "forms",
|
|
163
|
+
default: true,
|
|
164
|
+
sourcePath: "src/pages/admin/workspace/settings/index.vue"
|
|
165
|
+
},
|
|
159
166
|
{
|
|
160
167
|
id: "shell-layout:primary-menu",
|
|
161
168
|
host: "shell-layout",
|
|
162
169
|
position: "primary-menu",
|
|
163
170
|
default: false,
|
|
164
171
|
sourcePath: "src/components/ShellLayout.vue"
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
id: "workspace-settings:forms",
|
|
168
|
-
host: "workspace-settings",
|
|
169
|
-
position: "forms",
|
|
170
|
-
default: true,
|
|
171
|
-
sourcePath: "src/pages/admin/workspace/settings/index.vue"
|
|
172
172
|
}
|
|
173
173
|
]);
|
|
174
174
|
});
|
|
@@ -287,7 +287,7 @@ test("resolveShellOutletPlacementTargetFromApp throws when multiple default outl
|
|
|
287
287
|
"src/pages/admin/workspace/settings/index.vue",
|
|
288
288
|
`<template>
|
|
289
289
|
<section>
|
|
290
|
-
<ShellOutlet host="
|
|
290
|
+
<ShellOutlet host="admin-settings" position="forms" default />
|
|
291
291
|
</section>
|
|
292
292
|
</template>
|
|
293
293
|
`
|
|
@@ -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
|
+
});
|
|
@@ -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";
|