@jskit-ai/kernel 0.1.244 → 0.1.246
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/client/appConfig.js +2 -1
- package/client/asyncModuleRecovery.js +2 -4
- package/client/index.d.ts +18 -76
- package/client/index.js +7 -2
- package/client/logging.js +8 -2
- package/client/moduleBootstrap.js +41 -33
- package/client/moduleBootstrap.test.js +35 -0
- package/client/shellBootstrap.js +38 -10
- package/client/shellBootstrap.test.js +85 -6
- package/client/shellRouting.js +16 -61
- package/client/shellRouting.test.js +30 -1
- package/client/vite/clientBootstrapPlugin.js +0 -6
- package/client/vite/clientBootstrapPlugin.test.js +2 -3
- package/internal/node/installedPackages.js +14 -58
- package/package.json +2 -4
- package/server/actions/actionCatalogue.js +13 -34
- package/{shared → server}/actions/actionContributorHelpers.js +0 -5
- package/{shared → server}/actions/actionDefinitions.js +24 -131
- package/{shared → server}/actions/actionDefinitions.test.js +75 -21
- package/server/actions/actionEvents.js +97 -2
- package/server/actions/actionEvents.test.js +79 -0
- package/{shared → server}/actions/executionContext.js +3 -13
- package/server/actions/idempotency.js +24 -0
- package/server/actions/pipeline.js +87 -0
- package/{shared → server}/actions/policies.js +20 -33
- package/{shared → server}/actions/policies.test.js +6 -6
- package/{shared → server}/actions/registry.js +3 -22
- package/{shared → server}/actions/registry.test.js +70 -32
- package/server/actions/requestMeta.js +17 -0
- package/server/http/capabilityHttpRuntime.js +1 -1
- package/server/http/capabilityHttpRuntime.test.js +56 -0
- package/server/http/errorHandling.js +291 -0
- package/server/{runtime/fastifyBootstrap.test.js → http/errorHandling.test.js} +72 -120
- package/server/http/lib/routeRegistration.js +36 -150
- package/server/http/lib/routeTransport.js +2 -0
- package/server/http/lib/routeValidator.js +5 -47
- package/server/http/lib/routeValidator.test.js +15 -17
- package/server/http/lib/router.js +10 -94
- package/server/platform/providerRuntime/capabilityProviderLoader.js +2 -1
- package/server/platform/providerRuntime/helpers.js +1 -34
- package/server/runtime/integers.js +1 -5
- package/server/runtime/serviceAuthorization.js +4 -9
- package/server/support/appConfig.js +2 -1
- package/server/support/appConfigFiles.js +1 -38
- package/server/support/appConfigFiles.test.js +3 -39
- package/server/support/index.js +1 -20
- package/shared/capabilities/defineProvider.js +1 -1
- package/shared/capabilities/index.js +1 -1
- package/shared/index.js +1 -1
- package/shared/support/index.js +1 -4
- package/shared/support/mobileConfig.js +99 -0
- package/shared/support/mobileConfig.test.js +113 -0
- package/shared/support/normalize.js +0 -108
- package/shared/support/normalize.test.js +0 -134
- package/shared/{actions/actionContributorHelpers.test.js → support/permissions.test.js} +1 -1
- package/shared/support/returnToPath.js +16 -20
- package/shared/support/returnToPath.test.js +5 -33
- package/shared/support/shellLayoutTargets.js +2 -140
- package/shared/support/shellLayoutTargets.test.js +1 -110
- package/shared/support/sorting.js +1 -9
- package/shared/surface/apiPaths.js +1 -82
- package/shared/surface/paths.js +38 -171
- package/shared/surface/registry.js +2 -1
- package/shared/surface/runtime.js +18 -109
- package/shared/surface/runtime.test.js +57 -0
- package/shared/validators/index.js +1 -4
- package/shared/validators/jsonRestSchemaSupport.js +0 -9
- package/shared/validators/recordIdParamsValidator.js +0 -6
- package/shared/validators/schemaDefinitions.js +4 -30
- package/shared/validators/schemaPayloadValidation.js +2 -6
- package/test/barrelExposure.test.js +5 -8
- package/test/exportsContract.test.js +1 -3
- package/client/mobileLaunchRouting.js +0 -231
- package/client/mobileLaunchRouting.test.js +0 -230
- package/server/http/lib/controller.js +0 -183
- package/server/http/lib/controller.test.js +0 -143
- package/server/http/lib/index.js +0 -5
- package/server/runtime/canonicalJson.js +0 -74
- package/server/runtime/entityChangeEvents.js +0 -307
- package/server/runtime/entityChangeEvents.test.js +0 -274
- package/server/runtime/fastifyBootstrap.js +0 -553
- package/server/runtime/moduleConfig.js +0 -223
- package/server/runtime/moduleConfig.test.js +0 -165
- package/server/runtime/pagination.js +0 -13
- package/server/runtime/realtimeNormalization.js +0 -21
- package/server/runtime/routeUtils.js +0 -20
- package/server/runtime/securityAudit.js +0 -269
- package/server/runtime/securityAudit.test.js +0 -41
- package/server/support/pageTargets.js +0 -772
- package/server/support/pageTargets.test.js +0 -659
- package/server/support/shellOutlets.js +0 -576
- package/server/support/shellOutlets.test.js +0 -336
- package/server/surface/index.js +0 -18
- package/shared/actions/audit.js +0 -7
- package/shared/actions/idempotency.js +0 -62
- package/shared/actions/observability.js +0 -10
- package/shared/actions/pipeline.js +0 -309
- package/shared/actions/requestMeta.js +0 -36
- package/shared/actions/textNormalization.js +0 -3
- package/shared/actions/withActionDefaults.js +0 -34
- /package/{shared → server}/actions/executionContext.test.js +0 -0
|
@@ -20,7 +20,7 @@ test("plain json-rest-schema action schemas validate input without reshaping it"
|
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const result = await normalizeActionInput(definition, { workspaceSlug: " ACME " }
|
|
23
|
+
const result = await normalizeActionInput(definition, { workspaceSlug: " ACME " });
|
|
24
24
|
assert.deepEqual(result, { workspaceSlug: "ACME" });
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -41,7 +41,7 @@ test("json-rest-schema input validation surfaces plain field keys", async () =>
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
await assert.rejects(
|
|
44
|
-
() => normalizeActionInput(definition, { name: "too long" }
|
|
44
|
+
() => normalizeActionInput(definition, { name: "too long" }),
|
|
45
45
|
(error) => {
|
|
46
46
|
const fieldErrors = error.details?.fieldErrors || {};
|
|
47
47
|
assert.equal(typeof fieldErrors.name, "string");
|
|
@@ -67,11 +67,11 @@ test("action output validation preserves valid json-rest-schema output", async (
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
await assert.rejects(
|
|
70
|
-
() => normalizeActionOutput(definition, { ok: 2 }
|
|
70
|
+
() => normalizeActionOutput(definition, { ok: 2 }),
|
|
71
71
|
(error) => error?.code === "ACTION_OUTPUT_VALIDATION_FAILED"
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
const result = await normalizeActionOutput(definition, { ok: true }
|
|
74
|
+
const result = await normalizeActionOutput(definition, { ok: true });
|
|
75
75
|
assert.deepEqual(result, { ok: true });
|
|
76
76
|
});
|
|
77
77
|
|
|
@@ -94,7 +94,7 @@ test("json-rest-schema action validators normalize action input", async () => {
|
|
|
94
94
|
}
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
const result = await normalizeActionInput(definition, { name: " Acme " }
|
|
97
|
+
const result = await normalizeActionInput(definition, { name: " Acme " });
|
|
98
98
|
assert.deepEqual(result, { name: "Acme" });
|
|
99
99
|
});
|
|
100
100
|
|
|
@@ -118,7 +118,7 @@ test("json-rest-schema action validators surface field errors", async () => {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
await assert.rejects(
|
|
121
|
-
() => normalizeActionInput(definition, { name: " " }
|
|
121
|
+
() => normalizeActionInput(definition, { name: " " }),
|
|
122
122
|
(error) => {
|
|
123
123
|
assert.equal(error.code, "ACTION_VALIDATION_FAILED");
|
|
124
124
|
assert.equal(error.details?.fieldErrors?.name, "Name is required.");
|
|
@@ -111,9 +111,6 @@ function resolveActionDefinition(index, actionId, version) {
|
|
|
111
111
|
|
|
112
112
|
function createActionRegistry({
|
|
113
113
|
contributors = [],
|
|
114
|
-
idempotencyAdapter,
|
|
115
|
-
auditAdapter,
|
|
116
|
-
observabilityAdapter,
|
|
117
114
|
events,
|
|
118
115
|
logger = console
|
|
119
116
|
} = {}) {
|
|
@@ -122,19 +119,14 @@ function createActionRegistry({
|
|
|
122
119
|
|
|
123
120
|
async function execute({ actionId, version = null, input = {}, context = {}, deps = {} } = {}) {
|
|
124
121
|
const definition = resolveActionDefinition(index, actionId, version);
|
|
125
|
-
|
|
122
|
+
return executeActionPipeline({
|
|
126
123
|
definition,
|
|
127
124
|
input,
|
|
128
125
|
context,
|
|
129
126
|
deps,
|
|
130
|
-
idempotencyAdapter,
|
|
131
|
-
auditAdapter,
|
|
132
|
-
observabilityAdapter,
|
|
133
127
|
events,
|
|
134
128
|
logger
|
|
135
129
|
});
|
|
136
|
-
|
|
137
|
-
return execution.result;
|
|
138
130
|
}
|
|
139
131
|
|
|
140
132
|
async function executeStream({ actionId, version = null, input = {}, context = {}, deps = {} } = {}) {
|
|
@@ -150,19 +142,14 @@ function createActionRegistry({
|
|
|
150
142
|
});
|
|
151
143
|
}
|
|
152
144
|
|
|
153
|
-
|
|
145
|
+
return executeActionPipeline({
|
|
154
146
|
definition,
|
|
155
147
|
input,
|
|
156
148
|
context,
|
|
157
149
|
deps,
|
|
158
|
-
idempotencyAdapter,
|
|
159
|
-
auditAdapter,
|
|
160
|
-
observabilityAdapter,
|
|
161
150
|
events,
|
|
162
151
|
logger
|
|
163
152
|
});
|
|
164
|
-
|
|
165
|
-
return execution.result;
|
|
166
153
|
}
|
|
167
154
|
|
|
168
155
|
function listDefinitions() {
|
|
@@ -181,10 +168,4 @@ function createActionRegistry({
|
|
|
181
168
|
});
|
|
182
169
|
}
|
|
183
170
|
|
|
184
|
-
|
|
185
|
-
normalizeContributors,
|
|
186
|
-
buildDefinitionIndex,
|
|
187
|
-
resolveActionDefinition
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
export { createActionRegistry, __testables };
|
|
171
|
+
export { createActionRegistry };
|
|
@@ -26,10 +26,6 @@ test("action registry executes latest version by default", async () => {
|
|
|
26
26
|
surfaces: ["app", "admin", "console"],
|
|
27
27
|
input: { schema: createPassThroughSchema() },
|
|
28
28
|
idempotency: "none",
|
|
29
|
-
audit: {
|
|
30
|
-
actionName: "settings.read"
|
|
31
|
-
},
|
|
32
|
-
observability: {},
|
|
33
29
|
async execute() {
|
|
34
30
|
calls.push("version-one");
|
|
35
31
|
return {
|
|
@@ -46,10 +42,6 @@ test("action registry executes latest version by default", async () => {
|
|
|
46
42
|
surfaces: ["app", "admin", "console"],
|
|
47
43
|
input: { schema: createPassThroughSchema() },
|
|
48
44
|
idempotency: "none",
|
|
49
|
-
audit: {
|
|
50
|
-
actionName: "settings.read"
|
|
51
|
-
},
|
|
52
|
-
observability: {},
|
|
53
45
|
async execute() {
|
|
54
46
|
calls.push("v2");
|
|
55
47
|
return {
|
|
@@ -107,10 +99,6 @@ test("action registry validates action input with a single schema contract", asy
|
|
|
107
99
|
})
|
|
108
100
|
},
|
|
109
101
|
idempotency: "optional",
|
|
110
|
-
audit: {
|
|
111
|
-
actionName: "workspace.settings.update"
|
|
112
|
-
},
|
|
113
|
-
observability: {},
|
|
114
102
|
async execute(input) {
|
|
115
103
|
return input;
|
|
116
104
|
}
|
|
@@ -157,10 +145,6 @@ test("action registry fails startup on duplicate action id + version", () => {
|
|
|
157
145
|
surfaces: ["app"],
|
|
158
146
|
input: { schema: createPassThroughSchema() },
|
|
159
147
|
idempotency: "optional",
|
|
160
|
-
audit: {
|
|
161
|
-
actionName: "settings.profile.update"
|
|
162
|
-
},
|
|
163
|
-
observability: {},
|
|
164
148
|
async execute() {
|
|
165
149
|
return {
|
|
166
150
|
ok: true
|
|
@@ -182,10 +166,6 @@ test("action registry fails startup on duplicate action id + version", () => {
|
|
|
182
166
|
surfaces: ["app"],
|
|
183
167
|
input: { schema: createPassThroughSchema() },
|
|
184
168
|
idempotency: "optional",
|
|
185
|
-
audit: {
|
|
186
|
-
actionName: "settings.profile.update"
|
|
187
|
-
},
|
|
188
|
-
observability: {},
|
|
189
169
|
async execute() {
|
|
190
170
|
return {
|
|
191
171
|
ok: true
|
|
@@ -216,10 +196,6 @@ test("action registry rejects invalid version requests", async () => {
|
|
|
216
196
|
surfaces: ["app"],
|
|
217
197
|
input: { schema: createPassThroughSchema() },
|
|
218
198
|
idempotency: "none",
|
|
219
|
-
audit: {
|
|
220
|
-
actionName: "settings.read"
|
|
221
|
-
},
|
|
222
|
-
observability: {},
|
|
223
199
|
async execute() {
|
|
224
200
|
return {
|
|
225
201
|
ok: true
|
|
@@ -268,10 +244,6 @@ test("action registry ignores unknown extra fields", async () => {
|
|
|
268
244
|
consoleUsersOnly: true,
|
|
269
245
|
input: { schema: createPassThroughSchema() },
|
|
270
246
|
idempotency: "none",
|
|
271
|
-
audit: {
|
|
272
|
-
actionName: "settings.internal.ping"
|
|
273
|
-
},
|
|
274
|
-
observability: {},
|
|
275
247
|
async execute() {
|
|
276
248
|
return { ok: true };
|
|
277
249
|
}
|
|
@@ -311,10 +283,6 @@ test("action registry enforces action-level permissions", async () => {
|
|
|
311
283
|
},
|
|
312
284
|
input: { schema: createPassThroughSchema() },
|
|
313
285
|
idempotency: "optional",
|
|
314
|
-
audit: {
|
|
315
|
-
actionName: "workspace.settings.update"
|
|
316
|
-
},
|
|
317
|
-
observability: {},
|
|
318
286
|
async execute() {
|
|
319
287
|
return { ok: true };
|
|
320
288
|
}
|
|
@@ -353,3 +321,73 @@ test("action registry enforces action-level permissions", async () => {
|
|
|
353
321
|
|
|
354
322
|
assert.deepEqual(allowed, { ok: true });
|
|
355
323
|
});
|
|
324
|
+
|
|
325
|
+
function createSingleActionRegistry(action, options = {}) {
|
|
326
|
+
return createActionRegistry({
|
|
327
|
+
...options,
|
|
328
|
+
contributors: [
|
|
329
|
+
{
|
|
330
|
+
contributorId: "tests.single",
|
|
331
|
+
domain: "tests",
|
|
332
|
+
actions: [
|
|
333
|
+
{
|
|
334
|
+
id: "tests.run",
|
|
335
|
+
version: 1,
|
|
336
|
+
kind: "command",
|
|
337
|
+
channels: ["api"],
|
|
338
|
+
surfaces: ["app"],
|
|
339
|
+
input: { schema: createPassThroughSchema() },
|
|
340
|
+
...action
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
}
|
|
344
|
+
]
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
test("action registry requires an idempotency key only for required actions", async () => {
|
|
349
|
+
const registry = createSingleActionRegistry({
|
|
350
|
+
idempotency: "required",
|
|
351
|
+
async execute() {
|
|
352
|
+
return { ok: true };
|
|
353
|
+
}
|
|
354
|
+
}, { logger: null });
|
|
355
|
+
const context = { channel: "api", surface: "app" };
|
|
356
|
+
|
|
357
|
+
await assert.rejects(
|
|
358
|
+
() => registry.execute({ actionId: "tests.run", context }),
|
|
359
|
+
{ code: "ACTION_IDEMPOTENCY_KEY_REQUIRED", status: 400 }
|
|
360
|
+
);
|
|
361
|
+
assert.deepEqual(
|
|
362
|
+
await registry.execute({ actionId: "tests.run", context: { ...context, requestMeta: { commandId: " cmd-1 " } } }),
|
|
363
|
+
{ ok: true }
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("action registry logs one debug entry per outcome and returns the result directly", async () => {
|
|
368
|
+
const entries = [];
|
|
369
|
+
const logger = { debug: (payload, message) => entries.push({ payload, message }) };
|
|
370
|
+
let fail = false;
|
|
371
|
+
const registry = createSingleActionRegistry({
|
|
372
|
+
idempotency: "optional",
|
|
373
|
+
async execute() {
|
|
374
|
+
if (fail) {
|
|
375
|
+
throw Object.assign(new Error("boom"), { code: "TEST_FAILED" });
|
|
376
|
+
}
|
|
377
|
+
return { ok: true };
|
|
378
|
+
}
|
|
379
|
+
}, { logger });
|
|
380
|
+
const context = { channel: "api", surface: "app", requestMeta: { requestId: "req-1" } };
|
|
381
|
+
|
|
382
|
+
assert.deepEqual(await registry.execute({ actionId: "tests.run", context }), { ok: true });
|
|
383
|
+
fail = true;
|
|
384
|
+
await assert.rejects(() => registry.execute({ actionId: "tests.run", context }), { code: "TEST_FAILED" });
|
|
385
|
+
|
|
386
|
+
assert.deepEqual(
|
|
387
|
+
entries.map(({ payload, message }) => ({ ...payload, durationMs: typeof payload.durationMs, message })),
|
|
388
|
+
[
|
|
389
|
+
{ action: "tests.run", version: 1, channel: "api", surface: "app", requestId: "req-1", durationMs: "number", outcome: "success", errorCode: null, message: "action.execution" },
|
|
390
|
+
{ action: "tests.run", version: 1, channel: "api", surface: "app", requestId: "req-1", durationMs: "number", outcome: "failure", errorCode: "TEST_FAILED", message: "action.execution" }
|
|
391
|
+
]
|
|
392
|
+
);
|
|
393
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { normalizeText } from "../../shared/support/normalize.js";
|
|
2
|
+
|
|
3
|
+
const TEXT_FIELDS = Object.freeze(["requestId", "commandId", "idempotencyKey", "ip", "userAgent"]);
|
|
4
|
+
|
|
5
|
+
function normalizeRequestMeta(requestMeta = {}) {
|
|
6
|
+
const source = requestMeta && typeof requestMeta === "object" ? requestMeta : {};
|
|
7
|
+
const normalized = {
|
|
8
|
+
...source,
|
|
9
|
+
request: source.request || null
|
|
10
|
+
};
|
|
11
|
+
for (const field of TEXT_FIELDS) {
|
|
12
|
+
normalized[field] = normalizeText(source[field]);
|
|
13
|
+
}
|
|
14
|
+
return normalized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { normalizeRequestMeta };
|
|
@@ -9,7 +9,7 @@ import { registerRoutes } from "./lib/routeRegistration.js";
|
|
|
9
9
|
import {
|
|
10
10
|
registerApiErrorHandler,
|
|
11
11
|
registerBodylessContentTypeNormalizer
|
|
12
|
-
} from "
|
|
12
|
+
} from "./errorHandling.js";
|
|
13
13
|
import { isAppError } from "../runtime/errors.js";
|
|
14
14
|
|
|
15
15
|
function normalizeResolver({ id, resolve } = {}) {
|
|
@@ -159,3 +159,59 @@ test("direct HTTP action execution preserves request-scoped stream dependencies"
|
|
|
159
159
|
assert.deepEqual(reply.payload, { writerId: "writer-1", aborted: false });
|
|
160
160
|
await runtime.shutdown();
|
|
161
161
|
});
|
|
162
|
+
|
|
163
|
+
test("route transport reaches the error handler before the route handler runs, even without a kind", async () => {
|
|
164
|
+
const fastify = createFastifyStub();
|
|
165
|
+
const transport = {
|
|
166
|
+
contentType: "application/vnd.api+json",
|
|
167
|
+
request: {
|
|
168
|
+
query: (query) => ({ ...query, steps: [...(query.steps || []), "transport"] })
|
|
169
|
+
},
|
|
170
|
+
error(error, { statusCode, code }) {
|
|
171
|
+
return { errors: [{ status: String(statusCode), code, title: error.message }] };
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const feature = defineFeature({
|
|
175
|
+
id: "feature.transport-proof",
|
|
176
|
+
domain: "transport-proof",
|
|
177
|
+
requires: { http: "runtime.http" },
|
|
178
|
+
setup({ http }) {
|
|
179
|
+
http.router.get("/transport-proof/", {
|
|
180
|
+
surface: "app",
|
|
181
|
+
visibility: "public",
|
|
182
|
+
transport
|
|
183
|
+
}, async (request, reply) => {
|
|
184
|
+
reply.code(200).send({ query: request.input.query });
|
|
185
|
+
});
|
|
186
|
+
return {};
|
|
187
|
+
},
|
|
188
|
+
actions: []
|
|
189
|
+
});
|
|
190
|
+
const runtime = createCapabilityRuntime({
|
|
191
|
+
inputs: { "runtime.fastify": fastify },
|
|
192
|
+
providers: [createActionProvider(), HttpProvider, feature]
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await runtime.start();
|
|
196
|
+
const [route] = fastify.routes;
|
|
197
|
+
assert.equal(route.url, "/transport-proof");
|
|
198
|
+
assert.equal(typeof route.config.transport.error, "function");
|
|
199
|
+
|
|
200
|
+
const errorReply = createReply();
|
|
201
|
+
fastify.errorHandler(
|
|
202
|
+
{ message: "querystring/limit must be integer", validation: [{ instancePath: "/limit", message: "must be integer" }] },
|
|
203
|
+
{ routeOptions: { config: route.config } },
|
|
204
|
+
errorReply
|
|
205
|
+
);
|
|
206
|
+
assert.equal(errorReply.statusCode, 400);
|
|
207
|
+
assert.equal(errorReply.headers["content-type"], "application/vnd.api+json");
|
|
208
|
+
assert.deepEqual(errorReply.payload, {
|
|
209
|
+
errors: [{ status: "400", code: "validation_failed", title: "querystring/limit must be integer" }]
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const reply = createReply();
|
|
213
|
+
await route.handler({ query: { steps: ["client"] }, routeOptions: { config: route.config } }, reply);
|
|
214
|
+
assert.deepEqual(reply.payload, { query: { steps: ["client", "transport"] } });
|
|
215
|
+
assert.equal(reply.headers["content-type"], "application/vnd.api+json");
|
|
216
|
+
await runtime.shutdown();
|
|
217
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { ActionRuntimeError } from "../actions/actionDefinitions.js";
|
|
2
|
+
import { isRecord, normalizeTransactionOutcome } from "../../shared/support/normalize.js";
|
|
3
|
+
import { JSON_API_CONTENT_TYPE } from "./lib/routeTransport.js";
|
|
4
|
+
|
|
5
|
+
const JSON_API_CONTENT_TYPE_PARSER_MARKER = Symbol.for("jskit.fastify.jsonApiContentTypeParserRegistered");
|
|
6
|
+
const BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER = Symbol.for("jskit.fastify.bodylessContentTypeNormalizerRegistered");
|
|
7
|
+
|
|
8
|
+
function registerJsonApiContentTypeParser(fastify) {
|
|
9
|
+
if (!fastify || typeof fastify.addContentTypeParser !== "function") {
|
|
10
|
+
throw new TypeError("registerJsonApiContentTypeParser requires a Fastify instance.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER]) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof fastify.hasContentTypeParser === "function" && fastify.hasContentTypeParser(JSON_API_CONTENT_TYPE)) {
|
|
18
|
+
fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
fastify.addContentTypeParser(
|
|
23
|
+
JSON_API_CONTENT_TYPE,
|
|
24
|
+
{ parseAs: "string" },
|
|
25
|
+
fastify.getDefaultJsonParser("ignore", "ignore")
|
|
26
|
+
);
|
|
27
|
+
fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasHeaderValue(headers = {}, key = "") {
|
|
32
|
+
return String(headers?.[key] ?? "").trim().length > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hasRequestBody(headers = {}) {
|
|
36
|
+
const transferEncoding = String(headers?.["transfer-encoding"] ?? "").trim();
|
|
37
|
+
if (transferEncoding) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const contentLengthHeader = String(headers?.["content-length"] ?? "").trim();
|
|
42
|
+
if (!contentLengthHeader) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const contentLength = Number(contentLengthHeader);
|
|
47
|
+
if (!Number.isFinite(contentLength)) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return contentLength > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeBodylessContentTypeHeader(request = null) {
|
|
55
|
+
const headers = request?.headers;
|
|
56
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!hasHeaderValue(headers, "content-type")) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (hasRequestBody(headers)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
delete headers["content-type"];
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function registerBodylessContentTypeNormalizer(fastify) {
|
|
73
|
+
if (!fastify || typeof fastify.addHook !== "function") {
|
|
74
|
+
throw new TypeError("registerBodylessContentTypeNormalizer requires a Fastify instance.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER]) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fastify.addHook("onRequest", async (request) => {
|
|
82
|
+
normalizeBodylessContentTypeHeader(request);
|
|
83
|
+
});
|
|
84
|
+
fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER] = true;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveValidationFieldErrors(error) {
|
|
89
|
+
const issues = Array.isArray(error?.validation) ? error.validation : [];
|
|
90
|
+
const fieldErrors = {};
|
|
91
|
+
|
|
92
|
+
for (const issue of issues) {
|
|
93
|
+
const fieldFromPath = String(issue.instancePath || "")
|
|
94
|
+
.replace(/^\//, "")
|
|
95
|
+
.replace(/\//g, ".");
|
|
96
|
+
const field =
|
|
97
|
+
fieldFromPath || String(issue.params?.missingProperty || issue.params?.additionalProperty || "request").trim();
|
|
98
|
+
|
|
99
|
+
if (!fieldErrors[field]) {
|
|
100
|
+
fieldErrors[field] = issue.message || "Invalid value.";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fieldErrors;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveRequestRouteTransport(request) {
|
|
108
|
+
const transport = request?.routeOptions?.config?.transport;
|
|
109
|
+
return isRecord(transport) ? transport : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function applyRouteTransportErrorResponse(reply, request, error, {
|
|
113
|
+
statusCode = 500,
|
|
114
|
+
normalizedErrorCode = "",
|
|
115
|
+
message = error?.message,
|
|
116
|
+
exposeDetails = true
|
|
117
|
+
} = {}) {
|
|
118
|
+
const transport = resolveRequestRouteTransport(request);
|
|
119
|
+
const errorSerializer = transport && typeof transport.error === "function" ? transport.error : null;
|
|
120
|
+
if (!errorSerializer) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const payload = errorSerializer(error, {
|
|
125
|
+
request,
|
|
126
|
+
reply,
|
|
127
|
+
statusCode,
|
|
128
|
+
code: normalizedErrorCode,
|
|
129
|
+
message,
|
|
130
|
+
exposeDetails
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (payload && typeof payload.then === "function") {
|
|
134
|
+
throw new TypeError("Route transport error serializer must return synchronously.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (transport.contentType) {
|
|
138
|
+
reply.header("Content-Type", transport.contentType);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reply.code(statusCode).send(payload);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveApiErrorStatus(error, code) {
|
|
146
|
+
// JSON REST's public error codes need HTTP classification even without status hints.
|
|
147
|
+
switch (code) {
|
|
148
|
+
case "REST_API_VALIDATION":
|
|
149
|
+
return 422;
|
|
150
|
+
case "REST_API_RESOURCE":
|
|
151
|
+
switch (error?.subtype) {
|
|
152
|
+
case "not_found": return 404;
|
|
153
|
+
case "forbidden": return 403;
|
|
154
|
+
case "conflict": return 409;
|
|
155
|
+
default: return 400;
|
|
156
|
+
}
|
|
157
|
+
case "REST_API_VERSION_CONFLICT":
|
|
158
|
+
return 409;
|
|
159
|
+
case "REST_API_PRECONDITION_FAILED":
|
|
160
|
+
return 412;
|
|
161
|
+
case "REST_API_FIELDSET_INVALID":
|
|
162
|
+
case "REST_API_INCLUDE_INVALID":
|
|
163
|
+
return 400;
|
|
164
|
+
case "REST_API_PAYLOAD":
|
|
165
|
+
return error?.statusCode === 413 ? 413 : 400;
|
|
166
|
+
case "REST_API_TEMPORAL_DATA_INVALID":
|
|
167
|
+
return 500;
|
|
168
|
+
default: {
|
|
169
|
+
const status = Number(error?.statusCode || error?.status);
|
|
170
|
+
return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applyErrorHeaders(reply, error) {
|
|
176
|
+
if (error?.headers && typeof error.headers === "object") {
|
|
177
|
+
for (const [name, value] of Object.entries(error.headers)) {
|
|
178
|
+
reply.header(name, value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function registerApiErrorHandler(app, { isAppError } = {}) {
|
|
184
|
+
if (!app || typeof app.setErrorHandler !== "function") {
|
|
185
|
+
throw new TypeError("registerApiErrorHandler requires a Fastify instance.");
|
|
186
|
+
}
|
|
187
|
+
if (typeof isAppError !== "function") {
|
|
188
|
+
throw new TypeError("registerApiErrorHandler requires isAppError.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function shouldExposeAppErrorDetails(errorCode = "") {
|
|
192
|
+
return String(errorCode || "").trim() !== "ACTION_PERMISSION_DENIED";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
app.setErrorHandler((error, request, reply) => {
|
|
196
|
+
const normalizedErrorCode = String(error?.code || "").trim();
|
|
197
|
+
const transactionOutcome = normalizeTransactionOutcome(error?.transactionOutcome);
|
|
198
|
+
const outcomeFields = transactionOutcome ? { transactionOutcome } : {};
|
|
199
|
+
const isCsrfErrorCode = normalizedErrorCode.startsWith("FST_CSRF_");
|
|
200
|
+
const statusCode = resolveApiErrorStatus(error, normalizedErrorCode);
|
|
201
|
+
|
|
202
|
+
if (Array.isArray(error?.validation)) {
|
|
203
|
+
const fieldErrors = resolveValidationFieldErrors(error);
|
|
204
|
+
const validationErrorCode = normalizedErrorCode || "validation_failed";
|
|
205
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
206
|
+
statusCode: 400,
|
|
207
|
+
normalizedErrorCode: validationErrorCode
|
|
208
|
+
})) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
reply.code(400).send({
|
|
212
|
+
error: "Validation failed.",
|
|
213
|
+
code: validationErrorCode,
|
|
214
|
+
...outcomeFields,
|
|
215
|
+
fieldErrors,
|
|
216
|
+
details: {
|
|
217
|
+
fieldErrors
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
applyErrorHeaders(reply, error);
|
|
224
|
+
|
|
225
|
+
if (isAppError(error) || error instanceof ActionRuntimeError) {
|
|
226
|
+
if (error.status >= 500) {
|
|
227
|
+
app.log.error({ err: error }, "AppError 5xx");
|
|
228
|
+
}
|
|
229
|
+
const appErrorCode = normalizedErrorCode || "app_error";
|
|
230
|
+
const exposeDetails = shouldExposeAppErrorDetails(appErrorCode);
|
|
231
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
232
|
+
statusCode: error.status,
|
|
233
|
+
normalizedErrorCode: appErrorCode,
|
|
234
|
+
exposeDetails
|
|
235
|
+
})) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const payload = {
|
|
240
|
+
error: error.message,
|
|
241
|
+
code: appErrorCode,
|
|
242
|
+
...outcomeFields
|
|
243
|
+
};
|
|
244
|
+
if (error.details && exposeDetails) {
|
|
245
|
+
payload.details = error.details;
|
|
246
|
+
if (error.details.fieldErrors) {
|
|
247
|
+
payload.fieldErrors = error.details.fieldErrors;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
reply.code(error.status).send(payload);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (statusCode >= 500) {
|
|
256
|
+
app.log.error({ err: error }, "Unhandled error");
|
|
257
|
+
} else {
|
|
258
|
+
app.log.info({ err: error, statusCode }, "Request rejected");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const message = statusCode >= 500 ? "Internal server error." : String(error?.message || "Request failed.");
|
|
262
|
+
const fallbackErrorCode =
|
|
263
|
+
normalizedErrorCode || (statusCode >= 500 ? "internal_server_error" : "request_failed");
|
|
264
|
+
if (applyRouteTransportErrorResponse(reply, request, error, {
|
|
265
|
+
statusCode,
|
|
266
|
+
normalizedErrorCode: fallbackErrorCode,
|
|
267
|
+
message,
|
|
268
|
+
exposeDetails: statusCode < 500
|
|
269
|
+
})) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const payload = {
|
|
273
|
+
error: message,
|
|
274
|
+
code: fallbackErrorCode,
|
|
275
|
+
...outcomeFields
|
|
276
|
+
};
|
|
277
|
+
if (isCsrfErrorCode) {
|
|
278
|
+
payload.details = {
|
|
279
|
+
code: normalizedErrorCode
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
reply.code(statusCode).send(payload);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export {
|
|
287
|
+
registerJsonApiContentTypeParser,
|
|
288
|
+
normalizeBodylessContentTypeHeader,
|
|
289
|
+
registerBodylessContentTypeNormalizer,
|
|
290
|
+
registerApiErrorHandler
|
|
291
|
+
};
|