@nage-api/config 1.0.0-beta.2

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.
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * AWS Secrets Manager provider (PLAN.md §11.1 layer 5).
4
+ *
5
+ * The SDK is an **optional peer** loaded lazily: an application using env-based
6
+ * secrets should not carry an AWS client in its image. The client is also
7
+ * injectable, which is what makes this testable without the SDK installed and
8
+ * without a network — the legacy AWS-only singleton was neither.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.AwsSecretProvider = void 0;
12
+ const core_1 = require("@nage-api/core");
13
+ class AwsSecretProvider {
14
+ provider = 'aws-secrets-manager';
15
+ #options;
16
+ #client;
17
+ #commandFactory;
18
+ constructor(options = {}) {
19
+ this.#options = options;
20
+ this.#client = options.client;
21
+ this.#commandFactory = options.commandFactory;
22
+ }
23
+ async get(name) {
24
+ const client = await this.#resolveClient();
25
+ const commandFactory = this.#commandFactory;
26
+ if (commandFactory === undefined) {
27
+ throw new core_1.ConfigurationError({ detail: 'AWS secret provider is not initialised' });
28
+ }
29
+ const secretId = `${this.#options.prefix ?? ''}${name}`;
30
+ try {
31
+ const response = await client.send(commandFactory({ SecretId: secretId }));
32
+ return response.SecretString ?? null;
33
+ }
34
+ catch (error) {
35
+ if (isResourceNotFound(error))
36
+ return null;
37
+ // The upstream body may quote the secret's own metadata, so it is logged
38
+ // rather than returned (§17).
39
+ throw new core_1.ExternalServiceError('aws-secrets-manager', {
40
+ detail: `Could not read secret "${name}"`,
41
+ cause: error,
42
+ meta: { secretId },
43
+ });
44
+ }
45
+ }
46
+ async require(name) {
47
+ const value = await this.get(name);
48
+ if (value === null) {
49
+ throw new core_1.ConfigurationError({
50
+ detail: `Required secret "${name}" is not available`,
51
+ meta: { provider: this.provider, secretId: `${this.#options.prefix ?? ''}${name}` },
52
+ });
53
+ }
54
+ return value;
55
+ }
56
+ async #resolveClient() {
57
+ if (this.#client !== undefined)
58
+ return this.#client;
59
+ const importSdk = this.#options.importSdk ?? defaultSdkImport;
60
+ let sdk;
61
+ try {
62
+ sdk = await importSdk();
63
+ }
64
+ catch (error) {
65
+ throw new core_1.ConfigurationError({
66
+ message: 'secrets.provider is "aws" but @aws-sdk/client-secrets-manager is not installed. ' +
67
+ 'Install it, or switch secrets.provider to "env".',
68
+ cause: error,
69
+ });
70
+ }
71
+ this.#client = new sdk.SecretsManagerClient(this.#options.region === undefined ? {} : { region: this.#options.region });
72
+ this.#commandFactory ??= (input) => new sdk.GetSecretValueCommand(input);
73
+ return this.#client;
74
+ }
75
+ }
76
+ exports.AwsSecretProvider = AwsSecretProvider;
77
+ /**
78
+ * The specifier is held in a variable on purpose: the SDK is an optional peer,
79
+ * so a literal `import('@aws-sdk/...')` would make the package fail to compile
80
+ * wherever it is not installed — which is most installs.
81
+ */
82
+ const AWS_SDK_MODULE = '@aws-sdk/client-secrets-manager';
83
+ async function defaultSdkImport() {
84
+ const imported = await import(AWS_SDK_MODULE);
85
+ return imported;
86
+ }
87
+ function isResourceNotFound(error) {
88
+ return (typeof error === 'object' &&
89
+ error !== null &&
90
+ error.name === 'ResourceNotFoundException');
91
+ }
92
+ //# sourceMappingURL=aws.secret-provider.js.map
@@ -0,0 +1,24 @@
1
+ /**
2
+ * TTL cache in front of any `SecretProviderPort`.
3
+ *
4
+ * A remote secret store is a network call and a rate limit; resolving the same
5
+ * signing key on every request would be both slow and a dependency of every
6
+ * request on that store's availability. Wrapping is a decorator rather than a
7
+ * feature of each provider so caching behaves identically for all of them.
8
+ */
9
+ import type { SecretProviderPort } from '@nage-api/contracts';
10
+ export interface CachingSecretProviderOptions {
11
+ readonly ttlSeconds?: number;
12
+ /** Injectable clock, so the TTL is testable without waiting. */
13
+ readonly now?: () => number;
14
+ }
15
+ export declare class CachingSecretProvider implements SecretProviderPort {
16
+ #private;
17
+ constructor(inner: SecretProviderPort, options?: CachingSecretProviderOptions);
18
+ get provider(): string;
19
+ get(name: string): Promise<string | null>;
20
+ require(name: string): Promise<string>;
21
+ /** Drop cached values — used after a rotation event. */
22
+ invalidate(name?: string): void;
23
+ }
24
+ //# sourceMappingURL=caching.secret-provider.d.ts.map
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ /**
3
+ * TTL cache in front of any `SecretProviderPort`.
4
+ *
5
+ * A remote secret store is a network call and a rate limit; resolving the same
6
+ * signing key on every request would be both slow and a dependency of every
7
+ * request on that store's availability. Wrapping is a decorator rather than a
8
+ * feature of each provider so caching behaves identically for all of them.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.CachingSecretProvider = void 0;
12
+ class CachingSecretProvider {
13
+ #inner;
14
+ #ttlMs;
15
+ #now;
16
+ #cache = new Map();
17
+ constructor(inner, options = {}) {
18
+ this.#inner = inner;
19
+ this.#ttlMs = (options.ttlSeconds ?? 300) * 1000;
20
+ this.#now = options.now ?? Date.now;
21
+ }
22
+ get provider() {
23
+ return `${this.#inner.provider}+cache`;
24
+ }
25
+ async get(name) {
26
+ const cached = this.#cache.get(name);
27
+ if (cached !== undefined && cached.expiresAt > this.#now())
28
+ return cached.value;
29
+ const value = await this.#inner.get(name);
30
+ if (this.#ttlMs > 0) {
31
+ this.#cache.set(name, { value, expiresAt: this.#now() + this.#ttlMs });
32
+ }
33
+ return value;
34
+ }
35
+ async require(name) {
36
+ const cached = this.#cache.get(name);
37
+ if (cached !== undefined && cached.expiresAt > this.#now() && cached.value !== null) {
38
+ return cached.value;
39
+ }
40
+ const value = await this.#inner.require(name);
41
+ if (this.#ttlMs > 0) {
42
+ this.#cache.set(name, { value, expiresAt: this.#now() + this.#ttlMs });
43
+ }
44
+ return value;
45
+ }
46
+ /** Drop cached values — used after a rotation event. */
47
+ invalidate(name) {
48
+ if (name === undefined)
49
+ this.#cache.clear();
50
+ else
51
+ this.#cache.delete(name);
52
+ }
53
+ }
54
+ exports.CachingSecretProvider = CachingSecretProvider;
55
+ //# sourceMappingURL=caching.secret-provider.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Secrets from the environment (PLAN.md §11.1 layer 5).
3
+ *
4
+ * The default provider, and the right one for container platforms that inject
5
+ * secrets as env vars. Swapping to AWS or Vault is a config change because
6
+ * consumers depend on `SecretProviderPort`, not on this class.
7
+ */
8
+ import type { SecretProviderPort } from '@nage-api/contracts';
9
+ export interface EnvSecretProviderOptions {
10
+ /** Prefix applied to every lookup, e.g. `APP_`. */
11
+ readonly prefix?: string;
12
+ readonly source?: Record<string, string | undefined>;
13
+ }
14
+ export declare class EnvSecretProvider implements SecretProviderPort {
15
+ #private;
16
+ readonly provider = "env";
17
+ constructor(options?: EnvSecretProviderOptions);
18
+ get(name: string): Promise<string | null>;
19
+ require(name: string): Promise<string>;
20
+ }
21
+ //# sourceMappingURL=env.secret-provider.d.ts.map
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * Secrets from the environment (PLAN.md §11.1 layer 5).
4
+ *
5
+ * The default provider, and the right one for container platforms that inject
6
+ * secrets as env vars. Swapping to AWS or Vault is a config change because
7
+ * consumers depend on `SecretProviderPort`, not on this class.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.EnvSecretProvider = void 0;
11
+ const core_1 = require("@nage-api/core");
12
+ class EnvSecretProvider {
13
+ provider = 'env';
14
+ #prefix;
15
+ #source;
16
+ constructor(options = {}) {
17
+ this.#prefix = options.prefix ?? '';
18
+ this.#source = options.source ?? process.env;
19
+ }
20
+ get(name) {
21
+ const value = this.#source[`${this.#prefix}${name}`];
22
+ // An empty string is treated as unset: a blank secret is a misconfiguration,
23
+ // not a valid credential.
24
+ return Promise.resolve(value === undefined || value === '' ? null : value);
25
+ }
26
+ async require(name) {
27
+ const value = await this.get(name);
28
+ if (value === null) {
29
+ throw new core_1.ConfigurationError({
30
+ detail: `Required secret "${name}" is not set`,
31
+ meta: { provider: this.provider, variable: `${this.#prefix}${name}` },
32
+ });
33
+ }
34
+ return value;
35
+ }
36
+ }
37
+ exports.EnvSecretProvider = EnvSecretProvider;
38
+ //# sourceMappingURL=env.secret-provider.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * DI tokens owned by `@nage-api/config`.
3
+ *
4
+ * `NAGE_CONFIG` itself is declared by `@nage-api/core`, since core's bootstrap reads
5
+ * the config without knowing this package exists — the direction that lets an
6
+ * app boot with a hand-written literal and adopt `@nage-api/config` later.
7
+ */
8
+ import { type Token } from '@nage-api/core';
9
+ import type { SecretProviderPort } from '@nage-api/contracts';
10
+ import type { EnvRecord } from './config.service.js';
11
+ /** The validated environment object produced by the app's zod schema. */
12
+ export declare const NAGE_ENV: Token<EnvRecord>;
13
+ /** The configured secret source (§11.1 layer 5). */
14
+ export declare const NAGE_SECRET_PROVIDER: Token<SecretProviderPort>;
15
+ //# sourceMappingURL=tokens.d.ts.map
package/dist/tokens.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * DI tokens owned by `@nage-api/config`.
4
+ *
5
+ * `NAGE_CONFIG` itself is declared by `@nage-api/core`, since core's bootstrap reads
6
+ * the config without knowing this package exists — the direction that lets an
7
+ * app boot with a hand-written literal and adopt `@nage-api/config` later.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.NAGE_SECRET_PROVIDER = exports.NAGE_ENV = void 0;
11
+ const core_1 = require("@nage-api/core");
12
+ /** The validated environment object produced by the app's zod schema. */
13
+ exports.NAGE_ENV = (0, core_1.createToken)('NAGE_ENV');
14
+ /** The configured secret source (§11.1 layer 5). */
15
+ exports.NAGE_SECRET_PROVIDER = (0, core_1.createToken)('NAGE_SECRET_PROVIDER');
16
+ //# sourceMappingURL=tokens.js.map
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@nage-api/config",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "One typed configuration for a @nage-api application — defineConfig, zod env validation, secret providers",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/.tsbuildinfo",
20
+ "!dist/**/*.map",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "zod": "4.4.3",
28
+ "@nage-api/contracts": "1.0.0-beta.2",
29
+ "@nage-api/core": "1.0.0-beta.2"
30
+ },
31
+ "peerDependencies": {
32
+ "@aws-sdk/client-secrets-manager": "^3.0.0",
33
+ "@nestjs/common": "^11.0.0",
34
+ "@nestjs/core": "^11.0.0",
35
+ "reflect-metadata": "^0.2.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@aws-sdk/client-secrets-manager": {
39
+ "optional": true
40
+ }
41
+ },
42
+ "devDependencies": {
43
+ "@nestjs/common": "11.1.29",
44
+ "@nestjs/core": "11.1.29",
45
+ "@nestjs/platform-express": "11.1.29",
46
+ "@nestjs/testing": "11.1.29",
47
+ "@swc/core": "1.15.47",
48
+ "@types/node": "22.20.1",
49
+ "@types/supertest": "7.2.1",
50
+ "@vitest/coverage-v8": "4.1.10",
51
+ "reflect-metadata": "0.2.2",
52
+ "rimraf": "6.1.3",
53
+ "rxjs": "7.8.2",
54
+ "supertest": "7.2.2",
55
+ "typescript": "5.9.3",
56
+ "unplugin-swc": "1.5.11",
57
+ "vitest": "4.1.10"
58
+ },
59
+ "engines": {
60
+ "node": ">=22.0.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc -b tsconfig.build.json",
64
+ "clean": "rimraf dist .turbo",
65
+ "typecheck": "tsc -p tsconfig.json --noEmit",
66
+ "test": "vitest run"
67
+ }
68
+ }