@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,269 @@
1
+ import { parsePositiveInteger } from "./integers.js";
2
+ import { safePathnameFromRequest, resolveClientIpAddress } from "./requestUrl.js";
3
+ import { normalizeSurfaceId } from "../../shared/surface/registry.js";
4
+ import { normalizeOpaqueId } from "../../shared/support/normalize.js";
5
+ import { resolveDefaultSurfaceId } from "../support/appConfig.js";
6
+
7
+ function resolveAuditSurface(pathnameValue, explicitSurface = "", resolveSurfaceFromPathname = null, defaultSurfaceId = "") {
8
+ const normalizedExplicit = normalizeSurfaceId(explicitSurface);
9
+ if (normalizedExplicit) {
10
+ return normalizedExplicit;
11
+ }
12
+
13
+ if (typeof resolveSurfaceFromPathname === "function") {
14
+ const resolvedSurface = normalizeSurfaceId(resolveSurfaceFromPathname(pathnameValue));
15
+ if (resolvedSurface) {
16
+ return resolvedSurface;
17
+ }
18
+ }
19
+
20
+ return resolveDefaultSurfaceId(null, {
21
+ defaultSurfaceId
22
+ });
23
+ }
24
+
25
+ function buildAuditEventBase(request, { resolveSurfaceFromPathname = null, defaultSurfaceId = "" } = {}) {
26
+ const pathnameValue = safePathnameFromRequest(request);
27
+ return {
28
+ actorId: normalizeOpaqueId(request?.user?.id),
29
+ actorEmail: String(request?.user?.email || "")
30
+ .trim()
31
+ .toLowerCase(),
32
+ surface: resolveAuditSurface(pathnameValue, request?.surface, resolveSurfaceFromPathname, defaultSurfaceId),
33
+ requestId: String(request?.id || "").trim(),
34
+ method: String(request?.method || "")
35
+ .trim()
36
+ .toUpperCase(),
37
+ path: pathnameValue,
38
+ ipAddress: resolveClientIpAddress(request),
39
+ userAgent: String(request?.headers?.["user-agent"] || "")
40
+ };
41
+ }
42
+
43
+ function buildAuditError(error) {
44
+ const status = parsePositiveInteger(error?.status || error?.statusCode);
45
+ return {
46
+ name: String(error?.name || "Error"),
47
+ code: String(error?.code || ""),
48
+ status: status || null
49
+ };
50
+ }
51
+
52
+ function normalizeObjectPayload(value) {
53
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
54
+ return {};
55
+ }
56
+
57
+ return value;
58
+ }
59
+
60
+ function resolveObjectPayload(value, context) {
61
+ const resolvedValue = typeof value === "function" ? value(context) : value;
62
+ return normalizeObjectPayload(resolvedValue);
63
+ }
64
+
65
+ function mergeMetadataPayloads(...payloads) {
66
+ const merged = {};
67
+ for (const payloadEntry of payloads) {
68
+ const payload = normalizeObjectPayload(payloadEntry);
69
+ for (const [key, value] of Object.entries(payload)) {
70
+ merged[key] = value;
71
+ }
72
+ }
73
+
74
+ return merged;
75
+ }
76
+
77
+ function buildEventPayload({ action, outcome, shared, event, metadata, context }) {
78
+ const sharedPayload = resolveObjectPayload(shared, context);
79
+ const eventPayload = resolveObjectPayload(event, context);
80
+ const sharedMetadata = resolveObjectPayload(metadata, context);
81
+ const eventMetadata = resolveObjectPayload(eventPayload.metadata, context);
82
+ const mergedMetadata = mergeMetadataPayloads(sharedMetadata, eventMetadata);
83
+
84
+ const payload = {
85
+ ...sharedPayload,
86
+ ...eventPayload,
87
+ action,
88
+ outcome
89
+ };
90
+
91
+ if (Object.keys(mergedMetadata).length > 0) {
92
+ payload.metadata = mergedMetadata;
93
+ } else {
94
+ delete payload.metadata;
95
+ }
96
+
97
+ return payload;
98
+ }
99
+
100
+ function mergeFailureMetadata(error, metadata) {
101
+ const normalizedMetadata =
102
+ metadata && typeof metadata === "object" && !Array.isArray(metadata) ? { ...metadata } : {};
103
+ if (!Object.hasOwn(normalizedMetadata, "error")) {
104
+ normalizedMetadata.error = buildAuditError(error);
105
+ }
106
+
107
+ return normalizedMetadata;
108
+ }
109
+
110
+ function logAuditFailure(request, payload, message) {
111
+ if (!request?.log || typeof request.log.warn !== "function") {
112
+ return;
113
+ }
114
+
115
+ request.log.warn(payload, message);
116
+ }
117
+
118
+ function safeBuildEventPayload({ request, action, outcome, shared, event, metadata, context, stage }) {
119
+ try {
120
+ return buildEventPayload({
121
+ action,
122
+ outcome,
123
+ shared,
124
+ event,
125
+ metadata,
126
+ context
127
+ });
128
+ } catch (error) {
129
+ logAuditFailure(
130
+ request,
131
+ {
132
+ action,
133
+ outcome,
134
+ stage,
135
+ callbackError: buildAuditError(error)
136
+ },
137
+ "security.audit.callback_failed"
138
+ );
139
+ return {
140
+ action,
141
+ outcome
142
+ };
143
+ }
144
+ }
145
+
146
+ async function recordAuditEvent({ auditService, request, event, resolveSurfaceFromPathname = null, defaultSurfaceId = "" }) {
147
+ await auditService.recordSafe(
148
+ {
149
+ ...buildAuditEventBase(request, {
150
+ resolveSurfaceFromPathname,
151
+ defaultSurfaceId
152
+ }),
153
+ ...event
154
+ },
155
+ request?.log
156
+ );
157
+ }
158
+
159
+ async function safeRecordAuditEvent({ auditService, request, event, resolveSurfaceFromPathname = null, defaultSurfaceId = "" }) {
160
+ try {
161
+ await recordAuditEvent({
162
+ auditService,
163
+ request,
164
+ event,
165
+ resolveSurfaceFromPathname,
166
+ defaultSurfaceId
167
+ });
168
+ } catch (error) {
169
+ logAuditFailure(
170
+ request,
171
+ {
172
+ action: String(event?.action || ""),
173
+ outcome: String(event?.outcome || ""),
174
+ recordError: buildAuditError(error)
175
+ },
176
+ "security.audit.record_unexpected_failure"
177
+ );
178
+ }
179
+ }
180
+
181
+ async function withAuditEvent({
182
+ auditService,
183
+ request,
184
+ action,
185
+ execute,
186
+ shared,
187
+ metadata,
188
+ onSuccess,
189
+ onFailure,
190
+ resolveSurfaceFromPathname = null,
191
+ defaultSurfaceId = ""
192
+ }) {
193
+ if (!auditService || typeof auditService.recordSafe !== "function") {
194
+ throw new TypeError("withAuditEvent auditService.recordSafe is required.");
195
+ }
196
+ if (typeof execute !== "function") {
197
+ throw new TypeError("withAuditEvent execute callback is required.");
198
+ }
199
+
200
+ try {
201
+ const result = await execute();
202
+ const successEvent = safeBuildEventPayload({
203
+ request,
204
+ action,
205
+ outcome: "success",
206
+ shared,
207
+ event: onSuccess,
208
+ metadata,
209
+ context: {
210
+ request,
211
+ result,
212
+ error: null,
213
+ outcome: "success"
214
+ },
215
+ stage: "success"
216
+ });
217
+ await safeRecordAuditEvent({
218
+ auditService,
219
+ request,
220
+ event: successEvent,
221
+ resolveSurfaceFromPathname,
222
+ defaultSurfaceId
223
+ });
224
+ return result;
225
+ } catch (error) {
226
+ const failureEvent = safeBuildEventPayload({
227
+ request,
228
+ action,
229
+ outcome: "failure",
230
+ shared,
231
+ event: onFailure,
232
+ metadata,
233
+ context: {
234
+ request,
235
+ result: null,
236
+ error,
237
+ outcome: "failure"
238
+ },
239
+ stage: "failure"
240
+ });
241
+
242
+ await safeRecordAuditEvent({
243
+ auditService,
244
+ request,
245
+ event: {
246
+ ...failureEvent,
247
+ metadata: mergeFailureMetadata(error, failureEvent.metadata)
248
+ },
249
+ resolveSurfaceFromPathname,
250
+ defaultSurfaceId
251
+ });
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ const __testables = {
257
+ normalizeSurfaceId,
258
+ resolveAuditSurface,
259
+ buildAuditEventBase,
260
+ buildAuditError,
261
+ normalizeObjectPayload,
262
+ resolveObjectPayload,
263
+ mergeMetadataPayloads,
264
+ buildEventPayload,
265
+ mergeFailureMetadata,
266
+ safeBuildEventPayload
267
+ };
268
+
269
+ export { buildAuditEventBase, buildAuditError, recordAuditEvent, withAuditEvent, __testables };
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { buildAuditEventBase, __testables } from "./securityAudit.js";
5
+
6
+ const { resolveAuditSurface } = __testables;
7
+
8
+ test("resolveAuditSurface prefers explicit surface", () => {
9
+ assert.equal(resolveAuditSurface("/", " Admin "), "admin");
10
+ });
11
+
12
+ test("resolveAuditSurface resolves surface from pathname resolver before default", () => {
13
+ const resolved = resolveAuditSurface(
14
+ "/admin/users",
15
+ "",
16
+ (pathname) => (pathname.startsWith("/admin") ? "console" : ""),
17
+ "home"
18
+ );
19
+
20
+ assert.equal(resolved, "console");
21
+ });
22
+
23
+ test("resolveAuditSurface uses configured default and falls back to empty surface", () => {
24
+ assert.equal(resolveAuditSurface("/", "", null, "home"), "home");
25
+ assert.equal(resolveAuditSurface("/", "", null, ""), "");
26
+ });
27
+
28
+ test("buildAuditEventBase uses default surface id when request surface is missing", () => {
29
+ const event = buildAuditEventBase(
30
+ {
31
+ id: "req-1",
32
+ method: "GET",
33
+ headers: {}
34
+ },
35
+ {
36
+ defaultSurfaceId: "console"
37
+ }
38
+ );
39
+
40
+ assert.equal(event.surface, "console");
41
+ });
@@ -0,0 +1,113 @@
1
+ import { normalizeObject, normalizeOpaqueId, normalizeText } from "../../shared/support/normalize.js";
2
+ import { hasPermission, normalizePermissionList } from "../../shared/support/permissions.js";
3
+ import { AppError } from "./errors.js";
4
+
5
+ const AUTH_REQUIRE_MODES = new Set(["none", "authenticated", "all", "any"]);
6
+
7
+ function resolveServiceContext(source = {}) {
8
+ const payload = normalizeObject(source);
9
+
10
+ if (payload.context && typeof payload.context === "object" && !Array.isArray(payload.context)) {
11
+ return payload.context;
12
+ }
13
+
14
+ if (payload.actionContext && typeof payload.actionContext === "object" && !Array.isArray(payload.actionContext)) {
15
+ return payload.actionContext;
16
+ }
17
+
18
+ return payload;
19
+ }
20
+
21
+ function resolveRequireMode(value) {
22
+ const mode = normalizeText(value || "authenticated").toLowerCase();
23
+ if (!AUTH_REQUIRE_MODES.has(mode)) {
24
+ throw new TypeError("requireAuth options.require must be one of: none, authenticated, all, any.");
25
+ }
26
+ return mode;
27
+ }
28
+
29
+ function normalizeRequireAuthOptions(options = {}, { context = "requireAuth options" } = {}) {
30
+ const source = normalizeObject(options);
31
+ const mode = resolveRequireMode(source.require);
32
+ const permissions = normalizePermissionList(source.permissions);
33
+
34
+ return Object.freeze({
35
+ require: mode,
36
+ permissions: Object.freeze(permissions),
37
+ message: normalizeText(source.message),
38
+ code: normalizeText(source.code),
39
+ context
40
+ });
41
+ }
42
+
43
+ function requireAuthenticatedActor(context = {}, options = {}) {
44
+ const actor = normalizeObject(context.actor);
45
+ const actorId = normalizeOpaqueId(actor.id);
46
+
47
+ if (actorId == null) {
48
+ throw new AppError(401, options.message || "Authentication required.", {
49
+ code: options.code || "AUTHENTICATION_REQUIRED"
50
+ });
51
+ }
52
+
53
+ return {
54
+ ...actor,
55
+ id: actorId
56
+ };
57
+ }
58
+
59
+ function requireAuth(source = {}, options = {}) {
60
+ const context = resolveServiceContext(source);
61
+ const settings = normalizeRequireAuthOptions(options);
62
+ const mode = settings.require;
63
+
64
+ if (mode === "none") {
65
+ return null;
66
+ }
67
+
68
+ const actor = requireAuthenticatedActor(context, settings);
69
+
70
+ if (mode === "authenticated") {
71
+ return actor;
72
+ }
73
+
74
+ const requiredPermissions = normalizePermissionList(settings.permissions);
75
+ if (requiredPermissions.length < 1) {
76
+ return actor;
77
+ }
78
+ const actorPermissions = normalizePermissionList(context.permissions);
79
+
80
+ if (mode === "all") {
81
+ for (const permission of requiredPermissions) {
82
+ if (hasPermission(actorPermissions, permission)) {
83
+ continue;
84
+ }
85
+
86
+ throw new AppError(403, settings.message || "Forbidden.", {
87
+ code: settings.code || "PERMISSION_DENIED",
88
+ details: {
89
+ permission
90
+ }
91
+ });
92
+ }
93
+ return actor;
94
+ }
95
+
96
+ const allowed = requiredPermissions.some((permission) => hasPermission(actorPermissions, permission));
97
+ if (!allowed) {
98
+ throw new AppError(403, settings.message || "Forbidden.", {
99
+ code: settings.code || "PERMISSION_DENIED",
100
+ details: {
101
+ requiredPermissions
102
+ }
103
+ });
104
+ }
105
+
106
+ return actor;
107
+ }
108
+
109
+ export {
110
+ resolveServiceContext,
111
+ hasPermission,
112
+ requireAuth
113
+ };
@@ -0,0 +1,100 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { requireAuth } from "./serviceAuthorization.js";
4
+
5
+ test("requireAuth allows public mode without actor", () => {
6
+ assert.doesNotThrow(() =>
7
+ requireAuth(
8
+ {
9
+ context: {}
10
+ },
11
+ {
12
+ require: "none"
13
+ }
14
+ )
15
+ );
16
+ });
17
+
18
+ test("requireAuth accepts actor context", () => {
19
+ const actor = requireAuth({
20
+ context: {
21
+ actor: {
22
+ id: 7
23
+ }
24
+ }
25
+ });
26
+
27
+ assert.equal(actor.id, 7);
28
+ });
29
+
30
+ test("requireAuth accepts non-numeric actor ids", () => {
31
+ const actor = requireAuth({
32
+ context: {
33
+ actor: {
34
+ id: "user-7"
35
+ }
36
+ }
37
+ });
38
+
39
+ assert.equal(actor.id, "user-7");
40
+ });
41
+
42
+ test("requireAuth throws when actor is missing", () => {
43
+ assert.throws(
44
+ () => requireAuth({ context: {} }),
45
+ (error) => error?.statusCode === 401 && error?.code === "AUTHENTICATION_REQUIRED"
46
+ );
47
+ });
48
+
49
+ test("requireAuth enforces all permissions", () => {
50
+ assert.throws(
51
+ () =>
52
+ requireAuth(
53
+ {
54
+ context: {
55
+ actor: { id: 1 },
56
+ permissions: ["workspace.settings.view"]
57
+ }
58
+ },
59
+ {
60
+ require: "all",
61
+ permissions: ["workspace.settings.update"]
62
+ }
63
+ ),
64
+ (error) => error?.statusCode === 403 && error?.code === "PERMISSION_DENIED"
65
+ );
66
+ });
67
+
68
+ test("requireAuth allows wildcard permission for any mode", () => {
69
+ assert.doesNotThrow(() =>
70
+ requireAuth(
71
+ {
72
+ context: {
73
+ actor: { id: 1 },
74
+ permissions: ["*"]
75
+ }
76
+ },
77
+ {
78
+ require: "any",
79
+ permissions: ["workspace.settings.view", "workspace.settings.update"]
80
+ }
81
+ )
82
+ );
83
+ });
84
+
85
+ test("requireAuth allows namespace wildcard permissions", () => {
86
+ assert.doesNotThrow(() =>
87
+ requireAuth(
88
+ {
89
+ context: {
90
+ actor: { id: 1 },
91
+ permissions: ["crud_contacts.*"]
92
+ }
93
+ },
94
+ {
95
+ require: "all",
96
+ permissions: ["crud_contacts.update"]
97
+ }
98
+ )
99
+ );
100
+ });
@@ -0,0 +1,197 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createContainer } from "../container/index.js";
4
+ import {
5
+ installServiceRegistrationApi,
6
+ resolveServiceRegistrations
7
+ } from "../registries/serviceRegistrationRegistry.js";
8
+
9
+ test("installServiceRegistrationApi exposes app.service and publishes declared events", async () => {
10
+ const app = createContainer();
11
+ const published = [];
12
+ app.singleton("domainEvents", () => ({
13
+ async publish(payload) {
14
+ published.push(payload);
15
+ return null;
16
+ }
17
+ }));
18
+
19
+ installServiceRegistrationApi(app);
20
+ assert.equal(typeof app.service, "function");
21
+
22
+ app.service(
23
+ "test.customers.service",
24
+ () => ({
25
+ async createRecord() {
26
+ return { id: 41 };
27
+ }
28
+ }),
29
+ {
30
+ events: {
31
+ createRecord: [
32
+ {
33
+ type: "entity.changed",
34
+ source: "crud",
35
+ entity: "record",
36
+ operation: "created",
37
+ realtime: {
38
+ event: "customers.record.changed"
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ );
45
+
46
+ const service = app.make("test.customers.service");
47
+ const result = await service.createRecord({
48
+ context: {
49
+ actor: { id: 7 },
50
+ visibilityContext: {
51
+ scopeOwnerId: 13
52
+ }
53
+ }
54
+ });
55
+
56
+ assert.equal(result.id, 41);
57
+ assert.equal(service.serviceEvents.createRecord[0].realtime.audience, "event_scope");
58
+ assert.equal(published.length, 1);
59
+ assert.equal(published[0].source, "crud");
60
+ assert.equal(published[0].operation, "created");
61
+ assert.equal(published[0].meta?.service?.token, "test.customers.service");
62
+ assert.equal(published[0].meta?.service?.method, "createRecord");
63
+ assert.equal(published[0].meta?.realtime?.event, "customers.record.changed");
64
+ });
65
+
66
+ test("app.service rejects deprecated permissions metadata", () => {
67
+ const app = createContainer();
68
+ app.singleton("domainEvents", () => ({
69
+ async publish() {
70
+ return null;
71
+ }
72
+ }));
73
+ installServiceRegistrationApi(app);
74
+
75
+ assert.throws(
76
+ () =>
77
+ app.service(
78
+ "test.secure.service",
79
+ () => ({
80
+ async listRecords() {
81
+ return [];
82
+ }
83
+ }),
84
+ {
85
+ permissions: {
86
+ listRecords: {
87
+ require: "authenticated"
88
+ }
89
+ }
90
+ }
91
+ ),
92
+ /metadata\.permissions is no longer supported/
93
+ );
94
+ });
95
+
96
+ test("resolveServiceRegistrations returns declared service metadata", () => {
97
+ const app = createContainer();
98
+ app.singleton("domainEvents", () => ({
99
+ async publish() {
100
+ return null;
101
+ }
102
+ }));
103
+ installServiceRegistrationApi(app);
104
+
105
+ app.service(
106
+ "test.registry.service",
107
+ () => ({
108
+ async createRecord() {
109
+ return { id: 1 };
110
+ }
111
+ })
112
+ );
113
+
114
+ const entries = resolveServiceRegistrations(app);
115
+ assert.equal(entries.length, 1);
116
+ assert.equal(entries[0].serviceToken, "test.registry.service");
117
+ assert.deepEqual(entries[0].metadata, {
118
+ events: {}
119
+ });
120
+ });
121
+
122
+ test("app.service keeps realtime audience callbacks", () => {
123
+ const app = createContainer();
124
+ app.singleton("domainEvents", () => ({
125
+ async publish() {
126
+ return null;
127
+ }
128
+ }));
129
+ installServiceRegistrationApi(app);
130
+
131
+ const audience = () => ({ userId: 7 });
132
+ app.service(
133
+ "test.audience.service",
134
+ () => ({
135
+ async updateRecord() {
136
+ return { id: 2 };
137
+ }
138
+ }),
139
+ {
140
+ events: {
141
+ updateRecord: [
142
+ {
143
+ type: "entity.changed",
144
+ source: "crud",
145
+ entity: "record",
146
+ operation: "updated",
147
+ realtime: {
148
+ event: "customers.record.changed",
149
+ audience
150
+ }
151
+ }
152
+ ]
153
+ }
154
+ }
155
+ );
156
+
157
+ const service = app.make("test.audience.service");
158
+ assert.equal(typeof service.serviceEvents.updateRecord[0].realtime.audience, "function");
159
+ });
160
+
161
+ test("app.service accepts opaque realtime audience string presets", () => {
162
+ const app = createContainer();
163
+ app.singleton("domainEvents", () => ({
164
+ async publish() {
165
+ return null;
166
+ }
167
+ }));
168
+ installServiceRegistrationApi(app);
169
+
170
+ app.service(
171
+ "test.opaque.audience.service",
172
+ () => ({
173
+ async updateRecord() {
174
+ return { id: 2 };
175
+ }
176
+ }),
177
+ {
178
+ events: {
179
+ updateRecord: [
180
+ {
181
+ type: "entity.changed",
182
+ source: "crud",
183
+ entity: "record",
184
+ operation: "updated",
185
+ realtime: {
186
+ event: "customers.record.changed",
187
+ audience: "workspace_member"
188
+ }
189
+ }
190
+ ]
191
+ }
192
+ }
193
+ );
194
+
195
+ const service = app.make("test.opaque.audience.service");
196
+ assert.equal(service.serviceEvents.updateRecord[0].realtime.audience, "workspace_member");
197
+ });