@onlineapps/conn-orch-validator 6.0.1 → 7.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.
- package/CHANGELOG.md +33 -0
- package/README.md +37 -3
- package/package.json +1 -1
- package/src/CookbookTestRunner.js +14 -3
- package/src/ServiceReadinessValidator.js +69 -8
- package/src/ValidationOrchestrator.js +79 -4
- package/src/helpers/README.md +10 -1
- package/src/helpers/createServiceReadinessTests.js +15 -2
- package/src/utils/deployContract.js +73 -4
- package/src/validators/ServiceStructureValidator.js +19 -19
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
|
|
|
7
7
|
> ⚠ Neither this entry nor the connector-step work of `dcddb862` is published.
|
|
8
8
|
> The last version on NPM is 3.3.2, so **no biz CI run sees any of it yet**.
|
|
9
9
|
|
|
10
|
+
### BREAKING — standard level v1.1 "Multitenancy Standard" is removed (DÁVKA 88j)
|
|
11
|
+
|
|
12
|
+
**Major.** `getStandardLevels()` now returns three levels, `v1.0, v1.2, v1.3`, and
|
|
13
|
+
`determineStandardLevel().details` has one entry fewer. Anything indexing that
|
|
14
|
+
array by position, or expecting a `tenant_context` check, reads something else now.
|
|
15
|
+
|
|
16
|
+
Owner decision: `api/docs/governance/confirmations/service-shape-v11-retirement.md`.
|
|
17
|
+
|
|
18
|
+
v1.1 held a single check, `tenant_context`, and it asserted nothing but the
|
|
19
|
+
EXISTENCE of `wrapper.tenantContext` in `config/service/config.json`. Nothing
|
|
20
|
+
reads that key: the wrapper runtime reads `cache`, `infrastructureGate`,
|
|
21
|
+
`health`, `validation`, `mq`, `registry`, `monitoring`, `state`, `secrets` and
|
|
22
|
+
`heartbeat`; `createTenantContextMiddleware` had had no caller since ADR 0005
|
|
23
|
+
removed the HTTP surface (and was itself deleted on 2026-09-05,
|
|
24
|
+
`docs/governance/confirmations/wrapper-tenant-middleware.md`); at the time,
|
|
25
|
+
`service-wrapper/config/runtime-defaults.json` still supplied the key regardless
|
|
26
|
+
of what a service declared. The gate was enforcing a
|
|
27
|
+
dead declaration — `change-discipline.md` § *Removing something removes its
|
|
28
|
+
declaration*.
|
|
29
|
+
|
|
30
|
+
- **Removed** the level, not its checks. An empty level with `checks: () => []`
|
|
31
|
+
would pass for every service while asserting nothing, which is the false
|
|
32
|
+
guarantee `automation-gates.md` §5 names.
|
|
33
|
+
- **The number is not reused and the gap stays.** v1.2 and v1.3 keep their names,
|
|
34
|
+
so a level recorded in an earlier proof still means what it meant. Cumulative
|
|
35
|
+
now reads *"v1.2 requires v1.0"*.
|
|
36
|
+
- **A service is free to drop `wrapper.tenantContext`** and reach the same level
|
|
37
|
+
it reaches with it. Measured over `api_biz/*` (8 services, all of which still
|
|
38
|
+
declare the key): every verdict is `v1.3` before the change and `v1.3` after —
|
|
39
|
+
the removal takes a requirement away without moving a single live verdict.
|
|
40
|
+
- The package's own conformant fixture (`tests/fixtures/v3-cookbook-service`)
|
|
41
|
+
drops the key in the same change; it carried it only to satisfy v1.1.
|
|
42
|
+
|
|
10
43
|
### BREAKING — a cookbook step spelling its identifier `id` is refused (DÁVKA 77 A3)
|
|
11
44
|
|
|
12
45
|
`format.md` § Steps has banned `id` since v2.1; the code accepted it anyway, which
|
package/README.md
CHANGED
|
@@ -134,7 +134,14 @@ services/my-service/
|
|
|
134
134
|
- **Fingerprint:** SHA256 over (service version + operations + `@onlineapps/*`
|
|
135
135
|
dependencies + config + infra files + cookbooks + seeds), stored in the proof
|
|
136
136
|
as `contractFingerprint`. Nothing reads it back to skip work; it records what
|
|
137
|
-
the run covered.
|
|
137
|
+
the run covered. The **service version comes from the service `package.json`,
|
|
138
|
+
and from nowhere else** — the same single source of truth the readiness
|
|
139
|
+
section below describes. It used to be `config.service?.version ||
|
|
140
|
+
pkg.version`, which for a service carrying `"${npm_package_version}"` in
|
|
141
|
+
`config/service/config.json` hashed that literal and never consulted
|
|
142
|
+
package.json at all, so a version bump moved no bit of the fingerprint. A
|
|
143
|
+
missing, unparseable or version-less package.json now fails the run instead of
|
|
144
|
+
falling back.
|
|
138
145
|
- **Registry:** the proof travels in the registration payload
|
|
139
146
|
(`ServiceWrapper.js`, `validationProof` field) — a service that fails
|
|
140
147
|
validation never reaches registration, because phase 0.2 throws.
|
|
@@ -159,6 +166,22 @@ a result it never independently reached. What consumes it today is
|
|
|
159
166
|
The retired `health` check's 20 points folded into `operations` (ADR 0005), so
|
|
160
167
|
the maximum is still 100.
|
|
161
168
|
|
|
169
|
+
**"Can register" is not truthiness.** `canRegister` requires a `name`, an
|
|
170
|
+
`operations` object, and a `version` matching the semver 2.0.0 grammar — the
|
|
171
|
+
last one because the line below it compares that value against what the registry
|
|
172
|
+
already holds, and comparing two strings that are not versions decides nothing.
|
|
173
|
+
The string that made this measurable is `"${npm_package_version}"`: every biz
|
|
174
|
+
`config/service/config.json` declares it under `service.version`, it is truthy,
|
|
175
|
+
and the gate used to accept it (measured by BIZ-ingest). Each failing part
|
|
176
|
+
contributes one `[Context] Problem - Expected/Fix` line to `checks.registry.errors`.
|
|
177
|
+
|
|
178
|
+
The version a caller passes comes from the service **`package.json`**, never from
|
|
179
|
+
`config.json`. That is the single source of truth the runtime uses:
|
|
180
|
+
`ConfigLoader.loadServiceConfig()` overwrites `config.service.version` from
|
|
181
|
+
package.json before `${VAR}` placeholders are resolved, so the config.json key
|
|
182
|
+
never reaches a running service. `helpers/createServiceReadinessTests` reads
|
|
183
|
+
package.json for exactly that reason.
|
|
184
|
+
|
|
162
185
|
**Every evaluation writes exactly one line** through the injected logger:
|
|
163
186
|
`[ServiceReadinessValidator] Readiness evaluated` with a structured object
|
|
164
187
|
(`serviceName`, `score`, `maxScore`, `ready`, `checks` as `{name: {passed,
|
|
@@ -177,12 +200,23 @@ The validator evaluates each service against cumulative implementation standards
|
|
|
177
200
|
| Level | Name | Checks | Since |
|
|
178
201
|
|-------|------|--------|-------|
|
|
179
202
|
| **v1.0** | Base Service Standard | `config/service/`, `src/`, `tests/`, `src/handlers/` (v3 handler modules — ADR 0005 replaced `src/app.js`), `index.js`, `config.json` with `service.name`, `operations.json` with an `operations` object, `@onlineapps/service-wrapper` dep | 2025-06 |
|
|
180
|
-
| **v1.1** | Multitenancy Standard | `wrapper.tenantContext` configured in `config.json` | 2026-03 |
|
|
181
203
|
| **v1.2** | Business Error Handling Standard | `business_error_contract` (some file under `src/**` imports a `BusinessError` family from `@onlineapps/service-wrapper` or declares the brand `onlineapps.businessError`) + `no_retired_error_hierarchy` (no error class imported from `@onlineapps/service-common` anywhere under `src/`) | 2026-03 |
|
|
182
204
|
| **v1.3** | Zero-HTTP Shape (ADR 0005) | `src/app.js` absent, `src/routes/` absent, `src/middlewares/` absent, `express` not in dependencies | 2026-08 |
|
|
183
205
|
|
|
206
|
+
**v1.1 "Multitenancy Standard" was retired on 2026-09-04** (owner decision,
|
|
207
|
+
`/docs/governance/confirmations/service-shape-v11-retirement.md`). Its only check
|
|
208
|
+
asserted that `wrapper.tenantContext` was present in `config.json`, and nothing
|
|
209
|
+
reads that key — the wrapper runtime never looks it up,
|
|
210
|
+
`createTenantContextMiddleware` was deleted outright on 2026-09-05
|
|
211
|
+
(`/docs/governance/confirmations/wrapper-tenant-middleware.md`), and
|
|
212
|
+
`runtime-defaults.json` no longer carries the key either. The level is removed,
|
|
213
|
+
not emptied: a level with no checks passes for everyone while asserting nothing
|
|
214
|
+
(`automation-gates.md` §5). The number is not reused and the gap in the numbering
|
|
215
|
+
stays, so a level recorded earlier still means what it meant.
|
|
216
|
+
|
|
184
217
|
**Key properties:**
|
|
185
|
-
- **Cumulative** —
|
|
218
|
+
- **Cumulative** — each level requires every level listed above it (so v1.2
|
|
219
|
+
requires v1.0); the table is the authority, not the version number
|
|
186
220
|
- **Baked into validator** — older validator versions naturally know fewer levels (backward compatible)
|
|
187
221
|
- **Warnings** — next unsatisfied level generates `STANDARD_LEVEL_GAP` warnings with specific missing checks
|
|
188
222
|
- **In validation results** — `ServiceStructureValidator.validate()` returns
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/conn-orch-validator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -441,11 +441,22 @@ class CookbookTestRunner {
|
|
|
441
441
|
// Synthesize an HTTP-like response so existing expect validators
|
|
442
442
|
// continue to work (expect.status: 'error', expect.error.code, ...).
|
|
443
443
|
const errorCode = handlerError.code || handlerError.name || 'HANDLER_ERROR';
|
|
444
|
-
|
|
445
|
-
|
|
444
|
+
// `status` is the platform's ONE name for the number a thrown error
|
|
445
|
+
// carries: `BusinessError` sets it, `ErrorMapper` reads it
|
|
446
|
+
// (@onlineapps/service-wrapper, src/ErrorMapper.js), and a service that
|
|
447
|
+
// throws its own class declares the same shape.
|
|
448
|
+
// @see api/docs/biz/70-contracts/error-handling.md §1
|
|
449
|
+
//
|
|
450
|
+
// No `statusCode || status` chain. The retired second name was read here
|
|
451
|
+
// and nowhere else, which is why three biz services mirrored the value
|
|
452
|
+
// under both names to satisfy this line; a fallback would keep both alive
|
|
453
|
+
// and let them drift. An error carrying no usable status is a 500 — the
|
|
454
|
+
// handler declared no outcome, so it gets the server fault it earned.
|
|
455
|
+
const status = typeof handlerError.status === 'number'
|
|
456
|
+
? handlerError.status
|
|
446
457
|
: 500;
|
|
447
458
|
result.response = {
|
|
448
|
-
status
|
|
459
|
+
status,
|
|
449
460
|
statusText: errorCode,
|
|
450
461
|
data: { code: errorCode, message: handlerError.message }
|
|
451
462
|
};
|
|
@@ -6,6 +6,19 @@ const CookbookTestUtils = require('./CookbookTestUtils');
|
|
|
6
6
|
// error message lists them in.
|
|
7
7
|
const LOGGER_METHODS = ['info', 'warn', 'error', 'debug'];
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The semver 2.0.0 grammar, verbatim from semver.org's own published regular
|
|
11
|
+
* expression (anchored, no leading `v`, no surrounding whitespace, no leading
|
|
12
|
+
* zeroes in a numeric identifier).
|
|
13
|
+
*
|
|
14
|
+
* Written out rather than pulled from the `semver` package: this is the only
|
|
15
|
+
* version rule in the package, one anchored pattern expresses it completely,
|
|
16
|
+
* and a dependency added for a single `test()` call is a supply chain the gate
|
|
17
|
+
* does not need. If a second version rule ever appears here, that trade changes
|
|
18
|
+
* — and the change is then visible, because it is this comment that has to go.
|
|
19
|
+
*/
|
|
20
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
21
|
+
|
|
9
22
|
/**
|
|
10
23
|
* ServiceReadinessValidator - Orchestrates complete service validation.
|
|
11
24
|
*
|
|
@@ -311,16 +324,63 @@ class ServiceReadinessValidator {
|
|
|
311
324
|
}
|
|
312
325
|
|
|
313
326
|
/**
|
|
314
|
-
* Check registry compatibility
|
|
327
|
+
* Check registry compatibility.
|
|
328
|
+
*
|
|
329
|
+
* `canRegister` used to be `!!(name && version && operations)`. Truthiness is
|
|
330
|
+
* the wrong question about a version: the line below it compares this value
|
|
331
|
+
* against what the registry already holds, and a comparison between two
|
|
332
|
+
* strings that are not versions decides nothing. Measured (BIZ-ingest): every
|
|
333
|
+
* biz `config/service/config.json` declares
|
|
334
|
+
* `"service": { "version": "${npm_package_version}" }`, the placeholder was
|
|
335
|
+
* handed here unexpanded, and — being a non-empty string — it passed. The
|
|
336
|
+
* gate reported `canRegister: true` about a value that is not a version.
|
|
337
|
+
*
|
|
338
|
+
* The grammar is semver 2.0.0, checked here and nowhere else: this is the one
|
|
339
|
+
* place that decides `canRegister`. Callers supply the value (the `version`
|
|
340
|
+
* field of the service package.json, which is what ConfigLoader reads at
|
|
341
|
+
* runtime); they do not re-check it.
|
|
315
342
|
*/
|
|
316
343
|
async checkRegistryCompatibility(service, registry) {
|
|
317
344
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
345
|
+
const errors = [];
|
|
346
|
+
|
|
347
|
+
if (!service.name) {
|
|
348
|
+
errors.push(
|
|
349
|
+
'[ServiceReadinessValidator] Service name is missing - '
|
|
350
|
+
+ 'validateReadiness() received no `name`. '
|
|
351
|
+
+ 'Expected: the service name, e.g. biz-ingest. '
|
|
352
|
+
+ 'Fix: pass it in the object handed to validateReadiness().'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (service.version === undefined || service.version === null) {
|
|
357
|
+
errors.push(
|
|
358
|
+
'[ServiceReadinessValidator] Service version is missing - '
|
|
359
|
+
+ 'validateReadiness() received no `version`. '
|
|
360
|
+
+ 'Expected: the `version` field of the service package.json, e.g. 1.4.2. '
|
|
361
|
+
+ 'Fix: pass it in the object handed to validateReadiness().'
|
|
362
|
+
);
|
|
363
|
+
} else if (!SEMVER_PATTERN.test(service.version)) {
|
|
364
|
+
errors.push(
|
|
365
|
+
'[ServiceReadinessValidator] Service version is not a semantic version - '
|
|
366
|
+
+ `received "${service.version}". `
|
|
367
|
+
+ 'Expected: MAJOR.MINOR.PATCH per semver 2.0.0, e.g. 1.4.2 or 1.4.2-rc.1. '
|
|
368
|
+
+ 'Fix: pass the `version` field of the service package.json — the single source of '
|
|
369
|
+
+ 'truth ConfigLoader reads at runtime. An unresolved "${...}" means the value came '
|
|
370
|
+
+ 'from config.json instead, where the key is never expanded.'
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!service.operations) {
|
|
375
|
+
errors.push(
|
|
376
|
+
'[ServiceReadinessValidator] Operations are missing - '
|
|
377
|
+
+ 'validateReadiness() received no `operations`. '
|
|
378
|
+
+ 'Expected: the flat operations object from config/service/operations.json. '
|
|
379
|
+
+ 'Fix: pass it in the object handed to validateReadiness().'
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const canRegister = errors.length === 0;
|
|
324
384
|
|
|
325
385
|
// Check if service operations match registry expectations
|
|
326
386
|
const registeredService = registry.getService(service.name);
|
|
@@ -331,7 +391,8 @@ class ServiceReadinessValidator {
|
|
|
331
391
|
passed: canRegister && compatible,
|
|
332
392
|
canRegister,
|
|
333
393
|
compatible,
|
|
334
|
-
existingVersion: registeredService?.version
|
|
394
|
+
existingVersion: registeredService?.version,
|
|
395
|
+
errors
|
|
335
396
|
};
|
|
336
397
|
} catch (error) {
|
|
337
398
|
return {
|
|
@@ -4,7 +4,13 @@ const fs = require('fs');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { verifyConnectorContract } = require('./utils/connectorContract');
|
|
6
6
|
const { normalizeEnvDeclaration, collectEnvCoverage, verifyEnvPresence } = require('./utils/envContract');
|
|
7
|
-
|
|
7
|
+
// `ValidationProofVerifier` was destructured here alongside the codec and never
|
|
8
|
+
// called. It verified a cached proof before booting; the proof cache was
|
|
9
|
+
// removed (see validate()) and the verifier's last call site went with it,
|
|
10
|
+
// leaving a binding whose only remaining effect was to tell a reader that this
|
|
11
|
+
// class still checks proofs. `ValidationProofCodec` stays — finalizeResults()
|
|
12
|
+
// encodes the proof it writes.
|
|
13
|
+
const { ValidationProofCodec } = require('@onlineapps/service-validator-core');
|
|
8
14
|
const FingerprintUtils = require('@onlineapps/service-validator-core').FingerprintUtils;
|
|
9
15
|
const { ServiceStructureValidator } = require('./validators/ServiceStructureValidator');
|
|
10
16
|
const { describeStepFailureWithContext } = require('./utils/stepFailure');
|
|
@@ -115,15 +121,72 @@ class ValidationOrchestrator {
|
|
|
115
121
|
|
|
116
122
|
|
|
117
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Read and parse the service's package.json.
|
|
126
|
+
*
|
|
127
|
+
* It is the single source of truth for the service version — the same one
|
|
128
|
+
* validateConfig() names below, and the same one the biz pre-validation entry
|
|
129
|
+
* point (`src/utils/preValidation.js`) already reads. `config/service/config.json`
|
|
130
|
+
* cannot supply it: the wrapper's ConfigLoader
|
|
131
|
+
* (`api/shared/connector/service-wrapper/src/ConfigLoader.js`) assigns
|
|
132
|
+
* `config.service.version` from package.json unconditionally on every load, so
|
|
133
|
+
* the key in the file is dead — and where it is declared at all, biz services
|
|
134
|
+
* declare it as the unresolved literal `${npm_package_version}`.
|
|
135
|
+
*
|
|
136
|
+
* A missing, unreadable, unparseable or version-less package.json is a contract
|
|
137
|
+
* violation, not a computation hiccup, so it is raised here in full §5 shape
|
|
138
|
+
* rather than through calculateFingerprint()'s generic wrapper.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} packageFile absolute path to the service's package.json
|
|
141
|
+
* @returns {object} the parsed package.json, `version` guaranteed non-empty
|
|
142
|
+
*/
|
|
143
|
+
readServicePackageJson(packageFile) {
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = fs.readFileSync(packageFile, 'utf8');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[ValidationOrchestrator] Service package.json is unreadable - Expected: a readable ${packageFile}, `
|
|
150
|
+
+ 'the single source of truth for the service version. '
|
|
151
|
+
+ `Read failed with: ${error.code || error.message}. `
|
|
152
|
+
+ 'Fix: point options.serviceRoot at the service directory that contains package.json.'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let pkg;
|
|
157
|
+
try {
|
|
158
|
+
pkg = JSON.parse(raw);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`[ValidationOrchestrator] Service package.json is not valid JSON - Expected: parseable JSON at ${packageFile}. `
|
|
162
|
+
+ `Parse failed with: ${error.message}. `
|
|
163
|
+
+ 'Fix: repair the file.'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (typeof pkg.version !== 'string' || pkg.version.length === 0) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`[ValidationOrchestrator] Service package.json has no version - Expected: a non-empty "version" string in ${packageFile}, `
|
|
170
|
+
+ 'the single source of truth for the service version. '
|
|
171
|
+
+ `Got: ${JSON.stringify(pkg.version)}. `
|
|
172
|
+
+ 'Fix: set "version" in the service package.json.'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return pkg;
|
|
177
|
+
}
|
|
178
|
+
|
|
118
179
|
/**
|
|
119
180
|
* Calculate service fingerprint
|
|
120
181
|
* Based on: version + operations.json + dependencies + config
|
|
121
182
|
*/
|
|
122
183
|
async calculateFingerprint() {
|
|
184
|
+
const packageFile = path.join(this.serviceRoot, 'package.json');
|
|
185
|
+
const pkg = this.readServicePackageJson(packageFile);
|
|
186
|
+
|
|
123
187
|
try {
|
|
124
188
|
const configFile = path.join(this.configPath, 'config.json');
|
|
125
189
|
const operationsFile = path.join(this.configPath, 'operations.json');
|
|
126
|
-
const packageFile = path.join(this.serviceRoot, 'package.json');
|
|
127
190
|
const dockerFile = path.join(this.serviceRoot, 'Dockerfile');
|
|
128
191
|
const dockerComposeFile = path.join(this.serviceRoot, 'docker-compose.yml');
|
|
129
192
|
const envTemplateFile = path.join(this.serviceRoot, '..', '..', 'config', 'env-templates', `${path.basename(this.serviceRoot)}.env`);
|
|
@@ -134,7 +197,6 @@ class ValidationOrchestrator {
|
|
|
134
197
|
|
|
135
198
|
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
|
136
199
|
const operations = JSON.parse(fs.readFileSync(operationsFile, 'utf8'));
|
|
137
|
-
const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
|
|
138
200
|
|
|
139
201
|
// Include infra file contents in fingerprint to detect changes in environment/setup
|
|
140
202
|
const infra = {};
|
|
@@ -187,8 +249,21 @@ class ValidationOrchestrator {
|
|
|
187
249
|
}
|
|
188
250
|
|
|
189
251
|
// Generate fingerprint using FingerprintUtils (handles deep sorting)
|
|
252
|
+
//
|
|
253
|
+
// The version comes from package.json alone. It used to read
|
|
254
|
+
// `config.service?.version || pkg.version`, which hashed a value that was
|
|
255
|
+
// not a version at all: `config.service.version` is overwritten by the
|
|
256
|
+
// wrapper's ConfigLoader on every load (see readServicePackageJson above),
|
|
257
|
+
// and the services that declare it leave the unresolved literal
|
|
258
|
+
// `${npm_package_version}` in that key. The `||` then hid it — for those
|
|
259
|
+
// services the package.json branch was never reached, so publishing a new
|
|
260
|
+
// version of one did not move its fingerprint by one bit
|
|
261
|
+
// (package.json is otherwise represented here only by its @onlineapps
|
|
262
|
+
// dependency pins). config.json's own content is still hashed below, so
|
|
263
|
+
// editing that key is still detected — as a config change, which is what
|
|
264
|
+
// it is.
|
|
190
265
|
const fingerprintData = {
|
|
191
|
-
serviceVersion:
|
|
266
|
+
serviceVersion: pkg.version,
|
|
192
267
|
operations: operations,
|
|
193
268
|
dependencies: deps,
|
|
194
269
|
config: config,
|
package/src/helpers/README.md
CHANGED
|
@@ -126,7 +126,16 @@ const operations = JSON.parse(fs.readFileSync(path.join(serviceRoot, 'config/ser
|
|
|
126
126
|
|
|
127
127
|
// Extract metadata
|
|
128
128
|
const serviceName = config.service.name;
|
|
129
|
-
|
|
129
|
+
|
|
130
|
+
// The version comes from package.json, NEVER from config.json. ConfigLoader
|
|
131
|
+
// (shared/connector/service-wrapper/src/ConfigLoader.js) assigns
|
|
132
|
+
// `config.service.version` from package.json unconditionally on every load, and
|
|
133
|
+
// does it BEFORE `${VAR}` placeholders are resolved — so `service.version` in
|
|
134
|
+
// config.json never reaches a running service under any value, and reading it
|
|
135
|
+
// here yielded the literal string `${npm_package_version}`.
|
|
136
|
+
const serviceVersion = JSON.parse(
|
|
137
|
+
fs.readFileSync(path.join(serviceRoot, 'package.json'), 'utf-8')
|
|
138
|
+
).version;
|
|
130
139
|
```
|
|
131
140
|
|
|
132
141
|
Nothing loads `src/app.js` and nothing reads a health endpoint: both belonged to
|
|
@@ -84,9 +84,22 @@ function createServiceReadinessTests(testsDir, options = {}) {
|
|
|
84
84
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
85
85
|
const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf-8'));
|
|
86
86
|
|
|
87
|
-
// Extract metadata from config
|
|
88
87
|
const serviceName = config.service.name;
|
|
89
|
-
|
|
88
|
+
|
|
89
|
+
// The version comes from package.json, never from config.json — the same
|
|
90
|
+
// single source of truth the runtime uses:
|
|
91
|
+
//
|
|
92
|
+
// shared/connector/service-wrapper/src/ConfigLoader.js:239
|
|
93
|
+
// config.service.version = this.loadPackageVersion(basePath);
|
|
94
|
+
//
|
|
95
|
+
// That assignment is unconditional and happens BEFORE `${VAR}` placeholders
|
|
96
|
+
// are resolved, so `service.version` in config.json never reaches a running
|
|
97
|
+
// service under any value. Reading it here was reading a dead key, and the
|
|
98
|
+
// value it yielded was the literal string `${npm_package_version}`: the
|
|
99
|
+
// placeholder that no expansion pass ever touches, handed to
|
|
100
|
+
// ServiceReadinessValidator, which used to accept it for being truthy.
|
|
101
|
+
const packagePath = path.join(serviceRoot, 'package.json');
|
|
102
|
+
const serviceVersion = JSON.parse(fs.readFileSync(packagePath, 'utf-8')).version;
|
|
90
103
|
|
|
91
104
|
describe(`${serviceName} Service Readiness @integration`, () => {
|
|
92
105
|
test('service passes readiness validation', async () => {
|
|
@@ -331,6 +331,77 @@ function isCommentLine(line) {
|
|
|
331
331
|
return trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/**
|
|
335
|
+
* The other half of the same distinction. A comment is prose because of WHERE
|
|
336
|
+
* it sits; an error message is prose because of WHAT IT SAYS.
|
|
337
|
+
*
|
|
338
|
+
* "Inside quotes" alone is NOT the test, and must never become it: a query is
|
|
339
|
+
* a string too, and a query is exactly what reaches a database —
|
|
340
|
+
* `SELECT 1 FROM t WHERE tenant_id = 100` has to keep failing. What separates
|
|
341
|
+
* the two is the verb. Prose makes a CLAIM about the identifier ("workspace_id
|
|
342
|
+
* = 0 IS NOT a value"); code assigns a VALUE to it, and nothing follows the
|
|
343
|
+
* number but syntax.
|
|
344
|
+
*
|
|
345
|
+
* So the permit is granted only when the number is followed, inside the same
|
|
346
|
+
* string literal, by a finite verb from the closed list below. Everything else
|
|
347
|
+
* — end of string, `,`, `)`, ` AND `, ` order by ` — stays reported. The list
|
|
348
|
+
* is deliberately short and enumerated rather than clever: a gate whose verdict
|
|
349
|
+
* cannot be predicted from reading it is not a gate (`automation-gates.md` §1).
|
|
350
|
+
*
|
|
351
|
+
* Both conditions fail CLOSED. A string that spans several lines looks to this
|
|
352
|
+
* line-local scanner like no string at all, so its literal keeps its report.
|
|
353
|
+
*/
|
|
354
|
+
const PROSE_PREDICATE =
|
|
355
|
+
/^[\s,;:—-]*\b(is|are|was|were|has|have|had|means|meant|carries|carry|cannot|must|never|does|do|would|will|remains|stays)\b/i;
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The end offset of the CLOSED string literal containing `index`, or -1 when
|
|
359
|
+
* that position is code. Line-local and escape-aware; a quote opened on an
|
|
360
|
+
* earlier line is invisible here, which is the safe direction (no permit).
|
|
361
|
+
*/
|
|
362
|
+
function enclosingStringEnd(line, index) {
|
|
363
|
+
let quote = null;
|
|
364
|
+
let start = -1;
|
|
365
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
366
|
+
const ch = line[i];
|
|
367
|
+
if (quote !== null && ch === '\\') { i += 1; continue; }
|
|
368
|
+
if (quote === null) {
|
|
369
|
+
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; start = i; }
|
|
370
|
+
} else if (ch === quote) {
|
|
371
|
+
if (index > start && index < i) return i;
|
|
372
|
+
quote = null;
|
|
373
|
+
start = -1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return -1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* True when the literal at `index` is quoted inside an English sentence that
|
|
381
|
+
* talks ABOUT the identifier, rather than handing it a value.
|
|
382
|
+
*/
|
|
383
|
+
function isProseLiteral(line, index, matchText) {
|
|
384
|
+
const end = enclosingStringEnd(line, index);
|
|
385
|
+
if (end === -1) return false;
|
|
386
|
+
return PROSE_PREDICATE.test(line.slice(index + matchText.length, end));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* A tenant / workspace identity frozen into this line of executable code.
|
|
391
|
+
*
|
|
392
|
+
* One rail for both scan sites (src+scripts, and the shared test-namespace
|
|
393
|
+
* module), so the two permits — comment, prose — cannot drift apart.
|
|
394
|
+
*/
|
|
395
|
+
function freezesNamespaceIdentity(line) {
|
|
396
|
+
if (isCommentLine(line)) return false;
|
|
397
|
+
for (const pattern of [LITERAL_NAMESPACE, LITERAL_NAMESPACE_CONST]) {
|
|
398
|
+
const match = pattern.exec(line);
|
|
399
|
+
if (!match) continue;
|
|
400
|
+
if (!isProseLiteral(line, match.index, match[0])) return true;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
334
405
|
function walkFiles(dir, base, out) {
|
|
335
406
|
let entries;
|
|
336
407
|
try {
|
|
@@ -384,8 +455,7 @@ function checkNamespaceLiterals(serviceRoot, add) {
|
|
|
384
455
|
if (text === null) continue;
|
|
385
456
|
const lines = text.split('\n');
|
|
386
457
|
for (let i = 0; i < lines.length; i += 1) {
|
|
387
|
-
if (
|
|
388
|
-
if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
|
|
458
|
+
if (!freezesNamespaceIdentity(lines[i])) continue;
|
|
389
459
|
add('R8', `${file.relative}:${i + 1} freezes a tenant / workspace id into code:\n`
|
|
390
460
|
+ ` ${lines[i].trim()}\n`
|
|
391
461
|
+ ' Fix: take it from ctx (handlers), from a CLI argument (scripts),\n'
|
|
@@ -409,8 +479,7 @@ function checkIntegrationNamespaceSource(serviceRoot, add) {
|
|
|
409
479
|
if (isNamespaceModule(file.relative)) {
|
|
410
480
|
const lines = text.split('\n');
|
|
411
481
|
for (let i = 0; i < lines.length; i += 1) {
|
|
412
|
-
if (
|
|
413
|
-
if (!LITERAL_NAMESPACE.test(lines[i]) && !LITERAL_NAMESPACE_CONST.test(lines[i])) continue;
|
|
482
|
+
if (!freezesNamespaceIdentity(lines[i])) continue;
|
|
414
483
|
add('R8', `${file.relative}:${i + 1} freezes the test namespace into a literal:\n`
|
|
415
484
|
+ ` ${lines[i].trim()}\n`
|
|
416
485
|
+ ' This module exists so the namespace has one source; that source must\n'
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* Validates that a business service has correct directory structure,
|
|
5
5
|
* configuration files, and follows OA Drive standards.
|
|
6
6
|
*
|
|
7
|
-
* Each standard level is cumulative —
|
|
7
|
+
* Each standard level is cumulative — a level requires all checks from every level
|
|
8
|
+
* declared before it in STANDARD_LEVELS, in that order. The list is the authority,
|
|
9
|
+
* not the version number: v1.1 was retired on 2026-09-04 and its number is not
|
|
10
|
+
* reused, so today "v1.2" means v1.0 + v1.2.
|
|
8
11
|
* Older validator versions naturally support fewer levels (they don't know about newer ones).
|
|
9
12
|
*
|
|
10
13
|
* @module validators/ServiceStructureValidator
|
|
@@ -268,24 +271,21 @@ const STANDARD_LEVELS = [
|
|
|
268
271
|
return results;
|
|
269
272
|
}
|
|
270
273
|
},
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
];
|
|
287
|
-
}
|
|
288
|
-
},
|
|
274
|
+
// v1.1 "Multitenancy Standard" was retired on 2026-09-04 (owner decision,
|
|
275
|
+
// api/docs/governance/confirmations/service-shape-v11-retirement.md). Its single
|
|
276
|
+
// check `tenant_context` asserted only that `wrapper.tenantContext` EXISTED in
|
|
277
|
+
// config.json. Nothing reads that key: the wrapper runtime reads cache,
|
|
278
|
+
// infrastructureGate, health, validation, mq, registry, monitoring, state,
|
|
279
|
+
// secrets and heartbeat — not tenantContext; `createTenantContextMiddleware`
|
|
280
|
+
// was deleted on 2026-09-05 together with the runtime-defaults key
|
|
281
|
+
// (docs/governance/confirmations/wrapper-tenant-middleware.md). A
|
|
282
|
+
// level enforcing a dead declaration is the false guarantee automation-gates.md
|
|
283
|
+
// §5 names, so the level is GONE rather than emptied — an empty level would
|
|
284
|
+
// pass for every service while asserting nothing.
|
|
285
|
+
//
|
|
286
|
+
// The number is deliberately not reused and the gap is not closed up: v1.2 and
|
|
287
|
+
// v1.3 keep their names so that a level recorded in an older proof still means
|
|
288
|
+
// what it meant. Cumulative now reads "v1.2 requires v1.0".
|
|
289
289
|
{
|
|
290
290
|
level: 'v1.2',
|
|
291
291
|
name: 'Business Error Handling Standard',
|