@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/index.js CHANGED
@@ -5,16 +5,143 @@ import {
5
5
  SecretsManagerClient
6
6
  } from "@aws-sdk/client-secrets-manager";
7
7
 
8
+ // src/providers/errors.ts
9
+ var AUTH_NAMES = /* @__PURE__ */ new Set([
10
+ "CredentialsProviderError",
11
+ "TokenProviderError",
12
+ "ExpiredToken",
13
+ "ExpiredTokenException",
14
+ "InvalidClientTokenId",
15
+ "UnrecognizedClientException",
16
+ "InvalidIdentityToken",
17
+ "AuthFailure",
18
+ "SSOTokenProviderFailure"
19
+ ]);
20
+ var NOT_FOUND_NAMES = /* @__PURE__ */ new Set([
21
+ "ResourceNotFoundException",
22
+ "NoSuchEntity",
23
+ "SecretNotFound"
24
+ ]);
25
+ var DENIED_NAMES = /* @__PURE__ */ new Set([
26
+ "AccessDeniedException",
27
+ "AccessDenied",
28
+ "AuthorizationError",
29
+ "UnauthorizedOperation"
30
+ ]);
31
+ var TRANSIENT_NAMES = /* @__PURE__ */ new Set([
32
+ "TimeoutError",
33
+ "NetworkingError",
34
+ "RequestTimeout",
35
+ "RequestTimeoutException",
36
+ "ThrottlingException",
37
+ "TooManyRequestsException",
38
+ "InternalServiceError",
39
+ "InternalServerError",
40
+ "ServiceUnavailable",
41
+ "ServiceUnavailableException",
42
+ "AbortError",
43
+ "ECONNRESET",
44
+ "ECONNREFUSED",
45
+ "ETIMEDOUT",
46
+ "EAI_AGAIN"
47
+ ]);
48
+ var AUTH_FRAGMENTS = [
49
+ "could not load credentials",
50
+ "sso session associated with this profile has expired",
51
+ "security token included in the request is expired",
52
+ "unable to locate credentials",
53
+ "token is expired",
54
+ "credentials have expired",
55
+ "is expired"
56
+ ];
57
+ var TRANSIENT_FRAGMENTS = [
58
+ "socket hang up",
59
+ "network error",
60
+ "timed out",
61
+ "timeout",
62
+ "econnreset",
63
+ "econnrefused",
64
+ "getaddrinfo"
65
+ ];
66
+ function nameOf(err) {
67
+ if (typeof err !== "object" || err === null) return "";
68
+ const e = err;
69
+ for (const candidate of [e.name, e.code, e.__type]) {
70
+ if (typeof candidate === "string" && candidate) return candidate;
71
+ }
72
+ return "";
73
+ }
74
+ function statusOf(err) {
75
+ if (typeof err !== "object" || err === null) return void 0;
76
+ const meta = err.$metadata;
77
+ if (typeof meta?.httpStatusCode === "number") return meta.httpStatusCode;
78
+ const status = err.status ?? err.statusCode;
79
+ return typeof status === "number" ? status : void 0;
80
+ }
81
+ function classifyError(err) {
82
+ const name = nameOf(err);
83
+ if (AUTH_NAMES.has(name)) return "auth";
84
+ if (NOT_FOUND_NAMES.has(name)) return "not_found";
85
+ if (DENIED_NAMES.has(name)) return "denied";
86
+ if (TRANSIENT_NAMES.has(name)) return "transient";
87
+ const status = statusOf(err);
88
+ if (status === 401) return "auth";
89
+ if (status === 403) return "denied";
90
+ if (status === 404) return "not_found";
91
+ if (status === 408 || status === 429) return "transient";
92
+ if (status !== void 0 && status >= 500) return "transient";
93
+ const message = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase();
94
+ if (AUTH_FRAGMENTS.some((f) => message.includes(f))) return "auth";
95
+ if (TRANSIENT_FRAGMENTS.some((f) => message.includes(f))) return "transient";
96
+ return "unknown";
97
+ }
98
+ function isStaleServable(kind) {
99
+ return kind === "transient";
100
+ }
101
+ function remedyFor(kind, provider, err) {
102
+ if (kind !== "auth") return void 0;
103
+ const message = err instanceof Error ? err.message : "";
104
+ if (/sso session/i.test(message)) {
105
+ const profile = process.env.AWS_PROFILE;
106
+ return profile ? `Run: aws sso login --profile ${profile}` : "Run: aws sso login --profile <your-profile>";
107
+ }
108
+ switch (provider) {
109
+ case "aws": {
110
+ const profile = process.env.AWS_PROFILE;
111
+ 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.";
112
+ }
113
+ case "bitwarden":
114
+ return "Set BWS_ACCESS_TOKEN to a valid Bitwarden machine account token.";
115
+ case "vault":
116
+ return "Set VAULT_ADDR and VAULT_TOKEN, or renew the token if it has expired.";
117
+ default:
118
+ return void 0;
119
+ }
120
+ }
121
+
8
122
  // src/providers/base.ts
9
123
  var SecretFetchError = class extends Error {
10
124
  constructor(provider, path2, cause) {
11
- super(`[${provider}] failed to fetch secret at "${path2}": ${errorMessage(cause)}`);
125
+ const kind = classifyError(cause);
126
+ super(
127
+ kind === "auth" ? `[${provider}] cannot authenticate: ${errorMessage(cause)}` : `[${provider}] failed to fetch secret at "${path2}": ${errorMessage(cause)}`
128
+ );
12
129
  this.provider = provider;
13
130
  this.path = path2;
131
+ this.cause = cause;
14
132
  this.name = "SecretFetchError";
133
+ this.kind = kind;
134
+ this.remedy = remedyFor(kind, provider, cause);
15
135
  }
16
136
  provider;
17
137
  path;
138
+ cause;
139
+ /** Whose problem this is - see {@link SecretErrorKind}. Classified from
140
+ * `cause` so every existing throw site is categorised without having to
141
+ * know about categories. */
142
+ kind;
143
+ /** The action that fixes it, when there is one (auth failures). */
144
+ remedy;
18
145
  };
19
146
  var BaseSecretProvider = class {
20
147
  async fetchBatch(requests) {
@@ -59,6 +186,9 @@ function extractField(raw, field, context) {
59
186
  return typeof current === "object" ? JSON.stringify(current) : String(current);
60
187
  }
61
188
 
189
+ // src/providers/aws.ts
190
+ import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
191
+
62
192
  // src/ttlCache.ts
63
193
  var TtlCache = class {
64
194
  /** Settled values, only populated when a TTL is configured. */
@@ -69,9 +199,15 @@ var TtlCache = class {
69
199
  * stays correct even with caching fully disabled. */
70
200
  inFlight = /* @__PURE__ */ new Map();
71
201
  ttlMs;
202
+ staleGraceMs;
203
+ isStaleServable;
204
+ onStale;
72
205
  now;
73
206
  constructor(options = {}) {
74
207
  this.ttlMs = options.ttlMs ?? 0;
208
+ this.staleGraceMs = options.staleGraceMs ?? 0;
209
+ this.isStaleServable = options.isStaleServable ?? (() => false);
210
+ this.onStale = options.onStale;
75
211
  this.now = options.now ?? Date.now;
76
212
  }
77
213
  /**
@@ -95,11 +231,19 @@ var TtlCache = class {
95
231
  this.inFlight.set(key, pending);
96
232
  try {
97
233
  const value = await pending;
98
- if (this.ttlMs > 0) {
234
+ if (this.ttlMs > 0 || this.staleGraceMs > 0) {
99
235
  this.entries.set(key, { value: Promise.resolve(value), storedAt: this.now() });
100
236
  }
101
237
  return value;
102
238
  } catch (err) {
239
+ const previous = this.entries.get(key);
240
+ if (previous && this.staleGraceMs > 0 && this.isStaleServable(err)) {
241
+ const ageMs = this.now() - previous.storedAt;
242
+ if (ageMs <= this.staleGraceMs) {
243
+ this.onStale?.(key, ageMs, err);
244
+ return previous.value;
245
+ }
246
+ }
103
247
  this.entries.delete(key);
104
248
  throw err;
105
249
  } finally {
@@ -163,6 +307,7 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
163
307
  name = "aws";
164
308
  explicitClient;
165
309
  region;
310
+ profile;
166
311
  controlPlane;
167
312
  controlPlaneClient;
168
313
  ambientClient = null;
@@ -171,8 +316,14 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
171
316
  super();
172
317
  this.explicitClient = options.client;
173
318
  this.region = options.region;
319
+ this.profile = options.profile;
174
320
  this.controlPlane = options.controlPlane;
175
- this.rawCache = new TtlCache({ ttlMs: options.cacheTtlMs });
321
+ this.rawCache = new TtlCache({
322
+ ttlMs: options.cacheTtlMs,
323
+ staleGraceMs: options.staleGraceMs,
324
+ isStaleServable: (err) => isStaleServable(classifyError(err)),
325
+ onStale: options.onStaleValue
326
+ });
176
327
  if (this.controlPlane) {
177
328
  this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
178
329
  }
@@ -191,7 +342,15 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
191
342
  }
192
343
  return this.buildClientFromMintedCredentials(minted.credentials);
193
344
  }
194
- if (!this.ambientClient) this.ambientClient = new SecretsManagerClient({ region: this.region });
345
+ if (!this.ambientClient) {
346
+ this.ambientClient = new SecretsManagerClient({
347
+ region: this.region,
348
+ // fromNodeProviderChain honours the same precedence as the
349
+ // ambient default, just pinned to one profile - so instance
350
+ // roles and env vars still work when no profile is named.
351
+ ...this.profile ? { credentials: fromNodeProviderChain({ profile: this.profile }) } : {}
352
+ });
353
+ }
195
354
  return this.ambientClient;
196
355
  }
197
356
  buildClientFromMintedCredentials(credentials) {
@@ -217,7 +376,8 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
217
376
  }
218
377
  throw new Error(`secret "${path2}" has no SecretString or SecretBinary payload`);
219
378
  } catch (err) {
220
- throw new Error(`could not fetch secret "${path2}": ${errorMessage(err)}`);
379
+ if (err instanceof SecretFetchError) throw err;
380
+ throw new SecretFetchError(this.name, path2, err);
221
381
  }
222
382
  });
223
383
  }
@@ -559,16 +719,37 @@ function tryParseSecretRef(raw) {
559
719
  }
560
720
 
561
721
  // src/resolver.ts
722
+ function formatFailures(errors) {
723
+ const auth = errors.filter((e) => e.kind === "auth");
724
+ const rest = errors.filter((e) => e.kind !== "auth");
725
+ const lines = [];
726
+ for (const provider of [...new Set(auth.map((e) => e.provider ?? "unknown"))]) {
727
+ const group = auth.filter((e) => (e.provider ?? "unknown") === provider);
728
+ lines.push(`Cannot authenticate to provider "${provider}".`);
729
+ lines.push(` ${group[0].message}`);
730
+ const remedy = group.find((e) => e.remedy)?.remedy;
731
+ if (remedy) lines.push(` ${remedy}`);
732
+ lines.push(` Not resolved: ${group.map((e) => e.key).join(", ")}`);
733
+ }
734
+ if (rest.length > 0) {
735
+ if (lines.length > 0) lines.push("");
736
+ lines.push(`Failed to resolve ${rest.length} secret reference(s):`);
737
+ for (const e of rest) lines.push(` - ${e.key}: ${e.ref} -> ${e.message}`);
738
+ }
739
+ return lines.join("\n");
740
+ }
562
741
  var SecRefsResolutionError = class extends Error {
563
742
  constructor(errors) {
564
- super(
565
- `Failed to resolve ${errors.length} secret reference(s):
566
- ` + errors.map((e) => ` - ${e.key}: ${e.ref} -> ${e.message}`).join("\n")
567
- );
743
+ super(formatFailures(errors));
568
744
  this.errors = errors;
569
745
  this.name = "SecRefsResolutionError";
570
746
  }
571
747
  errors;
748
+ /** True when every failure was an environment/auth problem, so a caller
749
+ * can tell "your credentials lapsed" from "your references are wrong". */
750
+ get isAuthOnly() {
751
+ return this.errors.length > 0 && this.errors.every((e) => e.kind === "auth");
752
+ }
572
753
  };
573
754
  async function resolveOne(ref, providers) {
574
755
  const provider = providers[ref.provider];
@@ -608,7 +789,15 @@ async function expandKeyValueMap(input, options) {
608
789
  if (result.status === "fulfilled") {
609
790
  output[key] = result.value;
610
791
  } else {
611
- errors.push({ key, ref: ref.raw, message: errorMessage(result.reason) });
792
+ const reason = result.reason;
793
+ errors.push({
794
+ key,
795
+ ref: ref.raw,
796
+ message: errorMessage(reason),
797
+ kind: reason instanceof SecretFetchError ? reason.kind : void 0,
798
+ provider: reason instanceof SecretFetchError ? reason.provider : ref.provider,
799
+ remedy: reason instanceof SecretFetchError ? reason.remedy : void 0
800
+ });
612
801
  }
613
802
  });
614
803
  if (errors.length > 0) {
@@ -673,6 +862,128 @@ function parseEnvFileText(rawText) {
673
862
  return recoverTruncatedSecRefs(rawText, parsed);
674
863
  }
675
864
 
865
+ // src/config.ts
866
+ import { readFileSync } from "fs";
867
+ import { dirname, resolve } from "path";
868
+ var CONFIG_FILENAME = "secrefs.config.json";
869
+ var ConfigError = class extends Error {
870
+ constructor(message) {
871
+ super(message);
872
+ this.name = "ConfigError";
873
+ }
874
+ };
875
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
876
+ "token",
877
+ "accessToken",
878
+ "access_token",
879
+ "secret",
880
+ "secretKey",
881
+ "secretAccessKey",
882
+ "password",
883
+ "apiKey",
884
+ "credential",
885
+ "credentials"
886
+ ]);
887
+ function assertNoInlineSecrets(alias, config) {
888
+ for (const key of Object.keys(config)) {
889
+ if (FORBIDDEN_KEYS.has(key)) {
890
+ throw new ConfigError(
891
+ `${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".`
892
+ );
893
+ }
894
+ }
895
+ }
896
+ function requireEnv(alias, varName, env) {
897
+ const value = env[varName];
898
+ if (!value) {
899
+ throw new ConfigError(
900
+ `${CONFIG_FILENAME}: provider "${alias}" expects the credential in ${varName}, but that environment variable is not set.`
901
+ );
902
+ }
903
+ return value;
904
+ }
905
+ function parseConfig(raw, source = CONFIG_FILENAME) {
906
+ let parsed;
907
+ try {
908
+ parsed = JSON.parse(raw);
909
+ } catch (err) {
910
+ throw new ConfigError(`${source} is not valid JSON: ${err.message}`);
911
+ }
912
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
913
+ throw new ConfigError(`${source} must contain a JSON object.`);
914
+ }
915
+ const providers = parsed.providers;
916
+ if (typeof providers !== "object" || providers === null) {
917
+ throw new ConfigError(`${source} must have a "providers" object.`);
918
+ }
919
+ for (const [alias, value] of Object.entries(providers)) {
920
+ if (typeof value !== "object" || value === null) {
921
+ throw new ConfigError(`${source}: provider "${alias}" must be an object.`);
922
+ }
923
+ const type = value.type;
924
+ if (type !== "aws" && type !== "bitwarden" && type !== "vault" && type !== "local") {
925
+ throw new ConfigError(
926
+ `${source}: provider "${alias}" has type ${JSON.stringify(type)}; expected one of "aws", "bitwarden", "vault", "local".`
927
+ );
928
+ }
929
+ assertNoInlineSecrets(alias, value);
930
+ }
931
+ return parsed;
932
+ }
933
+ function buildProviders(config, options = {}) {
934
+ const env = options.env ?? process.env;
935
+ const configDir = options.configDir ?? process.cwd();
936
+ const registry = {};
937
+ for (const [alias, entry] of Object.entries(config.providers)) {
938
+ switch (entry.type) {
939
+ case "aws":
940
+ registry[alias] = new AwsSecretsManagerProvider({
941
+ region: entry.region,
942
+ cacheTtlMs: entry.cacheTtlMs,
943
+ staleGraceMs: entry.staleGraceMs,
944
+ profile: entry.profile
945
+ });
946
+ break;
947
+ case "bitwarden":
948
+ registry[alias] = new BitwardenProvider({
949
+ accessToken: requireEnv(alias, entry.tokenEnv ?? "BWS_ACCESS_TOKEN", env),
950
+ organizationId: entry.organizationIdEnv ? requireEnv(alias, entry.organizationIdEnv, env) : env.BWS_ORGANIZATION_ID,
951
+ apiUrl: entry.apiUrl,
952
+ identityUrl: entry.identityUrl
953
+ });
954
+ break;
955
+ case "vault":
956
+ registry[alias] = new VaultProvider({
957
+ endpoint: entry.addr ?? env.VAULT_ADDR,
958
+ token: requireEnv(alias, entry.tokenEnv ?? "VAULT_TOKEN", env)
959
+ });
960
+ break;
961
+ case "local":
962
+ registry[alias] = new LocalProvider({
963
+ filePath: entry.file ? resolve(configDir, entry.file) : void 0
964
+ });
965
+ break;
966
+ }
967
+ }
968
+ return registry;
969
+ }
970
+ function loadConfigFrom(dir = process.cwd()) {
971
+ let current = resolve(dir);
972
+ for (; ; ) {
973
+ const candidate = resolve(current, CONFIG_FILENAME);
974
+ let raw;
975
+ try {
976
+ raw = readFileSync(candidate, "utf8");
977
+ } catch {
978
+ const parent = dirname(current);
979
+ if (parent === current) return void 0;
980
+ current = parent;
981
+ continue;
982
+ }
983
+ return { config: parseConfig(raw, candidate), path: candidate };
984
+ }
985
+ }
986
+
676
987
  // src/index.ts
677
988
  function createDefaultProviders() {
678
989
  return {
@@ -725,6 +1036,8 @@ export {
725
1036
  AwsSecretsManagerProvider,
726
1037
  BaseSecretProvider,
727
1038
  BitwardenProvider,
1039
+ CONFIG_FILENAME,
1040
+ ConfigError,
728
1041
  ControlPlaneClient,
729
1042
  ControlPlaneRequestError,
730
1043
  LocalProvider,
@@ -734,12 +1047,15 @@ export {
734
1047
  SecretFetchError,
735
1048
  TtlCache,
736
1049
  VaultProvider,
1050
+ buildProviders,
737
1051
  checkReferences,
738
1052
  createDefaultProviders,
739
1053
  expandKeyValueMap,
740
1054
  expandProcessEnv,
741
1055
  extractField,
742
1056
  isSecretRef,
1057
+ loadConfigFrom,
1058
+ parseConfig,
743
1059
  parseEnvFileText,
744
1060
  parseSecretRef,
745
1061
  recoverTruncatedSecRefs,