@jskit-ai/kernel 0.1.23 → 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.23",
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 {
@@ -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: ["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
  );
@@ -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
+ });
@@ -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";