@fonderie/config 2.0.0 → 4.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 +84 -15
- package/brain/outcomes.md +59 -0
- package/brain/signatures.md +75 -4
- package/dist/{index-v2hLmm2m.d.cts → index-CLaJRR69.d.cts} +16 -3
- package/dist/{index-v2hLmm2m.d.ts → index-CLaJRR69.d.ts} +16 -3
- package/dist/index.cjs +412 -41
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -7
- package/dist/index.d.ts +42 -7
- package/dist/index.js +392 -39
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +11 -0
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +1 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/migrations/sql/002_config_versioning.sql +20 -0
- package/dist/migrations/sql/003_secrets.sql +32 -0
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +27 -1
- package/dist/types.d.ts +27 -1
- package/package.json +82 -79
package/dist/index.d.cts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
1
|
+
import { IFonderieModule, IFonderieApp, Middleware } from '@fonderie/core';
|
|
2
2
|
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export { VersionConflictError as ConfigConflictError } from '@fonderie/store';
|
|
4
|
+
import { R as RemoteConfigManager, I as IConfigOptions, a as ISecretEncryptor } from './index-CLaJRR69.cjs';
|
|
5
|
+
export { C as CONFIG_MANAGER_KEY, c as configContextMiddleware, b as createAesGcmEncryptor, g as getConfig, n as noopEncryptor } from './index-CLaJRR69.cjs';
|
|
6
|
+
import { IConfigEntry, IConfigRevision, ISecretEntry, ISecretRevision } from './types.cjs';
|
|
6
7
|
export { IConfigSnapshot } from './types.cjs';
|
|
7
8
|
|
|
8
|
-
declare class
|
|
9
|
+
declare class ConfigModule implements IFonderieModule {
|
|
10
|
+
private store;
|
|
11
|
+
private options;
|
|
9
12
|
readonly name = "@fonderie/config";
|
|
10
13
|
readonly manager: RemoteConfigManager;
|
|
11
|
-
constructor(store: IStoreAdapter, options?:
|
|
14
|
+
constructor(store: IStoreAdapter, options?: IConfigOptions);
|
|
12
15
|
install(app: IFonderieApp): Promise<void>;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
declare function buildAdminRoutes(store: IStoreAdapter, adminToken: string, encryptor?: ISecretEncryptor): Array<[string, string, Middleware]>;
|
|
19
|
+
|
|
15
20
|
declare function listConfigEntries(environment: string | null, store: IStoreAdapter): Promise<IConfigEntry[]>;
|
|
16
21
|
declare function getConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<IConfigEntry | null>;
|
|
17
22
|
declare function setConfigEntry(opts: {
|
|
@@ -20,7 +25,37 @@ declare function setConfigEntry(opts: {
|
|
|
20
25
|
environment?: string;
|
|
21
26
|
description?: string;
|
|
22
27
|
active?: boolean;
|
|
28
|
+
ifVersion?: number;
|
|
29
|
+
actor?: string;
|
|
30
|
+
}, store: IStoreAdapter): Promise<IConfigEntry>;
|
|
31
|
+
declare function rollbackConfigEntry(opts: {
|
|
32
|
+
key: string;
|
|
33
|
+
environment?: string;
|
|
34
|
+
toVersion: number;
|
|
35
|
+
actor?: string;
|
|
23
36
|
}, store: IStoreAdapter): Promise<IConfigEntry>;
|
|
37
|
+
declare function listConfigRevisions(key: string, environment: string, store: IStoreAdapter): Promise<IConfigRevision[]>;
|
|
24
38
|
declare function deleteConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<boolean>;
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
declare function listSecrets(environment: string | null, store: IStoreAdapter): Promise<ISecretEntry[]>;
|
|
41
|
+
declare function getSecret(key: string, environment: string, store: IStoreAdapter): Promise<ISecretEntry | null>;
|
|
42
|
+
declare function revealSecret(key: string, environment: string, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<string | null>;
|
|
43
|
+
declare function setSecret(opts: {
|
|
44
|
+
key: string;
|
|
45
|
+
value: string;
|
|
46
|
+
environment?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
active?: boolean;
|
|
49
|
+
ifVersion?: number;
|
|
50
|
+
actor?: string;
|
|
51
|
+
}, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<ISecretEntry>;
|
|
52
|
+
declare function rollbackSecret(opts: {
|
|
53
|
+
key: string;
|
|
54
|
+
environment?: string;
|
|
55
|
+
toVersion: number;
|
|
56
|
+
actor?: string;
|
|
57
|
+
}, store: IStoreAdapter): Promise<ISecretEntry>;
|
|
58
|
+
declare function listSecretRevisions(key: string, environment: string, store: IStoreAdapter): Promise<ISecretRevision[]>;
|
|
59
|
+
declare function deleteSecret(key: string, environment: string, store: IStoreAdapter): Promise<boolean>;
|
|
60
|
+
|
|
61
|
+
export { ConfigModule, IConfigEntry, IConfigOptions, IConfigRevision, ISecretEncryptor, ISecretEntry, ISecretRevision, RemoteConfigManager, buildAdminRoutes, deleteConfigEntry, deleteSecret, getConfigEntry, getSecret, listConfigEntries, listConfigRevisions, listSecretRevisions, listSecrets, revealSecret, rollbackConfigEntry, rollbackSecret, setConfigEntry, setSecret };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
import { IFonderieModule, IFonderieApp } from '@fonderie/core';
|
|
1
|
+
import { IFonderieModule, IFonderieApp, Middleware } from '@fonderie/core';
|
|
2
2
|
import { IStoreAdapter } from '@fonderie/store';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export { VersionConflictError as ConfigConflictError } from '@fonderie/store';
|
|
4
|
+
import { R as RemoteConfigManager, I as IConfigOptions, a as ISecretEncryptor } from './index-CLaJRR69.js';
|
|
5
|
+
export { C as CONFIG_MANAGER_KEY, c as configContextMiddleware, b as createAesGcmEncryptor, g as getConfig, n as noopEncryptor } from './index-CLaJRR69.js';
|
|
6
|
+
import { IConfigEntry, IConfigRevision, ISecretEntry, ISecretRevision } from './types.js';
|
|
6
7
|
export { IConfigSnapshot } from './types.js';
|
|
7
8
|
|
|
8
|
-
declare class
|
|
9
|
+
declare class ConfigModule implements IFonderieModule {
|
|
10
|
+
private store;
|
|
11
|
+
private options;
|
|
9
12
|
readonly name = "@fonderie/config";
|
|
10
13
|
readonly manager: RemoteConfigManager;
|
|
11
|
-
constructor(store: IStoreAdapter, options?:
|
|
14
|
+
constructor(store: IStoreAdapter, options?: IConfigOptions);
|
|
12
15
|
install(app: IFonderieApp): Promise<void>;
|
|
13
16
|
}
|
|
14
17
|
|
|
18
|
+
declare function buildAdminRoutes(store: IStoreAdapter, adminToken: string, encryptor?: ISecretEncryptor): Array<[string, string, Middleware]>;
|
|
19
|
+
|
|
15
20
|
declare function listConfigEntries(environment: string | null, store: IStoreAdapter): Promise<IConfigEntry[]>;
|
|
16
21
|
declare function getConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<IConfigEntry | null>;
|
|
17
22
|
declare function setConfigEntry(opts: {
|
|
@@ -20,7 +25,37 @@ declare function setConfigEntry(opts: {
|
|
|
20
25
|
environment?: string;
|
|
21
26
|
description?: string;
|
|
22
27
|
active?: boolean;
|
|
28
|
+
ifVersion?: number;
|
|
29
|
+
actor?: string;
|
|
30
|
+
}, store: IStoreAdapter): Promise<IConfigEntry>;
|
|
31
|
+
declare function rollbackConfigEntry(opts: {
|
|
32
|
+
key: string;
|
|
33
|
+
environment?: string;
|
|
34
|
+
toVersion: number;
|
|
35
|
+
actor?: string;
|
|
23
36
|
}, store: IStoreAdapter): Promise<IConfigEntry>;
|
|
37
|
+
declare function listConfigRevisions(key: string, environment: string, store: IStoreAdapter): Promise<IConfigRevision[]>;
|
|
24
38
|
declare function deleteConfigEntry(key: string, environment: string, store: IStoreAdapter): Promise<boolean>;
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
declare function listSecrets(environment: string | null, store: IStoreAdapter): Promise<ISecretEntry[]>;
|
|
41
|
+
declare function getSecret(key: string, environment: string, store: IStoreAdapter): Promise<ISecretEntry | null>;
|
|
42
|
+
declare function revealSecret(key: string, environment: string, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<string | null>;
|
|
43
|
+
declare function setSecret(opts: {
|
|
44
|
+
key: string;
|
|
45
|
+
value: string;
|
|
46
|
+
environment?: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
active?: boolean;
|
|
49
|
+
ifVersion?: number;
|
|
50
|
+
actor?: string;
|
|
51
|
+
}, store: IStoreAdapter, encryptor?: ISecretEncryptor): Promise<ISecretEntry>;
|
|
52
|
+
declare function rollbackSecret(opts: {
|
|
53
|
+
key: string;
|
|
54
|
+
environment?: string;
|
|
55
|
+
toVersion: number;
|
|
56
|
+
actor?: string;
|
|
57
|
+
}, store: IStoreAdapter): Promise<ISecretEntry>;
|
|
58
|
+
declare function listSecretRevisions(key: string, environment: string, store: IStoreAdapter): Promise<ISecretRevision[]>;
|
|
59
|
+
declare function deleteSecret(key: string, environment: string, store: IStoreAdapter): Promise<boolean>;
|
|
60
|
+
|
|
61
|
+
export { ConfigModule, IConfigEntry, IConfigOptions, IConfigRevision, ISecretEncryptor, ISecretEntry, ISecretRevision, RemoteConfigManager, buildAdminRoutes, deleteConfigEntry, deleteSecret, getConfigEntry, getSecret, listConfigEntries, listConfigRevisions, listSecretRevisions, listSecrets, revealSecret, rollbackConfigEntry, rollbackSecret, setConfigEntry, setSecret };
|
package/dist/index.js
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
// src/manager.ts
|
|
2
|
+
import pg from "pg";
|
|
2
3
|
var FONDERIE_CONFIG_KEY = "fonderie.config.snapshot";
|
|
4
|
+
var NOTIFY_CHANNEL = "fonderie_config_changed";
|
|
3
5
|
var RemoteConfigManager = class {
|
|
4
6
|
constructor(store, options = {}) {
|
|
5
7
|
this.store = store;
|
|
6
8
|
this.ttl = options.ttl ?? 3e4;
|
|
7
9
|
this.environment = options.environment ?? process.env["NODE_ENV"] ?? "development";
|
|
8
10
|
this.table = options.table ?? "fonderie_config";
|
|
11
|
+
this.connectionUrl = options.connectionUrl;
|
|
9
12
|
}
|
|
10
13
|
store;
|
|
11
14
|
snapshot = null;
|
|
12
15
|
interval = null;
|
|
16
|
+
listenClient = null;
|
|
13
17
|
environment;
|
|
14
18
|
ttl;
|
|
15
19
|
table;
|
|
20
|
+
connectionUrl;
|
|
16
21
|
async boot() {
|
|
17
22
|
await this.refresh();
|
|
18
23
|
this.interval = setInterval(() => {
|
|
19
24
|
this.refresh().catch((err) => console.error("[config] refresh error:", err));
|
|
20
25
|
}, this.ttl);
|
|
26
|
+
if (this.connectionUrl) await this.startListening();
|
|
27
|
+
}
|
|
28
|
+
async startListening() {
|
|
29
|
+
try {
|
|
30
|
+
this.listenClient = new pg.Client(this.connectionUrl);
|
|
31
|
+
await this.listenClient.connect();
|
|
32
|
+
await this.listenClient.query(`LISTEN ${NOTIFY_CHANNEL}`);
|
|
33
|
+
this.listenClient.on("notification", () => {
|
|
34
|
+
this.refresh().catch((err) => console.error("[config] refresh error:", err));
|
|
35
|
+
});
|
|
36
|
+
this.listenClient.on(
|
|
37
|
+
"error",
|
|
38
|
+
(err) => console.error("[config] listen client error:", err.message)
|
|
39
|
+
);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error("[config] failed to start LISTEN, falling back to poll:", err);
|
|
42
|
+
this.listenClient = null;
|
|
43
|
+
}
|
|
21
44
|
}
|
|
22
45
|
stop() {
|
|
23
46
|
if (this.interval) {
|
|
24
47
|
clearInterval(this.interval);
|
|
25
48
|
this.interval = null;
|
|
26
49
|
}
|
|
50
|
+
void this.listenClient?.end();
|
|
51
|
+
this.listenClient = null;
|
|
27
52
|
}
|
|
28
53
|
// Get a single value with a typed fallback
|
|
29
54
|
get(key, fallback) {
|
|
@@ -97,29 +122,71 @@ function getConfig(ctx, key, fallback = null) {
|
|
|
97
122
|
return manager.get(key, fallback);
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
// src/
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
await this.manager.boot();
|
|
109
|
-
app.use(configContextMiddleware(this.manager));
|
|
110
|
-
}
|
|
125
|
+
// src/admin.ts
|
|
126
|
+
import { setApiResponse, HTTP } from "@fonderie/core";
|
|
127
|
+
|
|
128
|
+
// src/crypto.ts
|
|
129
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
|
130
|
+
var noopEncryptor = {
|
|
131
|
+
encrypt: (plain) => plain,
|
|
132
|
+
decrypt: (cipher) => cipher
|
|
111
133
|
};
|
|
134
|
+
function createAesGcmEncryptor(keyHex) {
|
|
135
|
+
const key = Buffer.from(keyHex, "hex");
|
|
136
|
+
if (key.length !== 32) {
|
|
137
|
+
throw new Error("[config] secret encryptor key must be 32 bytes (64 hex chars)");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
encrypt(plain) {
|
|
141
|
+
const iv = randomBytes(12);
|
|
142
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
143
|
+
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
144
|
+
const tag = cipher.getAuthTag();
|
|
145
|
+
return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
|
|
146
|
+
},
|
|
147
|
+
decrypt(cipher) {
|
|
148
|
+
const [ivHex, tagHex, dataHex] = cipher.split(":");
|
|
149
|
+
if (!ivHex || !tagHex || !dataHex) {
|
|
150
|
+
throw new Error("[config] malformed secret ciphertext");
|
|
151
|
+
}
|
|
152
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
153
|
+
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
|
154
|
+
return Buffer.concat([
|
|
155
|
+
decipher.update(Buffer.from(dataHex, "hex")),
|
|
156
|
+
decipher.final()
|
|
157
|
+
]).toString("utf8");
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/services/versioned.ts
|
|
163
|
+
import {
|
|
164
|
+
versionedWrite,
|
|
165
|
+
versionedRollback,
|
|
166
|
+
VersionConflictError,
|
|
167
|
+
VersionConflictError as VersionConflictError2
|
|
168
|
+
} from "@fonderie/store";
|
|
112
169
|
|
|
113
170
|
// src/services/config.ts
|
|
114
|
-
var
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
var ENTRY_COLS = `
|
|
172
|
+
key,
|
|
173
|
+
value,
|
|
174
|
+
environment,
|
|
175
|
+
description,
|
|
176
|
+
active,
|
|
177
|
+
version,
|
|
178
|
+
updated_by AS "updatedBy",
|
|
179
|
+
updated_at AS "updatedAt"`;
|
|
180
|
+
var SELECT_ENTRY = `SELECT ${ENTRY_COLS} FROM fonderie_config`;
|
|
181
|
+
var CONFIG_TABLE = {
|
|
182
|
+
table: "fonderie_config",
|
|
183
|
+
revisions: "fonderie_config_revisions",
|
|
184
|
+
channel: "fonderie_config_changed",
|
|
185
|
+
keyColumns: ["key", "environment"],
|
|
186
|
+
contentColumns: ["value"],
|
|
187
|
+
metaColumns: ["description", "active"],
|
|
188
|
+
returning: ENTRY_COLS
|
|
189
|
+
};
|
|
123
190
|
async function listConfigEntries(environment, store) {
|
|
124
191
|
return environment ? store.query(
|
|
125
192
|
`${SELECT_ENTRY} WHERE (environment = $1 OR environment = 'all') AND active = true ORDER BY key`,
|
|
@@ -134,24 +201,33 @@ async function getConfigEntry(key, environment, store) {
|
|
|
134
201
|
return row ?? null;
|
|
135
202
|
}
|
|
136
203
|
async function setConfigEntry(opts, store) {
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
204
|
+
const rawValue = typeof opts.value === "string" ? opts.value : JSON.stringify(opts.value);
|
|
205
|
+
const data = { value: rawValue, active: opts.active ?? true };
|
|
206
|
+
if (opts.description !== void 0) data["description"] = opts.description;
|
|
207
|
+
return versionedWrite(CONFIG_TABLE, store, {
|
|
208
|
+
key: opts.key,
|
|
209
|
+
scope: opts.environment ?? "all",
|
|
210
|
+
data,
|
|
211
|
+
...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
|
|
212
|
+
actor: opts.actor ?? null
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
async function rollbackConfigEntry(opts, store) {
|
|
216
|
+
return versionedRollback(CONFIG_TABLE, store, {
|
|
217
|
+
key: opts.key,
|
|
218
|
+
scope: opts.environment ?? "all",
|
|
219
|
+
toVersion: opts.toVersion,
|
|
220
|
+
actor: opts.actor ?? null
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
async function listConfigRevisions(key, environment, store) {
|
|
224
|
+
return store.query(
|
|
225
|
+
`SELECT key, environment, value, version, actor, created_at AS "createdAt"
|
|
226
|
+
FROM fonderie_config_revisions
|
|
227
|
+
WHERE key = $1 AND environment = $2
|
|
228
|
+
ORDER BY version DESC`,
|
|
229
|
+
[key, environment]
|
|
152
230
|
);
|
|
153
|
-
if (!row) throw new Error("Failed to upsert config entry");
|
|
154
|
-
return row;
|
|
155
231
|
}
|
|
156
232
|
async function deleteConfigEntry(key, environment, store) {
|
|
157
233
|
const rows = await store.query(
|
|
@@ -160,15 +236,292 @@ async function deleteConfigEntry(key, environment, store) {
|
|
|
160
236
|
);
|
|
161
237
|
return rows.length > 0;
|
|
162
238
|
}
|
|
239
|
+
|
|
240
|
+
// src/services/secrets.ts
|
|
241
|
+
var META_COLS = `
|
|
242
|
+
key,
|
|
243
|
+
environment,
|
|
244
|
+
description,
|
|
245
|
+
active,
|
|
246
|
+
version,
|
|
247
|
+
updated_by AS "updatedBy",
|
|
248
|
+
updated_at AS "updatedAt"`;
|
|
249
|
+
var SECRET_TABLE = {
|
|
250
|
+
table: "fonderie_secrets",
|
|
251
|
+
revisions: "fonderie_secret_revisions",
|
|
252
|
+
channel: "fonderie_secrets_changed",
|
|
253
|
+
keyColumns: ["key", "environment"],
|
|
254
|
+
contentColumns: ["value"],
|
|
255
|
+
metaColumns: ["description", "active"],
|
|
256
|
+
returning: META_COLS
|
|
257
|
+
// masked — no value
|
|
258
|
+
};
|
|
259
|
+
async function listSecrets(environment, store) {
|
|
260
|
+
return environment ? store.query(
|
|
261
|
+
`SELECT ${META_COLS} FROM fonderie_secrets
|
|
262
|
+
WHERE (environment = $1 OR environment = 'all') AND active = true ORDER BY key`,
|
|
263
|
+
[environment]
|
|
264
|
+
) : store.query(
|
|
265
|
+
`SELECT ${META_COLS} FROM fonderie_secrets ORDER BY environment, key`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
async function getSecret(key, environment, store) {
|
|
269
|
+
const [row] = await store.query(
|
|
270
|
+
`SELECT ${META_COLS} FROM fonderie_secrets WHERE key = $1 AND environment = $2`,
|
|
271
|
+
[key, environment]
|
|
272
|
+
);
|
|
273
|
+
return row ?? null;
|
|
274
|
+
}
|
|
275
|
+
async function revealSecret(key, environment, store, encryptor = noopEncryptor) {
|
|
276
|
+
const [row] = await store.query(
|
|
277
|
+
`SELECT value FROM fonderie_secrets WHERE key = $1 AND environment = $2`,
|
|
278
|
+
[key, environment]
|
|
279
|
+
);
|
|
280
|
+
return row ? encryptor.decrypt(row.value) : null;
|
|
281
|
+
}
|
|
282
|
+
async function setSecret(opts, store, encryptor = noopEncryptor) {
|
|
283
|
+
const data = {
|
|
284
|
+
value: encryptor.encrypt(opts.value),
|
|
285
|
+
active: opts.active ?? true
|
|
286
|
+
};
|
|
287
|
+
if (opts.description !== void 0) data["description"] = opts.description;
|
|
288
|
+
return versionedWrite(SECRET_TABLE, store, {
|
|
289
|
+
key: opts.key,
|
|
290
|
+
scope: opts.environment ?? "all",
|
|
291
|
+
data,
|
|
292
|
+
...opts.ifVersion !== void 0 ? { ifVersion: opts.ifVersion } : {},
|
|
293
|
+
actor: opts.actor ?? null
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async function rollbackSecret(opts, store) {
|
|
297
|
+
return versionedRollback(SECRET_TABLE, store, {
|
|
298
|
+
key: opts.key,
|
|
299
|
+
scope: opts.environment ?? "all",
|
|
300
|
+
toVersion: opts.toVersion,
|
|
301
|
+
actor: opts.actor ?? null
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async function listSecretRevisions(key, environment, store) {
|
|
305
|
+
return store.query(
|
|
306
|
+
`SELECT key, environment, version, actor, created_at AS "createdAt"
|
|
307
|
+
FROM fonderie_secret_revisions
|
|
308
|
+
WHERE key = $1 AND environment = $2
|
|
309
|
+
ORDER BY version DESC`,
|
|
310
|
+
[key, environment]
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
async function deleteSecret(key, environment, store) {
|
|
314
|
+
const rows = await store.query(
|
|
315
|
+
`DELETE FROM fonderie_secrets WHERE key = $1 AND environment = $2 RETURNING key`,
|
|
316
|
+
[key, environment]
|
|
317
|
+
);
|
|
318
|
+
return rows.length > 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/admin.ts
|
|
322
|
+
function checkAdmin(ctx, adminToken) {
|
|
323
|
+
const header = ctx.request.headers.get("authorization") ?? "";
|
|
324
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
325
|
+
if (!token || token !== adminToken) {
|
|
326
|
+
return setApiResponse(HTTP.UNAUTHORIZED, "UNAUTHORIZED", "Missing or invalid admin token");
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
function actorOf(ctx) {
|
|
331
|
+
return ctx.request.headers.get("x-actor") || "admin-token";
|
|
332
|
+
}
|
|
333
|
+
function envOf(ctx) {
|
|
334
|
+
return new URL(ctx.request.url).searchParams.get("environment") ?? void 0;
|
|
335
|
+
}
|
|
336
|
+
function keyOf(ctx) {
|
|
337
|
+
return ctx.meta.params?.["key"] ?? "";
|
|
338
|
+
}
|
|
339
|
+
function body(ctx) {
|
|
340
|
+
return ctx.meta["body"] ?? {};
|
|
341
|
+
}
|
|
342
|
+
function writeOpts(ctx, b) {
|
|
343
|
+
const opts = {
|
|
344
|
+
actor: actorOf(ctx)
|
|
345
|
+
};
|
|
346
|
+
const env = b["environment"] ?? envOf(ctx);
|
|
347
|
+
if (env !== void 0) opts.environment = env;
|
|
348
|
+
if (typeof b["description"] === "string") opts.description = b["description"];
|
|
349
|
+
if (typeof b["ifVersion"] === "number") opts.ifVersion = b["ifVersion"];
|
|
350
|
+
return opts;
|
|
351
|
+
}
|
|
352
|
+
function rollbackOpts(ctx, b, toVersion) {
|
|
353
|
+
const opts = {
|
|
354
|
+
key: keyOf(ctx),
|
|
355
|
+
toVersion,
|
|
356
|
+
actor: actorOf(ctx)
|
|
357
|
+
};
|
|
358
|
+
const env = b["environment"] ?? envOf(ctx);
|
|
359
|
+
if (env !== void 0) opts.environment = env;
|
|
360
|
+
return opts;
|
|
361
|
+
}
|
|
362
|
+
function guarded(adminToken, handler) {
|
|
363
|
+
return async (ctx, next) => {
|
|
364
|
+
const denied = checkAdmin(ctx, adminToken);
|
|
365
|
+
return denied ?? handler(ctx, next);
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function buildAdminRoutes(store, adminToken, encryptor = noopEncryptor) {
|
|
369
|
+
const g = (h) => guarded(adminToken, h);
|
|
370
|
+
return [
|
|
371
|
+
// ── config ──────────────────────────────────────────────────
|
|
372
|
+
["GET", "/admin/config", g(async (ctx) => {
|
|
373
|
+
const rows = await listConfigEntries(envOf(ctx) ?? null, store);
|
|
374
|
+
return setApiResponse(HTTP.OK, "CONFIG_LISTED", "Config entries", rows);
|
|
375
|
+
})],
|
|
376
|
+
["GET", "/admin/config/:key", g(async (ctx) => {
|
|
377
|
+
const row = await getConfigEntry(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
378
|
+
return row ? setApiResponse(HTTP.OK, "CONFIG_ENTRY", "Config entry", row) : setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "No such config entry");
|
|
379
|
+
})],
|
|
380
|
+
["PUT", "/admin/config/:key", g(async (ctx) => {
|
|
381
|
+
const b = body(ctx);
|
|
382
|
+
if (!("value" in b)) {
|
|
383
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.value is required");
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const row = await setConfigEntry(
|
|
387
|
+
{ key: keyOf(ctx), value: b["value"], ...writeOpts(ctx, b) },
|
|
388
|
+
store
|
|
389
|
+
);
|
|
390
|
+
return setApiResponse(HTTP.OK, "CONFIG_SET", "Config entry saved", row);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return conflictOr(err);
|
|
393
|
+
}
|
|
394
|
+
})],
|
|
395
|
+
["DELETE", "/admin/config/:key", g(async (ctx) => {
|
|
396
|
+
const ok = await deleteConfigEntry(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
397
|
+
return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such config entry");
|
|
398
|
+
})],
|
|
399
|
+
["GET", "/admin/config/:key/revisions", g(async (ctx) => {
|
|
400
|
+
const revs = await listConfigRevisions(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
401
|
+
return setApiResponse(HTTP.OK, "REVISIONS", "Config revisions", revs);
|
|
402
|
+
})],
|
|
403
|
+
["POST", "/admin/config/:key/rollback", g(async (ctx) => {
|
|
404
|
+
const b = body(ctx);
|
|
405
|
+
const toVersion = Number(b["toVersion"]);
|
|
406
|
+
if (!Number.isInteger(toVersion)) {
|
|
407
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
|
|
408
|
+
}
|
|
409
|
+
const row = await rollbackConfigEntry(
|
|
410
|
+
rollbackOpts(ctx, b, toVersion),
|
|
411
|
+
store
|
|
412
|
+
);
|
|
413
|
+
return setApiResponse(HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
|
|
414
|
+
})],
|
|
415
|
+
// ── secrets (masked) ────────────────────────────────────────
|
|
416
|
+
["GET", "/admin/secrets", g(async (ctx) => {
|
|
417
|
+
const rows = await listSecrets(envOf(ctx) ?? null, store);
|
|
418
|
+
return setApiResponse(HTTP.OK, "SECRETS_LISTED", "Secrets (masked)", rows);
|
|
419
|
+
})],
|
|
420
|
+
["GET", "/admin/secrets/:key", g(async (ctx) => {
|
|
421
|
+
const row = await getSecret(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
422
|
+
return row ? setApiResponse(HTTP.OK, "SECRET", "Secret (masked)", row) : setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "No such secret");
|
|
423
|
+
})],
|
|
424
|
+
["PUT", "/admin/secrets/:key", g(async (ctx) => {
|
|
425
|
+
const b = body(ctx);
|
|
426
|
+
if (typeof b["value"] !== "string") {
|
|
427
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.value (string) is required");
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const row = await setSecret(
|
|
431
|
+
{ key: keyOf(ctx), value: b["value"], ...writeOpts(ctx, b) },
|
|
432
|
+
store,
|
|
433
|
+
encryptor
|
|
434
|
+
);
|
|
435
|
+
return setApiResponse(HTTP.OK, "SECRET_SET", "Secret saved (masked)", row);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
return conflictOr(err);
|
|
438
|
+
}
|
|
439
|
+
})],
|
|
440
|
+
["DELETE", "/admin/secrets/:key", g(async (ctx) => {
|
|
441
|
+
const ok = await deleteSecret(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
442
|
+
return setApiResponse(ok ? HTTP.OK : HTTP.NOT_FOUND, ok ? "DELETED" : "NOT_FOUND", ok ? "Deleted" : "No such secret");
|
|
443
|
+
})],
|
|
444
|
+
["GET", "/admin/secrets/:key/revisions", g(async (ctx) => {
|
|
445
|
+
const revs = await listSecretRevisions(keyOf(ctx), envOf(ctx) ?? "all", store);
|
|
446
|
+
return setApiResponse(HTTP.OK, "REVISIONS", "Secret revisions", revs);
|
|
447
|
+
})],
|
|
448
|
+
["POST", "/admin/secrets/:key/rollback", g(async (ctx) => {
|
|
449
|
+
const b = body(ctx);
|
|
450
|
+
const toVersion = Number(b["toVersion"]);
|
|
451
|
+
if (!Number.isInteger(toVersion)) {
|
|
452
|
+
return setApiResponse(HTTP.UNPROCESSABLE, "INVALID", "body.toVersion (int) is required");
|
|
453
|
+
}
|
|
454
|
+
const row = await rollbackSecret(
|
|
455
|
+
rollbackOpts(ctx, b, toVersion),
|
|
456
|
+
store
|
|
457
|
+
);
|
|
458
|
+
return setApiResponse(HTTP.OK, "ROLLED_BACK", `Rolled back to v${toVersion}`, row);
|
|
459
|
+
})],
|
|
460
|
+
// The one plaintext path — behind the same token; POST so the value never
|
|
461
|
+
// lands in a URL/log. Returns the decrypted value.
|
|
462
|
+
["POST", "/admin/secrets/:key/reveal", g(async (ctx) => {
|
|
463
|
+
const value = await revealSecret(keyOf(ctx), envOf(ctx) ?? "all", store, encryptor);
|
|
464
|
+
return value === null ? setApiResponse(HTTP.NOT_FOUND, "NOT_FOUND", "No such secret") : setApiResponse(HTTP.OK, "SECRET_REVEALED", "Decrypted secret value", { value });
|
|
465
|
+
})]
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
function conflictOr(err) {
|
|
469
|
+
if (err instanceof VersionConflictError2) {
|
|
470
|
+
return setApiResponse(HTTP.CONFLICT, "VERSION_CONFLICT", err.message, {
|
|
471
|
+
currentVersion: err.currentVersion
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
throw err;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/module.ts
|
|
478
|
+
var ConfigModule = class {
|
|
479
|
+
constructor(store, options = {}) {
|
|
480
|
+
this.store = store;
|
|
481
|
+
this.options = options;
|
|
482
|
+
this.manager = new RemoteConfigManager(store, options);
|
|
483
|
+
}
|
|
484
|
+
store;
|
|
485
|
+
options;
|
|
486
|
+
name = "@fonderie/config";
|
|
487
|
+
manager;
|
|
488
|
+
async install(app) {
|
|
489
|
+
await this.manager.boot();
|
|
490
|
+
app.use(configContextMiddleware(this.manager));
|
|
491
|
+
if (this.options.adminToken) {
|
|
492
|
+
const routes = buildAdminRoutes(
|
|
493
|
+
this.store,
|
|
494
|
+
this.options.adminToken,
|
|
495
|
+
this.options.secretEncryptor ?? noopEncryptor
|
|
496
|
+
);
|
|
497
|
+
for (const [method, path, handler] of routes) {
|
|
498
|
+
app.addRoute(method, path, handler);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
};
|
|
163
503
|
export {
|
|
164
504
|
CONFIG_MANAGER_KEY,
|
|
505
|
+
VersionConflictError2 as ConfigConflictError,
|
|
506
|
+
ConfigModule,
|
|
165
507
|
RemoteConfigManager,
|
|
166
|
-
|
|
508
|
+
buildAdminRoutes,
|
|
167
509
|
configContextMiddleware,
|
|
510
|
+
createAesGcmEncryptor,
|
|
168
511
|
deleteConfigEntry,
|
|
512
|
+
deleteSecret,
|
|
169
513
|
getConfig,
|
|
170
514
|
getConfigEntry,
|
|
515
|
+
getSecret,
|
|
171
516
|
listConfigEntries,
|
|
172
|
-
|
|
517
|
+
listConfigRevisions,
|
|
518
|
+
listSecretRevisions,
|
|
519
|
+
listSecrets,
|
|
520
|
+
noopEncryptor,
|
|
521
|
+
revealSecret,
|
|
522
|
+
rollbackConfigEntry,
|
|
523
|
+
rollbackSecret,
|
|
524
|
+
setConfigEntry,
|
|
525
|
+
setSecret
|
|
173
526
|
};
|
|
174
527
|
//# sourceMappingURL=index.js.map
|