@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/README.md CHANGED
@@ -121,4 +121,4 @@ MIT. See [LICENSE](./LICENSE).
121
121
 
122
122
  The client libraries are MIT permanently and unconditionally — the SecRefs
123
123
  control plane is licensed separately. See
124
- [LICENSING.md](https://github.com/Armistice-Group/secrefs/blob/main/LICENSING.md).
124
+ [LICENSING.md](https://github.com/secrefs/secrefs/blob/main/LICENSING.md).
package/dist/index.cjs CHANGED
@@ -33,6 +33,8 @@ __export(src_exports, {
33
33
  AwsSecretsManagerProvider: () => AwsSecretsManagerProvider,
34
34
  BaseSecretProvider: () => BaseSecretProvider,
35
35
  BitwardenProvider: () => BitwardenProvider,
36
+ CONFIG_FILENAME: () => CONFIG_FILENAME,
37
+ ConfigError: () => ConfigError,
36
38
  ControlPlaneClient: () => ControlPlaneClient,
37
39
  ControlPlaneRequestError: () => ControlPlaneRequestError,
38
40
  LocalProvider: () => LocalProvider,
@@ -42,12 +44,15 @@ __export(src_exports, {
42
44
  SecretFetchError: () => SecretFetchError,
43
45
  TtlCache: () => TtlCache,
44
46
  VaultProvider: () => VaultProvider,
47
+ buildProviders: () => buildProviders,
45
48
  checkReferences: () => checkReferences,
46
49
  createDefaultProviders: () => createDefaultProviders,
47
50
  expandKeyValueMap: () => expandKeyValueMap,
48
51
  expandProcessEnv: () => expandProcessEnv,
49
52
  extractField: () => extractField,
50
53
  isSecretRef: () => isSecretRef,
54
+ loadConfigFrom: () => loadConfigFrom,
55
+ parseConfig: () => parseConfig,
51
56
  parseEnvFileText: () => parseEnvFileText,
52
57
  parseSecretRef: () => parseSecretRef,
53
58
  recoverTruncatedSecRefs: () => recoverTruncatedSecRefs,
@@ -59,16 +64,143 @@ module.exports = __toCommonJS(src_exports);
59
64
  // src/providers/aws.ts
60
65
  var import_client_secrets_manager = require("@aws-sdk/client-secrets-manager");
61
66
 
67
+ // src/providers/errors.ts
68
+ var AUTH_NAMES = /* @__PURE__ */ new Set([
69
+ "CredentialsProviderError",
70
+ "TokenProviderError",
71
+ "ExpiredToken",
72
+ "ExpiredTokenException",
73
+ "InvalidClientTokenId",
74
+ "UnrecognizedClientException",
75
+ "InvalidIdentityToken",
76
+ "AuthFailure",
77
+ "SSOTokenProviderFailure"
78
+ ]);
79
+ var NOT_FOUND_NAMES = /* @__PURE__ */ new Set([
80
+ "ResourceNotFoundException",
81
+ "NoSuchEntity",
82
+ "SecretNotFound"
83
+ ]);
84
+ var DENIED_NAMES = /* @__PURE__ */ new Set([
85
+ "AccessDeniedException",
86
+ "AccessDenied",
87
+ "AuthorizationError",
88
+ "UnauthorizedOperation"
89
+ ]);
90
+ var TRANSIENT_NAMES = /* @__PURE__ */ new Set([
91
+ "TimeoutError",
92
+ "NetworkingError",
93
+ "RequestTimeout",
94
+ "RequestTimeoutException",
95
+ "ThrottlingException",
96
+ "TooManyRequestsException",
97
+ "InternalServiceError",
98
+ "InternalServerError",
99
+ "ServiceUnavailable",
100
+ "ServiceUnavailableException",
101
+ "AbortError",
102
+ "ECONNRESET",
103
+ "ECONNREFUSED",
104
+ "ETIMEDOUT",
105
+ "EAI_AGAIN"
106
+ ]);
107
+ var AUTH_FRAGMENTS = [
108
+ "could not load credentials",
109
+ "sso session associated with this profile has expired",
110
+ "security token included in the request is expired",
111
+ "unable to locate credentials",
112
+ "token is expired",
113
+ "credentials have expired",
114
+ "is expired"
115
+ ];
116
+ var TRANSIENT_FRAGMENTS = [
117
+ "socket hang up",
118
+ "network error",
119
+ "timed out",
120
+ "timeout",
121
+ "econnreset",
122
+ "econnrefused",
123
+ "getaddrinfo"
124
+ ];
125
+ function nameOf(err) {
126
+ if (typeof err !== "object" || err === null) return "";
127
+ const e = err;
128
+ for (const candidate of [e.name, e.code, e.__type]) {
129
+ if (typeof candidate === "string" && candidate) return candidate;
130
+ }
131
+ return "";
132
+ }
133
+ function statusOf(err) {
134
+ if (typeof err !== "object" || err === null) return void 0;
135
+ const meta = err.$metadata;
136
+ if (typeof meta?.httpStatusCode === "number") return meta.httpStatusCode;
137
+ const status = err.status ?? err.statusCode;
138
+ return typeof status === "number" ? status : void 0;
139
+ }
140
+ function classifyError(err) {
141
+ const name = nameOf(err);
142
+ if (AUTH_NAMES.has(name)) return "auth";
143
+ if (NOT_FOUND_NAMES.has(name)) return "not_found";
144
+ if (DENIED_NAMES.has(name)) return "denied";
145
+ if (TRANSIENT_NAMES.has(name)) return "transient";
146
+ const status = statusOf(err);
147
+ if (status === 401) return "auth";
148
+ if (status === 403) return "denied";
149
+ if (status === 404) return "not_found";
150
+ if (status === 408 || status === 429) return "transient";
151
+ if (status !== void 0 && status >= 500) return "transient";
152
+ const message = (err instanceof Error ? err.message : String(err ?? "")).toLowerCase();
153
+ if (AUTH_FRAGMENTS.some((f) => message.includes(f))) return "auth";
154
+ if (TRANSIENT_FRAGMENTS.some((f) => message.includes(f))) return "transient";
155
+ return "unknown";
156
+ }
157
+ function isStaleServable(kind) {
158
+ return kind === "transient";
159
+ }
160
+ function remedyFor(kind, provider, err) {
161
+ if (kind !== "auth") return void 0;
162
+ const message = err instanceof Error ? err.message : "";
163
+ if (/sso session/i.test(message)) {
164
+ const profile = process.env.AWS_PROFILE;
165
+ return profile ? `Run: aws sso login --profile ${profile}` : "Run: aws sso login --profile <your-profile>";
166
+ }
167
+ switch (provider) {
168
+ case "aws": {
169
+ const profile = process.env.AWS_PROFILE;
170
+ 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.";
171
+ }
172
+ case "bitwarden":
173
+ return "Set BWS_ACCESS_TOKEN to a valid Bitwarden machine account token.";
174
+ case "vault":
175
+ return "Set VAULT_ADDR and VAULT_TOKEN, or renew the token if it has expired.";
176
+ default:
177
+ return void 0;
178
+ }
179
+ }
180
+
62
181
  // src/providers/base.ts
63
182
  var SecretFetchError = class extends Error {
64
183
  constructor(provider, path2, cause) {
65
- super(`[${provider}] failed to fetch secret at "${path2}": ${errorMessage(cause)}`);
184
+ const kind = classifyError(cause);
185
+ super(
186
+ kind === "auth" ? `[${provider}] cannot authenticate: ${errorMessage(cause)}` : `[${provider}] failed to fetch secret at "${path2}": ${errorMessage(cause)}`
187
+ );
66
188
  this.provider = provider;
67
189
  this.path = path2;
190
+ this.cause = cause;
68
191
  this.name = "SecretFetchError";
192
+ this.kind = kind;
193
+ this.remedy = remedyFor(kind, provider, cause);
69
194
  }
70
195
  provider;
71
196
  path;
197
+ cause;
198
+ /** Whose problem this is - see {@link SecretErrorKind}. Classified from
199
+ * `cause` so every existing throw site is categorised without having to
200
+ * know about categories. */
201
+ kind;
202
+ /** The action that fixes it, when there is one (auth failures). */
203
+ remedy;
72
204
  };
73
205
  var BaseSecretProvider = class {
74
206
  async fetchBatch(requests) {
@@ -113,6 +245,9 @@ function extractField(raw, field, context) {
113
245
  return typeof current === "object" ? JSON.stringify(current) : String(current);
114
246
  }
115
247
 
248
+ // src/providers/aws.ts
249
+ var import_credential_providers = require("@aws-sdk/credential-providers");
250
+
116
251
  // src/ttlCache.ts
117
252
  var TtlCache = class {
118
253
  /** Settled values, only populated when a TTL is configured. */
@@ -123,9 +258,15 @@ var TtlCache = class {
123
258
  * stays correct even with caching fully disabled. */
124
259
  inFlight = /* @__PURE__ */ new Map();
125
260
  ttlMs;
261
+ staleGraceMs;
262
+ isStaleServable;
263
+ onStale;
126
264
  now;
127
265
  constructor(options = {}) {
128
266
  this.ttlMs = options.ttlMs ?? 0;
267
+ this.staleGraceMs = options.staleGraceMs ?? 0;
268
+ this.isStaleServable = options.isStaleServable ?? (() => false);
269
+ this.onStale = options.onStale;
129
270
  this.now = options.now ?? Date.now;
130
271
  }
131
272
  /**
@@ -149,11 +290,19 @@ var TtlCache = class {
149
290
  this.inFlight.set(key, pending);
150
291
  try {
151
292
  const value = await pending;
152
- if (this.ttlMs > 0) {
293
+ if (this.ttlMs > 0 || this.staleGraceMs > 0) {
153
294
  this.entries.set(key, { value: Promise.resolve(value), storedAt: this.now() });
154
295
  }
155
296
  return value;
156
297
  } catch (err) {
298
+ const previous = this.entries.get(key);
299
+ if (previous && this.staleGraceMs > 0 && this.isStaleServable(err)) {
300
+ const ageMs = this.now() - previous.storedAt;
301
+ if (ageMs <= this.staleGraceMs) {
302
+ this.onStale?.(key, ageMs, err);
303
+ return previous.value;
304
+ }
305
+ }
157
306
  this.entries.delete(key);
158
307
  throw err;
159
308
  } finally {
@@ -217,6 +366,7 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
217
366
  name = "aws";
218
367
  explicitClient;
219
368
  region;
369
+ profile;
220
370
  controlPlane;
221
371
  controlPlaneClient;
222
372
  ambientClient = null;
@@ -225,8 +375,14 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
225
375
  super();
226
376
  this.explicitClient = options.client;
227
377
  this.region = options.region;
378
+ this.profile = options.profile;
228
379
  this.controlPlane = options.controlPlane;
229
- this.rawCache = new TtlCache({ ttlMs: options.cacheTtlMs });
380
+ this.rawCache = new TtlCache({
381
+ ttlMs: options.cacheTtlMs,
382
+ staleGraceMs: options.staleGraceMs,
383
+ isStaleServable: (err) => isStaleServable(classifyError(err)),
384
+ onStale: options.onStaleValue
385
+ });
230
386
  if (this.controlPlane) {
231
387
  this.controlPlaneClient = this.controlPlane.client ?? new ControlPlaneClient({ baseUrl: this.controlPlane.baseUrl, token: this.controlPlane.token });
232
388
  }
@@ -245,7 +401,15 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
245
401
  }
246
402
  return this.buildClientFromMintedCredentials(minted.credentials);
247
403
  }
248
- if (!this.ambientClient) this.ambientClient = new import_client_secrets_manager.SecretsManagerClient({ region: this.region });
404
+ if (!this.ambientClient) {
405
+ this.ambientClient = new import_client_secrets_manager.SecretsManagerClient({
406
+ region: this.region,
407
+ // fromNodeProviderChain honours the same precedence as the
408
+ // ambient default, just pinned to one profile - so instance
409
+ // roles and env vars still work when no profile is named.
410
+ ...this.profile ? { credentials: (0, import_credential_providers.fromNodeProviderChain)({ profile: this.profile }) } : {}
411
+ });
412
+ }
249
413
  return this.ambientClient;
250
414
  }
251
415
  buildClientFromMintedCredentials(credentials) {
@@ -271,7 +435,8 @@ var AwsSecretsManagerProvider = class extends BaseSecretProvider {
271
435
  }
272
436
  throw new Error(`secret "${path2}" has no SecretString or SecretBinary payload`);
273
437
  } catch (err) {
274
- throw new Error(`could not fetch secret "${path2}": ${errorMessage(err)}`);
438
+ if (err instanceof SecretFetchError) throw err;
439
+ throw new SecretFetchError(this.name, path2, err);
275
440
  }
276
441
  });
277
442
  }
@@ -613,16 +778,37 @@ function tryParseSecretRef(raw) {
613
778
  }
614
779
 
615
780
  // src/resolver.ts
781
+ function formatFailures(errors) {
782
+ const auth = errors.filter((e) => e.kind === "auth");
783
+ const rest = errors.filter((e) => e.kind !== "auth");
784
+ const lines = [];
785
+ for (const provider of [...new Set(auth.map((e) => e.provider ?? "unknown"))]) {
786
+ const group = auth.filter((e) => (e.provider ?? "unknown") === provider);
787
+ lines.push(`Cannot authenticate to provider "${provider}".`);
788
+ lines.push(` ${group[0].message}`);
789
+ const remedy = group.find((e) => e.remedy)?.remedy;
790
+ if (remedy) lines.push(` ${remedy}`);
791
+ lines.push(` Not resolved: ${group.map((e) => e.key).join(", ")}`);
792
+ }
793
+ if (rest.length > 0) {
794
+ if (lines.length > 0) lines.push("");
795
+ lines.push(`Failed to resolve ${rest.length} secret reference(s):`);
796
+ for (const e of rest) lines.push(` - ${e.key}: ${e.ref} -> ${e.message}`);
797
+ }
798
+ return lines.join("\n");
799
+ }
616
800
  var SecRefsResolutionError = class extends Error {
617
801
  constructor(errors) {
618
- super(
619
- `Failed to resolve ${errors.length} secret reference(s):
620
- ` + errors.map((e) => ` - ${e.key}: ${e.ref} -> ${e.message}`).join("\n")
621
- );
802
+ super(formatFailures(errors));
622
803
  this.errors = errors;
623
804
  this.name = "SecRefsResolutionError";
624
805
  }
625
806
  errors;
807
+ /** True when every failure was an environment/auth problem, so a caller
808
+ * can tell "your credentials lapsed" from "your references are wrong". */
809
+ get isAuthOnly() {
810
+ return this.errors.length > 0 && this.errors.every((e) => e.kind === "auth");
811
+ }
626
812
  };
627
813
  async function resolveOne(ref, providers) {
628
814
  const provider = providers[ref.provider];
@@ -662,7 +848,15 @@ async function expandKeyValueMap(input, options) {
662
848
  if (result.status === "fulfilled") {
663
849
  output[key] = result.value;
664
850
  } else {
665
- errors.push({ key, ref: ref.raw, message: errorMessage(result.reason) });
851
+ const reason = result.reason;
852
+ errors.push({
853
+ key,
854
+ ref: ref.raw,
855
+ message: errorMessage(reason),
856
+ kind: reason instanceof SecretFetchError ? reason.kind : void 0,
857
+ provider: reason instanceof SecretFetchError ? reason.provider : ref.provider,
858
+ remedy: reason instanceof SecretFetchError ? reason.remedy : void 0
859
+ });
666
860
  }
667
861
  });
668
862
  if (errors.length > 0) {
@@ -727,6 +921,128 @@ function parseEnvFileText(rawText) {
727
921
  return recoverTruncatedSecRefs(rawText, parsed);
728
922
  }
729
923
 
924
+ // src/config.ts
925
+ var import_node_fs = require("fs");
926
+ var import_node_path2 = require("path");
927
+ var CONFIG_FILENAME = "secrefs.config.json";
928
+ var ConfigError = class extends Error {
929
+ constructor(message) {
930
+ super(message);
931
+ this.name = "ConfigError";
932
+ }
933
+ };
934
+ var FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
935
+ "token",
936
+ "accessToken",
937
+ "access_token",
938
+ "secret",
939
+ "secretKey",
940
+ "secretAccessKey",
941
+ "password",
942
+ "apiKey",
943
+ "credential",
944
+ "credentials"
945
+ ]);
946
+ function assertNoInlineSecrets(alias, config) {
947
+ for (const key of Object.keys(config)) {
948
+ if (FORBIDDEN_KEYS.has(key)) {
949
+ throw new ConfigError(
950
+ `${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".`
951
+ );
952
+ }
953
+ }
954
+ }
955
+ function requireEnv(alias, varName, env) {
956
+ const value = env[varName];
957
+ if (!value) {
958
+ throw new ConfigError(
959
+ `${CONFIG_FILENAME}: provider "${alias}" expects the credential in ${varName}, but that environment variable is not set.`
960
+ );
961
+ }
962
+ return value;
963
+ }
964
+ function parseConfig(raw, source = CONFIG_FILENAME) {
965
+ let parsed;
966
+ try {
967
+ parsed = JSON.parse(raw);
968
+ } catch (err) {
969
+ throw new ConfigError(`${source} is not valid JSON: ${err.message}`);
970
+ }
971
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
972
+ throw new ConfigError(`${source} must contain a JSON object.`);
973
+ }
974
+ const providers = parsed.providers;
975
+ if (typeof providers !== "object" || providers === null) {
976
+ throw new ConfigError(`${source} must have a "providers" object.`);
977
+ }
978
+ for (const [alias, value] of Object.entries(providers)) {
979
+ if (typeof value !== "object" || value === null) {
980
+ throw new ConfigError(`${source}: provider "${alias}" must be an object.`);
981
+ }
982
+ const type = value.type;
983
+ if (type !== "aws" && type !== "bitwarden" && type !== "vault" && type !== "local") {
984
+ throw new ConfigError(
985
+ `${source}: provider "${alias}" has type ${JSON.stringify(type)}; expected one of "aws", "bitwarden", "vault", "local".`
986
+ );
987
+ }
988
+ assertNoInlineSecrets(alias, value);
989
+ }
990
+ return parsed;
991
+ }
992
+ function buildProviders(config, options = {}) {
993
+ const env = options.env ?? process.env;
994
+ const configDir = options.configDir ?? process.cwd();
995
+ const registry = {};
996
+ for (const [alias, entry] of Object.entries(config.providers)) {
997
+ switch (entry.type) {
998
+ case "aws":
999
+ registry[alias] = new AwsSecretsManagerProvider({
1000
+ region: entry.region,
1001
+ cacheTtlMs: entry.cacheTtlMs,
1002
+ staleGraceMs: entry.staleGraceMs,
1003
+ profile: entry.profile
1004
+ });
1005
+ break;
1006
+ case "bitwarden":
1007
+ registry[alias] = new BitwardenProvider({
1008
+ accessToken: requireEnv(alias, entry.tokenEnv ?? "BWS_ACCESS_TOKEN", env),
1009
+ organizationId: entry.organizationIdEnv ? requireEnv(alias, entry.organizationIdEnv, env) : env.BWS_ORGANIZATION_ID,
1010
+ apiUrl: entry.apiUrl,
1011
+ identityUrl: entry.identityUrl
1012
+ });
1013
+ break;
1014
+ case "vault":
1015
+ registry[alias] = new VaultProvider({
1016
+ endpoint: entry.addr ?? env.VAULT_ADDR,
1017
+ token: requireEnv(alias, entry.tokenEnv ?? "VAULT_TOKEN", env)
1018
+ });
1019
+ break;
1020
+ case "local":
1021
+ registry[alias] = new LocalProvider({
1022
+ filePath: entry.file ? (0, import_node_path2.resolve)(configDir, entry.file) : void 0
1023
+ });
1024
+ break;
1025
+ }
1026
+ }
1027
+ return registry;
1028
+ }
1029
+ function loadConfigFrom(dir = process.cwd()) {
1030
+ let current = (0, import_node_path2.resolve)(dir);
1031
+ for (; ; ) {
1032
+ const candidate = (0, import_node_path2.resolve)(current, CONFIG_FILENAME);
1033
+ let raw;
1034
+ try {
1035
+ raw = (0, import_node_fs.readFileSync)(candidate, "utf8");
1036
+ } catch {
1037
+ const parent = (0, import_node_path2.dirname)(current);
1038
+ if (parent === current) return void 0;
1039
+ current = parent;
1040
+ continue;
1041
+ }
1042
+ return { config: parseConfig(raw, candidate), path: candidate };
1043
+ }
1044
+ }
1045
+
730
1046
  // src/index.ts
731
1047
  function createDefaultProviders() {
732
1048
  return {
@@ -780,6 +1096,8 @@ var secRefs = new SecRefs();
780
1096
  AwsSecretsManagerProvider,
781
1097
  BaseSecretProvider,
782
1098
  BitwardenProvider,
1099
+ CONFIG_FILENAME,
1100
+ ConfigError,
783
1101
  ControlPlaneClient,
784
1102
  ControlPlaneRequestError,
785
1103
  LocalProvider,
@@ -789,12 +1107,15 @@ var secRefs = new SecRefs();
789
1107
  SecretFetchError,
790
1108
  TtlCache,
791
1109
  VaultProvider,
1110
+ buildProviders,
792
1111
  checkReferences,
793
1112
  createDefaultProviders,
794
1113
  expandKeyValueMap,
795
1114
  expandProcessEnv,
796
1115
  extractField,
797
1116
  isSecretRef,
1117
+ loadConfigFrom,
1118
+ parseConfig,
798
1119
  parseEnvFileText,
799
1120
  parseSecretRef,
800
1121
  recoverTruncatedSecRefs,