@onlineapps/service-wrapper 3.4.7 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/service-wrapper",
3
- "version": "3.4.7",
3
+ "version": "4.1.0",
4
4
  "description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -33,7 +33,7 @@
33
33
  "@onlineapps/conn-orch-cookbook": "2.1.4",
34
34
  "@onlineapps/conn-orch-orchestrator": "2.1.7",
35
35
  "@onlineapps/conn-orch-registry": "1.2.2",
36
- "@onlineapps/conn-orch-validator": "3.3.2",
36
+ "@onlineapps/conn-orch-validator": "4.0.0",
37
37
  "@onlineapps/infrastructure-tools": "1.2.6",
38
38
  "@onlineapps/monitoring-core": "1.0.26",
39
39
  "@onlineapps/runtime-config": "1.0.2",
@@ -9,6 +9,8 @@
9
9
  * modules, no third-party deps. Logger is injected via constructor (DI).
10
10
  *
11
11
  * Exports:
12
+ * - BUSINESS_ERROR_BRAND — Symbol.for('onlineapps.businessError'), the mark.
13
+ * - isBusinessError(err) — brand + shape predicate (the contract itself).
12
14
  * - ErrorMapper — maps exceptions to { status, error: {...} } envelopes.
13
15
  * - BusinessError — base class for handler-authored errors (code + status required).
14
16
  * - ValidationError — 400 VALIDATION_FAILED. Thrown by SchemaValidator
@@ -22,6 +24,32 @@
22
24
  * - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
23
25
  * - AbortError — thrown when ctx.abortSignal aborts the invocation.
24
26
  *
27
+ * THE CONTRACT IS A BRAND PLUS A SHAPE, NOT A CLASS IDENTITY (DÁVKA 50).
28
+ * Owner decision: api/docs/governance/confirmations/business-error-contract.md
29
+ * (20260829-1500-business-error-contract-001).
30
+ *
31
+ * An error is a business error when it carries ALL of:
32
+ * - the brand `Symbol.for('onlineapps.businessError')` with value `true`
33
+ * (a GLOBAL registry symbol, so it survives two copies of this package in
34
+ * node_modules and can be claimed by a service's own class);
35
+ * - `code` — string matching /^[A-Z][A-Z0-9_]*$/;
36
+ * - `status` — number in [100, 599];
37
+ * - `message` — non-empty string.
38
+ *
39
+ * `map()` therefore dispatches on `code`, never on `instanceof`. `instanceof`
40
+ * remains true for our own classes but is no longer what decides anything.
41
+ *
42
+ * The two outcomes for an error that is not a business error are deliberately
43
+ * different, and that difference is the point:
44
+ * - BRANDED but malformed → OUR defect. 500 `MALFORMED_BUSINESS_ERROR` and an
45
+ * `error` log naming the offending field. Loud, never anonymous.
46
+ * - UNBRANDED → a genuinely unknown error. 500 `INTERNAL_ERROR`,
47
+ * message and details masked. Unchanged.
48
+ *
49
+ * A service may throw its own class as long as it declares the brand and the
50
+ * shape. Extending `BusinessError` is the easy way to get both; it is not the
51
+ * only permitted one.
52
+ *
25
53
  * Two constructor shapes, because the parameter sets genuinely differ:
26
54
  * - `BusinessError` takes `{ code, status, message, details }` — the author
27
55
  * must supply code + status, so they are named.
@@ -36,6 +64,88 @@
36
64
  * base class fail-fasts instead.
37
65
  */
38
66
 
67
+ /**
68
+ * The business-error brand. `Symbol.for` puts it in the GLOBAL symbol registry,
69
+ * so two independently loaded copies of this file resolve to the SAME symbol —
70
+ * which is exactly the case `instanceof` used to lose.
71
+ */
72
+ const BUSINESS_ERROR_BRAND = Symbol.for('onlineapps.businessError');
73
+
74
+ /** `code` must be an uppercase SNAKE_CASE identifier. */
75
+ const BUSINESS_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
76
+
77
+ const BUSINESS_ERROR_STATUS_MIN = 100;
78
+ const BUSINESS_ERROR_STATUS_MAX = 599;
79
+
80
+ /** Render a value for an operator-facing defect line: `"404" (string)`. */
81
+ function describeValue(value) {
82
+ const rendered = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
83
+ ? JSON.stringify(value)
84
+ : String(value);
85
+ return `${rendered} (${typeof value})`;
86
+ }
87
+
88
+ /**
89
+ * Does the error claim to be one of ours? Claiming is not being — see
90
+ * {@link describeBusinessErrorDefects}.
91
+ *
92
+ * @param {*} err
93
+ * @returns {boolean}
94
+ */
95
+ function hasBusinessErrorBrand(err) {
96
+ return !!err && (typeof err === 'object' || typeof err === 'function') && err[BUSINESS_ERROR_BRAND] === true;
97
+ }
98
+
99
+ /**
100
+ * Every way a branded error breaks the shape, named field by field. Empty array
101
+ * = the contract holds.
102
+ *
103
+ * @param {Object} err
104
+ * @returns {string[]}
105
+ */
106
+ function describeBusinessErrorDefects(err) {
107
+ const defects = [];
108
+
109
+ if (typeof err.code !== 'string' || !BUSINESS_ERROR_CODE_PATTERN.test(err.code)) {
110
+ defects.push(`code is ${describeValue(err.code)}, expected a string matching ${BUSINESS_ERROR_CODE_PATTERN.source}`);
111
+ }
112
+ if (typeof err.status !== 'number' || !Number.isFinite(err.status) ||
113
+ err.status < BUSINESS_ERROR_STATUS_MIN || err.status > BUSINESS_ERROR_STATUS_MAX) {
114
+ defects.push(`status is ${describeValue(err.status)}, expected a number between ${BUSINESS_ERROR_STATUS_MIN} and ${BUSINESS_ERROR_STATUS_MAX}`);
115
+ }
116
+ if (typeof err.message !== 'string' || err.message.length === 0) {
117
+ defects.push(`message is ${describeValue(err.message)}, expected a non-empty string`);
118
+ }
119
+
120
+ return defects;
121
+ }
122
+
123
+ /**
124
+ * THE contract. A service's own class passes this by declaring the brand and
125
+ * the three fields — it does not have to extend `BusinessError`.
126
+ *
127
+ * @param {*} err
128
+ * @returns {boolean}
129
+ */
130
+ function isBusinessError(err) {
131
+ return hasBusinessErrorBrand(err) && describeBusinessErrorDefects(err).length === 0;
132
+ }
133
+
134
+ /**
135
+ * Stamp the brand on a class prototype: non-enumerable and non-writable, so it
136
+ * never reaches a JSON payload and cannot be unset by accident.
137
+ *
138
+ * @param {Function} Cls
139
+ */
140
+ function brand(Cls) {
141
+ Object.defineProperty(Cls.prototype, BUSINESS_ERROR_BRAND, {
142
+ value: true,
143
+ enumerable: false,
144
+ writable: false,
145
+ configurable: false
146
+ });
147
+ }
148
+
39
149
  class BusinessError extends Error {
40
150
  constructor({ code, status, message, details } = {}) {
41
151
  if (!code) {
@@ -163,6 +273,12 @@ class AbortError extends Error {
163
273
  }
164
274
  }
165
275
 
276
+ // `UnknownOperationError` and `AbortError` do not extend `BusinessError` (their
277
+ // constructor shape differs), so each is branded on its own prototype.
278
+ brand(BusinessError);
279
+ brand(UnknownOperationError);
280
+ brand(AbortError);
281
+
166
282
  class ErrorMapper {
167
283
  /**
168
284
  * @param {Object} options
@@ -179,6 +295,10 @@ class ErrorMapper {
179
295
  * Map a handler exception to an MQ response envelope.
180
296
  * Never throws. Always returns `{ status, error: { code, message, [details], [correlation_id] } }`.
181
297
  *
298
+ * Recognition is by the brand + shape contract (see the file header), never by
299
+ * `instanceof`. Branded + malformed → 500 `MALFORMED_BUSINESS_ERROR` with the
300
+ * offending field named in the log; unbranded → 500 `INTERNAL_ERROR`, masked.
301
+ *
182
302
  * @param {Error} err
183
303
  * @param {Object} meta
184
304
  * @param {string} [meta.operation]
@@ -188,8 +308,34 @@ class ErrorMapper {
188
308
  map(err, meta = {}) {
189
309
  const { operation, correlation_id } = meta;
190
310
 
191
- if (err instanceof ValidationError) {
192
- if (err.phase === 'output') {
311
+ if (hasBusinessErrorBrand(err)) {
312
+ const defects = describeBusinessErrorDefects(err);
313
+ if (defects.length > 0) {
314
+ // Branded but malformed = a defect in OUR code (or in a service that
315
+ // claimed the brand and then broke the shape). It gets a named, loud
316
+ // 500 instead of the anonymous one an unknown error gets, so the log
317
+ // says which field is wrong rather than "something threw".
318
+ this._logger.error(
319
+ `[ErrorMapper] branded error has invalid shape - ${defects.join('; ')}. ` +
320
+ 'Fix: a branded business error must carry code (' + BUSINESS_ERROR_CODE_PATTERN.source + '), ' +
321
+ `status (number ${BUSINESS_ERROR_STATUS_MIN}-${BUSINESS_ERROR_STATUS_MAX}) and a non-empty message.`,
322
+ {
323
+ err,
324
+ operation,
325
+ correlation_id,
326
+ code: 'MALFORMED_BUSINESS_ERROR',
327
+ status: 500,
328
+ stack: err && err.stack,
329
+ defects
330
+ }
331
+ );
332
+ return this._buildError(500, 'MALFORMED_BUSINESS_ERROR', 'An internal error occurred', undefined, correlation_id);
333
+ }
334
+
335
+ // From here on the shape is guaranteed. Branches key on `code`, so a
336
+ // class from a second copy of this package — or a service's own class
337
+ // declaring the same code — takes exactly the same branch as ours.
338
+ if (err.code === 'VALIDATION_FAILED' && err.phase === 'output') {
193
339
  this._logger.error(
194
340
  'output schema validation failed - server bug',
195
341
  { err, operation, correlation_id, code: 'INTERNAL_ERROR', status: 500 }
@@ -197,23 +343,7 @@ class ErrorMapper {
197
343
  return this._buildError(500, 'INTERNAL_ERROR', 'Internal validation error', undefined, correlation_id);
198
344
  }
199
345
 
200
- this._logger.info(
201
- 'input schema validation failed',
202
- { err, operation, correlation_id, code: 'VALIDATION_FAILED', status: 400 }
203
- );
204
- return this._buildError(400, 'VALIDATION_FAILED', err.message, err.details, correlation_id);
205
- }
206
-
207
- if (err instanceof UnknownOperationError) {
208
- this._logger.warn(
209
- 'unknown operation requested',
210
- { err, operation, correlation_id, code: 'UNKNOWN_OPERATION', status: 404 }
211
- );
212
- return this._buildError(404, 'UNKNOWN_OPERATION', err.message, undefined, correlation_id);
213
- }
214
-
215
- if (err instanceof BusinessError) {
216
- // A BusinessError with a 5xx status is a server fault wearing a business
346
+ // A business error with a 5xx status is a server fault wearing a business
217
347
  // class. It is logged like one — `error` level WITH the stack — and the
218
348
  // caller gets the same neutral text as any other internal error, so the
219
349
  // original message (which may name a tenant, a column or a credential)
@@ -226,6 +356,30 @@ class ErrorMapper {
226
356
  return this._buildError(err.status, err.code, 'An internal error occurred', undefined, correlation_id);
227
357
  }
228
358
 
359
+ if (err.code === 'UNKNOWN_OPERATION') {
360
+ this._logger.warn(
361
+ 'unknown operation requested',
362
+ { err, operation, correlation_id, code: err.code, status: err.status }
363
+ );
364
+ return this._buildError(err.status, err.code, err.message, undefined, correlation_id);
365
+ }
366
+
367
+ if (err.code === 'CLIENT_CANCELLED') {
368
+ this._logger.info(
369
+ 'invocation aborted by client',
370
+ { err, operation, correlation_id, code: err.code, status: err.status }
371
+ );
372
+ return this._buildError(err.status, err.code, err.message, undefined, correlation_id);
373
+ }
374
+
375
+ if (err.code === 'VALIDATION_FAILED') {
376
+ this._logger.info(
377
+ 'input schema validation failed',
378
+ { err, operation, correlation_id, code: err.code, status: err.status }
379
+ );
380
+ return this._buildError(err.status, err.code, err.message, err.details, correlation_id);
381
+ }
382
+
229
383
  this._logger.info(
230
384
  'handler raised BusinessError',
231
385
  { err, operation, correlation_id, code: err.code, status: err.status }
@@ -233,8 +387,11 @@ class ErrorMapper {
233
387
  return this._buildError(err.status, err.code, err.message, err.details, correlation_id);
234
388
  }
235
389
 
236
- if (err instanceof AbortError || (err && err.name === 'AbortError')) {
237
- const message = err && err.message ? err.message : 'Invocation aborted';
390
+ // Deliberately NOT the brand contract: an abort is a platform signal, not a
391
+ // business error, and the one we must recognise is Node's own DOMException
392
+ // from AbortController, which we do not construct and cannot brand.
393
+ if (err && err.name === 'AbortError') {
394
+ const message = err.message ? err.message : 'Invocation aborted';
238
395
  this._logger.info(
239
396
  'invocation aborted by client',
240
397
  { err, operation, correlation_id, code: 'CLIENT_CANCELLED', status: 499 }
@@ -265,6 +422,8 @@ class ErrorMapper {
265
422
  }
266
423
 
267
424
  module.exports = {
425
+ BUSINESS_ERROR_BRAND,
426
+ isBusinessError,
268
427
  ErrorMapper,
269
428
  BusinessError,
270
429
  ValidationError,
@@ -326,7 +326,8 @@ class ServiceWrapper {
326
326
  /**
327
327
  * Fail-fast validation of naming conventions and workspaceScoped invariants.
328
328
  *
329
- * Enforced rules (all normative — see OPERATIONS.md, biz-service-onboarding.md):
329
+ * Enforced rules (all normative — see api/docs/biz/30-operations/schema-v3.md and
330
+ * api/docs/biz/60-templates/onboarding-checklist.md):
330
331
  * 1. Every key in `operations.operations` matches kebab-case regex
331
332
  * /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ (no camelCase, snake_case, or PascalCase).
332
333
  * 2. `config.service.workspaceScoped` is an explicit boolean (no default).
@@ -379,12 +380,14 @@ class ServiceWrapper {
379
380
  }
380
381
 
381
382
  // Rules 3 & 4: workspaceScoped consistency with list-workspaces
383
+ // @see api/docs/biz/60-templates/onboarding-checklist.md §4 (workspace discovery convention)
384
+ // @see api/docs/biz/30-operations/schema-v3.md § Reserved operation names
382
385
  const hasListWorkspaces = Object.prototype.hasOwnProperty.call(opsMap, 'list-workspaces');
383
386
  if (ws === true && !hasListWorkspaces) {
384
387
  throw new Error(
385
388
  `[ServiceWrapper][${serviceName}] service.workspaceScoped is true but 'list-workspaces' operation is not declared. ` +
386
- `Workspace-scoped services MUST expose list-workspaces (workspace discovery convention). ` +
387
- `See api/docs/biz/60-templates/onboarding-checklist.md §4 and OPERATIONS.md §Reserved operation names.`
389
+ `Workspace-scoped services MUST expose list-workspaces (workspace discovery convention) - ` +
390
+ `Fix: declare a 'list-workspaces' operation in config/service/operations.json, or set service.workspaceScoped to false.`
388
391
  );
389
392
  }
390
393
  if (ws === false && hasListWorkspaces) {
package/src/index.js CHANGED
@@ -124,6 +124,8 @@ const { OperationContext } = require('./OperationContext');
124
124
  const { ContextBuilder } = require('./ContextBuilder');
125
125
  const { SchemaValidator } = require('./SchemaValidator');
126
126
  const {
127
+ BUSINESS_ERROR_BRAND,
128
+ isBusinessError,
127
129
  ErrorMapper,
128
130
  BusinessError,
129
131
  ValidationError,
@@ -161,6 +163,18 @@ module.exports.ErrorMapper = ErrorMapper;
161
163
  // biz service throws; the retired @onlineapps/service-common hierarchy used the
162
164
  // same `(message, options)` signature, so migration is an import-line change.
163
165
  // Contract + per-class status/code: src/ErrorMapper.js header.
166
+ //
167
+ // The contract itself (DÁVKA 50, confirmation 20260829-1500-business-error-contract-001)
168
+ // is the BRAND plus the SHAPE, not the class identity: an error is a business
169
+ // error when it carries `BUSINESS_ERROR_BRAND` === true, a `code` matching
170
+ // /^[A-Z][A-Z0-9_]*$/, a numeric `status` in [100, 599] and a non-empty
171
+ // `message`. ErrorMapper dispatches on `code`, never on `instanceof`, so a
172
+ // service may throw its OWN class as long as it declares the brand and the
173
+ // shape — `isBusinessError()` is the predicate that decides. Branded but
174
+ // malformed is a named 500 MALFORMED_BUSINESS_ERROR; unbranded stays a masked
175
+ // 500 INTERNAL_ERROR.
176
+ module.exports.BUSINESS_ERROR_BRAND = BUSINESS_ERROR_BRAND;
177
+ module.exports.isBusinessError = isBusinessError;
164
178
  module.exports.BusinessError = BusinessError;
165
179
  module.exports.ValidationError = ValidationError;
166
180
  module.exports.NotFoundError = NotFoundError;