mandrel-platform 1.9.0 → 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.9.0",
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)",
@@ -132,12 +134,97 @@ export const KEY_SCHEMA = Object.freeze({
132
134
  sensitivity: "'public' | 'secret'",
133
135
  residency:
134
136
  "object — {local: 'var'|'secret'|'file'|null, github: G|G[]|null where G = {scope,kind,environments?}, cloudflare: {workers,kind}|null}",
135
- infisical: "{folder, environments} | 'unmanaged'",
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,12 @@ 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 };
182
271
  }
183
272
 
184
273
  /**
@@ -271,6 +360,102 @@ function normalizeGitHubResidency(raw, { at, name, environments }) {
271
360
  });
272
361
  }
273
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 };
457
+ }
458
+
274
459
  /**
275
460
  * @param {unknown} entry
276
461
  * @param {number} index
@@ -324,21 +509,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
324
509
  }
325
510
  }
326
511
 
327
- const infisical = entry.infisical ?? "unmanaged";
328
- if (infisical !== "unmanaged") {
329
- if (!infisical || typeof infisical !== "object" || typeof infisical.folder !== "string") {
330
- throw new Error(`${at}.infisical must be "unmanaged" or {folder, environments} (key ${name})`);
331
- }
332
- const envs = infisical.environments ?? environments;
333
- if (!Array.isArray(envs) || !envs.every((e) => typeof e === "string")) {
334
- throw new Error(`${at}.infisical.environments must be an array of environment slugs (key ${name})`);
335
- }
336
- for (const e of envs) {
337
- if (!environments.includes(e)) {
338
- throw new Error(`${at}.infisical.environments names "${e}", absent from manifest.environments (key ${name})`);
339
- }
340
- }
341
- }
512
+ const infisical = normalizeInfisicalResidency(entry.infisical, { at, name, environments });
342
513
 
343
514
  if (entry.shape !== undefined) {
344
515
  if (typeof entry.shape !== "string" || !Object.hasOwn(SHAPE_VOCABULARY, entry.shape)) {
@@ -361,10 +532,7 @@ function validateKeyEntry(entry, index, workers, environments, seen) {
361
532
  github,
362
533
  cloudflare: cloudflare ? { workers: [...cloudflare.workers], kind: cloudflare.kind } : null,
363
534
  },
364
- infisical:
365
- infisical === "unmanaged"
366
- ? "unmanaged"
367
- : { folder: infisical.folder, environments: [...(infisical.environments ?? environments)] },
535
+ infisical,
368
536
  shape: entry.shape ?? null,
369
537
  placeholderPattern: entry.placeholderPattern ?? null,
370
538
  note: typeof entry.note === "string" ? entry.note : null,
@@ -1305,6 +1473,48 @@ async function probeCloudflare({ manifest, environments, cloudflare, surfaces, f
1305
1473
  }
1306
1474
  }
1307
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
+
1308
1518
  /**
1309
1519
  * The Infisical probe, plus the shape stage — the only place a value is read.
1310
1520
  *
@@ -1321,27 +1531,39 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
1321
1531
  return;
1322
1532
  }
1323
1533
  try {
1324
- 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)));
1325
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();
1326
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);
1327
1544
  for (const folder of folders) {
1328
- const expected = managed
1329
- .filter((k) => k.infisical.folder === folder && k.infisical.environments.includes(environment))
1330
- .map((k) => k.name);
1331
- 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 });
1332
1547
  findings.push(
1333
- ...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
+ })
1334
1556
  );
1335
1557
 
1336
1558
  // Shape stage. Values enter this scope and leave it as verdicts.
1337
1559
  const needShape = managed.filter(
1338
1560
  (k) =>
1339
- k.infisical.folder === folder &&
1340
- k.infisical.environments.includes(environment) &&
1341
- (k.shape || k.placeholderPattern)
1561
+ residentAt(k, folder, environment) &&
1562
+ (k.shape || k.placeholderPattern) &&
1563
+ !shapeSeen.has(`${environment}\u241f${k.name}`)
1342
1564
  );
1343
1565
  if (needShape.length === 0) continue;
1344
- const values = await infisical.listValues({ environment, folder });
1566
+ const values = await infisical.listValues({ environment: slug, folder });
1345
1567
  for (const key of needShape) {
1346
1568
  if (!values.has(key.name)) continue;
1347
1569
  const verdict = checkShape({
@@ -1350,6 +1572,7 @@ async function probeInfisical({ manifest, environments, infisical, surfaces, fin
1350
1572
  placeholderPattern: key.placeholderPattern,
1351
1573
  });
1352
1574
  shapeChecked.push(key.name);
1575
+ shapeSeen.add(`${environment}\u241f${key.name}`);
1353
1576
  if (!verdict.ok) {
1354
1577
  findings.push({
1355
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";
@@ -1043,6 +1046,388 @@ test("no secret VALUE reaches stdout or stderr on a real run against a mock Infi
1043
1046
  }
1044
1047
  });
1045
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
+
1046
1431
  // ---------------------------------------------------------------------------
1047
1432
  // Shipped workflow shape
1048
1433
  // ---------------------------------------------------------------------------