mandrel-platform 1.9.0 → 1.11.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.
@@ -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",