@onlineapps/conn-orch-validator 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,73 @@ All notable changes to this package. Follows [Keep a Changelog](https://keepacha
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [12.1.0] — 2026-09-18
8
+
9
+ ### Fixed — lint, který se odmítl spustit, je NOT RUN, ne šest nálezů o dokumentaci (d.632, W632)
10
+
11
+ Řádky dokumentace (`docs-lint`, `docs-lint-clean` → `D-PORT`, `D-NPM`, `D-SCRIPT`, `D-RETIRED`, `D-HEADER`, `D-LINT`): lint
12
+ `lint-biz-docs`, který skončil bez verdiktu (exit 2, pád spawnu, nerozparsovatelný JSON), je NOT RUN na každém řádku, který
13
+ z něj čte, a signál nese `complete: false`; dosud z toho most dělal šest nálezů `deploy` s radami o obsahu dokumentace a
14
+ `complete: true` (změřeno 2026-09-17, CI joby 16572194080 a 16571115198: GitLab Runner zakládá `api_biz/<svc>.tmp` a lint ho
15
+ bral za službu). Důvod NOT RUN nese CELÝ stderr lintu, první řádek první (poslední řádek byla závorka gitu „Stopping at
16
+ filesystem boundary“); `Fix:` se doplní jen když ho hláška lintu nenese. Chybějící `config/biz-docs-lint.tree.json` zůstává
17
+ nálezem (`C-LINT`). Zároveň v api (`6f9d2f2b`): lint i `sync-biz-facts` berou sourozence ze SSOT `config/services.json`
18
+ (`directory` u `enabled`), ne z výpisu `api_biz/` — nedeklarovaný adresář se nečte a řekne se to jednou větou.
19
+
20
+ ### Changed (testy) — třetí živá DB sada přes `liveDatabase.js`; sondy skládají dočasný adresář z `os.tmpdir()` (d.620b, W620b)
21
+
22
+ `bizCiGateCli.setupDbAccount.integration` (blok „how far the production grant reaches“) bere spojení z
23
+ `tests/helpers/liveDatabase.js`, práce je v `tests/helpers/grantReachProbe.js` a sada je třetí v jobu `test-validator-db`;
24
+ `setupDatabaseProbe.js` a `dbAccountProbe.js` už nepíší do doslovného `/tmp`.
25
+
26
+
27
+ ### Changed (testy) — živé DB sady databázi deklarují, nehledají; „unwritable root“ se pod rootem předvede (d.628, d.623c, W623)
28
+
29
+ `setupDatabaseLive.integration` a `dbCiAccount.integration` čtou spojení z `DB_HOST`, `DB_PORT`, `CI_DB_ROOT_USER`,
30
+ `CI_DB_ROOT_PASSWORD` — jména, která čte `biz-ci-gate setup-db-account` — přes jednoho vlastníka `tests/helpers/liveDatabase.js`;
31
+ dosud každá nesla vlastní kopii s vývojovým kontejnerem `gen_mariadb10.5`, sítí `gendb-network` a heslem z gitignorovaného
32
+ `config/env-active/gen-db.env`, takže v CI checkoutu padaly. Bez deklarace se případy registrují jako přeskočené s vytištěným
33
+ důvodem (runner hlásí „27 skipped“, ne „27 passed“); s deklarací se měří a nedostupný server, chybějící klient i chybějící
34
+ `DB_DOCKER_NETWORK` jsou pád. V CI je pouští nový job `test-validator-db` se službou `mariadb:10.5.29` (vlastník 2026-09-18,
35
+ conf `ci-validator-db-job` 001). Lokálně: README § Running the live-database suites. `oaValidateCli.integration` — případ
36
+ „an unwritable service root fails fast“ se pod uid 0 předvádí pod neprivilegovaným účtem stroje místo NOT RUN.
37
+
38
+
39
+ ### Changed — šablona `init.sh` staví strom z `package-lock.json` (`npm ci`), ne `npm install` (d.629, W629)
40
+
41
+ `templates/business-service/init.sh` `oa_npm_install()` = `npm ci --no-audit --no-fund`. `npm install --package-lock-only`
42
+ (re-pin v živém bind-mount stromu) přepíše lock i skrytý `node_modules/.package-lock.json`, složky balíčků nechá staré,
43
+ a `npm install` pak hlásí „up to date“ — služba tiše běží na jiné verzi, než pinuje lock a R6 (nález INFRA-monitoring,
44
+ INFRA B137 `28fef3dc` totéž v sedmi infra službách). Podmínka instalace (blok `oa-deps-guard v1`, marker
45
+ `.oa_drive_deps_hash`) ani znění bloku se nemění. Změřeno bez sítě nad lokálním tarballem: složka 1.0.0 × lock 2.0.0 →
46
+ `npm install` „up to date“ (1.0.0), `npm ci` „added 1 package“ (2.0.0). Řádek uniformy `F-INIT` roznáší jen blok, servisní
47
+ `oa_npm_install()` je řádek každé služby — běžících osm se nemění, dostanou ho pokynem. Služba oskládaná ze šablony
48
+ potřebuje `package-lock.json` už při prvním startu: šablona ho neveze, první boot bez něj skončí hlášeným `EUSAGE`.
49
+
50
+
51
+ ### Fixed — hlavní grant databázového účtu escapuje `_` (d.620, W620)
52
+
53
+ `databaseAccountSql` escapuje `_` i v hlavním grantu (`` ON `oagen\_x`.* ``), ne jen v CI wildcardu: databázová část
54
+ `GRANT`u je LIKE vzor i bez `%`, takže grant, který dostane každá nasazená služba, otevíral i sousední schéma lišící se
55
+ v tom jednom znaku (`oagen_emailer` → `oagen5emailer`; nález INFRA-DOCS z falzifikace `INSTALL.md`). Escape je jedna
56
+ funkce `escapeSchemaPattern()` použitá na obě věty; `%` ve jméně schématu se odmítá s `Fix:` (žádné platformní jméno ho
57
+ nenese, `\` odmítal už `FORBIDDEN_IN_VALUE`). Změřeno proti živé MariaDB 10.5: před opravou účet přečetl sousedovy řádky,
58
+ po opravě `ERROR 1142 (42000) … SELECT command denied`; vlastní schéma dál čitelné, CI throwaway grant (`oagen\_x\_%`) drží.
59
+
60
+
61
+ ### Fixed — pozice balíčku ve workspace nese konvenční prefix `api/`, ne jméno adresáře checkoutu (d.625, W625)
62
+
63
+ `sync/readmeLocation.js` odvozovalo `PACKAGE_IN_WORKSPACE` (a s ním `requiresSiblings` řádku `L-README-REGION`) z adresáře
64
+ NAD checkoutem. GitLab CI klonuje repozitář pod jménem projektu `infra-mono`, takže vznikalo
65
+ `infra-mono/shared/connector/conn-orch-validator`; `resolveWorkspacePath()` rozřeší přes marker jen hlavu `api` a zbytek
66
+ připojí doslova → řetězec neukazoval nikam a běh končil větou `Fix: run with --workspace pointing at a checkout that carries
67
+ infra-mono/…`, kterou nikdo nemůže splnit (`automation-gates.md` §1/4). Pravidlo stojí v hlavičce `manifest/workspaceRoot.js`:
68
+ `api/` na začátku cesty je konvence deklarujícího textu, nikdy jméno adresáře na disku — prefix se proto čte z
69
+ `WORKSPACE_MARKER` (jediný vlastník), ne píše podruhé. Změřeno v obrazu jobu nad jedním stromem pod dvěma jmény: 27 unit
70
+ testů a 6 bats červených pod `infra-mono`, zelené pod `api`; po opravě zelené pod oběma. Štítek souboru na stdout
71
+ `oa-sync-template` dál nese cestu na disku (tam soubor je) — srovnán test, ne výstup.
72
+
73
+
7
74
  ## [12.0.0] — 2026-09-17
8
75
 
9
76
  ### Fixed — BREAKING: R2 tvrdí pravidlo sekvence nasazení, ne včerejší text (d.614, W614)
package/README.md CHANGED
@@ -269,6 +269,53 @@ namespace an integration tier builds into (`src/utils/throwawaySchema.js`). The
269
269
  `_` is escaped, so the grant stays inside one service — unescaped it is a LIKE
270
270
  wildcard, and `oagen_meta_%` would also match `oagen_metadata`.
271
271
 
272
+ **The escape belongs to BOTH grants.** The database-name position of a `GRANT`
273
+ is a LIKE pattern whether or not a `%` follows it, so the grant every installed
274
+ service gets is written `` `oagen\_meta`.* `` too: unescaped, it also grants
275
+ `oagen5meta` and every other schema differing in that one character. `%` in a
276
+ schema name is refused rather than escaped — no platform name carries one.
277
+
278
+ ### Running the live-database suites
279
+
280
+ Two suites of this package measure what a **real** MariaDB does with the SQL the
281
+ package builds — `tests/unit/setupDatabaseLive.integration.test.js` (what
282
+ `ci:gate:setup` leaves in the schema) and `tests/unit/dbCiAccount.integration.test.js`
283
+ (whether the grants above are enough, and still not too much). They are part of
284
+ `npm run test:unit`, and in CI they have a job with a database of its own,
285
+ `test-validator-db` (owner decision
286
+ [`ci-validator-db-job` 001](../../../docs/governance/confirmations/ci-validator-db-job.md)).
287
+
288
+ Neither suite looks for a database. It is **declared**, under the names this
289
+ package's own CLI reads:
290
+
291
+ | Variable | What it is |
292
+ |---|---|
293
+ | `DB_HOST` / `DB_PORT` | the server the run measures |
294
+ | `CI_DB_ROOT_USER` / `CI_DB_ROOT_PASSWORD` | its administrative login — the suites create and drop schemas of their own |
295
+ | `DB_DOCKER_NETWORK` | only where no `mariadb` client is on PATH: the docker network the probe is carried in on |
296
+
297
+ Declare nothing and the suites report **NOT RUN** with that sentence and a `Fix:`
298
+ — they never stand down quietly. Declare a database and it is measured: an
299
+ unreachable server, a missing client and a missing network are failures, so a run
300
+ cannot go green having looked at nothing.
301
+
302
+ On a developer machine the dev server publishes no port and macOS carries no
303
+ client, so both the network and the credential come from the local environment —
304
+ the password is read out of the gitignored file that owns it, never written into
305
+ the repository:
306
+
307
+ ```bash
308
+ cd api/shared/connector/conn-orch-validator
309
+ DB_HOST=gen_mariadb10.5 DB_PORT=3306 \
310
+ CI_DB_ROOT_USER=root \
311
+ CI_DB_ROOT_PASSWORD="$(grep '^MARIADB_ROOT_PASSWORD=' ../../../config/env-active/gen-db.env | cut -d= -f2-)" \
312
+ DB_DOCKER_NETWORK=gendb-network \
313
+ npx jest tests/unit/setupDatabaseLive.integration.test.js tests/unit/dbCiAccount.integration.test.js
314
+ ```
315
+
316
+ The run prints which server it measured, or why it measured none — that line is
317
+ what tells a green suite that looked from a green suite that did not.
318
+
272
319
  ---
273
320
 
274
321
  ## Environment contract (`env` block)
package/jest.config.js CHANGED
@@ -13,6 +13,17 @@ module.exports = {
13
13
  testMatch: [
14
14
  '**/tests/**/*.test.js'
15
15
  ],
16
+
17
+ // tests/fixtures/manifest/** holds SIMULATED CHECKOUTS that the manifest
18
+ // suites read from disk as data, never require. Jest's haste map indexes every
19
+ // package.json it crawls, so the three fixture workspaces that each carry the
20
+ // service `biz-alpha` read as three modules of one name and the crawl prints
21
+ // "Haste module naming collision" on stdout. The reason the fixtures keep
22
+ // their names, and why the pattern stops at `manifest/` rather than covering
23
+ // tests/fixtures/ whole, is written once — in the repository's jest.config.js,
24
+ // beside the same string. This config owns the same exclusion for its own map,
25
+ // because rootDir here is the package.
26
+ modulePathIgnorePatterns: ['/tests/fixtures/manifest/'],
16
27
  coverageThreshold: {
17
28
  global: {
18
29
  branches: 80,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-validator",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "Validation orchestrator for OA Drive microservices - coordinates validation across all layers (base, infra, orch, business)",
5
5
  "oa": {
6
6
  "category": "orchestration"
@@ -107,6 +107,50 @@ const LINT_TIMEOUT_MS = 120000;
107
107
  /** Room for the JSON of a large tree; the biggest measured today is 54 findings. */
108
108
  const LINT_MAX_BUFFER = 32 * 1024 * 1024;
109
109
 
110
+ /**
111
+ * How much of the linter's own output the refusal sentence carries, in
112
+ * characters. A budget, not a line count: what makes a refusal actionable is the
113
+ * FIRST line, which is the linter's `[tool] ERROR:` message with its own `Fix:`
114
+ * in it, and the lines after it are context of falling value. So the first line
115
+ * is never cut and the rest fills what is left.
116
+ */
117
+ const REFUSAL_BUDGET = 700;
118
+
119
+ /** The remedy for a refusal whose own message named none. */
120
+ const REFUSAL_FIX = `Fix: run ${LINT_SCRIPT} over this tree by hand and repair what it reports.`;
121
+
122
+ /**
123
+ * What a run that produced no verdict says, from everything the linter printed.
124
+ *
125
+ * Until d.632 this read `…split('\n').filter(Boolean).pop()` — the LAST line —
126
+ * and the last line of the measured failure was git's own aside, `Stopping at
127
+ * filesystem boundary (GIT_DISCOVERY_ACROSS_FILESYSTEM not set)`. The sentence
128
+ * that says WHAT the linter refused, and how to repair it, is the first one, so
129
+ * the reader was handed the footnote and not the message (CI jobs 16572194080,
130
+ * 16571115198; `.claude/rules/automation-gates.md` §1 requirement 4).
131
+ *
132
+ * @param {{status: number|null, stderr: string, stdout: string}} run the finished process
133
+ * @returns {string}
134
+ */
135
+ function describeRefusal({ status, stderr, stdout }) {
136
+ const lines = String(stderr || stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
137
+ const exited = status === null || status === undefined ? 'on a signal' : String(status);
138
+ if (lines.length === 0) return `the lint exited ${exited} without a verdict and printed nothing. ${REFUSAL_FIX}`;
139
+
140
+ let said = lines[0];
141
+ for (const line of lines.slice(1)) {
142
+ if (said.length + line.length + 3 > REFUSAL_BUDGET) {
143
+ said += ' …';
144
+ break;
145
+ }
146
+ said += ` | ${line}`;
147
+ }
148
+ // The linter writes its own `Fix:` into the message; a second one beside it
149
+ // would be this module restating the tool (docblock at the top of this file).
150
+ // Only a message that named none gets the command.
151
+ return `the lint exited ${exited} without a verdict — ${said}${said.includes('Fix:') ? '' : `. ${REFUSAL_FIX}`}`;
152
+ }
153
+
110
154
  /**
111
155
  * The lint's answers, keyed by what the run was about. One service is linted
112
156
  * ONCE however many rows cite it: five rows spawning five processes over the
@@ -296,7 +340,7 @@ function claimantOf(ruleId, rows) {
296
340
  * Run the lint over one tree, once.
297
341
  *
298
342
  * @param {{ lintScript: string, docsDir: string, treeConfig: string }} params absolute paths
299
- * @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
343
+ * @returns {{findings: Array<object>, skipped: Array<object>}|{refusal: string}}
300
344
  */
301
345
  function lintOnce({ lintScript, docsDir, treeConfig }) {
302
346
  // `\0` as the escape, never the raw byte: written literally it makes the
@@ -313,7 +357,7 @@ function lintOnce({ lintScript, docsDir, treeConfig }) {
313
357
 
314
358
  /**
315
359
  * @param {{ lintScript: string, docsDir: string, treeConfig: string }} params
316
- * @returns {{findings: Array<object>, skipped: Array<object>}|{problem: string}}
360
+ * @returns {{findings: Array<object>, skipped: Array<object>}|{refusal: string}}
317
361
  */
318
362
  function runLint({ lintScript, docsDir, treeConfig }) {
319
363
  const run = spawnSync(process.execPath, [lintScript, ...lintArgumentsFor({ docsDir, treeConfig })], {
@@ -326,23 +370,20 @@ function runLint({ lintScript, docsDir, treeConfig }) {
326
370
  maxBuffer: LINT_MAX_BUFFER
327
371
  });
328
372
 
329
- if (run.error) return { problem: `${run.error.message} (${lintScript})` };
373
+ if (run.error) return { refusal: `the lint could not be started: ${run.error.message} (${lintScript}). ${REFUSAL_FIX}` };
330
374
 
331
375
  // 0 = clean, 1 = findings; anything else is the lint refusing to run, and its
332
376
  // reason is on stderr. A run that ended there is NOT an empty finding list.
333
- if (run.status !== 0 && run.status !== 1) {
334
- const said = String(run.stderr || run.stdout || '').trim().split('\n').filter(Boolean).pop();
335
- return { problem: `the lint exited ${run.status === null ? 'on a signal' : run.status}: ${said || 'no output'}` };
336
- }
377
+ if (run.status !== 0 && run.status !== 1) return { refusal: describeRefusal(run) };
337
378
 
338
379
  let parsed;
339
380
  try {
340
381
  parsed = JSON.parse(run.stdout);
341
382
  } catch (error) {
342
- return { problem: `its JSON output could not be read: ${error.message}` };
383
+ return { refusal: `the lint exited ${run.status} and its JSON output could not be read: ${error.message}. ${REFUSAL_FIX}` };
343
384
  }
344
385
  if (!Array.isArray(parsed.findings) || !Array.isArray(parsed.skipped)) {
345
- return { problem: 'its JSON output carries no "findings"/"skipped" arrays' };
386
+ return { refusal: `the lint exited ${run.status} and its JSON output carries no "findings"/"skipped" arrays. ${REFUSAL_FIX}` };
346
387
  }
347
388
  return { findings: parsed.findings, skipped: parsed.skipped };
348
389
  }
@@ -408,13 +449,23 @@ function treeConfigOf(block) {
408
449
  /**
409
450
  * Ask the lint about one service tree, once, for whichever row is asking.
410
451
  *
411
- * Three answers, and every caller words them in its own sentence: the tree has
412
- * nothing to lint, the question could not be decided (with the reason), or the
413
- * lint's own payload.
452
+ * Four answers, and every caller words them in its own sentence: the tree has
453
+ * nothing to lint, the question could not be decided because a file with a fix
454
+ * of its own is missing, the lint REFUSED to run at all, or the lint's own
455
+ * payload.
456
+ *
457
+ * The last two were one answer until d.632, and both became findings. They are
458
+ * different facts. An absent tree config is a defect of this repository with a
459
+ * row that owns it (`C-LINT`) and a repair somebody can carry out here, so it
460
+ * stays a finding. A lint that refused to run measured NOTHING: it decided no
461
+ * rule, so a row reporting a `deploy` finding about documentation is asserting
462
+ * something nobody looked at — six of them, in the run this split comes from.
463
+ * That is NOT RUN (`.claude/rules/automation-gates.md` §5).
414
464
  *
415
465
  * @param {{ block: object, serviceRoot: string, workspaceRoot: string }} params
416
466
  * @returns {{nothingToLint: true}
417
467
  * |{undecidable: {relative: string, reason: string}}
468
+ * |{refused: string}
418
469
  * |{answer: {findings: Array<object>, skipped: Array<object>}}}
419
470
  */
420
471
  function askLint({ block, serviceRoot, workspaceRoot }) {
@@ -442,7 +493,7 @@ function askLint({ block, serviceRoot, workspaceRoot }) {
442
493
  docsDir,
443
494
  treeConfig
444
495
  });
445
- if (answer.problem !== undefined) return { undecidable: { relative: DOCS_DIR, reason: answer.problem } };
496
+ if (answer.refusal !== undefined) return { refused: answer.refusal };
446
497
 
447
498
  return { answer };
448
499
  }
@@ -475,7 +526,7 @@ const docsLint = Object.freeze({
475
526
 
476
527
  /**
477
528
  * @param {{ row: object, block: object, serviceRoot: string, workspaceRoot: string }} params
478
- * @returns {Array<{where: string, what: string}>}
529
+ * @returns {Array<{where: string, what: string}>|{findings: Array<object>, notRun: string}}
479
530
  */
480
531
  run({ row, block, serviceRoot, workspaceRoot }) {
481
532
  const rows = rowsOf(block);
@@ -490,6 +541,9 @@ const docsLint = Object.freeze({
490
541
  what: `${row.rule} could not be decided — ${asked.undecidable.reason}`
491
542
  }];
492
543
  }
544
+ if (asked.refused !== undefined) {
545
+ return { findings: [], notRun: `${row.rule} could not be decided — ${asked.refused}` };
546
+ }
493
547
  const answer = asked.answer;
494
548
 
495
549
  const found = answer.findings
@@ -511,9 +565,10 @@ const docsLint = Object.freeze({
511
565
  // The mapping is the linter's own two channels onto this uniform's two: its
512
566
  // `findings` are findings, its `skipped` list is the NOT RUN one (that is
513
567
  // what `--allow-missing-siblings` fills, and what its own text output prints
514
- // as NOT RUN lines). A tree the linter REFUSES outright is neither — that
515
- // is `answer.problem` above, and the absent tree config before it, both of
516
- // which stay findings with a fix somebody can carry out.
568
+ // as NOT RUN lines). A tree whose lint REFUSED to run is NOT RUN too, for
569
+ // the same reason and one branch above (`askLint` § refused); the absent
570
+ // tree config before it stays a finding, because `C-LINT` owns that file
571
+ // and the repair is here.
517
572
  //
518
573
  // The sentence itself is `describeUndecided` above — shared with `D-LINT`,
519
574
  // which reports the same absence about the rules no row claims.
@@ -579,6 +634,9 @@ const docsLintClean = Object.freeze({
579
634
  what: `no finding could be decided — ${asked.undecidable.reason}`
580
635
  }];
581
636
  }
637
+ if (asked.refused !== undefined) {
638
+ return { findings: [], notRun: `no finding could be decided — ${asked.refused}` };
639
+ }
582
640
  const answer = asked.answer;
583
641
 
584
642
  const found = errorGraded(answer.findings)
@@ -605,6 +663,7 @@ module.exports = {
605
663
  claimantOf,
606
664
  citationsOf,
607
665
  covers,
666
+ describeRefusal,
608
667
  errorGraded,
609
668
  LINT_SCRIPT,
610
669
  LINT_DIR
@@ -29,7 +29,7 @@ const path = require('path');
29
29
 
30
30
  const { loadManifest, DEFAULT_MANIFEST_PATH, LIBRARY_MANIFEST_PATH } = require('../manifest/loadManifest');
31
31
  const {
32
- API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, resolveWorkspacePath
32
+ API_CHECKOUT_ROOT, PACKAGE_ROOT: OWN_ROOT, WORKSPACE_MARKER, resolveWorkspacePath
33
33
  } = require('../manifest/workspaceRoot');
34
34
  const { KINDS, renderUniformRegion } = require('./readmePointer');
35
35
 
@@ -74,6 +74,13 @@ function serviceManifestPath(serviceRoot) {
74
74
  return path.join(serviceRoot, 'node_modules', ...selfName().split('/'), SERVICE_MANIFEST_IN_PACKAGE);
75
75
  }
76
76
 
77
+ /**
78
+ * The conventional prefix a workspace-relative path uses for the api checkout,
79
+ * read off the marker that declares it rather than typed a second time here
80
+ * (`../manifest/workspaceRoot.js`, which owns both the prefix and the marker).
81
+ */
82
+ const [API_PREFIX] = WORKSPACE_MARKER.split('/');
83
+
77
84
  /**
78
85
  * Where this package lies inside its own workspace, workspace-relative — or null
79
86
  * when it lies in no checkout at all (an installed copy, a service container).
@@ -84,10 +91,25 @@ function serviceManifestPath(serviceRoot) {
84
91
  * at the manifest AS IT LIES IN THAT CHECKOUT, and where there is no copy there
85
92
  * is no link — which is a question the run could not answer, never a package
86
93
  * that passed (`.claude/rules/automation-gates.md` §5).
94
+ *
95
+ * The head of that path is the CONVENTION `api/`, never the name the checkout
96
+ * happens to wear on disk — the rule `workspaceRoot.js` states in its header and
97
+ * implements in `resolveWorkspacePath()`, which is the function this string is
98
+ * handed to. Deriving it as "the directory above the checkout, then the checkout"
99
+ * broke that rule wherever the checkout is named something else: GitLab CI checks
100
+ * this repository out as `infra-mono`, so the string became
101
+ * `infra-mono/shared/connector/conn-orch-validator`, which `resolveWorkspacePath`
102
+ * joins literally — pointing at nothing in every workspace, including the fixture
103
+ * and sandbox trees the suites build. Measured 2026-09-17 in the job's own image,
104
+ * one tree under two names: under `infra-mono` the run died with
105
+ * `Fix: run with --workspace pointing at a checkout that carries
106
+ * infra-mono/shared/connector/conn-orch-validator` — an instruction nobody can
107
+ * carry out (`.claude/rules/automation-gates.md` §1 requirement 4) — while the
108
+ * same tree named `api` was green.
87
109
  */
88
110
  const PACKAGE_IN_WORKSPACE = API_CHECKOUT_ROOT === null
89
111
  ? null
90
- : path.relative(path.dirname(API_CHECKOUT_ROOT), OWN_ROOT).split(path.sep).join('/');
112
+ : [API_PREFIX, ...path.relative(API_CHECKOUT_ROOT, OWN_ROOT).split(path.sep)].join('/');
91
113
 
92
114
  /**
93
115
  * The manifest as it lies in the workspace being read, whichever of the two it
@@ -33,6 +33,24 @@
33
33
  * what keeps the grant inside one service, and it is the difference
34
34
  * `tests/unit/dbAccountGrants.test.js` measures.
35
35
  *
36
+ * ## The escape belongs to BOTH grants, not only the wildcard one
37
+ *
38
+ * The database-name position of a `GRANT` is read as a LIKE pattern whether or
39
+ * not a `%` follows it, so `` ON `oagen_emailer`.* `` — the grant EVERY
40
+ * installed service gets — also grants `oagen5emailer` and every other schema
41
+ * differing in that one character. One escape function therefore runs on the
42
+ * schema name before either statement is built; the production runbook
43
+ * (`api/docs/setup/INSTALL.md` § the account and its grants, `schema_grant`)
44
+ * already escaped it, and a definition laxer than the runbook installing from it
45
+ * is the wrong way round. Measured against the dev server in
46
+ * `tests/unit/bizCiGateCli.setupDbAccount.integration.test.js`: with the
47
+ * unescaped grant the account reads the neighbour's rows; with it, `ERROR 1142`.
48
+ *
49
+ * `%` — the other wildcard of that position — is REFUSED rather than escaped,
50
+ * for the reason the comment above `FORBIDDEN_IN_VALUE` gives: no platform
51
+ * schema name carries one, so a value that does came from somewhere it should
52
+ * not have. `_` cannot be refused the same way: every platform name has one.
53
+ *
36
54
  * Production never gets that grant: it installs one schema, and a wildcard there
37
55
  * would be a standing privilege nobody asked for.
38
56
  *
@@ -84,6 +102,43 @@ function requireValue(value, name, fix) {
84
102
  return value;
85
103
  }
86
104
 
105
+ /**
106
+ * The one escape of this file, used by every grant it builds.
107
+ *
108
+ * MariaDB reads the database-name position of a `GRANT` as a LIKE pattern, so
109
+ * `_` there matches any single character. Every platform schema is
110
+ * `oagen_<shortname>`, which means every unescaped grant this package could
111
+ * build would reach a schema nobody named.
112
+ *
113
+ * `%` is not escaped here — `requireGrantableSchema` refuses it before this
114
+ * runs, so the only wildcard left to neutralise is `_`.
115
+ *
116
+ * @param {string} schema a schema name already checked by `requireGrantableSchema`
117
+ * @returns {string} the same name as a LIKE pattern matching only itself
118
+ */
119
+ function escapeSchemaPattern(schema) {
120
+ return schema.replace(/_/g, '\\_');
121
+ }
122
+
123
+ /**
124
+ * A schema name this file can turn into a grant that reaches that schema ALONE.
125
+ *
126
+ * `requireValue` already refuses the backslash (an escape nobody wrote here) and
127
+ * the quoting characters. This adds the one character that is neither a quoting
128
+ * problem nor escapable without changing what the name means: `%`.
129
+ */
130
+ function requireGrantableSchema(schema, fix) {
131
+ requireValue(schema, 'schema', fix);
132
+ if (schema.includes('%')) {
133
+ throw new Error('[DbAccountGrants] schema carries `%`, which the database-name position of a GRANT '
134
+ + `reads as a wildcard: ${JSON.stringify(schema)}.\n`
135
+ + ' A grant built from it would reach schemas nobody named, and escaping it would grant a '
136
+ + 'schema whose name really contains `%` — neither is what a platform schema means.\n'
137
+ + ` Fix: ${fix}`);
138
+ }
139
+ return schema;
140
+ }
141
+
87
142
  /**
88
143
  * Every statement that creates this service's account and grants it what it
89
144
  * needs, in the order they must run.
@@ -95,26 +150,30 @@ function requireValue(value, name, fix) {
95
150
  * (`DB_USER` of `config/env-templates/<service>.env`)
96
151
  * @param {string} options.password the password the account is created with
97
152
  * @param {boolean} [options.ciThrowaway] also grant the throwaway namespace
98
- * `<schema>\_%`, which only an integration tier builds into
153
+ * `<escaped schema>\_%`, which only an integration tier builds into
99
154
  * @returns {string[]} the statements, in order
100
155
  */
101
156
  function databaseAccountSql({ schema, account, password, ciThrowaway = false } = {}) {
102
- requireValue(schema, 'schema', 'pass database.schema from config/service/integration-contract.json.');
157
+ requireGrantableSchema(schema, 'pass database.schema from config/service/integration-contract.json.');
103
158
  requireValue(account, 'account', 'pass DB_USER from this service\'s config/env-templates/<service>.env.');
104
159
  requireValue(password, 'password', 'pass the password the account is created with; an account created '
105
160
  + 'with an empty password is one anybody on the network can use.');
106
161
 
107
162
  const identity = `'${account}'@'${ACCOUNT_HOST}'`;
163
+ // Both grants below are built from this one value: the database-name position
164
+ // is a LIKE pattern in either of them (see the head of this file).
165
+ const pattern = escapeSchemaPattern(schema);
108
166
 
109
167
  const statements = [
110
168
  `CREATE USER IF NOT EXISTS ${identity} IDENTIFIED BY '${password}';`,
111
- `GRANT ALL PRIVILEGES ON \`${schema}\`.* TO ${identity};`
169
+ `GRANT ALL PRIVILEGES ON \`${pattern}\`.* TO ${identity};`
112
170
  ];
113
171
 
114
172
  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};`);
173
+ // `\_%` is the throwaway SUFFIX — the one wildcard meant to be one — added
174
+ // to the escaped name; see the head of this file for the neighbour an
175
+ // unescaped name would otherwise reach.
176
+ statements.push(`GRANT ALL PRIVILEGES ON \`${pattern}\\_%\`.* TO ${identity};`);
118
177
  }
119
178
 
120
179
  // Last, always: a grant is not live for a connection opened before it.
@@ -1,8 +1,14 @@
1
1
  #!/bin/sh
2
2
  set -e
3
3
 
4
+ # `npm ci`, not `npm install`: the tree is built from package-lock.json alone,
5
+ # never reconciled against npm's hidden node_modules/.package-lock.json, which
6
+ # a `--package-lock-only` re-pin rewrites while the package directories keep
7
+ # the old versions. A lock that disagrees with package.json stops the boot here
8
+ # instead of installing something nobody declared.
9
+ # @see api/tests/scripts/infra-init-scripts.bats
4
10
  oa_npm_install() {
5
- npm install --no-audit --no-fund
11
+ npm ci --no-audit --no-fund
6
12
  }
7
13
 
8
14
  # --- oa-deps-guard v1 (identical in every OA Drive init.sh) ---
@@ -42,7 +48,7 @@ if [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ] || [ "$NEEDS_INSTALL" = "1" ]; then
42
48
  echo "$CURRENT_HASH" > "$MARKER_FILE"
43
49
  echo "[init.sh] Dependencies installed, marker $MARKER_FILE updated."
44
50
  else
45
- echo "[init.sh] Dependency install failed - marker $MARKER_FILE left untouched, so the next start retries instead of trusting a half-installed tree. Fix: read the npm error above, then run 'npm install' in this service directory."
51
+ echo "[init.sh] Dependency install failed - marker $MARKER_FILE left untouched, so the next start retries instead of trusting a half-installed tree. Fix: read the npm error above; a service that has a package-lock.json installs with 'npm ci --no-audit --no-fund' in this directory - 'npm install' is what let the folders and the lock drift apart; a service that has none yet needs 'npm install' once, to create it."
46
52
  exit 1
47
53
  fi
48
54
  else