noodleseed-cli 0.1.10 → 0.1.12

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 (76) hide show
  1. package/dist/agents.d.ts +3 -0
  2. package/dist/agents.d.ts.map +1 -0
  3. package/dist/agents.js +231 -0
  4. package/dist/agents.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +1102 -107
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +9 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +2 -2
  11. package/dist/config.js.map +1 -1
  12. package/dist/control-plane.d.ts +17 -1
  13. package/dist/control-plane.d.ts.map +1 -1
  14. package/dist/control-plane.js +227 -15
  15. package/dist/control-plane.js.map +1 -1
  16. package/dist/deploy.d.ts +3 -5
  17. package/dist/deploy.d.ts.map +1 -1
  18. package/dist/deploy.js +20 -7
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/dev.d.ts +11 -5
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +7 -17
  23. package/dist/dev.js.map +1 -1
  24. package/dist/diagnostics.d.ts +9 -0
  25. package/dist/diagnostics.d.ts.map +1 -0
  26. package/dist/diagnostics.js +10 -0
  27. package/dist/diagnostics.js.map +1 -0
  28. package/dist/doctor.d.ts +7 -0
  29. package/dist/doctor.d.ts.map +1 -0
  30. package/dist/doctor.js +396 -0
  31. package/dist/doctor.js.map +1 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/openapi-import.d.ts +12 -0
  37. package/dist/openapi-import.d.ts.map +1 -0
  38. package/dist/openapi-import.js +95 -0
  39. package/dist/openapi-import.js.map +1 -0
  40. package/dist/project.d.ts +45 -0
  41. package/dist/project.d.ts.map +1 -0
  42. package/dist/project.js +252 -0
  43. package/dist/project.js.map +1 -0
  44. package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.d.ts +2 -2
  45. package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.js +2 -2
  46. package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.d.ts +14 -0
  47. package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.d.ts.map +1 -1
  48. package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.js +35 -0
  49. package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.js.map +1 -1
  50. package/node_modules/@noodle-borg/service/dist/index.d.ts +1 -1
  51. package/node_modules/@noodle-borg/service/dist/index.d.ts.map +1 -1
  52. package/node_modules/@noodle-borg/service/dist/index.js +1 -1
  53. package/node_modules/@noodle-borg/service/dist/index.js.map +1 -1
  54. package/node_modules/@noodle-borg/service/dist/service.d.ts +6 -25
  55. package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
  56. package/node_modules/@noodle-borg/service/dist/service.js +249 -174
  57. package/node_modules/@noodle-borg/service/dist/service.js.map +1 -1
  58. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts +2 -1
  59. package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts.map +1 -1
  60. package/node_modules/@noodle-borg/service/dist/store/postgres.js +19 -78
  61. package/node_modules/@noodle-borg/service/dist/store/postgres.js.map +1 -1
  62. package/node_modules/@noodle-borg/service/dist/store.d.ts +24 -12
  63. package/node_modules/@noodle-borg/service/dist/store.d.ts.map +1 -1
  64. package/node_modules/@noodle-borg/service/dist/store.js +18 -18
  65. package/node_modules/@noodle-borg/service/dist/store.js.map +1 -1
  66. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +2 -21
  67. package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts.map +1 -1
  68. package/node_modules/@noodle-borg/transport-http/dist/handler.js +14 -25
  69. package/node_modules/@noodle-borg/transport-http/dist/handler.js.map +1 -1
  70. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -2
  71. package/node_modules/@noodle-borg/transport-http/dist/index.d.ts.map +1 -1
  72. package/node_modules/@noodle-borg/transport-http/dist/index.js +0 -1
  73. package/node_modules/@noodle-borg/transport-http/dist/index.js.map +1 -1
  74. package/node_modules/@noodle-borg/transport-http/dist/logging.d.ts +1 -1
  75. package/node_modules/@noodle-borg/transport-http/dist/logging.js +1 -1
  76. package/package.json +16 -16
@@ -4,8 +4,8 @@ import { createJwtVerifier, protectedResourceMetadata, } from '@noodle-borg/auth
4
4
  import { compile, InMemoryCatalog } from '@noodle-borg/compiler';
5
5
  import { compileConnectors } from '@noodle-borg/connector-defs';
6
6
  import { InMemoryConnectorRegistry, MapServiceBroker, SecretBox, staticMasterKeyProvider, } from '@noodle-borg/runtime';
7
- import { applySecurityHeaders, createMcpRouter, effectiveProto, enforceHttps, mintCallerKey, noopLogger, verifyCallerKey, } from '@noodle-borg/transport-http';
8
- import { allowAllGate, GoogleControlPlaneGate, } from './auth/deploy-gate.js';
7
+ import { applySecurityHeaders, createMcpRouter, effectiveProto, enforceHttps, noopLogger, } from '@noodle-borg/transport-http';
8
+ import { allowAllGate, GoogleControlPlaneGate, NoodleOAuthControlPlaneGate, } from './auth/deploy-gate.js';
9
9
  import { isAuthServerPath } from './oauth/paths.js';
10
10
  import { InMemoryConfigStore, InMemoryControlPlaneStore, JsonFileArtifactStore, resolveConfigScope, scopeChain, validateConfigName, validateConfigScope, validateOrgRole, validateSlug, validateTenantRef, } from './store.js';
11
11
  const DEFAULT_MAX_BODY = 1 << 20;
@@ -44,18 +44,11 @@ export class ServerRegistry {
44
44
  this.#store = store;
45
45
  this.#configStore = configStore ?? new InMemoryConfigStore();
46
46
  }
47
- async deploy(tenant, manifest, connectors, actor, accessMode = 'caller-key',
48
- /**
49
- * When true and a prior active deployment exists for this tenant, reuse its caller-key **hash** instead
50
- * of minting a new key (the plaintext is unrecoverable, so none is returned). Used by `noodle dev`
51
- * to keep the local key stable across hot-reloads; the HTTP deploy route never sets it, so production
52
- * redeploys keep rotating the key.
53
- */
54
- reuseCallerKey = false) {
47
+ async deploy(tenant, manifest, connectors, actor, accessMode) {
55
48
  const safeTenant = validateTenantRef(tenant);
56
49
  // Identity-based access modes gate the data plane on verified identity, so the deploy must have an
57
50
  // authenticated actor to establish ownership/audit provenance and avoid unauthored protected servers.
58
- if (isIdentityAccessMode(accessMode) && actor === undefined) {
51
+ if (accessMode !== undefined && actor === undefined) {
59
52
  return {
60
53
  ok: false,
61
54
  errors: [
@@ -71,18 +64,10 @@ export class ServerRegistry {
71
64
  if (!built.ok)
72
65
  return { ok: false, errors: built.errors };
73
66
  const deploymentId = mintDeploymentId(built.served.artifact.server.name);
74
- // `caller-key`: mint a per-server caller key, store only its hash, return the key once — unless
75
- // `reuseCallerKey` is set and a prior active deployment exists, in which case keep its hash (no plaintext
76
- // to return). Identity modes: no shared key at all — callers authenticate with verified identity.
77
- const reusedHash = accessMode === 'caller-key' && reuseCallerKey
78
- ? await this.#activeCallerKeyHash(safeTenant)
79
- : undefined;
80
- const callerKey = accessMode === 'caller-key' && reusedHash === undefined ? mintCallerKey() : undefined;
81
- const callerKeyHash = callerKey?.hash ?? reusedHash;
82
67
  const version = Date.now();
83
68
  const ownerSubject = accessMode === 'owner-only' ? actor?.subject : undefined;
84
69
  // Persist before registering so a write failure fails the deploy closed (nothing is served that would
85
- // silently vanish on restart). The plaintext caller key is never persisted — only its hash (ADR 0030).
70
+ // silently vanish on restart).
86
71
  if (this.#store) {
87
72
  const record = {
88
73
  schemaVersion: 1,
@@ -95,8 +80,7 @@ export class ServerRegistry {
95
80
  serverName: built.served.artifact.server.name,
96
81
  createdAt: new Date().toISOString(),
97
82
  ...(actor ? { createdBySubject: actor.subject, createdByEmail: actor.email } : {}),
98
- accessMode,
99
- ...(callerKeyHash ? { callerKeyHash } : {}),
83
+ ...(accessMode !== undefined ? { accessMode } : {}),
100
84
  manifest,
101
85
  ...(connectors !== undefined ? { connectors } : {}),
102
86
  secrets: emptySecretEnvelope(),
@@ -124,8 +108,7 @@ export class ServerRegistry {
124
108
  serverName: built.served.artifact.server.name,
125
109
  createdAt: new Date().toISOString(),
126
110
  ...(actor ? { createdBySubject: actor.subject, createdByEmail: actor.email } : {}),
127
- accessMode,
128
- ...(callerKeyHash ? { callerKeyHash } : {}),
111
+ ...(accessMode !== undefined ? { accessMode } : {}),
129
112
  manifest,
130
113
  ...(connectors !== undefined ? { connectors } : {}),
131
114
  secrets: emptySecretEnvelope(),
@@ -133,41 +116,20 @@ export class ServerRegistry {
133
116
  }
134
117
  this.#servers.set(deploymentId, {
135
118
  served: built.served,
136
- accessMode,
119
+ ...(accessMode !== undefined ? { accessMode } : {}),
137
120
  org: safeTenant.org,
138
- ...(callerKeyHash ? { callerKeyHash } : {}),
139
121
  ...(ownerSubject !== undefined ? { ownerSubject } : {}),
140
122
  });
141
123
  this.#activeTenants.set(tenantKey(safeTenant), deploymentId);
142
124
  return {
143
125
  ok: true,
144
126
  deploymentId,
145
- accessMode,
146
- ...(callerKey ? { callerKey: callerKey.key } : {}),
147
- ...(reusedHash !== undefined ? { callerKeyReused: true } : {}),
127
+ ...(accessMode !== undefined ? { accessMode } : {}),
148
128
  };
149
129
  }
150
- /**
151
- * The caller-key **hash** of the tenant's current active deployment, or `undefined` if none. Checks the
152
- * in-memory registry first (the `dev` hot-reload path), then the durable store. Used only by the
153
- * `reuseCallerKey` deploy path to keep a key stable across redeploys without ever handling its plaintext.
154
- */
155
- async #activeCallerKeyHash(tenant) {
156
- const cachedId = this.#activeTenants.get(tenantKey(tenant));
157
- if (cachedId !== undefined) {
158
- const hash = this.#servers.get(cachedId)?.callerKeyHash;
159
- if (hash !== undefined)
160
- return hash;
161
- }
162
- if (this.#store) {
163
- const record = await this.#store.getActiveByTenant(tenant);
164
- return record?.callerKeyHash;
165
- }
166
- return undefined;
167
- }
168
130
  /**
169
131
  * Rebuild every persisted server into memory on startup. Each record is replayed through the same
170
- * compile path as a fresh deploy, reusing its stored id and caller-key hash. A record that no longer
132
+ * compile path as a fresh deploy, reusing its stored id and access metadata. A record that no longer
171
133
  * compiles (e.g. source/catalog drift) is skipped and reported — the remaining servers still recover.
172
134
  */
173
135
  async recover() {
@@ -177,6 +139,19 @@ export class ServerRegistry {
177
139
  const failed = [];
178
140
  let recovered = 0;
179
141
  for (const record of records) {
142
+ if (record.accessMode === undefined) {
143
+ failed.push({
144
+ deploymentId: record.deploymentId,
145
+ errors: [
146
+ {
147
+ code: 'unsupported_legacy_access_mode',
148
+ path: 'accessMode',
149
+ message: 'caller-key deployments are no longer supported; redeploy with owner-only or org-members access',
150
+ },
151
+ ],
152
+ });
153
+ continue;
154
+ }
180
155
  const built = await this.#compileTarget(recordTenant(record), record.manifest, record.connectors);
181
156
  if (!built.ok) {
182
157
  failed.push({ deploymentId: record.deploymentId, errors: built.errors });
@@ -285,43 +260,66 @@ export class ServerRegistry {
285
260
  .sort((a, b) => b.deploymentVersion - a.deploymentVersion)
286
261
  .map(recordSummary);
287
262
  }
288
- async rotateCallerKey(tenant) {
289
- const safe = validateTenantRef(tenant);
290
- const active = this.#store
291
- ? await this.#store.getActiveByTenant(safe)
292
- : [...this.#records.values()]
293
- .filter((record) => record.active &&
294
- record.orgSlug === safe.org &&
295
- record.appSlug === safe.app &&
296
- record.environment === safe.env)
297
- .sort((a, b) => b.deploymentVersion - a.deploymentVersion)[0];
298
- if (!active || (active.accessMode ?? 'caller-key') !== 'caller-key')
263
+ async getStatus(ref, baseUrl) {
264
+ const safe = validateTenantRef(ref);
265
+ const record = await this.#activeRecord(safe);
266
+ if (record === undefined)
299
267
  return undefined;
300
- const minted = mintCallerKey();
301
- const updated = this.#store
302
- ? await this.#store.updateActiveCallerKey(safe, minted.hash)
303
- : { ...active, callerKeyHash: minted.hash };
304
- if (!updated)
268
+ const built = await this.#compileTarget(recordTenant(record), record.manifest, record.connectors);
269
+ const missingSecrets = built.ok ? [] : missingSecretNames(built.errors);
270
+ const unhealthy = !built.ok && missingSecrets.length === 0;
271
+ return {
272
+ target: safe,
273
+ deployment: {
274
+ deploymentId: record.deploymentId,
275
+ endpointUrl: tenantMcpUrl(baseUrl, safe),
276
+ active: record.active,
277
+ serverName: record.serverName,
278
+ createdAt: record.createdAt,
279
+ ...(record.createdByEmail !== undefined ? { createdByEmail: record.createdByEmail } : {}),
280
+ accessMode: record.accessMode ?? 'owner-only',
281
+ },
282
+ health: {
283
+ state: built.ok ? 'ready' : unhealthy ? 'unhealthy' : 'missing-config',
284
+ },
285
+ config: {
286
+ ok: missingSecrets.length === 0,
287
+ missingSecrets,
288
+ },
289
+ };
290
+ }
291
+ async updateAccess(ref, accessMode) {
292
+ const safe = validateTenantRef(ref);
293
+ const record = await this.#activeRecord(safe);
294
+ if (record === undefined)
305
295
  return undefined;
296
+ const updated = { ...record, accessMode };
297
+ if (this.#store)
298
+ await this.#store.updateActiveAccess(safe, accessMode);
306
299
  this.#records.set(updated.deploymentId, updated);
307
- const cached = this.#servers.get(updated.deploymentId);
308
- if (cached) {
309
- this.#servers.set(updated.deploymentId, { ...cached, callerKeyHash: minted.hash });
300
+ const existing = this.#servers.get(updated.deploymentId);
301
+ if (existing)
302
+ this.#servers.set(updated.deploymentId, servedTargetFor(updated, existing.served));
303
+ this.#activeTenants.set(tenantKey(safe), updated.deploymentId);
304
+ return updated;
305
+ }
306
+ async #activeRecord(ref) {
307
+ const cachedId = this.#activeTenants.get(tenantKey(ref));
308
+ if (cachedId !== undefined) {
309
+ const cached = this.#records.get(cachedId);
310
+ if (cached !== undefined)
311
+ return cached;
312
+ if (this.#store)
313
+ return this.#store.get(cachedId);
310
314
  }
311
- return { key: minted.key, deployment: recordSummary(updated) };
312
- }
313
- async verifyActiveCallerKey(tenant, token) {
314
- const safe = validateTenantRef(tenant);
315
- const active = this.#store
316
- ? await this.#store.getActiveByTenant(safe)
317
- : [...this.#records.values()]
318
- .filter((record) => record.active &&
319
- record.orgSlug === safe.org &&
320
- record.appSlug === safe.app &&
321
- record.environment === safe.env)
322
- .sort((a, b) => b.deploymentVersion - a.deploymentVersion)[0];
323
- const hash = active?.callerKeyHash;
324
- return hash !== undefined && verifyCallerKey(token, hash);
315
+ if (this.#store)
316
+ return this.#store.getActiveByTenant(ref);
317
+ return [...this.#records.values()]
318
+ .filter((record) => record.active &&
319
+ record.orgSlug === ref.org &&
320
+ record.appSlug === ref.app &&
321
+ record.environment === ref.env)
322
+ .sort((a, b) => b.deploymentVersion - a.deploymentVersion)[0];
325
323
  }
326
324
  get configStore() {
327
325
  return this.#configStore;
@@ -352,16 +350,17 @@ export class ServerRegistry {
352
350
  }
353
351
  /**
354
352
  * Build the front-door {@link ServedTarget} for a persisted record: its access mode plus the credential the
355
- * front-door checks a caller-key hash (`caller-key`), the owner subject (`owner-only`), or the tenant org
356
- * (`org-members`). A legacy record with no `accessMode` reads as `caller-key`.
353
+ * front-door checks. Legacy alpha records without an identity access mode are not served.
357
354
  */
358
355
  function servedTargetFor(record, served) {
359
- const accessMode = record.accessMode ?? 'caller-key';
356
+ const accessMode = record.accessMode;
357
+ if (accessMode === undefined) {
358
+ throw new Error(`deployment ${record.deploymentId} has unsupported legacy access mode`);
359
+ }
360
360
  return {
361
361
  served,
362
362
  accessMode,
363
363
  org: record.orgSlug,
364
- ...(record.callerKeyHash !== undefined ? { callerKeyHash: record.callerKeyHash } : {}),
365
364
  ...(accessMode === 'owner-only' && record.createdBySubject !== undefined
366
365
  ? { ownerSubject: record.createdBySubject }
367
366
  : {}),
@@ -377,8 +376,7 @@ function recordSummary(record) {
377
376
  serverName: record.serverName,
378
377
  createdAt: record.createdAt,
379
378
  ...(record.createdByEmail !== undefined ? { createdByEmail: record.createdByEmail } : {}),
380
- accessMode: record.accessMode ?? 'caller-key',
381
- hasCallerKey: record.callerKeyHash !== undefined,
379
+ accessMode: record.accessMode ?? 'owner-only',
382
380
  };
383
381
  }
384
382
  function recordTenant(record) {
@@ -455,7 +453,6 @@ export function createServiceHandler(registry, options = {}) {
455
453
  return Promise.resolve(false);
456
454
  return controlPlane.isOrgMember({ org: input.org, subject: input.subject });
457
455
  },
458
- verifyCallerKeyForTenant: (input) => registry.verifyActiveCallerKey(input.tenant, input.token),
459
456
  admissionGate: createAdmissionGate(logger),
460
457
  tenantLookup: (ref) => registry.getActiveByTenant(ref),
461
458
  });
@@ -504,12 +501,26 @@ export function createServiceHandler(registry, options = {}) {
504
501
  if (enforceHttps(req, res, tls))
505
502
  return;
506
503
  const base = options.publicBaseUrl ?? baseFromRequest(req, tls);
504
+ const controlPlaneResource = normalizeServiceBase(options.publicBaseUrl ?? options.authServerIssuer ?? base);
505
+ const selfHostedOAuthLogin = options.authServerApp !== undefined &&
506
+ options.authServerIssuer !== undefined &&
507
+ options.verifyOwnerToken !== undefined;
507
508
  return sendJson(res, 200, {
508
509
  ok: true,
509
510
  service: base,
510
511
  googleClientId: options.controlPlaneGoogleClientId ?? null,
512
+ ...(selfHostedOAuthLogin
513
+ ? {
514
+ authorizationServerIssuer: options.authServerIssuer,
515
+ controlPlaneResource,
516
+ }
517
+ : {}),
511
518
  allowedEmailDomain: options.controlPlaneAllowedEmailDomain ?? '@noodleseed.com',
512
- authType: options.controlPlaneGoogleClientId ? 'google-oauth-pkce' : 'open-dev',
519
+ authType: selfHostedOAuthLogin
520
+ ? 'noodle-oauth-pkce'
521
+ : options.controlPlaneGoogleClientId
522
+ ? 'google-oauth-pkce'
523
+ : 'open-dev',
513
524
  });
514
525
  }
515
526
  const deployRef = parseTenantDeployPath(url.pathname);
@@ -518,8 +529,8 @@ export function createServiceHandler(registry, options = {}) {
518
529
  applySecurityHeaders(res, tls);
519
530
  if (enforceHttps(req, res, tls))
520
531
  return;
521
- // Admin boundary: authenticate the deployer before reading the body. The gate guards only
522
- // management deploys tenant MCP routes are governed separately (caller key, Slice 24).
532
+ // Admin boundary: authenticate the deployer before reading the body. The gate guards management
533
+ // deploys; tenant MCP routes are governed separately by identity-based data-plane auth.
523
534
  handleDeploy(req, res, registry, options, maxBody, deployRef, gate, controlPlane).catch((error) => {
524
535
  // A thrown deploy failure (compile/seal/persist) must be observable, not a silent 500. The message
525
536
  // is operational detail (a DB/KMS/connector fault), not a secret — values/keys are never included.
@@ -576,23 +587,34 @@ export function createServiceHandler(registry, options = {}) {
576
587
  });
577
588
  return;
578
589
  }
579
- const deploymentsRef = parseDeploymentsPath(url.pathname);
580
- if (deploymentsRef !== undefined && req.method === 'GET') {
590
+ const statusRef = parseTenantStatusPath(url.pathname);
591
+ if (statusRef !== undefined && req.method === 'GET') {
581
592
  applySecurityHeaders(res, tls);
582
593
  if (enforceHttps(req, res, tls))
583
594
  return;
584
- handleDeployments(req, res, registry, gate, controlPlane, deploymentsRef, url).catch(() => {
595
+ handleDeploymentStatus(req, res, registry, gate, controlPlane, statusRef, options).catch(() => {
585
596
  if (!res.headersSent)
586
597
  sendJson(res, 500, { error: 'internal error' });
587
598
  });
588
599
  return;
589
600
  }
590
- const keysRef = parseKeysPath(url.pathname);
591
- if (keysRef !== undefined && (req.method === 'GET' || req.method === 'POST')) {
601
+ const accessRef = parseTenantAccessPath(url.pathname);
602
+ if (accessRef !== undefined && req.method === 'PATCH') {
592
603
  applySecurityHeaders(res, tls);
593
604
  if (enforceHttps(req, res, tls))
594
605
  return;
595
- handleKeys(req, res, registry, gate, controlPlane, keysRef).catch(() => {
606
+ handleAccessUpdate(req, res, registry, gate, controlPlane, maxBody, accessRef).catch(() => {
607
+ if (!res.headersSent)
608
+ sendJson(res, 500, { error: 'internal error' });
609
+ });
610
+ return;
611
+ }
612
+ const deploymentsRef = parseDeploymentsPath(url.pathname);
613
+ if (deploymentsRef !== undefined && req.method === 'GET') {
614
+ applySecurityHeaders(res, tls);
615
+ if (enforceHttps(req, res, tls))
616
+ return;
617
+ handleDeployments(req, res, registry, gate, controlPlane, deploymentsRef, url).catch(() => {
596
618
  if (!res.headersSent)
597
619
  sendJson(res, 500, { error: 'internal error' });
598
620
  });
@@ -615,7 +637,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
615
637
  return sendJson(res, 413, { error: 'request body too large' });
616
638
  let manifest;
617
639
  let connectors;
618
- let accessMode = 'caller-key';
640
+ let accessMode = 'owner-only';
619
641
  try {
620
642
  const parsed = JSON.parse(body.text);
621
643
  if (typeof parsed.manifest !== 'string')
@@ -631,7 +653,10 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
631
653
  }
632
654
  if (parsed.accessMode !== undefined) {
633
655
  if (!isAccessMode(parsed.accessMode)) {
634
- throw new Error('"accessMode" must be "caller-key", "owner-only", or "org-members"');
656
+ if (parsed.accessMode === 'caller-key') {
657
+ throw new Error('caller-key access has been removed; use owner-only or org-members');
658
+ }
659
+ throw new Error('"accessMode" must be "owner-only" or "org-members"');
635
660
  }
636
661
  accessMode = parsed.accessMode;
637
662
  }
@@ -657,7 +682,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
657
682
  });
658
683
  return sendJson(res, 400, { ok: false, errors: result.errors });
659
684
  }
660
- // Counts only — never secret names or values, never the minted caller key or its hash.
685
+ // Counts only — never secret names or values.
661
686
  logger.info('deploy.ok', {
662
687
  deploymentId: result.deploymentId,
663
688
  org: tenant.org,
@@ -676,13 +701,10 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
676
701
  url: tenant.env === 'prod'
677
702
  ? `${base}/o/${tenant.org}/${tenant.app}/mcp`
678
703
  : `${base}/o/${tenant.org}/${tenant.app}/${tenant.env}/mcp`,
679
- // Caller-key deploys only: shown once, callers send `Authorization: Bearer <callerKey>` (only the hash
680
- // is stored). Identity-based deploys mint no key — callers authenticate with their own identity.
681
- ...(result.callerKey !== undefined ? { callerKey: result.callerKey } : {}),
682
704
  });
683
705
  }
684
706
  function isAccessMode(value) {
685
- return value === 'caller-key' || value === 'owner-only' || value === 'org-members';
707
+ return value === 'owner-only' || value === 'org-members';
686
708
  }
687
709
  function isIdentityAccessMode(accessMode) {
688
710
  return accessMode === 'owner-only' || accessMode === 'org-members';
@@ -782,7 +804,7 @@ async function handleMembers(req, res, gate, controlPlane, maxBody, ref) {
782
804
  return sendJson(res, 404, { error: 'not found' });
783
805
  }
784
806
  async function handleConfigValues(req, res, gate, controlPlane, configStore, maxBody, ref) {
785
- const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: false });
807
+ const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
786
808
  if (identity === false)
787
809
  return;
788
810
  if (ref.invalid !== undefined)
@@ -829,30 +851,66 @@ async function handleConfigValues(req, res, gate, controlPlane, configStore, max
829
851
  }
830
852
  return sendJson(res, 404, { error: 'not found' });
831
853
  }
832
- async function handleDeployments(req, res, registry, gate, controlPlane, ref, url) {
854
+ async function authorizeTenantControl(req, res, gate, controlPlane, org) {
833
855
  const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
834
856
  if (identity === false)
835
- return;
857
+ return false;
836
858
  if (!identity.superAdmin) {
837
- const member = await controlPlane.isOrgMember({ org: ref.org, subject: identity.subject });
838
- if (!member)
839
- return sendForbidden(res, 'forbidden');
859
+ const member = await controlPlane.isOrgMember({ org, subject: identity.subject });
860
+ if (!member) {
861
+ sendForbidden(res, 'forbidden');
862
+ return false;
863
+ }
840
864
  }
841
- const app = url.searchParams.get('app') ?? undefined;
842
- const env = url.searchParams.get('env') ?? undefined;
865
+ return identity;
866
+ }
867
+ async function handleDeploymentStatus(req, res, registry, gate, controlPlane, tenant, options) {
868
+ const identity = await authorizeTenantControl(req, res, gate, controlPlane, tenant.org);
869
+ if (identity === false)
870
+ return;
843
871
  try {
844
- const deployments = await registry.listDeployments({
845
- org: ref.org,
846
- ...(app !== undefined ? { app } : {}),
847
- ...(env !== undefined ? { env } : {}),
872
+ const base = options.publicBaseUrl ?? baseFromRequest(req, options.tls ?? {});
873
+ const status = await registry.getStatus(tenant, base);
874
+ if (status === undefined)
875
+ return sendJson(res, 404, { error: 'no active deployment' });
876
+ return sendJson(res, 200, { ok: true, ...status });
877
+ }
878
+ catch (error) {
879
+ return sendJson(res, 400, { error: error.message });
880
+ }
881
+ }
882
+ async function handleAccessUpdate(req, res, registry, gate, controlPlane, maxBody, tenant) {
883
+ const identity = await authorizeTenantControl(req, res, gate, controlPlane, tenant.org);
884
+ if (identity === false)
885
+ return;
886
+ const body = await readJsonBody(req, maxBody);
887
+ if (!body.ok)
888
+ return sendJson(res, body.status, { error: body.error });
889
+ const parsed = body.value;
890
+ if (!isAccessMode(parsed.accessMode)) {
891
+ const message = parsed.accessMode === 'caller-key'
892
+ ? 'caller-key access has been removed; use owner-only or org-members'
893
+ : '"accessMode" must be "owner-only" or "org-members"';
894
+ return sendJson(res, 400, { error: message });
895
+ }
896
+ try {
897
+ const record = await registry.updateAccess(tenant, parsed.accessMode);
898
+ if (record === undefined)
899
+ return sendJson(res, 404, { error: 'no active deployment' });
900
+ return sendJson(res, 200, {
901
+ ok: true,
902
+ target: tenant,
903
+ deployment: {
904
+ deploymentId: record.deploymentId,
905
+ accessMode: record.accessMode ?? 'owner-only',
906
+ },
848
907
  });
849
- return sendJson(res, 200, { ok: true, deployments });
850
908
  }
851
909
  catch (error) {
852
910
  return sendJson(res, 400, { error: error.message });
853
911
  }
854
912
  }
855
- async function handleKeys(req, res, registry, gate, controlPlane, ref) {
913
+ async function handleDeployments(req, res, registry, gate, controlPlane, ref, url) {
856
914
  const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
857
915
  if (identity === false)
858
916
  return;
@@ -861,47 +919,19 @@ async function handleKeys(req, res, registry, gate, controlPlane, ref) {
861
919
  if (!member)
862
920
  return sendForbidden(res, 'forbidden');
863
921
  }
864
- if (req.method === 'GET' && ref.action === undefined) {
922
+ const app = url.searchParams.get('app') ?? undefined;
923
+ const env = url.searchParams.get('env') ?? undefined;
924
+ try {
865
925
  const deployments = await registry.listDeployments({
866
926
  org: ref.org,
867
- app: ref.app,
868
- env: ref.env,
869
- });
870
- return sendJson(res, 200, {
871
- ok: true,
872
- keys: deployments
873
- .filter((deployment) => deployment.active && deployment.hasCallerKey)
874
- .map((deployment) => ({
875
- org: deployment.orgSlug,
876
- app: deployment.appSlug,
877
- env: deployment.environment,
878
- deploymentId: deployment.deploymentId,
879
- active: deployment.active,
880
- createdAt: deployment.createdAt,
881
- })),
927
+ ...(app !== undefined ? { app } : {}),
928
+ ...(env !== undefined ? { env } : {}),
882
929
  });
930
+ return sendJson(res, 200, { ok: true, deployments });
883
931
  }
884
- if (req.method === 'POST' && ref.action === 'rotate') {
885
- const rotated = await registry.rotateCallerKey(ref);
886
- if (!rotated) {
887
- return sendJson(res, 404, {
888
- error: 'no active caller-key deployment found for this app environment',
889
- });
890
- }
891
- return sendJson(res, 201, {
892
- ok: true,
893
- key: {
894
- org: rotated.deployment.orgSlug,
895
- app: rotated.deployment.appSlug,
896
- env: rotated.deployment.environment,
897
- deploymentId: rotated.deployment.deploymentId,
898
- active: rotated.deployment.active,
899
- createdAt: new Date().toISOString(),
900
- },
901
- callerKey: rotated.key,
902
- });
932
+ catch (error) {
933
+ return sendJson(res, 400, { error: error.message });
903
934
  }
904
- return sendJson(res, 404, { error: 'not found' });
905
935
  }
906
936
  async function authorizeControlPlane(req, res, gate, options) {
907
937
  const auth = await gate.authorize(req);
@@ -1006,17 +1036,22 @@ function parseDeploymentsPath(pathname) {
1006
1036
  return undefined;
1007
1037
  }
1008
1038
  }
1009
- function parseKeysPath(pathname) {
1010
- const match = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/keys(?:\/(rotate))?$/.exec(pathname);
1039
+ function parseTenantStatusPath(pathname) {
1040
+ return parseTenantActionPath(pathname, 'status');
1041
+ }
1042
+ function parseTenantAccessPath(pathname) {
1043
+ return parseTenantActionPath(pathname, 'access');
1044
+ }
1045
+ function parseTenantActionPath(pathname, action) {
1046
+ const match = new RegExp(`^/v1/orgs/([^/]+)/apps/([^/]+)/envs/([^/]+)/${action}$`).exec(pathname);
1011
1047
  if (!match)
1012
1048
  return undefined;
1013
1049
  try {
1014
- const ref = validateTenantRef({
1050
+ return validateTenantRef({
1015
1051
  org: decodeURIComponent(match[1]),
1016
1052
  app: decodeURIComponent(match[2]),
1017
1053
  env: decodeURIComponent(match[3]),
1018
1054
  });
1019
- return { ...ref, ...(match[4] === 'rotate' ? { action: 'rotate' } : {}) };
1020
1055
  }
1021
1056
  catch {
1022
1057
  return undefined;
@@ -1025,6 +1060,20 @@ function parseKeysPath(pathname) {
1025
1060
  function createAdmissionGate(_logger) {
1026
1061
  return async () => ({ allow: true });
1027
1062
  }
1063
+ function tenantMcpUrl(base, tenant) {
1064
+ return tenant.env === 'prod'
1065
+ ? `${base}/o/${tenant.org}/${tenant.app}/mcp`
1066
+ : `${base}/o/${tenant.org}/${tenant.app}/${tenant.env}/mcp`;
1067
+ }
1068
+ function missingSecretNames(errors) {
1069
+ const names = new Set();
1070
+ for (const error of errors) {
1071
+ if (error.code === 'missing_secret' && error.path.startsWith('secrets.')) {
1072
+ names.add(error.path.slice('secrets.'.length));
1073
+ }
1074
+ }
1075
+ return [...names].sort();
1076
+ }
1028
1077
  function baseFromRequest(req, tls) {
1029
1078
  const host = req.headers.host ?? 'localhost';
1030
1079
  return `${effectiveProto(req, tls.trustProxy ?? false)}://${host}`;
@@ -1032,22 +1081,7 @@ function baseFromRequest(req, tls) {
1032
1081
  /** Start the deploy service on its own `node:http` server (used by the `noodle-service` bin). */
1033
1082
  export async function serveService(options = {}) {
1034
1083
  const host = options.host ?? '127.0.0.1';
1035
- const gate = options.deployGate ??
1036
- (options.googleClientId !== undefined
1037
- ? new GoogleControlPlaneGate({
1038
- audience: options.googleClientId,
1039
- admins: options.controlPlaneAdmins ?? [],
1040
- ...(options.controlPlaneAllowedEmailDomain !== undefined
1041
- ? { allowedEmailDomain: options.controlPlaneAllowedEmailDomain }
1042
- : {}),
1043
- ...(options.googleVerifier ? { verifier: options.googleVerifier } : {}),
1044
- })
1045
- : undefined);
1046
- // Fail closed: never expose an unauthenticated deploy endpoint on a non-loopback bind.
1047
- if (gate === undefined && !isLoopbackHost(host)) {
1048
- throw new Error(`refusing to bind a non-loopback host (${host}) without deploy authentication; ` +
1049
- 'set NOODLE_GOOGLE_CLIENT_ID (or pass a deployGate)');
1050
- }
1084
+ let gate = options.deployGate;
1051
1085
  const durableStoreRequested = options.dataDir !== undefined || options.databaseUrl !== undefined || options.cloudSql !== undefined;
1052
1086
  // Fail closed: any durable store must have a master-key custodian. Existing deploy records may still
1053
1087
  // contain encrypted legacy secret envelopes, and managed config secrets are encrypted independently.
@@ -1139,6 +1173,34 @@ export async function serveService(options = {}) {
1139
1173
  keyResolver: await options.oauth.signer.verifierKey(),
1140
1174
  });
1141
1175
  }
1176
+ if (gate === undefined &&
1177
+ resolvedVerifyOwnerToken !== undefined &&
1178
+ resolvedAuthServerIssuer !== undefined) {
1179
+ const allowedEmailDomain = options.controlPlaneAllowedEmailDomain ?? options.oauth?.allowedEmailDomain;
1180
+ gate = new NoodleOAuthControlPlaneGate({
1181
+ verifier: resolvedVerifyOwnerToken,
1182
+ audience: normalizeOAuthResource(options.publicBaseUrl ?? resolvedAuthServerIssuer),
1183
+ admins: options.controlPlaneAdmins ?? [],
1184
+ ...(allowedEmailDomain !== undefined ? { allowedEmailDomain } : {}),
1185
+ });
1186
+ }
1187
+ gate =
1188
+ gate ??
1189
+ (options.googleClientId !== undefined
1190
+ ? new GoogleControlPlaneGate({
1191
+ audience: options.googleClientId,
1192
+ admins: options.controlPlaneAdmins ?? [],
1193
+ ...(options.controlPlaneAllowedEmailDomain !== undefined
1194
+ ? { allowedEmailDomain: options.controlPlaneAllowedEmailDomain }
1195
+ : {}),
1196
+ ...(options.googleVerifier ? { verifier: options.googleVerifier } : {}),
1197
+ })
1198
+ : undefined);
1199
+ // Fail closed: never expose an unauthenticated deploy endpoint on a non-loopback bind.
1200
+ if (gate === undefined && !isLoopbackHost(host)) {
1201
+ throw new Error(`refusing to bind a non-loopback host (${host}) without deploy authentication; ` +
1202
+ 'set NOODLE_OAUTH_ISSUER/Google OAuth creds, NOODLE_GOOGLE_CLIENT_ID, or pass a deployGate');
1203
+ }
1142
1204
  const registry = new ServerRegistry(store, secretBox, configStore);
1143
1205
  // Recovery is lazy by default (ADR 0036): each server recompiles on its first request, which is correct
1144
1206
  // on a multi-instance platform (any instance serves any deploy) and a cheaper cold start. `warmAll` opts
@@ -1227,6 +1289,19 @@ function sendForbidden(res, message) {
1227
1289
  res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' });
1228
1290
  res.end(JSON.stringify({ error: message }));
1229
1291
  }
1292
+ function normalizeServiceBase(value) {
1293
+ return value.replace(/\/+$/, '');
1294
+ }
1295
+ function normalizeOAuthResource(value) {
1296
+ const normalized = normalizeServiceBase(value);
1297
+ try {
1298
+ const url = new URL(normalized);
1299
+ return url.pathname === '/' && url.search === '' && url.hash === '' ? url.href : normalized;
1300
+ }
1301
+ catch {
1302
+ return normalized;
1303
+ }
1304
+ }
1230
1305
  /** Loopback hosts may run the control plane without auth; any other bind must configure auth. */
1231
1306
  function isLoopbackHost(host) {
1232
1307
  return host === '127.0.0.1' || host === '::1' || host === 'localhost';