@onlineapps/conn-orch-validator 3.3.2 → 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.
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one schema build for every biz service.
5
+ *
6
+ * The service declares WHAT its database is in integration-contract.json; this
7
+ * decides HOW it gets built, identically for all of them
8
+ * (docs/biz/00-model/uniformity-principle.md).
9
+ *
10
+ * It replaces six per-repo ci-setup-db.js scripts that had each solved the same
11
+ * problem differently — 113, 69, 56, 54, 22 and 20 lines, in three strategies,
12
+ * two of which bypassed migrations entirely with sequelize.sync(). That bypass
13
+ * let a service's CI prove a schema built from models while production ran one
14
+ * built from hand-applied migrations. There is deliberately no option here to
15
+ * build from anything but the migrations: the absence is what makes the bypass
16
+ * impossible rather than merely discouraged.
17
+ *
18
+ * Two invariants, both from ADR 0006:
19
+ *
20
+ * §2 every .sql file in the declared directory is applied, in sorted order.
21
+ * No list lives in this file — the set grows with each schema change, and a
22
+ * list would drift until a missing table surfaced in an unrelated test.
23
+ *
24
+ * §3 foreign key checks stay ON. Deferring them would let a migration
25
+ * reference a table a later file creates and still report success, which is
26
+ * the exact defect class building from empty exists to catch.
27
+ *
28
+ * SQL runs through the mysql/mariadb CLI rather than a driver, because trigger
29
+ * definitions use DELIMITER — a client directive drivers report as a syntax
30
+ * error. The executor is injected so the decisions are testable without a
31
+ * database.
32
+ */
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const { spawnSync } = require('child_process');
37
+
38
+ function requireValue(value, name) {
39
+ if (value === undefined || value === null || value === '') {
40
+ throw new Error(`[SetupDatabase] Missing ${name} - Expected a value. `
41
+ + `Fix: set ${name} in the CI job (or env-active/*.env locally).`);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ /**
47
+ * Decide which files to apply and in what order.
48
+ *
49
+ * @param {string} serviceRoot repository root
50
+ * @param {{migrations: string, seeds: string[]}} database the contract declaration
51
+ */
52
+ function resolveMigrationPlan(serviceRoot, database) {
53
+ // The declaration is a glob-ish path; only the directory part is read, and
54
+ // only its own .sql files — subdirectories such as migrations/superseded/ hold
55
+ // the record of how a baseline was reached and must not be replayed.
56
+ const dir = path.resolve(serviceRoot, path.dirname(database.migrations));
57
+
58
+ if (!fs.existsSync(dir)) {
59
+ throw new Error(`[SetupDatabase] Migrations directory not found: ${dir}\n`
60
+ + ` Fix: create it, or correct database.migrations in the integration contract.`);
61
+ }
62
+
63
+ const migrations = fs.readdirSync(dir, { withFileTypes: true })
64
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
65
+ .map((entry) => entry.name)
66
+ .sort()
67
+ .map((name) => path.join(dir, name));
68
+
69
+ if (migrations.length === 0) {
70
+ throw new Error(`[SetupDatabase] No .sql files in ${dir} - refusing to report a schema as built.\n`
71
+ + ' Fix: the migration set is the source of truth for the schema; it cannot be empty.');
72
+ }
73
+
74
+ const seeds = (database.seeds ?? []).map((seed) => {
75
+ const file = path.resolve(serviceRoot, seed);
76
+ if (!fs.existsSync(file)) {
77
+ throw new Error(`[SetupDatabase] Declared seed not found: ${seed}\n`
78
+ + ' Fix: correct database.seeds in the integration contract, or add the file.');
79
+ }
80
+ return file;
81
+ });
82
+
83
+ return { dir, migrations, seeds };
84
+ }
85
+
86
+ /** Run one statement or file through the CLI client. Injectable for tests. */
87
+ function defaultExec({ connection, schema, sql, file }) {
88
+ const client = ['mariadb', 'mysql'].find((bin) => !spawnSync(bin, ['--version'], { encoding: 'utf8' }).error);
89
+ if (!client) {
90
+ return {
91
+ status: 1,
92
+ stderr: '[SetupDatabase] No mysql/mariadb client on PATH - required because migrations use DELIMITER. '
93
+ + 'Fix: install mariadb-client in the CI job image.'
94
+ };
95
+ }
96
+
97
+ const args = ['-h', connection.host, '-P', String(connection.port), '-u', connection.user,
98
+ '--default-character-set=utf8mb4'];
99
+ // MariaDB Connector/C 3.x requires TLS by default and the CI database is a
100
+ // sidecar with none; the connection never leaves the job's private network.
101
+ if (connection.requireTls !== true) args.push('--skip-ssl');
102
+ if (connection.password) args.push(`-p${connection.password}`);
103
+ if (schema) args.push(schema);
104
+
105
+ const result = spawnSync(client, args, {
106
+ input: sql !== undefined ? sql : fs.readFileSync(file, 'utf8'),
107
+ encoding: 'utf8'
108
+ });
109
+
110
+ return {
111
+ status: result.error ? 1 : result.status,
112
+ stderr: result.error ? result.error.message
113
+ : (result.stderr || '').split('\n').filter((l) => !l.includes('Using a password')).join('\n').trim()
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Build the schema from the declared migrations.
119
+ *
120
+ * @returns {{schema: string, migrationsApplied: number, seedsApplied: number}}
121
+ */
122
+ function buildSchema({ serviceRoot, database, connection, exec = defaultExec }) {
123
+ requireValue(connection?.host, 'DB_HOST');
124
+ requireValue(connection?.user, 'DB_USER');
125
+ const port = connection.port ?? 3306;
126
+
127
+ const conn = { ...connection, port };
128
+ const plan = resolveMigrationPlan(serviceRoot, database);
129
+
130
+ const created = exec({ connection: conn, sql: `CREATE DATABASE IF NOT EXISTS \`${database.schema}\`;` });
131
+ if (created.status !== 0) {
132
+ throw new Error(`[SetupDatabase] Could not create schema ${database.schema}:\n${created.stderr}`);
133
+ }
134
+
135
+ const apply = (file, kind) => {
136
+ const result = exec({ connection: conn, schema: database.schema, file });
137
+ if (result.status !== 0) {
138
+ throw new Error(`[SetupDatabase] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
139
+ + ' Fix: the set must apply to an empty database in file order. Either the SQL is wrong, '
140
+ + 'or the file is numbered before something it depends on.');
141
+ }
142
+ };
143
+
144
+ for (const file of plan.migrations) apply(file, 'Migration');
145
+ for (const file of plan.seeds) apply(file, 'Seed');
146
+
147
+ return {
148
+ schema: database.schema,
149
+ migrationsApplied: plan.migrations.length,
150
+ seedsApplied: plan.seeds.length
151
+ };
152
+ }
153
+
154
+ module.exports = { resolveMigrationPlan, buildSchema };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One owner for the sentence that says WHY a cookbook step failed.
5
+ *
6
+ * Two consumers read it and they must not drift apart:
7
+ * - CookbookTestRunner.executeStep — the per-step log line at boot;
8
+ * - ValidationOrchestrator.runCookbookTests — the step-4 error list that
9
+ * reaches `results.errors`, the wrapper's `Validation failed: …` message
10
+ * and validation.responses.
11
+ *
12
+ * Before 2026-08-29 neither of them said anything: the log line was
13
+ * `Step count_stub: FAILED (1101ms)` and the orchestrator's only error was
14
+ * `1 cookbook test(s) failed`. The reason existed the whole time in
15
+ * `result.validationErrors` / `result.error` and was discarded at both ends,
16
+ * so a failed biz-hello boot could not be diagnosed from its own logs
17
+ * (automation-gates.md §5 — silence is a defect).
18
+ */
19
+
20
+ /**
21
+ * Why this step failed, in one line, from whichever field holds it.
22
+ *
23
+ * @param {Object} stepResult - a step result produced by CookbookTestRunner
24
+ * @returns {string} the concrete reason; never an empty string
25
+ */
26
+ function describeStepFailure(stepResult) {
27
+ if (!stepResult || typeof stepResult !== 'object') {
28
+ throw new Error('[stepFailure] stepResult is required - Expected the step result object produced by CookbookTestRunner');
29
+ }
30
+
31
+ const parts = [];
32
+
33
+ if (Array.isArray(stepResult.validationErrors) && stepResult.validationErrors.length > 0) {
34
+ parts.push(stepResult.validationErrors.join('; '));
35
+ }
36
+
37
+ const error = stepResult.error;
38
+ if (typeof error === 'string' && error.length > 0) {
39
+ parts.push(error);
40
+ } else if (error && typeof error === 'object') {
41
+ parts.push(`${error.code || 'HANDLER_ERROR'}: ${error.message}`);
42
+ }
43
+
44
+ if (typeof stepResult.errorStack === 'string' && stepResult.errorStack.length > 0) {
45
+ parts.push(stepResult.errorStack);
46
+ }
47
+
48
+ if (parts.length === 0) {
49
+ // A step marked failed with nothing recorded is itself a defect; say so
50
+ // rather than printing an empty reason that reads like "no problem".
51
+ return 'no reason recorded — neither validationErrors nor error was set on the step result';
52
+ }
53
+
54
+ return parts.join(' | ');
55
+ }
56
+
57
+ /**
58
+ * The same reason, prefixed with enough context to find the case: which
59
+ * cookbook, which step, which operation. This is what step 4 puts into
60
+ * `results.errors`.
61
+ *
62
+ * @param {Object} stepResult - a step result carrying `cookbook`
63
+ * @returns {string}
64
+ */
65
+ function describeStepFailureWithContext(stepResult) {
66
+ const reason = describeStepFailure(stepResult);
67
+ const cookbook = stepResult.cookbook || 'unknown cookbook';
68
+ const stepId = stepResult.id || stepResult.step_id || 'unnamed step';
69
+ const operation = stepResult.operation ? ` (${stepResult.operation})` : '';
70
+ return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
71
+ }
72
+
73
+ module.exports = { describeStepFailure, describeStepFailureWithContext };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The tenant / workspace an integration test is allowed to write into.
5
+ *
6
+ * An integration test reaches a real database, so the namespace it targets is a
7
+ * safety boundary. Two incidents show what happens when that boundary is
8
+ * written per file instead of read from one source:
9
+ *
10
+ * biz-property and biz-converter overrode TESTING_TENANT_ID in their own env
11
+ * template to point at the LIVE tenant, because that is where their cookbook
12
+ * fixtures happened to exist. The Tier-1 runner executes real handler
13
+ * dispatch at every service start, production included — so every restart
14
+ * wrote into real data, and did.
15
+ *
16
+ * 24 of biz-property's 26 integration tests carried their own
17
+ * `const TENANT = 100` and inserted and deleted under it. Nothing could
18
+ * redirect them, because there was nothing to redirect.
19
+ *
20
+ * So the value comes from the platform env (`TESTING_TENANT_ID` /
21
+ * `TESTING_WORKSPACE_ID`, set in `config/env-templates/shared.env`) and this
22
+ * module is the only place that reads it. It throws when the env is absent
23
+ * rather than guessing: a test that does not know which namespace it owns must
24
+ * not write anywhere at all.
25
+ *
26
+ * Enforced by deploy-contract requirement R8 (see utils/deployContract.js).
27
+ */
28
+
29
+ /**
30
+ * The environment classes a validation or test run may write into.
31
+ *
32
+ * This is a WHITELIST, and that is the whole point. The previous rule refused
33
+ * the one tenant an env var declared live (`PLATFORM_LIVE_TENANT_ID=100`), which
34
+ * left every customer tenant — 101+, production, real data, 101 = Meditest —
35
+ * outside the guard, and the gap grew with every customer onboarded. Naming what
36
+ * is permitted cannot grow a gap: a class that is not on this list is refused
37
+ * whether or not anyone remembered to declare it.
38
+ *
39
+ * Why the values live in the library and not in the environment: they are a
40
+ * CONTRACT CONSTANT taken from a normative standard, not a deployment knob. The
41
+ * standard says WHAT each tenant id means (an environment class, allocated once,
42
+ * "Adding a class — do not"); this module is the HOW of enforcing it at run time.
43
+ * Architecture principle §2 (No Hardcoded Values) targets configuration of an
44
+ * environment — ports, hosts, timeouts, credentials — which differs per
45
+ * deployment by design. This boundary does not: it is identical in every
46
+ * environment because the norm defines it, and making it env-settable would hand
47
+ * the very misconfiguration this guard exists to stop a switch to turn it off.
48
+ *
49
+ * @see api/docs/standards/tenant-allocation.md § "The allocation"
50
+ */
51
+ const ALLOWED_TENANT_CLASSES = Object.freeze([
52
+ 96, // CI
53
+ 97, // TESTING
54
+ 98, // DEVEL
55
+ 99 // VALIDATION
56
+ ]);
57
+
58
+ function readNamespaceId(key) {
59
+ const raw = process.env[key];
60
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
61
+ throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
62
+ + 'Fix: set it in config/env-active/shared.env (the platform default is the '
63
+ + 'non-production namespace). An integration test may not pick a namespace itself.');
64
+ }
65
+ const value = Number(String(raw).trim());
66
+ if (!Number.isInteger(value)) {
67
+ throw new Error(`[TestNamespace] Invalid ${key}="${raw}" - Expected an integer id. `
68
+ + 'Fix: correct the value in config/env-active/shared.env.');
69
+ }
70
+ return value;
71
+ }
72
+
73
+ /**
74
+ * @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, so it can be
75
+ * spread straight into a handler call.
76
+ */
77
+ function getTestNamespace() {
78
+ const tenantId = readNamespaceId('TESTING_TENANT_ID');
79
+
80
+ // The runtime half of the protection, and the reason the tenant is decided
81
+ // before anything else is read. R8 rejects a per-service override before it
82
+ // ships; this refuses the accident that ships anyway, so a startup probe can
83
+ // never reach real data even if the configuration is wrong.
84
+ //
85
+ // The workspace id is deliberately NOT class-checked: tenant-allocation.md
86
+ // allocates environment classes to TENANTS, and calls a workspace "the purpose
87
+ // within the class" without naming any range for it. A restriction here would
88
+ // be one this library invented.
89
+ if (!ALLOWED_TENANT_CLASSES.includes(tenantId)) {
90
+ throw new Error(`[TestNamespace] TESTING_TENANT_ID=${tenantId} is not an allowed `
91
+ + 'environment class - test and validation runs may never write into production data '
92
+ + '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
93
+ + '97 (TESTING), 98 (DEVEL), 99 (VALIDATION). Fix: set TESTING_TENANT_ID to one of '
94
+ + 'those classes in config/env-active/shared.env and seed that namespace from '
95
+ + 'migrations/ - see api/docs/standards/tenant-allocation.md.');
96
+ }
97
+
98
+ return {
99
+ tenant_id: tenantId,
100
+ workspace_id: readNamespaceId('TESTING_WORKSPACE_ID')
101
+ };
102
+ }
103
+
104
+ module.exports = { getTestNamespace };
@@ -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',
@@ -111,47 +289,7 @@ const STANDARD_LEVELS = [
111
289
  level: 'v1.2',
112
290
  name: 'Business Error Handling Standard',
113
291
  since: '2026-03-24',
114
- checks: (root) => {
115
- const read = (p) => { try { return fs.readFileSync(path.join(root, p), 'utf-8'); } catch { return null; } };
116
-
117
- const pkgRaw = read('package.json');
118
- let hasServiceCommon = false;
119
- if (pkgRaw) {
120
- try {
121
- const pkg = JSON.parse(pkgRaw);
122
- hasServiceCommon = !!pkg.dependencies?.['@onlineapps/service-common'];
123
- } catch { /* invalid JSON */ }
124
- }
125
-
126
- // ADR 0005 / F6: businessErrorHandler was Express middleware in
127
- // src/app.js. Post-F6 there is no src/app.js. Error handling now
128
- // happens via BusinessError thrown from handlers → wrapper's
129
- // ErrorMapper. Check: at least one handler imports a
130
- // BusinessError-family class from @onlineapps/service-common.
131
- const handlersDir = path.join(root, 'src', 'handlers');
132
- let hasBusinessErrorUsage = false;
133
- if (fs.existsSync(handlersDir)) {
134
- const walk = (dir) => {
135
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
136
- const p = path.join(dir, entry.name);
137
- if (entry.isDirectory()) walk(p);
138
- else if (entry.isFile() && entry.name.endsWith('.js')) {
139
- const content = read(path.relative(root, p));
140
- if (content && /@onlineapps\/service-common/.test(content) &&
141
- /BusinessError|ValidationError|NotFoundError|ConflictError|ForbiddenError/.test(content)) {
142
- hasBusinessErrorUsage = true;
143
- }
144
- }
145
- }
146
- };
147
- try { walk(handlersDir); } catch { /* ignore */ }
148
- }
149
-
150
- return [
151
- { passed: hasServiceCommon, id: 'dep_service_common', message: '@onlineapps/service-common dependency' },
152
- { passed: hasBusinessErrorUsage, id: 'business_error_usage', message: 'At least one handler throws a BusinessError family from @onlineapps/service-common' }
153
- ];
154
- }
292
+ checks: (root) => businessErrorChecks(root)
155
293
  },
156
294
  {
157
295
  level: 'v1.3',
@@ -272,12 +410,18 @@ class ServiceStructureValidator {
272
410
  if (nextLevel) {
273
411
  const failing = nextLevel.checks.filter(c => !c.passed);
274
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.
275
416
  this.warnings.push({
276
417
  type: 'STANDARD_LEVEL_GAP',
277
418
  level: nextLevel.level,
278
419
  check: check.id,
279
- message: `Standard ${nextLevel.level} (${nextLevel.name}): missing ${check.message}`,
280
- 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`
281
425
  });
282
426
  }
283
427
  }
@@ -465,7 +609,8 @@ class ServiceStructureValidator {
465
609
 
466
610
  /**
467
611
  * Validate operations.json structure (v3 — handler registry dispatch).
468
- * v3 schema per biz-service-invocation-model.md §5.3.
612
+ *
613
+ * @see api/docs/biz/30-operations/schema-v3.md § File shape
469
614
  */
470
615
  validateOperationsStructure(operations) {
471
616
  if (!operations.operations) {
@@ -473,7 +618,7 @@ class ServiceStructureValidator {
473
618
  type: 'INVALID_OPERATIONS_STRUCTURE',
474
619
  path: 'config/service/operations.json',
475
620
  message: 'operations.json must have "operations" key',
476
- fix: 'Wrap operations in {"operations": {...}}. See: biz-service-invocation-model.md §5.3'
621
+ fix: 'Wrap operations in {"operations": {...}} in config/service/operations.json'
477
622
  });
478
623
  return;
479
624
  }
@@ -496,7 +641,7 @@ class ServiceStructureValidator {
496
641
  field: 'schema_version',
497
642
  value: operations.schema_version,
498
643
  message: `operations.json schema_version is "${operations.schema_version}" — expected "3.0"`,
499
- fix: 'Update schema_version to "3.0" (RFC §5.3)'
644
+ fix: 'Set schema_version to "3.0" in config/service/operations.json'
500
645
  });
501
646
  }
502
647
 
@@ -518,6 +663,8 @@ class ServiceStructureValidator {
518
663
  /**
519
664
  * Validate single operation structure (v3).
520
665
  * Required: handler, bundle_scope. Forbidden (v2): endpoint, method, path.
666
+ *
667
+ * @see api/docs/biz/30-operations/schema-v3.md § Per-operation keys
521
668
  */
522
669
  validateOperation(name, spec) {
523
670
  const requiredFields = ['handler', 'bundle_scope'];
@@ -530,7 +677,7 @@ class ServiceStructureValidator {
530
677
  operation: name,
531
678
  field,
532
679
  message: `Operation "${name}" missing required field: ${field}`,
533
- fix: `Add "${field}" to operation "${name}" (v3 schema — RFC §5.3)`
680
+ fix: `Add "${field}" to operation "${name}" in config/service/operations.json`
534
681
  });
535
682
  }
536
683
  }
@@ -571,7 +718,7 @@ class ServiceStructureValidator {
571
718
  field: forbidden,
572
719
  value: spec[forbidden],
573
720
  message: `Operation "${name}" has retired v2 field "${forbidden}" — not allowed in v3 schema`,
574
- 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`
575
722
  });
576
723
  }
577
724
  }
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
-