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.
package/scripts/env-doctor.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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({
|
|
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
|
|
1340
|
-
k.
|
|
1341
|
-
(k.
|
|
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",
|