@onlineapps/conn-orch-validator 10.0.0 → 12.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,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
+ };
@@ -0,0 +1,278 @@
1
+ 'use strict';
2
+
3
+ const { resolveReferencesWith, resolveReferencePath } = require('@onlineapps/cookbook-core');
4
+
5
+ /**
6
+ * The run context a Tier-1 cookbook resolves its `{{…}}` references against.
7
+ *
8
+ * WHY IT EXISTS. Until 2026-09-17 the runner executed every step independently:
9
+ * `stepResults` only counted, and nothing a step returned could reach the next
10
+ * one. A recipe therefore could not build the data it needs, so biz-converter's
11
+ * boot recipe depended on a row only a TEST_ONLY seed supplies — and on
12
+ * production Tier-1 answered `NOT_CONVERTIBLE`, the boot exited 1 after ~18
13
+ * minutes of backoff and the container restarted in a loop (measured
14
+ * 2026-09-16). The owner's decision was that the recipe stays as written and the
15
+ * RUNNER learns what the orchestrator already does
16
+ * (`api/docs/governance/confirmations/converter-boot-cookbook.md` 001).
17
+ *
18
+ * ONE RAIL, AND IT IS NOT THIS PACKAGE'S. The syntax, the type rule for a whole
19
+ * expression, the ban on addressing a step by position and the fate of an
20
+ * unresolved reference all belong to the cookbook format —
21
+ * `api/docs/biz/40-cookbooks/variable-references.md`. The traversal of the value
22
+ * and the walk of one path belong to `@onlineapps/cookbook-core`:
23
+ * `resolveReferencesWith()` and `resolveReferencePath()`, the same two functions
24
+ * `WorkflowOrchestrator._resolveInputReferencesAsync` /
25
+ * `_resolveTemplateExpression` call. This module writes no parser; it supplies
26
+ * the SCOPE, which is the only part Tier-1 owns.
27
+ *
28
+ * THE SCOPE IS THE ORCHESTRATOR'S, SPELLED THE SAME WAY. A completed entry is
29
+ * "the step definition unchanged, plus two added fields: `output` and
30
+ * `_execution`" — the section "Runtime context (`context.steps`)" of
31
+ * api/docs/biz/40-cookbooks/format.md — and a reference addresses it by
32
+ * `step_id`, which is what
33
+ * `WorkflowOrchestrator.referenceScope()` puts under the root `steps` (its
34
+ * `steps_by_id` map). A second spelling here, such as a bare `status` beside
35
+ * `output`, would resolve at boot and stay literal in production: precisely the
36
+ * divergence class that broke the converter's boot.
37
+ *
38
+ * WHAT TIER-1 DELIBERATELY DOES NOT CARRY. The orchestrator's context also holds
39
+ * `api_input`, `delivery` and the tenancy keys. A Tier-1 run has no workflow
40
+ * input and no delivery at all, so those roots are absent and a reference to
41
+ * them stays literal — the norm's documented outcome for an unresolvable
42
+ * reference, not a silent substitution.
43
+ *
44
+ * @see api/docs/biz/40-cookbooks/variable-references.md
45
+ * @see api/docs/biz/40-cookbooks/format.md
46
+ * @module utils/stepReferences
47
+ */
48
+
49
+ /**
50
+ * The context one cookbook run starts from: no step has produced anything yet.
51
+ *
52
+ * It is per COOKBOOK, never per runner — `runCookbooks()` executes many
53
+ * cookbooks through one runner and a step of one must not see a step of
54
+ * another.
55
+ *
56
+ * @returns {{steps: Object}} the scope `resolveStepInput` resolves against
57
+ */
58
+ function createRunContext() {
59
+ return { steps: {} };
60
+ }
61
+
62
+ /**
63
+ * Resolve the `{{…}}` references in ONE step's input.
64
+ *
65
+ * `input` is the only field expanded. The format names three expansion sites —
66
+ * `step.input`, a `foreach` iterator and a `switch` expression
67
+ * (`variable-references.md` § Limitations) — and a Tier-1 cookbook has only the
68
+ * first: `CookbookTestRunner.validateCookbook` requires `service` and
69
+ * `operation` of every step, so a `foreach` or `switch` step never reaches this
70
+ * runner. `expect` is not on that list and is therefore taken literally, the
71
+ * same as `service`, `operation` and `depends_on`.
72
+ *
73
+ * @param {Object} step - the cookbook step as written
74
+ * @param {{steps: Object}} runContext - what earlier steps produced
75
+ * @returns {Promise<*>} the input the handler is called with
76
+ */
77
+ function resolveStepInput(step, runContext) {
78
+ return resolveReferencesWith(
79
+ step.input,
80
+ (expression) => resolveReferencePath(expression, runContext)
81
+ );
82
+ }
83
+
84
+ /**
85
+ * WHAT A TIER-1 RECIPE MAY WRITE IN `input`, checked before the run starts.
86
+ *
87
+ * Tier-1 is the service's own startup proof, so a recipe it passes must be a
88
+ * recipe production would accept. Two shapes broke that symmetry, both by going
89
+ * QUIET where production is loud:
90
+ *
91
+ * 1. a helper call — `{{webalizeString(…)}}`, `{{string2file(…)}}`. The
92
+ * orchestrator resolves it through its helper registry and throws on a
93
+ * name the registry does not carry
94
+ * (`WorkflowOrchestrator._executeTemplateHelper`). This runner has no
95
+ * registry — deliberately: a helper reaches its own runtime (ContentResolver,
96
+ * files, storage), which is not what a boot probe may do. So the expression
97
+ * resolved to nothing and survived as literal text, and the step ran with
98
+ * `{{webalizeString(…)}}` where a value belonged.
99
+ * 2. a `{{steps.<id>}}` naming a step the recipe does not define. Production
100
+ * refuses such a cookbook at submission with 400 `Invalid cookbook
101
+ * references` (`infra/api_gateway/shared/cookbookReferences.js`,
102
+ * `api/docs/governance/confirmations/cookbook-validation-placement.md` 001
103
+ * — the receiver's own throw is the safety net for the paths that bypass
104
+ * the gateway, and a Tier-1 boot run is exactly such a path). Here it was a
105
+ * literal too.
106
+ *
107
+ * The literal outcome is right for a reference whose VALUE is missing at that
108
+ * moment — a step that has not run, or one that failed
109
+ * (`variable-references.md` § Unresolved references), and those two stay literal
110
+ * below. It is wrong for a name that does not exist in the recipe at all: no
111
+ * run can ever make it resolve.
112
+ *
113
+ * NO SECOND WALK. The traversal and the expression extraction are the same
114
+ * `resolveReferencesWith()` that resolves the input for real one moment later,
115
+ * so this check sees exactly the expressions the resolver will see. The
116
+ * evaluator returns `undefined` for everything: nothing is substituted here,
117
+ * only read.
118
+ *
119
+ * WHERE THE HELPER-CALL SHAPE OUGHT TO LIVE. `@onlineapps/cookbook-core` owns
120
+ * the format and exports the traversal and the path walk, but no definition of
121
+ * "this expression is a helper call" — the only one on the platform is the
122
+ * private regex in `WorkflowOrchestrator._resolveTemplateExpression`, and this
123
+ * module now holds a second copy of it. That is one concern on two rails
124
+ * (`.claude/rules/change-discipline.md` § One rail per concern) and it belongs
125
+ * beside `resolveReferencePath()` in cookbook-core, where both callers could
126
+ * read it. Recorded for the owner rather than fixed here: cookbook-core is
127
+ * another package's manifest.
128
+ *
129
+ * SCOPE. `input` only, which is the single expansion site a Tier-1 cookbook has
130
+ * (`resolveStepInput` above says why), plus `depends_on`, whose values are step
131
+ * names by definition.
132
+ *
133
+ * @see api/docs/biz/40-cookbooks/variable-references.md
134
+ * @see api/docs/governance/confirmations/cookbook-validation-placement.md
135
+ */
136
+
137
+ /** `helperName(...)` — the shape `WorkflowOrchestrator` routes to its registry. */
138
+ const HELPER_CALL = /^([a-zA-Z_][a-zA-Z0-9_]*)\(([\s\S]*)\)$/;
139
+
140
+ /** Root of the step namespace; the only root whose names a recipe can be checked against. */
141
+ const STEPS_ROOT = 'steps';
142
+
143
+ function helperCallProblem(stepId, helperName, expression) {
144
+ return `Step ${stepId} input calls the template helper "${helperName}" - `
145
+ + `Tier-1 runs no helper registry, so "{{${expression}}}" would reach the handler as literal `
146
+ + 'text while the production orchestrator evaluates it (and throws when the helper name is '
147
+ + 'unknown). '
148
+ + 'Fix: remove the helper call from the step input; a Tier-1 recipe builds its values from '
149
+ + 'earlier steps (api/docs/biz/40-cookbooks/variable-references.md).';
150
+ }
151
+
152
+ function undefinedStepProblem(stepId, referencedId, expression, knownIds) {
153
+ return `Step ${stepId} references an undefined step - "{{${expression}}}" names "${referencedId}", `
154
+ + 'which is not a step_id of this cookbook. '
155
+ + `Expected one of: ${knownIds.join(', ')}. `
156
+ + 'Fix: correct the step_id in the reference, or add the missing step '
157
+ + '(api/docs/biz/40-cookbooks/variable-references.md § Unresolved references).';
158
+ }
159
+
160
+ function undefinedDependencyProblem(stepId, dependencyId, knownIds) {
161
+ return `Step ${stepId} depends on an undefined step - "depends_on" names "${dependencyId}", `
162
+ + 'which is not a step_id of this cookbook. '
163
+ + `Expected one of: ${knownIds.join(', ')}. `
164
+ + 'Fix: correct the step_id in "depends_on", or add the missing step '
165
+ + '(api/docs/biz/40-cookbooks/format.md § Step definition).';
166
+ }
167
+
168
+ /**
169
+ * What ONE expression is, measured against the names this cookbook defines.
170
+ *
171
+ * A path whose root is not `steps` — `api_input.…`, `context.…`, `current.…` —
172
+ * is left alone: its value comes into being at run time and a recipe says
173
+ * nothing about whether it will. The BRACKET form `steps[0]` is not a step
174
+ * reference either, in this runner or at the gateway; the dotted `steps.0` is,
175
+ * and it names a step no recipe can define.
176
+ *
177
+ * @returns {string|null} the problem, or `null` when the expression is fine
178
+ */
179
+ function checkExpression(expression, stepId, known, knownIds) {
180
+ const helperCall = expression.match(HELPER_CALL);
181
+ if (helperCall) {
182
+ return helperCallProblem(stepId, helperCall[1], expression);
183
+ }
184
+
185
+ const parts = expression.split('.');
186
+ if (parts[0] !== STEPS_ROOT || parts.length < 2) return null;
187
+
188
+ const referencedId = parts[1];
189
+ if (known.has(referencedId)) return null;
190
+
191
+ return undefinedStepProblem(stepId, referencedId, expression, knownIds);
192
+ }
193
+
194
+ /**
195
+ * The problem this cookbook's step inputs carry, or `null` when they carry none.
196
+ *
197
+ * Returns the FIRST problem rather than throwing, the way
198
+ * `utils/cookbookFormat.checkCookbookFormatVersion` does: the caller owns the
199
+ * `[Context]` of the message it throws, and the caller here is the runner
200
+ * (`.claude/rules/architecture-principles.md` §5).
201
+ *
202
+ * @param {Array<Object>} steps - the cookbook's steps, already proved to be the array shape
203
+ * @returns {Promise<string|null>} the problem text without its context prefix
204
+ */
205
+ async function checkStepInputExpressions(steps) {
206
+ const knownIds = steps
207
+ .map((step) => step && step.step_id)
208
+ .filter((stepId) => typeof stepId === 'string' && stepId.length > 0);
209
+ const known = new Set(knownIds);
210
+
211
+ for (const step of steps) {
212
+ const dependencies = Array.isArray(step.depends_on) ? step.depends_on : [];
213
+ for (const dependency of dependencies) {
214
+ if (typeof dependency !== 'string' || known.has(dependency)) continue;
215
+ return undefinedDependencyProblem(step.step_id, dependency, knownIds);
216
+ }
217
+
218
+ let problem = null;
219
+ await resolveReferencesWith(step.input, (expression) => {
220
+ if (problem === null) {
221
+ problem = checkExpression(expression, step.step_id, known, knownIds);
222
+ }
223
+ return undefined;
224
+ });
225
+ if (problem !== null) return problem;
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Write one finished step into the run context, under its own name.
233
+ *
234
+ * `output` appears ONLY on a step that passed — "an entry carries `output` and
235
+ * `_execution` once it has completed, not before"
236
+ * (the same "Runtime context (`context.steps`)" section). A step that failed is
237
+ * recorded with its status and no output, so a reference into it resolves to
238
+ * nothing and survives as literal text: the cookbook author sees
239
+ * `{{steps.x.output.id}}` sitting in the failing step's input, which names the
240
+ * real cause.
241
+ *
242
+ * The `step_id` is REQUIRED here rather than assumed. `validateCookbook`
243
+ * already refuses a cookbook whose step has none, so in a run this cannot
244
+ * happen; the check is at the entry of this function because the alternative is
245
+ * a silent `steps[undefined]` — a nameless step that a later reference could
246
+ * never address on purpose (architecture-principles.md §4).
247
+ *
248
+ * @param {{steps: Object}} runContext - the context to write into
249
+ * @param {Object} step - the cookbook step as written
250
+ * @param {{passed: boolean, actual: *}} result - the step's result record
251
+ * @returns {void}
252
+ */
253
+ function recordStepOutcome(runContext, step, result) {
254
+ if (typeof step.step_id !== 'string' || step.step_id.length === 0) {
255
+ throw new Error('[stepReferences] A step with no step_id cannot enter the run context - Expected a '
256
+ + 'non-empty "step_id"; a step is addressed by its name and never by its position '
257
+ + '(api/docs/biz/40-cookbooks/variable-references.md § Rules). '
258
+ + 'Fix: add "step_id" to the step.');
259
+ }
260
+
261
+ const entry = {
262
+ ...step,
263
+ _execution: { status: result.passed ? 'completed' : 'failed' }
264
+ };
265
+
266
+ if (result.passed) {
267
+ entry.output = result.actual;
268
+ }
269
+
270
+ runContext.steps[step.step_id] = entry;
271
+ }
272
+
273
+ module.exports = {
274
+ createRunContext,
275
+ resolveStepInput,
276
+ recordStepOutcome,
277
+ checkStepInputExpressions
278
+ };
@@ -202,6 +202,16 @@ function assertAllowedTenant(tenantId, options) {
202
202
  * fallback there would hide exactly the misconfiguration the boundary exists to
203
203
  * surface (architecture-principles.md §3).
204
204
  *
205
+ * What it DOES hold is the shape of a workspace id: a POSITIVE integer. The
206
+ * recorded decision names it (api/docs/governance/confirmations/tier1-runner-workspace.md,
207
+ * entry 002) and `.claude/rules/workspace-architecture.md` says `workspace_id` 0
208
+ * is not a value; the guard checked `Number.isInteger` only, so `0` and `-5` came
209
+ * back normalized and a cookbook could send the boot probe into a workspace that
210
+ * cannot exist. The check lives here rather than in `normalizeNamespaceId()`
211
+ * because the tenant rail already refuses both — by the class allowlist, for a
212
+ * different reason and with a different fix — and one message per boundary is
213
+ * what keeps the refusal actionable.
214
+ *
205
215
  * There is deliberately NO class allowlist here, and that is not an omission:
206
216
  *
207
217
  * - `api/docs/standards/tenant-allocation.md` allocates environment classes to
@@ -222,7 +232,7 @@ function assertAllowedTenant(tenantId, options) {
222
232
  * `purpose` is the name the caller knows the id by (here, the cookbook key
223
233
  * that carries it), `setBy` where that name is given a value.
224
234
  * @returns {number} the normalized id.
225
- * @throws {Error} when the id is absent or not an integer.
235
+ * @throws {Error} when the id is absent, not an integer, or not positive.
226
236
  */
227
237
  function assertWorkspaceId(workspaceId, options) {
228
238
  if (options === null || typeof options !== 'object') {
@@ -232,7 +242,14 @@ function assertWorkspaceId(workspaceId, options) {
232
242
  requireOriginText(purpose, 'purpose');
233
243
  requireOriginText(setBy, 'setBy');
234
244
 
235
- return normalizeNamespaceId(workspaceId, { purpose, setBy });
245
+ const value = normalizeNamespaceId(workspaceId, { purpose, setBy });
246
+ if (value <= 0) {
247
+ throw new Error(`[TestNamespace] Invalid ${purpose}=${value} - Expected a positive integer `
248
+ + 'workspace id (1 or greater); 0 identifies no workspace. '
249
+ + `Fix: set ${purpose} to the workspace the run owns - ${setBy} - `
250
+ + 'see api/docs/standards/tenant-allocation.md § The allocation.');
251
+ }
252
+ return value;
236
253
  }
237
254
 
238
255
  /**
@@ -275,9 +292,19 @@ function getTestNamespace() {
275
292
  setBy: ENV_SET_BY
276
293
  });
277
294
 
295
+ // The workspace takes the same shape check a cookbook's declared value takes,
296
+ // under the same function and the same sentence. It did not, and that made the
297
+ // boundary half a boundary: the positivity rule held for a value a cookbook
298
+ // declares and not for the variable every integration test and every boot
299
+ // probe actually runs under (measured: TESTING_WORKSPACE_ID=0 returned
300
+ // workspace_id 0). The tenant above shows the shape — the env is one caller of
301
+ // a boundary, never its owner.
278
302
  return {
279
303
  tenant_id: tenantId,
280
- workspace_id: readNamespaceId('TESTING_WORKSPACE_ID')
304
+ workspace_id: assertWorkspaceId(readNamespaceId('TESTING_WORKSPACE_ID'), {
305
+ purpose: 'TESTING_WORKSPACE_ID',
306
+ setBy: ENV_SET_BY
307
+ })
281
308
  };
282
309
  }
283
310
 
@@ -906,8 +906,14 @@ class ServiceStructureValidator {
906
906
  this.errors.push({
907
907
  type: 'MISSING_COOKBOOKS',
908
908
  path: 'tests/cookbooks',
909
+ // The problem only. The remedy is `fix`, and the printer composes the
910
+ // two — `formatResult` below writes a `Fix:` line of its own, the
911
+ // service wrapper composes `message (Fix: fix)` inline. A message that
912
+ // carries `Fix:` as well prints the remedy twice, which reads as two
913
+ // instructions; this was the only one of the validator's findings that
914
+ // did it (d.599).
909
915
  message: '[ServiceStructure] tests/cookbooks is missing or empty - Tier-1 boot '
910
- + 'validates operations by these recipes. Fix: add tests/cookbooks/<op>.json',
916
+ + 'validates operations by these recipes.',
911
917
  fix: 'add tests/cookbooks/<op>.json'
912
918
  });
913
919
  return;