@onlineapps/conn-orch-validator 10.0.0 → 11.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.
@@ -53,6 +53,9 @@ const SERVICE_MEMORY_PATH = 'deploy.resources.limits.memory';
53
53
  /** The profile that marks the test runner — its budget is F-RUNNER's row, not R-MEM's. */
54
54
  const TEST_PROFILE = 'test';
55
55
 
56
+ /** How a compose service names the identity its container runs as. */
57
+ const COMPOSE_USER_KEY = 'user';
58
+
56
59
  const readOrNull = (serviceRoot, relative) => {
57
60
  const target = path.join(serviceRoot, ...relative.split('/'));
58
61
  return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
@@ -269,6 +272,125 @@ const composeCommand = Object.freeze({
269
272
  }
270
273
  });
271
274
 
275
+ /**
276
+ * The instructions of ONE `FROM … AS <stage>` block, up to the next `FROM`.
277
+ *
278
+ * Comments are dropped, because the comment beside the identity names the very
279
+ * words this check reads and a comment is not an instruction.
280
+ *
281
+ * @param {string} dockerfile
282
+ * @param {string} stage the name after `AS`
283
+ * @returns {string[]|null} null when the file declares no such stage
284
+ */
285
+ function stageInstructions(dockerfile, stage) {
286
+ const lines = dockerfile.split('\n').map((line) => line.trim());
287
+ const starts = lines
288
+ .map((line, index) => ({ line, index }))
289
+ .filter((entry) => /^FROM\s/i.test(entry.line));
290
+
291
+ const wanted = starts.find((entry) => new RegExp(`\\sAS\\s+${stage}\\s*$`, 'i').test(entry.line));
292
+ if (wanted === undefined) return null;
293
+
294
+ const after = starts.find((entry) => entry.index > wanted.index);
295
+ return lines
296
+ .slice(wanted.index, after === undefined ? lines.length : after.index)
297
+ .filter((line) => line !== '' && !line.startsWith('#'));
298
+ }
299
+
300
+ /**
301
+ * `R-USER` — WHICH USER the service process is.
302
+ *
303
+ * Measured 2026-09-17 on a production image built from the platform template:
304
+ * `docker run --rm <image> id` answered `uid=0(root) gid=0(root)`, while the dev
305
+ * container beside it ran as `1000:1000` because `docker-compose.yml` pins it.
306
+ * Nobody had decided that difference — the production stage declared no `USER`
307
+ * at all — and nothing in the uniform could say so, because `Dockerfile` is a
308
+ * file of the `own` class. This row is the SECOND platform fact carved out of
309
+ * that class, for the same reason as the first (`R-NODE`, the node major): what
310
+ * a service BUILDS is its own, what it RUNS AS is the platform's.
311
+ *
312
+ * Two facts, and they are the two nothing else holds:
313
+ *
314
+ * * the `production` stage of the `Dockerfile` declares a non-root `USER` —
315
+ * that is the stage CI builds (`docker build --target production`), and the
316
+ * one the defect lived in;
317
+ * * the SERVICE node of the dev compose pins the same identity numerically.
318
+ *
319
+ * The other two places it appears already have an owner, and a second rail over
320
+ * them would report one fact twice (`change-discipline.md` § One rail per
321
+ * concern): the runner's `user:` sits inside the block `F-RUNNER` holds byte for
322
+ * byte, and the production compose is `G-PROD`'s whole-file render.
323
+ *
324
+ * The two spellings are one identity, and the row carries both because no
325
+ * machine can resolve an account NAME to a uid without the image: `node` is uid
326
+ * 1000, gid 1000 in the node images this platform builds on (measured
327
+ * `docker run --rm node:24-alpine id node`), which is the `1000:1000` the compose
328
+ * files pin. Same shape as `R-MEM` and `R-PID1`, which carry their values for
329
+ * the same reason.
330
+ *
331
+ * @see api/docs/biz/00-model/service-shape.md
332
+ */
333
+ const processIdentity = Object.freeze({
334
+ scope: 'service',
335
+ requires: Object.freeze(['path', 'image_path', 'image_stage', 'service_user', 'service_uid']),
336
+
337
+ run({ row, serviceRoot, workspaceRoot }) {
338
+ const findings = [];
339
+ const identity = `the platform identity is ${JSON.stringify(row.service_user)} `
340
+ + '(uid 1000, gid 1000 in the node images this platform builds on)';
341
+ const imageWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.image_path });
342
+
343
+ const dockerfile = readOrNull(serviceRoot, row.image_path);
344
+ if (dockerfile === null) {
345
+ findings.push({ where: imageWhere, what: 'absent — the image this service runs as is built from it' });
346
+ } else {
347
+ const stage = stageInstructions(dockerfile, row.image_stage);
348
+ if (stage === null) {
349
+ findings.push({
350
+ where: imageWhere,
351
+ what: `declares no stage "AS ${row.image_stage}" — that is the stage CI builds, so nothing here `
352
+ + 'says what the deployed container runs as'
353
+ });
354
+ } else {
355
+ // The LAST one wins, the way docker reads it: a stage may switch twice,
356
+ // and what the container runs as is whatever stood at the end.
357
+ const declared = stage.filter((line) => /^USER\s/i.test(line)).pop();
358
+ if (declared === undefined) {
359
+ findings.push({
360
+ where: imageWhere,
361
+ what: `the ${row.image_stage} stage declares no USER, so the container runs as root — ${identity}`
362
+ });
363
+ } else if (declared.replace(/^USER\s+/i, '').trim() !== row.service_user) {
364
+ findings.push({
365
+ where: imageWhere,
366
+ what: `the ${row.image_stage} stage runs as ${JSON.stringify(declared.replace(/^USER\s+/i, '').trim())} — ${identity}`
367
+ });
368
+ }
369
+ }
370
+ }
371
+
372
+ const composeWhere = whereOf({ scope: 'bearer', serviceRoot, workspaceRoot, relative: row.path });
373
+ const compose = readOrNull(serviceRoot, row.path);
374
+ if (compose === null) {
375
+ findings.push({ where: composeWhere, what: 'absent — the service is run from it' });
376
+ return findings;
377
+ }
378
+
379
+ for (const [name, node] of runtimeServices(compose)) {
380
+ const declared = declarationOf(node, COMPOSE_USER_KEY).trim().replace(/^["']|["']$/g, '');
381
+ if (declared === row.service_uid) continue;
382
+ findings.push({
383
+ where: composeWhere,
384
+ what: declared === ''
385
+ ? `service ${name} declares no user — the platform identity is ${JSON.stringify(row.service_uid)}`
386
+ : `service ${name} runs as ${JSON.stringify(declared)} — the platform identity is ${JSON.stringify(row.service_uid)}`
387
+ });
388
+ }
389
+
390
+ return findings;
391
+ }
392
+ });
393
+
272
394
  const composeNoPorts = Object.freeze({
273
395
  scope: 'service',
274
396
  requires: Object.freeze(['path']),
@@ -291,6 +413,7 @@ module.exports = {
291
413
  { name: 'node-major', check: nodeMajor },
292
414
  { name: 'memory-limit', check: memoryLimit },
293
415
  { name: 'compose-command', check: composeCommand },
416
+ { name: 'process-identity', check: processIdentity },
294
417
  { name: 'compose-no-ports', check: composeNoPorts }
295
418
  ],
296
419
  majorOf
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one probe that answers "is this tree a git checkout" — and the one command
5
+ * that asks it.
6
+ *
7
+ * Two places need the answer and they must never disagree:
8
+ *
9
+ * - the `git-tracked` row (`checks/gitTracked.js`), which asks what a clone
10
+ * would receive and reports NOT RUN where git cannot say;
11
+ * - step 7 of `ValidationOrchestrator`, which measures the whole uniform and,
12
+ * outside a checkout, is NOT RUN for the same reason (owner decision
13
+ * 2026-09-17, confirmation `api/docs/governance/confirmations/biz-service-manifest.md`
14
+ * 011).
15
+ *
16
+ * A second copy of `git ls-files` would be a second rail for one concern
17
+ * (`.claude/rules/change-discipline.md` § One rail per concern) and, worse, two
18
+ * rails that can answer differently: one of them would eventually grow a
19
+ * `.git` existence check, which is not the same question — a worktree, a
20
+ * submodule and a `$GIT_DIR` elsewhere are checkouts without a `.git`
21
+ * DIRECTORY, and a directory called `.git` inside an exported tarball is not a
22
+ * checkout. What decides is whether git itself can answer.
23
+ *
24
+ * Nothing here guesses: a command git could not answer returns `null`, and the
25
+ * caller turns that into NOT RUN. What a negative answer MEANS is stated here
26
+ * once, in `NOT_A_CHECKOUT`; the remedy is the caller's own, because a row and a
27
+ * whole step do not fix the same way.
28
+ */
29
+
30
+ const { execFileSync } = require('child_process');
31
+
32
+ /**
33
+ * What a negative answer means — the half both callers state identically, so it
34
+ * cannot drift into two versions of one fact. Each of them appends its own
35
+ * remedy sentence after it.
36
+ */
37
+ const NOT_A_CHECKOUT = 'this tree is not a git checkout (git ls-files could not answer), so what a clone '
38
+ + 'would receive cannot be read — a container and an exported tarball are in exactly this state.';
39
+
40
+ /**
41
+ * Run one git command in the tree, or return null when git itself could not
42
+ * answer.
43
+ *
44
+ * @param {string} root the directory to run in
45
+ * @param {string[]} args the git arguments, without `-C <root>`
46
+ * @param {string} [input] stdin for commands that read it
47
+ * @returns {string|null} stdout, or null when git could not answer
48
+ */
49
+ function gitCommand(root, args, input = undefined) {
50
+ try {
51
+ return execFileSync('git', ['-C', root, ...args], {
52
+ encoding: 'utf8',
53
+ input,
54
+ stdio: ['pipe', 'pipe', 'pipe']
55
+ });
56
+ } catch (error) {
57
+ // check-ignore exits 1 when nothing matched, which is an ANSWER.
58
+ if (error.status === 1 && typeof error.stdout === 'string') return error.stdout;
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * What git tracks in this tree, NUL-separated exactly as `git ls-files -z`
65
+ * printed it — or null when this is not a checkout.
66
+ *
67
+ * @param {string} root
68
+ * @returns {string|null}
69
+ */
70
+ function listTrackedFiles(root) {
71
+ return gitCommand(root, ['ls-files', '-z']);
72
+ }
73
+
74
+ /**
75
+ * Is this tree a git checkout?
76
+ *
77
+ * @param {string} root
78
+ * @returns {boolean}
79
+ */
80
+ function isGitCheckout(root) {
81
+ return listTrackedFiles(root) !== null;
82
+ }
83
+
84
+ module.exports = { gitCommand, listTrackedFiles, isGitCheckout, NOT_A_CHECKOUT };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one definition of a biz service's database account.
5
+ *
6
+ * Owner decision `db-accounts-per-service` 001: every service reaches its schema
7
+ * as ITSELF, with grants on its own schemas, and root stops being an operational
8
+ * identity. `db-migrations-first-deploy` 001 says what that is worth — "migrace
9
+ * nikdy pod rootem" (:32) — and, in the same breath, "žádný nový generátor SQL …
10
+ * žádná druhá kolej" (:33). So these statements exist ONCE: the production
11
+ * runbook (`api/docs/setup/INSTALL.md` § the account and its grants) and
12
+ * `biz-ci-gate setup-db-account` are two CALLERS of this function, not two
13
+ * copies of the same SQL.
14
+ *
15
+ * `createDatabaseSql` in `setupDatabase.js` stays where it is and is not
16
+ * re-exported here: creating the SCHEMA and creating the ACCOUNT are two
17
+ * concerns with two moments (the account exists before the schema in CI, and is
18
+ * created by the operator before the installer runs in production).
19
+ *
20
+ * ## The one difference between CI and production
21
+ *
22
+ * An integration suite builds THROWAWAY schemas beside the declared one —
23
+ * `src/utils/throwawaySchema.js`, used today as `oagen_emailer_fixture_<ms>_<pid>`
24
+ * (`api_biz/emailer/tests/integration/fixtureSchema.js`) and
25
+ * `oagen_meta_platformseedtest` (`api_biz/meta`). A grant on `oagen_emailer`
26
+ * alone refuses every one of them, which is why `ciThrowaway: true` adds a
27
+ * second grant over the service's own namespace.
28
+ *
29
+ * It does NOT widen the account's reach to another service: the pattern is
30
+ * anchored on this schema's name, and the `_` is ESCAPED. Unescaped, `_` is a
31
+ * single-character LIKE wildcard, so `oagen_meta_%` would also match
32
+ * `oagen_metadata` — a neighbouring service handed over whole. The backslash is
33
+ * what keeps the grant inside one service, and it is the difference
34
+ * `tests/unit/dbAccountGrants.test.js` measures.
35
+ *
36
+ * Production never gets that grant: it installs one schema, and a wildcard there
37
+ * would be a standing privilege nobody asked for.
38
+ *
39
+ * ## What is deliberately NOT here
40
+ *
41
+ * `GRANT SELECT ON information_schema.*` — the privilege
42
+ * `setupDatabase.assertTargetIsEmpty` names in its own error message. The server
43
+ * refuses to grant it at all (measured 2026-09-16, dev `gen_mariadb10.5`,
44
+ * MariaDB 10.5.17: `ERROR 1044 (42000): Access denied for user 'root'@'localhost'
45
+ * to database 'information_schema'`). Every account reads that catalogue
46
+ * already, filtered to the schemas it is granted — which is precisely the answer
47
+ * the emptiness probe needs.
48
+ *
49
+ * @see api/docs/governance/confirmations/db-accounts-per-service.md
50
+ * @see api/docs/governance/confirmations/db-migrations-first-deploy.md
51
+ * @see src/utils/setupDatabase.js
52
+ */
53
+
54
+ /**
55
+ * The host part of every account. One value, because a second spelling is a
56
+ * second account: `'x'@'%'` and `'x'@'localhost'` are different rows in
57
+ * `mysql.user`, with different grants, and an installer that picked one while
58
+ * the gate created the other would report an account the service cannot use.
59
+ */
60
+ const ACCOUNT_HOST = '%';
61
+
62
+ /**
63
+ * Identifiers and passwords reach the server as TEXT, so they are checked rather
64
+ * than escaped: an identifier in this platform is `oagen_<shortname>`, and
65
+ * anything carrying a quote, a backslash or a backtick is not one.
66
+ *
67
+ * Refusing is the whole of the answer (`architecture-principles.md` §3): a value
68
+ * that needs escaping here came from somewhere it should not have.
69
+ */
70
+ const FORBIDDEN_IN_VALUE = /['"`\\;\n\r]/;
71
+
72
+ function requireValue(value, name, fix) {
73
+ if (typeof value !== 'string' || value === '') {
74
+ throw new Error(`[DbAccountGrants] Missing ${name} - Expected a non-empty value.\n`
75
+ + ` Fix: ${fix}`);
76
+ }
77
+ if (FORBIDDEN_IN_VALUE.test(value)) {
78
+ throw new Error(`[DbAccountGrants] ${name} carries a character these statements cannot contain: `
79
+ + `${JSON.stringify(value)}.\n`
80
+ + ' The statements are text handed to the SQL client, so a quote, backslash, backtick, '
81
+ + 'semicolon or newline would end one statement and start another.\n'
82
+ + ` Fix: ${fix}`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ /**
88
+ * Every statement that creates this service's account and grants it what it
89
+ * needs, in the order they must run.
90
+ *
91
+ * @param {object} options
92
+ * @param {string} options.schema the schema the service declares
93
+ * (`database.schema` of `config/service/integration-contract.json`)
94
+ * @param {string} options.account the account the service declares
95
+ * (`DB_USER` of `config/env-templates/<service>.env`)
96
+ * @param {string} options.password the password the account is created with
97
+ * @param {boolean} [options.ciThrowaway] also grant the throwaway namespace
98
+ * `<schema>\_%`, which only an integration tier builds into
99
+ * @returns {string[]} the statements, in order
100
+ */
101
+ function databaseAccountSql({ schema, account, password, ciThrowaway = false } = {}) {
102
+ requireValue(schema, 'schema', 'pass database.schema from config/service/integration-contract.json.');
103
+ requireValue(account, 'account', 'pass DB_USER from this service\'s config/env-templates/<service>.env.');
104
+ requireValue(password, 'password', 'pass the password the account is created with; an account created '
105
+ + 'with an empty password is one anybody on the network can use.');
106
+
107
+ const identity = `'${account}'@'${ACCOUNT_HOST}'`;
108
+
109
+ const statements = [
110
+ `CREATE USER IF NOT EXISTS ${identity} IDENTIFIED BY '${password}';`,
111
+ `GRANT ALL PRIVILEGES ON \`${schema}\`.* TO ${identity};`
112
+ ];
113
+
114
+ if (ciThrowaway === true) {
115
+ // The backslash escapes `_` so the pattern stays inside this service; see
116
+ // the head of this file for the neighbour it would otherwise reach.
117
+ statements.push(`GRANT ALL PRIVILEGES ON \`${schema}\\_%\`.* TO ${identity};`);
118
+ }
119
+
120
+ // Last, always: a grant is not live for a connection opened before it.
121
+ statements.push('FLUSH PRIVILEGES;');
122
+
123
+ return statements;
124
+ }
125
+
126
+ module.exports = { databaseAccountSql, ACCOUNT_HOST };
@@ -378,17 +378,26 @@ function assertScannableServiceRoot(serviceRoot) {
378
378
  }
379
379
 
380
380
  /**
381
- * COMPLETENESS: every environment name the repository visibly reads is either
382
- * declared in the env block or covered by M1/M2.
381
+ * The names this service's environment is ALLOWED to be judged by: everything
382
+ * the declaration names, plus everything M1/M2 already cover.
383
+ *
384
+ * It is the set `verifyEnvCompleteness` measures the repository against, lifted
385
+ * out of it so a second caller cannot grow a second definition of it (d.533c;
386
+ * `change-discipline.md` § One rail per concern). The completeness check below
387
+ * asks the same question from the other side — which read is NOT in this set —
388
+ * and `oa-validate --env-reads` prints the set itself, for the deploy gate that
389
+ * must not demand a value for a name this service never reads.
390
+ *
391
+ * Sorted with the default comparator rather than `localeCompare`: the order is
392
+ * read by a machine, and a collation that depends on the host's locale data is
393
+ * the ambient state `automation-gates.md` §1.1 forbids.
383
394
  *
384
395
  * @param {object} args
385
396
  * @param {string} args.serviceRoot
386
397
  * @param {object} args.contract normalized integration contract
387
- * @returns {{ok: boolean, violations: Array<{name: string, sources: string[]}>, declaredCount: number,
388
- * coveredCount: number, readCount: number, filesScanned: number,
389
- * dynamicSites: Array<{file: string, line: number}>}}
398
+ * @returns {{names: string[], coverage: Map<string,string>, declared: Set<string>}}
390
399
  */
391
- function verifyEnvCompleteness({ serviceRoot, contract }) {
400
+ function collectDeclaredEnvNames({ serviceRoot, contract }) {
392
401
  assertScannableServiceRoot(serviceRoot);
393
402
  assertNormalizedContract(contract);
394
403
 
@@ -409,6 +418,26 @@ function verifyEnvCompleteness({ serviceRoot, contract }) {
409
418
  for (const item of declaration?.[listKey] ?? []) declared.add(item.name);
410
419
  }
411
420
 
421
+ const names = new Set(coverage.keys());
422
+ for (const name of declared) names.add(name);
423
+
424
+ return { names: [...names].sort(), coverage, declared };
425
+ }
426
+
427
+ /**
428
+ * COMPLETENESS: every environment name the repository visibly reads is either
429
+ * declared in the env block or covered by M1/M2.
430
+ *
431
+ * @param {object} args
432
+ * @param {string} args.serviceRoot
433
+ * @param {object} args.contract normalized integration contract
434
+ * @returns {{ok: boolean, violations: Array<{name: string, sources: string[]}>, declaredCount: number,
435
+ * coveredCount: number, readCount: number, filesScanned: number,
436
+ * dynamicSites: Array<{file: string, line: number}>}}
437
+ */
438
+ function verifyEnvCompleteness({ serviceRoot, contract }) {
439
+ const { coverage, declared } = collectDeclaredEnvNames({ serviceRoot, contract });
440
+
412
441
  const { reads, dynamicSites, filesScanned } = collectEnvReads({ serviceRoot });
413
442
 
414
443
  const violations = [];
@@ -467,6 +496,7 @@ module.exports = {
467
496
  ENV_SCAN_BLIND_SPOTS,
468
497
  normalizeEnvDeclaration,
469
498
  collectEnvCoverage,
499
+ collectDeclaredEnvNames,
470
500
  collectEnvReads,
471
501
  verifyEnvCompleteness,
472
502
  verifyEnvPresence
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Which environment names a service's image reads — asked by a deploy gate,
5
+ * answered by the contract (d.533c).
6
+ *
7
+ * ## The defect this ends
8
+ *
9
+ * `api/scripts/validate-env.sh` refuses a deploy when a key in the target's
10
+ * `.env` still says `CHANGE_ME`. Pointed at a biz service it refused on
11
+ * `JWT_SECRET`, a key the gateway reads and no biz image ever opens: the gate
12
+ * was judging the platform's whole key set against one service. The fix is not
13
+ * a list of exceptions in bash — it is the service saying what it reads.
14
+ *
15
+ * ## Why not a grep for `process.env`
16
+ *
17
+ * Because most of the platform's environment is read inside installed
18
+ * libraries: `@onlineapps/service-wrapper` reads `SECRETS_MASTER_KEYS`,
19
+ * `@onlineapps/conn-base-cache` reads `REDIS_URL`. A grep over `src/` sees none
20
+ * of them and would answer a set too small — which in a gate means a key
21
+ * silently unchecked. That blind spot is stated, not discovered:
22
+ * `utils/envContract.js` § Measurability boundary.
23
+ *
24
+ * ## The set is the uniform's, not a second definition
25
+ *
26
+ * The answer is `collectDeclaredEnvNames` — the contract's `env` block, the
27
+ * `${VAR}` placeholders of `config/service/*.json`, and the endpoint variables
28
+ * of the connectors the contract declares required. That is exactly the set row
29
+ * `C-ENV-READS` of the biz-service uniform measures the repository against, so
30
+ * the two can never disagree about what a service reads
31
+ * (`change-discipline.md` § One rail per concern).
32
+ *
33
+ * The consequence is deliberate: a name the SOURCE reads and the contract does
34
+ * not declare is absent from this answer. It is a blocking finding of
35
+ * `C-ENV-READS`, with its own fix, and a gate must not demand a value for a key
36
+ * nobody owns.
37
+ *
38
+ * The messages carry the context `[oa-validate]` because the subcommand is this
39
+ * module's only caller and the message goes to a bash gate reading stderr; there
40
+ * is no wrapper re-wording them, which is how they stay the exact sentence the
41
+ * test asserts (`automation-gates.md` §2).
42
+ *
43
+ * @see api/docs/biz/70-contracts/env-contract.md
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+
49
+ const { loadAndValidateIntegrationContract } = require('./bizCiGateContract');
50
+ const { collectDeclaredEnvNames } = require('./envContract');
51
+
52
+ /** Where every biz service declares what it integrates with. */
53
+ const CONTRACT_RELATIVE_PATH = path.join('config', 'service', 'integration-contract.json');
54
+
55
+ /**
56
+ * Every environment name the service at `serviceRoot` reads, as the uniform
57
+ * measures it.
58
+ *
59
+ * @param {string} serviceRoot repository root of the service; the caller
60
+ * resolves its own default, so an empty value is refused rather than silently
61
+ * answered about `process.cwd()`
62
+ * @returns {string[]} the names, sorted, without duplicates
63
+ * @throws {Error} when the root or the contract cannot be read — a gate that
64
+ * cannot get the set must stop, never continue with an empty one
65
+ */
66
+ function listEnvReads(serviceRoot) {
67
+ if (typeof serviceRoot !== 'string' || serviceRoot.trim() === '') {
68
+ throw new Error('[oa-validate] No service root to answer about - --env-reads was given '
69
+ + `${typeof serviceRoot === 'string' ? 'an empty path' : typeof serviceRoot}. `
70
+ + 'Fix: oa-validate --env-reads <serviceRoot>, or run it inside the service repository.');
71
+ }
72
+
73
+ const root = path.resolve(serviceRoot);
74
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
75
+ throw new Error(`[oa-validate] Service root not found - ${root}. `
76
+ + 'Fix: pass an existing repository directory, or run the command inside the service repository.');
77
+ }
78
+
79
+ const contractPath = path.join(root, CONTRACT_RELATIVE_PATH);
80
+ if (!fs.existsSync(contractPath)) {
81
+ throw new Error(`[oa-validate] No environment contract - ${contractPath} does not exist, so nothing `
82
+ + 'declares which environment names this service reads and the answer would be an empty set. '
83
+ + 'Fix: add config/service/integration-contract.json with requiredConnectors and, where the '
84
+ + 'service reads a name no placeholder and no connector covers, an "env" block.');
85
+ }
86
+
87
+ let contract;
88
+ try {
89
+ contract = loadAndValidateIntegrationContract(root).contract;
90
+ } catch (error) {
91
+ throw new Error(`[oa-validate] Unusable environment contract - ${contractPath}: `
92
+ + `${String(error.message).split('\n')[0].trim()} `
93
+ + 'Fix: correct the contract, then run npx oa-validate to see the row that judges it.');
94
+ }
95
+
96
+ return collectDeclaredEnvNames({ serviceRoot: root, contract }).names;
97
+ }
98
+
99
+ module.exports = {
100
+ CONTRACT_RELATIVE_PATH,
101
+ listEnvReads
102
+ };