mandrel-platform 1.8.1 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -121,6 +121,8 @@ export const SHAPE_NAMES = Object.freeze(Object.keys(SHAPE_VOCABULARY));
121
121
  */
122
122
  export const MANIFEST_SCHEMA = Object.freeze({
123
123
  environments: "string[] — environment slugs, e.g. ['staging','production']",
124
+ environmentSlugs:
125
+ "object? — surface name -> {environment: slug}. Only 'infisical' is honored; an unmapped environment resolves to itself.",
124
126
  workers:
125
127
  "object? — worker id -> {config, scriptName}. `scriptName` may contain '{env}', substituted per environment.",
126
128
  keys: "object[] — one entry per env var / secret (see KEY_SCHEMA)",
@@ -131,13 +133,98 @@ export const KEY_SCHEMA = Object.freeze({
131
133
  kind: "'var' | 'secret'",
132
134
  sensitivity: "'public' | 'secret'",
133
135
  residency:
134
- "object — {local: 'var'|'secret'|'file'|null, github: {scope,kind}|null, cloudflare: {workers,kind}|null}",
135
- infisical: "{folder, environments} | 'unmanaged'",
136
+ "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
137
+ infisical:
138
+ "{folder, environments} | {folders: (string | {folder, environments})[]} | 'unmanaged'",
136
139
  shape: `string? — one of ${SHAPE_NAMES.join(", ")}`,
137
140
  placeholderPattern: "string? — literal, case-insensitive substring marking an unset placeholder value",
138
141
  note: "string? — free text",
139
142
  });
140
143
 
144
+ /**
145
+ * The surfaces whose environment namespace can be remapped.
146
+ *
147
+ * `manifest.environments` names DEPLOY environments, and until Story #464 that
148
+ * one list was substituted verbatim into three namespaces that are not the
149
+ * same namespace — the Worker script name, the GitHub Environment API path,
150
+ * and the Infisical environment slug. A project whose Infisical slugs differ
151
+ * from its deploy names (`prod` against a `production` environment) could not
152
+ * be probed at all: the surface went to `error`, which is deliberately
153
+ * unsuppressable, so the lane was permanently red with no downstream fix.
154
+ *
155
+ * Only `infisical` is remappable, and deliberately so. Cloudflare already has
156
+ * its own escape hatch — `resolveScriptName` substitutes `{env}` into
157
+ * `workers[].scriptName`, so `acme-site-{env}` resolves whatever the deploy
158
+ * name is — and giving it a slug map too would be two mechanisms for one job.
159
+ * The GitHub Environment API is read at `manifest.environments` verbatim and
160
+ * no divergence has been observed there. The CONTAINER is nonetheless keyed by
161
+ * surface rather than being an Infisical-only field, so a surface that ever
162
+ * does diverge adopts it without inventing a second idiom.
163
+ */
164
+ export const SLUG_MAPPED_SURFACES = Object.freeze(["infisical"]);
165
+
166
+ /**
167
+ * Normalize `manifest.environmentSlugs` to a total map over the slug-mapped
168
+ * surfaces, so a caller never has to distinguish "absent" from "empty".
169
+ *
170
+ * Validation fails closed, matching this module's posture on an unknown
171
+ * `shape`: silently ignoring a misspelled surface or environment is how a
172
+ * manifest author comes to believe they have remapped something they have not,
173
+ * and the symptom — a 404 from the store — looks nothing like the cause.
174
+ *
175
+ * @param {unknown} raw
176
+ * @param {string[]} environments
177
+ * @returns {Record<string, Record<string, string>>}
178
+ */
179
+ function normalizeEnvironmentSlugs(raw, environments) {
180
+ const out = {};
181
+ for (const surface of SLUG_MAPPED_SURFACES) out[surface] = {};
182
+ if (raw === undefined || raw === null) return out;
183
+ if (typeof raw !== "object" || Array.isArray(raw)) {
184
+ throw new Error("manifest.environmentSlugs must be an object mapping a surface name -> {environment: slug}");
185
+ }
186
+
187
+ for (const [surface, map] of Object.entries(raw)) {
188
+ if (!SLUG_MAPPED_SURFACES.includes(surface)) {
189
+ throw new Error(
190
+ `manifest.environmentSlugs."${surface}" is not a slug-mapped surface — supported: ` +
191
+ `${SLUG_MAPPED_SURFACES.join(", ")}. Cloudflare resolves each environment through the ` +
192
+ `"{env}" substitution in workers[].scriptName, and the GitHub Environment API is read at ` +
193
+ `manifest.environments verbatim, so neither takes a slug map.`
194
+ );
195
+ }
196
+ if (!map || typeof map !== "object" || Array.isArray(map)) {
197
+ throw new Error(`manifest.environmentSlugs.${surface} must be an object mapping environment -> slug`);
198
+ }
199
+ for (const [environment, slug] of Object.entries(map)) {
200
+ if (!environments.includes(environment)) {
201
+ throw new Error(
202
+ `manifest.environmentSlugs.${surface} maps "${environment}", absent from manifest.environments`
203
+ );
204
+ }
205
+ if (typeof slug !== "string" || !slug) {
206
+ throw new Error(`manifest.environmentSlugs.${surface}["${environment}"] must be a non-empty slug string`);
207
+ }
208
+ out[surface][environment] = slug;
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Resolve the slug one surface uses for a manifest environment. Identity for
216
+ * any environment the manifest does not remap, which is what keeps an absent
217
+ * `environmentSlugs` a no-op for every manifest written before Story #464.
218
+ *
219
+ * @param {object} manifest A parsed manifest.
220
+ * @param {string} surface
221
+ * @param {string} environment
222
+ * @returns {string}
223
+ */
224
+ export function resolveSurfaceEnvironment(manifest, surface, environment) {
225
+ return manifest?.environmentSlugs?.[surface]?.[environment] ?? environment;
226
+ }
227
+
141
228
  /**
142
229
  * Parse and validate a raw manifest object. Throws with an actionable message
143
230
  * on any violation — a manifest is authored by hand, so a vague error costs a
@@ -175,10 +262,198 @@ export function parseManifest(raw) {
175
262
  throw new Error("manifest.keys must be a non-empty array");
176
263
  }
177
264
 
265
+ const environmentSlugs = normalizeEnvironmentSlugs(raw.environmentSlugs, environments);
266
+
178
267
  const seen = new Set();
179
268
  const keys = raw.keys.map((entry, i) => validateKeyEntry(entry, i, workers, environments, seen));
180
269
 
181
- return { environments: [...environments], workers, keys };
270
+ return { environments: [...environments], environmentSlugs, workers, keys };
271
+ }
272
+
273
+ /**
274
+ * Normalize `residency.github` to its canonical array form.
275
+ *
276
+ * The field accepts EITHER a single `{scope, kind}` object — the shape every
277
+ * manifest written before Story #459 uses — or an array of them, because two
278
+ * residencies that occur in practice cannot be said with one object:
279
+ *
280
+ * 1. **Dual scope.** A key can legitimately live at repository level AND at
281
+ * environment level, for different consumers: a deploy job declares
282
+ * `environment:` and reads the per-environment value while a CI job
283
+ * declares none and reads a repo-level credential of the same name.
284
+ * Under a single object, whichever scope the manifest declared, the other
285
+ * reported as an orphan — permanently, on a healthy repo.
286
+ * 2. **Per-environment presence.** A key can be deliberately present in one
287
+ * environment and absent from another (a production-only analytics token
288
+ * whose staging counterpart is meant to resolve empty). With no
289
+ * per-entry `environments`, the absence read as a `missing` failure.
290
+ *
291
+ * Both authored shapes normalize to one array of `{scope, kind, environments}`
292
+ * with `environments` defaulted and materialized to `manifest.environments`,
293
+ * so `probeGitHub` has exactly one shape to read — the same normalize-at-parse
294
+ * treatment `infisical.environments` already receives.
295
+ *
296
+ * Validation fails closed, matching this module's posture on an unknown
297
+ * `shape`: silently ignoring a misplaced or misspelled `environments` is how a
298
+ * manifest author comes to believe they have scoped something they have not.
299
+ *
300
+ * @param {unknown} raw
301
+ * @param {{at: string, name: string, environments: string[]}} ctx
302
+ * @returns {Array<{scope: string, kind: string, environments: string[]}> | null}
303
+ */
304
+ function normalizeGitHubResidency(raw, { at, name, environments }) {
305
+ if (raw === undefined || raw === null) return null;
306
+
307
+ const authoredAsArray = Array.isArray(raw);
308
+ const entries = authoredAsArray ? raw : [raw];
309
+ if (entries.length === 0) {
310
+ throw new Error(
311
+ `${at}.residency.github must not be an empty array — use null when the key does not belong in GitHub (key ${name})`
312
+ );
313
+ }
314
+
315
+ const seenPairs = new Set();
316
+ return entries.map((entry, j) => {
317
+ const where = authoredAsArray ? `${at}.residency.github[${j}]` : `${at}.residency.github`;
318
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
319
+ throw new Error(`${where} must be an object with {scope, kind} (key ${name})`);
320
+ }
321
+ if (entry.scope !== "environment" && entry.scope !== "repository") {
322
+ throw new Error(`${where}.scope must be "environment" or "repository" (key ${name})`);
323
+ }
324
+ if (entry.kind !== "secret" && entry.kind !== "var") {
325
+ throw new Error(`${where}.kind must be "secret" or "var" (key ${name})`);
326
+ }
327
+
328
+ const pair = `${entry.scope}/${entry.kind}`;
329
+ if (seenPairs.has(pair)) {
330
+ throw new Error(
331
+ `${where} repeats the (scope, kind) pair "${pair}" — declare one entry per pair (key ${name})`
332
+ );
333
+ }
334
+ seenPairs.add(pair);
335
+
336
+ if (entry.environments !== undefined) {
337
+ if (entry.scope === "repository") {
338
+ throw new Error(
339
+ `${where}.environments is meaningful only under scope "environment" — a repository-scope ` +
340
+ `secret or variable belongs to no environment (key ${name})`
341
+ );
342
+ }
343
+ if (!Array.isArray(entry.environments) || !entry.environments.every((e) => typeof e === "string")) {
344
+ throw new Error(`${where}.environments must be an array of environment slugs (key ${name})`);
345
+ }
346
+ for (const e of entry.environments) {
347
+ if (!environments.includes(e)) {
348
+ throw new Error(
349
+ `${where}.environments names "${e}", absent from manifest.environments (key ${name})`
350
+ );
351
+ }
352
+ }
353
+ }
354
+
355
+ return {
356
+ scope: entry.scope,
357
+ kind: entry.kind,
358
+ environments: entry.environments ? [...entry.environments] : [...environments],
359
+ };
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Normalize `infisical` residency to its canonical `{folders: [...]}` form.
365
+ *
366
+ * The field accepts EITHER a single `{folder, environments}` object — the shape
367
+ * every manifest written before Story #464 uses — or `{folders: [...]}` whose
368
+ * entries are a bare folder path or a `{folder, environments}` object, because
369
+ * two residencies that occur in practice cannot be said with one folder:
370
+ *
371
+ * 1. **Multi-folder residency.** A key can legitimately be resident in more
372
+ * than one folder, and folder IMPORTS are what make that normal rather
373
+ * than sloppy: when `/cloudflare` imports `/shared`, a value entering at
374
+ * `/shared` is genuinely read through `/cloudflare` as well. Both
375
+ * statements are true and one field could hold only one, so the same
376
+ * misplacement was counted TWICE — `missing` from the declared folder and
377
+ * `orphan` in the folder that actually held it.
378
+ * 2. **Per-environment residency.** A key can live in a different folder per
379
+ * environment (an operator-held credential that arrives via `/github` in
380
+ * staging only). With no per-entry `environments`, one of the two folders
381
+ * was always wrong.
382
+ *
383
+ * Both authored shapes normalize to one array of `{folder, environments}` with
384
+ * `environments` defaulted and materialized to `manifest.environments`, so
385
+ * `probeInfisical` has exactly one shape to read — the same normalize-at-parse
386
+ * treatment `residency.github` received in Story #459, so there is one
387
+ * precedent for expressive residency rather than two idioms.
388
+ *
389
+ * @param {unknown} raw
390
+ * @param {{at: string, name: string, environments: string[]}} ctx
391
+ * @returns {"unmanaged" | {folders: Array<{folder: string, environments: string[]}>}}
392
+ */
393
+ function normalizeInfisicalResidency(raw, { at, name, environments }) {
394
+ if (raw === undefined || raw === null || raw === "unmanaged") return "unmanaged";
395
+ if (typeof raw !== "object" || Array.isArray(raw)) {
396
+ throw new Error(`${at}.infisical must be "unmanaged", {folder, environments} or {folders: [...]} (key ${name})`);
397
+ }
398
+
399
+ const hasFolder = raw.folder !== undefined;
400
+ const hasFolders = raw.folders !== undefined;
401
+ if (hasFolder && hasFolders) {
402
+ throw new Error(`${at}.infisical declares both "folder" and "folders" — use one or the other (key ${name})`);
403
+ }
404
+ if (!hasFolder && !hasFolders) {
405
+ throw new Error(`${at}.infisical must declare "folder" or "folders", or be "unmanaged" (key ${name})`);
406
+ }
407
+
408
+ let authored;
409
+ if (hasFolder) {
410
+ authored = [{ folder: raw.folder, environments: raw.environments, where: `${at}.infisical` }];
411
+ } else {
412
+ if (!Array.isArray(raw.folders) || raw.folders.length === 0) {
413
+ throw new Error(
414
+ `${at}.infisical.folders must be a non-empty array — use "unmanaged" when the key does not ` +
415
+ `belong in Infisical (key ${name})`
416
+ );
417
+ }
418
+ if (raw.environments !== undefined) {
419
+ throw new Error(
420
+ `${at}.infisical.environments is meaningful only beside a single "folder" — under "folders" ` +
421
+ `each entry carries its own environments (key ${name})`
422
+ );
423
+ }
424
+ authored = raw.folders.map((folderEntry, j) => {
425
+ const where = `${at}.infisical.folders[${j}]`;
426
+ if (typeof folderEntry === "string") return { folder: folderEntry, environments: undefined, where };
427
+ if (!folderEntry || typeof folderEntry !== "object" || Array.isArray(folderEntry)) {
428
+ throw new Error(`${where} must be a folder path string or {folder, environments} (key ${name})`);
429
+ }
430
+ return { folder: folderEntry.folder, environments: folderEntry.environments, where };
431
+ });
432
+ }
433
+
434
+ const seenFolders = new Set();
435
+ const folders = authored.map(({ folder, environments: authoredEnvs, where }) => {
436
+ if (typeof folder !== "string" || !folder) {
437
+ throw new Error(`${where}.folder must be a non-empty folder path string (key ${name})`);
438
+ }
439
+ if (seenFolders.has(folder)) {
440
+ throw new Error(`${at}.infisical repeats the folder "${folder}" — declare one entry per folder (key ${name})`);
441
+ }
442
+ seenFolders.add(folder);
443
+
444
+ const envs = authoredEnvs ?? environments;
445
+ if (!Array.isArray(envs) || !envs.every((e) => typeof e === "string")) {
446
+ throw new Error(`${at}.infisical.environments must be an array of environment slugs (key ${name})`);
447
+ }
448
+ for (const e of envs) {
449
+ if (!environments.includes(e)) {
450
+ throw new Error(`${at}.infisical.environments names "${e}", absent from manifest.environments (key ${name})`);
451
+ }
452
+ }
453
+ return { folder, environments: [...envs] };
454
+ });
455
+
456
+ return { folders };
182
457
  }
183
458
 
184
459
  /**
@@ -217,15 +492,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
217
492
  throw new Error(`${at}.residency.local must be "var", "secret", "file" or null (key ${name})`);
218
493
  }
219
494
 
220
- const github = residency.github ?? null;
221
- if (github !== null) {
222
- if (github.scope !== "environment" && github.scope !== "repository") {
223
- throw new Error(`${at}.residency.github.scope must be "environment" or "repository" (key ${name})`);
224
- }
225
- if (github.kind !== "secret" && github.kind !== "var") {
226
- throw new Error(`${at}.residency.github.kind must be "secret" or "var" (key ${name})`);
227
- }
228
- }
495
+ const github = normalizeGitHubResidency(residency.github, { at, name, environments });
229
496
 
230
497
  const cloudflare = residency.cloudflare ?? null;
231
498
  if (cloudflare !== null) {
@@ -242,21 +509,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
242
509
  }
243
510
  }
244
511
 
245
- const infisical = entry.infisical ?? "unmanaged";
246
- if (infisical !== "unmanaged") {
247
- if (!infisical || typeof infisical !== "object" || typeof infisical.folder !== "string") {
248
- throw new Error(`${at}.infisical must be "unmanaged" or {folder, environments} (key ${name})`);
249
- }
250
- const envs = infisical.environments ?? environments;
251
- if (!Array.isArray(envs) || !envs.every((e) => typeof e === "string")) {
252
- throw new Error(`${at}.infisical.environments must be an array of environment slugs (key ${name})`);
253
- }
254
- for (const e of envs) {
255
- if (!environments.includes(e)) {
256
- throw new Error(`${at}.infisical.environments names "${e}", absent from manifest.environments (key ${name})`);
257
- }
258
- }
259
- }
512
+ const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
260
513
 
261
514
  if (entry.shape !== undefined) {
262
515
  if (typeof entry.shape !== "string" || !Object.hasOwn(SHAPE_VOCABULARY, entry.shape)) {
@@ -279,10 +532,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
279
532
  github,
280
533
  cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
281
534
  },
282
- infisical:
283
- infisical === "unmanaged"
284
- ? "unmanaged"
285
- : { folder: infisical.folder, environments: [...(infisical.environments ?? environments)] },
535
+ infisical,
286
536
  shape: entry.shape ?? null,
287
537
  placeholderPattern: entry.placeholderPattern ?? null,
288
538
  note: typeof entry.note === "string" ? entry.note : null,
@@ -774,12 +1024,22 @@ export function runOfflineChecks({ manifest, repoRoot }) {
774
1024
  * @param {string} opts.surface
775
1025
  * @param {string | null} opts.environment
776
1026
  * @param {string} [opts.scope] Free-text scope for the detail line.
1027
+ * @param {Iterable<string> | null} [opts.declaredElsewhere]
1028
+ * Names the caller has already accounted for in a SIBLING partition of the
1029
+ * same surface. A name in this set is never reported as an orphan here; it
1030
+ * has no effect on `missing`. The option exists because a surface can be
1031
+ * probed in more than one partition — GitHub is read at repository scope and
1032
+ * again per environment — and a key legitimately resident in two of them is
1033
+ * not drift. Only the caller knows which partitions are siblings, so the set
1034
+ * is supplied rather than inferred: the default is `null`, which keeps this
1035
+ * function's behaviour identical for every caller that omits it.
777
1036
  * @returns {object[]} findings
778
1037
  */
779
- export function reconcileNames({ expected, present, surface, environment, scope = "" }) {
1038
+ export function reconcileNames({ expected, present, surface, environment, scope = "", declaredElsewhere = null }) {
780
1039
  const findings = [];
781
1040
  const presentSet = new Set(present);
782
1041
  const expectedSet = new Set(expected);
1042
+ const elsewhere = declaredElsewhere ? new Set(declaredElsewhere) : null;
783
1043
  const where = scope ? ` (${scope})` : "";
784
1044
  for (const name of expected) {
785
1045
  if (!presentSet.has(name)) {
@@ -795,6 +1055,7 @@ export function reconcileNames({ expected, present, surface, environment, scope
795
1055
  }
796
1056
  for (const name of present) {
797
1057
  if (!expectedSet.has(name)) {
1058
+ if (elsewhere?.has(name)) continue;
798
1059
  findings.push({
799
1060
  severity: "orphan",
800
1061
  kind: "orphan",
@@ -1065,6 +1326,45 @@ function unavailableNotice(unavailability, surface) {
1065
1326
  }
1066
1327
 
1067
1328
  /**
1329
+ * Names declared at one GitHub `(scope, kind)`, optionally narrowed to the
1330
+ * entries that name a given environment.
1331
+ *
1332
+ * @param {object[]} keys Parsed manifest keys.
1333
+ * @param {string} scope "repository" | "environment"
1334
+ * @param {string} kind "secret" | "var"
1335
+ * @param {string | null} environment Non-null narrows to entries naming it.
1336
+ * @returns {string[]}
1337
+ */
1338
+ function githubNamesAt(keys, scope, kind, environment) {
1339
+ return keys
1340
+ .filter((k) =>
1341
+ (k.residency.github ?? []).some(
1342
+ (g) =>
1343
+ g.scope === scope && g.kind === kind && (environment === null || g.environments.includes(environment))
1344
+ )
1345
+ )
1346
+ .map((k) => k.name);
1347
+ }
1348
+
1349
+ /**
1350
+ * Reconcile the GitHub surface per `(scope, kind, environment)` triple.
1351
+ *
1352
+ * Two partitions are read — repository scope once, environment scope once per
1353
+ * environment — and a key may legitimately be resident in both. So each
1354
+ * partition suppresses orphans for names declared at the OTHER scope with the
1355
+ * SAME kind, and nothing wider:
1356
+ *
1357
+ * - **Cross-scope, same kind** is suppressed: that is the dual-scope residency
1358
+ * the array form exists to describe, and reporting it was the false orphan.
1359
+ * - **Cross-kind is NOT suppressed.** A name declared as a secret but present
1360
+ * as a variable is a real mismatch, and the orphan is how it surfaces.
1361
+ * - **Cross-environment is NOT suppressed.** A production-only key turning up
1362
+ * in staging is undeclared presence in that environment — arguably the most
1363
+ * interesting thing this surface can find — so it still orphans.
1364
+ *
1365
+ * A name declared in no GitHub scope at all orphans exactly as before, which
1366
+ * is what keeps `--strict-orphans` worth enabling once a manifest is correct.
1367
+ *
1068
1368
  * @param {object} ctx
1069
1369
  */
1070
1370
  async function probeGitHub({ manifest, environments, github, surfaces, findings, unavailability = {} }) {
@@ -1076,18 +1376,18 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1076
1376
  });
1077
1377
  return;
1078
1378
  }
1079
- const repoKeys = manifest.keys.filter((k) => k.residency.github?.scope === "repository");
1080
- const envKeys = manifest.keys.filter((k) => k.residency.github?.scope === "environment");
1379
+ const keys = manifest.keys;
1081
1380
  try {
1082
1381
  const present = await github.repositoryNames();
1083
1382
  for (const kind of ["secret", "var"]) {
1084
1383
  findings.push(
1085
1384
  ...reconcileNames({
1086
- expected: repoKeys.filter((k) => k.residency.github.kind === kind).map((k) => k.name),
1385
+ expected: githubNamesAt(keys, "repository", kind, null),
1087
1386
  present: present[kind] ?? [],
1088
1387
  surface: "github",
1089
1388
  environment: null,
1090
1389
  scope: `repository ${kind}s`,
1390
+ declaredElsewhere: githubNamesAt(keys, "environment", kind, null),
1091
1391
  })
1092
1392
  );
1093
1393
  }
@@ -1096,11 +1396,12 @@ async function probeGitHub({ manifest, environments, github, surfaces, findings,
1096
1396
  for (const kind of ["secret", "var"]) {
1097
1397
  findings.push(
1098
1398
  ...reconcileNames({
1099
- expected: envKeys.filter((k) => k.residency.github.kind === kind).map((k) => k.name),
1399
+ expected: githubNamesAt(keys, "environment", kind, environment),
1100
1400
  present: envPresent[kind] ?? [],
1101
1401
  surface: "github",
1102
1402
  environment,
1103
1403
  scope: `environment ${kind}s`,
1404
+ declaredElsewhere: githubNamesAt(keys, "repository", kind, null),
1104
1405
  })
1105
1406
  );
1106
1407
  }
@@ -1172,6 +1473,48 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1172
1473
  }
1173
1474
  }
1174
1475
 
1476
+ /**
1477
+ * Does a key declare residency at this folder in this environment?
1478
+ *
1479
+ * @param {object} key A parsed manifest key with normalized infisical residency.
1480
+ * @param {string} folder
1481
+ * @param {string} environment
1482
+ * @returns {boolean}
1483
+ */
1484
+ function residentAt(key, folder, environment) {
1485
+ return key.infisical.folders.some((f) => f.folder === folder && f.environments.includes(environment));
1486
+ }
1487
+
1488
+ /**
1489
+ * Names declared in a SIBLING declared folder of the same environment.
1490
+ *
1491
+ * Infisical is probed once per (environment, folder) pair, so a key resident
1492
+ * in two folders is read in two partitions — and reporting it as an orphan in
1493
+ * the one the manifest happens not to be reconciling is the false positive the
1494
+ * `folders` array exists to remove. Suppression is deliberately no wider:
1495
+ *
1496
+ * - **Cross-folder, same environment** is suppressed: that is multi-folder
1497
+ * residency, including the folder-import case.
1498
+ * - **Cross-environment is NOT suppressed.** A key declared for staging only
1499
+ * but present in production is undeclared presence in that environment —
1500
+ * the most interesting thing this surface can find — so it still orphans.
1501
+ *
1502
+ * A name declared in no folder at all orphans exactly as before, which is what
1503
+ * keeps `--strict-orphans` worth enabling once a manifest is correct. This is
1504
+ * the `declaredElsewhere` treatment `probeGitHub` has had since Story #459;
1505
+ * its absence here is why a single misplacement was reported twice.
1506
+ *
1507
+ * @param {object[]} managed
1508
+ * @param {string} folder
1509
+ * @param {string} environment
1510
+ * @returns {string[]}
1511
+ */
1512
+ function infisicalNamesElsewhere(managed, folder, environment) {
1513
+ return managed
1514
+ .filter((k) => k.infisical.folders.some((f) => f.folder !== folder && f.environments.includes(environment)))
1515
+ .map((k) => k.name);
1516
+ }
1517
+
1175
1518
  /**
1176
1519
  * The Infisical probe, plus the shape stage — the only place a value is read.
1177
1520
  *
@@ -1188,27 +1531,39 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
1188
1531
  return;
1189
1532
  }
1190
1533
  try {
1191
- const folders = new Set(managed.map((k) => k.infisical.folder));
1534
+ const folders = new Set(managed.flatMap((k) => k.infisical.folders.map((f) => f.folder)));
1192
1535
  const shapeChecked = [];
1536
+ // A key resident in several folders is READ in each of them, but its value
1537
+ // is one value — so it earns at most one shape verdict per environment.
1538
+ // Scoring it once per folder would re-introduce, in the shape stage, the
1539
+ // very double-reporting this Story removes from the residency stage.
1540
+ const shapeSeen = new Set();
1193
1541
  for (const environment of environments) {
1542
+ // The store's own environment slug, which need not be the deploy name.
1543
+ const slug = resolveSurfaceEnvironment(manifest, "infisical", environment);
1194
1544
  for (const folder of folders) {
1195
- const expected = managed
1196
- .filter((k) => k.infisical.folder === folder && k.infisical.environments.includes(environment))
1197
- .map((k) => k.name);
1198
- const present = await infisical.listNames({ environment, folder });
1545
+ const expected = managed.filter((k) => residentAt(k, folder, environment)).map((k) => k.name);
1546
+ const present = await infisical.listNames({ environment: slug, folder });
1199
1547
  findings.push(
1200
- ...reconcileNames({ expected, present, surface: "infisical", environment, scope: `folder ${folder}` })
1548
+ ...reconcileNames({
1549
+ expected,
1550
+ present,
1551
+ surface: "infisical",
1552
+ environment,
1553
+ scope: `folder ${folder}`,
1554
+ declaredElsewhere: infisicalNamesElsewhere(managed, folder, environment),
1555
+ })
1201
1556
  );
1202
1557
 
1203
1558
  // Shape stage. Values enter this scope and leave it as verdicts.
1204
1559
  const needShape = managed.filter(
1205
1560
  (k) =>
1206
- k.infisical.folder === folder &&
1207
- k.infisical.environments.includes(environment) &&
1208
- (k.shape || k.placeholderPattern)
1561
+ residentAt(k, folder, environment) &&
1562
+ (k.shape || k.placeholderPattern) &&
1563
+ !shapeSeen.has(`${environment}\u241f${k.name}`)
1209
1564
  );
1210
1565
  if (needShape.length === 0) continue;
1211
- const values = await infisical.listValues({ environment, folder });
1566
+ const values = await infisical.listValues({ environment: slug, folder });
1212
1567
  for (const key of needShape) {
1213
1568
  if (!values.has(key.name)) continue;
1214
1569
  const verdict = checkShape({
@@ -1217,6 +1572,7 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
1217
1572
  placeholderPattern: key.placeholderPattern,
1218
1573
  });
1219
1574
  shapeChecked.push(key.name);
1575
+ shapeSeen.add(`${environment}\u241f${key.name}`);
1220
1576
  if (!verdict.ok) {
1221
1577
  findings.push({
1222
1578
  severity: "fail",
@@ -31,7 +31,9 @@ import { promisify } from "node:util";
31
31
  import { test } from "node:test";
32
32
 
33
33
  import {
34
+ KEY_SCHEMA,
34
35
  MANIFEST_SCHEMA,
36
+ SLUG_MAPPED_SURFACES,
35
37
  SHAPE_NAMES,
36
38
  SHAPE_VOCABULARY,
37
39
  applyExceptions,
@@ -49,6 +51,7 @@ import {
49
51
  redactUrl,
50
52
  renderReport,
51
53
  resolveScriptName,
54
+ resolveSurfaceEnvironment,
52
55
  runDoctor,
53
56
  runOfflineChecks,
54
57
  } from "./env-doctor.mjs";
@@ -186,6 +189,78 @@ test("parseManifest rejects a duplicate key name and an infisical env outside th
186
189
  assert.throws(() => parseManifest(badEnv), /absent from manifest.environments/);
187
190
  });
188
191
 
192
+ test("parseManifest normalizes the single-object residency.github to a one-entry array", () => {
193
+ const m = parseManifest(singleWorkerManifest());
194
+ // The authored shape is an object; the parsed shape is always an array, so
195
+ // probeGitHub reads exactly one shape. `environments` defaults to all of
196
+ // manifest.environments, the same treatment infisical.environments gets.
197
+ assert.deepEqual(m.keys[0].residency.github, [
198
+ { scope: "environment", kind: "var", environments: ["staging", "production"] },
199
+ ]);
200
+ });
201
+
202
+ test("parseManifest accepts the array form and defaults environments per entry", () => {
203
+ const raw = singleWorkerManifest();
204
+ raw.keys[0].residency.github = [
205
+ { scope: "repository", kind: "var" },
206
+ { scope: "environment", kind: "var", environments: ["production"] },
207
+ ];
208
+ const m = parseManifest(raw);
209
+ assert.deepEqual(m.keys[0].residency.github, [
210
+ { scope: "repository", kind: "var", environments: ["staging", "production"] },
211
+ { scope: "environment", kind: "var", environments: ["production"] },
212
+ ]);
213
+ });
214
+
215
+ test("parseManifest rejects an residency.github environments slug outside manifest.environments", () => {
216
+ const raw = singleWorkerManifest();
217
+ raw.keys[0].residency.github = [{ scope: "environment", kind: "var", environments: ["preview"] }];
218
+ assert.throws(() => parseManifest(raw), (err) => {
219
+ assert.match(err.message, /absent from manifest.environments/);
220
+ assert.match(err.message, /PUBLIC_SITE_URL/);
221
+ return true;
222
+ });
223
+ });
224
+
225
+ test("parseManifest rejects environments on a repository-scope entry rather than ignoring it", () => {
226
+ // Silently ignoring it is how an author comes to believe they scoped
227
+ // something they did not — the same fail-closed posture as an unknown shape.
228
+ const raw = singleWorkerManifest();
229
+ raw.keys[0].residency.github = [{ scope: "repository", kind: "var", environments: ["production"] }];
230
+ assert.throws(() => parseManifest(raw), (err) => {
231
+ assert.match(err.message, /meaningful only under scope "environment"/);
232
+ assert.match(err.message, /PUBLIC_SITE_URL/);
233
+ return true;
234
+ });
235
+ });
236
+
237
+ test("parseManifest rejects a duplicate (scope, kind) pair and an empty residency.github array", () => {
238
+ const dup = singleWorkerManifest();
239
+ dup.keys[0].residency.github = [
240
+ { scope: "environment", kind: "var" },
241
+ { scope: "environment", kind: "var", environments: ["production"] },
242
+ ];
243
+ assert.throws(() => parseManifest(dup), (err) => {
244
+ assert.match(err.message, /repeats the \(scope, kind\) pair "environment\/var"/);
245
+ assert.match(err.message, /PUBLIC_SITE_URL/);
246
+ return true;
247
+ });
248
+
249
+ const empty = singleWorkerManifest();
250
+ empty.keys[0].residency.github = [];
251
+ assert.throws(() => parseManifest(empty), /must not be an empty array/);
252
+ });
253
+
254
+ test("parseManifest still rejects a malformed scope and kind under both authored shapes", () => {
255
+ const objForm = singleWorkerManifest();
256
+ objForm.keys[0].residency.github = { scope: "org", kind: "var" };
257
+ assert.throws(() => parseManifest(objForm), /\.scope must be "environment" or "repository"/);
258
+
259
+ const arrForm = singleWorkerManifest();
260
+ arrForm.keys[0].residency.github = [{ scope: "repository", kind: "file" }];
261
+ assert.throws(() => parseManifest(arrForm), /residency\.github\[0\]\.kind must be "secret" or "var"/);
262
+ });
263
+
189
264
  test("resolveScriptName substitutes {env} per environment", () => {
190
265
  assert.equal(resolveScriptName("acme-site-{env}", "staging"), "acme-site-staging");
191
266
  assert.equal(resolveScriptName("acme-site-{env}", "production"), "acme-site-production");
@@ -458,6 +533,214 @@ test("a non-404 probe failure becomes an error surface and exits 1 — never 'no
458
533
  }
459
534
  });
460
535
 
536
+ // ---------------------------------------------------------------------------
537
+ // GitHub residency — dual scope and per-environment presence (Story #459)
538
+ // ---------------------------------------------------------------------------
539
+
540
+ /**
541
+ * A manifest with no workers and no local residency, so the offline arm over an
542
+ * empty repo root contributes no findings and every finding under test comes
543
+ * from the GitHub probe.
544
+ */
545
+ function githubOnlyManifest(github) {
546
+ return parseManifest({
547
+ environments: ["staging", "production"],
548
+ keys: [{ name: "SHARED_TOKEN", kind: "secret", sensitivity: "secret", residency: { local: null, github } }],
549
+ });
550
+ }
551
+
552
+ /** Mock the two GitHub probe calls from a `{repository, staging, production}` map. */
553
+ function githubProbe(present) {
554
+ const at = (slot) => ({ secret: [], var: [], ...(present[slot] ?? {}) });
555
+ return {
556
+ repositoryNames: async () => at("repository"),
557
+ environmentNames: async (environment) => at(environment),
558
+ };
559
+ }
560
+
561
+ async function githubFindings({ manifest, present, environments = ["staging", "production"] }) {
562
+ const root = makeRepo({});
563
+ try {
564
+ const report = await runDoctor({ manifest, repoRoot: root, environments, github: githubProbe(present) });
565
+ return report.findings.filter((f) => f.surface === "github");
566
+ } finally {
567
+ rmSync(root, { recursive: true, force: true });
568
+ }
569
+ }
570
+
571
+ const DUAL_SCOPE = [
572
+ { scope: "repository", kind: "secret" },
573
+ { scope: "environment", kind: "secret" },
574
+ ];
575
+
576
+ test("a dual-scope key present at both scopes reports no finding", async () => {
577
+ const findings = await githubFindings({
578
+ manifest: githubOnlyManifest(DUAL_SCOPE),
579
+ present: {
580
+ repository: { secret: ["SHARED_TOKEN"] },
581
+ staging: { secret: ["SHARED_TOKEN"] },
582
+ production: { secret: ["SHARED_TOKEN"] },
583
+ },
584
+ });
585
+ assert.deepEqual(findings, []);
586
+ });
587
+
588
+ test("a dual-scope key absent from one scope reports a missing naming that scope only", async () => {
589
+ const findings = await githubFindings({
590
+ manifest: githubOnlyManifest(DUAL_SCOPE),
591
+ present: {
592
+ repository: { secret: [] },
593
+ staging: { secret: ["SHARED_TOKEN"] },
594
+ production: { secret: ["SHARED_TOKEN"] },
595
+ },
596
+ });
597
+ assert.equal(findings.length, 1);
598
+ assert.equal(findings[0].kind, "missing");
599
+ assert.equal(findings[0].key, "SHARED_TOKEN");
600
+ assert.equal(findings[0].environment, null);
601
+ assert.match(findings[0].detail, /repository secrets/);
602
+ });
603
+
604
+ test("a dual-scope key absent from one environment reports a missing naming that environment only", async () => {
605
+ const findings = await githubFindings({
606
+ manifest: githubOnlyManifest(DUAL_SCOPE),
607
+ present: {
608
+ repository: { secret: ["SHARED_TOKEN"] },
609
+ staging: { secret: [] },
610
+ production: { secret: ["SHARED_TOKEN"] },
611
+ },
612
+ });
613
+ assert.equal(findings.length, 1);
614
+ assert.equal(findings[0].kind, "missing");
615
+ assert.equal(findings[0].environment, "staging");
616
+ });
617
+
618
+ test("an environments-scoped entry reports no missing for an environment it never names", async () => {
619
+ // The motivating case: a production-only analytics token whose staging
620
+ // counterpart is meant to resolve empty. That is design, not drift.
621
+ const manifest = githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]);
622
+ assert.deepEqual(
623
+ await githubFindings({ manifest, present: { production: { secret: ["SHARED_TOKEN"] } } }),
624
+ []
625
+ );
626
+
627
+ const missing = await githubFindings({ manifest, present: {} });
628
+ assert.equal(missing.length, 1, "production still fails when it lacks the key");
629
+ assert.equal(missing[0].kind, "missing");
630
+ assert.equal(missing[0].environment, "production");
631
+ });
632
+
633
+ test("a key declared only at environment scope is not a repository-level orphan", async () => {
634
+ const findings = await githubFindings({
635
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
636
+ present: {
637
+ repository: { secret: ["SHARED_TOKEN"] },
638
+ staging: { secret: ["SHARED_TOKEN"] },
639
+ production: { secret: ["SHARED_TOKEN"] },
640
+ },
641
+ });
642
+ assert.deepEqual(findings, []);
643
+ });
644
+
645
+ test("suppression is cross-scope only — an undeclared name still orphans", async () => {
646
+ // The half that keeps --strict-orphans worth enabling: nothing about the
647
+ // suppression hides a name the manifest never declared anywhere.
648
+ const findings = await githubFindings({
649
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
650
+ present: {
651
+ repository: { secret: ["SHARED_TOKEN", "UNDECLARED_TOKEN"] },
652
+ staging: { secret: ["SHARED_TOKEN"] },
653
+ production: { secret: ["SHARED_TOKEN"] },
654
+ },
655
+ });
656
+ assert.equal(findings.length, 1);
657
+ assert.equal(findings[0].kind, "orphan");
658
+ assert.equal(findings[0].key, "UNDECLARED_TOKEN");
659
+ assert.equal(findings[0].environment, null);
660
+ });
661
+
662
+ test("suppression does not cross kind — a secret declared, a variable present, still orphans", async () => {
663
+ const findings = await githubFindings({
664
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret" }]),
665
+ present: {
666
+ repository: { var: ["SHARED_TOKEN"] },
667
+ staging: { secret: ["SHARED_TOKEN"] },
668
+ production: { secret: ["SHARED_TOKEN"] },
669
+ },
670
+ });
671
+ assert.equal(findings.length, 1);
672
+ assert.equal(findings[0].kind, "orphan");
673
+ assert.match(findings[0].detail, /repository vars/);
674
+ });
675
+
676
+ test("suppression does not cross environment — a production-only key found in staging orphans", async () => {
677
+ // Undeclared presence in the wrong environment is the most interesting thing
678
+ // this surface can find, so the cross-scope rule must not reach it.
679
+ const findings = await githubFindings({
680
+ manifest: githubOnlyManifest([{ scope: "environment", kind: "secret", environments: ["production"] }]),
681
+ present: {
682
+ staging: { secret: ["SHARED_TOKEN"] },
683
+ production: { secret: ["SHARED_TOKEN"] },
684
+ },
685
+ });
686
+ assert.equal(findings.length, 1);
687
+ assert.equal(findings[0].kind, "orphan");
688
+ assert.equal(findings[0].environment, "staging");
689
+ });
690
+
691
+ test("the single-object residency.github form yields exactly the findings it did before", async () => {
692
+ // The regression case, over the unchanged existing fixture: both keys are
693
+ // authored as single objects at environment scope, so a repo-level probe
694
+ // holding neither must produce two per-environment missings and nothing else.
695
+ const root = makeRepo(CONSISTENT_REPO);
696
+ try {
697
+ const report = await runDoctor({
698
+ manifest: parseManifest(singleWorkerManifest()),
699
+ repoRoot: root,
700
+ environments: ["staging", "production"],
701
+ github: githubProbe({
702
+ staging: { var: ["PUBLIC_SITE_URL"], secret: ["TURSO_AUTH_TOKEN"] },
703
+ production: { var: ["PUBLIC_SITE_URL"] },
704
+ }),
705
+ });
706
+ const gh = report.findings.filter((f) => f.surface === "github");
707
+ assert.equal(gh.length, 1);
708
+ assert.deepEqual(
709
+ { kind: gh[0].kind, key: gh[0].key, environment: gh[0].environment },
710
+ { kind: "missing", key: "TURSO_AUTH_TOKEN", environment: "production" }
711
+ );
712
+ assert.equal(report.surfaces.find((s) => s.surface === "github").status, "checked");
713
+ } finally {
714
+ rmSync(root, { recursive: true, force: true });
715
+ }
716
+ });
717
+
718
+ test("declaredElsewhere suppresses only orphans, never a missing", async () => {
719
+ // The new argument is opt-in and orphan-only, so the cloudflare and infisical
720
+ // call sites that omit it cannot change behaviour.
721
+ const suppressed = reconcileNames({
722
+ expected: ["A"],
723
+ present: ["B"],
724
+ surface: "github",
725
+ environment: null,
726
+ declaredElsewhere: ["A", "B"],
727
+ });
728
+ assert.deepEqual(
729
+ suppressed.map((f) => [f.kind, f.key]),
730
+ [["missing", "A"]]
731
+ );
732
+ assert.deepEqual(reconcileNames({ expected: [], present: ["B"], surface: "github", environment: null }), [
733
+ {
734
+ severity: "orphan",
735
+ kind: "orphan",
736
+ key: "B",
737
+ surface: "github",
738
+ environment: null,
739
+ detail: "present in github but declared by no manifest key",
740
+ },
741
+ ]);
742
+ });
743
+
461
744
  test("the Cloudflare probe resolves {env} in scriptName once per environment", async () => {
462
745
  const root = makeRepo(CONSISTENT_REPO);
463
746
  const asked = [];
@@ -763,6 +1046,388 @@ test("no secret VALUE reaches stdout or stderr on a real run against a mock Infi
763
1046
  }
764
1047
  });
765
1048
 
1049
+ // ---------------------------------------------------------------------------
1050
+ // Infisical environment slugs and folder residency (Story #464)
1051
+ // ---------------------------------------------------------------------------
1052
+
1053
+ /**
1054
+ * A manifest with no local residency and no GitHub residency, so every finding
1055
+ * under test comes from the Infisical probe. One Worker is kept so the
1056
+ * Cloudflare surface can be asserted to keep the DEPLOY name while Infisical
1057
+ * is asked for the mapped slug.
1058
+ */
1059
+ function infisicalOnlyManifest(infisical, { environmentSlugs, keys } = {}) {
1060
+ return parseManifest({
1061
+ environments: ["staging", "production"],
1062
+ ...(environmentSlugs ? { environmentSlugs } : {}),
1063
+ workers: { site: { scriptName: "acme-site-{env}" } },
1064
+ keys:
1065
+ keys ??
1066
+ [
1067
+ {
1068
+ name: "SHARED_TOKEN",
1069
+ kind: "secret",
1070
+ sensitivity: "secret",
1071
+ residency: { local: null, github: null, cloudflare: null },
1072
+ infisical,
1073
+ },
1074
+ ],
1075
+ });
1076
+ }
1077
+
1078
+ /**
1079
+ * Run the Infisical probe against a `{ "<environment-slug><folder>": [names] }`
1080
+ * map, recording every (environment, folder) pair the client was asked for.
1081
+ * The map is keyed by the slug the CLIENT sees, which is the whole point: a
1082
+ * remapped environment must be looked up under its store slug.
1083
+ */
1084
+ async function infisicalRun({ manifest, present, environments = ["staging", "production"], cloudflare = null }) {
1085
+ const root = makeRepo({});
1086
+ const asked = [];
1087
+ try {
1088
+ const report = await runDoctor({
1089
+ manifest,
1090
+ repoRoot: root,
1091
+ environments,
1092
+ cloudflare,
1093
+ infisical: {
1094
+ listNames: async ({ environment, folder }) => {
1095
+ asked.push(`${environment}${folder}`);
1096
+ return present[`${environment}${folder}`] ?? [];
1097
+ },
1098
+ listValues: async () => new Map(),
1099
+ },
1100
+ });
1101
+ return { asked, findings: report.findings.filter((f) => f.surface === "infisical"), report };
1102
+ } finally {
1103
+ rmSync(root, { recursive: true, force: true });
1104
+ }
1105
+ }
1106
+
1107
+ test("a remapped Infisical slug is probed at the slug while Cloudflare keeps the deploy name", async () => {
1108
+ // The motivating case: deploy environments are staging/production, but the
1109
+ // Infisical project's slugs are staging/prod. Before this, `production`
1110
+ // reached Infisical verbatim and 404'd the whole surface into `error`.
1111
+ const scripts = [];
1112
+ const { asked } = await infisicalRun({
1113
+ manifest: infisicalOnlyManifest(
1114
+ { folder: "/shared" },
1115
+ { environmentSlugs: { infisical: { production: "prod" } } }
1116
+ ),
1117
+ present: { "staging/shared": ["SHARED_TOKEN"], "prod/shared": ["SHARED_TOKEN"] },
1118
+ cloudflare: {
1119
+ secretNames: async (scriptName) => {
1120
+ scripts.push(scriptName);
1121
+ return [];
1122
+ },
1123
+ },
1124
+ });
1125
+
1126
+ assert.deepEqual(asked, ["staging/shared", "prod/shared"]);
1127
+ assert.ok(!asked.some((a) => a.startsWith("production")), "the deploy name must not reach Infisical");
1128
+ // The Cloudflare surface resolves {env} from the DEPLOY name, unmapped —
1129
+ // which is why it needs no slug map of its own.
1130
+ assert.deepEqual(scripts, []);
1131
+ });
1132
+
1133
+ test("a manifest declaring a Cloudflare secret still resolves {env} from the unmapped deploy name", async () => {
1134
+ const scripts = [];
1135
+ await infisicalRun({
1136
+ manifest: infisicalOnlyManifest(undefined, {
1137
+ environmentSlugs: { infisical: { production: "prod" } },
1138
+ keys: [
1139
+ {
1140
+ name: "SHARED_TOKEN",
1141
+ kind: "secret",
1142
+ sensitivity: "secret",
1143
+ residency: { local: null, github: null, cloudflare: { workers: ["site"], kind: "secret" } },
1144
+ infisical: { folder: "/shared" },
1145
+ },
1146
+ ],
1147
+ }),
1148
+ present: {},
1149
+ cloudflare: {
1150
+ secretNames: async (scriptName) => {
1151
+ scripts.push(scriptName);
1152
+ return ["SHARED_TOKEN"];
1153
+ },
1154
+ },
1155
+ });
1156
+ assert.deepEqual(scripts, ["acme-site-staging", "acme-site-production"]);
1157
+ });
1158
+
1159
+ test("environmentSlugs rejects a surface that is not slug-mapped, and names it", () => {
1160
+ assert.throws(
1161
+ () =>
1162
+ infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { cloudflare: { production: "prod" } } }),
1163
+ (err) => {
1164
+ assert.match(err.message, /"cloudflare" is not a slug-mapped surface/);
1165
+ assert.match(err.message, /infisical/);
1166
+ // The message must say WHY, or the author re-files the same request.
1167
+ assert.match(err.message, /scriptName/);
1168
+ return true;
1169
+ }
1170
+ );
1171
+ });
1172
+
1173
+ test("environmentSlugs rejects an unmapped environment and a slug that is not a non-empty string", () => {
1174
+ assert.throws(
1175
+ () => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { preview: "prev" } } }),
1176
+ /maps "preview", absent from manifest.environments/
1177
+ );
1178
+ assert.throws(
1179
+ () => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { production: "" } } }),
1180
+ /must be a non-empty slug string/
1181
+ );
1182
+ assert.throws(
1183
+ () => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: { production: 7 } } }),
1184
+ /must be a non-empty slug string/
1185
+ );
1186
+ assert.throws(
1187
+ () => infisicalOnlyManifest({ folder: "/" }, { environmentSlugs: { infisical: ["prod"] } }),
1188
+ /must be an object mapping environment -> slug/
1189
+ );
1190
+ });
1191
+
1192
+ test("with no environmentSlugs every environment resolves to itself", async () => {
1193
+ const manifest = infisicalOnlyManifest({ folder: "/shared" });
1194
+ // The container is normalized to a total-but-empty map, so no caller has to
1195
+ // distinguish "absent" from "empty".
1196
+ assert.deepEqual(manifest.environmentSlugs, { infisical: {} });
1197
+ assert.equal(resolveSurfaceEnvironment(manifest, "infisical", "production"), "production");
1198
+ const { asked } = await infisicalRun({ manifest, present: { "staging/shared": ["SHARED_TOKEN"] } });
1199
+ assert.deepEqual(asked, ["staging/shared", "production/shared"]);
1200
+ });
1201
+
1202
+ test("parseManifest normalizes the single-object infisical form to a one-entry folders array", () => {
1203
+ // The pre-#464 authored shape, parsed: one shape reaches probeInfisical, the
1204
+ // same treatment residency.github received in #459.
1205
+ const m = infisicalOnlyManifest({ folder: "/shared", environments: ["production"] });
1206
+ assert.deepEqual(m.keys[0].infisical, { folders: [{ folder: "/shared", environments: ["production"] }] });
1207
+
1208
+ const defaulted = infisicalOnlyManifest({ folder: "/shared" });
1209
+ assert.deepEqual(defaulted.keys[0].infisical, {
1210
+ folders: [{ folder: "/shared", environments: ["staging", "production"] }],
1211
+ });
1212
+ });
1213
+
1214
+ test("the folders array accepts bare paths and per-entry environments", () => {
1215
+ const m = infisicalOnlyManifest({
1216
+ folders: ["/shared", { folder: "/github", environments: ["staging"] }],
1217
+ });
1218
+ assert.deepEqual(m.keys[0].infisical, {
1219
+ folders: [
1220
+ { folder: "/shared", environments: ["staging", "production"] },
1221
+ { folder: "/github", environments: ["staging"] },
1222
+ ],
1223
+ });
1224
+ });
1225
+
1226
+ test("a key resident in two folders and present in both reports no finding", async () => {
1227
+ // The folder-import case: /cloudflare imports /shared, so the value is
1228
+ // genuinely readable through both. Both statements are true.
1229
+ const { findings } = await infisicalRun({
1230
+ manifest: infisicalOnlyManifest({ folders: ["/shared", "/cloudflare"] }),
1231
+ present: {
1232
+ "staging/shared": ["SHARED_TOKEN"],
1233
+ "staging/cloudflare": ["SHARED_TOKEN"],
1234
+ "production/shared": ["SHARED_TOKEN"],
1235
+ "production/cloudflare": ["SHARED_TOKEN"],
1236
+ },
1237
+ });
1238
+ assert.deepEqual(findings, []);
1239
+ });
1240
+
1241
+ test("a misplacement across two declared folders is reported ONCE, as the missing", async () => {
1242
+ // Declared in /shared, actually resident in /cloudflare. Before #464 this
1243
+ // was two findings for one fact — a missing AND an orphan — and the orphan
1244
+ // was unsuppressable by an exception, so --strict-orphans could never go
1245
+ // green on a manifest that was merely imprecise about placement.
1246
+ const { findings } = await infisicalRun({
1247
+ manifest: infisicalOnlyManifest({ folders: ["/shared", "/cloudflare"] }),
1248
+ present: {
1249
+ "staging/cloudflare": ["SHARED_TOKEN"],
1250
+ "production/cloudflare": ["SHARED_TOKEN"],
1251
+ },
1252
+ environments: ["staging"],
1253
+ });
1254
+ assert.equal(findings.length, 1);
1255
+ assert.equal(findings[0].kind, "missing");
1256
+ assert.equal(findings[0].key, "SHARED_TOKEN");
1257
+ assert.match(findings[0].detail, /folder \/shared/);
1258
+ assert.equal(
1259
+ findings.filter((f) => f.kind === "orphan").length,
1260
+ 0,
1261
+ "the sibling declared folder must not also orphan the same key"
1262
+ );
1263
+ });
1264
+
1265
+ test("suppression does not cross environment — a staging-only key found in production orphans", async () => {
1266
+ // SHARED_TOKEN is declared in /github for staging only. Finding it in
1267
+ // /shared in production is undeclared presence in that environment, which
1268
+ // is the most interesting thing this surface can report.
1269
+ const { findings } = await infisicalRun({
1270
+ manifest: infisicalOnlyManifest({
1271
+ folders: [{ folder: "/github", environments: ["staging"] }, { folder: "/shared", environments: ["staging"] }],
1272
+ }),
1273
+ present: {
1274
+ "staging/github": ["SHARED_TOKEN"],
1275
+ "staging/shared": ["SHARED_TOKEN"],
1276
+ "production/shared": ["SHARED_TOKEN"],
1277
+ },
1278
+ });
1279
+ assert.equal(findings.length, 1);
1280
+ assert.equal(findings[0].kind, "orphan");
1281
+ assert.equal(findings[0].environment, "production");
1282
+ });
1283
+
1284
+ test("a per-environment folder entry reports no missing for an environment it never names", async () => {
1285
+ const { findings } = await infisicalRun({
1286
+ manifest: infisicalOnlyManifest({ folders: [{ folder: "/operator", environments: ["production"] }] }),
1287
+ present: { "production/operator": ["SHARED_TOKEN"] },
1288
+ });
1289
+ assert.deepEqual(findings, []);
1290
+ });
1291
+
1292
+ test("infisical folder residency fails closed on every malformed shape", () => {
1293
+ const bad = (infisical) => () => infisicalOnlyManifest(infisical);
1294
+ assert.throws(bad({ folders: [] }), /folders must be a non-empty array/);
1295
+ assert.throws(bad({ folders: ["/shared", "/shared"] }), /repeats the folder "\/shared"/);
1296
+ assert.throws(bad({ folders: [{ folder: "/s", environments: ["preview"] }] }), /absent from manifest.environments/);
1297
+ assert.throws(bad({ folder: "/s", folders: ["/t"] }), /declares both "folder" and "folders"/);
1298
+ assert.throws(bad({ folders: ["/s"], environments: ["staging"] }), /meaningful only beside a single "folder"/);
1299
+ assert.throws(bad({}), /must declare "folder" or "folders"/);
1300
+ assert.throws(bad({ folders: [""] }), /must be a non-empty folder path string/);
1301
+ assert.throws(bad({ folders: [42] }), /must be a folder path string or \{folder, environments\}/);
1302
+ assert.throws(bad("nope"), /must be "unmanaged", \{folder, environments\} or \{folders: \[\.\.\.\]\}/);
1303
+ // Unchanged from before #464: the single-object form's own env validation.
1304
+ assert.throws(bad({ folder: "/s", environments: ["preview"] }), /absent from manifest.environments/);
1305
+ });
1306
+
1307
+ test("a multi-folder key with a shape earns ONE verdict per environment, not one per folder", async () => {
1308
+ // A key resident in two folders is READ twice, but it is one value — so
1309
+ // scoring it per folder would re-introduce double-reporting in the shape
1310
+ // stage, the very defect #464 removes from the residency stage.
1311
+ const root = makeRepo({});
1312
+ try {
1313
+ const manifest = infisicalOnlyManifest(undefined, {
1314
+ keys: [
1315
+ {
1316
+ name: "SHARED_TOKEN",
1317
+ kind: "var",
1318
+ sensitivity: "public",
1319
+ residency: { local: null, github: null, cloudflare: null },
1320
+ infisical: { folders: ["/shared", "/cloudflare"] },
1321
+ shape: "url",
1322
+ },
1323
+ ],
1324
+ });
1325
+ const report = await runDoctor({
1326
+ manifest,
1327
+ repoRoot: root,
1328
+ environments: ["staging"],
1329
+ infisical: {
1330
+ listNames: async () => ["SHARED_TOKEN"],
1331
+ listValues: async () => new Map([["SHARED_TOKEN", "example.test"]]),
1332
+ },
1333
+ });
1334
+ const shapeFails = report.findings.filter((f) => f.kind === "shape-fail");
1335
+ assert.equal(shapeFails.length, 1, "one value, one verdict");
1336
+ assert.equal(shapeFails[0].key, "SHARED_TOKEN");
1337
+ } finally {
1338
+ rmSync(root, { recursive: true, force: true });
1339
+ }
1340
+ });
1341
+
1342
+ test("MANIFEST_SCHEMA and KEY_SCHEMA describe the slug container and the folders array", () => {
1343
+ assert.ok(Object.hasOwn(MANIFEST_SCHEMA, "environmentSlugs"));
1344
+ assert.match(MANIFEST_SCHEMA.environmentSlugs, /infisical/);
1345
+ assert.match(KEY_SCHEMA.infisical, /folders/);
1346
+ assert.deepEqual(SLUG_MAPPED_SURFACES, ["infisical"]);
1347
+ });
1348
+
1349
+ test("the documented manifest schema block names the new shapes", () => {
1350
+ // The script exports the schema so the doc and the code describe one shape;
1351
+ // this asserts the DOC kept its half of that bargain.
1352
+ const doc = readFileSync(join(HERE, "..", "docs", "reusable-workflows.md"), "utf8");
1353
+ assert.match(doc, /"environmentSlugs"/);
1354
+ assert.match(doc, /"folders"/);
1355
+ });
1356
+
1357
+ test("no secret VALUE reaches stdout or stderr through the remapped-slug, multi-folder path", async () => {
1358
+ // The values-safety guarantee, re-asserted over the shapes #464 adds: a
1359
+ // remapped environment slug and a key resident in two folders. Same
1360
+ // low-entropy dictionary canaries as the sibling leak test, for the same
1361
+ // reason (a key-shaped fixture is a true positive for gitleaks).
1362
+ const INJECTED = {
1363
+ PUBLIC_SITE_URL: "example.test/no-scheme-here",
1364
+ TURSO_AUTH_TOKEN: "second-canary-that-must-never-be-printed",
1365
+ };
1366
+ const requested = [];
1367
+
1368
+ const server = createServer((req, res) => {
1369
+ if (req.method === "POST" && req.url.startsWith("/api/v1/auth/universal-auth/login")) {
1370
+ res.writeHead(200, { "Content-Type": "application/json" });
1371
+ res.end(JSON.stringify({ accessToken: "mock-token", expiresIn: 3600, tokenType: "Bearer" }));
1372
+ return;
1373
+ }
1374
+ if (req.url.startsWith("/api/v4/secrets")) {
1375
+ const params = new URL(req.url, "http://localhost").searchParams;
1376
+ requested.push(`${params.get("environment")}${params.get("secretPath")}`);
1377
+ const withValues = params.get("viewSecretValue") === "true";
1378
+ res.writeHead(200, { "Content-Type": "application/json" });
1379
+ res.end(
1380
+ JSON.stringify({
1381
+ secrets: Object.entries(INJECTED).map(([secretKey, secretValue]) => ({
1382
+ secretKey,
1383
+ ...(withValues ? { secretValue } : {}),
1384
+ })),
1385
+ })
1386
+ );
1387
+ return;
1388
+ }
1389
+ res.writeHead(404).end("{}");
1390
+ });
1391
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
1392
+ const site = `http://127.0.0.1:${server.address().port}`;
1393
+
1394
+ const raw = singleWorkerManifest();
1395
+ raw.environmentSlugs = { infisical: { production: "prod" } };
1396
+ for (const key of raw.keys) key.infisical = { folders: ["/", "/shared"] };
1397
+
1398
+ const root = makeRepo(CONSISTENT_REPO);
1399
+ const manifestPath = join(root, "env.manifest.json");
1400
+ writeFileSync(manifestPath, JSON.stringify(raw));
1401
+
1402
+ try {
1403
+ const run = await runCli(
1404
+ [
1405
+ "--manifest", manifestPath,
1406
+ "--repo-root", root,
1407
+ "--environments", "production",
1408
+ "--infisical-project", "proj-1",
1409
+ "--infisical-site", site,
1410
+ "--json",
1411
+ ],
1412
+ { env: { INFISICAL_CLIENT_ID: "id", INFISICAL_CLIENT_SECRET: "sec", ENV_DRIFT_GITHUB_TOKEN: "", CLOUDFLARE_API_TOKEN: "" } }
1413
+ );
1414
+
1415
+ // End-to-end proof that the slug reaches the wire through the real CLI:
1416
+ // every request names `prod`, never the `production` deploy name.
1417
+ assert.ok(requested.length > 0, "the mock store should have been asked for something");
1418
+ assert.deepEqual([...new Set(requested.map((r) => r.split("/")[0]))], ["prod"]);
1419
+
1420
+ const captured = run.stdout + run.stderr;
1421
+ assert.ok(captured.includes("PUBLIC_SITE_URL"), "the failing key's NAME should be reported");
1422
+ for (const [name, value] of Object.entries(INJECTED)) {
1423
+ assert.ok(!captured.includes(value), `the VALUE of ${name} leaked into the doctor's output`);
1424
+ }
1425
+ } finally {
1426
+ rmSync(root, { recursive: true, force: true });
1427
+ await new Promise((r) => server.close(r));
1428
+ }
1429
+ });
1430
+
766
1431
  // ---------------------------------------------------------------------------
767
1432
  // Shipped workflow shape
768
1433
  // ---------------------------------------------------------------------------