@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,87 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { DomainValidationError } from "./errors.js";
5
+ import { assertNoDomainRuleFailures, collectDomainFieldErrors } from "./domainRules.js";
6
+
7
+ test("collectDomainFieldErrors collects string and object rule outcomes", () => {
8
+ const fieldErrors = collectDomainFieldErrors([
9
+ {
10
+ field: "name",
11
+ check: () => "name is required"
12
+ },
13
+ {
14
+ field: "email",
15
+ check: () => ({
16
+ message: "invalid email"
17
+ })
18
+ },
19
+ {
20
+ field: "country",
21
+ when: () => false,
22
+ check: () => "country not allowed"
23
+ }
24
+ ]);
25
+
26
+ assert.deepEqual(fieldErrors, {
27
+ name: "name is required",
28
+ email: "invalid email"
29
+ });
30
+ });
31
+
32
+ test("assertNoDomainRuleFailures does not throw when rules pass", () => {
33
+ assert.doesNotThrow(() =>
34
+ assertNoDomainRuleFailures([
35
+ {
36
+ field: "name",
37
+ check: () => null
38
+ }
39
+ ])
40
+ );
41
+ });
42
+
43
+ test("assertNoDomainRuleFailures throws DomainValidationError with fieldErrors", () => {
44
+ assert.throws(
45
+ () =>
46
+ assertNoDomainRuleFailures([
47
+ {
48
+ field: "plan",
49
+ check: () => "starter plan supports up to 200 employees"
50
+ }
51
+ ]),
52
+ (error) => {
53
+ assert.equal(error instanceof DomainValidationError, true);
54
+ assert.equal(error.code, "domain_validation_failed");
55
+ assert.deepEqual(error.details, {
56
+ fieldErrors: {
57
+ plan: "starter plan supports up to 200 employees"
58
+ }
59
+ });
60
+ return true;
61
+ }
62
+ );
63
+ });
64
+
65
+ test("assertNoDomainRuleFailures applies custom message and code", () => {
66
+ assert.throws(
67
+ () =>
68
+ assertNoDomainRuleFailures(
69
+ [
70
+ {
71
+ field: "email",
72
+ check: () => "email must include @"
73
+ }
74
+ ],
75
+ {
76
+ message: "Contact domain validation failed.",
77
+ code: "contact_domain_invalid"
78
+ }
79
+ ),
80
+ (error) => {
81
+ assert.equal(error instanceof DomainValidationError, true);
82
+ assert.equal(error.message, "Contact domain validation failed.");
83
+ assert.equal(error.code, "contact_domain_invalid");
84
+ return true;
85
+ }
86
+ );
87
+ });
@@ -0,0 +1,182 @@
1
+ import { normalizeObject, normalizeOpaqueId, normalizeText } from "../../shared/support/normalize.js";
2
+ import { resolveServiceContext } from "./serviceAuthorization.js";
3
+
4
+ const ENTITY_CHANGE_OPERATIONS = new Set(["created", "updated", "deleted"]);
5
+
6
+ function resolveContextScope(context = {}) {
7
+ const sourceScope = normalizeObject(context?.scope || context?.requestMeta?.scope || context?.request?.scope);
8
+ const kind = normalizeText(sourceScope.kind).toLowerCase();
9
+ if (!kind) {
10
+ return null;
11
+ }
12
+ if (kind === "global") {
13
+ return {
14
+ kind: "global",
15
+ id: null
16
+ };
17
+ }
18
+
19
+ const scopeId = normalizeOpaqueId(sourceScope.id);
20
+ if (scopeId == null) {
21
+ return null;
22
+ }
23
+
24
+ const resolvedScope = {
25
+ kind,
26
+ id: scopeId
27
+ };
28
+
29
+ const scopeUserId = normalizeOpaqueId(sourceScope.userId);
30
+ if (scopeUserId != null) {
31
+ resolvedScope.userId = scopeUserId;
32
+ }
33
+
34
+ const scopedScopeId = normalizeOpaqueId(sourceScope.scopeId);
35
+ if (scopedScopeId != null) {
36
+ resolvedScope.scopeId = scopedScopeId;
37
+ }
38
+
39
+ return resolvedScope;
40
+ }
41
+
42
+ function resolveVisibilityScope(visibilityContext = {}, runtimeContext = {}) {
43
+ const visibility = normalizeText(visibilityContext.visibility).toLowerCase();
44
+ const scopeKind = normalizeText(visibilityContext.scopeKind || visibility).toLowerCase();
45
+ const scopeOwnerId = normalizeOpaqueId(visibilityContext.scopeOwnerId);
46
+ const userOwnerId = normalizeOpaqueId(visibilityContext.userOwnerId);
47
+ const requiresActorScope = visibilityContext.requiresActorScope === true;
48
+
49
+ if (requiresActorScope && userOwnerId == null) {
50
+ return null;
51
+ }
52
+
53
+ if (scopeKind && scopeOwnerId != null) {
54
+ const scope = {
55
+ kind: scopeKind,
56
+ id: scopeOwnerId
57
+ };
58
+ if (requiresActorScope) {
59
+ scope.scopeId = scopeOwnerId;
60
+ scope.userId = userOwnerId;
61
+ }
62
+ return scope;
63
+ }
64
+ if (scopeKind && scopeKind !== "user") {
65
+ return null;
66
+ }
67
+
68
+ if (!scopeKind && scopeOwnerId != null) {
69
+ return {
70
+ kind: "scope",
71
+ id: scopeOwnerId
72
+ };
73
+ }
74
+
75
+ if (scopeKind === "user" && userOwnerId != null) {
76
+ return {
77
+ kind: "user",
78
+ id: userOwnerId
79
+ };
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ function resolveDefaultScope(visibilityContext = {}, runtime = {}) {
86
+ const runtimeContext = normalizeObject(runtime?.context);
87
+
88
+ const visibilityScope = resolveVisibilityScope(visibilityContext, runtimeContext);
89
+ if (visibilityScope) {
90
+ return visibilityScope;
91
+ }
92
+
93
+ const contextScope = resolveContextScope(runtimeContext);
94
+ if (contextScope) {
95
+ return contextScope;
96
+ }
97
+
98
+ return {
99
+ kind: "global",
100
+ id: null
101
+ };
102
+ }
103
+
104
+ function resolveCommandId(requestMeta = {}) {
105
+ return normalizeText(requestMeta.commandId || requestMeta.idempotencyKey || "") || null;
106
+ }
107
+
108
+ function resolveSourceClientId(requestMeta = {}) {
109
+ return normalizeText(requestMeta.sourceClientId) || null;
110
+ }
111
+
112
+ function createEntityChangePublisher({
113
+ domainEvents,
114
+ source,
115
+ entity,
116
+ scopeResolver = resolveDefaultScope
117
+ } = {}) {
118
+ if (!domainEvents || typeof domainEvents.publish !== "function") {
119
+ throw new TypeError("createEntityChangePublisher requires domainEvents.publish().");
120
+ }
121
+
122
+ const normalizedSource = normalizeText(source);
123
+ if (!normalizedSource) {
124
+ throw new TypeError("createEntityChangePublisher requires source.");
125
+ }
126
+
127
+ const normalizedEntity = normalizeText(entity);
128
+ if (!normalizedEntity) {
129
+ throw new TypeError("createEntityChangePublisher requires entity.");
130
+ }
131
+
132
+ return async function publishEntityChange(operation, entityId, options = {}, meta = null) {
133
+ const normalizedOperation = normalizeText(operation).toLowerCase();
134
+ if (!ENTITY_CHANGE_OPERATIONS.has(normalizedOperation)) {
135
+ throw new TypeError("publishEntityChange operation must be one of: created, updated, deleted.");
136
+ }
137
+
138
+ const normalizedEntityId = normalizeOpaqueId(entityId);
139
+ if (normalizedEntityId == null) {
140
+ return null;
141
+ }
142
+
143
+ const context = resolveServiceContext(options);
144
+ const requestMeta = normalizeObject(context.requestMeta);
145
+ const visibilityContext = normalizeObject(options.visibilityContext || context.visibilityContext);
146
+ const scope = scopeResolver(visibilityContext, {
147
+ context,
148
+ options
149
+ });
150
+
151
+ const payload = {
152
+ source: normalizedSource,
153
+ entity: normalizedEntity,
154
+ operation: normalizedOperation,
155
+ entityId: normalizedEntityId,
156
+ scope: scope && typeof scope === "object" ? scope : resolveDefaultScope(visibilityContext),
157
+ actorId: normalizeOpaqueId(context?.actor?.id),
158
+ commandId: resolveCommandId(requestMeta),
159
+ sourceClientId: resolveSourceClientId(requestMeta),
160
+ occurredAt: new Date().toISOString()
161
+ };
162
+ const normalizedMeta = normalizeObject(meta);
163
+ if (Object.keys(normalizedMeta).length > 0) {
164
+ payload.meta = normalizedMeta;
165
+ }
166
+
167
+ await domainEvents.publish(payload);
168
+ return payload;
169
+ };
170
+ }
171
+
172
+ function createNoopEntityChangePublisher() {
173
+ return async function publishEntityChange() {
174
+ return null;
175
+ };
176
+ }
177
+
178
+ export {
179
+ resolveDefaultScope,
180
+ createEntityChangePublisher,
181
+ createNoopEntityChangePublisher
182
+ };
@@ -0,0 +1,211 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createEntityChangePublisher } from "./entityChangeEvents.js";
4
+
5
+ test("entity change publisher emits normalized event payload", async () => {
6
+ const published = [];
7
+ const publishEntityChange = createEntityChangePublisher({
8
+ domainEvents: {
9
+ async publish(payload) {
10
+ published.push(payload);
11
+ }
12
+ },
13
+ source: "crud",
14
+ entity: "record"
15
+ });
16
+
17
+ const payload = await publishEntityChange("created", 5, {
18
+ context: {
19
+ actor: { id: 17 },
20
+ requestMeta: {
21
+ commandId: "cmd-1",
22
+ sourceClientId: "client-a"
23
+ },
24
+ visibilityContext: {
25
+ scopeOwnerId: 23
26
+ }
27
+ }
28
+ }, {
29
+ service: {
30
+ token: "crud.customers",
31
+ method: "createRecord"
32
+ }
33
+ });
34
+
35
+ assert.equal(payload?.operation, "created");
36
+ assert.equal(payload?.entityId, 5);
37
+ assert.deepEqual(payload?.scope, { kind: "scope", id: 23 });
38
+ assert.equal(payload?.actorId, 17);
39
+ assert.equal(payload?.commandId, "cmd-1");
40
+ assert.equal(payload?.sourceClientId, "client-a");
41
+ assert.equal(payload?.meta?.service?.token, "crud.customers");
42
+ assert.equal(payload?.meta?.service?.method, "createRecord");
43
+ assert.equal(published.length, 1);
44
+ });
45
+
46
+ test("entity change publisher accepts opaque entity identifiers", async () => {
47
+ const published = [];
48
+ const publishEntityChange = createEntityChangePublisher({
49
+ domainEvents: {
50
+ async publish(payload) {
51
+ published.push(payload);
52
+ }
53
+ },
54
+ source: "crud",
55
+ entity: "record"
56
+ });
57
+
58
+ const payload = await publishEntityChange("updated", "record_4f5d2f6a-607e-4b4b-9bfa-4f8c2b1d3c8e");
59
+
60
+ assert.equal(payload?.entityId, "record_4f5d2f6a-607e-4b4b-9bfa-4f8c2b1d3c8e");
61
+ assert.equal(published.length, 1);
62
+ });
63
+
64
+ test("entity change publisher ignores missing entity identifiers", async () => {
65
+ const published = [];
66
+ const publishEntityChange = createEntityChangePublisher({
67
+ domainEvents: {
68
+ async publish(payload) {
69
+ published.push(payload);
70
+ }
71
+ },
72
+ source: "crud",
73
+ entity: "record"
74
+ });
75
+
76
+ const payload = await publishEntityChange("updated", null);
77
+
78
+ assert.equal(payload, null);
79
+ assert.equal(published.length, 0);
80
+ });
81
+
82
+ test("entity change publisher infers scoped owner from service context when visibility owner ids are missing", async () => {
83
+ const published = [];
84
+ const publishEntityChange = createEntityChangePublisher({
85
+ domainEvents: {
86
+ async publish(payload) {
87
+ published.push(payload);
88
+ }
89
+ },
90
+ source: "workspace",
91
+ entity: "settings"
92
+ });
93
+
94
+ const payload = await publishEntityChange(
95
+ "updated",
96
+ 11,
97
+ {
98
+ context: {
99
+ actor: { id: 17 },
100
+ scope: { kind: "workspace", id: 23 },
101
+ visibilityContext: {
102
+ visibility: "workspace"
103
+ }
104
+ }
105
+ },
106
+ {
107
+ service: {
108
+ token: "users.workspace.settings.service",
109
+ method: "updateWorkspaceSettings"
110
+ }
111
+ }
112
+ );
113
+
114
+ assert.deepEqual(payload?.scope, { kind: "workspace", id: 23 });
115
+ assert.equal(published.length, 1);
116
+ });
117
+
118
+ test("entity change publisher supports opaque actor and scope identifiers", async () => {
119
+ const published = [];
120
+ const publishEntityChange = createEntityChangePublisher({
121
+ domainEvents: {
122
+ async publish(payload) {
123
+ published.push(payload);
124
+ }
125
+ },
126
+ source: "workspace",
127
+ entity: "settings"
128
+ });
129
+
130
+ const payload = await publishEntityChange("updated", 11, {
131
+ context: {
132
+ actor: { id: "user_17" },
133
+ visibilityContext: {
134
+ scopeKind: "workspace_user",
135
+ scopeOwnerId: "workspace_23",
136
+ userOwnerId: "user_17",
137
+ requiresActorScope: true
138
+ }
139
+ }
140
+ });
141
+
142
+ assert.deepEqual(payload?.scope, {
143
+ kind: "workspace_user",
144
+ id: "workspace_23",
145
+ scopeId: "workspace_23",
146
+ userId: "user_17"
147
+ });
148
+ assert.equal(payload?.actorId, "user_17");
149
+ assert.equal(published.length, 1);
150
+ });
151
+
152
+ test("entity change publisher does not infer actor-scoped ownership from actor.id", async () => {
153
+ const published = [];
154
+ const publishEntityChange = createEntityChangePublisher({
155
+ domainEvents: {
156
+ async publish(payload) {
157
+ published.push(payload);
158
+ }
159
+ },
160
+ source: "workspace",
161
+ entity: "settings"
162
+ });
163
+
164
+ const payload = await publishEntityChange("updated", 11, {
165
+ context: {
166
+ actor: { id: "user_17" },
167
+ visibilityContext: {
168
+ scopeKind: "workspace_user",
169
+ scopeOwnerId: "workspace_23",
170
+ requiresActorScope: true
171
+ }
172
+ }
173
+ });
174
+
175
+ assert.deepEqual(payload?.scope, {
176
+ kind: "global",
177
+ id: null
178
+ });
179
+ assert.equal(payload?.actorId, "user_17");
180
+ assert.equal(published.length, 1);
181
+ });
182
+
183
+ test("entity change publisher does not infer actor scope from scope kind suffix", async () => {
184
+ const published = [];
185
+ const publishEntityChange = createEntityChangePublisher({
186
+ domainEvents: {
187
+ async publish(payload) {
188
+ published.push(payload);
189
+ }
190
+ },
191
+ source: "workspace",
192
+ entity: "settings"
193
+ });
194
+
195
+ const payload = await publishEntityChange("updated", 11, {
196
+ context: {
197
+ visibilityContext: {
198
+ scopeKind: "workspace_user",
199
+ scopeOwnerId: "workspace_23",
200
+ requiresActorScope: false
201
+ }
202
+ }
203
+ });
204
+
205
+ assert.deepEqual(payload?.scope, {
206
+ kind: "workspace_user",
207
+ id: "workspace_23"
208
+ });
209
+ assert.equal(payload?.actorId, null);
210
+ assert.equal(published.length, 1);
211
+ });
@@ -0,0 +1,68 @@
1
+ export class AppError extends Error {
2
+ constructor(status, message, options = {}) {
3
+ super(message);
4
+ this.name = "AppError";
5
+ this.status = Number(status) || 500;
6
+ this.statusCode = this.status;
7
+ this.code = options.code || "APP_ERROR";
8
+ this.details = options.details;
9
+ this.headers = options.headers || {};
10
+ }
11
+ }
12
+
13
+ export class DomainError extends AppError {
14
+ constructor(status, message, options = {}) {
15
+ super(status, message, {
16
+ ...options,
17
+ code: options.code || "domain_error"
18
+ });
19
+ this.name = "DomainError";
20
+ }
21
+ }
22
+
23
+ export class DomainValidationError extends DomainError {
24
+ constructor(details, options = {}) {
25
+ super(422, options.message || "Domain validation failed.", {
26
+ ...options,
27
+ code: options.code || "domain_validation_failed",
28
+ details
29
+ });
30
+ this.name = "DomainValidationError";
31
+ }
32
+ }
33
+
34
+ export class ConflictError extends DomainError {
35
+ constructor(message = "Conflict.", options = {}) {
36
+ super(409, message, {
37
+ ...options,
38
+ code: options.code || "conflict"
39
+ });
40
+ this.name = "ConflictError";
41
+ }
42
+ }
43
+
44
+ export class NotFoundError extends DomainError {
45
+ constructor(message = "Not found.", options = {}) {
46
+ super(404, message, {
47
+ ...options,
48
+ code: options.code || "not_found"
49
+ });
50
+ this.name = "NotFoundError";
51
+ }
52
+ }
53
+
54
+ export function isAppError(error) {
55
+ return error instanceof AppError;
56
+ }
57
+
58
+ export function isDomainError(error) {
59
+ return error instanceof DomainError;
60
+ }
61
+
62
+ export function createValidationError(fieldErrors) {
63
+ return new AppError(400, "Validation failed.", {
64
+ details: {
65
+ fieldErrors
66
+ }
67
+ });
68
+ }
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import {
5
+ AppError,
6
+ DomainError,
7
+ DomainValidationError,
8
+ ConflictError,
9
+ NotFoundError,
10
+ isAppError,
11
+ isDomainError,
12
+ createValidationError
13
+ } from "./errors.js";
14
+
15
+ test("DomainValidationError defaults to 422 and domain_validation_failed", () => {
16
+ const error = new DomainValidationError(["email is required"]);
17
+
18
+ assert.equal(error.status, 422);
19
+ assert.equal(error.statusCode, 422);
20
+ assert.equal(error.code, "domain_validation_failed");
21
+ assert.equal(error.message, "Domain validation failed.");
22
+ assert.deepEqual(error.details, ["email is required"]);
23
+ assert.equal(error.name, "DomainValidationError");
24
+ });
25
+
26
+ test("ConflictError defaults to 409 and conflict", () => {
27
+ const error = new ConflictError("Duplicate contact.", {
28
+ details: { email: "alice@example.com" }
29
+ });
30
+
31
+ assert.equal(error.status, 409);
32
+ assert.equal(error.code, "conflict");
33
+ assert.equal(error.message, "Duplicate contact.");
34
+ assert.deepEqual(error.details, { email: "alice@example.com" });
35
+ assert.equal(error.name, "ConflictError");
36
+ });
37
+
38
+ test("NotFoundError defaults to 404 and not_found", () => {
39
+ const error = new NotFoundError("Contact not found.", {
40
+ details: { id: "contact-1" }
41
+ });
42
+
43
+ assert.equal(error.status, 404);
44
+ assert.equal(error.code, "not_found");
45
+ assert.equal(error.message, "Contact not found.");
46
+ assert.deepEqual(error.details, { id: "contact-1" });
47
+ assert.equal(error.name, "NotFoundError");
48
+ });
49
+
50
+ test("isDomainError identifies DomainError subclasses and isAppError remains true", () => {
51
+ const domainError = new DomainError(422, "Domain failed.", {
52
+ code: "domain_failed"
53
+ });
54
+
55
+ assert.equal(isDomainError(domainError), true);
56
+ assert.equal(isAppError(domainError), true);
57
+
58
+ const appError = new AppError(500, "App failed.");
59
+ assert.equal(isDomainError(appError), false);
60
+ assert.equal(isAppError(appError), true);
61
+ });
62
+
63
+ test("createValidationError remains compatible", () => {
64
+ const error = createValidationError({ email: "Invalid." });
65
+
66
+ assert.equal(error.status, 400);
67
+ assert.equal(error.message, "Validation failed.");
68
+ assert.deepEqual(error.details, {
69
+ fieldErrors: {
70
+ email: "Invalid."
71
+ }
72
+ });
73
+ });