mandrel-platform 1.13.0 → 1.13.1

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.
@@ -13,7 +13,10 @@
13
13
  *
14
14
  * The five surfaces:
15
15
  *
16
- * 1. local — `.env` / `.env.example` in the caller repo
16
+ * 1. local — `.env.example` in the caller repo (the onboarding
17
+ * contract). The doctor never reads a developer's real
18
+ * local file: it holds live values, and a surface this
19
+ * script reports on must be one every run can see.
17
20
  * 2. wrangler — `[vars]` in each Worker's wrangler config
18
21
  * 3. github — Actions secret/variable NAMES (repo + environment scope)
19
22
  * 4. cloudflare— Worker secret NAMES per resolved script name
@@ -666,27 +669,181 @@ export function checkShape({ value, shape, placeholderPattern = null }) {
666
669
  // ---------------------------------------------------------------------------
667
670
 
668
671
  /**
669
- * Perform a JSON request and tag any HTTP failure with `.httpStatus`, so a
670
- * caller can apply the 404-only degradation rule (see the module docblock).
672
+ * Production request bounds. Every one is a CLIENT-CONSTRUCTOR option so the
673
+ * sibling suite can pass millisecond-scale values: a suite that had to wait
674
+ * out the real budget would simply not assert the timeout at all.
675
+ *
676
+ * A nightly drift gate hanging on one unresponsive store is the fail-open this
677
+ * module exists to refuse in a slower disguise — the job burns its runner
678
+ * minutes and reports nothing, which reads in the Actions UI as a run that has
679
+ * not finished rather than a probe that failed.
680
+ */
681
+ export const DEFAULT_TIMEOUT_MS = 15_000;
682
+ export const DEFAULT_RETRY_DELAY_MS = 500;
683
+
684
+ /** Attempts per request, INCLUDING the first. */
685
+ export const MAX_ATTEMPTS = 3;
686
+
687
+ /**
688
+ * Pages a single listing may follow before the probe fails closed. A store
689
+ * that keeps handing back a `rel="next"` is malfunctioning, and truncating its
690
+ * listing silently would report every un-fetched name as an orphan-free match
691
+ * — the same "no drift because we stopped looking" this module refuses.
692
+ */
693
+ export const MAX_PAGES = 50;
694
+
695
+ /**
696
+ * Which HTTP failures are worth a second attempt. 429 and 5xx are transient by
697
+ * definition; everything else is a statement about the request itself. Retrying
698
+ * a 401 just spends the budget three times to learn what the first attempt said,
699
+ * and retrying a 404 would fight the one degradation rule this module allows.
700
+ *
701
+ * @param {number} status
702
+ * @returns {boolean}
703
+ */
704
+ export function isRetryableStatus(status) {
705
+ return status === 429 || (status >= 500 && status <= 599);
706
+ }
707
+
708
+ /**
709
+ * @param {number} ms
710
+ * @returns {Promise<void>}
711
+ */
712
+ function sleep(ms) {
713
+ return new Promise((resolve) => setTimeout(resolve, ms));
714
+ }
715
+
716
+ /**
717
+ * Fetch with a deadline that does not depend on the fetch honouring it.
718
+ *
719
+ * An `AbortSignal` alone is a REQUEST to stop, and it is only as good as the
720
+ * implementation reading it — a stub, a polyfill, or a wrapper that rebuilds
721
+ * `init` can drop `init.signal` without any error, and the await then never
722
+ * returns. So the signal is passed (real `fetch` uses it to release the socket)
723
+ * AND raced against a timer, and the timer is what actually bounds the call.
724
+ *
725
+ * @param {typeof fetch} fetchImpl
726
+ * @param {string} url
727
+ * @param {RequestInit} init
728
+ * @param {number} timeoutMs
729
+ * @returns {Promise<Response>}
730
+ */
731
+ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
732
+ const controller = new AbortController();
733
+ let timer = null;
734
+ const deadline = new Promise((_resolve, reject) => {
735
+ timer = setTimeout(() => {
736
+ controller.abort();
737
+ const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} timed out after ${timeoutMs}ms`);
738
+ err.timedOut = true;
739
+ reject(err);
740
+ }, timeoutMs);
741
+ });
742
+ // Resolve.then keeps a fetchImpl that THROWS synchronously on the same
743
+ // rejection path as one that returns a rejected promise.
744
+ const pending = Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal }));
745
+ // The loser of the race still settles. Absorbing its rejection here is what
746
+ // keeps a post-timeout abort from surfacing as an unhandled rejection and
747
+ // tearing down a process that has already handled the timeout.
748
+ pending.catch(() => {});
749
+ try {
750
+ return await Promise.race([pending, deadline]);
751
+ } finally {
752
+ if (timer !== null) clearTimeout(timer);
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Perform one bounded, retried request and return the raw `Response`.
758
+ *
759
+ * Any HTTP failure is tagged with `.httpStatus`, so a caller can apply the
760
+ * 404-only degradation rule (see the module docblock).
761
+ *
762
+ * **A timeout is never retried.** Retrying it would multiply the wall clock by
763
+ * the attempt count, and the surface's whole contract is that it fails within
764
+ * its budget rather than eventually.
671
765
  *
672
766
  * @param {typeof fetch} fetchImpl
673
767
  * @param {string} url
674
768
  * @param {RequestInit} [init]
675
- * @returns {Promise<unknown>}
769
+ * @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
770
+ * @returns {Promise<Response>}
676
771
  */
677
- export async function requestJson(fetchImpl, url, init = {}) {
678
- const res = await fetchImpl(url, init);
679
- if (!res.ok) {
772
+ export async function requestResponse(fetchImpl, url, init = {}, options = {}) {
773
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
774
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
775
+ const maxAttempts = options.maxAttempts ?? MAX_ATTEMPTS;
776
+
777
+ let lastError = null;
778
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
779
+ let res;
780
+ try {
781
+ res = await fetchWithTimeout(fetchImpl, url, init, timeoutMs);
782
+ } catch (err) {
783
+ if (err?.timedOut) throw err;
784
+ lastError = err;
785
+ if (attempt === maxAttempts) throw err;
786
+ await sleep(retryDelayMs * 2 ** (attempt - 1));
787
+ continue;
788
+ }
789
+ if (res.ok) return res;
680
790
  // The body can echo request context; it is never a secret VALUE (these are
681
791
  // name-listing endpoints), but it is also not needed — the status is what
682
792
  // routes the decision, so only the status and a redacted URL are surfaced.
683
793
  const err = new Error(`${init.method ?? "GET"} ${redactUrl(url)} failed: ${res.status} ${res.statusText}`);
684
794
  err.httpStatus = res.status;
685
- throw err;
795
+ lastError = err;
796
+ if (!isRetryableStatus(res.status) || attempt === maxAttempts) throw err;
797
+ await sleep(retryDelayMs * 2 ** (attempt - 1));
686
798
  }
799
+ /* c8 ignore next 2 -- unreachable: every loop exit above returns or throws. */
800
+ throw lastError ?? new Error(`${redactUrl(url)} failed with no attempt recorded`);
801
+ }
802
+
803
+ /**
804
+ * `requestResponse`, decoded as JSON — what every non-paginating call wants.
805
+ *
806
+ * @param {typeof fetch} fetchImpl
807
+ * @param {string} url
808
+ * @param {RequestInit} [init]
809
+ * @param {{timeoutMs?: number, retryDelayMs?: number, maxAttempts?: number}} [options]
810
+ * @returns {Promise<unknown>}
811
+ */
812
+ export async function requestJson(fetchImpl, url, init = {}, options = {}) {
813
+ const res = await requestResponse(fetchImpl, url, init, options);
687
814
  return res.json();
688
815
  }
689
816
 
817
+ /**
818
+ * The `rel="next"` URL of an RFC 8288 `Link` header, or `null`.
819
+ *
820
+ * Parsed by splitting rather than by regex, deliberately twice over: a pattern
821
+ * over a header carrying a URL is the shape CodeQL flags as an unanchored host
822
+ * match, and the grammar here — comma-separated `<uri>; param=value` — is
823
+ * cleanly separable without one.
824
+ *
825
+ * @param {string | null | undefined} header
826
+ * @returns {string | null}
827
+ */
828
+ export function linkNextUrl(header) {
829
+ if (typeof header !== "string" || header.length === 0) return null;
830
+ for (const part of header.split(",")) {
831
+ const segment = part.trim();
832
+ if (!segment.startsWith("<")) continue;
833
+ const close = segment.indexOf(">");
834
+ if (close === -1) continue;
835
+ const url = segment.slice(1, close);
836
+ for (const param of segment.slice(close + 1).split(";")) {
837
+ const [rawName, ...rest] = param.split("=");
838
+ if (rawName.trim().toLowerCase() !== "rel") continue;
839
+ let value = rest.join("=").trim();
840
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
841
+ if (value.toLowerCase() === "next" && url.length > 0) return url;
842
+ }
843
+ }
844
+ return null;
845
+ }
846
+
690
847
  /**
691
848
  * Strip the query string from a URL before it reaches a log line. Query
692
849
  * parameters carry project ids and environment slugs, and a future caller
@@ -718,8 +875,9 @@ export function isAbsentStatus(err) {
718
875
 
719
876
  /**
720
877
  * Parse a dotenv-style file into a name -> value map. Used for the local
721
- * surface; values are read (a local `.env` is already on the developer's disk)
722
- * but only names cross the boundary unless the shape stage asks.
878
+ * surface, whose only input is the committed `.env.example` — a placeholder
879
+ * file by construction. Values are parsed because the format has them, and
880
+ * only names cross the boundary.
723
881
  *
724
882
  * @param {string} text
725
883
  * @returns {Record<string, string>}
@@ -745,26 +903,98 @@ export function parseDotenv(text) {
745
903
  return out;
746
904
  }
747
905
 
906
+ /**
907
+ * Reduce a JSONC document to JSON: drop `//` and block comments, drop trailing
908
+ * commas, and leave everything inside a string literal untouched.
909
+ *
910
+ * A character scanner rather than a substitution, for two independent reasons.
911
+ * The correctness one: a pattern cannot tell a `//` that opens a comment from
912
+ * one inside `"https://example.test"`, and the line-comment substitution this
913
+ * replaces truncated exactly that value — quietly, since the result usually
914
+ * still parsed. The policy one: this repo's SAST refuses a dynamically built
915
+ * `RegExp` outright, so the parsing rules a config like this needs are written
916
+ * as code or not at all.
917
+ *
918
+ * `wrangler.jsonc` is a real shape, not a hypothetical: create-cloudflare's own
919
+ * template emits trailing commas, and until Story #487 every one of them made
920
+ * this function return an empty set — which the caller then read as "this
921
+ * worker declares no vars", the false no-drift verdict.
922
+ *
923
+ * @param {string} text
924
+ * @returns {string}
925
+ */
926
+ export function stripJsonc(text) {
927
+ let out = "";
928
+ let inString = false;
929
+ for (let i = 0; i < text.length; i += 1) {
930
+ const ch = text[i];
931
+ if (inString) {
932
+ out += ch;
933
+ if (ch === "\\") {
934
+ out += text[i + 1] ?? "";
935
+ i += 1;
936
+ continue;
937
+ }
938
+ if (ch === '"') inString = false;
939
+ continue;
940
+ }
941
+ if (ch === '"') {
942
+ inString = true;
943
+ out += ch;
944
+ continue;
945
+ }
946
+ if (ch === "/" && text[i + 1] === "/") {
947
+ while (i < text.length && text[i] !== "\n") i += 1;
948
+ // Keep the newline: JSON ignores it, but a preserved line count keeps a
949
+ // `JSON.parse` position error pointing at the author's own line.
950
+ out += "\n";
951
+ continue;
952
+ }
953
+ if (ch === "/" && text[i + 1] === "*") {
954
+ i += 2;
955
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) {
956
+ if (text[i] === "\n") out += "\n";
957
+ i += 1;
958
+ }
959
+ i += 1;
960
+ continue;
961
+ }
962
+ if (ch === ",") {
963
+ let j = i + 1;
964
+ while (j < text.length && (text[j] === " " || text[j] === "\t" || text[j] === "\n" || text[j] === "\r")) j += 1;
965
+ if (text[j] === "}" || text[j] === "]") continue;
966
+ }
967
+ out += ch;
968
+ }
969
+ return out;
970
+ }
971
+
748
972
  /**
749
973
  * Extract the `[vars]` block key names from a wrangler config. Supports both
750
974
  * TOML (`[vars]` / `[env.<name>.vars]`) and JSON/JSONC (`"vars": {…}`) — the
751
975
  * two shapes wrangler accepts.
752
976
  *
977
+ * **Throws** when a `.json`/`.jsonc` config cannot be parsed even after the
978
+ * JSONC reduction. Returning `[]` there — as this did until Story #487 — is
979
+ * indistinguishable from a config that genuinely declares nothing, so the
980
+ * caller reported no drift precisely because it could not read the file. The
981
+ * caller turns the throw into one `fail` finding on the `wrangler` surface.
982
+ *
753
983
  * @param {string} text
754
984
  * @param {string} path Used only to pick the parser by extension.
755
985
  * @returns {string[]} Sorted var names.
986
+ * @throws {Error} With `.wranglerParseFailure === true` on unparseable JSONC.
756
987
  */
757
988
  export function parseWranglerVars(text, path) {
758
989
  const names = new Set();
759
990
  if (/\.jsonc?$/.test(path)) {
760
- // Strip line comments so JSONC parses; block comments are not used by
761
- // wrangler's own generated configs.
762
- const stripped = text.replace(/^\s*\/\/.*$/gm, "");
763
991
  let doc;
764
992
  try {
765
- doc = JSON.parse(stripped);
766
- } catch {
767
- return [];
993
+ doc = JSON.parse(stripJsonc(text));
994
+ } catch (err) {
995
+ const failure = new Error(`is not parseable as JSON/JSONC even after comment and trailing-comma removal: ${err.message}`);
996
+ failure.wranglerParseFailure = true;
997
+ throw failure;
768
998
  }
769
999
  collectJsonVars(doc, names);
770
1000
  return [...names].sort();
@@ -809,41 +1039,73 @@ function collectJsonVars(node, names) {
809
1039
  * access to either collection, so the default token can never serve this probe
810
1040
  * (verified against the workflow-syntax permissions reference, 2026-09).
811
1041
  *
1042
+ * `per_page=100` bounds a PAGE, not the collection. A repository with more
1043
+ * than a hundred Actions secrets returns the first hundred and a `Link` header
1044
+ * naming the rest, and reading only page one reports every name beyond it as
1045
+ * `missing` while every genuine orphan past the boundary goes unseen — drift
1046
+ * invented and drift hidden by the same omission. So every listing follows
1047
+ * `rel="next"` to completion, and a listing that will not end fails closed.
1048
+ *
812
1049
  * @param {object} opts
813
1050
  * @param {string} opts.token
814
1051
  * @param {string} opts.repo "owner/name"
815
1052
  * @param {typeof fetch} [opts.fetchImpl]
816
1053
  * @param {string} [opts.apiBase]
1054
+ * @param {number} [opts.timeoutMs]
1055
+ * @param {number} [opts.retryDelayMs]
817
1056
  */
818
- export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = GITHUB_API_BASE }) {
1057
+ export function createGitHubClient({
1058
+ token,
1059
+ repo,
1060
+ fetchImpl = fetch,
1061
+ apiBase = GITHUB_API_BASE,
1062
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1063
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
1064
+ }) {
819
1065
  const headers = {
820
1066
  Authorization: `Bearer ${token}`,
821
1067
  Accept: "application/vnd.github+json",
822
1068
  "X-GitHub-Api-Version": "2022-11-28",
823
1069
  };
824
- const get = (path) => requestJson(fetchImpl, `${apiBase}${path}`, { headers });
1070
+ const bounds = { timeoutMs, retryDelayMs };
1071
+
1072
+ /**
1073
+ * @param {string} path
1074
+ * @param {(body: any) => Array<{name: string}>} pick
1075
+ * @returns {Promise<string[]>}
1076
+ */
1077
+ async function listAll(path, pick) {
1078
+ const names = [];
1079
+ let url = `${apiBase}${path}`;
1080
+ for (let page = 0; page < MAX_PAGES; page += 1) {
1081
+ const res = await requestResponse(fetchImpl, url, { headers }, bounds);
1082
+ const body = await res.json();
1083
+ for (const entry of pick(body) ?? []) if (entry?.name) names.push(entry.name);
1084
+ const next = linkNextUrl(typeof res.headers?.get === "function" ? res.headers.get("link") : null);
1085
+ if (!next) return names.sort();
1086
+ url = next;
1087
+ }
1088
+ throw new Error(`${redactUrl(`${apiBase}${path}`)} still offered a rel="next" after ${MAX_PAGES} pages`);
1089
+ }
1090
+
1091
+ /**
1092
+ * @param {string} prefix
1093
+ * @returns {Promise<{secret: string[], var: string[]}>}
1094
+ */
1095
+ async function namesUnder(prefix) {
1096
+ const [secret, vars] = await Promise.all([
1097
+ listAll(`${prefix}/secrets?per_page=100`, (b) => b.secrets ?? []),
1098
+ listAll(`${prefix}/variables?per_page=100`, (b) => b.variables ?? []),
1099
+ ]);
1100
+ return { secret, var: vars };
1101
+ }
825
1102
 
826
1103
  return {
827
- async repositoryNames() {
828
- const [secrets, variables] = await Promise.all([
829
- get(`/repos/${repo}/actions/secrets?per_page=100`),
830
- get(`/repos/${repo}/actions/variables?per_page=100`),
831
- ]);
832
- return {
833
- secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
834
- var: (variables.variables ?? []).map((v) => v.name).sort(),
835
- };
1104
+ repositoryNames() {
1105
+ return namesUnder(`/repos/${repo}/actions`);
836
1106
  },
837
- async environmentNames(environment) {
838
- const env = encodeURIComponent(environment);
839
- const [secrets, variables] = await Promise.all([
840
- get(`/repos/${repo}/environments/${env}/secrets?per_page=100`),
841
- get(`/repos/${repo}/environments/${env}/variables?per_page=100`),
842
- ]);
843
- return {
844
- secret: (secrets.secrets ?? []).map((s) => s.name).sort(),
845
- var: (variables.variables ?? []).map((v) => v.name).sort(),
846
- };
1107
+ environmentNames(environment) {
1108
+ return namesUnder(`/repos/${repo}/environments/${encodeURIComponent(environment)}`);
847
1109
  },
848
1110
  };
849
1111
  }
@@ -857,21 +1119,71 @@ export function createGitHubClient({ token, repo, fetchImpl = fetch, apiBase = G
857
1119
  * @param {string} opts.accountId
858
1120
  * @param {typeof fetch} [opts.fetchImpl]
859
1121
  * @param {string} [opts.apiBase]
1122
+ * @param {number} [opts.timeoutMs]
1123
+ * @param {number} [opts.retryDelayMs]
860
1124
  */
861
- export function createCloudflareClient({ token, accountId, fetchImpl = fetch, apiBase = CLOUDFLARE_API_BASE }) {
1125
+ export function createCloudflareClient({
1126
+ token,
1127
+ accountId,
1128
+ fetchImpl = fetch,
1129
+ apiBase = CLOUDFLARE_API_BASE,
1130
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1131
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
1132
+ }) {
862
1133
  const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
1134
+ const bounds = { timeoutMs, retryDelayMs };
863
1135
  return {
864
1136
  async secretNames(scriptName) {
865
1137
  const body = await requestJson(
866
1138
  fetchImpl,
867
1139
  `${apiBase}/accounts/${encodeURIComponent(accountId)}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`,
868
- { headers }
1140
+ { headers },
1141
+ bounds
869
1142
  );
870
1143
  return (body.result ?? []).map((s) => s.name).sort();
871
1144
  },
872
1145
  };
873
1146
  }
874
1147
 
1148
+ /**
1149
+ * Flatten a v4 secrets response into the secrets RESIDENT AT THE REQUESTED
1150
+ * FOLDER — its own entries plus everything reaching it through an import.
1151
+ *
1152
+ * The v4 list endpoint answers in two parts: `secrets[]` holds what the queried
1153
+ * folder defines itself, and a separate top-level `imports[]` holds one group
1154
+ * per import, each carrying the SOURCE folder in `secretPath`. Reading only the
1155
+ * first part is what made a folder that imports its whole content report every
1156
+ * key `missing` — the store had the secret, the Worker would resolve it, and
1157
+ * the doctor said it was absent.
1158
+ *
1159
+ * `imports[].secretPath` is deliberately DISCARDED rather than used to
1160
+ * re-attribute the name. The manifest declares where a key must be RESOLVABLE,
1161
+ * which is the folder the deploy reads; attributing an imported key back to
1162
+ * `/shared` would report it missing from the folder that legitimately resolves
1163
+ * it and orphaned in a folder the manifest never asked about.
1164
+ *
1165
+ * A name defined directly in the queried folder wins over an imported one of
1166
+ * the same name, matching Infisical's own precedence — so the shape stage
1167
+ * checks the value the deploy would actually see.
1168
+ *
1169
+ * @param {unknown} body
1170
+ * @returns {Array<{secretKey: string, secretValue?: string}>}
1171
+ */
1172
+ export function collectInfisicalSecrets(body) {
1173
+ const merged = new Map();
1174
+ const add = (entry) => {
1175
+ if (entry && typeof entry.secretKey === "string" && entry.secretKey.length > 0) {
1176
+ merged.set(entry.secretKey, entry);
1177
+ }
1178
+ };
1179
+ const imports = Array.isArray(body?.imports) ? body.imports : [];
1180
+ for (const group of imports) {
1181
+ for (const entry of Array.isArray(group?.secrets) ? group.secrets : []) add(entry);
1182
+ }
1183
+ for (const entry of Array.isArray(body?.secrets) ? body.secrets : []) add(entry);
1184
+ return [...merged.values()];
1185
+ }
1186
+
875
1187
  /**
876
1188
  * Infisical client. Authenticates with a pre-issued access token when one is
877
1189
  * supplied, otherwise with a Universal Auth machine identity.
@@ -891,6 +1203,8 @@ export function createCloudflareClient({ token, accountId, fetchImpl = fetch, ap
891
1203
  * @param {string} opts.projectId
892
1204
  * @param {string} [opts.siteUrl]
893
1205
  * @param {typeof fetch} [opts.fetchImpl]
1206
+ * @param {number} [opts.timeoutMs]
1207
+ * @param {number} [opts.retryDelayMs]
894
1208
  */
895
1209
  export function createInfisicalClient({
896
1210
  token = null,
@@ -899,17 +1213,25 @@ export function createInfisicalClient({
899
1213
  projectId,
900
1214
  siteUrl = INFISICAL_DEFAULT_SITE,
901
1215
  fetchImpl = fetch,
1216
+ timeoutMs = DEFAULT_TIMEOUT_MS,
1217
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
902
1218
  }) {
903
1219
  const base = siteUrl.replace(/\/+$/, "");
1220
+ const bounds = { timeoutMs, retryDelayMs };
904
1221
  let accessToken = token;
905
1222
 
906
1223
  async function auth() {
907
1224
  if (accessToken) return accessToken;
908
- const body = await requestJson(fetchImpl, `${base}/api/v1/auth/universal-auth/login`, {
909
- method: "POST",
910
- headers: { "Content-Type": "application/json" },
911
- body: JSON.stringify({ clientId, clientSecret }),
912
- });
1225
+ const body = await requestJson(
1226
+ fetchImpl,
1227
+ `${base}/api/v1/auth/universal-auth/login`,
1228
+ {
1229
+ method: "POST",
1230
+ headers: { "Content-Type": "application/json" },
1231
+ body: JSON.stringify({ clientId, clientSecret }),
1232
+ },
1233
+ bounds
1234
+ );
913
1235
  if (!body.accessToken) throw new Error("Infisical universal-auth login returned no accessToken");
914
1236
  accessToken = body.accessToken;
915
1237
  return accessToken;
@@ -922,11 +1244,19 @@ export function createInfisicalClient({
922
1244
  environment,
923
1245
  secretPath: folder || "/",
924
1246
  viewSecretValue: withValues ? "true" : "false",
1247
+ // Sent EXPLICITLY. The upstream default is documented as true, and a
1248
+ // default is not a contract — a server-side change to it would silently
1249
+ // hide every imported secret and report each one `missing`, which is the
1250
+ // false-drift twin of the false no-drift this module is built against.
1251
+ includeImports: "true",
925
1252
  });
926
- const body = await requestJson(fetchImpl, `${base}/api/v4/secrets?${params.toString()}`, {
927
- headers: { Authorization: `Bearer ${t}` },
928
- });
929
- return body.secrets ?? [];
1253
+ const body = await requestJson(
1254
+ fetchImpl,
1255
+ `${base}/api/v4/secrets?${params.toString()}`,
1256
+ { headers: { Authorization: `Bearer ${t}` } },
1257
+ bounds
1258
+ );
1259
+ return collectInfisicalSecrets(body);
930
1260
  }
931
1261
 
932
1262
  return {
@@ -954,17 +1284,35 @@ export function createInfisicalClient({
954
1284
  * parse: a reference can appear anywhere an expression can, including inside
955
1285
  * a `run:` block's shell, and a structural walk would miss those.
956
1286
  *
1287
+ * Two narrowings keep that reach from over-claiming, and each one had produced
1288
+ * a manifest key that had to exist for a variable that does not:
1289
+ *
1290
+ * - **A whole-line YAML comment is prose, not a reference.** The `#` line
1291
+ * documenting which secret a caller should pass is the single most common
1292
+ * place either token appears, and demanding a manifest entry for it makes
1293
+ * the doctor fail on its own documentation.
1294
+ * - **`vars` reached as a property of something else is not the `vars`
1295
+ * context.** `steps.build.outputs.vars.PROFILE` is a step output that
1296
+ * happens to be named `vars`; `\b` matched it, because a `.` is a word
1297
+ * boundary. The lookbehind refuses any match preceded by a `.` or an
1298
+ * identifier character, which is exactly the set of ways a longer path
1299
+ * can end just before this one starts.
1300
+ *
957
1301
  * @param {string} text
958
1302
  * @returns {{secrets: string[], vars: string[]}}
959
1303
  */
960
1304
  export function collectWorkflowReferences(text) {
961
1305
  const secrets = new Set();
962
1306
  const vars = new Set();
963
- const re = /\b(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)/g;
964
- let m = re.exec(text);
1307
+ const scannable = text
1308
+ .split(/\r?\n/)
1309
+ .map((line) => (line.trimStart().startsWith("#") ? "" : line))
1310
+ .join("\n");
1311
+ const re = /(?<![\w.])(secrets|vars)\.([A-Za-z_][A-Za-z0-9_]*)/g;
1312
+ let m = re.exec(scannable);
965
1313
  while (m !== null) {
966
1314
  (m[1] === "secrets" ? secrets : vars).add(m[2]);
967
- m = re.exec(text);
1315
+ m = re.exec(scannable);
968
1316
  }
969
1317
  return { secrets: [...secrets].sort(), vars: [...vars].sort() };
970
1318
  }
@@ -1061,8 +1409,24 @@ export function runOfflineChecks({ manifest, repoRoot }) {
1061
1409
  });
1062
1410
  continue;
1063
1411
  }
1412
+ let present;
1413
+ try {
1414
+ present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
1415
+ } catch (err) {
1416
+ // One finding, and the worker's expected/orphan reconciliation is
1417
+ // skipped entirely: reporting every declared var as `missing` from a
1418
+ // file nobody could read blames the manifest for the config's syntax.
1419
+ findings.push({
1420
+ severity: "fail",
1421
+ kind: "unreadable",
1422
+ key: null,
1423
+ surface: "wrangler",
1424
+ environment: null,
1425
+ detail: `manifest.workers["${id}"].config ${worker.config} ${err.message}`,
1426
+ });
1427
+ continue;
1428
+ }
1064
1429
  checked.push(`wrangler:${id}`);
1065
- const present = new Set(parseWranglerVars(readFileSync(configPath, "utf8"), configPath));
1066
1430
  const expected = manifest.keys.filter(
1067
1431
  // Environment-agnostic by design: this check reports `environment: null`
1068
1432
  // and `parseWranglerVars` flattens `[env.X.vars]` into one set, so there
@@ -1170,6 +1534,13 @@ export function reconcileNames({ expected, present, surface, environment, scope
1170
1534
  * lapsing quietly would re-raise a finding the operator already chose to defer
1171
1535
  * without anyone noticing the deferral had run out.
1172
1536
  *
1537
+ * `severity` selects WHICH finding an entry silences and defaults to `"fail"`.
1538
+ * The `"orphan"` form exists because the alternative was worse: the only way
1539
+ * to silence one known orphan under `--strict-orphans` was to add a manifest
1540
+ * key for a secret the project does not actually declare, which buys quiet by
1541
+ * making the manifest lie — and a lying manifest is the exact false no-drift
1542
+ * this whole module refuses. An orphan exception still expires on its date.
1543
+ *
1173
1544
  * @param {unknown} raw
1174
1545
  * @returns {object[]}
1175
1546
  */
@@ -1186,10 +1557,18 @@ export function parseExceptions(raw) {
1186
1557
  if (Number.isNaN(Date.parse(`${revisit}T00:00:00Z`))) {
1187
1558
  throw new Error(`exceptions[${i}] ("${e.key}") has an unparseable revisit-date "${revisit}"`);
1188
1559
  }
1560
+ const severity = e.severity ?? "fail";
1561
+ if (severity !== "fail" && severity !== "orphan") {
1562
+ throw new Error(
1563
+ `exceptions[${i}] ("${e.key}").severity must be "fail" or "orphan" — got "${severity}". ` +
1564
+ `Omit it to default to "fail".`
1565
+ );
1566
+ }
1189
1567
  return {
1190
1568
  key: e.key,
1191
1569
  surface: e.surface ?? null,
1192
1570
  environment: e.environment ?? null,
1571
+ severity,
1193
1572
  reason: typeof e.reason === "string" ? e.reason : "",
1194
1573
  revisitDate: revisit,
1195
1574
  };
@@ -1216,10 +1595,11 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
1216
1595
  const match = active.find(
1217
1596
  (e) =>
1218
1597
  e.key === f.key &&
1598
+ e.severity === f.severity &&
1219
1599
  (e.surface === null || e.surface === f.surface) &&
1220
1600
  (e.environment === null || e.environment === f.environment)
1221
1601
  );
1222
- if (match && f.severity === "fail") {
1602
+ if (match) {
1223
1603
  suppressed.push({ ...f, exception: match });
1224
1604
  } else {
1225
1605
  kept.push(f);
@@ -1232,6 +1612,48 @@ export function applyExceptions({ findings, exceptions, now = new Date() }) {
1232
1612
  // Exit contract
1233
1613
  // ---------------------------------------------------------------------------
1234
1614
 
1615
+ /**
1616
+ * Resolve the environments to check, failing CLOSED on one the manifest does
1617
+ * not declare.
1618
+ *
1619
+ * Nothing downstream can catch a misspelling. `--environments prodcution`
1620
+ * against a manifest declaring `production` narrows every reconcile to a slug
1621
+ * no key claims, so `expected` is empty everywhere, the stores are asked for
1622
+ * folders and environments that do not exist, and the run exits 0 with every
1623
+ * surface `checked` and zero findings — the most convincing possible report
1624
+ * that nothing is wrong, produced by a run that examined nothing. A typo in a
1625
+ * cron-scheduled workflow input can hold that state indefinitely.
1626
+ *
1627
+ * An empty request is not an error: it means "use the manifest's own list",
1628
+ * which is exactly what the reusable workflow's empty `environments` input
1629
+ * interpolates to.
1630
+ *
1631
+ * @param {object} opts
1632
+ * @param {string | null | undefined} opts.requested Raw comma-separated CLI/input value.
1633
+ * @param {{environments: string[]}} opts.manifest
1634
+ * @returns {string[]}
1635
+ * @throws {Error} When a requested slug is not in `manifest.environments`.
1636
+ */
1637
+ export function resolveEnvironments({ requested, manifest }) {
1638
+ const wanted =
1639
+ typeof requested === "string"
1640
+ ? requested
1641
+ .split(",")
1642
+ .map((slug) => slug.trim())
1643
+ .filter(Boolean)
1644
+ : [];
1645
+ if (wanted.length === 0) return manifest.environments;
1646
+ const unknown = wanted.filter((slug) => !manifest.environments.includes(slug));
1647
+ if (unknown.length > 0) {
1648
+ throw new Error(
1649
+ `--environments requested ${unknown.map((slug) => `"${slug}"`).join(", ")}, which manifest.environments ` +
1650
+ `does not declare. Declared environments: ${manifest.environments.join(", ")}. ` +
1651
+ `Nothing was probed — an undeclared environment would report zero findings on every surface.`
1652
+ );
1653
+ }
1654
+ return wanted;
1655
+ }
1656
+
1235
1657
  /**
1236
1658
  * Decide the process exit code from a completed report.
1237
1659
  *
@@ -1292,7 +1714,8 @@ export function renderReport(report) {
1292
1714
  lines.push("Suppressed by an active exception:");
1293
1715
  for (const s of report.suppressed) {
1294
1716
  lines.push(
1295
- ` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}] — revisit ${s.exception.revisitDate}${s.exception.reason ? `: ${s.exception.reason}` : ""}`
1717
+ ` - ${s.key} [${s.surface}${s.environment ? `/${s.environment}` : ""}] (${s.severity}) — ` +
1718
+ `revisit ${s.exception.revisitDate}${s.exception.reason ? `: ${s.exception.reason}` : ""}`
1296
1719
  );
1297
1720
  }
1298
1721
  }
@@ -1873,12 +2296,13 @@ async function main() {
1873
2296
  }
1874
2297
  }
1875
2298
 
1876
- const environments = opts.environments
1877
- ? opts.environments
1878
- .split(",")
1879
- .map((s) => s.trim())
1880
- .filter(Boolean)
1881
- : manifest.environments;
2299
+ let environments;
2300
+ try {
2301
+ environments = resolveEnvironments({ requested: opts.environments, manifest });
2302
+ } catch (err) {
2303
+ process.stderr.write(`[env-doctor] ERROR: ${err.message}\n`);
2304
+ process.exit(1);
2305
+ }
1882
2306
 
1883
2307
  // Offline builds no clients at all; `runDoctor` marks the live surfaces
1884
2308
  // skipped-because-offline rather than reaching for an unavailability reason.
@@ -1906,12 +2330,19 @@ async function main() {
1906
2330
  process.stdout.write(renderReport(report));
1907
2331
  }
1908
2332
 
2333
+ // Annotations go to STDERR in every mode, not just under `--json`. Actions
2334
+ // reads workflow commands from both streams, so nothing is lost — but stdout
2335
+ // is the machine channel, and `--json` promising one JSON document while
2336
+ // appending `::notice` lines to it made `JSON.parse(stdout)` throw for every
2337
+ // consumer whose run had an unchecked surface, which is most of them.
2338
+ // Splitting by mode would leave the text mode's stdout un-pipeable for the
2339
+ // same reason, so the rule is unconditional.
1909
2340
  for (const s of report.surfaces) {
1910
2341
  if (s.status === "unchecked") {
1911
- process.stdout.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
2342
+ process.stderr.write(`::notice title=env-doctor surface unchecked::${s.surface}: ${s.notice}\n`);
1912
2343
  }
1913
2344
  if (s.status === "error") {
1914
- process.stdout.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
2345
+ process.stderr.write(`::error title=env-doctor probe failed::${s.surface}: ${s.notice}\n`);
1915
2346
  }
1916
2347
  }
1917
2348