@onlineapps/conn-orch-validator 3.3.1 → 4.0.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.
@@ -37,6 +37,184 @@ function hasConfigDir(root) {
37
37
  fs.existsSync(path.join(root, 'conn-config'));
38
38
  }
39
39
 
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // v1.2 — business-error CONTRACT (DÁVKA 50)
43
+ //
44
+ // Owner decision: api/docs/governance/confirmations/business-error-contract.md
45
+ // (20260829-1500-business-error-contract-001, CONFIRMED).
46
+ //
47
+ // v1.2 used to ask "does the service depend on @onlineapps/service-common and
48
+ // import an error class FROM IT?". Both retired checks (`dep_service_common`,
49
+ // `business_error_usage`) demanded the defeated hierarchy, so a service that had
50
+ // already migrated to the wrapper's classes failed the gate for being correct.
51
+ //
52
+ // The question is now the contract, which survives the package moving: does the
53
+ // service raise errors that the wrapper's ErrorMapper will translate — i.e. a
54
+ // BusinessError family from @onlineapps/service-wrapper, or its own class
55
+ // declaring the brand `onlineapps.businessError`?
56
+ //
57
+ // Scope is ANY file under src/, not "a handler or a src/lib/ module a handler
58
+ // requires" — one rule, one sentence (automation-gates.md §1 requirement 2,
59
+ // Simple). A static check cannot prove a `throw` reaches a handler anyway; it
60
+ // can only see the declaration. The narrower rule was measured on 2026-08-29
61
+ // and failed biz-converter and biz-ingest, both of which declare the contract in
62
+ // src/lib/ and reach it through src/services/. Lead decision, same day.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** Names exported by the wrapper's error module that satisfy the contract. */
66
+ const WRAPPER_ERROR_NAMES = [
67
+ 'BusinessError',
68
+ 'ValidationError',
69
+ 'NotFoundError',
70
+ 'ConflictError',
71
+ 'BusinessRuleError',
72
+ 'AuthorizationError',
73
+ 'InvalidEnvelopeError',
74
+ 'BUSINESS_ERROR_BRAND'
75
+ ];
76
+
77
+ /**
78
+ * The RETIRED hierarchy: everything @onlineapps/service-common exports from
79
+ * src/errors/BusinessError.js. Deliberately NOT every error class in that
80
+ * package — `ScopedRegistryError` belongs to the live scoped-registry helper and
81
+ * is not part of what DÁVKA 50 retires.
82
+ */
83
+ const RETIRED_ERROR_NAMES = [
84
+ 'BusinessError',
85
+ 'NotFoundError',
86
+ 'ValidationError',
87
+ 'ConflictError',
88
+ 'BusinessRuleError',
89
+ 'AuthorizationError',
90
+ 'ServiceUnavailableError',
91
+ 'isBusinessError',
92
+ 'ERROR_TYPES',
93
+ 'businessErrorHandler'
94
+ ];
95
+
96
+ const BUSINESS_ERROR_BRAND_TOKEN = 'onlineapps.businessError';
97
+
98
+ function requirePattern(pkg) {
99
+ return new RegExp(`require\\(\\s*['"]${pkg.replace('/', '\\/')}['"]\\s*\\)`);
100
+ }
101
+
102
+ /** Collect every .js file under `dir`, as paths relative to `root`. */
103
+ function listJsFiles(root, dir) {
104
+ const out = [];
105
+ if (!fs.existsSync(dir)) return out;
106
+ const walk = (d) => {
107
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
108
+ const p = path.join(d, entry.name);
109
+ if (entry.isDirectory()) walk(p);
110
+ else if (entry.isFile() && entry.name.endsWith('.js')) out.push(path.relative(root, p));
111
+ }
112
+ };
113
+ try { walk(dir); } catch { /* unreadable tree — callers treat as empty */ }
114
+ return out;
115
+ }
116
+
117
+ function readFile(root, rel) {
118
+ try { return fs.readFileSync(path.join(root, rel), 'utf-8'); } catch { return null; }
119
+ }
120
+
121
+ /**
122
+ * Names destructured from `require('<pkg>')` in `content`, across every such
123
+ * statement (single-line or multi-line).
124
+ *
125
+ * The body is `[^{}]*?`, NOT `[\s\S]*?`: a lazy any-character body starts at the
126
+ * nearest preceding `const {` and swallows whole lines to reach the right
127
+ * `require`, which dropped the first imported name. Measured on
128
+ * `api_biz/meta/src/handlers/persons.js:10-11`, where an unrelated `models`
129
+ * destructuring sits directly above the service-common one.
130
+ *
131
+ * @returns {string[]}
132
+ */
133
+ function destructuredFrom(content, pkg) {
134
+ const re = new RegExp(
135
+ `(?:const|let|var)\\s*\\{([^{}]*?)\\}\\s*=\\s*require\\(\\s*['"]${pkg.replace('/', '\\/')}['"]\\s*\\)`,
136
+ 'g'
137
+ );
138
+ const names = [];
139
+ let m;
140
+ while ((m = re.exec(content)) !== null) {
141
+ for (const raw of m[1].split(',')) {
142
+ const name = raw.split(':')[0].trim();
143
+ if (name) names.push(name);
144
+ }
145
+ }
146
+ return names;
147
+ }
148
+
149
+ /** Does this file satisfy the business-error contract? */
150
+ function declaresBusinessErrorContract(content) {
151
+ if (content.includes(BUSINESS_ERROR_BRAND_TOKEN)) return true;
152
+ if (!requirePattern('@onlineapps/service-wrapper').test(content)) return false;
153
+ return destructuredFrom(content, '@onlineapps/service-wrapper')
154
+ .some(name => WRAPPER_ERROR_NAMES.includes(name));
155
+ }
156
+
157
+ /**
158
+ * v1.2 checks: the business-error contract is declared somewhere under src/, and
159
+ * the retired hierarchy is gone from src/ entirely.
160
+ *
161
+ * @param {string} root - Service root
162
+ * @returns {Array<{ passed: boolean, id: string, message: string, fix?: string }>}
163
+ */
164
+ function businessErrorChecks(root) {
165
+ const srcFiles = listJsFiles(root, path.join(root, 'src'));
166
+
167
+ let contractFile = null;
168
+ for (const rel of srcFiles) {
169
+ const content = readFile(root, rel);
170
+ if (content && declaresBusinessErrorContract(content)) { contractFile = rel; break; }
171
+ }
172
+
173
+ const contractCheck = contractFile
174
+ ? {
175
+ passed: true,
176
+ id: 'business_error_contract',
177
+ message: `Business-error contract declared in ${contractFile}`
178
+ }
179
+ : {
180
+ passed: false,
181
+ id: 'business_error_contract',
182
+ message: '[ServiceStructureValidator] no business-error contract declared - ' +
183
+ 'nothing under src/ imports a BusinessError family from @onlineapps/service-wrapper ' +
184
+ 'or declares the brand ' + BUSINESS_ERROR_BRAND_TOKEN,
185
+ fix: 'Throw a contract-carrying error: ' +
186
+ "const { ValidationError } = require('@onlineapps/service-wrapper'); " +
187
+ `— or declare Symbol.for('${BUSINESS_ERROR_BRAND_TOKEN}') on the service's own error class.`
188
+ };
189
+
190
+ const offenders = [];
191
+ for (const rel of listJsFiles(root, path.join(root, 'src'))) {
192
+ const content = readFile(root, rel);
193
+ if (!content) continue;
194
+ const names = destructuredFrom(content, '@onlineapps/service-common')
195
+ .filter(name => RETIRED_ERROR_NAMES.includes(name));
196
+ if (names.length > 0) offenders.push({ file: rel, names });
197
+ }
198
+
199
+ const retiredCheck = offenders.length === 0
200
+ ? {
201
+ passed: true,
202
+ id: 'no_retired_error_hierarchy',
203
+ message: 'No @onlineapps/service-common error-hierarchy import under src/'
204
+ }
205
+ : {
206
+ passed: false,
207
+ id: 'no_retired_error_hierarchy',
208
+ message: '[ServiceStructureValidator] retired error hierarchy imported from @onlineapps/service-common - ' +
209
+ offenders.map(o => `${o.file} imports ${o.names.join(', ')}`).join('; '),
210
+ fix: offenders
211
+ .map(o => `Replace in ${o.file} with: const { ${o.names.join(', ')} } = require('@onlineapps/service-wrapper');`)
212
+ .join(' ')
213
+ };
214
+
215
+ return [contractCheck, retiredCheck];
216
+ }
217
+
40
218
  const STANDARD_LEVELS = [
41
219
  {
42
220
  level: 'v1.0',
@@ -50,7 +228,9 @@ const STANDARD_LEVELS = [
50
228
  results.push({ passed: hasConfigDir(root), id: 'dir_conn_config', message: 'config/service/ or conn-config/ directory' });
51
229
  results.push({ passed: has('src'), id: 'dir_src', message: 'src/ directory' });
52
230
  results.push({ passed: has('tests'), id: 'dir_tests', message: 'tests/ directory' });
53
- results.push({ passed: has('src/app.js'), id: 'file_app', message: 'src/app.js' });
231
+ // ADR 0005: src/handlers/ replaces src/app.js. Flat handlers/ or
232
+ // handlers/v3/ subdir are both valid canonical shapes.
233
+ results.push({ passed: has('src/handlers'), id: 'dir_handlers', message: 'src/handlers/ (v3 handler modules)' });
54
234
  results.push({ passed: has('index.js'), id: 'file_index', message: 'index.js entry point' });
55
235
 
56
236
  const configRaw = readConfigFile(root, 'config.json');
@@ -58,10 +238,11 @@ const STANDARD_LEVELS = [
58
238
  if (configRaw) {
59
239
  try {
60
240
  const cfg = JSON.parse(configRaw);
61
- configValid = !!(cfg.service?.name && cfg.service?.port);
241
+ // ADR 0005: service.port not required post zero-HTTP. Just name.
242
+ configValid = !!cfg.service?.name;
62
243
  } catch { /* invalid JSON */ }
63
244
  }
64
- results.push({ passed: configValid, id: 'config_valid', message: 'Valid config.json with service.name + service.port' });
245
+ results.push({ passed: configValid, id: 'config_valid', message: 'Valid config.json with service.name' });
65
246
 
66
247
  const opsRaw = readConfigFile(root, 'operations.json');
67
248
  let opsValid = false;
@@ -108,24 +289,35 @@ const STANDARD_LEVELS = [
108
289
  level: 'v1.2',
109
290
  name: 'Business Error Handling Standard',
110
291
  since: '2026-03-24',
292
+ checks: (root) => businessErrorChecks(root)
293
+ },
294
+ {
295
+ level: 'v1.3',
296
+ name: 'Zero-HTTP Shape (ADR 0005)',
297
+ since: '2026-08-17',
111
298
  checks: (root) => {
299
+ const has = (p) => fs.existsSync(path.join(root, p));
112
300
  const read = (p) => { try { return fs.readFileSync(path.join(root, p), 'utf-8'); } catch { return null; } };
113
301
 
302
+ // v1.3 asserts that F6/A2 cleanup landed: no dead Express surface.
303
+ const noAppJs = !has('src/app.js');
304
+ const noRoutesDir = !has('src/routes');
305
+ const noMiddlewaresDir = !has('src/middlewares');
306
+
114
307
  const pkgRaw = read('package.json');
115
- let hasServiceCommon = false;
308
+ let noExpressDep = true;
116
309
  if (pkgRaw) {
117
310
  try {
118
311
  const pkg = JSON.parse(pkgRaw);
119
- hasServiceCommon = !!pkg.dependencies?.['@onlineapps/service-common'];
120
- } catch { /* invalid JSON */ }
312
+ noExpressDep = !pkg.dependencies?.express;
313
+ } catch { /* invalid JSON — separate check catches it */ }
121
314
  }
122
315
 
123
- const appContent = read('src/app.js') || '';
124
- const hasErrorMiddleware = appContent.includes('businessErrorHandler');
125
-
126
316
  return [
127
- { passed: hasServiceCommon, id: 'dep_service_common', message: '@onlineapps/service-common dependency' },
128
- { passed: hasErrorMiddleware, id: 'error_middleware', message: 'businessErrorHandler middleware in src/app.js' }
317
+ { passed: noAppJs, id: 'no_app_js', message: 'src/app.js absent (retired Express dispatch surface)' },
318
+ { passed: noRoutesDir, id: 'no_routes_dir', message: 'src/routes/ absent (dead HTTP route mounts)' },
319
+ { passed: noMiddlewaresDir, id: 'no_middlewares_dir', message: 'src/middlewares/ absent (dead Express middleware)' },
320
+ { passed: noExpressDep, id: 'no_express_dep', message: 'express not in package.json dependencies' }
129
321
  ];
130
322
  }
131
323
  }
@@ -218,12 +410,18 @@ class ServiceStructureValidator {
218
410
  if (nextLevel) {
219
411
  const failing = nextLevel.checks.filter(c => !c.passed);
220
412
  for (const check of failing) {
413
+ // A check that carries its own `fix` has already written a
414
+ // `[Context] Problem - Expected/Fix` message naming the offending file;
415
+ // wrapping it in "missing …" would only bury the finding.
221
416
  this.warnings.push({
222
417
  type: 'STANDARD_LEVEL_GAP',
223
418
  level: nextLevel.level,
224
419
  check: check.id,
225
- message: `Standard ${nextLevel.level} (${nextLevel.name}): missing ${check.message}`,
226
- fix: `Implement ${check.message} to reach standard ${nextLevel.level}. See docs/biz/60-templates/service-template.md`
420
+ message: check.fix
421
+ ? `Standard ${nextLevel.level} (${nextLevel.name}): ${check.message}`
422
+ : `Standard ${nextLevel.level} (${nextLevel.name}): missing ${check.message}`,
423
+ fix: check.fix
424
+ || `Implement ${check.message} to reach standard ${nextLevel.level}. See docs/biz/60-templates/service-template.md`
227
425
  });
228
426
  }
229
427
  }
@@ -411,7 +609,8 @@ class ServiceStructureValidator {
411
609
 
412
610
  /**
413
611
  * Validate operations.json structure (v3 — handler registry dispatch).
414
- * v3 schema per biz-service-invocation-model.md §5.3.
612
+ *
613
+ * @see api/docs/biz/30-operations/schema-v3.md § File shape
415
614
  */
416
615
  validateOperationsStructure(operations) {
417
616
  if (!operations.operations) {
@@ -419,7 +618,7 @@ class ServiceStructureValidator {
419
618
  type: 'INVALID_OPERATIONS_STRUCTURE',
420
619
  path: 'config/service/operations.json',
421
620
  message: 'operations.json must have "operations" key',
422
- fix: 'Wrap operations in {"operations": {...}}. See: biz-service-invocation-model.md §5.3'
621
+ fix: 'Wrap operations in {"operations": {...}} in config/service/operations.json'
423
622
  });
424
623
  return;
425
624
  }
@@ -442,7 +641,7 @@ class ServiceStructureValidator {
442
641
  field: 'schema_version',
443
642
  value: operations.schema_version,
444
643
  message: `operations.json schema_version is "${operations.schema_version}" — expected "3.0"`,
445
- fix: 'Update schema_version to "3.0" (RFC §5.3)'
644
+ fix: 'Set schema_version to "3.0" in config/service/operations.json'
446
645
  });
447
646
  }
448
647
 
@@ -464,6 +663,8 @@ class ServiceStructureValidator {
464
663
  /**
465
664
  * Validate single operation structure (v3).
466
665
  * Required: handler, bundle_scope. Forbidden (v2): endpoint, method, path.
666
+ *
667
+ * @see api/docs/biz/30-operations/schema-v3.md § Per-operation keys
467
668
  */
468
669
  validateOperation(name, spec) {
469
670
  const requiredFields = ['handler', 'bundle_scope'];
@@ -476,7 +677,7 @@ class ServiceStructureValidator {
476
677
  operation: name,
477
678
  field,
478
679
  message: `Operation "${name}" missing required field: ${field}`,
479
- fix: `Add "${field}" to operation "${name}" (v3 schema — RFC §5.3)`
680
+ fix: `Add "${field}" to operation "${name}" in config/service/operations.json`
480
681
  });
481
682
  }
482
683
  }
@@ -517,7 +718,7 @@ class ServiceStructureValidator {
517
718
  field: forbidden,
518
719
  value: spec[forbidden],
519
720
  message: `Operation "${name}" has retired v2 field "${forbidden}" — not allowed in v3 schema`,
520
- fix: `Remove "${forbidden}" — v3 dispatches via handler registry (RFC §5.3, §5.9)`
721
+ fix: `Remove "${forbidden}" from operation "${name}" in config/service/operations.json — v3 dispatches via the handler registry, not by URL`
521
722
  });
522
723
  }
523
724
  }
@@ -594,39 +795,45 @@ class ServiceStructureValidator {
594
795
  }
595
796
 
596
797
  /**
597
- * Validate source code structure
798
+ * Validate source code structure.
799
+ *
800
+ * ADR 0005 (2026-08-17): biz containers have zero HTTP surface —
801
+ * no Express, no `src/app.js`, no per-op HTTP routes. The v3
802
+ * handler-registry model instead requires `src/handlers/` (flat
803
+ * or `handlers/v3/` subdir — both variants of the canonical
804
+ * shape) with `handler` refs from `operations.json` resolvable
805
+ * to real exports.
598
806
  */
599
807
  validateSourceStructure() {
600
- const appPath = path.join(this.serviceRoot, 'src/app.js');
601
- if (!fs.existsSync(appPath)) {
808
+ // Post-ADR-0005 hard requirement: at least one handler module
809
+ // (flat or under v3/) must exist. HandlerRegistry.validate() at
810
+ // bootstrap time enforces per-op resolution; here we just ensure
811
+ // the directory itself is present so the service has something
812
+ // to dispatch to.
813
+ const handlersFlat = path.join(this.serviceRoot, 'src/handlers');
814
+ if (!fs.existsSync(handlersFlat)) {
602
815
  this.errors.push({
603
- type: 'MISSING_APP',
604
- path: 'src/app.js',
605
- message: 'Express application missing: src/app.js',
606
- fix: 'Create src/app.js with Express app. See: /docs/biz/60-templates/service-template.md'
816
+ type: 'MISSING_HANDLERS',
817
+ path: 'src/handlers',
818
+ message: 'Handlers directory missing: src/handlers/',
819
+ fix: 'Create src/handlers/ with at least one v3 handler module. See: /docs/biz/60-templates/service-template.md'
607
820
  });
608
821
  } else {
609
- this.info.push('✓ Found src/app.js');
822
+ this.info.push('✓ Found src/handlers/');
823
+ }
610
824
 
611
- try {
612
- const appContent = fs.readFileSync(appPath, 'utf-8');
613
- if (appContent.includes('businessErrorHandler')) {
614
- this.info.push('✓ businessErrorHandler middleware registered');
615
- } else {
616
- this.warnings.push({
617
- type: 'MISSING_ERROR_MIDDLEWARE',
618
- path: 'src/app.js',
619
- message: 'businessErrorHandler middleware not found in src/app.js',
620
- fix: 'Register businessErrorHandler from @onlineapps/service-common as Express error middleware. See: /docs/standards/ERROR_HANDLING.md'
621
- });
622
- }
623
- } catch (readError) {
624
- this.warnings.push({
625
- type: 'UNREADABLE_APP',
626
- path: 'src/app.js',
627
- message: `Could not read src/app.js: ${readError.message}`
628
- });
629
- }
825
+ // src/app.js is ANTI-PATTERN post-ADR 0005 (dead Express surface).
826
+ // Warn if present so authors of new services notice they should
827
+ // remove it; existing services scheduled to be cleaned up via the
828
+ // Fáze F6 pass.
829
+ const appPath = path.join(this.serviceRoot, 'src/app.js');
830
+ if (fs.existsSync(appPath)) {
831
+ this.warnings.push({
832
+ type: 'LEGACY_APP_JS',
833
+ path: 'src/app.js',
834
+ message: 'src/app.js is dead code post-ADR 0005 (bootstrap no longer reads it)',
835
+ fix: 'Delete src/app.js; move any residual middleware into the wrapper adapter. See: /docs/biz/80-decisions/0005-no-http-in-biz-containers.md'
836
+ });
630
837
  }
631
838
 
632
839
  const indexPath = path.join(this.serviceRoot, 'index.js');
package/src/config.js DELETED
@@ -1,32 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Runtime configuration schema for @onlineapps/conn-orch-validator.
5
- *
6
- * Uses @onlineapps/runtime-config for unified priority:
7
- * 1. Explicit config (passed to ValidationOrchestrator/readiness options)
8
- * 2. Environment variable
9
- * 3. Module-owned defaults (none for topology)
10
- *
11
- * IMPORTANT: Integration test topology is FAIL-FAST (no defaults).
12
- */
13
-
14
- const { createRuntimeConfig } = require('@onlineapps/runtime-config');
15
- const DEFAULTS = require('./defaults');
16
-
17
- const runtimeCfg = createRuntimeConfig({
18
- defaults: DEFAULTS,
19
- schema: {
20
- serviceUrl: { env: 'TEST_SERVICE_URL', required: true },
21
- mqUrl: { env: 'TEST_MQ_URL', required: true },
22
- registryUrl: { env: 'TEST_REGISTRY_URL', required: true },
23
- storageUrl: { env: 'TEST_STORAGE_URL', required: true },
24
- }
25
- });
26
-
27
- module.exports = runtimeCfg;
28
-
29
-
30
-
31
-
32
-
package/src/defaults.js DELETED
@@ -1,11 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * Module-owned defaults for @onlineapps/conn-orch-validator.
5
- *
6
- * NOTE: Integration test topology (URLs) is FAIL-FAST and has NO defaults.
7
- */
8
-
9
- module.exports = {};
10
-
11
-