noodleseed-cli 0.1.11 → 0.1.13

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/bin.d.ts +3 -0
  6. package/dist/bin.d.ts.map +1 -0
  7. package/dist/bin.js +16 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +1102 -107
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts +2 -4
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/control-plane.d.ts +15 -1
  17. package/dist/control-plane.d.ts.map +1 -1
  18. package/dist/control-plane.js +90 -15
  19. package/dist/control-plane.js.map +1 -1
  20. package/dist/deploy.d.ts +3 -5
  21. package/dist/deploy.d.ts.map +1 -1
  22. package/dist/deploy.js +20 -7
  23. package/dist/deploy.js.map +1 -1
  24. package/dist/dev.d.ts +11 -5
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +7 -17
  27. package/dist/dev.js.map +1 -1
  28. package/dist/diagnostics.d.ts +9 -0
  29. package/dist/diagnostics.d.ts.map +1 -0
  30. package/dist/diagnostics.js +10 -0
  31. package/dist/diagnostics.js.map +1 -0
  32. package/dist/doctor.d.ts +7 -0
  33. package/dist/doctor.d.ts.map +1 -0
  34. package/dist/doctor.js +396 -0
  35. package/dist/doctor.js.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/node-version.d.ts +5 -0
  41. package/dist/node-version.d.ts.map +1 -0
  42. package/dist/node-version.js +17 -0
  43. package/dist/node-version.js.map +1 -0
  44. package/dist/openapi-import.d.ts +12 -0
  45. package/dist/openapi-import.d.ts.map +1 -0
  46. package/dist/openapi-import.js +95 -0
  47. package/dist/openapi-import.js.map +1 -0
  48. package/dist/project.d.ts +45 -0
  49. package/dist/project.d.ts.map +1 -0
  50. package/dist/project.js +252 -0
  51. package/dist/project.js.map +1 -0
  52. package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.d.ts +2 -2
  53. package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.js +2 -2
  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 +219 -175
  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 +2 -2
@@ -4,7 +4,7 @@ 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';
7
+ import { applySecurityHeaders, createMcpRouter, effectiveProto, enforceHttps, noopLogger, } from '@noodle-borg/transport-http';
8
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';
@@ -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
  });
@@ -532,8 +529,8 @@ export function createServiceHandler(registry, options = {}) {
532
529
  applySecurityHeaders(res, tls);
533
530
  if (enforceHttps(req, res, tls))
534
531
  return;
535
- // Admin boundary: authenticate the deployer before reading the body. The gate guards only
536
- // 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.
537
534
  handleDeploy(req, res, registry, options, maxBody, deployRef, gate, controlPlane).catch((error) => {
538
535
  // A thrown deploy failure (compile/seal/persist) must be observable, not a silent 500. The message
539
536
  // is operational detail (a DB/KMS/connector fault), not a secret — values/keys are never included.
@@ -590,23 +587,34 @@ export function createServiceHandler(registry, options = {}) {
590
587
  });
591
588
  return;
592
589
  }
593
- const deploymentsRef = parseDeploymentsPath(url.pathname);
594
- if (deploymentsRef !== undefined && req.method === 'GET') {
590
+ const statusRef = parseTenantStatusPath(url.pathname);
591
+ if (statusRef !== undefined && req.method === 'GET') {
595
592
  applySecurityHeaders(res, tls);
596
593
  if (enforceHttps(req, res, tls))
597
594
  return;
598
- handleDeployments(req, res, registry, gate, controlPlane, deploymentsRef, url).catch(() => {
595
+ handleDeploymentStatus(req, res, registry, gate, controlPlane, statusRef, options).catch(() => {
596
+ if (!res.headersSent)
597
+ sendJson(res, 500, { error: 'internal error' });
598
+ });
599
+ return;
600
+ }
601
+ const accessRef = parseTenantAccessPath(url.pathname);
602
+ if (accessRef !== undefined && req.method === 'PATCH') {
603
+ applySecurityHeaders(res, tls);
604
+ if (enforceHttps(req, res, tls))
605
+ return;
606
+ handleAccessUpdate(req, res, registry, gate, controlPlane, maxBody, accessRef).catch(() => {
599
607
  if (!res.headersSent)
600
608
  sendJson(res, 500, { error: 'internal error' });
601
609
  });
602
610
  return;
603
611
  }
604
- const keysRef = parseKeysPath(url.pathname);
605
- if (keysRef !== undefined && (req.method === 'GET' || req.method === 'POST')) {
612
+ const deploymentsRef = parseDeploymentsPath(url.pathname);
613
+ if (deploymentsRef !== undefined && req.method === 'GET') {
606
614
  applySecurityHeaders(res, tls);
607
615
  if (enforceHttps(req, res, tls))
608
616
  return;
609
- handleKeys(req, res, registry, gate, controlPlane, keysRef).catch(() => {
617
+ handleDeployments(req, res, registry, gate, controlPlane, deploymentsRef, url).catch(() => {
610
618
  if (!res.headersSent)
611
619
  sendJson(res, 500, { error: 'internal error' });
612
620
  });
@@ -629,7 +637,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
629
637
  return sendJson(res, 413, { error: 'request body too large' });
630
638
  let manifest;
631
639
  let connectors;
632
- let accessMode = 'caller-key';
640
+ let accessMode = 'owner-only';
633
641
  try {
634
642
  const parsed = JSON.parse(body.text);
635
643
  if (typeof parsed.manifest !== 'string')
@@ -645,7 +653,10 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
645
653
  }
646
654
  if (parsed.accessMode !== undefined) {
647
655
  if (!isAccessMode(parsed.accessMode)) {
648
- 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"');
649
660
  }
650
661
  accessMode = parsed.accessMode;
651
662
  }
@@ -671,7 +682,7 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
671
682
  });
672
683
  return sendJson(res, 400, { ok: false, errors: result.errors });
673
684
  }
674
- // Counts only — never secret names or values, never the minted caller key or its hash.
685
+ // Counts only — never secret names or values.
675
686
  logger.info('deploy.ok', {
676
687
  deploymentId: result.deploymentId,
677
688
  org: tenant.org,
@@ -690,13 +701,10 @@ async function handleDeploy(req, res, registry, options, maxBody, tenant, gate,
690
701
  url: tenant.env === 'prod'
691
702
  ? `${base}/o/${tenant.org}/${tenant.app}/mcp`
692
703
  : `${base}/o/${tenant.org}/${tenant.app}/${tenant.env}/mcp`,
693
- // Caller-key deploys only: shown once, callers send `Authorization: Bearer <callerKey>` (only the hash
694
- // is stored). Identity-based deploys mint no key — callers authenticate with their own identity.
695
- ...(result.callerKey !== undefined ? { callerKey: result.callerKey } : {}),
696
704
  });
697
705
  }
698
706
  function isAccessMode(value) {
699
- return value === 'caller-key' || value === 'owner-only' || value === 'org-members';
707
+ return value === 'owner-only' || value === 'org-members';
700
708
  }
701
709
  function isIdentityAccessMode(accessMode) {
702
710
  return accessMode === 'owner-only' || accessMode === 'org-members';
@@ -796,7 +804,7 @@ async function handleMembers(req, res, gate, controlPlane, maxBody, ref) {
796
804
  return sendJson(res, 404, { error: 'not found' });
797
805
  }
798
806
  async function handleConfigValues(req, res, gate, controlPlane, configStore, maxBody, ref) {
799
- const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: false });
807
+ const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
800
808
  if (identity === false)
801
809
  return;
802
810
  if (ref.invalid !== undefined)
@@ -843,30 +851,66 @@ async function handleConfigValues(req, res, gate, controlPlane, configStore, max
843
851
  }
844
852
  return sendJson(res, 404, { error: 'not found' });
845
853
  }
846
- async function handleDeployments(req, res, registry, gate, controlPlane, ref, url) {
854
+ async function authorizeTenantControl(req, res, gate, controlPlane, org) {
847
855
  const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
848
856
  if (identity === false)
849
- return;
857
+ return false;
850
858
  if (!identity.superAdmin) {
851
- const member = await controlPlane.isOrgMember({ org: ref.org, subject: identity.subject });
852
- if (!member)
853
- 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
+ }
854
864
  }
855
- const app = url.searchParams.get('app') ?? undefined;
856
- 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;
857
871
  try {
858
- const deployments = await registry.listDeployments({
859
- org: ref.org,
860
- ...(app !== undefined ? { app } : {}),
861
- ...(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
+ },
862
907
  });
863
- return sendJson(res, 200, { ok: true, deployments });
864
908
  }
865
909
  catch (error) {
866
910
  return sendJson(res, 400, { error: error.message });
867
911
  }
868
912
  }
869
- async function handleKeys(req, res, registry, gate, controlPlane, ref) {
913
+ async function handleDeployments(req, res, registry, gate, controlPlane, ref, url) {
870
914
  const identity = await authorizeControlPlane(req, res, gate, { requireIdentity: true });
871
915
  if (identity === false)
872
916
  return;
@@ -875,47 +919,19 @@ async function handleKeys(req, res, registry, gate, controlPlane, ref) {
875
919
  if (!member)
876
920
  return sendForbidden(res, 'forbidden');
877
921
  }
878
- 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 {
879
925
  const deployments = await registry.listDeployments({
880
926
  org: ref.org,
881
- app: ref.app,
882
- env: ref.env,
883
- });
884
- return sendJson(res, 200, {
885
- ok: true,
886
- keys: deployments
887
- .filter((deployment) => deployment.active && deployment.hasCallerKey)
888
- .map((deployment) => ({
889
- org: deployment.orgSlug,
890
- app: deployment.appSlug,
891
- env: deployment.environment,
892
- deploymentId: deployment.deploymentId,
893
- active: deployment.active,
894
- createdAt: deployment.createdAt,
895
- })),
927
+ ...(app !== undefined ? { app } : {}),
928
+ ...(env !== undefined ? { env } : {}),
896
929
  });
930
+ return sendJson(res, 200, { ok: true, deployments });
897
931
  }
898
- if (req.method === 'POST' && ref.action === 'rotate') {
899
- const rotated = await registry.rotateCallerKey(ref);
900
- if (!rotated) {
901
- return sendJson(res, 404, {
902
- error: 'no active caller-key deployment found for this app environment',
903
- });
904
- }
905
- return sendJson(res, 201, {
906
- ok: true,
907
- key: {
908
- org: rotated.deployment.orgSlug,
909
- app: rotated.deployment.appSlug,
910
- env: rotated.deployment.environment,
911
- deploymentId: rotated.deployment.deploymentId,
912
- active: rotated.deployment.active,
913
- createdAt: new Date().toISOString(),
914
- },
915
- callerKey: rotated.key,
916
- });
932
+ catch (error) {
933
+ return sendJson(res, 400, { error: error.message });
917
934
  }
918
- return sendJson(res, 404, { error: 'not found' });
919
935
  }
920
936
  async function authorizeControlPlane(req, res, gate, options) {
921
937
  const auth = await gate.authorize(req);
@@ -1020,17 +1036,22 @@ function parseDeploymentsPath(pathname) {
1020
1036
  return undefined;
1021
1037
  }
1022
1038
  }
1023
- function parseKeysPath(pathname) {
1024
- 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);
1025
1047
  if (!match)
1026
1048
  return undefined;
1027
1049
  try {
1028
- const ref = validateTenantRef({
1050
+ return validateTenantRef({
1029
1051
  org: decodeURIComponent(match[1]),
1030
1052
  app: decodeURIComponent(match[2]),
1031
1053
  env: decodeURIComponent(match[3]),
1032
1054
  });
1033
- return { ...ref, ...(match[4] === 'rotate' ? { action: 'rotate' } : {}) };
1034
1055
  }
1035
1056
  catch {
1036
1057
  return undefined;
@@ -1039,6 +1060,20 @@ function parseKeysPath(pathname) {
1039
1060
  function createAdmissionGate(_logger) {
1040
1061
  return async () => ({ allow: true });
1041
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
+ }
1042
1077
  function baseFromRequest(req, tls) {
1043
1078
  const host = req.headers.host ?? 'localhost';
1044
1079
  return `${effectiveProto(req, tls.trustProxy ?? false)}://${host}`;
@@ -1138,30 +1173,29 @@ export async function serveService(options = {}) {
1138
1173
  keyResolver: await options.oauth.signer.verifierKey(),
1139
1174
  });
1140
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
+ }
1141
1187
  gate =
1142
1188
  gate ??
1143
- (options.oauth !== undefined && resolvedVerifyOwnerToken !== undefined
1144
- ? new NoodleOAuthControlPlaneGate({
1145
- verifier: resolvedVerifyOwnerToken,
1146
- audience: normalizeServiceBase(options.publicBaseUrl ?? options.oauth.issuer),
1189
+ (options.googleClientId !== undefined
1190
+ ? new GoogleControlPlaneGate({
1191
+ audience: options.googleClientId,
1147
1192
  admins: options.controlPlaneAdmins ?? [],
1148
- ...(options.controlPlaneAllowedEmailDomain !== undefined ||
1149
- options.oauth.allowedEmailDomain !== undefined
1150
- ? {
1151
- allowedEmailDomain: options.controlPlaneAllowedEmailDomain ?? options.oauth.allowedEmailDomain,
1152
- }
1193
+ ...(options.controlPlaneAllowedEmailDomain !== undefined
1194
+ ? { allowedEmailDomain: options.controlPlaneAllowedEmailDomain }
1153
1195
  : {}),
1196
+ ...(options.googleVerifier ? { verifier: options.googleVerifier } : {}),
1154
1197
  })
1155
- : options.googleClientId !== undefined
1156
- ? new GoogleControlPlaneGate({
1157
- audience: options.googleClientId,
1158
- admins: options.controlPlaneAdmins ?? [],
1159
- ...(options.controlPlaneAllowedEmailDomain !== undefined
1160
- ? { allowedEmailDomain: options.controlPlaneAllowedEmailDomain }
1161
- : {}),
1162
- ...(options.googleVerifier ? { verifier: options.googleVerifier } : {}),
1163
- })
1164
- : undefined);
1198
+ : undefined);
1165
1199
  // Fail closed: never expose an unauthenticated deploy endpoint on a non-loopback bind.
1166
1200
  if (gate === undefined && !isLoopbackHost(host)) {
1167
1201
  throw new Error(`refusing to bind a non-loopback host (${host}) without deploy authentication; ` +
@@ -1258,6 +1292,16 @@ function sendForbidden(res, message) {
1258
1292
  function normalizeServiceBase(value) {
1259
1293
  return value.replace(/\/+$/, '');
1260
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
+ }
1261
1305
  /** Loopback hosts may run the control plane without auth; any other bind must configure auth. */
1262
1306
  function isLoopbackHost(host) {
1263
1307
  return host === '127.0.0.1' || host === '::1' || host === 'localhost';