@nestlingjs/config.vault 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eonae
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 ADDED
@@ -0,0 +1,79 @@
1
+ # @nestlingjs/config.vault
2
+
3
+ A config source on top of HashiCorp Vault: a KV v2 secret is read with a
4
+ single request in phase 0, and the coordinates of the storage come from
5
+ another source — from `.env`, for example.
6
+
7
+ > 🚧 Active development, the API may change.
8
+ > Design: [`docs/en/design/config.md`](../../docs/en/design/config.md).
9
+ > Recipe: [config sources](../../docs/en/recipes/config-sources.md).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @nestlingjs/config.vault zod
15
+ ```
16
+
17
+ The package takes no Vault client: the request goes through the standard
18
+ `fetch`. `zod` installs next to it: the package writes the schema of its own
19
+ section in it, and one copy of the validator serves the package and the
20
+ application.
21
+
22
+ ## Minimal example
23
+
24
+ ```typescript
25
+ import { bind, defaultSources, makeApp } from '@nestlingjs/app';
26
+ import { vault, VaultConfig } from '@nestlingjs/config.vault';
27
+
28
+ export const app = makeApp({
29
+ features: [OrdersFeature],
30
+ transports: [http()],
31
+ });
32
+
33
+ await app.build().run({
34
+ config: [
35
+ bind(vault(VaultConfig, { retries: 2 }), { timeout: 3000 }),
36
+ ...defaultSources,
37
+ ],
38
+ });
39
+ ```
40
+
41
+ The order of the list is the priority of key resolution: the value from
42
+ Vault beats the value from the environment. The order of raising is
43
+ another one and follows on its own: `VAULT_ADDR` and `VAULT_TOKEN` are
44
+ named by the `VaultConfig` section, so Vault is raised after `env()` and
45
+ `dotenv('.env')`, which cover those keys.
46
+
47
+ ## Exports
48
+
49
+ | Name | What it is |
50
+ | --- | --- |
51
+ | `vault` | the source: the section of coordinates first, options second |
52
+ | `VaultConfig` | the ready section of coordinates with the `vault` prefix |
53
+ | `VaultCoordinates` | the shape of values `vault(section)` requires |
54
+ | `VaultOptions` | the options of the source: `retries` |
55
+
56
+ The `VaultConfig` section declares `VAULT_ADDR`, `VAULT_TOKEN`
57
+ (`secret()`), `VAULT_MOUNT` (defaults to `secret`) and `VAULT_PATH`. An
58
+ application with two storages declares its own section of any shape that
59
+ fits `VaultCoordinates` and passes it as the same argument: the keys take
60
+ its prefix.
61
+
62
+ `retries` is the number of extra attempts on a network failure and on a
63
+ `5xx` answer; the pause starts at 250 ms and doubles. Answers `401`, `403`
64
+ and `404` cause no retry. The time limit of raising is set by the binding
65
+ with the `timeout` option.
66
+
67
+ ## Package boundaries
68
+
69
+ There is one way to authenticate — the `X-Vault-Token` header. AppRole,
70
+ Kubernetes and AWS are not in the package: the minimal source goes with a
71
+ token it already has, and the way to get it stays with the environment.
72
+
73
+ There is no watching of the secret: the value is read once in phase 0 and
74
+ lives in the snapshot, like the value of any source. Dynamic secrets and
75
+ lease renewal are not supported — a rotated secret reaches the process on
76
+ a restart.
77
+
78
+ One engine is read — KV v2 at the `{mount}/data/{path}` path. A KV v1
79
+ answer fails the raise: it carries no `data.data` record.
package/README.ru.md ADDED
@@ -0,0 +1,77 @@
1
+ # @nestlingjs/config.vault
2
+
3
+ Источник конфигурации поверх HashiCorp Vault: секрет KV v2 читается одним
4
+ запросом на фазе 0, а координаты хранилища приходят из другого источника —
5
+ например из `.env`.
6
+
7
+ > 🚧 Активная разработка, API может меняться.
8
+ > Дизайн: [`docs/design/config.md`](../../docs/design/config.md).
9
+ > Рецепт: [источники конфигурации](../../docs/recipes/config-sources.md).
10
+
11
+ ## Установка
12
+
13
+ ```bash
14
+ npm install @nestlingjs/config.vault zod
15
+ ```
16
+
17
+ Клиента Vault пакет не берёт: запрос идёт штатным `fetch`. Рядом ставится
18
+ `zod`: схему своей секции пакет пишет им, и копия валидатора у него и у
19
+ приложения одна.
20
+
21
+ ## Минимальный пример
22
+
23
+ ```typescript
24
+ import { bind, defaultSources, makeApp } from '@nestlingjs/app';
25
+ import { vault, VaultConfig } from '@nestlingjs/config.vault';
26
+
27
+ export const app = makeApp({
28
+ features: [OrdersFeature],
29
+ transports: [http()],
30
+ });
31
+
32
+ await app.build().run({
33
+ config: [
34
+ bind(vault(VaultConfig, { retries: 2 }), { timeout: 3000 }),
35
+ ...defaultSources,
36
+ ],
37
+ });
38
+ ```
39
+
40
+ Порядок списка — приоритет разрешения ключа: значение из Vault побеждает
41
+ значение из окружения. Очерёдность подъёма другая и выводится сама:
42
+ `VAULT_ADDR` и `VAULT_TOKEN` называет секция `VaultConfig`, поэтому Vault
43
+ поднимается после `env()` и `dotenv('.env')`, которые эти ключи покрывают.
44
+
45
+ ## Экспорты
46
+
47
+ | Имя | Что это |
48
+ | --- | --- |
49
+ | `vault` | источник: секция координат аргументом, опции — вторым |
50
+ | `VaultConfig` | готовая секция координат с префиксом `vault` |
51
+ | `VaultCoordinates` | форма значений, которую требует `vault(section)` |
52
+ | `VaultOptions` | опции источника: `retries` |
53
+
54
+ Секция `VaultConfig` объявляет `VAULT_ADDR`, `VAULT_TOKEN` (`secret()`),
55
+ `VAULT_MOUNT` (умолчание `secret`) и `VAULT_PATH`. Приложение с двумя
56
+ хранилищами объявляет свою секцию любой формы, подходящей под
57
+ `VaultCoordinates`, и передаёт её тем же аргументом: ключи получат её
58
+ префикс.
59
+
60
+ `retries` — число дополнительных попыток при сетевом отказе и ответе
61
+ `5xx`; пауза начинается с 250 мс и удваивается. Ответы `401`, `403` и
62
+ `404` повтора не вызывают. Границу времени подъёма задаёт привязка опцией
63
+ `timeout`.
64
+
65
+ ## Границы пакета
66
+
67
+ Аутентификация одна — заголовок `X-Vault-Token`. AppRole, Kubernetes и
68
+ AWS в пакет не входят: минимальный источник ходит с готовыми учётными
69
+ данными, а способ их получить остаётся за стендом.
70
+
71
+ Наблюдения за секретом нет: значение читается один раз на фазе 0 и живёт
72
+ в снимке, как у любого источника. Динамические секреты и продление аренды
73
+ пакет не поддерживает — обновление секрета доходит до процесса
74
+ перезапуском.
75
+
76
+ Движок читается один — KV v2 по пути `{mount}/data/{path}`. Ответ KV v1
77
+ отказывает подъём: у него нет рекорда `data.data`.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Секция координат хранилища и её форма.
3
+ *
4
+ * Секция передаётся источнику аргументом, а не берётся им из себя: связь
5
+ * «источник ↔ его координаты» видна в строке вызова. `VaultConfig` —
6
+ * готовая секция на обычный случай; приложение с двумя хранилищами
7
+ * объявляет свою секцию любой формы, подходящей под {@link VaultCoordinates}.
8
+ */
9
+ import type { ConfigSectionToken } from '@nestlingjs/app';
10
+ /**
11
+ * Значения, которые источник берёт из секции.
12
+ *
13
+ * Тип ограничивает аргумент {@link vault}: секция без адреса или без пути
14
+ * не компилируется, а секция с лишними полями годится — источник читает
15
+ * только эти четыре.
16
+ */
17
+ export interface VaultCoordinates {
18
+ /** Адрес сервера: `https://vault.internal:8200` */
19
+ readonly addr: string;
20
+ /** Учётные данные: уходят заголовком `X-Vault-Token` */
21
+ readonly token: string;
22
+ /** Точка монтирования движка KV v2 */
23
+ readonly mount: string;
24
+ /** Путь секрета внутри точки монтирования */
25
+ readonly path: string;
26
+ }
27
+ /**
28
+ * Секция координат: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT`, `VAULT_PATH`.
29
+ *
30
+ * Поле `token` объявлено `secret()`, поэтому его значение не попадает ни в
31
+ * снимок реестра, ни в диагностику. Точка монтирования по умолчанию — `secret`,
32
+ * то есть движок KV, который Vault поднимает сам.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * await app.build().run({
37
+ * config: [bind(vault(VaultConfig)), ...defaultSources],
38
+ * });
39
+ * ```
40
+ */
41
+ export declare const VaultConfig: ConfigSectionToken<VaultCoordinates, 'vault'>;
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Секция координат хранилища и её форма.
3
+ *
4
+ * Секция передаётся источнику аргументом, а не берётся им из себя: связь
5
+ * «источник ↔ его координаты» видна в строке вызова. `VaultConfig` —
6
+ * готовая секция на обычный случай; приложение с двумя хранилищами
7
+ * объявляет свою секцию любой формы, подходящей под {@link VaultCoordinates}.
8
+ */ import { makeConfig, secret } from '@nestlingjs/app';
9
+ import { z } from 'zod';
10
+ /**
11
+ * Секция координат: `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT`, `VAULT_PATH`.
12
+ *
13
+ * Поле `token` объявлено `secret()`, поэтому его значение не попадает ни в
14
+ * снимок реестра, ни в диагностику. Точка монтирования по умолчанию — `secret`,
15
+ * то есть движок KV, который Vault поднимает сам.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * await app.build().run({
20
+ * config: [bind(vault(VaultConfig)), ...defaultSources],
21
+ * });
22
+ * ```
23
+ */ export const VaultConfig = makeConfig('vault', {
24
+ addr: z.url(),
25
+ token: secret(z.string()),
26
+ mount: z.string().default('secret'),
27
+ path: z.string()
28
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@nestlingjs/config.vault`: источник конфигурации поверх HashiCorp Vault.
3
+ *
4
+ * Барель перечисляет имена поимённо, а не через `export *`. Наружу выходят
5
+ * источник, готовая секция координат и два типа — остальное остаётся
6
+ * внутренним, и менять его можно без ломающей правки для тех, кто
7
+ * установил пакет.
8
+ */
9
+ export { VaultConfig } from './config.js';
10
+ export type { VaultCoordinates } from './config.js';
11
+ export { vault } from './source.js';
12
+ export type { VaultOptions } from './source.js';
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@nestlingjs/config.vault`: источник конфигурации поверх HashiCorp Vault.
3
+ *
4
+ * Барель перечисляет имена поимённо, а не через `export *`. Наружу выходят
5
+ * источник, готовая секция координат и два типа — остальное остаётся
6
+ * внутренним, и менять его можно без ломающей правки для тех, кто
7
+ * установил пакет.
8
+ */ // ./config.js — 2
9
+ export { VaultConfig } from './config.js';
10
+ // ./source.js — 2
11
+ export { vault } from './source.js';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Источник конфигурации поверх Vault: секрет KV v2 читается одним запросом
3
+ * на фазе 0.
4
+ *
5
+ * Координаты приходят значениями секции, объявленной в `needs`, поэтому
6
+ * читалка поднимает Vault после источников, которые эти ключи покрывают.
7
+ * Запрос идёт штатным `fetch` — клиента Vault в зависимостях пакета нет.
8
+ */
9
+ import type { VaultCoordinates } from './config.js';
10
+ import type { ConfigSectionToken, ConfigSource } from '@nestlingjs/app';
11
+ /** Опции {@link vault} */
12
+ export interface VaultOptions {
13
+ /**
14
+ * Число дополнительных попыток при сетевом отказе и ответе `5xx`.
15
+ *
16
+ * Умолчание `0` — попытка одна. Пауза перед первым повтором 250 мс и
17
+ * дальше удваивается. Ответы `401`, `403` и `404` повтора не вызывают:
18
+ * сами они не проходят. Границу времени подъёма задаёт привязка опцией
19
+ * `timeout`, а не источник.
20
+ */
21
+ readonly retries?: number;
22
+ }
23
+ /**
24
+ * Источник значений из секрета Vault.
25
+ *
26
+ * Секция координат приходит аргументом: `vault(VaultConfig)` берёт готовую
27
+ * секцию пакета, а приложение с двумя хранилищами объявляет свою секцию
28
+ * любой формы, подходящей под `VaultCoordinates`. Она же становится полем
29
+ * `needs` источника, поэтому фаза 0 поднимает Vault после источников,
30
+ * покрывающих её ключи.
31
+ *
32
+ * `init()` делает один запрос `GET {addr}/v1/{mount}/data/{path}` с
33
+ * заголовком `X-Vault-Token` и запоминает рекорд `data.data`. Дальше
34
+ * `get(key)` читает из этого рекорда, не обращаясь к сети.
35
+ *
36
+ * @param section - DI-токен секции координат
37
+ * @param options - Число повторов при временном отказе
38
+ * @returns Источник для `bind()`
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * await app.build().run({
43
+ * config: [
44
+ * bind(vault(VaultConfig, { retries: 2 }), { timeout: 3000 }),
45
+ * ...defaultSources,
46
+ * ],
47
+ * });
48
+ * ```
49
+ */
50
+ export declare const vault: <T extends VaultCoordinates>(section: ConfigSectionToken<T>, options?: VaultOptions) => ConfigSource<T>;
package/dist/source.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Источник конфигурации поверх Vault: секрет KV v2 читается одним запросом
3
+ * на фазе 0.
4
+ *
5
+ * Координаты приходят значениями секции, объявленной в `needs`, поэтому
6
+ * читалка поднимает Vault после источников, которые эти ключи покрывают.
7
+ * Запрос идёт штатным `fetch` — клиента Vault в зависимостях пакета нет.
8
+ */ /** Пауза перед первым повтором, мс; перед каждым следующим удваивается */ const FIRST_RETRY_DELAY = 250;
9
+ /**
10
+ * Отказ запроса к Vault.
11
+ *
12
+ * Флаг `retryable` отделяет временное от постоянного: повторяются сетевой
13
+ * отказ и `5xx`, а отказ прав или отсутствие секрета сами не пройдут.
14
+ */ let VaultRequestError = class VaultRequestError extends Error {
15
+ retryable;
16
+ constructor(message, /** Отказ временный: повтор имеет смысл */ retryable, cause){
17
+ super(message, cause === undefined ? undefined : {
18
+ cause
19
+ }), this.retryable = retryable;
20
+ this.name = 'VaultRequestError';
21
+ }
22
+ };
23
+ /** Адрес секрета KV v2; лишние слэши адреса отбрасываются */ const secretUrl = (coordinates)=>`${coordinates.addr.replaceAll(/\/+$/g, '')}/v1/${coordinates.mount}/data/${coordinates.path}`;
24
+ /**
25
+ * Хранилище в тексте отказа: адрес, точка монтирования и путь.
26
+ *
27
+ * Учётных данных здесь нет и не будет: ошибка старта попадает в лог
28
+ * целиком.
29
+ */ const describeTarget = (coordinates)=>`Vault at ${coordinates.addr} (mount '${coordinates.mount}', path '${coordinates.path}')`;
30
+ /** Что означает ответ, который сам по себе не пройдёт */ const explainStatus = (status)=>{
31
+ if (status === 401 || status === 403) {
32
+ return `: the token is not allowed to read this path`;
33
+ }
34
+ if (status === 404) {
35
+ return `: there is no secret at this path, or the mount is another one`;
36
+ }
37
+ return '';
38
+ };
39
+ const sleep = (ms)=>new Promise((resolve)=>{
40
+ setTimeout(resolve, ms);
41
+ });
42
+ /** Один запрос секрета: рекорд `data.data` ответа или отказ */ const requestSecret = async (coordinates)=>{
43
+ const target = describeTarget(coordinates);
44
+ let response;
45
+ try {
46
+ response = await fetch(secretUrl(coordinates), {
47
+ headers: {
48
+ 'X-Vault-Token': coordinates.token
49
+ }
50
+ });
51
+ } catch (error) {
52
+ throw new VaultRequestError(`${target} is unreachable`, true, error);
53
+ }
54
+ if (!response.ok) {
55
+ throw new VaultRequestError(`${target} answered ${response.status}${explainStatus(response.status)}`, response.status >= 500);
56
+ }
57
+ let payload;
58
+ try {
59
+ payload = await response.json();
60
+ } catch (error) {
61
+ throw new VaultRequestError(`${target} answered with a body that is not JSON`, false, error);
62
+ }
63
+ const data = payload.data?.data;
64
+ if (!data) {
65
+ throw new VaultRequestError(`${target} answered without 'data.data': the path holds no secret, or the mount runs the KV v1 engine`, false);
66
+ }
67
+ return data;
68
+ };
69
+ /**
70
+ * Читает секрет, повторяя временные отказы.
71
+ *
72
+ * Повтор принадлежит источнику, а не ядру: ядру нечем отличить временный
73
+ * отказ от постоянного, а источник знает свой протокол.
74
+ */ const readSecret = async (coordinates, retries)=>{
75
+ let delay = FIRST_RETRY_DELAY;
76
+ for(let attempt = 0;; attempt += 1){
77
+ try {
78
+ return await requestSecret(coordinates);
79
+ } catch (error) {
80
+ if (attempt >= retries || !(error instanceof VaultRequestError)) {
81
+ throw error;
82
+ }
83
+ if (!error.retryable) {
84
+ throw error;
85
+ }
86
+ await sleep(delay);
87
+ delay *= 2;
88
+ }
89
+ }
90
+ };
91
+ /**
92
+ * Источник значений из секрета Vault.
93
+ *
94
+ * Секция координат приходит аргументом: `vault(VaultConfig)` берёт готовую
95
+ * секцию пакета, а приложение с двумя хранилищами объявляет свою секцию
96
+ * любой формы, подходящей под `VaultCoordinates`. Она же становится полем
97
+ * `needs` источника, поэтому фаза 0 поднимает Vault после источников,
98
+ * покрывающих её ключи.
99
+ *
100
+ * `init()` делает один запрос `GET {addr}/v1/{mount}/data/{path}` с
101
+ * заголовком `X-Vault-Token` и запоминает рекорд `data.data`. Дальше
102
+ * `get(key)` читает из этого рекорда, не обращаясь к сети.
103
+ *
104
+ * @param section - DI-токен секции координат
105
+ * @param options - Число повторов при временном отказе
106
+ * @returns Источник для `bind()`
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * await app.build().run({
111
+ * config: [
112
+ * bind(vault(VaultConfig, { retries: 2 }), { timeout: 3000 }),
113
+ * ...defaultSources,
114
+ * ],
115
+ * });
116
+ * ```
117
+ */ export const vault = (section, options = {})=>{
118
+ const retries = options.retries ?? 0;
119
+ let values = {};
120
+ return {
121
+ name: `vault(${section.keys.prefix})`,
122
+ needs: section,
123
+ init: async (coordinates)=>{
124
+ values = await readSecret(coordinates, retries);
125
+ },
126
+ get: (key)=>values[key]
127
+ };
128
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@nestlingjs/config.vault",
3
+ "version": "0.4.0",
4
+ "description": "Источник конфигурации поверх HashiCorp Vault: секрет KV v2 читается на фазе 0 по координатам из другой секции",
5
+ "keywords": [
6
+ "nestling",
7
+ "typescript",
8
+ "esm",
9
+ "config",
10
+ "vault",
11
+ "secrets"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "eonae",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/eonae/nestling.git",
18
+ "directory": "packages/nestling.config.vault"
19
+ },
20
+ "homepage": "https://github.com/eonae/nestling/tree/main/packages/nestling.config.vault#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/eonae/nestling/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "type": "module",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "clear": "rm -rf dist",
42
+ "vault:up": "docker compose up -d --wait",
43
+ "vault:down": "docker compose down -v",
44
+ "typecheck": "run -T tsc -p tsconfig.json",
45
+ "build": "node ../../scripts/build-package.mjs",
46
+ "lint": "run -T eslint .",
47
+ "lint:fix": "yarn run lint --fix",
48
+ "test": "run -T vitest run"
49
+ },
50
+ "dependencies": {
51
+ "@nestlingjs/app": "0.4.0"
52
+ },
53
+ "peerDependencies": {
54
+ "zod": "^4.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "typescript": "5.7.3",
58
+ "zod": "^4.0.0"
59
+ }
60
+ }