noodleseed-cli 0.1.5 → 0.1.6

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.
Files changed (53) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +285 -14
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +7 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js.map +1 -1
  7. package/dist/deploy.d.ts +0 -11
  8. package/dist/deploy.d.ts.map +1 -1
  9. package/dist/deploy.js +0 -31
  10. package/dist/deploy.js.map +1 -1
  11. package/dist/dev.d.ts +0 -3
  12. package/dist/dev.d.ts.map +1 -1
  13. package/dist/dev.js +6 -6
  14. package/dist/dev.js.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/local-config.d.ts +38 -0
  20. package/dist/local-config.d.ts.map +1 -0
  21. package/dist/local-config.js +197 -0
  22. package/dist/local-config.js.map +1 -0
  23. package/node_modules/@noodle-borg/compiler/dist/manifest/expression.d.ts +2 -2
  24. package/node_modules/@noodle-borg/compiler/dist/manifest/expression.js +2 -2
  25. package/node_modules/@noodle-borg/compiler/dist/manifest/expression.js.map +1 -1
  26. package/node_modules/@noodle-borg/connector-defs/dist/schema.d.ts.map +1 -1
  27. package/node_modules/@noodle-borg/connector-defs/dist/schema.js +2 -3
  28. package/node_modules/@noodle-borg/connector-defs/dist/schema.js.map +1 -1
  29. package/node_modules/@noodle-borg/runtime/dist/broker/map.d.ts +5 -6
  30. package/node_modules/@noodle-borg/runtime/dist/broker/map.d.ts.map +1 -1
  31. package/node_modules/@noodle-borg/runtime/dist/broker/map.js +5 -6
  32. package/node_modules/@noodle-borg/runtime/dist/broker/map.js.map +1 -1
  33. package/node_modules/@noodle-borg/runtime/dist/execute.d.ts +1 -0
  34. package/node_modules/@noodle-borg/runtime/dist/execute.d.ts.map +1 -1
  35. package/node_modules/@noodle-borg/runtime/dist/execute.js +11 -5
  36. package/node_modules/@noodle-borg/runtime/dist/execute.js.map +1 -1
  37. package/node_modules/@noodle-borg/service/dist/index.d.ts +1 -1
  38. package/node_modules/@noodle-borg/service/dist/index.d.ts.map +1 -1
  39. package/node_modules/@noodle-borg/service/dist/index.js +1 -1
  40. package/node_modules/@noodle-borg/service/dist/index.js.map +1 -1
  41. package/node_modules/@noodle-borg/service/dist/service.d.ts +6 -3
  42. package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
  43. package/node_modules/@noodle-borg/service/dist/service.js +185 -114
  44. package/node_modules/@noodle-borg/service/dist/service.js.map +1 -1
  45. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts +15 -3
  46. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts.map +1 -1
  47. package/node_modules/@noodle-borg/service/dist/store/postgres.js +144 -2
  48. package/node_modules/@noodle-borg/service/dist/store/postgres.js.map +1 -1
  49. package/node_modules/@noodle-borg/service/dist/store.d.ts +59 -0
  50. package/node_modules/@noodle-borg/service/dist/store.d.ts.map +1 -1
  51. package/node_modules/@noodle-borg/service/dist/store.js +107 -0
  52. package/node_modules/@noodle-borg/service/dist/store.js.map +1 -1
  53. 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
- #secretBox;
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, secretBox) {
43
+ constructor(store, _secretBox, configStore) {
44
44
  this.#store = store;
45
- this.#secretBox = secretBox;
45
+ this.#configStore = configStore ?? new InMemoryConfigStore();
46
46
  }
47
- async deploy(tenant, manifest, connectors, secrets, actor, accessMode = 'caller-key',
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, secrets ?? {});
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: await sealSecrets(secrets ?? {}, this.#secretBox),
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: { enc: 'none', values: 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
- // Decrypt the persisted secrets first; a wrong/missing master key (or tampered ciphertext) must be
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, secrets) {
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 broker = buildBroker(secretBindings, secrets);
229
- if (!broker.ok)
230
- return { ok: false, errors: broker.errors };
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: broker.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
- // A wrong/missing master key (or tampered ciphertext) throws here and propagates: the record exists but
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
- * Encrypt deploy-supplied secret values into the at-rest envelope. With a {@link SecretBox} the whole
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
- * Recover plaintext secret values from a persisted envelope for replay. A `none` envelope is read
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
- * Resolve declared secret references against the deploy-supplied values into a per-server
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
- const value = secrets[binding.secretRef];
431
- if (value === undefined) {
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 supplied for required secret "${binding.secretRef}"`,
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
- if (errors.length > 0)
442
- return { ok: false, errors };
443
- return { ok: true, broker: new MapServiceBroker(entries) };
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
- secrets = parseSecrets(parsed.secrets);
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, secrets, identity || undefined, accessMode);
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
- // Fail closed: any durable store must encrypt secrets at rest, so it requires a master-key custodian. A
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
  : {}),