@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "typebox": "^1.0.81"
@@ -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
- roleId: "owner"
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?.roleId, "owner");
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: ["crud_contacts.*"]
91
+ permissions: ["crud.contacts.*"]
92
92
  }
93
93
  },
94
94
  {
95
95
  require: "all",
96
- permissions: ["crud_contacts.update"]
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 discovered = discoverShellOutletTargetsFromVueSource(source, {
162
- context: relativePath
163
- });
164
- const targets = Array.isArray(discovered.targets) ? discovered.targets : [];
165
- for (const target of targets) {
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 discoveredDefaultTargetId = normalizeShellOutletTargetId(discovered.defaultTargetId);
178
- if (!discoveredDefaultTargetId) {
179
- continue;
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
- if (defaultTargetId && discoveredDefaultTargetId !== defaultTargetId) {
183
- throw new Error(
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
- roleId: "OWNER",
45
+ roleSid: "OWNER",
46
46
  customFlag: true
47
47
  },
48
48
  membership: {
49
- roleId: "OWNER",
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
- roleId: "OWNER",
58
+ roleSid: "OWNER",
59
59
  customFlag: true
60
60
  });
61
61
  assert.deepEqual(context.membership, {
62
- roleId: "OWNER",
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 resolveCrudLookupFieldKeys(resource = {}, { allowKeys = [] } = {}) {
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(key);
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
- resolveCrudLookupFieldKeys
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
- normalized[normalizedFieldName] = normalizer(sourceValue);
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 = positiveIntegerValidator.normalize(source.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 normalizes invalid values to 0", () => {
13
- assert.deepEqual(cursorPaginationQueryValidator.normalize({ cursor: "abc", limit: "-1" }), {
14
- cursor: 0,
15
- limit: 0
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";