@jskit-ai/kernel 0.1.4

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.
Files changed (185) hide show
  1. package/README.md +24 -0
  2. package/_testable/index.js +4 -0
  3. package/client/appConfig.js +33 -0
  4. package/client/componentInteraction.js +51 -0
  5. package/client/componentInteraction.test.js +111 -0
  6. package/client/descriptorSections.js +75 -0
  7. package/client/index.d.ts +70 -0
  8. package/client/index.js +3 -0
  9. package/client/logging.js +38 -0
  10. package/client/moduleBootstrap.js +670 -0
  11. package/client/moduleBootstrap.test.js +403 -0
  12. package/client/shellBootstrap.js +233 -0
  13. package/client/shellBootstrap.test.js +185 -0
  14. package/client/shellRouting.js +321 -0
  15. package/client/shellRouting.test.js +113 -0
  16. package/client/vite/clientBootstrapPlugin.js +259 -0
  17. package/client/vite/clientBootstrapPlugin.test.js +563 -0
  18. package/client/vite/index.js +3 -0
  19. package/internal/node/fileSystem.js +21 -0
  20. package/internal/node/installedPackageDescriptor.js +104 -0
  21. package/package.json +43 -0
  22. package/server/actions/ActionRuntimeServiceProvider.js +309 -0
  23. package/server/actions/ActionRuntimeServiceProvider.test.js +551 -0
  24. package/server/actions/index.js +8 -0
  25. package/server/container/ContainerCoreServiceProvider.js +27 -0
  26. package/server/container/index.js +10 -0
  27. package/server/exportPolicy.test.js +68 -0
  28. package/server/http/HttpFastifyServiceProvider.js +25 -0
  29. package/server/http/_testable/index.js +2 -0
  30. package/server/http/index.js +1 -0
  31. package/server/http/lib/controller.js +183 -0
  32. package/server/http/lib/controller.test.js +143 -0
  33. package/server/http/lib/errors.js +12 -0
  34. package/server/http/lib/httpRuntime.js +82 -0
  35. package/server/http/lib/index.js +18 -0
  36. package/server/http/lib/kernel.js +15 -0
  37. package/server/http/lib/kernel.test.js +880 -0
  38. package/server/http/lib/middlewareRuntime.js +149 -0
  39. package/server/http/lib/requestActionExecutor.js +258 -0
  40. package/server/http/lib/requestScope.js +59 -0
  41. package/server/http/lib/routeRegistration.js +165 -0
  42. package/server/http/lib/routeSupport.js +45 -0
  43. package/server/http/lib/routeValidator.js +469 -0
  44. package/server/http/lib/routeValidator.test.js +474 -0
  45. package/server/http/lib/router.js +206 -0
  46. package/server/kernel/KernelCoreServiceProvider.js +27 -0
  47. package/server/kernel/index.js +10 -0
  48. package/server/platform/PlatformServerRuntimeServiceProvider.js +30 -0
  49. package/server/platform/index.js +5 -0
  50. package/server/platform/providerRuntime/descriptorCatalog.js +170 -0
  51. package/server/platform/providerRuntime/helpers.js +45 -0
  52. package/server/platform/providerRuntime/lockfile.js +27 -0
  53. package/server/platform/providerRuntime/providerLoader.js +283 -0
  54. package/server/platform/providerRuntime.js +142 -0
  55. package/server/platform/providerRuntime.test.js +217 -0
  56. package/server/platform/runtime.js +40 -0
  57. package/server/platform/surfaceRuntime.js +150 -0
  58. package/server/platform/surfaceRuntime.test.js +136 -0
  59. package/server/registries/actionSurfaceSourceRegistry.js +150 -0
  60. package/server/registries/bootstrapPayloadContributorRegistry.js +41 -0
  61. package/server/registries/domainEventListenerRegistry.js +61 -0
  62. package/server/registries/index.js +36 -0
  63. package/server/registries/primitives.js +63 -0
  64. package/server/registries/routeVisibilityResolverRegistry.js +87 -0
  65. package/server/registries/serviceRegistrationRegistry.js +431 -0
  66. package/server/runtime/ServerRuntimeCoreServiceProvider.js +65 -0
  67. package/server/runtime/ServerRuntimeCoreServiceProvider.test.js +53 -0
  68. package/server/runtime/apiRoutePolicyParity.test.js +109 -0
  69. package/server/runtime/apiRouteRegistration.js +65 -0
  70. package/server/runtime/bootBootstrapRoutes.js +46 -0
  71. package/server/runtime/bootBootstrapRoutes.test.js +79 -0
  72. package/server/runtime/bootstrapContributors.test.js +114 -0
  73. package/server/runtime/canonicalJson.js +74 -0
  74. package/server/runtime/composition.js +142 -0
  75. package/server/runtime/domainEvents.test.js +114 -0
  76. package/server/runtime/domainRules.js +50 -0
  77. package/server/runtime/domainRules.test.js +87 -0
  78. package/server/runtime/entityChangeEvents.js +182 -0
  79. package/server/runtime/entityChangeEvents.test.js +211 -0
  80. package/server/runtime/errors.js +68 -0
  81. package/server/runtime/errors.test.js +73 -0
  82. package/server/runtime/fastifyBootstrap.js +372 -0
  83. package/server/runtime/fastifyBootstrap.test.js +194 -0
  84. package/server/runtime/index.js +6 -0
  85. package/server/runtime/integers.js +13 -0
  86. package/server/runtime/moduleConfig.js +269 -0
  87. package/server/runtime/moduleConfig.test.js +141 -0
  88. package/server/runtime/pagination.js +13 -0
  89. package/server/runtime/realtimeNormalization.js +21 -0
  90. package/server/runtime/requestUrl.js +38 -0
  91. package/server/runtime/routeUtils.js +20 -0
  92. package/server/runtime/runtimeAssembly.js +113 -0
  93. package/server/runtime/runtimeKernel.js +55 -0
  94. package/server/runtime/securityAudit.js +269 -0
  95. package/server/runtime/securityAudit.test.js +41 -0
  96. package/server/runtime/serviceAuthorization.js +113 -0
  97. package/server/runtime/serviceAuthorization.test.js +100 -0
  98. package/server/runtime/serviceRegistration.test.js +197 -0
  99. package/server/support/SupportCoreServiceProvider.js +25 -0
  100. package/server/support/appConfig.js +37 -0
  101. package/server/support/appConfig.test.js +94 -0
  102. package/server/support/defaultMissingHandler.js +7 -0
  103. package/server/support/index.js +2 -0
  104. package/server/support/routePolicyConfig.js +51 -0
  105. package/server/support/symlinkSafeRequire.js +78 -0
  106. package/server/support/symlinkSafeRequire.test.js +27 -0
  107. package/server/surface/SurfaceRoutingServiceProvider.js +27 -0
  108. package/server/surface/index.js +19 -0
  109. package/shared/actions/actionContributorHelpers.js +34 -0
  110. package/shared/actions/actionContributorHelpers.test.js +16 -0
  111. package/shared/actions/actionDefinitions.js +488 -0
  112. package/shared/actions/actionDefinitions.test.js +212 -0
  113. package/shared/actions/audit.js +7 -0
  114. package/shared/actions/executionContext.js +97 -0
  115. package/shared/actions/executionContext.test.js +66 -0
  116. package/shared/actions/idempotency.js +62 -0
  117. package/shared/actions/index.js +2 -0
  118. package/shared/actions/observability.js +10 -0
  119. package/shared/actions/pipeline.js +287 -0
  120. package/shared/actions/policies.js +342 -0
  121. package/shared/actions/policies.test.js +233 -0
  122. package/shared/actions/registry.js +187 -0
  123. package/shared/actions/registry.test.js +381 -0
  124. package/shared/actions/requestMeta.js +36 -0
  125. package/shared/actions/textNormalization.js +3 -0
  126. package/shared/actions/withActionDefaults.js +34 -0
  127. package/shared/index.js +2 -0
  128. package/shared/runtime/application.js +323 -0
  129. package/shared/runtime/container.js +261 -0
  130. package/shared/runtime/containerErrors.js +22 -0
  131. package/shared/runtime/index.js +18 -0
  132. package/shared/runtime/kernelErrors.js +20 -0
  133. package/shared/runtime/serviceProvider.js +13 -0
  134. package/shared/support/formatDateTime.js +10 -0
  135. package/shared/support/formatDateTime.test.js +15 -0
  136. package/shared/support/index.js +14 -0
  137. package/shared/support/linkPath.js +67 -0
  138. package/shared/support/linkPath.test.js +35 -0
  139. package/shared/support/normalize.js +116 -0
  140. package/shared/support/normalize.test.js +48 -0
  141. package/shared/support/packageDescriptor.test.js +121 -0
  142. package/shared/support/permissions.js +50 -0
  143. package/shared/support/pickOwnProperties.js +17 -0
  144. package/shared/support/pickOwnProperties.test.js +25 -0
  145. package/shared/support/policies.js +11 -0
  146. package/shared/support/queryPath.js +33 -0
  147. package/shared/support/queryPath.test.js +19 -0
  148. package/shared/support/queryResilience.js +34 -0
  149. package/shared/support/queryResilience.test.js +33 -0
  150. package/shared/support/returnToPath.js +153 -0
  151. package/shared/support/returnToPath.test.js +123 -0
  152. package/shared/support/sorting.js +15 -0
  153. package/shared/support/tokens.js +23 -0
  154. package/shared/support/tokens.test.js +17 -0
  155. package/shared/support/visibility.js +56 -0
  156. package/shared/support/visibility.test.js +45 -0
  157. package/shared/surface/apiPaths.js +84 -0
  158. package/shared/surface/escapeRegExp.js +5 -0
  159. package/shared/surface/index.js +6 -0
  160. package/shared/surface/paths.js +273 -0
  161. package/shared/surface/registry.js +135 -0
  162. package/shared/surface/registry.test.js +44 -0
  163. package/shared/surface/runtime.js +357 -0
  164. package/shared/surface/runtime.test.js +319 -0
  165. package/shared/validators/createCursorListValidator.js +42 -0
  166. package/shared/validators/createCursorListValidator.test.js +34 -0
  167. package/shared/validators/cursorPaginationQueryValidator.js +31 -0
  168. package/shared/validators/cursorPaginationQueryValidator.test.js +21 -0
  169. package/shared/validators/index.js +12 -0
  170. package/shared/validators/inputNormalization.js +13 -0
  171. package/shared/validators/mergeObjectSchemas.js +31 -0
  172. package/shared/validators/mergeObjectSchemas.test.js +67 -0
  173. package/shared/validators/mergeValidators.js +89 -0
  174. package/shared/validators/mergeValidators.test.js +116 -0
  175. package/shared/validators/nestValidator.js +53 -0
  176. package/shared/validators/nestValidator.test.js +60 -0
  177. package/shared/validators/recordIdParamsValidator.js +36 -0
  178. package/shared/validators/recordIdParamsValidator.test.js +20 -0
  179. package/shared/validators/resourceRequiredMetadata.js +41 -0
  180. package/shared/validators/resourceRequiredMetadata.test.js +49 -0
  181. package/test/barrelExposure.test.js +106 -0
  182. package/test/dynamicImportPolicy.test.js +89 -0
  183. package/test/exportsContract.test.js +168 -0
  184. package/test/routeInputContractGuard.test.js +78 -0
  185. package/test/surfaceIndependence.test.js +109 -0
@@ -0,0 +1,880 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { KERNEL_TOKENS } from "../../../shared/support/tokens.js";
5
+ import { registerActionContextContributor } from "../../actions/ActionRuntimeServiceProvider.js";
6
+ import { createApplication } from "../../kernel/index.js";
7
+ import { createRouter } from "./router.js";
8
+ import { createHttpRuntime, registerHttpRuntime, registerRoutes } from "./kernel.js";
9
+ import { registerRouteVisibilityResolver } from "../../registries/routeVisibilityResolverRegistry.js";
10
+
11
+ function createFastifyStub() {
12
+ const routes = [];
13
+ return {
14
+ routes,
15
+ setErrorHandlerCalls: 0,
16
+ errorHandler: null,
17
+ route(definition) {
18
+ routes.push(definition);
19
+ },
20
+ setErrorHandler(handler) {
21
+ this.errorHandler = handler;
22
+ this.setErrorHandlerCalls += 1;
23
+ }
24
+ };
25
+ }
26
+
27
+ function createReplyStub() {
28
+ return {
29
+ sent: false,
30
+ statusCode: 200,
31
+ payload: undefined,
32
+ headers: {},
33
+ code(value) {
34
+ this.statusCode = Number(value);
35
+ return this;
36
+ },
37
+ header(name, value) {
38
+ this.headers[name] = value;
39
+ return this;
40
+ },
41
+ send(payload) {
42
+ this.payload = payload;
43
+ this.sent = true;
44
+ return this;
45
+ }
46
+ };
47
+ }
48
+
49
+ test("registerRoutes attaches request scope and request context tokens", async () => {
50
+ const fastify = createFastifyStub();
51
+ const app = createApplication();
52
+ const observed = {};
53
+
54
+ registerRoutes(fastify, {
55
+ app,
56
+ routes: [
57
+ {
58
+ method: "GET",
59
+ path: "/scope-check",
60
+ middleware: [
61
+ (request) => {
62
+ observed.middlewareRequest = request.scope.make(KERNEL_TOKENS.Request);
63
+ observed.middlewareReply = request.scope.make(KERNEL_TOKENS.Reply);
64
+ observed.middlewareRequestId = request.scope.make(KERNEL_TOKENS.RequestId);
65
+ observed.middlewareScope = request.scope.make(KERNEL_TOKENS.RequestScope);
66
+ }
67
+ ],
68
+ handler: async (request, reply) => {
69
+ observed.handlerScope = request.scope;
70
+ observed.handlerRequest = request.scope.make(KERNEL_TOKENS.Request);
71
+ observed.handlerRequestId = request.scope.make(KERNEL_TOKENS.RequestId);
72
+ reply.code(200).send({ ok: true });
73
+ }
74
+ }
75
+ ]
76
+ });
77
+
78
+ const request = { id: "req-123" };
79
+ const reply = createReplyStub();
80
+
81
+ await fastify.routes[0].handler(request, reply);
82
+
83
+ assert.equal(reply.statusCode, 200);
84
+ assert.equal(request.scope.scopeId, "http:req-123");
85
+ assert.equal(observed.middlewareRequest, request);
86
+ assert.equal(observed.middlewareReply, reply);
87
+ assert.equal(observed.middlewareRequestId, "req-123");
88
+ assert.equal(observed.middlewareScope, request.scope);
89
+ assert.equal(observed.handlerScope, request.scope);
90
+ assert.equal(observed.handlerRequest, request);
91
+ assert.equal(observed.handlerRequestId, "req-123");
92
+ });
93
+
94
+ test("registerRoutes attaches request.executeAction and applies action context contributors", async () => {
95
+ const fastify = createFastifyStub();
96
+ const app = createApplication();
97
+ const observed = [];
98
+
99
+ registerActionContextContributor(app, "test.auth.actionContextContributor", () => ({
100
+ contributorId: "test.auth",
101
+ contribute({ request }) {
102
+ return {
103
+ actor: request?.user || null,
104
+ permissions: Array.isArray(request?.permissions) ? request.permissions.slice() : [],
105
+ workspace: request?.workspace || null,
106
+ membership: request?.membership || null
107
+ };
108
+ }
109
+ }));
110
+
111
+ app.instance("actionExecutor", {
112
+ async execute(payload) {
113
+ observed.push(payload);
114
+ return {
115
+ ok: true
116
+ };
117
+ }
118
+ });
119
+
120
+ registerRoutes(fastify, {
121
+ app,
122
+ routes: [
123
+ {
124
+ method: "GET",
125
+ path: "/action-helper",
126
+ surface: "coffie",
127
+ middleware: [
128
+ (request) => {
129
+ request.user = {
130
+ id: 7,
131
+ email: "user@example.com"
132
+ };
133
+ request.permissions = ["settings.read"];
134
+ request.workspace = {
135
+ id: 10,
136
+ slug: "main"
137
+ };
138
+ request.membership = {
139
+ roleId: "owner"
140
+ };
141
+ }
142
+ ],
143
+ handler: async (request, reply) => {
144
+ await request.executeAction({
145
+ actionId: "settings.read",
146
+ input: {
147
+ locale: "en-US"
148
+ },
149
+ context: {
150
+ requestMeta: {
151
+ commandId: "cmd-1"
152
+ }
153
+ }
154
+ });
155
+
156
+ await request.executeAction({
157
+ actionId: "settings.override",
158
+ context: {
159
+ actor: {
160
+ id: 99
161
+ },
162
+ permissions: ["*"],
163
+ surface: "console",
164
+ channel: "internal"
165
+ }
166
+ });
167
+
168
+ reply.code(200).send({
169
+ ok: true
170
+ });
171
+ }
172
+ }
173
+ ]
174
+ });
175
+
176
+ const request = {
177
+ id: "req-55",
178
+ raw: {
179
+ url: "/api/admin/workspace/settings?mode=full"
180
+ }
181
+ };
182
+ const reply = createReplyStub();
183
+
184
+ await fastify.routes[0].handler(request, reply);
185
+
186
+ assert.equal(reply.statusCode, 200);
187
+ assert.equal(typeof request.executeAction, "function");
188
+ assert.equal(Object.prototype.propertyIsEnumerable.call(request, "executeAction"), false);
189
+ assert.equal(observed.length, 2);
190
+
191
+ assert.equal(observed[0].actionId, "settings.read");
192
+ assert.deepEqual(observed[0].input, { locale: "en-US" });
193
+ assert.equal(observed[0].context.actor?.id, 7);
194
+ assert.deepEqual(observed[0].context.permissions, ["settings.read"]);
195
+ assert.equal(observed[0].context.workspace?.id, 10);
196
+ assert.equal(observed[0].context.membership?.roleId, "owner");
197
+ assert.equal(observed[0].context.surface, "coffie");
198
+ assert.equal(observed[0].context.channel, "api");
199
+ assert.equal(observed[0].context.requestMeta.commandId, "cmd-1");
200
+ assert.equal(observed[0].context.requestMeta.request, request);
201
+
202
+ assert.equal(observed[1].actionId, "settings.override");
203
+ assert.equal(observed[1].context.actor?.id, 99);
204
+ assert.deepEqual(observed[1].context.permissions, ["*"]);
205
+ assert.equal(observed[1].context.surface, "console");
206
+ assert.equal(observed[1].context.channel, "internal");
207
+ });
208
+
209
+ test("registerRoutes attaches visibilityContext from route visibility resolvers", async () => {
210
+ const fastify = createFastifyStub();
211
+ const app = createApplication();
212
+ const observed = [];
213
+
214
+ registerActionContextContributor(app, "test.auth.actionContextContributor", () => ({
215
+ contributorId: "test.auth",
216
+ contribute({ request }) {
217
+ return {
218
+ actor: request?.user || null
219
+ };
220
+ }
221
+ }));
222
+
223
+ registerRouteVisibilityResolver(app, "test.http.visibilityResolver", () => ({
224
+ resolverId: "test.visibility",
225
+ resolve({ visibility, context }) {
226
+ if (visibility !== "user") {
227
+ return {};
228
+ }
229
+
230
+ return {
231
+ userOwnerId: context?.actor?.id,
232
+ requiresActorScope: true
233
+ };
234
+ }
235
+ }));
236
+
237
+ app.instance("actionExecutor", {
238
+ async execute(payload) {
239
+ observed.push(payload);
240
+ return { ok: true };
241
+ }
242
+ });
243
+
244
+ registerRoutes(fastify, {
245
+ app,
246
+ routes: [
247
+ {
248
+ method: "GET",
249
+ path: "/visible",
250
+ visibility: "user",
251
+ handler: async (request, reply) => {
252
+ request.user = {
253
+ id: 23
254
+ };
255
+ await request.executeAction({
256
+ actionId: "contacts.list"
257
+ });
258
+ reply.code(200).send({ ok: true });
259
+ }
260
+ }
261
+ ]
262
+ });
263
+
264
+ const request = { id: "req-visible" };
265
+ const reply = createReplyStub();
266
+
267
+ await fastify.routes[0].handler(request, reply);
268
+
269
+ assert.equal(reply.statusCode, 200);
270
+ assert.equal(observed.length, 1);
271
+ assert.deepEqual(observed[0].context.visibilityContext, {
272
+ visibility: "user",
273
+ scopeKind: null,
274
+ requiresActorScope: true,
275
+ scopeOwnerId: null,
276
+ userOwnerId: 23
277
+ });
278
+ assert.deepEqual(observed[0].context.requestMeta.visibilityContext, observed[0].context.visibilityContext);
279
+ assert.equal(observed[0].context.requestMeta.routeVisibility, "user");
280
+ });
281
+
282
+ test("registerRoutes keeps actor scope requirement for core user visibility without resolver opt-in", async () => {
283
+ const fastify = createFastifyStub();
284
+ const app = createApplication();
285
+ const observed = [];
286
+
287
+ registerActionContextContributor(app, "test.auth.actionContextContributor", () => ({
288
+ contributorId: "test.auth",
289
+ contribute({ request }) {
290
+ return {
291
+ actor: request?.user || null
292
+ };
293
+ }
294
+ }));
295
+
296
+ registerRouteVisibilityResolver(app, "test.http.visibilityResolver", () => ({
297
+ resolverId: "test.visibility",
298
+ resolve({ visibility, context }) {
299
+ if (visibility !== "user") {
300
+ return {};
301
+ }
302
+
303
+ return {
304
+ userOwnerId: context?.actor?.id
305
+ };
306
+ }
307
+ }));
308
+
309
+ app.instance("actionExecutor", {
310
+ async execute(payload) {
311
+ observed.push(payload);
312
+ return { ok: true };
313
+ }
314
+ });
315
+
316
+ registerRoutes(fastify, {
317
+ app,
318
+ routes: [
319
+ {
320
+ method: "GET",
321
+ path: "/visible-no-actor-scope",
322
+ visibility: "user",
323
+ handler: async (request, reply) => {
324
+ request.user = {
325
+ id: 23
326
+ };
327
+ await request.executeAction({
328
+ actionId: "contacts.list"
329
+ });
330
+ reply.code(200).send({ ok: true });
331
+ }
332
+ }
333
+ ]
334
+ });
335
+
336
+ const request = { id: "req-visible-no-actor-scope" };
337
+ const reply = createReplyStub();
338
+
339
+ await fastify.routes[0].handler(request, reply);
340
+
341
+ assert.equal(reply.statusCode, 200);
342
+ assert.equal(observed.length, 1);
343
+ assert.deepEqual(observed[0].context.visibilityContext, {
344
+ visibility: "user",
345
+ scopeKind: null,
346
+ requiresActorScope: true,
347
+ scopeOwnerId: null,
348
+ userOwnerId: 23
349
+ });
350
+ assert.equal(observed[0].context.requestMeta.routeVisibility, "user");
351
+ });
352
+
353
+ test("registerRoutes does not infer actor scope from non-core route visibility tokens", async () => {
354
+ const fastify = createFastifyStub();
355
+ const app = createApplication();
356
+ const observed = [];
357
+
358
+ app.instance("actionExecutor", {
359
+ async execute(payload) {
360
+ observed.push(payload);
361
+ return { ok: true };
362
+ }
363
+ });
364
+
365
+ registerRoutes(fastify, {
366
+ app,
367
+ routes: [
368
+ {
369
+ method: "GET",
370
+ path: "/scoped-visible",
371
+ visibility: "workspace_user",
372
+ handler: async (request, reply) => {
373
+ await request.executeAction({
374
+ actionId: "contacts.list"
375
+ });
376
+ reply.code(200).send({ ok: true });
377
+ }
378
+ }
379
+ ]
380
+ });
381
+
382
+ const request = { id: "req-scoped-visible" };
383
+ const reply = createReplyStub();
384
+
385
+ await fastify.routes[0].handler(request, reply);
386
+
387
+ assert.equal(reply.statusCode, 200);
388
+ assert.equal(observed.length, 1);
389
+ assert.deepEqual(observed[0].context.visibilityContext, {
390
+ visibility: "workspace_user",
391
+ scopeKind: null,
392
+ requiresActorScope: false,
393
+ scopeOwnerId: null,
394
+ userOwnerId: null
395
+ });
396
+ assert.equal(observed[0].context.requestMeta.routeVisibility, "workspace_user");
397
+ });
398
+
399
+ test("registerRoutes can disable request scope attachment", async () => {
400
+ const fastify = createFastifyStub();
401
+ const app = createApplication();
402
+
403
+ registerRoutes(fastify, {
404
+ app,
405
+ enableRequestScope: false,
406
+ routes: [
407
+ {
408
+ method: "GET",
409
+ path: "/scope-disabled",
410
+ handler: async (request, reply) => {
411
+ assert.equal(request.scope, undefined);
412
+ reply.code(200).send({ ok: true });
413
+ }
414
+ }
415
+ ]
416
+ });
417
+
418
+ const request = { id: "req-999" };
419
+ const reply = createReplyStub();
420
+
421
+ await fastify.routes[0].handler(request, reply);
422
+
423
+ assert.equal(reply.statusCode, 200);
424
+ assert.equal(request.scope, undefined);
425
+ });
426
+
427
+ test("registerRoutes supports custom request scope property and requestId resolver", async () => {
428
+ const fastify = createFastifyStub();
429
+ const app = createApplication();
430
+
431
+ registerRoutes(fastify, {
432
+ app,
433
+ requestScopeProperty: "requestScope",
434
+ requestScopeIdPrefix: "request",
435
+ requestIdResolver: (request) => request.meta?.requestKey,
436
+ routes: [
437
+ {
438
+ method: "GET",
439
+ path: "/scope-custom",
440
+ handler: async (request, reply) => {
441
+ assert.equal(Boolean(request.scope), false);
442
+ assert.equal(Boolean(request.requestScope), true);
443
+ assert.equal(request.requestScope.scopeId, "request:r-42");
444
+ assert.equal(request.requestScope.make(KERNEL_TOKENS.RequestId), "r-42");
445
+ reply.code(200).send({ ok: true });
446
+ }
447
+ }
448
+ ]
449
+ });
450
+
451
+ const request = {
452
+ id: "ignored",
453
+ meta: {
454
+ requestKey: "r-42"
455
+ }
456
+ };
457
+ const reply = createReplyStub();
458
+
459
+ await fastify.routes[0].handler(request, reply);
460
+
461
+ assert.equal(reply.statusCode, 200);
462
+ });
463
+
464
+ test("registerHttpRuntime passes app context so request scope is available", async () => {
465
+ const app = createApplication();
466
+ const fastify = createFastifyStub();
467
+ const router = createRouter();
468
+
469
+ router.get("/runtime-scope", async (request, reply) => {
470
+ const requestId = request.scope.make(KERNEL_TOKENS.RequestId);
471
+ reply.code(200).send({ requestId });
472
+ });
473
+
474
+ app.instance(KERNEL_TOKENS.Fastify, fastify);
475
+ app.instance(KERNEL_TOKENS.HttpRouter, router);
476
+
477
+ const registration = registerHttpRuntime(app);
478
+ assert.equal(registration.routeCount, 1);
479
+
480
+ const request = { id: "runtime-1" };
481
+ const reply = createReplyStub();
482
+
483
+ await fastify.routes[0].handler(request, reply);
484
+
485
+ assert.equal(reply.statusCode, 200);
486
+ assert.deepEqual(reply.payload, { requestId: "runtime-1" });
487
+ assert.equal(fastify.setErrorHandlerCalls, 1);
488
+ });
489
+
490
+ test("registerHttpRuntime installs API error handling once by default", () => {
491
+ const app = createApplication();
492
+ const fastify = createFastifyStub();
493
+ const router = createRouter();
494
+
495
+ app.instance(KERNEL_TOKENS.Fastify, fastify);
496
+ app.instance(KERNEL_TOKENS.HttpRouter, router);
497
+
498
+ registerHttpRuntime(app);
499
+ registerHttpRuntime(app);
500
+
501
+ assert.equal(fastify.setErrorHandlerCalls, 1);
502
+ assert.equal(typeof fastify.errorHandler, "function");
503
+ });
504
+
505
+ test("registerHttpRuntime can disable automatic API error handling", () => {
506
+ const app = createApplication();
507
+ const fastify = createFastifyStub();
508
+ const router = createRouter();
509
+
510
+ app.instance(KERNEL_TOKENS.Fastify, fastify);
511
+ app.instance(KERNEL_TOKENS.HttpRouter, router);
512
+
513
+ registerHttpRuntime(app, {
514
+ autoRegisterApiErrorHandling: false
515
+ });
516
+
517
+ assert.equal(fastify.setErrorHandlerCalls, 0);
518
+ });
519
+
520
+ test("registerHttpRuntime resolves request action default surface from appConfig", async () => {
521
+ const app = createApplication();
522
+ const fastify = createFastifyStub();
523
+ const router = createRouter();
524
+ const observed = [];
525
+
526
+ router.get("/runtime-default-surface", async (request, reply) => {
527
+ await request.executeAction({
528
+ actionId: "surface.default"
529
+ });
530
+ reply.code(200).send({ ok: true });
531
+ });
532
+
533
+ app.instance(KERNEL_TOKENS.Fastify, fastify);
534
+ app.instance(KERNEL_TOKENS.HttpRouter, router);
535
+ app.instance("appConfig", {
536
+ surfaceDefaultId: "home"
537
+ });
538
+ app.instance("actionExecutor", {
539
+ async execute(payload) {
540
+ observed.push(payload);
541
+ return { ok: true };
542
+ }
543
+ });
544
+
545
+ registerHttpRuntime(app);
546
+
547
+ const request = { id: "runtime-default-surface" };
548
+ const reply = createReplyStub();
549
+ await fastify.routes[0].handler(request, reply);
550
+
551
+ assert.equal(reply.statusCode, 200);
552
+ assert.equal(observed.length, 1);
553
+ assert.equal(observed[0].context.surface, "home");
554
+ });
555
+
556
+ test("registerRoutes leaves request action surface empty when no default is configured", async () => {
557
+ const fastify = createFastifyStub();
558
+ const app = createApplication();
559
+ const observed = [];
560
+
561
+ app.instance("actionExecutor", {
562
+ async execute(payload) {
563
+ observed.push(payload);
564
+ return { ok: true };
565
+ }
566
+ });
567
+
568
+ registerRoutes(fastify, {
569
+ app,
570
+ routes: [
571
+ {
572
+ method: "GET",
573
+ path: "/public-default-surface",
574
+ handler: async (request, reply) => {
575
+ await request.executeAction({
576
+ actionId: "surface.default.public"
577
+ });
578
+ reply.code(200).send({ ok: true });
579
+ }
580
+ }
581
+ ]
582
+ });
583
+
584
+ const request = { id: "public-default-surface" };
585
+ const reply = createReplyStub();
586
+ await fastify.routes[0].handler(request, reply);
587
+
588
+ assert.equal(reply.statusCode, 200);
589
+ assert.equal(observed.length, 1);
590
+ assert.equal(observed[0].context.surface, "");
591
+ });
592
+
593
+ test("createHttpRuntime installs API error handling when Fastify is provided", () => {
594
+ const app = createApplication();
595
+ const fastify = createFastifyStub();
596
+
597
+ createHttpRuntime({
598
+ app,
599
+ fastify
600
+ });
601
+
602
+ assert.equal(fastify.setErrorHandlerCalls, 1);
603
+ });
604
+
605
+ test("registerHttpRuntime forwards middleware alias/group config to route execution", async () => {
606
+ const app = createApplication();
607
+ const fastify = createFastifyStub();
608
+ const router = createRouter();
609
+ const observed = [];
610
+
611
+ router.get(
612
+ "/runtime-middleware",
613
+ {
614
+ middleware: ["api"]
615
+ },
616
+ async (_request, reply) => {
617
+ observed.push("handler");
618
+ reply.code(200).send({
619
+ ok: true
620
+ });
621
+ }
622
+ );
623
+
624
+ app.instance(KERNEL_TOKENS.Fastify, fastify);
625
+ app.instance(KERNEL_TOKENS.HttpRouter, router);
626
+
627
+ registerHttpRuntime(app, {
628
+ middleware: {
629
+ aliases: {
630
+ auth: async () => {
631
+ observed.push("auth");
632
+ },
633
+ "throttle:60,1": async () => {
634
+ observed.push("throttle");
635
+ }
636
+ },
637
+ groups: {
638
+ api: ["auth", "throttle:60,1"]
639
+ }
640
+ }
641
+ });
642
+
643
+ const request = {};
644
+ const reply = createReplyStub();
645
+ await fastify.routes[0].handler(request, reply);
646
+
647
+ assert.equal(reply.statusCode, 200);
648
+ assert.deepEqual(observed, ["auth", "throttle", "handler"]);
649
+ });
650
+
651
+ test("registerRoutes attaches request.input when route input transforms are configured", async () => {
652
+ const fastify = createFastifyStub();
653
+
654
+ registerRoutes(fastify, {
655
+ routes: [
656
+ {
657
+ method: "POST",
658
+ path: "/input-transform",
659
+ input: {
660
+ body: (body) => ({
661
+ name: String(body?.name || "").trim(),
662
+ email: String(body?.email || "")
663
+ .trim()
664
+ .toLowerCase()
665
+ }),
666
+ query: (query) => ({
667
+ dryRun: query?.dryRun === true
668
+ })
669
+ },
670
+ middleware: [
671
+ (request) => {
672
+ assert.deepEqual(request.input, {
673
+ body: {
674
+ name: "Alice",
675
+ email: "alice@example.com"
676
+ },
677
+ query: {
678
+ dryRun: true
679
+ },
680
+ params: undefined
681
+ });
682
+ }
683
+ ],
684
+ handler: async (request, reply) => {
685
+ assert.deepEqual(request.input.body, {
686
+ name: "Alice",
687
+ email: "alice@example.com"
688
+ });
689
+ assert.deepEqual(request.input.query, { dryRun: true });
690
+ reply.code(200).send({ ok: true });
691
+ }
692
+ }
693
+ ]
694
+ });
695
+
696
+ const request = {
697
+ body: {
698
+ name: " Alice ",
699
+ email: " ALICE@EXAMPLE.COM "
700
+ },
701
+ query: {
702
+ dryRun: true
703
+ }
704
+ };
705
+ const reply = createReplyStub();
706
+
707
+ await fastify.routes[0].handler(request, reply);
708
+
709
+ assert.equal(reply.statusCode, 200);
710
+ });
711
+
712
+ test("registerRoutes leaves request.input undefined when route input is not configured", async () => {
713
+ const fastify = createFastifyStub();
714
+
715
+ registerRoutes(fastify, {
716
+ routes: [
717
+ {
718
+ method: "GET",
719
+ path: "/no-input-transform",
720
+ handler: async (request, reply) => {
721
+ assert.equal(request.input, undefined);
722
+ reply.code(200).send({ ok: true });
723
+ }
724
+ }
725
+ ]
726
+ });
727
+
728
+ const request = {};
729
+ const reply = createReplyStub();
730
+
731
+ await fastify.routes[0].handler(request, reply);
732
+ assert.equal(reply.statusCode, 200);
733
+ });
734
+
735
+ test("registerRoutes rejects invalid route input transform definitions", () => {
736
+ const fastify = createFastifyStub();
737
+
738
+ assert.throws(
739
+ () =>
740
+ registerRoutes(fastify, {
741
+ routes: [
742
+ {
743
+ method: "POST",
744
+ path: "/invalid-input",
745
+ input: {
746
+ body: "not-a-function"
747
+ },
748
+ handler: async () => {}
749
+ }
750
+ ]
751
+ }),
752
+ /input\.body must be a function/
753
+ );
754
+ });
755
+
756
+ test("registerRoutes resolves middleware aliases and groups", async () => {
757
+ const fastify = createFastifyStub();
758
+ const observed = {
759
+ traces: []
760
+ };
761
+
762
+ const requireAuth = async (request) => {
763
+ observed.traces.push("auth");
764
+ request.user = {
765
+ id: 7
766
+ };
767
+ };
768
+ const throttle = async () => {
769
+ observed.traces.push("throttle");
770
+ };
771
+ const attachAuditContext = async (request) => {
772
+ observed.traces.push("audit");
773
+ request.audit = {
774
+ requestScoped: true
775
+ };
776
+ };
777
+
778
+ registerRoutes(fastify, {
779
+ middleware: {
780
+ aliases: {
781
+ auth: requireAuth,
782
+ "throttle:60,1": throttle,
783
+ audit: attachAuditContext
784
+ },
785
+ groups: {
786
+ api: ["auth", "throttle:60,1", "audit"],
787
+ publicApi: ["throttle:60,1"]
788
+ }
789
+ },
790
+ routes: [
791
+ {
792
+ method: "GET",
793
+ path: "/contacts",
794
+ middleware: ["api"],
795
+ handler: async (request, reply) => {
796
+ assert.equal(request.user?.id, 7);
797
+ assert.equal(request.audit?.requestScoped, true);
798
+ reply.code(200).send({
799
+ ok: true
800
+ });
801
+ }
802
+ },
803
+ {
804
+ method: "GET",
805
+ path: "/public/ping",
806
+ middleware: ["publicApi"],
807
+ handler: async (request, reply) => {
808
+ assert.equal(request.user, undefined);
809
+ assert.equal(request.audit, undefined);
810
+ reply.code(200).send({
811
+ ok: true
812
+ });
813
+ }
814
+ }
815
+ ]
816
+ });
817
+
818
+ const contactsReply = createReplyStub();
819
+ await fastify.routes[0].handler({}, contactsReply);
820
+ assert.equal(contactsReply.statusCode, 200);
821
+ assert.deepEqual(observed.traces, ["auth", "throttle", "audit"]);
822
+
823
+ observed.traces.length = 0;
824
+ const publicReply = createReplyStub();
825
+ await fastify.routes[1].handler({}, publicReply);
826
+ assert.equal(publicReply.statusCode, 200);
827
+ assert.deepEqual(observed.traces, ["throttle"]);
828
+ });
829
+
830
+ test("registerRoutes rejects unknown named middleware references", () => {
831
+ const fastify = createFastifyStub();
832
+
833
+ assert.throws(
834
+ () =>
835
+ registerRoutes(fastify, {
836
+ middleware: {
837
+ aliases: {
838
+ auth: async () => {}
839
+ },
840
+ groups: {
841
+ api: ["auth"]
842
+ }
843
+ },
844
+ routes: [
845
+ {
846
+ method: "GET",
847
+ path: "/contacts",
848
+ middleware: ["missing-group"],
849
+ handler: async () => {}
850
+ }
851
+ ]
852
+ }),
853
+ /unknown middleware "missing-group"/
854
+ );
855
+ });
856
+
857
+ test("registerRoutes rejects middleware group cycles", () => {
858
+ const fastify = createFastifyStub();
859
+
860
+ assert.throws(
861
+ () =>
862
+ registerRoutes(fastify, {
863
+ middleware: {
864
+ groups: {
865
+ api: ["audited"],
866
+ audited: ["api"]
867
+ }
868
+ },
869
+ routes: [
870
+ {
871
+ method: "GET",
872
+ path: "/contacts",
873
+ middleware: ["api"],
874
+ handler: async () => {}
875
+ }
876
+ ]
877
+ }),
878
+ /middleware group cycle detected/
879
+ );
880
+ });