@better-forge/vault 0.0.0-stage → 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/dist/createVault.d.ts +70 -0
  4. package/dist/createVault.d.ts.map +1 -0
  5. package/dist/createVault.js +28 -0
  6. package/dist/createVault.js.map +1 -0
  7. package/dist/hashicorp.d.ts +92 -0
  8. package/dist/hashicorp.d.ts.map +1 -0
  9. package/dist/hashicorp.js +436 -0
  10. package/dist/hashicorp.js.map +1 -0
  11. package/dist/index.d.ts +10 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +9 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/migrations.d.ts +4 -0
  16. package/dist/migrations.d.ts.map +1 -0
  17. package/dist/migrations.js +28 -0
  18. package/dist/migrations.js.map +1 -0
  19. package/dist/module.d.ts +8 -0
  20. package/dist/module.d.ts.map +1 -0
  21. package/dist/module.js +5 -0
  22. package/dist/module.js.map +1 -0
  23. package/dist/resolveSecret.d.ts +17 -0
  24. package/dist/resolveSecret.d.ts.map +1 -0
  25. package/dist/resolveSecret.js +80 -0
  26. package/dist/resolveSecret.js.map +1 -0
  27. package/dist/sealed.d.ts +45 -0
  28. package/dist/sealed.d.ts.map +1 -0
  29. package/dist/sealed.js +20 -0
  30. package/dist/sealed.js.map +1 -0
  31. package/dist/secretBox.d.ts +17 -0
  32. package/dist/secretBox.d.ts.map +1 -0
  33. package/dist/secretBox.js +63 -0
  34. package/dist/secretBox.js.map +1 -0
  35. package/dist/secretFields.d.ts +48 -0
  36. package/dist/secretFields.d.ts.map +1 -0
  37. package/dist/secretFields.js +159 -0
  38. package/dist/secretFields.js.map +1 -0
  39. package/dist/tables.d.ts +267 -0
  40. package/dist/tables.d.ts.map +1 -0
  41. package/dist/tables.js +22 -0
  42. package/dist/tables.js.map +1 -0
  43. package/dist/vaultRoutes.d.ts +42 -0
  44. package/dist/vaultRoutes.d.ts.map +1 -0
  45. package/dist/vaultRoutes.js +408 -0
  46. package/dist/vaultRoutes.js.map +1 -0
  47. package/dist/vaultUse.d.ts +15 -0
  48. package/dist/vaultUse.d.ts.map +1 -0
  49. package/dist/vaultUse.js +39 -0
  50. package/dist/vaultUse.js.map +1 -0
  51. package/package.json +54 -4
  52. package/src/createVault.ts +45 -0
  53. package/src/hashicorp.ts +466 -0
  54. package/src/index.ts +9 -0
  55. package/src/migrations.ts +29 -0
  56. package/src/module.ts +6 -0
  57. package/src/resolveSecret.ts +96 -0
  58. package/src/sealed.ts +38 -0
  59. package/src/secretBox.ts +72 -0
  60. package/src/secretFields.ts +177 -0
  61. package/src/tables.ts +23 -0
  62. package/src/vaultRoutes.ts +422 -0
  63. package/src/vaultUse.ts +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @better-forge/vault
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Local vault crypto, HashiCorp client, secret resolution and vault routes.
4
+
5
+ Status: skeleton. Depends on: `@better-forge/db`.
@@ -0,0 +1,70 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { SqliteDatabase } from '@better-forge/db';
3
+ import { type SecretBox } from './secretBox.js';
4
+ import { type VaultAudit } from './vaultRoutes.js';
5
+ export interface VaultOptions {
6
+ /** The application's database handle. It is used lazily, so it may be opened after this is created. */
7
+ db: SqliteDatabase;
8
+ audit: VaultAudit;
9
+ /** The signed-in caller of a request; the routes sit behind the application's own guards. */
10
+ identity: (req: FastifyRequest) => {
11
+ userId: number;
12
+ email: string;
13
+ };
14
+ /** Where the encryption key comes from. Defaults to the process environment. */
15
+ env?: NodeJS.ProcessEnv;
16
+ /** A box the application already created, for the tables that seal their own columns; it is made from `env` when absent. */
17
+ secretBox?: SecretBox;
18
+ }
19
+ /**
20
+ * Vaults of secrets (kept encrypted here, or read live from HashiCorp Vault), the lookup that turns a reference into
21
+ * its value, and the routes that manage them. Everything is built from `options` and kept in the returned closures.
22
+ */
23
+ export declare function createVault(options: VaultOptions): {
24
+ secretBox: SecretBox;
25
+ hashicorp: {
26
+ sealConnection: (cfg: import("./hashicorp.js").HashicorpConfig) => string;
27
+ openConnection: (sealed: string | null) => import("./hashicorp.js").HashicorpConfig;
28
+ forgetHashicorpVault: (vaultId: number) => void;
29
+ forgetCredentialDependents: (vaultId: number, secretId?: number) => void;
30
+ readKvSecret: (vault: import("./hashicorp.js").HashicorpVaultRow, path: string) => Promise<Record<string, unknown>>;
31
+ verifyCredentialsRef: (cfg: import("./hashicorp.js").HashicorpConfig) => Promise<void>;
32
+ testConnection: (vault: import("./hashicorp.js").HashicorpVaultRow) => Promise<{
33
+ authMethod: import("./hashicorp.js").HashicorpAuthMethod;
34
+ ttlSeconds: number | null;
35
+ }>;
36
+ testDraftConnection: (cfg: import("./hashicorp.js").HashicorpConfig) => Promise<{
37
+ authMethod: import("./hashicorp.js").HashicorpAuthMethod;
38
+ ttlSeconds: number | null;
39
+ }>;
40
+ };
41
+ resolver: {
42
+ loadSecretPayload: (secret: {
43
+ id: number;
44
+ name: string;
45
+ type: string;
46
+ createdAt: number;
47
+ updatedAt: number;
48
+ vaultId: number;
49
+ encryptedValue: string;
50
+ }, vault: {
51
+ id: number;
52
+ name: string;
53
+ type: string;
54
+ description: string | null;
55
+ createdAt: number;
56
+ updatedAt: number;
57
+ connectionConfig: string | null;
58
+ }) => Promise<Record<string, unknown>>;
59
+ resolveVaultSecret: (ref: import("@better-forge/contracts").VaultRef) => Promise<Record<string, string>>;
60
+ };
61
+ /** Plain plugins, so the hooks of the scope they are registered in apply to them. */
62
+ routes: {
63
+ /** Read-only catalogue of vault and secret names and types, never values. */
64
+ catalogue: (app: import("fastify").FastifyInstance) => Promise<void>;
65
+ /** Create, change, reveal and delete vaults and secrets. */
66
+ manage: (app: import("fastify").FastifyInstance) => Promise<void>;
67
+ };
68
+ };
69
+ export type Vault = ReturnType<typeof createVault>;
70
+ //# sourceMappingURL=createVault.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createVault.d.ts","sourceRoot":"","sources":["../src/createVault.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAEhE,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAErE,MAAM,WAAW,YAAY;IAC3B,uGAAuG;IACvG,EAAE,EAAE,cAAc,CAAA;IAClB,KAAK,EAAE,UAAU,CAAA;IACjB,6FAA6F;IAC7F,QAAQ,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;IACpE,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,4HAA4H;IAC5H,SAAS,CAAC,EAAE,SAAS,CAAA;CACtB;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAW7C,qFAAqF;;QAEnF,6EAA6E;;QAE7E,4DAA4D;;;EAIjE;AAED,MAAM,MAAM,KAAK,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAA"}
@@ -0,0 +1,28 @@
1
+ import { createHashicorp } from './hashicorp.js';
2
+ import { createSecretBox } from './secretBox.js';
3
+ import { createSecretResolver } from './resolveSecret.js';
4
+ import { createVaultRoutes } from './vaultRoutes.js';
5
+ /**
6
+ * Vaults of secrets (kept encrypted here, or read live from HashiCorp Vault), the lookup that turns a reference into
7
+ * its value, and the routes that manage them. Everything is built from `options` and kept in the returned closures.
8
+ */
9
+ export function createVault(options) {
10
+ const secretBox = options.secretBox ?? createSecretBox(options.env ? { env: options.env } : {});
11
+ const ctx = { db: options.db, secretBox };
12
+ const hashicorp = createHashicorp(ctx);
13
+ const resolver = createSecretResolver(ctx, hashicorp);
14
+ const routes = createVaultRoutes({ ...ctx, audit: options.audit, identity: options.identity }, { hashicorp, resolver });
15
+ return {
16
+ secretBox,
17
+ hashicorp,
18
+ resolver,
19
+ /** Plain plugins, so the hooks of the scope they are registered in apply to them. */
20
+ routes: {
21
+ /** Read-only catalogue of vault and secret names and types, never values. */
22
+ catalogue: routes.vaultCatalogRoutes,
23
+ /** Create, change, reveal and delete vaults and secrets. */
24
+ manage: routes.vaultRoutes,
25
+ },
26
+ };
27
+ }
28
+ //# sourceMappingURL=createVault.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createVault.js","sourceRoot":"","sources":["../src/createVault.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAkB,MAAM,gBAAgB,CAAA;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAA;AACzD,OAAO,EAAE,iBAAiB,EAAmB,MAAM,kBAAkB,CAAA;AAcrE;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,OAAqB;IAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,eAAe,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAC/F,MAAM,GAAG,GAAG,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,SAAS,EAAE,CAAA;IACzC,MAAM,SAAS,GAAG,eAAe,CAAC,GAAG,CAAC,CAAA;IACtC,MAAM,QAAQ,GAAG,oBAAoB,CAAC,GAAG,EAAE,SAAS,CAAC,CAAA;IACrD,MAAM,MAAM,GAAG,iBAAiB,CAAC,EAAE,GAAG,GAAG,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,CAAA;IAEvH,OAAO;QACL,SAAS;QACT,SAAS;QACT,QAAQ;QACR,qFAAqF;QACrF,MAAM,EAAE;YACN,6EAA6E;YAC7E,SAAS,EAAE,MAAM,CAAC,kBAAkB;YACpC,4DAA4D;YAC5D,MAAM,EAAE,MAAM,CAAC,WAAW;SAC3B;KACF,CAAA;AACH,CAAC"}
@@ -0,0 +1,92 @@
1
+ import type { SqliteDatabase } from '@better-forge/db';
2
+ import type { SecretBox } from './secretBox.js';
3
+ /**
4
+ * Client for HashiCorp Vault (KV v2). A vault of type `hashicorp` keeps only the
5
+ * connection settings (encrypted); secret values are read live on every use and never stored.
6
+ *
7
+ * The Vault address is private-network by nature, so unlike webhooks no public-IP guard applies.
8
+ * Instead the token is protected by: no redirects, strict path-segment validation, a response size
9
+ * cap and fixed error messages that never echo request headers, bodies or credentials.
10
+ */
11
+ export type HashicorpAuthMethod = 'token' | 'approle';
12
+ export interface HashicorpConfig {
13
+ address: string;
14
+ namespace?: string;
15
+ mount: string;
16
+ authMethod: HashicorpAuthMethod;
17
+ token?: string | undefined;
18
+ roleId?: string;
19
+ secretId?: string;
20
+ approleMount: string;
21
+ caCert?: string;
22
+ /**
23
+ * Reads the credentials from a secret in a local vault instead of storing them here: a Secure Value or JSON
24
+ * secret holding the token, or a User / Password (Role ID and Secret ID) or JSON secret for AppRole.
25
+ */
26
+ credentialsRef?: CredentialsRef;
27
+ }
28
+ export interface CredentialsRef {
29
+ vaultId: number;
30
+ secretId: number;
31
+ /** For a JSON secret: the JSON key holding each credential (`token`, or `roleId` and `secretId`); the key defaults to that name. */
32
+ fieldMapping?: Record<string, string>;
33
+ }
34
+ /** Message is always safe to return to the browser. */
35
+ export declare class HashicorpVaultError extends Error {
36
+ }
37
+ /** Mutable so tests can shorten the timeout. */
38
+ export declare const hashicorpLimits: {
39
+ timeoutMs: number;
40
+ maxBodyBytes: number;
41
+ /** A 403 for a token younger than this is a policy denial, not an expired token: no new login. */
42
+ freshTokenMs: number;
43
+ /** Grace period before a replaced connection pool is closed, so in-flight requests can finish. */
44
+ agentCloseDelayMs: number;
45
+ };
46
+ /** Validates a slash-separated Vault path and returns it URL-encoded per segment. */
47
+ export declare function encodePath(raw: string, label: string): string;
48
+ /**
49
+ * Validates connection input from the admin API. With `existing`, blank credentials keep the stored
50
+ * ones, except when the address, namespace, auth method or AppRole mount changes: credentials must
51
+ * then be sent again, so a changed address cannot receive a stored token.
52
+ */
53
+ export declare function parseConnection(input: unknown, existing?: HashicorpConfig): HashicorpConfig;
54
+ /** Connection settings safe to return to admins: credentials are reported only as present or not. */
55
+ export declare function publicConnection(cfg: HashicorpConfig): {
56
+ address: string;
57
+ namespace: string;
58
+ mount: string;
59
+ authMethod: HashicorpAuthMethod;
60
+ approleMount: string;
61
+ roleId: string;
62
+ caCert: string;
63
+ hasToken: boolean;
64
+ hasSecretId: boolean;
65
+ credentialsRef: CredentialsRef | null;
66
+ };
67
+ export interface HashicorpVaultRow {
68
+ id: number;
69
+ updatedAt: number;
70
+ connectionConfig: string | null;
71
+ }
72
+ export declare function createHashicorp(ctx: {
73
+ db: SqliteDatabase;
74
+ secretBox: SecretBox;
75
+ }): {
76
+ sealConnection: (cfg: HashicorpConfig) => string;
77
+ openConnection: (sealed: string | null) => HashicorpConfig;
78
+ forgetHashicorpVault: (vaultId: number) => void;
79
+ forgetCredentialDependents: (vaultId: number, secretId?: number) => void;
80
+ readKvSecret: (vault: HashicorpVaultRow, path: string) => Promise<Record<string, unknown>>;
81
+ verifyCredentialsRef: (cfg: HashicorpConfig) => Promise<void>;
82
+ testConnection: (vault: HashicorpVaultRow) => Promise<{
83
+ authMethod: HashicorpAuthMethod;
84
+ ttlSeconds: number | null;
85
+ }>;
86
+ testDraftConnection: (cfg: HashicorpConfig) => Promise<{
87
+ authMethod: HashicorpAuthMethod;
88
+ ttlSeconds: number | null;
89
+ }>;
90
+ };
91
+ export type Hashicorp = ReturnType<typeof createHashicorp>;
92
+ //# sourceMappingURL=hashicorp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hashicorp.d.ts","sourceRoot":"","sources":["../src/hashicorp.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAE/C;;;;;;;GAOG;AAEH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,SAAS,CAAA;AAErD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,mBAAmB,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,cAAc,CAAC,EAAE,cAAc,CAAA;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,oIAAoI;IACpI,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC;AAKD,uDAAuD;AACvD,qBAAa,mBAAoB,SAAQ,KAAK;CAAG;AAEjD,gDAAgD;AAChD,eAAO,MAAM,eAAe;;;IAG1B,kGAAkG;;IAElG,kGAAkG;;CAEnG,CAAA;AAgBD,qFAAqF;AACrF,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAa7D;AAuBD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,eAAe,GAAG,eAAe,CAsD3F;AAiBD,qGAAqG;AACrG,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe;;;;;;;;;;;EAapD;AAID,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAcD,wBAAgB,eAAe,CAAC,GAAG,EAAE;IAAE,EAAE,EAAE,cAAc,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE;0BAIlD,eAAe,KAAG,MAAM;6BAIrB,MAAM,GAAG,IAAI,KAAG,eAAe;oCAYxB,MAAM,KAAG,IAAI;0CAWP,MAAM,aAAa,MAAM,KAAG,IAAI;0BAmK1C,iBAAiB,QAAQ,MAAM,KAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gCAc5D,eAAe,KAAG,OAAO,CAAC,IAAI,CAAC;4BAKnC,iBAAiB,KAAG,OAAO,CAAC;QAAE,UAAU,EAAE,mBAAmB,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;+BAKxF,eAAe,KAAG,OAAO,CAAC;QAAE,UAAU,EAAE,mBAAmB,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;EAyBlI;AAED,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAA"}
@@ -0,0 +1,436 @@
1
+ import { Agent, fetch as httpFetch } from 'undici';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import { vaults, vaultSecrets } from './tables.js';
4
+ /** Message is always safe to return to the browser. */
5
+ export class HashicorpVaultError extends Error {
6
+ }
7
+ /** Mutable so tests can shorten the timeout. */
8
+ export const hashicorpLimits = {
9
+ timeoutMs: 10_000,
10
+ maxBodyBytes: 1024 * 1024,
11
+ /** A 403 for a token younger than this is a policy denial, not an expired token: no new login. */
12
+ freshTokenMs: 10_000,
13
+ /** Grace period before a replaced connection pool is closed, so in-flight requests can finish. */
14
+ agentCloseDelayMs: 30_000,
15
+ };
16
+ const MAX_FIELD = 2048;
17
+ const MAX_CA = 64 * 1024;
18
+ function text(value, label, max = MAX_FIELD) {
19
+ if (value === undefined || value === null || value === '')
20
+ return undefined;
21
+ if (typeof value !== 'string')
22
+ throw new HashicorpVaultError(`${label} must be a string`);
23
+ const trimmed = value.trim();
24
+ if (trimmed.length > max)
25
+ throw new HashicorpVaultError(`${label} is too long`);
26
+ // eslint-disable-next-line no-control-regex
27
+ if (/[\u0000-\u001f\u007f]/.test(trimmed))
28
+ throw new HashicorpVaultError(`${label} contains invalid characters`);
29
+ return trimmed || undefined;
30
+ }
31
+ /** Validates a slash-separated Vault path and returns it URL-encoded per segment. */
32
+ export function encodePath(raw, label) {
33
+ const segments = raw.trim().replace(/^\/+|\/+$/g, '').split('/');
34
+ if (segments.length === 0 || segments.some((s) => s === '')) {
35
+ throw new HashicorpVaultError(`${label} is invalid`);
36
+ }
37
+ return segments.map((segment) => {
38
+ let decoded;
39
+ try {
40
+ decoded = decodeURIComponent(segment);
41
+ }
42
+ catch {
43
+ throw new HashicorpVaultError(`${label} is invalid`);
44
+ }
45
+ if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) {
46
+ throw new HashicorpVaultError(`${label} is invalid`);
47
+ }
48
+ return encodeURIComponent(decoded);
49
+ }).join('/');
50
+ }
51
+ function requiredText(value, label) {
52
+ const v = text(value, label);
53
+ if (!v)
54
+ throw new HashicorpVaultError(`${label} is required`);
55
+ return v;
56
+ }
57
+ function normalizeAddress(raw) {
58
+ let url;
59
+ try {
60
+ url = new URL(raw);
61
+ }
62
+ catch {
63
+ throw new HashicorpVaultError('Address must be a valid URL');
64
+ }
65
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
66
+ throw new HashicorpVaultError('Address must start with http:// or https://');
67
+ }
68
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
69
+ throw new HashicorpVaultError('Address must be a plain URL such as https://vault.example.com:8200');
70
+ }
71
+ return url.origin;
72
+ }
73
+ /** True when changing these fields would redirect stored credentials somewhere else. */
74
+ const IDENTITY_FIELDS = ['address', 'namespace', 'mount', 'authMethod', 'approleMount', 'caCert'];
75
+ /**
76
+ * Validates connection input from the admin API. With `existing`, blank credentials keep the stored
77
+ * ones, except when the address, namespace, auth method or AppRole mount changes: credentials must
78
+ * then be sent again, so a changed address cannot receive a stored token.
79
+ */
80
+ export function parseConnection(input, existing) {
81
+ if (!input || typeof input !== 'object')
82
+ throw new HashicorpVaultError('Connection settings are required');
83
+ const body = input;
84
+ const authMethod = body['authMethod'] ?? existing?.authMethod;
85
+ if (authMethod !== 'token' && authMethod !== 'approle') {
86
+ throw new HashicorpVaultError('Auth method must be token or approle');
87
+ }
88
+ const namespace = text(body['namespace'], 'Namespace');
89
+ const cfg = {
90
+ address: normalizeAddress(requiredText(body['address'], 'Address')),
91
+ mount: requiredText(body['mount'] ?? 'secret', 'KV mount'),
92
+ authMethod,
93
+ approleMount: text(body['approleMount'], 'AppRole mount') ?? 'approle',
94
+ };
95
+ if (namespace)
96
+ cfg.namespace = namespace;
97
+ encodePath(cfg.mount, 'KV mount');
98
+ encodePath(cfg.approleMount, 'AppRole mount');
99
+ if (cfg.namespace)
100
+ encodePath(cfg.namespace, 'Namespace');
101
+ const caCert = text(body['caCert'], 'CA certificate', MAX_CA);
102
+ if (caCert) {
103
+ if (!caCert.includes('BEGIN CERTIFICATE'))
104
+ throw new HashicorpVaultError('CA certificate must be PEM encoded');
105
+ cfg.caCert = caCert;
106
+ }
107
+ const identityChanged = existing ? IDENTITY_FIELDS.some((f) => (existing[f] ?? '') !== (cfg[f] ?? '')) : false;
108
+ const keep = existing && !identityChanged ? existing : undefined;
109
+ // An object picks a local vault secret; null means direct input; absent keeps what is saved.
110
+ const refInput = body['credentialsRef'];
111
+ if (refInput !== undefined && refInput !== null) {
112
+ cfg.credentialsRef = parseCredentialsRef(refInput, authMethod);
113
+ return cfg;
114
+ }
115
+ const keptRef = refInput === undefined ? keep?.credentialsRef : undefined;
116
+ if (authMethod === 'token') {
117
+ const typed = text(body['token'], 'Token');
118
+ if (!typed && keptRef) {
119
+ cfg.credentialsRef = keptRef;
120
+ return cfg;
121
+ }
122
+ const token = typed ?? keep?.token;
123
+ if (!token)
124
+ throw new HashicorpVaultError(identityChanged ? 'Re-enter the token after changing the connection target' : 'Token is required');
125
+ cfg.token = token;
126
+ }
127
+ else {
128
+ const typedSecretId = text(body['secretId'], 'Secret ID');
129
+ const typedRoleId = text(body['roleId'], 'Role ID');
130
+ if (!typedSecretId && !typedRoleId && keptRef) {
131
+ cfg.credentialsRef = keptRef;
132
+ return cfg;
133
+ }
134
+ const roleId = typedRoleId ?? keep?.roleId;
135
+ const secretId = typedSecretId ?? keep?.secretId;
136
+ if (!roleId || !secretId) {
137
+ throw new HashicorpVaultError(identityChanged ? 'Re-enter the Role ID and Secret ID after changing the connection target' : 'Role ID and Secret ID are required');
138
+ }
139
+ cfg.roleId = roleId;
140
+ cfg.secretId = secretId;
141
+ }
142
+ return cfg;
143
+ }
144
+ function parseCredentialsRef(input, authMethod) {
145
+ const { vaultId, secretId, fieldMapping } = (typeof input === 'object' ? input : {});
146
+ if (!Number.isInteger(vaultId) || !Number.isInteger(secretId) || vaultId <= 0 || secretId <= 0) {
147
+ throw new HashicorpVaultError('Select a secret from a local vault for the credentials');
148
+ }
149
+ const ref = { vaultId: vaultId, secretId: secretId };
150
+ const mapping = {};
151
+ for (const field of authMethod === 'token' ? ['token'] : ['roleId', 'secretId']) {
152
+ const key = text(fieldMapping?.[field], 'JSON key', 200);
153
+ if (key)
154
+ mapping[field] = key;
155
+ }
156
+ if (Object.keys(mapping).length)
157
+ ref.fieldMapping = mapping;
158
+ return ref;
159
+ }
160
+ /** Connection settings safe to return to admins: credentials are reported only as present or not. */
161
+ export function publicConnection(cfg) {
162
+ return {
163
+ address: cfg.address,
164
+ namespace: cfg.namespace ?? '',
165
+ mount: cfg.mount,
166
+ authMethod: cfg.authMethod,
167
+ approleMount: cfg.approleMount,
168
+ roleId: cfg.roleId ?? '',
169
+ caCert: cfg.caCert ?? '',
170
+ hasToken: !!cfg.token,
171
+ hasSecretId: !!cfg.secretId,
172
+ credentialsRef: cfg.credentialsRef ?? null,
173
+ };
174
+ }
175
+ export function createHashicorp(ctx) {
176
+ const { db } = ctx;
177
+ const { encrypt, decrypt } = ctx.secretBox;
178
+ function sealConnection(cfg) {
179
+ return encrypt(JSON.stringify(cfg));
180
+ }
181
+ function openConnection(sealed) {
182
+ if (!sealed)
183
+ throw new HashicorpVaultError('Vault connection is not configured');
184
+ try {
185
+ return JSON.parse(decrypt(sealed));
186
+ }
187
+ catch {
188
+ throw new HashicorpVaultError('Failed to decrypt vault connection');
189
+ }
190
+ }
191
+ const sessions = new Map();
192
+ /** Drops the cached token and closes the connection pool; call after the vault is changed or deleted. */
193
+ function forgetHashicorpVault(vaultId) {
194
+ const session = sessions.get(vaultId);
195
+ sessions.delete(vaultId);
196
+ const agent = session?.agent;
197
+ if (agent)
198
+ setTimeout(() => void agent.close().catch(() => undefined), hashicorpLimits.agentCloseDelayMs).unref();
199
+ }
200
+ /**
201
+ * Drops the cached sessions of HashiCorp vaults that read their credentials from this local vault (or one
202
+ * of its secrets), so a rotated or deleted secret is used from the next request, also for AppRole.
203
+ */
204
+ function forgetCredentialDependents(vaultId, secretId) {
205
+ for (const [id, session] of sessions) {
206
+ const ref = session.cfg.credentialsRef;
207
+ if (ref && ref.vaultId === vaultId && (secretId === undefined || ref.secretId === secretId))
208
+ forgetHashicorpVault(id);
209
+ }
210
+ }
211
+ function sessionFor(vault) {
212
+ const existing = sessions.get(vault.id);
213
+ if (existing && existing.fingerprint === vault.updatedAt)
214
+ return existing;
215
+ forgetHashicorpVault(vault.id);
216
+ const cfg = openConnection(vault.connectionConfig);
217
+ const session = {
218
+ fingerprint: vault.updatedAt,
219
+ cfg,
220
+ agent: cfg.caCert ? new Agent({ connect: { ca: cfg.caCert } }) : undefined,
221
+ expiresAt: 0,
222
+ loginAt: 0,
223
+ };
224
+ sessions.set(vault.id, session);
225
+ return session;
226
+ }
227
+ function scrub(message, creds) {
228
+ let out = message;
229
+ for (const secret of [creds?.token, creds?.secretId])
230
+ if (secret)
231
+ out = out.split(secret).join('[redacted]');
232
+ return out;
233
+ }
234
+ /**
235
+ * Returns the credentials to use now. A referenced local vault secret is read on every call, so a
236
+ * rotation there takes effect on the next request; it must still exist, live in a local vault and
237
+ * have the type the auth method needs.
238
+ */
239
+ async function credentialsFor(session) {
240
+ const { cfg } = session;
241
+ const ref = cfg.credentialsRef;
242
+ if (!ref)
243
+ return (session.creds = { token: cfg.token, roleId: cfg.roleId, secretId: cfg.secretId });
244
+ const [row] = await db.select().from(vaultSecrets).where(and(eq(vaultSecrets.id, ref.secretId), eq(vaultSecrets.vaultId, ref.vaultId)));
245
+ const [owner] = row ? await db.select({ type: vaults.type }).from(vaults).where(eq(vaults.id, ref.vaultId)) : [];
246
+ if (!row || owner?.type !== 'local')
247
+ throw new HashicorpVaultError('the credentials secret no longer exists in a local vault');
248
+ let payload;
249
+ try {
250
+ payload = JSON.parse(decrypt(row.encryptedValue));
251
+ }
252
+ catch {
253
+ throw new HashicorpVaultError('the credentials secret cannot be decrypted');
254
+ }
255
+ // Same trimming and character checks as typed credentials, so a stray newline is a clear error, not a failed request.
256
+ const clean = (value) => { try {
257
+ return text(value, 'The credentials secret');
258
+ }
259
+ catch {
260
+ return undefined;
261
+ } };
262
+ // A JSON secret holds an object; the mapping (or the default key names) picks each credential from it.
263
+ let fields = {};
264
+ if (row.type === 'json') {
265
+ try {
266
+ fields = JSON.parse(String(payload['value'] ?? ''));
267
+ }
268
+ catch { /* reported below as a missing credential */ }
269
+ }
270
+ const pick = (field) => clean(fields[ref.fieldMapping?.[field] ?? field]);
271
+ if (cfg.authMethod === 'token') {
272
+ const token = row.type === 'value' ? clean(payload['value']) : row.type === 'json' ? pick('token') : undefined;
273
+ if (!token)
274
+ throw new HashicorpVaultError('the credentials secret must be a Secure Value, or a JSON secret with the token key');
275
+ return (session.creds = { token });
276
+ }
277
+ const roleId = row.type === 'userpass' ? clean(payload['username']) : row.type === 'json' ? pick('roleId') : undefined;
278
+ const secretId = row.type === 'userpass' ? clean(payload['password']) : row.type === 'json' ? pick('secretId') : undefined;
279
+ if (!roleId || !secretId) {
280
+ throw new HashicorpVaultError('the credentials secret must be a User / Password secret, or a JSON secret with the Role ID and Secret ID keys');
281
+ }
282
+ return (session.creds = { roleId, secretId });
283
+ }
284
+ function describeNetworkError(err) {
285
+ const e = err;
286
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
287
+ return `timed out after ${Math.round(hashicorpLimits.timeoutMs / 1000)} s`;
288
+ }
289
+ const code = e?.cause?.code ?? '';
290
+ if (/redirect/i.test(e?.cause?.message ?? e?.message ?? ''))
291
+ return 'redirects are not followed';
292
+ if (/^(UNABLE_TO|CERT_|DEPTH_ZERO|SELF_SIGNED|ERR_TLS|ERR_SSL|HOSTNAME_MISMATCH)/.test(code)) {
293
+ return `TLS certificate not trusted (${code})`;
294
+ }
295
+ return code ? `cannot connect (${code})` : 'cannot connect';
296
+ }
297
+ async function readCapped(res) {
298
+ const reader = res.body?.getReader();
299
+ if (!reader)
300
+ return null;
301
+ const chunks = [];
302
+ let size = 0;
303
+ for (;;) {
304
+ const { done, value } = await reader.read();
305
+ if (done)
306
+ break;
307
+ size += value.byteLength;
308
+ if (size > hashicorpLimits.maxBodyBytes) {
309
+ void reader.cancel();
310
+ throw new HashicorpVaultError('response from Vault is too large');
311
+ }
312
+ chunks.push(value);
313
+ }
314
+ try {
315
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ }
321
+ async function call(session, method, apiPath, token, body) {
322
+ const { cfg } = session;
323
+ const headers = { 'X-Vault-Request': 'true' };
324
+ if (token)
325
+ headers['X-Vault-Token'] = token;
326
+ if (cfg.namespace)
327
+ headers['X-Vault-Namespace'] = cfg.namespace;
328
+ if (body !== undefined)
329
+ headers['Content-Type'] = 'application/json';
330
+ try {
331
+ const res = await httpFetch(`${cfg.address}/v1/${apiPath}`, {
332
+ method,
333
+ headers,
334
+ ...(body !== undefined && { body: JSON.stringify(body) }),
335
+ redirect: 'error',
336
+ signal: AbortSignal.timeout(hashicorpLimits.timeoutMs),
337
+ ...(session.agent && { dispatcher: session.agent }),
338
+ });
339
+ return { status: res.status, json: await readCapped(res) };
340
+ }
341
+ catch (err) {
342
+ if (err instanceof HashicorpVaultError)
343
+ throw err;
344
+ throw new HashicorpVaultError(scrub(describeNetworkError(err), session.creds));
345
+ }
346
+ }
347
+ function login(session) {
348
+ session.login ??= (async () => {
349
+ const { cfg } = session;
350
+ const creds = await credentialsFor(session);
351
+ const { status, json } = await call(session, 'POST', `auth/${encodePath(cfg.approleMount, 'AppRole mount')}/login`, undefined, { role_id: creds.roleId, secret_id: creds.secretId });
352
+ const auth = json?.auth;
353
+ if (status !== 200 || !auth?.client_token) {
354
+ throw new HashicorpVaultError(status === 400 || status === 403 ? 'AppRole login was rejected' : `AppRole login failed (HTTP ${status})`);
355
+ }
356
+ const ttlMs = (auth.lease_duration ?? 0) * 1000;
357
+ session.token = auth.client_token;
358
+ session.loginAt = Date.now();
359
+ // A lease of 0 means the token does not expire; a 403 then decides when to log in again.
360
+ session.expiresAt = ttlMs > 0 ? session.loginAt + ttlMs - Math.min(60_000, ttlMs * 0.1) : Infinity;
361
+ return auth.client_token;
362
+ })().finally(() => { session.login = undefined; });
363
+ return session.login;
364
+ }
365
+ async function tokenFor(session) {
366
+ if (session.cfg.authMethod === 'token')
367
+ return (await credentialsFor(session)).token;
368
+ if (session.token && Date.now() < session.expiresAt)
369
+ return session.token;
370
+ return login(session);
371
+ }
372
+ /** Authenticated request; with AppRole a 403 triggers one fresh login and retry. */
373
+ async function authed(session, method, apiPath) {
374
+ const used = await tokenFor(session);
375
+ let res = await call(session, method, apiPath, used);
376
+ const expired = session.cfg.authMethod === 'approle' && session.token === used
377
+ && Date.now() - session.loginAt >= hashicorpLimits.freshTokenMs;
378
+ if (res.status === 403 && expired) {
379
+ session.token = undefined;
380
+ res = await call(session, method, apiPath, await tokenFor(session));
381
+ }
382
+ return res;
383
+ }
384
+ /** Reads the data of a KV v2 secret (latest version). */
385
+ async function readKvSecret(vault, path) {
386
+ const session = sessionFor(vault);
387
+ const kvPath = encodePath(path, 'Path');
388
+ const { status, json } = await authed(session, 'GET', `${encodePath(session.cfg.mount, 'KV mount')}/data/${kvPath}`);
389
+ if (status === 404)
390
+ throw new HashicorpVaultError(`secret not found at ${session.cfg.mount}/${path.replace(/^\/+|\/+$/g, '')}`);
391
+ if (status === 403)
392
+ throw new HashicorpVaultError('permission denied (HTTP 403)');
393
+ const data = json?.data?.data;
394
+ if (status !== 200 || !data || typeof data !== 'object' || Array.isArray(data)) {
395
+ throw new HashicorpVaultError(`unexpected response from Vault (HTTP ${status})`);
396
+ }
397
+ return data;
398
+ }
399
+ /** Fails unless the connection's credentials secret (if any) exists in a local vault with the type its auth method needs. */
400
+ async function verifyCredentialsRef(cfg) {
401
+ if (cfg.credentialsRef)
402
+ await credentialsFor({ fingerprint: 0, cfg, expiresAt: 0, loginAt: 0 });
403
+ }
404
+ /** Verifies the saved credentials. Returns the remaining token TTL when Vault reports one. */
405
+ async function testConnection(vault) {
406
+ return verifySession(sessionFor(vault));
407
+ }
408
+ /** Verifies connection settings that are not saved yet, without touching the session cache. */
409
+ async function testDraftConnection(cfg) {
410
+ const agent = cfg.caCert ? new Agent({ connect: { ca: cfg.caCert } }) : undefined;
411
+ try {
412
+ return await verifySession({ fingerprint: 0, cfg, agent, expiresAt: 0, loginAt: 0 });
413
+ }
414
+ finally {
415
+ void agent?.close();
416
+ }
417
+ }
418
+ async function verifySession(session) {
419
+ session.token = undefined;
420
+ session.loginAt = 0;
421
+ const { cfg } = session;
422
+ if (cfg.authMethod === 'approle') {
423
+ await login(session);
424
+ return { authMethod: 'approle', ttlSeconds: Math.max(0, Math.round((session.expiresAt - Date.now()) / 1000)) };
425
+ }
426
+ const { status, json } = await call(session, 'GET', 'auth/token/lookup-self', (await credentialsFor(session)).token);
427
+ if (status === 403)
428
+ throw new HashicorpVaultError('token was rejected (HTTP 403)');
429
+ if (status !== 200)
430
+ throw new HashicorpVaultError(`unexpected response from Vault (HTTP ${status})`);
431
+ const ttl = json?.data?.ttl;
432
+ return { authMethod: 'token', ttlSeconds: typeof ttl === 'number' && ttl > 0 ? ttl : null };
433
+ }
434
+ return { sealConnection, openConnection, forgetHashicorpVault, forgetCredentialDependents, readKvSecret, verifyCredentialsRef, testConnection, testDraftConnection };
435
+ }
436
+ //# sourceMappingURL=hashicorp.js.map