noodleseed-cli 0.1.5 → 0.1.7
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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +285 -14
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +7 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/control-plane.js +1 -1
- package/dist/control-plane.js.map +1 -1
- package/dist/deploy.d.ts +0 -11
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +0 -31
- package/dist/deploy.js.map +1 -1
- package/dist/dev.d.ts +0 -3
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +6 -6
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-config.d.ts +38 -0
- package/dist/local-config.d.ts.map +1 -0
- package/dist/local-config.js +197 -0
- package/dist/local-config.js.map +1 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/expression.d.ts +2 -2
- package/node_modules/@noodle-borg/compiler/dist/manifest/expression.js +2 -2
- package/node_modules/@noodle-borg/compiler/dist/manifest/expression.js.map +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/schema.d.ts.map +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/schema.js +2 -3
- package/node_modules/@noodle-borg/connector-defs/dist/schema.js.map +1 -1
- package/node_modules/@noodle-borg/runtime/dist/broker/map.d.ts +5 -6
- package/node_modules/@noodle-borg/runtime/dist/broker/map.d.ts.map +1 -1
- package/node_modules/@noodle-borg/runtime/dist/broker/map.js +5 -6
- package/node_modules/@noodle-borg/runtime/dist/broker/map.js.map +1 -1
- package/node_modules/@noodle-borg/runtime/dist/execute.d.ts +1 -0
- package/node_modules/@noodle-borg/runtime/dist/execute.d.ts.map +1 -1
- package/node_modules/@noodle-borg/runtime/dist/execute.js +11 -5
- package/node_modules/@noodle-borg/runtime/dist/execute.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/index.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/index.js +1 -1
- package/node_modules/@noodle-borg/service/dist/index.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/service.d.ts +6 -3
- package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/service.js +185 -114
- package/node_modules/@noodle-borg/service/dist/service.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts +15 -3
- package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store/postgres.js +144 -2
- package/node_modules/@noodle-borg/service/dist/store/postgres.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store.d.ts +59 -0
- package/node_modules/@noodle-borg/service/dist/store.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store.js +107 -0
- package/node_modules/@noodle-borg/service/dist/store.js.map +1 -1
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ import { InMemoryConnectorRegistry, MapServiceBroker, SecretBox, staticMasterKey
|
|
|
7
7
|
import { applySecurityHeaders, createMcpRouter, effectiveProto, enforceHttps, mintCallerKey, noopLogger, verifyCallerKey, } from '@noodle-borg/transport-http';
|
|
8
8
|
import { allowAllGate, GoogleControlPlaneGate, } from './auth/deploy-gate.js';
|
|
9
9
|
import { isAuthServerPath } from './oauth/paths.js';
|
|
10
|
-
import { InMemoryControlPlaneStore, JsonFileArtifactStore, validateOrgRole, validateSlug, validateTenantRef, } from './store.js';
|
|
10
|
+
import { InMemoryConfigStore, InMemoryControlPlaneStore, JsonFileArtifactStore, resolveConfigScope, scopeChain, validateConfigName, validateConfigScope, validateOrgRole, validateSlug, validateTenantRef, } from './store.js';
|
|
11
11
|
const DEFAULT_MAX_BODY = 1 << 20;
|
|
12
12
|
/** Well-known prefix for OAuth 2.0 Protected Resource Metadata (RFC 9728); the resource path follows it. */
|
|
13
13
|
const PRM_PREFIX = '/.well-known/oauth-protected-resource';
|
|
@@ -30,7 +30,7 @@ export class ServerRegistry {
|
|
|
30
30
|
#records = new Map();
|
|
31
31
|
#activeTenants = new Map();
|
|
32
32
|
#store;
|
|
33
|
-
#
|
|
33
|
+
#configStore;
|
|
34
34
|
/** Single-flight: concurrent first-hits for an uncached id share one in-flight lazy compile (ADR 0036). */
|
|
35
35
|
#inflight = new Map();
|
|
36
36
|
/**
|
|
@@ -40,11 +40,11 @@ export class ServerRegistry {
|
|
|
40
40
|
* envelope. `serveService` enforces this (a `dataDir` fails closed without a master key); a caller that
|
|
41
41
|
* constructs this directly is responsible for the same pairing.
|
|
42
42
|
*/
|
|
43
|
-
constructor(store,
|
|
43
|
+
constructor(store, _secretBox, configStore) {
|
|
44
44
|
this.#store = store;
|
|
45
|
-
this.#
|
|
45
|
+
this.#configStore = configStore ?? new InMemoryConfigStore();
|
|
46
46
|
}
|
|
47
|
-
async deploy(tenant, manifest, connectors,
|
|
47
|
+
async deploy(tenant, manifest, connectors, actor, accessMode = 'caller-key',
|
|
48
48
|
/**
|
|
49
49
|
* When true and a prior active deployment exists for this tenant, reuse its caller-key **hash** instead
|
|
50
50
|
* of minting a new key (the plaintext is unrecoverable, so none is returned). Used by `noodle dev`
|
|
@@ -67,7 +67,7 @@ export class ServerRegistry {
|
|
|
67
67
|
],
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
-
const built = this.#compileTarget(manifest, connectors
|
|
70
|
+
const built = await this.#compileTarget(safeTenant, manifest, connectors);
|
|
71
71
|
if (!built.ok)
|
|
72
72
|
return { ok: false, errors: built.errors };
|
|
73
73
|
const deploymentId = mintDeploymentId(built.served.artifact.server.name);
|
|
@@ -99,7 +99,7 @@ export class ServerRegistry {
|
|
|
99
99
|
...(callerKeyHash ? { callerKeyHash } : {}),
|
|
100
100
|
manifest,
|
|
101
101
|
...(connectors !== undefined ? { connectors } : {}),
|
|
102
|
-
secrets:
|
|
102
|
+
secrets: emptySecretEnvelope(),
|
|
103
103
|
};
|
|
104
104
|
await this.#store.append(record);
|
|
105
105
|
this.#records.set(record.deploymentId, record);
|
|
@@ -128,7 +128,7 @@ export class ServerRegistry {
|
|
|
128
128
|
...(callerKeyHash ? { callerKeyHash } : {}),
|
|
129
129
|
manifest,
|
|
130
130
|
...(connectors !== undefined ? { connectors } : {}),
|
|
131
|
-
secrets:
|
|
131
|
+
secrets: emptySecretEnvelope(),
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
this.#servers.set(deploymentId, {
|
|
@@ -177,23 +177,7 @@ export class ServerRegistry {
|
|
|
177
177
|
const failed = [];
|
|
178
178
|
let recovered = 0;
|
|
179
179
|
for (const record of records) {
|
|
180
|
-
|
|
181
|
-
// fail-soft (skip + report this server), not crash the whole recovery. The error message carries no
|
|
182
|
-
// secret material (SecretDecryptError is deliberately opaque).
|
|
183
|
-
let secrets;
|
|
184
|
-
try {
|
|
185
|
-
secrets = await secretsOf(record.secrets, this.#secretBox);
|
|
186
|
-
}
|
|
187
|
-
catch (error) {
|
|
188
|
-
failed.push({
|
|
189
|
-
deploymentId: record.deploymentId,
|
|
190
|
-
errors: [
|
|
191
|
-
{ code: 'secret_decrypt_failed', path: 'secrets', message: error.message },
|
|
192
|
-
],
|
|
193
|
-
});
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const built = this.#compileTarget(record.manifest, record.connectors, secrets);
|
|
180
|
+
const built = await this.#compileTarget(recordTenant(record), record.manifest, record.connectors);
|
|
197
181
|
if (!built.ok) {
|
|
198
182
|
failed.push({ deploymentId: record.deploymentId, errors: built.errors });
|
|
199
183
|
continue;
|
|
@@ -213,7 +197,7 @@ export class ServerRegistry {
|
|
|
213
197
|
* {@link recover}. Fails closed when a referenced secret has no value; the error names only the
|
|
214
198
|
* reference, never a value.
|
|
215
199
|
*/
|
|
216
|
-
#compileTarget(manifest, connectors
|
|
200
|
+
async #compileTarget(tenant, manifest, connectors) {
|
|
217
201
|
let catalogConnectors = [];
|
|
218
202
|
let httpConnectors = [];
|
|
219
203
|
let secretBindings = [];
|
|
@@ -225,19 +209,26 @@ export class ServerRegistry {
|
|
|
225
209
|
httpConnectors = cc.connectors;
|
|
226
210
|
secretBindings = cc.secretBindings;
|
|
227
211
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
212
|
+
const scope = resolveConfigScope({
|
|
213
|
+
org: tenant.org,
|
|
214
|
+
app: tenant.app,
|
|
215
|
+
env: tenant.env,
|
|
216
|
+
});
|
|
217
|
+
const missing = missingSecretErrors(secretBindings, await this.#configStore.resolveConfigValues('secret', scope));
|
|
218
|
+
if (missing.length > 0)
|
|
219
|
+
return { ok: false, errors: missing };
|
|
231
220
|
const compiled = compile(manifest, { catalog: new InMemoryCatalog(catalogConnectors) });
|
|
232
221
|
if (!compiled.ok)
|
|
233
222
|
return { ok: false, errors: compiled.errors };
|
|
223
|
+
const broker = new ManagedConfigBroker(secretBindings, this.#configStore, scope);
|
|
234
224
|
return {
|
|
235
225
|
ok: true,
|
|
236
226
|
served: {
|
|
237
227
|
artifact: compiled.artifact,
|
|
238
228
|
deps: {
|
|
239
229
|
connectors: new InMemoryConnectorRegistry(httpConnectors),
|
|
240
|
-
broker
|
|
230
|
+
broker,
|
|
231
|
+
env: () => this.#configStore.resolveConfigValues('variable', scope),
|
|
241
232
|
},
|
|
242
233
|
},
|
|
243
234
|
};
|
|
@@ -332,6 +323,9 @@ export class ServerRegistry {
|
|
|
332
323
|
const hash = active?.callerKeyHash;
|
|
333
324
|
return hash !== undefined && verifyCallerKey(token, hash);
|
|
334
325
|
}
|
|
326
|
+
get configStore() {
|
|
327
|
+
return this.#configStore;
|
|
328
|
+
}
|
|
335
329
|
/**
|
|
336
330
|
* The lazy half of {@link get}: point-read one persisted record and recompile it on a cache miss. `store`
|
|
337
331
|
* is passed in (resolved by the caller) so this stays free of `#store`-narrowing. No record → `undefined`
|
|
@@ -341,11 +335,7 @@ export class ServerRegistry {
|
|
|
341
335
|
const record = await store.get(deploymentId);
|
|
342
336
|
if (!record)
|
|
343
337
|
return undefined;
|
|
344
|
-
|
|
345
|
-
// cannot be materialised (possibly transiently, e.g. KMS unavailable), so it is a `500`, not a `404`.
|
|
346
|
-
// `SecretDecryptError` is deliberately opaque — no secret material reaches the (swallowed) 500 body.
|
|
347
|
-
const secrets = await secretsOf(record.secrets, this.#secretBox);
|
|
348
|
-
const built = this.#compileTarget(record.manifest, record.connectors, secrets);
|
|
338
|
+
const built = await this.#compileTarget(recordTenant(record), record.manifest, record.connectors);
|
|
349
339
|
if (!built.ok) {
|
|
350
340
|
// The inputs compiled at deploy time; failing now is source/catalog drift — a server error. Codes
|
|
351
341
|
// only (never a secret name or value) in case this is ever logged; the client gets a generic 500.
|
|
@@ -391,56 +381,51 @@ function recordSummary(record) {
|
|
|
391
381
|
hasCallerKey: record.callerKeyHash !== undefined,
|
|
392
382
|
};
|
|
393
383
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
* map is AES-256-GCM-sealed (`aes-256-gcm`); without one — non-persistent / in-memory paths only — it
|
|
397
|
-
* falls back to the interim plaintext form (`none`). The live in-memory broker still holds plaintext;
|
|
398
|
-
* only the persisted projection is sealed.
|
|
399
|
-
*/
|
|
400
|
-
async function sealSecrets(values, box) {
|
|
401
|
-
if (!box)
|
|
402
|
-
return { enc: 'none', values: { ...values } };
|
|
403
|
-
return { enc: 'aes-256-gcm', sealed: await box.seal(JSON.stringify(values)) };
|
|
384
|
+
function recordTenant(record) {
|
|
385
|
+
return { org: record.orgSlug, app: record.appSlug, env: record.environment };
|
|
404
386
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
* directly (non-persistent/in-memory); an `aes-256-gcm` envelope is decrypted with the {@link SecretBox} —
|
|
408
|
-
* fail-closed if no box (no master key) is available, and a `SecretDecryptError` on a wrong key or
|
|
409
|
-
* tampered ciphertext.
|
|
410
|
-
*/
|
|
411
|
-
async function secretsOf(envelope, box) {
|
|
412
|
-
if (envelope.enc === 'none')
|
|
413
|
-
return { ...envelope.values };
|
|
414
|
-
if (!box) {
|
|
415
|
-
throw new Error('cannot recover an encrypted secret envelope without a secret master key');
|
|
416
|
-
}
|
|
417
|
-
return JSON.parse(await box.open(envelope.sealed));
|
|
387
|
+
function emptySecretEnvelope() {
|
|
388
|
+
return { enc: 'none', values: {} };
|
|
418
389
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
* {@link MapServiceBroker}. Each binding keyed by `(connectorId, operation?)` gets a `{token}` credential;
|
|
422
|
-
* operations with no declared auth fall back to the broker's empty-token default (preserving public-API
|
|
423
|
-
* behavior). A reference with no supplied value fails closed — the server is not registered — and the
|
|
424
|
-
* error names only the reference (never a value), keeping secrets out of error bodies and logs.
|
|
425
|
-
*/
|
|
426
|
-
function buildBroker(bindings, secrets) {
|
|
427
|
-
const entries = new Map();
|
|
390
|
+
function missingSecretErrors(bindings, secrets) {
|
|
391
|
+
const seen = new Set();
|
|
428
392
|
const errors = [];
|
|
429
393
|
for (const binding of bindings) {
|
|
430
|
-
|
|
431
|
-
|
|
394
|
+
if (secrets[binding.secretRef] === undefined && !seen.has(binding.secretRef)) {
|
|
395
|
+
seen.add(binding.secretRef);
|
|
432
396
|
errors.push({
|
|
433
397
|
code: 'missing_secret',
|
|
434
398
|
path: `secrets.${binding.secretRef}`,
|
|
435
|
-
message: `no value
|
|
399
|
+
message: `no managed value found for required secret "${binding.secretRef}"`,
|
|
436
400
|
});
|
|
437
|
-
continue;
|
|
438
401
|
}
|
|
439
|
-
entries.set(MapServiceBroker.key(binding.connectorId, binding.operation), { token: value });
|
|
440
402
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
403
|
+
return errors;
|
|
404
|
+
}
|
|
405
|
+
class ManagedConfigBroker {
|
|
406
|
+
#bindings = new Map();
|
|
407
|
+
#store;
|
|
408
|
+
#scope;
|
|
409
|
+
constructor(bindings, store, scope) {
|
|
410
|
+
this.#store = store;
|
|
411
|
+
this.#scope = scope;
|
|
412
|
+
for (const binding of bindings) {
|
|
413
|
+
this.#bindings.set(MapServiceBroker.key(binding.connectorId, binding.operation), binding.secretRef);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async getCredential(request) {
|
|
417
|
+
const opRef = request.operation !== undefined
|
|
418
|
+
? this.#bindings.get(MapServiceBroker.key(request.connectorId, request.operation))
|
|
419
|
+
: undefined;
|
|
420
|
+
const ref = opRef ?? this.#bindings.get(MapServiceBroker.key(request.connectorId));
|
|
421
|
+
if (ref === undefined)
|
|
422
|
+
return { token: '' };
|
|
423
|
+
const secrets = await this.#store.resolveConfigValues('secret', this.#scope);
|
|
424
|
+
const token = secrets[ref];
|
|
425
|
+
if (token === undefined)
|
|
426
|
+
throw new Error(`missing managed secret "${ref}"`);
|
|
427
|
+
return { token };
|
|
428
|
+
}
|
|
444
429
|
}
|
|
445
430
|
function tenantKey(ref) {
|
|
446
431
|
return `${ref.org}/${ref.app}/${ref.env}`;
|
|
@@ -460,6 +445,7 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
460
445
|
const logger = options.logger ?? noopLogger;
|
|
461
446
|
const tls = options.tls ?? {};
|
|
462
447
|
const controlPlane = options.controlPlaneStore ?? new InMemoryControlPlaneStore();
|
|
448
|
+
const configStore = options.configStore ?? registry.configStore;
|
|
463
449
|
const router = createMcpRouter(() => Promise.resolve(undefined), {
|
|
464
450
|
logger,
|
|
465
451
|
tls,
|
|
@@ -578,6 +564,18 @@ export function createServiceHandler(registry, options = {}) {
|
|
|
578
564
|
});
|
|
579
565
|
return;
|
|
580
566
|
}
|
|
567
|
+
const configRef = parseConfigPath(url.pathname);
|
|
568
|
+
if (configRef !== undefined &&
|
|
569
|
+
(req.method === 'GET' || req.method === 'PUT' || req.method === 'DELETE')) {
|
|
570
|
+
applySecurityHeaders(res, tls);
|
|
571
|
+
if (enforceHttps(req, res, tls))
|
|
572
|
+
return;
|
|
573
|
+
handleConfigValues(req, res, gate, controlPlane, configStore, maxBody, configRef).catch(() => {
|
|
574
|
+
if (!res.headersSent)
|
|
575
|
+
sendJson(res, 500, { error: 'internal error' });
|
|
576
|
+
});
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
581
579
|
const deploymentsRef = parseDeploymentsPath(url.pathname);
|
|
582
580
|
if (deploymentsRef !== undefined && req.method === 'GET') {
|
|
583
581
|
applySecurityHeaders(res, tls);
|
|
@@ -617,7 +615,6 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
|
|
|
617
615
|
return sendJson(res, 413, { error: 'request body too large' });
|
|
618
616
|
let manifest;
|
|
619
617
|
let connectors;
|
|
620
|
-
let secrets;
|
|
621
618
|
let accessMode = 'caller-key';
|
|
622
619
|
try {
|
|
623
620
|
const parsed = JSON.parse(body.text);
|
|
@@ -630,7 +627,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
|
|
|
630
627
|
connectors = parsed.connectors;
|
|
631
628
|
}
|
|
632
629
|
if (parsed.secrets !== undefined) {
|
|
633
|
-
|
|
630
|
+
throw new Error('"secrets" is no longer accepted; use noodle secrets set before deploy');
|
|
634
631
|
}
|
|
635
632
|
if (parsed.accessMode !== undefined) {
|
|
636
633
|
if (!isAccessMode(parsed.accessMode)) {
|
|
@@ -650,7 +647,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
|
|
|
650
647
|
});
|
|
651
648
|
}
|
|
652
649
|
const logger = options.logger ?? noopLogger;
|
|
653
|
-
const result = await registry.deploy(tenant, manifest, connectors,
|
|
650
|
+
const result = await registry.deploy(tenant, manifest, connectors, identity || undefined, accessMode);
|
|
654
651
|
if (!result.ok) {
|
|
655
652
|
// Codes are safe enum strings; never a path-with-value. No secret name or value is logged.
|
|
656
653
|
logger.warn('deploy.rejected', {
|
|
@@ -667,7 +664,6 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
|
|
|
667
664
|
app: tenant.app,
|
|
668
665
|
env: tenant.env,
|
|
669
666
|
hasConnectors: connectors !== undefined,
|
|
670
|
-
secretCount: secrets ? Object.keys(secrets).length : 0,
|
|
671
667
|
});
|
|
672
668
|
const base = options.publicBaseUrl ?? baseFromRequest(req, options.tls ?? {});
|
|
673
669
|
return sendJson(res, 201, {
|
|
@@ -785,6 +781,54 @@ async function handleMembers(req, res, gate, controlPlane, maxBody, ref) {
|
|
|
785
781
|
}
|
|
786
782
|
return sendJson(res, 404, { error: 'not found' });
|
|
787
783
|
}
|
|
784
|
+
async function handleConfigValues(req, res, gate, controlPlane, configStore, maxBody, ref) {
|
|
785
|
+
const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: false });
|
|
786
|
+
if (identity === false)
|
|
787
|
+
return;
|
|
788
|
+
if (ref.invalid !== undefined)
|
|
789
|
+
return sendJson(res, 400, { error: ref.invalid });
|
|
790
|
+
if (identity !== undefined && !identity.superAdmin) {
|
|
791
|
+
const member = await controlPlane.isOrgMember({ org: ref.scope.org, subject: identity.subject });
|
|
792
|
+
if (!member)
|
|
793
|
+
return sendForbidden(res, 'forbidden');
|
|
794
|
+
}
|
|
795
|
+
if (req.method === 'GET' && ref.name === undefined) {
|
|
796
|
+
return sendJson(res, 200, {
|
|
797
|
+
ok: true,
|
|
798
|
+
values: await configStore.listConfigValues(ref.kind, ref.scope),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
if (req.method === 'PUT' && ref.name !== undefined) {
|
|
802
|
+
const body = await readJsonBody(req, maxBody);
|
|
803
|
+
if (!body.ok)
|
|
804
|
+
return sendJson(res, body.status, { error: body.error });
|
|
805
|
+
const parsed = body.value;
|
|
806
|
+
if (typeof parsed.value !== 'string') {
|
|
807
|
+
return sendJson(res, 400, { error: '"value" must be a string' });
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
const value = await configStore.setConfigValue({
|
|
811
|
+
kind: ref.kind,
|
|
812
|
+
scope: ref.scope,
|
|
813
|
+
name: ref.name,
|
|
814
|
+
value: parsed.value,
|
|
815
|
+
...(identity !== undefined ? { updatedBySubject: identity.subject } : {}),
|
|
816
|
+
...(identity?.email !== undefined ? { updatedByEmail: identity.email } : {}),
|
|
817
|
+
});
|
|
818
|
+
return sendJson(res, 200, { ok: true, value });
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
return sendJson(res, 400, { error: error.message });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (req.method === 'DELETE' && ref.name !== undefined) {
|
|
825
|
+
await configStore.deleteConfigValue(ref.kind, ref.scope, ref.name);
|
|
826
|
+
res.writeHead(204);
|
|
827
|
+
res.end();
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
return sendJson(res, 404, { error: 'not found' });
|
|
831
|
+
}
|
|
788
832
|
async function handleDeployments(req, res, registry, gate, controlPlane, ref, url) {
|
|
789
833
|
const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
|
|
790
834
|
if (identity === false)
|
|
@@ -912,6 +956,45 @@ function parseMembersPath(pathname) {
|
|
|
912
956
|
return undefined;
|
|
913
957
|
}
|
|
914
958
|
}
|
|
959
|
+
function parseConfigPath(pathname) {
|
|
960
|
+
const kindPattern = '(secrets|variables)';
|
|
961
|
+
const org = new RegExp(`^/v1/orgs/([^/]+)/${kindPattern}(?:/([^/]+))?$`).exec(pathname);
|
|
962
|
+
if (org) {
|
|
963
|
+
return configRouteRef(org[2] === 'secrets' ? 'secret' : 'variable', { level: 'org', org: decodeURIComponent(org[1]) }, org[3]);
|
|
964
|
+
}
|
|
965
|
+
const app = new RegExp(`^/v1/orgs/([^/]+)/apps/([^/]+)/${kindPattern}(?:/([^/]+))?$`).exec(pathname);
|
|
966
|
+
if (app) {
|
|
967
|
+
return configRouteRef(app[3] === 'secrets' ? 'secret' : 'variable', {
|
|
968
|
+
level: 'app',
|
|
969
|
+
org: decodeURIComponent(app[1]),
|
|
970
|
+
app: decodeURIComponent(app[2]),
|
|
971
|
+
}, app[4]);
|
|
972
|
+
}
|
|
973
|
+
const env = new RegExp(`^/v1/orgs/([^/]+)/apps/([^/]+)/envs/([^/]+)/${kindPattern}(?:/([^/]+))?$`).exec(pathname);
|
|
974
|
+
if (!env)
|
|
975
|
+
return undefined;
|
|
976
|
+
return configRouteRef(env[4] === 'secrets' ? 'secret' : 'variable', {
|
|
977
|
+
level: 'env',
|
|
978
|
+
org: decodeURIComponent(env[1]),
|
|
979
|
+
app: decodeURIComponent(env[2]),
|
|
980
|
+
env: decodeURIComponent(env[3]),
|
|
981
|
+
}, env[5]);
|
|
982
|
+
}
|
|
983
|
+
function configRouteRef(kind, scope, encodedName) {
|
|
984
|
+
try {
|
|
985
|
+
const safeScope = validateConfigScope(scope);
|
|
986
|
+
const name = encodedName !== undefined ? validateConfigName(decodeURIComponent(encodedName)) : undefined;
|
|
987
|
+
return { kind, scope: safeScope, ...(name !== undefined ? { name } : {}) };
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
return {
|
|
991
|
+
kind,
|
|
992
|
+
scope: { level: 'org', org: '__invalid__' },
|
|
993
|
+
...(encodedName !== undefined ? { name: '__invalid__' } : {}),
|
|
994
|
+
invalid: 'invalid config scope or name',
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
}
|
|
915
998
|
function parseDeploymentsPath(pathname) {
|
|
916
999
|
const match = /^\/v1\/orgs\/([^/]+)\/deployments$/.exec(pathname);
|
|
917
1000
|
if (!match)
|
|
@@ -942,22 +1025,6 @@ function parseKeysPath(pathname) {
|
|
|
942
1025
|
function createAdmissionGate(_logger) {
|
|
943
1026
|
return async () => ({ allow: true });
|
|
944
1027
|
}
|
|
945
|
-
/**
|
|
946
|
-
* Validate the deploy request's `secrets` field into a `name -> value` map. Rejects a non-object or a
|
|
947
|
-
* non-string value, naming only the offending key (never a value) so secrets never reach an error body.
|
|
948
|
-
*/
|
|
949
|
-
function parseSecrets(raw) {
|
|
950
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
951
|
-
throw new Error('"secrets" must be an object of string values');
|
|
952
|
-
}
|
|
953
|
-
const out = {};
|
|
954
|
-
for (const [name, value] of Object.entries(raw)) {
|
|
955
|
-
if (typeof value !== 'string')
|
|
956
|
-
throw new Error(`secret "${name}" must be a string`);
|
|
957
|
-
out[name] = value;
|
|
958
|
-
}
|
|
959
|
-
return out;
|
|
960
|
-
}
|
|
961
1028
|
function baseFromRequest(req, tls) {
|
|
962
1029
|
const host = req.headers.host ?? 'localhost';
|
|
963
1030
|
return `${effectiveProto(req, tls.trustProxy ?? false)}://${host}`;
|
|
@@ -981,11 +1048,29 @@ export async function serveService(options = {}) {
|
|
|
981
1048
|
throw new Error(`refusing to bind a non-loopback host (${host}) without deploy authentication; ` +
|
|
982
1049
|
'set NOODLE_GOOGLE_CLIENT_ID (or pass a deployGate)');
|
|
983
1050
|
}
|
|
1051
|
+
const durableStoreRequested = options.dataDir !== undefined || options.databaseUrl !== undefined || options.cloudSql !== undefined;
|
|
1052
|
+
// Fail closed: any durable store must have a master-key custodian. Existing deploy records may still
|
|
1053
|
+
// contain encrypted legacy secret envelopes, and managed config secrets are encrypted independently.
|
|
1054
|
+
// KMS (the KEK never enters the process — ADR 0037) takes precedence over a static local key.
|
|
1055
|
+
let secretBox;
|
|
1056
|
+
if (durableStoreRequested) {
|
|
1057
|
+
if (options.kmsKeyName !== undefined) {
|
|
1058
|
+
const { gcpKmsMasterKeyProvider } = await import('./secret/kms-master-key.js');
|
|
1059
|
+
secretBox = new SecretBox(gcpKmsMasterKeyProvider({ keyName: options.kmsKeyName }));
|
|
1060
|
+
}
|
|
1061
|
+
else if (options.secretMasterKey !== undefined) {
|
|
1062
|
+
secretBox = new SecretBox(staticMasterKeyProvider(options.secretMasterKey));
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
throw new Error('persistence requires a secret master-key custodian; set NOODLE_KMS_KEY or NOODLE_SECRET_MASTER_KEY');
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
984
1068
|
// Select exactly one durable backend: Postgres (cloud, ADR 0035) > file (local) > in-memory. The
|
|
985
1069
|
// Postgres modules are loaded lazily so `pg` / the Cloud SQL connector are never pulled in on the
|
|
986
1070
|
// file/in-memory paths (keeping the default startup light and dependency-free).
|
|
987
1071
|
let store;
|
|
988
1072
|
let controlPlaneStore = options.controlPlaneStore;
|
|
1073
|
+
let configStore = options.configStore;
|
|
989
1074
|
let pgPool;
|
|
990
1075
|
if (options.databaseUrl !== undefined || options.cloudSql !== undefined) {
|
|
991
1076
|
const [{ createPostgresPool }, { PostgresArtifactStore }] = await Promise.all([
|
|
@@ -996,32 +1081,17 @@ export async function serveService(options = {}) {
|
|
|
996
1081
|
...(options.databaseUrl !== undefined ? { databaseUrl: options.databaseUrl } : {}),
|
|
997
1082
|
...(options.cloudSql !== undefined ? { cloudSql: options.cloudSql } : {}),
|
|
998
1083
|
});
|
|
999
|
-
const postgres = new PostgresArtifactStore(pgPool.pool);
|
|
1084
|
+
const postgres = new PostgresArtifactStore(pgPool.pool, secretBox);
|
|
1000
1085
|
await postgres.ensureSchema();
|
|
1001
1086
|
store = postgres;
|
|
1002
1087
|
controlPlaneStore = controlPlaneStore ?? postgres;
|
|
1088
|
+
configStore = configStore ?? postgres;
|
|
1003
1089
|
}
|
|
1004
1090
|
else if (options.dataDir !== undefined) {
|
|
1005
1091
|
store = new JsonFileArtifactStore(options.dataDir);
|
|
1006
1092
|
}
|
|
1007
1093
|
controlPlaneStore = controlPlaneStore ?? new InMemoryControlPlaneStore();
|
|
1008
|
-
|
|
1009
|
-
// KMS key (the KEK never enters the process — ADR 0037) takes precedence over a static key; an
|
|
1010
|
-
// absent/invalid key throws here, before any traffic is served. The KMS adapter + its `@google-cloud/kms`
|
|
1011
|
-
// SDK load lazily, so a static/file deployment never pulls them in.
|
|
1012
|
-
let secretBox;
|
|
1013
|
-
if (store) {
|
|
1014
|
-
if (options.kmsKeyName !== undefined) {
|
|
1015
|
-
const { gcpKmsMasterKeyProvider } = await import('./secret/kms-master-key.js');
|
|
1016
|
-
secretBox = new SecretBox(gcpKmsMasterKeyProvider({ keyName: options.kmsKeyName }));
|
|
1017
|
-
}
|
|
1018
|
-
else if (options.secretMasterKey !== undefined) {
|
|
1019
|
-
secretBox = new SecretBox(staticMasterKeyProvider(options.secretMasterKey));
|
|
1020
|
-
}
|
|
1021
|
-
else {
|
|
1022
|
-
throw new Error('persistence requires a secret master-key custodian; set NOODLE_KMS_KEY or NOODLE_SECRET_MASTER_KEY');
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1094
|
+
configStore = configStore ?? new InMemoryConfigStore();
|
|
1025
1095
|
// Self-hosted authorization server (OA-2): build the AS Express app + its state store (the shared Postgres
|
|
1026
1096
|
// store when persistence is on, else in-memory). Express + the SDK auth handlers load lazily, only here, so
|
|
1027
1097
|
// an AS-less deployment never pulls them in. Owner-only token verification is wired to the same signing key
|
|
@@ -1069,7 +1139,7 @@ export async function serveService(options = {}) {
|
|
|
1069
1139
|
keyResolver: await options.oauth.signer.verifierKey(),
|
|
1070
1140
|
});
|
|
1071
1141
|
}
|
|
1072
|
-
const registry = new ServerRegistry(store, secretBox);
|
|
1142
|
+
const registry = new ServerRegistry(store, secretBox, configStore);
|
|
1073
1143
|
// Recovery is lazy by default (ADR 0036): each server recompiles on its first request, which is correct
|
|
1074
1144
|
// on a multi-instance platform (any instance serves any deploy) and a cheaper cold start. `warmAll` opts
|
|
1075
1145
|
// into eager recompile-all-on-boot (boot-time validation of every server, for a pinned/on-prem instance).
|
|
@@ -1091,6 +1161,7 @@ export async function serveService(options = {}) {
|
|
|
1091
1161
|
...options,
|
|
1092
1162
|
readinessProbe,
|
|
1093
1163
|
controlPlaneStore,
|
|
1164
|
+
configStore,
|
|
1094
1165
|
...(options.googleClientId !== undefined
|
|
1095
1166
|
? { controlPlaneGoogleClientId: options.googleClientId }
|
|
1096
1167
|
: {}),
|