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.
- 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/cli.d.ts.map +1 -1
- package/dist/cli.js +1102 -107
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +9 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/control-plane.d.ts +17 -1
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +227 -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/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/auth/deploy-gate.d.ts +14 -0
- package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.js +35 -0
- package/node_modules/@noodle-borg/service/dist/auth/deploy-gate.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/index.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/index.js +1 -1
- package/node_modules/@noodle-borg/service/dist/index.js.map +1 -1
- package/node_modules/@noodle-borg/service/dist/service.d.ts +6 -25
- package/node_modules/@noodle-borg/service/dist/service.d.ts.map +1 -1
- package/node_modules/@noodle-borg/service/dist/service.js +249 -174
- 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 +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,
|
|
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
|
|
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
|
});
|
|
@@ -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:
|
|
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
|
|
522
|
-
//
|
|
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
|
|
580
|
-
if (
|
|
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
|
-
|
|
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
|
|
591
|
-
if (
|
|
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
|
-
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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:
|
|
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
|
|
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
|
|
838
|
-
if (!member)
|
|
839
|
-
|
|
859
|
+
const member = await controlPlane.isOrgMember({ org, subject: identity.subject });
|
|
860
|
+
if (!member) {
|
|
861
|
+
sendForbidden(res, 'forbidden');
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
840
864
|
}
|
|
841
|
-
|
|
842
|
-
|
|
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
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
868
|
-
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
|
-
|
|
885
|
-
|
|
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
|
|
1010
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|