@secrefs/node 0.1.0 → 0.2.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/dist/secrefs.cjs CHANGED
@@ -24,24 +24,151 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // bin/secrefs.ts
27
- var import_node_fs = require("fs");
28
- var import_node_path2 = __toESM(require("path"), 1);
27
+ var import_node_fs2 = require("fs");
28
+ var import_node_path3 = __toESM(require("path"), 1);
29
29
  var import_commander = require("commander");
30
30
  var import_cross_spawn = __toESM(require("cross-spawn"), 1);
31
31
 
32
32
  // src/providers/aws.ts
33
33
  var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
34
34
 
35
+ // src/providers/errors.ts
36
+ var AUTH_NAMES = /* @__PURE__ */ new Set([
37
+ "CredentialsProviderError",
38
+ "TokenProviderError",
39
+ "ExpiredToken",
40
+ "ExpiredTokenException",
41
+ "InvalidClientTokenId",
42
+ "UnrecognizedClientException",
43
+ "InvalidIdentityToken",
44
+ "AuthFailure",
45
+ "SSOTokenProviderFailure"
46
+ ]);
47
+ var NOT_FOUND_NAMES = /* @__PURE__ */ new Set([
48
+ "ResourceNotFoundException",
49
+ "NoSuchEntity",
50
+ "SecretNotFound"
51
+ ]);
52
+ var DENIED_NAMES = /* @__PURE__ */ new Set([
53
+ "AccessDeniedException",
54
+ "AccessDenied",
55
+ "AuthorizationError",
56
+ "UnauthorizedOperation"
57
+ ]);
58
+ var TRANSIENT_NAMES = /* @__PURE__ */ new Set([
59
+ "TimeoutError",
60
+ "NetworkingError",
61
+ "RequestTimeout",
62
+ "RequestTimeoutException",
63
+ "ThrottlingException",
64
+ "TooManyRequestsException",
65
+ "InternalServiceError",
66
+ "InternalServerError",
67
+ "ServiceUnavailable",
68
+ "ServiceUnavailableException",
69
+ "AbortError",
70
+ "ECONNRESET",
71
+ "ECONNREFUSED",
72
+ "ETIMEDOUT",
73
+ "EAI_AGAIN"
74
+ ]);
75
+ var AUTH_FRAGMENTS = [
76
+ "could not load credentials",
77
+ "sso session associated with this profile has expired",
78
+ "security token included in the request is expired",
79
+ "unable to locate credentials",
80
+ "token is expired",
81
+ "credentials have expired",
82
+ "is expired"
83
+ ];
84
+ var TRANSIENT_FRAGMENTS = [
85
+ "socket hang up",
86
+ "network error",
87
+ "timed out",
88
+ "timeout",
89
+ "econnreset",
90
+ "econnrefused",
91
+ "getaddrinfo"
92
+ ];
93
+ function nameOf(err) {
94
+ if (typeof err !== "object" || err === null) return "";
95
+ const e = err;
96
+ for (const candidate of [e.name, e.code, e.__type]) {
97
+ if (typeof candidate === "string" && candidate) return candidate;
98
+ }
99
+ return "";
100
+ }
101
+ function statusOf(err) {
102
+ if (typeof err !== "object" || err === null) return void 0;
103
+ const meta = err.$metadata;
104
+ if (typeof meta?.httpStatusCode === "number") return meta.httpStatusCode;
105
+ const status = err.status ?? err.statusCode;
106
+ return typeof status === "number" ? status : void 0;
107
+ }
108
+ function classifyError(err) {
109
+ const name = nameOf(err);
110
+ if (AUTH_NAMES.has(name)) return "auth";
111
+ if (NOT_FOUND_NAMES.has(name)) return "not_found";
112
+ if (DENIED_NAMES.has(name)) return "denied";
113
+ if (TRANSIENT_NAMES.has(name)) return "transient";
114
+ const status = statusOf(err);
115
+ if (status === 401) return "auth";
116
+ if (status === 403) return "denied";
117
+ if (status === 404) return "not_found";
118
+ if (status === 408 || status === 429) return "transient";
119
+ if (status !== void 0 && status >= 500) return "transient";
120
+ const message = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase();
121
+ if (AUTH_FRAGMENTS.some((f) => message.includes(f))) return "auth";
122
+ if (TRANSIENT_FRAGMENTS.some((f) => message.includes(f))) return "transient";
123
+ return "unknown";
124
+ }
125
+ function isStaleServable(kind) {
126
+ return kind === "transient";
127
+ }
128
+ function remedyFor(kind, provider, err) {
129
+ if (kind !== "auth") return void 0;
130
+ const message = err instanceof Error ? err.message : "";
131
+ if (/sso session/i.test(message)) {
132
+ const profile = process.env.AWS_PROFILE;
133
+ return profile ? `Run: aws sso login --profile ${profile}` : "Run: aws sso login --profile <your-profile>";
134
+ }
135
+ switch (provider) {
136
+ case "aws": {
137
+ const profile = process.env.AWS_PROFILE;
138
+ return profile ? `Check credentials for AWS profile "${profile}" - if it uses SSO, run: aws sso login --profile ${profile}` : "No AWS credentials found. Set AWS_PROFILE, export static keys, or attach an instance role.";
139
+ }
140
+ case "bitwarden":
141
+ return "Set BWS_ACCESS_TOKEN to a valid Bitwarden machine account token.";
142
+ case "vault":
143
+ return "Set VAULT_ADDR and VAULT_TOKEN, or renew the token if it has expired.";
144
+ default:
145
+ return void 0;
146
+ }
147
+ }
148
+
35
149
  // src/providers/base.ts
36
150
  var SecretFetchError = class extends Error {
37
151
  constructor(provider, path3, cause) {
38
- super(`[${provider}] failed to fetch secret at "${path3}": ${errorMessage(cause)}`);
152
+ const kind = classifyError(cause);
153
+ super(
154
+ kind === "auth" ? `[${provider}] cannot authenticate: ${errorMessage(cause)}` : `[${provider}] failed to fetch secret at "${path3}": ${errorMessage(cause)}`
155
+ );
39
156
  this.provider = provider;
40
157
  this.path = path3;
158
+ this.cause = cause;
41
159
  this.name = "SecretFetchError";
160
+ this.kind = kind;
161
+ this.remedy = remedyFor(kind, provider, cause);
42
162
  }
43
163
  provider;
44
164
  path;
165
+ cause;
166
+ /** Whose problem this is - see {@link SecretErrorKind}. Classified from
167
+ * `cause` so every existing throw site is categorised without having to
168
+ * know about categories. */
169
+ kind;
170
+ /** The action that fixes it, when there is one (auth failures). */
171
+ remedy;
45
172
  };
46
173
  var BaseSecretProvider = class {
47
174
  async fetchBatch(requests) {
@@ -86,6 +213,9 @@ function extractField(raw, field, context) {
86
213
  return typeof current === "object" ? JSON.stringify(current) : String(current);
87
214
  }
88
215
 
216
+ // src/providers/aws.ts
217
+ var import_credential_providers = require("@aws-sdk/credential-providers");
218
+
89
219
  // src/ttlCache.ts
90
220
  var TtlCache = class {
91
221
  /** Settled values, only populated when a TTL is configured. */
@@ -96,9 +226,15 @@ var TtlCache = class {
96
226
  * stays correct even with caching fully disabled. */
97
227
  inFlight = /* @__PURE__ */ new Map();
98
228
  ttlMs;
229
+ staleGraceMs;
230
+ isStaleServable;
231
+ onStale;
99
232
  now;
100
233
  constructor(options = {}) {
101
234
  this.ttlMs = options.ttlMs ?? 0;
235
+ this.staleGraceMs = options.staleGraceMs ?? 0;
236
+ this.isStaleServable = options.isStaleServable ?? (() => false);
237
+ this.onStale = options.onStale;
102
238
  this.now = options.now ?? Date.now;
103
239
  }
104
240
  /**
@@ -122,11 +258,19 @@ var TtlCache = class {
122
258
  this.inFlight.set(key, pending);
123
259
  try {
124
260
  const value = await pending;
125
- if (this.ttlMs > 0) {
261
+ if (this.ttlMs > 0 || this.staleGraceMs > 0) {
126
262
  this.entries.set(key, { value: Promise.resolve(value), storedAt: this.now() });
127
263
  }
128
264
  return value;
129
265
  } catch (err) {
266
+ const previous = this.entries.get(key);
267
+ if (previous && this.staleGraceMs > 0 && this.isStaleServable(err)) {
268
+ const ageMs = this.now() - previous.storedAt;
269
+ if (ageMs <= this.staleGraceMs) {
270
+ this.onStale?.(key, ageMs, err);
271
+ return previous.value;
272
+ }
273
+ }
130
274
  this.entries.delete(key);
131
275
  throw err;
132
276
  } finally {
@@ -190,6 +334,7 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
190
334
  name = "aws";
191
335
  explicitClient;
192
336
  region;
337
+ profile;
193
338
  controlPlane;
194
339
  controlPlaneClient;
195
340
  ambientClient = null;
@@ -198,8 +343,14 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
198
343
  super();
199
344
  this.explicitClient = options.client;
200
345
  this.region = options.region;
346
+ this.profile = options.profile;
201
347
  this.controlPlane = options.controlPlane;
202
- this.rawCache = new TtlCache({ ttlMs: options.cacheTtlMs });
348
+ this.rawCache = new TtlCache({
349
+ ttlMs: options.cacheTtlMs,
350
+ staleGraceMs: options.staleGraceMs,
351
+ isStaleServable: (err) => isStaleServable(classifyError(err)),
352
+ onStale: options.onStaleValue
353
+ });
203
354
  if (this.controlPlane) {
204
355
  this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
205
356
  }
@@ -218,7 +369,15 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
218
369
  }
219
370
  return this.buildClientFromMintedCredentials(minted.credentials);
220
371
  }
221
- if (!this.ambientClient) this.ambientClient = new import_client_secrets_manager.SecretsManagerClient({ region: this.region });
372
+ if (!this.ambientClient) {
373
+ this.ambientClient = new import_client_secrets_manager.SecretsManagerClient({
374
+ region: this.region,
375
+ // fromNodeProviderChain honours the same precedence as the
376
+ // ambient default, just pinned to one profile - so instance
377
+ // roles and env vars still work when no profile is named.
378
+ ...this.profile ? { credentials: (0, import_credential_providers.fromNodeProviderChain)({ profile: this.profile }) } : {}
379
+ });
380
+ }
222
381
  return this.ambientClient;
223
382
  }
224
383
  buildClientFromMintedCredentials(credentials) {
@@ -244,7 +403,8 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
244
403
  }
245
404
  throw new Error(`secret "${path3}" has no SecretString or SecretBinary payload`);
246
405
  } catch (err) {
247
- throw new Error(`could not fetch secret "${path3}": ${errorMessage(err)}`);
406
+ if (err instanceof SecretFetchError) throw err;
407
+ throw new SecretFetchError(this.name, path3, err);
248
408
  }
249
409
  });
250
410
  }
@@ -579,16 +739,37 @@ function parseSecretRef(raw) {
579
739
  }
580
740
 
581
741
  // src/resolver.ts
742
+ function formatFailures(errors) {
743
+ const auth = errors.filter((e) => e.kind === "auth");
744
+ const rest = errors.filter((e) => e.kind !== "auth");
745
+ const lines = [];
746
+ for (const provider of [...new Set(auth.map((e) => e.provider ?? "unknown"))]) {
747
+ const group = auth.filter((e) => (e.provider ?? "unknown") === provider);
748
+ lines.push(`Cannot authenticate to provider "${provider}".`);
749
+ lines.push(` ${group[0].message}`);
750
+ const remedy = group.find((e) => e.remedy)?.remedy;
751
+ if (remedy) lines.push(` ${remedy}`);
752
+ lines.push(` Not resolved: ${group.map((e) => e.key).join(", ")}`);
753
+ }
754
+ if (rest.length > 0) {
755
+ if (lines.length > 0) lines.push("");
756
+ lines.push(`Failed to resolve ${rest.length} secret reference(s):`);
757
+ for (const e of rest) lines.push(` - ${e.key}: ${e.ref} -> ${e.message}`);
758
+ }
759
+ return lines.join("\n");
760
+ }
582
761
  var SecRefsResolutionError = class extends Error {
583
762
  constructor(errors) {
584
- super(
585
- `Failed to resolve ${errors.length} secret reference(s):
586
- ` + errors.map((e) => ` - ${e.key}: ${e.ref} -> ${e.message}`).join("\n")
587
- );
763
+ super(formatFailures(errors));
588
764
  this.errors = errors;
589
765
  this.name = "SecRefsResolutionError";
590
766
  }
591
767
  errors;
768
+ /** True when every failure was an environment/auth problem, so a caller
769
+ * can tell "your credentials lapsed" from "your references are wrong". */
770
+ get isAuthOnly() {
771
+ return this.errors.length > 0 && this.errors.every((e) => e.kind === "auth");
772
+ }
592
773
  };
593
774
  async function resolveOne(ref, providers) {
594
775
  const provider = providers[ref.provider];
@@ -628,7 +809,15 @@ async function expandKeyValueMap(input, options) {
628
809
  if (result.status === "fulfilled") {
629
810
  output[key] = result.value;
630
811
  } else {
631
- errors.push({ key, ref: ref.raw, message: errorMessage(result.reason) });
812
+ const reason = result.reason;
813
+ errors.push({
814
+ key,
815
+ ref: ref.raw,
816
+ message: errorMessage(reason),
817
+ kind: reason instanceof SecretFetchError ? reason.kind : void 0,
818
+ provider: reason instanceof SecretFetchError ? reason.provider : ref.provider,
819
+ remedy: reason instanceof SecretFetchError ? reason.remedy : void 0
820
+ });
632
821
  }
633
822
  });
634
823
  if (errors.length > 0) {
@@ -693,6 +882,128 @@ function parseEnvFileText(rawText) {
693
882
  return recoverTruncatedSecRefs(rawText, parsed);
694
883
  }
695
884
 
885
+ // src/config.ts
886
+ var import_node_fs = require("fs");
887
+ var import_node_path2 = require("path");
888
+ var CONFIG_FILENAME = "secrefs.config.json";
889
+ var ConfigError = class extends Error {
890
+ constructor(message) {
891
+ super(message);
892
+ this.name = "ConfigError";
893
+ }
894
+ };
895
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
896
+ "token",
897
+ "accessToken",
898
+ "access_token",
899
+ "secret",
900
+ "secretKey",
901
+ "secretAccessKey",
902
+ "password",
903
+ "apiKey",
904
+ "credential",
905
+ "credentials"
906
+ ]);
907
+ function assertNoInlineSecrets(alias, config) {
908
+ for (const key of Object.keys(config)) {
909
+ if (FORBIDDEN_KEYS.has(key)) {
910
+ throw new ConfigError(
911
+ `${CONFIG_FILENAME}: provider "${alias}" sets "${key}". This file is meant to be committed and must never contain a credential. Reference the environment variable that holds it instead - e.g. "tokenEnv": "BWS_ACCESS_TOKEN".`
912
+ );
913
+ }
914
+ }
915
+ }
916
+ function requireEnv(alias, varName, env) {
917
+ const value = env[varName];
918
+ if (!value) {
919
+ throw new ConfigError(
920
+ `${CONFIG_FILENAME}: provider "${alias}" expects the credential in ${varName}, but that environment variable is not set.`
921
+ );
922
+ }
923
+ return value;
924
+ }
925
+ function parseConfig(raw, source = CONFIG_FILENAME) {
926
+ let parsed;
927
+ try {
928
+ parsed = JSON.parse(raw);
929
+ } catch (err) {
930
+ throw new ConfigError(`${source} is not valid JSON: ${err.message}`);
931
+ }
932
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
933
+ throw new ConfigError(`${source} must contain a JSON object.`);
934
+ }
935
+ const providers = parsed.providers;
936
+ if (typeof providers !== "object" || providers === null) {
937
+ throw new ConfigError(`${source} must have a "providers" object.`);
938
+ }
939
+ for (const [alias, value] of Object.entries(providers)) {
940
+ if (typeof value !== "object" || value === null) {
941
+ throw new ConfigError(`${source}: provider "${alias}" must be an object.`);
942
+ }
943
+ const type = value.type;
944
+ if (type !== "aws" && type !== "bitwarden" && type !== "vault" && type !== "local") {
945
+ throw new ConfigError(
946
+ `${source}: provider "${alias}" has type ${JSON.stringify(type)}; expected one of "aws", "bitwarden", "vault", "local".`
947
+ );
948
+ }
949
+ assertNoInlineSecrets(alias, value);
950
+ }
951
+ return parsed;
952
+ }
953
+ function buildProviders(config, options = {}) {
954
+ const env = options.env ?? process.env;
955
+ const configDir = options.configDir ?? process.cwd();
956
+ const registry = {};
957
+ for (const [alias, entry] of Object.entries(config.providers)) {
958
+ switch (entry.type) {
959
+ case "aws":
960
+ registry[alias] = new AwsSecretsManagerProvider({
961
+ region: entry.region,
962
+ cacheTtlMs: entry.cacheTtlMs,
963
+ staleGraceMs: entry.staleGraceMs,
964
+ profile: entry.profile
965
+ });
966
+ break;
967
+ case "bitwarden":
968
+ registry[alias] = new BitwardenProvider({
969
+ accessToken: requireEnv(alias, entry.tokenEnv ?? "BWS_ACCESS_TOKEN", env),
970
+ organizationId: entry.organizationIdEnv ? requireEnv(alias, entry.organizationIdEnv, env) : env.BWS_ORGANIZATION_ID,
971
+ apiUrl: entry.apiUrl,
972
+ identityUrl: entry.identityUrl
973
+ });
974
+ break;
975
+ case "vault":
976
+ registry[alias] = new VaultProvider({
977
+ endpoint: entry.addr ?? env.VAULT_ADDR,
978
+ token: requireEnv(alias, entry.tokenEnv ?? "VAULT_TOKEN", env)
979
+ });
980
+ break;
981
+ case "local":
982
+ registry[alias] = new LocalProvider({
983
+ filePath: entry.file ? (0, import_node_path2.resolve)(configDir, entry.file) : void 0
984
+ });
985
+ break;
986
+ }
987
+ }
988
+ return registry;
989
+ }
990
+ function loadConfigFrom(dir = process.cwd()) {
991
+ let current = (0, import_node_path2.resolve)(dir);
992
+ for (; ; ) {
993
+ const candidate = (0, import_node_path2.resolve)(current, CONFIG_FILENAME);
994
+ let raw;
995
+ try {
996
+ raw = (0, import_node_fs.readFileSync)(candidate, "utf8");
997
+ } catch {
998
+ const parent = (0, import_node_path2.dirname)(current);
999
+ if (parent === current) return void 0;
1000
+ current = parent;
1001
+ continue;
1002
+ }
1003
+ return { config: parseConfig(raw, candidate), path: candidate };
1004
+ }
1005
+ }
1006
+
696
1007
  // src/index.ts
697
1008
  function createDefaultProviders() {
698
1009
  return {
@@ -746,11 +1057,11 @@ var secRefs = new SecRefs();
746
1057
  var CLI_VERSION = "0.1.0";
747
1058
  function loadEnvFile(opts) {
748
1059
  if (opts.envFile === false) return;
749
- const envFilePath = import_node_path2.default.resolve(process.cwd(), opts.envFile);
750
- if (!(0, import_node_fs.existsSync)(envFilePath)) return;
1060
+ const envFilePath = import_node_path3.default.resolve(process.cwd(), opts.envFile);
1061
+ if (!(0, import_node_fs2.existsSync)(envFilePath)) return;
751
1062
  let rawText;
752
1063
  try {
753
- rawText = (0, import_node_fs.readFileSync)(envFilePath, "utf8");
1064
+ rawText = (0, import_node_fs2.readFileSync)(envFilePath, "utf8");
754
1065
  } catch (err) {
755
1066
  console.error(
756
1067
  `secrefs: failed to read ${envFilePath}: ${err instanceof Error ? err.message : String(err)}`
@@ -774,6 +1085,34 @@ function loadEnvFile(opts) {
774
1085
  }
775
1086
  }
776
1087
  }
1088
+ function buildSecRefs() {
1089
+ let found;
1090
+ try {
1091
+ found = loadConfigFrom();
1092
+ } catch (err) {
1093
+ if (err instanceof ConfigError) {
1094
+ console.error(`secrefs: ${err.message}`);
1095
+ process.exit(1);
1096
+ }
1097
+ throw err;
1098
+ }
1099
+ if (!found) return new SecRefs();
1100
+ let providers;
1101
+ try {
1102
+ providers = buildProviders(found.config, { configDir: import_node_path3.default.dirname(found.path) });
1103
+ } catch (err) {
1104
+ if (err instanceof ConfigError) {
1105
+ console.error(`secrefs: ${err.message}`);
1106
+ process.exit(1);
1107
+ }
1108
+ throw err;
1109
+ }
1110
+ const aliases = Object.keys(providers);
1111
+ console.error(
1112
+ `secrefs: using ${import_node_path3.default.relative(process.cwd(), found.path) || found.path} (${aliases.length} alias${aliases.length === 1 ? "" : "es"}: ${aliases.join(", ")})`
1113
+ );
1114
+ return new SecRefs({ providers });
1115
+ }
777
1116
  async function runCommand(commandArgs, opts) {
778
1117
  if (commandArgs.length === 0) {
779
1118
  console.error("secrefs run: no command given. Usage: secrefs run -- <command> [args...]");
@@ -785,7 +1124,7 @@ async function runCommand(commandArgs, opts) {
785
1124
  } catch {
786
1125
  return;
787
1126
  }
788
- const instance = new SecRefs();
1127
+ const instance = buildSecRefs();
789
1128
  try {
790
1129
  const changedKeys = await instance.init();
791
1130
  if (changedKeys.length > 0) {
@@ -795,7 +1134,11 @@ async function runCommand(commandArgs, opts) {
795
1134
  }
796
1135
  } catch (err) {
797
1136
  if (err instanceof SecRefsResolutionError) {
798
- console.error("secrefs: failed to resolve one or more secret references:");
1137
+ if (!err.isAuthOnly) {
1138
+ console.error("secrefs: failed to resolve one or more secret references:");
1139
+ } else {
1140
+ console.error("secrefs: could not authenticate to a secret provider.");
1141
+ }
799
1142
  console.error(err.message);
800
1143
  } else {
801
1144
  console.error(`secrefs: ${err instanceof Error ? err.message : String(err)}`);
@@ -839,7 +1182,7 @@ async function checkCommand(opts) {
839
1182
  } catch {
840
1183
  return;
841
1184
  }
842
- const instance = new SecRefs();
1185
+ const instance = buildSecRefs();
843
1186
  const results = await instance.check();
844
1187
  if (results.length === 0) {
845
1188
  console.log("secrefs check: no sec:// references found in the environment.");