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.
- package/dist/agents.d.ts +3 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +231 -0
- package/dist/agents.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +16 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1102 -107
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/control-plane.d.ts +15 -1
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +90 -15
- package/dist/control-plane.js.map +1 -1
- package/dist/deploy.d.ts +3 -5
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +20 -7
- package/dist/deploy.js.map +1 -1
- package/dist/dev.d.ts +11 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +7 -17
- package/dist/dev.js.map +1 -1
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +10 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +396 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/node-version.d.ts +5 -0
- package/dist/node-version.d.ts.map +1 -0
- package/dist/node-version.js +17 -0
- package/dist/node-version.js.map +1 -0
- package/dist/openapi-import.d.ts +12 -0
- package/dist/openapi-import.d.ts.map +1 -0
- package/dist/openapi-import.js +95 -0
- package/dist/openapi-import.js.map +1 -0
- package/dist/project.d.ts +45 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +252 -0
- package/dist/project.js.map +1 -0
- package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.d.ts +2 -2
- package/node_modules/@noodle-borg/runtime/dist/broker/secret-box.js +2 -2
- package/node_modules/@noodle-borg/service/dist/service.d.ts +6 -25
- package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/service.js +219 -175
- package/node_modules/@noodle-borg/service/dist/service.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts +2 -1
- package/node_modules/@noodle-borg/service/dist/store/postgres.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store/postgres.js +19 -78
- package/node_modules/@noodle-borg/service/dist/store/postgres.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store.d.ts +24 -12
- package/node_modules/@noodle-borg/service/dist/store.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/store.js +18 -18
- package/node_modules/@noodle-borg/service/dist/store.js.map +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts +2 -21
- package/node_modules/@noodle-borg/transport-http/dist/handler.d.ts.map +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/handler.js +14 -25
- package/node_modules/@noodle-borg/transport-http/dist/handler.js.map +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/index.d.ts +1 -2
- package/node_modules/@noodle-borg/transport-http/dist/index.d.ts.map +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/index.js +0 -1
- package/node_modules/@noodle-borg/transport-http/dist/index.js.map +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/logging.d.ts +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/logging.js +1 -1
- 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,
|
|
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
|
|
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 (
|
|
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).
|
|
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
|
|
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
|
|
289
|
-
const safe = validateTenantRef(
|
|
290
|
-
const
|
|
291
|
-
|
|
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
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
308
|
-
if (
|
|
309
|
-
this.#servers.set(updated.deploymentId,
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
|
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 ?? '
|
|
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
|
|
536
|
-
//
|
|
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
|
|
594
|
-
if (
|
|
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
|
-
|
|
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
|
|
605
|
-
if (
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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:
|
|
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
|
|
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
|
|
852
|
-
if (!member)
|
|
853
|
-
|
|
859
|
+
const member = await controlPlane.isOrgMember({ org, subject: identity.subject });
|
|
860
|
+
if (!member) {
|
|
861
|
+
sendForbidden(res, 'forbidden');
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
854
864
|
}
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
882
|
-
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
|
-
|
|
899
|
-
|
|
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
|
|
1024
|
-
|
|
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
|
-
|
|
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.
|
|
1144
|
-
? new
|
|
1145
|
-
|
|
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.
|
|
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
|
-
:
|
|
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';
|