@fonderie/config 3.0.0 → 5.0.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
@@ -1,7 +1,9 @@
1
1
  # @fonderie/config
2
2
 
3
- DB-backed feature flags and remote config: per-environment overrides and
4
- TTL-cached snapshots, changeable at runtime without a deploy.
3
+ A Postgres-backed **control plane** for the settings and secrets your app changes
4
+ without a deploy — feature flags, remote config, and secrets each **versioned,
5
+ with optimistic concurrency, revisions, rollback, and near-instant propagation**.
6
+ Manage it over an admin HTTP surface or the `fonderie` CLI.
5
7
 
6
8
  ## Install
7
9
 
@@ -9,35 +11,102 @@ TTL-cached snapshots, changeable at runtime without a deploy.
9
11
  npm install @fonderie/config
10
12
  ```
11
13
 
12
- ## Use
14
+ ## Read config at runtime
15
+
16
+ Register the module and read live values through `ctx.meta` — a per-environment
17
+ snapshot (with `'all'` as the base, env-specific overrides on top) kept fresh by
18
+ a poll floor and, when a `connectionUrl` is given, **`LISTEN/NOTIFY` push** so a
19
+ change lands in milliseconds.
13
20
 
14
21
  ```ts
15
22
  import { FonderieApp, defineConfig } from '@fonderie/core';
16
- import { ConfigModule } from '@fonderie/config';
23
+ import { PGAdapter } from '@fonderie/store';
24
+ import { ConfigModule, getConfig } from '@fonderie/config';
17
25
 
26
+ const store = new PGAdapter(process.env.DATABASE_URL);
18
27
  const app = await new FonderieApp(defineConfig({}))
19
- .register(new ConfigModule())
28
+ .register(new ConfigModule(store, {
29
+ environment: 'production',
30
+ connectionUrl: process.env.DATABASE_URL, // opt-in push invalidation
31
+ }))
20
32
  .boot();
33
+
34
+ // in a handler:
35
+ const on = getConfig(ctx, 'feature.new-onboarding', false);
36
+ ```
37
+
38
+ ## Manage it (versioned + optimistic concurrency + rollback)
39
+
40
+ Every write bumps a monotonic `version`, appends an immutable revision, and
41
+ records the actor. Pass `ifVersion` for a compare-and-swap — the write commits
42
+ only if the version matches, else throws `ConfigConflictError` (reject-and-retry).
43
+ Writes are advisory-locked per `(key, environment)`, so concurrent creators of the
44
+ same key are serialized. `rollbackConfigEntry` rolls *forward* to a past value.
45
+
46
+ ```ts
47
+ import {
48
+ setConfigEntry, getConfigEntry, rollbackConfigEntry,
49
+ listConfigRevisions, ConfigConflictError,
50
+ } from '@fonderie/config';
51
+
52
+ await setConfigEntry({ key: 'rate.limit', value: 100, ifVersion: 3, actor: 'ada' }, store);
53
+ const revs = await listConfigRevisions('rate.limit', 'all', store);
54
+ await rollbackConfigEntry({ key: 'rate.limit', toVersion: 2, actor: 'ada' }, store);
55
+ ```
56
+
57
+ ## Secrets (masked, encryptable)
58
+
59
+ Same lifecycle, a separate `fonderie_secrets` table + read path so config can
60
+ never leak a secret. `getSecret`/`listSecrets` return **metadata only** (never the
61
+ value); values are **encrypted at rest** via a pluggable encryptor; `revealSecret`
62
+ is the single decrypt path.
63
+
64
+ ```ts
65
+ import { setSecret, revealSecret, createAesGcmEncryptor } from '@fonderie/config';
66
+
67
+ const enc = createAesGcmEncryptor(process.env.SECRET_KEY); // 32-byte hex, or omit for masked-only
68
+ await setSecret({ key: 'stripe.key', value: 'sk_live_…', actor: 'ada' }, store, enc);
69
+ const value = await revealSecret('stripe.key', 'all', store, enc);
21
70
  ```
22
71
 
72
+ ## Admin HTTP surface
73
+
74
+ Set `adminToken` and the module registers `Bearer`-guarded routes (fail-closed —
75
+ no token, no surface): `GET/PUT/DELETE /admin/config[/:key]`,
76
+ `GET /admin/config/:key/revisions`, `POST /admin/config/:key/rollback`, and the
77
+ same for `/admin/secrets/*` (`PUT` honours `ifVersion` → **409** on conflict;
78
+ secret reads masked; `POST /admin/secrets/:key/reveal` decrypts).
79
+
23
80
  ```ts
24
- import { configContextMiddleware, getConfig } from '@fonderie/config';
81
+ new ConfigModule(store, { adminToken: process.env.ADMIN_TOKEN, secretEncryptor: enc });
25
82
  ```
26
83
 
27
- CRUD services (`listConfigEntries`, `setConfigEntry`, …) are exported for
28
- building an admin surface on top.
84
+ ## CLI
85
+
86
+ A thin client over that surface (`FONDERIE_ADMIN_URL` + `FONDERIE_ADMIN_TOKEN`):
87
+
88
+ ```sh
89
+ fonderie config set feature.new-onboarding true --if-version 3
90
+ fonderie config history feature.new-onboarding
91
+ fonderie config rollback feature.new-onboarding --to-version 2
92
+ fonderie secret set stripe.key sk_live_… && fonderie secret reveal stripe.key
93
+ ```
94
+
95
+ ## Dependencies
96
+
97
+ Zero runtime dependencies. Peers only: `@fonderie/core`, `@fonderie/store`, and
98
+ `pg` (for the LISTEN client) — all already present in a Fonderie app.
29
99
 
30
100
  ## Why this exists
31
101
 
32
- You've shipped this plumbing before — auth, teams, billing, messaging —
33
- and the next project will ask for it again. Fonderie packages it once:
34
- plain TypeScript modules for
102
+ You've shipped this plumbing before — auth, teams, billing, messaging — and the
103
+ next project will ask for it again. Fonderie packages it once: plain TypeScript
104
+ modules for
35
105
  [`@fonderie/core`](https://github.com/fonderie-js/sdk/tree/main/packages/core),
36
- PostgreSQL-backed, self-hosted, MIT. No external control plane, no
37
- per-seat anything. Register the modules you need; skip the ones you don't.
106
+ PostgreSQL-backed, self-hosted, MIT. No external control plane, no per-seat
107
+ anything. Register the modules you need; skip the ones you don't.
38
108
 
39
- **This package owns** how behavior changes without a deploy. Feature flags and remote
40
- config read live by the other bricks.
109
+ **This package owns** how behavior and secrets — change without a deploy.
41
110
 
42
111
  Browse the whole set at
43
112
  [fonderie-js/sdk](https://github.com/fonderie-js/sdk) · follow
package/brain/outcomes.md CHANGED
@@ -19,7 +19,66 @@ environment TEXT NOT NULL DEFAULT 'all'
19
19
  description TEXT
20
20
  active BOOLEAN NOT NULL DEFAULT true
21
21
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
22
+ version INT NOT NULL DEFAULT 1
23
+ updated_by TEXT
24
+ -- UNIQUE (key, environment)
25
+ ```
26
+
27
+ ### `fonderie_config_revisions`
28
+
29
+ ```sql
30
+ key TEXT NOT NULL
31
+ environment TEXT NOT NULL DEFAULT 'all'
32
+ value TEXT NOT NULL
33
+ version INT NOT NULL
34
+ actor TEXT
35
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
36
+ -- PRIMARY KEY (key, environment, version)
37
+ ```
38
+
39
+ ### `fonderie_secret_revisions`
40
+
41
+ ```sql
42
+ key TEXT NOT NULL
43
+ environment TEXT NOT NULL DEFAULT 'all'
44
+ value TEXT NOT NULL
45
+ version INT NOT NULL
46
+ actor TEXT
47
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
48
+ -- PRIMARY KEY (key, environment, version)
49
+ ```
50
+
51
+ ### `fonderie_secrets`
52
+
53
+ ```sql
54
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid()
55
+ key TEXT NOT NULL
56
+ value TEXT NOT NULL
57
+ environment TEXT NOT NULL DEFAULT 'all'
58
+ description TEXT
59
+ active BOOLEAN NOT NULL DEFAULT true
60
+ version INT NOT NULL DEFAULT 1
61
+ updated_by TEXT
62
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
22
63
  -- UNIQUE (key, environment)
23
64
  ```
24
65
 
25
66
  Raw SQL ships in `node_modules/@fonderie/config/dist/migrations/sql/` — read it there if you must; never download tarballs.
67
+
68
+ ## HTTP routes registered
69
+
70
+ | Method | Path | Middleware chain (auth / validation / handler) |
71
+ |---|---|---|
72
+ | GET | `/admin/config` | `g(async (ctx) => { const rows = await listConfigEntries(envOf(ctx) ?? null, store); return setApiResponse(HTTP.OK, 'CONFIG_LISTED', 'Config entries', rows); })` |
73
+ | DELETE | `/admin/config/:key` | `g(async (ctx) => { const ok = await deleteConfigEntry(keyOf(ctx), envOf(ctx) ?? 'all', store); return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? 'DELETED' : 'NOT_FOUND', ok ? 'Deleted' : 'No such config entry'); })` |
74
+ | GET | `/admin/config/:key` | `g(async (ctx) => { const row = await getConfigEntry(keyOf(ctx), envOf(ctx) ?? 'all', store); return row ? setApiResponse(HTTP.OK, 'CONFIG_ENTRY', 'Config entry', row) : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such config entry'); })` |
75
+ | PUT | `/admin/config/:key` | `g(async (ctx) => { const b = body(ctx); if (!('value' in b)) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.value is required'); } try { const row = await setConfigEntry( { key: keyOf(ctx), value: b['value'], ...writeOpts(ctx, b) }, store, ); return setApiResponse(HTTP.OK, 'CONFIG_SET', 'Config entry saved', row); } catch (err) { return conflictOr(err); } })` |
76
+ | GET | `/admin/config/:key/revisions` | `g(async (ctx) => { const revs = await listConfigRevisions(keyOf(ctx), envOf(ctx) ?? 'all', store); return setApiResponse(HTTP.OK, 'REVISIONS', 'Config revisions', revs); })` |
77
+ | POST | `/admin/config/:key/rollback` | `g(async (ctx) => { const b = body(ctx); const toVersion = Number(b['toVersion']); if (!Number.isInteger(toVersion)) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.toVersion (int) is required'); } const row = await rollbackConfigEntry( rollbackOpts(ctx, b, toVersion), store, ); return setApiResponse(HTTP.OK, 'ROLLED_BACK', `Rolled back to v${toVersion}`, row); })` |
78
+ | GET | `/admin/secrets` | `g(async (ctx) => { const rows = await listSecrets(envOf(ctx) ?? null, store); return setApiResponse(HTTP.OK, 'SECRETS_LISTED', 'Secrets (masked)', rows); })` |
79
+ | DELETE | `/admin/secrets/:key` | `g(async (ctx) => { const ok = await deleteSecret(keyOf(ctx), envOf(ctx) ?? 'all', store); return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? 'DELETED' : 'NOT_FOUND', ok ? 'Deleted' : 'No such secret'); })` |
80
+ | GET | `/admin/secrets/:key` | `g(async (ctx) => { const row = await getSecret(keyOf(ctx), envOf(ctx) ?? 'all', store); return row ? setApiResponse(HTTP.OK, 'SECRET', 'Secret (masked)', row) : setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such secret'); })` |
81
+ | PUT | `/admin/secrets/:key` | `g(async (ctx) => { const b = body(ctx); if (typeof b['value'] !== 'string') { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.value (string) is required'); } try { const row = await setSecret( { key: keyOf(ctx), value: b['value'], ...writeOpts(ctx, b) }, store, encryptor, ); return setApiResponse(HTTP.OK, 'SECRET_SET', 'Secret saved (masked)', row); } catch (err) { return conflictOr(err); } })` |
82
+ | POST | `/admin/secrets/:key/reveal` | `g(async (ctx) => { const value = await revealSecret(keyOf(ctx), envOf(ctx) ?? 'all', store, encryptor); return value === null ? setApiResponse(HTTP.NOT_FOUND, 'NOT_FOUND', 'No such secret') : setApiResponse(HTTP.OK, 'SECRET_REVEALED', 'Decrypted secret value', { value }); })` |
83
+ | GET | `/admin/secrets/:key/revisions` | `g(async (ctx) => { const revs = await listSecretRevisions(keyOf(ctx), envOf(ctx) ?? 'all', store); return setApiResponse(HTTP.OK, 'REVISIONS', 'Secret revisions', revs); })` |
84
+ | POST | `/admin/secrets/:key/rollback` | `g(async (ctx) => { const b = body(ctx); const toVersion = Number(b['toVersion']); if (!Number.isInteger(toVersion)) { return setApiResponse(HTTP.UNPROCESSABLE, 'INVALID', 'body.toVersion (int) is required'); } const row = await rollbackSecret( rollbackOpts(ctx, b, toVersion), store, ); return setApiResponse(HTTP.OK, 'ROLLED_BACK', `Rolled back to v${toVersion}`, row); })` |
@@ -12,6 +12,8 @@ new ConfigModule(store: IStoreAdapter, options?: IConfigOptions): ConfigModule
12
12
  .manager: RemoteConfigManager
13
13
  .install(app: IFonderieApp): Promise<void>
14
14
 
15
+ function buildAdminRoutes(store: IStoreAdapter, adminToken: string, encryptor?: ISecretEncryptor): [string, string, Middleware][]
16
+
15
17
  new RemoteConfigManager(store: IStoreAdapter, options?: IConfigOptions): RemoteConfigManager
16
18
  .boot(): Promise<void>
17
19
  .stop(): void
@@ -30,27 +32,96 @@ function listConfigEntries(environment: string | null, store: IStoreAdapter): Pr
30
32
 
31
33
  function getConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<IConfigEntry | null>
32
34
 
33
- function setConfigEntry(opts: { key: string; value: unknown; environment?: string; description?: string; active?: boolean; }, store: IStoreAdapter): Promise<IConfigEntry>
35
+ function setConfigEntry(opts: { key: string; value: unknown; environment?: string; description?: string; active?: boolean; ifVersion?: number; actor?: string; }, store: IStoreAdapter): Promise<IConfigEntry>
34
36
 
35
37
  function deleteConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<boolean>
36
38
 
39
+ function rollbackConfigEntry(opts: { key: string; environment?: string; toVersion: number; actor?: string; }, store: IStoreAdapter): Promise<IConfigEntry>
40
+
41
+ function listConfigRevisions(key: string, environment: string, store: IStoreAdapter): Promise<IConfigRevision[]>
42
+
43
+ new ConfigConflictError(key: string, scope: string | null, currentVersion: number | null, expectedVersion: number): VersionConflictError
44
+ .key: string
45
+ .scope: string | null
46
+ .currentVersion: number | null
47
+ .expectedVersion: number
48
+ .name: string
49
+ .message: string
50
+ .stack: string
51
+ .cause: unknown
52
+
37
53
  interface IConfigEntry {
38
54
  key: string;
39
55
  value: unknown;
40
56
  environment: string;
41
57
  description: string | null;
42
58
  active: boolean;
59
+ version: number;
60
+ updatedBy: string | null;
43
61
  updatedAt: string;
44
62
  }
45
63
 
64
+ interface IConfigRevision {
65
+ key: string;
66
+ environment: string;
67
+ value: unknown;
68
+ version: number;
69
+ actor: string | null;
70
+ createdAt: string;
71
+ }
72
+
46
73
  interface IConfigSnapshot {
47
74
  entries: Record<string, unknown>;
48
75
  fetchedAt: Date;
49
76
  }
50
77
 
78
+ function listSecrets(environment: string | null, store: IStoreAdapter): Promise<ISecretEntry[]>
79
+
80
+ function getSecret(key: string, environment: string, store: IStoreAdapter): Promise<ISecretEntry | null>
81
+
82
+ function revealSecret(key: string, environment: string, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<string | null>
83
+
84
+ function setSecret(opts: { key: string; value: string; environment?: string; description?: string; active?: boolean; ifVersion?: number; actor?: string; }, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<...>
85
+
86
+ function rollbackSecret(opts: { key: string; environment?: string; toVersion: number; actor?: string; }, store: IStoreAdapter): Promise<ISecretEntry>
87
+
88
+ function listSecretRevisions(key: string, environment: string, store: IStoreAdapter): Promise<ISecretRevision[]>
89
+
90
+ function deleteSecret(key: string, environment: string, store: IStoreAdapter): Promise<boolean>
91
+
92
+ interface ISecretEntry {
93
+ key: string;
94
+ environment: string;
95
+ description: string | null;
96
+ active: boolean;
97
+ version: number;
98
+ updatedBy: string | null;
99
+ updatedAt: string;
100
+ }
101
+
102
+ interface ISecretRevision {
103
+ key: string;
104
+ environment: string;
105
+ version: number;
106
+ actor: string | null;
107
+ createdAt: string;
108
+ }
109
+
110
+ const noopEncryptor: ISecretEncryptor
111
+
112
+ function createAesGcmEncryptor(keyHex: string): ISecretEncryptor
113
+
114
+ interface ISecretEncryptor {
115
+ encrypt(plain: string): string;
116
+ decrypt(cipher: string): string;
117
+ }
118
+
51
119
  interface IConfigOptions {
52
120
  ttl?: number;
53
121
  environment?: string;
54
122
  table?: string;
123
+ connectionUrl?: string;
124
+ adminToken?: string;
125
+ secretEncryptor?: ISecretEncryptor;
55
126
  }
56
127
  ```
@@ -1,21 +1,34 @@
1
1
  import { Middleware } from '@fonderie/core';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
3
 
4
+ interface ISecretEncryptor {
5
+ encrypt(plain: string): string;
6
+ decrypt(cipher: string): string;
7
+ }
8
+ declare const noopEncryptor: ISecretEncryptor;
9
+ declare function createAesGcmEncryptor(keyHex: string): ISecretEncryptor;
10
+
4
11
  interface IConfigOptions {
5
12
  ttl?: number;
6
13
  environment?: string;
7
14
  table?: string;
15
+ connectionUrl?: string;
16
+ adminToken?: string;
17
+ secretEncryptor?: ISecretEncryptor;
8
18
  }
9
19
 
10
20
  declare class RemoteConfigManager {
11
21
  private store;
12
22
  private snapshot;
13
23
  private interval;
24
+ private listenClient;
14
25
  private environment;
15
26
  private ttl;
16
27
  private table;
28
+ private connectionUrl;
17
29
  constructor(store: IStoreAdapter, options?: IConfigOptions);
18
30
  boot(): Promise<void>;
31
+ private startListening;
19
32
  stop(): void;
20
33
  get<T>(key: string, fallback: T): T;
21
34
  all(): Record<string, unknown>;
@@ -29,4 +42,4 @@ declare function getConfig(ctx: {
29
42
  meta: Record<string, unknown>;
30
43
  }, key: string, fallback?: unknown): unknown;
31
44
 
32
- export { CONFIG_MANAGER_KEY as C, type IConfigOptions as I, RemoteConfigManager as R, configContextMiddleware as c, getConfig as g };
45
+ export { CONFIG_MANAGER_KEY as C, type IConfigOptions as I, RemoteConfigManager as R, type ISecretEncryptor as a, createAesGcmEncryptor as b, configContextMiddleware as c, getConfig as g, noopEncryptor as n };
@@ -1,21 +1,34 @@
1
1
  import { Middleware } from '@fonderie/core';
2
2
  import { IStoreAdapter } from '@fonderie/store';
3
3
 
4
+ interface ISecretEncryptor {
5
+ encrypt(plain: string): string;
6
+ decrypt(cipher: string): string;
7
+ }
8
+ declare const noopEncryptor: ISecretEncryptor;
9
+ declare function createAesGcmEncryptor(keyHex: string): ISecretEncryptor;
10
+
4
11
  interface IConfigOptions {
5
12
  ttl?: number;
6
13
  environment?: string;
7
14
  table?: string;
15
+ connectionUrl?: string;
16
+ adminToken?: string;
17
+ secretEncryptor?: ISecretEncryptor;
8
18
  }
9
19
 
10
20
  declare class RemoteConfigManager {
11
21
  private store;
12
22
  private snapshot;
13
23
  private interval;
24
+ private listenClient;
14
25
  private environment;
15
26
  private ttl;
16
27
  private table;
28
+ private connectionUrl;
17
29
  constructor(store: IStoreAdapter, options?: IConfigOptions);
18
30
  boot(): Promise<void>;
31
+ private startListening;
19
32
  stop(): void;
20
33
  get<T>(key: string, fallback: T): T;
21
34
  all(): Record<string, unknown>;
@@ -29,4 +42,4 @@ declare function getConfig(ctx: {
29
42
  meta: Record<string, unknown>;
30
43
  }, key: string, fallback?: unknown): unknown;
31
44
 
32
- export { CONFIG_MANAGER_KEY as C, type IConfigOptions as I, RemoteConfigManager as R, configContextMiddleware as c, getConfig as g };
45
+ export { CONFIG_MANAGER_KEY as C, type IConfigOptions as I, RemoteConfigManager as R, type ISecretEncryptor as a, createAesGcmEncryptor as b, configContextMiddleware as c, getConfig as g, noopEncryptor as n };