@substrat-run/control-plane-api 0.13.0 → 0.16.0
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/README.md +58 -0
- package/dist/api.d.ts +29 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +437 -14
- package/dist/api.js.map +1 -1
- package/dist/auth.d.ts +2 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +11 -0
- package/dist/auth.js.map +1 -1
- package/dist/cf-observability.d.ts +22 -0
- package/dist/cf-observability.d.ts.map +1 -0
- package/dist/cf-observability.js +110 -0
- package/dist/cf-observability.js.map +1 -0
- package/dist/client.d.ts +7 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +17 -0
- package/dist/client.js.map +1 -1
- package/dist/deploy.d.ts +3 -32
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +22 -26
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/mask.d.ts +4 -0
- package/dist/mask.d.ts.map +1 -0
- package/dist/mask.js +71 -0
- package/dist/mask.js.map +1 -0
- package/dist/observability.d.ts +53 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +17 -0
- package/dist/observability.js.map +1 -0
- package/dist/push-token.d.ts +33 -0
- package/dist/push-token.d.ts.map +1 -0
- package/dist/push-token.js +114 -0
- package/dist/push-token.js.map +1 -0
- package/dist/vertical-client.d.ts +89 -4
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +97 -0
- package/dist/vertical-client.js.map +1 -1
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +4 -0
- package/dist/wfp.js.map +1 -1
- package/package.json +5 -5
package/dist/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { adminAction, channelName, createTenantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, registerVerticalInput, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
2
|
+
import { adminAction, channelName, createTenantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
3
3
|
import { ulid } from '@substrat-run/kernel';
|
|
4
4
|
import { ControlPlaneError } from './client.js';
|
|
5
5
|
import { mapError } from './errors.js';
|
|
6
|
+
import { maskDump } from './mask.js';
|
|
6
7
|
import { assertSandboxContract, deployManifest, deploymentRefFor } from './deploy.js';
|
|
8
|
+
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
7
9
|
// -- request schemas ---------------------------------------------------------
|
|
8
10
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
9
11
|
// CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
|
|
@@ -31,6 +33,10 @@ const provisionInstanceBody = z.object({
|
|
|
31
33
|
owner: z.string().min(1),
|
|
32
34
|
slug: z.string().min(1),
|
|
33
35
|
name: z.string().min(1),
|
|
36
|
+
config: z.record(z.string(), z.string()).optional(),
|
|
37
|
+
});
|
|
38
|
+
const configureInstanceBody = z.object({
|
|
39
|
+
entries: z.array(z.object({ key: z.string().min(1), value: z.string() })).min(1),
|
|
34
40
|
});
|
|
35
41
|
const bindHostnameBody = z.object({
|
|
36
42
|
hostname: hostnameSchema,
|
|
@@ -67,7 +73,19 @@ const promoteVersionBody = z.object({
|
|
|
67
73
|
// needs no acknowledgement.
|
|
68
74
|
acknowledge: promotionAcknowledgement.optional(),
|
|
69
75
|
});
|
|
70
|
-
const bindScopeVersionBody = z.object({
|
|
76
|
+
const bindScopeVersionBody = z.object({
|
|
77
|
+
versionId: z.string().min(1),
|
|
78
|
+
// Fork-before-promote (preview-and-snapshots.md §4): snapshot the pre-migration
|
|
79
|
+
// data first when this bind crosses a migration-digest boundary. Optional and
|
|
80
|
+
// ignored on a code-only rebind — the digest compare is the gate, not the flag.
|
|
81
|
+
snapshot: z.boolean().optional(),
|
|
82
|
+
});
|
|
83
|
+
// A snapshot request (preview-and-snapshots.md §3/§9). `expiresAt` opts into the GC
|
|
84
|
+
// sweep; absent = pinned until deliberately deleted. `kind` defaults to 'archive'.
|
|
85
|
+
const snapshotScopeBody = z.object({
|
|
86
|
+
kind: z.string().min(1).optional(),
|
|
87
|
+
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
88
|
+
});
|
|
71
89
|
const listRolesQuery = z.object({
|
|
72
90
|
tenantId: tenantIdSchema.optional(),
|
|
73
91
|
// Free-form: a module id or 'vertical'. Not narrowed to the source union here
|
|
@@ -155,8 +173,12 @@ export function createControlPlaneApi(options) {
|
|
|
155
173
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions$/ },
|
|
156
174
|
{ method: 'POST', re: /\/verticals\/[^/]+\/versions$/ },
|
|
157
175
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
|
|
176
|
+
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
|
|
158
177
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
|
|
159
178
|
{ method: 'POST', re: /\/verticals\/[^/]+\/deploy$/ },
|
|
179
|
+
// A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5); the
|
|
180
|
+
// `listing` flip stays staff-only. Ownership is checked in the handler.
|
|
181
|
+
{ method: 'POST', re: /\/verticals\/[^/]+\/publish-request$/ },
|
|
160
182
|
];
|
|
161
183
|
app.use('*', async (c, next) => {
|
|
162
184
|
if (c.get('principal').kind === 'builder') {
|
|
@@ -237,6 +259,106 @@ export function createControlPlaneApi(options) {
|
|
|
237
259
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
238
260
|
return c.json(record);
|
|
239
261
|
});
|
|
262
|
+
// Read-only introspection of a scope's own database (kernel-design §5.4 admin-query
|
|
263
|
+
// RPC) — the console/dashboard "Data" view.
|
|
264
|
+
//
|
|
265
|
+
// The scope's DATA lives in the vertical's own deployment (K-31), NOT in this control
|
|
266
|
+
// plane's own (empty-module) scope host — so we DELEGATE to the vertical, the mirror
|
|
267
|
+
// of `/verticals/:slug/instances`. `getScopeRecord` first does the K-3 cross-check +
|
|
268
|
+
// access-log entry and tells us which vertical (and which VERSION) backs the scope.
|
|
269
|
+
//
|
|
270
|
+
// Resolution order matters. A scope's data DO lives in the deployment of its BOUND
|
|
271
|
+
// version (`verticalVersionId`) — each `substrat push` is a separate WfP script with
|
|
272
|
+
// its own DO namespace, so the prod-channel deployment is the wrong one the moment an
|
|
273
|
+
// installed app lags prod. So we prefer bound-version resolution, then fall back to
|
|
274
|
+
// prod-channel/static (a scope with no bound version), then to reading this host's own
|
|
275
|
+
// scope DB directly (a co-located host, or the contract tests — data is right here).
|
|
276
|
+
const verticalForScope = async (c, scope) => {
|
|
277
|
+
const slug = scope.vertical;
|
|
278
|
+
if (!slug)
|
|
279
|
+
return undefined;
|
|
280
|
+
const actor = c.get('actor');
|
|
281
|
+
if (scope.verticalVersionId && options.resolveVerticalVersion) {
|
|
282
|
+
const bound = await options.resolveVerticalVersion(slug, scope.verticalVersionId, actor);
|
|
283
|
+
if (bound)
|
|
284
|
+
return bound;
|
|
285
|
+
}
|
|
286
|
+
return options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
|
|
287
|
+
};
|
|
288
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
|
|
289
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
290
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
291
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
292
|
+
if (!scope)
|
|
293
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
294
|
+
const vertical = await verticalForScope(c, scope);
|
|
295
|
+
return c.json(vertical
|
|
296
|
+
? await vertical.listScopeTables(scopeId)
|
|
297
|
+
: await admin.listScopeTables(c.get('actor'), tenantId, scopeId));
|
|
298
|
+
});
|
|
299
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
|
|
300
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
301
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
302
|
+
const input = readScopeTableInput.parse({
|
|
303
|
+
table: c.req.param('table'),
|
|
304
|
+
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
|
305
|
+
offset: c.req.query('offset') ? Number(c.req.query('offset')) : undefined,
|
|
306
|
+
});
|
|
307
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
308
|
+
if (!scope)
|
|
309
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
310
|
+
const vertical = await verticalForScope(c, scope);
|
|
311
|
+
return c.json(vertical
|
|
312
|
+
? await vertical.readScopeTable(scopeId, input)
|
|
313
|
+
: await admin.readScopeTable(c.get('actor'), tenantId, scopeId, input));
|
|
314
|
+
});
|
|
315
|
+
// The SQL console (#219): one read-only statement, POSTed because SQL does not
|
|
316
|
+
// belong in a URL. Same delegation as the table reads; the gate's refusal maps to
|
|
317
|
+
// 400 (errors.ts), and a vertical that cannot answer safely (auth-server, whose
|
|
318
|
+
// DO redacts secret columns on table reads — arbitrary SQL would walk around the
|
|
319
|
+
// redaction) refuses via its own 501, relayed verbatim.
|
|
320
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/query', async (c) => {
|
|
321
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
322
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
323
|
+
const input = queryScopeInput.parse(await c.req.json());
|
|
324
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
325
|
+
if (!scope)
|
|
326
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
327
|
+
const vertical = await verticalForScope(c, scope);
|
|
328
|
+
return c.json(vertical
|
|
329
|
+
? await vertical.queryScope(scopeId, input)
|
|
330
|
+
: await admin.queryScope(c.get('actor'), tenantId, scopeId, input));
|
|
331
|
+
});
|
|
332
|
+
// Deliver per-instance CONFIG to the scope's own storage (vertical-auth-detach.md
|
|
333
|
+
// §2.2) — the missing "delivery" step behind the dashboard's Env tab. Same K-3
|
|
334
|
+
// addressing + bound-version resolution as introspection: the scope's DO lives in the
|
|
335
|
+
// deployment of its BOUND version, so that is where its config must land. A scope with
|
|
336
|
+
// no reachable vertical deployment (co-located/contract-test hosts run no vertical
|
|
337
|
+
// code) has nowhere to deliver to — 501, so the caller can tell "authored but not
|
|
338
|
+
// delivered" from "failed". The vertical's own status (e.g. its 501 for no live-config
|
|
339
|
+
// support) propagates rather than collapsing to a 500.
|
|
340
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/configure', async (c) => {
|
|
341
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
342
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
343
|
+
const input = configureInstanceBody.parse(await c.req.json());
|
|
344
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
345
|
+
if (!scope)
|
|
346
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
347
|
+
const vertical = await verticalForScope(c, scope);
|
|
348
|
+
if (!vertical) {
|
|
349
|
+
return c.json({ error: `no deployment is bound for vertical '${scope.vertical ?? '(none)'}'` }, 501);
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
if (e instanceof ControlPlaneError) {
|
|
356
|
+
return c.json({ error: e.message }, e.status);
|
|
357
|
+
}
|
|
358
|
+
throw e;
|
|
359
|
+
}
|
|
360
|
+
return c.json({ applied: input.entries.length });
|
|
361
|
+
});
|
|
240
362
|
// The four lifecycle transitions, one route each — mirroring the four audited
|
|
241
363
|
// actions rather than collapsing into a PATCH that would accept a target
|
|
242
364
|
// status the transition graph forbids. The graph is enforced below the seam;
|
|
@@ -256,15 +378,178 @@ export function createControlPlaneApi(options) {
|
|
|
256
378
|
return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, scopeId));
|
|
257
379
|
});
|
|
258
380
|
}
|
|
381
|
+
// -- snapshots (preview-and-snapshots.md §3/§9) -----------------------------
|
|
382
|
+
// The DATA half of a snapshot runs inside the vertical's own deployment (the
|
|
383
|
+
// scope's bytes never cross the boundary — the §9 property the trust line rests
|
|
384
|
+
// on); the DIRECTORY half — provenance row, activation, version bind — runs here.
|
|
385
|
+
// With no vertical client resolved (co-located host, tests, self-host) the host's
|
|
386
|
+
// in-process snapshotScope does both halves against its own SCOPE namespace.
|
|
387
|
+
const orchestratedSnapshot = async (c, tenantId, scope, opts) => {
|
|
388
|
+
const actor = c.get('actor');
|
|
389
|
+
const vertical = await verticalForScope(c, scope);
|
|
390
|
+
if (!vertical)
|
|
391
|
+
return options.host.snapshotScope(actor, tenantId, scope.id, opts);
|
|
392
|
+
const snapId = scopeIdSchema.parse(ulid());
|
|
393
|
+
// Directory row FIRST, as `provisioning` (K-31's two-phase shape, used as
|
|
394
|
+
// intended): a crash between the row and the data copy leaves an inert
|
|
395
|
+
// provisioning row — which, carrying provenance and an expiry, the GC sweep
|
|
396
|
+
// eventually reaps — never copied data with no record.
|
|
397
|
+
await options.host.provisionScope(actor, {
|
|
398
|
+
tenantId,
|
|
399
|
+
scopeId: snapId,
|
|
400
|
+
kind: opts.kind ?? 'archive',
|
|
401
|
+
vertical: scope.vertical,
|
|
402
|
+
jurisdiction: scope.jurisdiction,
|
|
403
|
+
forkedFrom: scope.id,
|
|
404
|
+
forkedAt: new Date().toISOString(),
|
|
405
|
+
expiresAt: opts.expiresAt,
|
|
406
|
+
});
|
|
407
|
+
await vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId });
|
|
408
|
+
await admin.activateScope(actor, tenantId, snapId);
|
|
409
|
+
// Bound to the SOURCE's current version: source and fork share a deployment, so
|
|
410
|
+
// the fork resolves to the DO namespace its bytes actually live in.
|
|
411
|
+
if (scope.verticalVersionId) {
|
|
412
|
+
await admin.bindScopeVersion(actor, tenantId, snapId, scope.verticalVersionId);
|
|
413
|
+
}
|
|
414
|
+
return snapId;
|
|
415
|
+
};
|
|
416
|
+
// The forks OF one scope — what a Snapshots UI lists. A directory read (kind,
|
|
417
|
+
// provenance, expiry all live on the scope row); newest first.
|
|
418
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/snapshots', async (c) => {
|
|
419
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
420
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
421
|
+
const scopes = await admin.listScopes(c.get('actor'), { tenantId });
|
|
422
|
+
return c.json(scopes
|
|
423
|
+
.filter((s) => s.forkedFrom === scopeId)
|
|
424
|
+
.sort((a, b) => (a.forkedAt < b.forkedAt ? 1 : -1)));
|
|
425
|
+
});
|
|
426
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/snapshots', async (c) => {
|
|
427
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
428
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
429
|
+
const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
430
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
431
|
+
if (!scope)
|
|
432
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
433
|
+
try {
|
|
434
|
+
const snapId = await orchestratedSnapshot(c, tenantId, scope, body);
|
|
435
|
+
return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, snapId), 201);
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
if (e instanceof ControlPlaneError) {
|
|
439
|
+
return c.json({ error: e.message }, e.status);
|
|
440
|
+
}
|
|
441
|
+
throw e;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// Reap a fork. The fork-only refusal is surfaced HERE, before any delegation —
|
|
445
|
+
// the vertical must never even be asked to wipe a primary scope — and re-checked
|
|
446
|
+
// below the seam by deleteSnapshot, which also wipes the co-located storage,
|
|
447
|
+
// removes hostnames + the directory row, and writes the audit entry.
|
|
448
|
+
app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
|
|
449
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
450
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
451
|
+
const actor = c.get('actor');
|
|
452
|
+
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
453
|
+
if (!scope)
|
|
454
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
455
|
+
if (!scope.forkedFrom) {
|
|
456
|
+
return c.json({ error: `scope ${scopeId} is not a fork — only snapshots may be deleted` }, 409);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
// Vertical's storage first, then the in-process delete (refusal re-check,
|
|
460
|
+
// local/placeholder wipe, hostnames + directory row, audit) — the same
|
|
461
|
+
// storage-before-row ordering deleteSnapshot itself keeps, so a crash
|
|
462
|
+
// between the two converges on retry.
|
|
463
|
+
const vertical = await verticalForScope(c, scope);
|
|
464
|
+
if (vertical)
|
|
465
|
+
await vertical.deleteScope({ scopeId });
|
|
466
|
+
await options.host.deleteSnapshot(actor, tenantId, scopeId);
|
|
467
|
+
return c.json({ deleted: scopeId });
|
|
468
|
+
}
|
|
469
|
+
catch (e) {
|
|
470
|
+
if (e instanceof ControlPlaneError) {
|
|
471
|
+
return c.json({ error: e.message }, e.status);
|
|
472
|
+
}
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
// The governed pull (preview-and-snapshots.md §6/§8) — the ONE route that
|
|
477
|
+
// deliberately hands scope BYTES to the caller, which is why every §6 layer sits
|
|
478
|
+
// on it: staff-only (not in BUILDER_ROUTES), K-3 cross-checked, K-24 audited (the
|
|
479
|
+
// exportScope access-log entry), jurisdiction-gated, and MASKED by default —
|
|
480
|
+
// `?full=true` is the explicit break-glass. Dumps are JSON-safe today (no BLOB
|
|
481
|
+
// columns exist in any schema); a vertical that adds one needs an encoding here.
|
|
482
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/export', async (c) => {
|
|
483
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
484
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
485
|
+
const actor = c.get('actor');
|
|
486
|
+
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
487
|
+
if (!scope)
|
|
488
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
489
|
+
// Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
|
|
490
|
+
// lands the data on a machine outside the platform's control, so anything
|
|
491
|
+
// pinned tighter than `global` is refused until a compliant path exists.
|
|
492
|
+
if (scope.jurisdiction !== 'global') {
|
|
493
|
+
return c.json({
|
|
494
|
+
error: `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
|
|
495
|
+
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
|
|
496
|
+
}, 403);
|
|
497
|
+
}
|
|
498
|
+
const full = c.req.query('full') === 'true';
|
|
499
|
+
try {
|
|
500
|
+
// The canonical export first: it writes the K-24 access-log entry and is the
|
|
501
|
+
// bytes when the host is co-located. When the scope's data lives in a vertical
|
|
502
|
+
// deployment, its dump OVERLAYS the (placeholder) tables — audit stays on the
|
|
503
|
+
// one canonical path either way.
|
|
504
|
+
const dump = await admin.exportScope(actor, tenantId, scopeId);
|
|
505
|
+
const vertical = await verticalForScope(c, scope);
|
|
506
|
+
const tables = vertical ? await vertical.exportScope(scopeId) : dump.tables;
|
|
507
|
+
return c.json({ ...dump, tables: full ? tables : maskDump(tables), masked: !full });
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
if (e instanceof ControlPlaneError) {
|
|
511
|
+
return c.json({ error: e.message }, e.status);
|
|
512
|
+
}
|
|
513
|
+
throw e;
|
|
514
|
+
}
|
|
515
|
+
});
|
|
259
516
|
// Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
|
|
260
517
|
// non-admitted version below the seam — that refusal is the registry's reason to
|
|
261
|
-
// exist. A scope operation, so it keeps the scope route shape.
|
|
518
|
+
// exist. A scope operation, so it keeps the scope route shape. `snapshot: true`
|
|
519
|
+
// opts into fork-before-promote (§4): on a migration-digest-crossing bind the
|
|
520
|
+
// pre-migration data is snapshotted first — orchestrated through the vertical
|
|
521
|
+
// when one resolves, in-process otherwise.
|
|
262
522
|
app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
|
|
263
523
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
264
524
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
265
|
-
const { versionId } = bindScopeVersionBody.parse(await c.req.json());
|
|
266
|
-
|
|
267
|
-
|
|
525
|
+
const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
|
|
526
|
+
const actor = c.get('actor');
|
|
527
|
+
if (snapshot) {
|
|
528
|
+
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
529
|
+
if (!scope) {
|
|
530
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
531
|
+
}
|
|
532
|
+
const vertical = await verticalForScope(c, scope);
|
|
533
|
+
if (vertical) {
|
|
534
|
+
// Delegated path: the digest compare lives here (the in-process path does
|
|
535
|
+
// it below the seam). Snapshot only a migration-crossing bind.
|
|
536
|
+
if (scope.vertical && scope.verticalVersionId) {
|
|
537
|
+
const versions = await admin.listVersions(actor, scope.vertical);
|
|
538
|
+
const current = versions.find((v) => v.id === scope.verticalVersionId);
|
|
539
|
+
const incoming = versions.find((v) => v.id === versionId);
|
|
540
|
+
if (current && incoming && current.migrationDigest !== incoming.migrationDigest) {
|
|
541
|
+
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
|
|
548
|
+
}
|
|
549
|
+
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
550
|
+
}
|
|
551
|
+
await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
|
|
552
|
+
return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
|
|
268
553
|
});
|
|
269
554
|
// -- instances (K-31) -------------------------------------------------------
|
|
270
555
|
// The one place this surface calls OUT rather than sitting over `HostAdmin`, and
|
|
@@ -278,6 +563,13 @@ export function createControlPlaneApi(options) {
|
|
|
278
563
|
// in-between properly, and it is still unused — see the PR.
|
|
279
564
|
app.post('/verticals/:slug/instances', async (c) => {
|
|
280
565
|
const slug = c.req.param('slug');
|
|
566
|
+
// The install kill-switch: a blocked vertical takes no NEW instances, for anyone
|
|
567
|
+
// including its owner. Refused before deployment resolution so the answer is
|
|
568
|
+
// uniform whether or not anything is deployed. Existing scopes keep serving.
|
|
569
|
+
const registered = (await admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
|
|
570
|
+
if (registered?.installsBlocked) {
|
|
571
|
+
return c.json({ error: `new installs of vertical '${slug}' are blocked` }, 403);
|
|
572
|
+
}
|
|
281
573
|
// Static binding first (milestone-one shape), then the dispatch resolver for a
|
|
282
574
|
// pushed vertical — the provisioning mirror of the router's verticalFor.
|
|
283
575
|
const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
|
|
@@ -314,6 +606,10 @@ export function createControlPlaneApi(options) {
|
|
|
314
606
|
const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
315
607
|
return v ? v.ownerTenant : undefined;
|
|
316
608
|
};
|
|
609
|
+
// The full registry row, for the checks that need more than the owner — whether the
|
|
610
|
+
// vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
|
|
611
|
+
// self-serve below.
|
|
612
|
+
const verticalOf = async (actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
317
613
|
// The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
|
|
318
614
|
// (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
|
|
319
615
|
// from their authenticated tenant — so two builders can each own a `helpdesk` with no
|
|
@@ -389,6 +685,43 @@ export function createControlPlaneApi(options) {
|
|
|
389
685
|
await admin.rejectVersion(c.get('actor'), id, note);
|
|
390
686
|
return c.json((await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id));
|
|
391
687
|
});
|
|
688
|
+
// A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5) — any owner
|
|
689
|
+
// may ask; the staff `listing` flip below is the review gate. Owner-checked (like promote).
|
|
690
|
+
app.post('/verticals/:slug/publish-request', async (c) => {
|
|
691
|
+
const p = c.get('principal');
|
|
692
|
+
const slug = effectiveSlug(p, c.req.param('slug'));
|
|
693
|
+
if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
|
|
694
|
+
return c.json({ error: 'not found' }, 404);
|
|
695
|
+
}
|
|
696
|
+
await admin.requestPublish(c.get('actor'), slug);
|
|
697
|
+
return c.json({ slug, requested: true });
|
|
698
|
+
});
|
|
699
|
+
// Publish/unpublish a vertical to the PUBLIC marketplace (marketplace-publish.md §5). Staff
|
|
700
|
+
// admission of a publish request — NOT in BUILDER_ROUTES, so a builder is refused (the review
|
|
701
|
+
// gate). `listed` then makes `availableCatalog` offer it to every tenant + resolves the request.
|
|
702
|
+
app.post('/verticals/:slug/listing', async (c) => {
|
|
703
|
+
const slug = c.req.param('slug');
|
|
704
|
+
const { listed } = z.object({ listed: z.boolean() }).parse(await c.req.json());
|
|
705
|
+
await admin.setVerticalListed(c.get('actor'), slug, listed);
|
|
706
|
+
return c.json({ slug, listed });
|
|
707
|
+
});
|
|
708
|
+
// The install kill-switch (staff-only — not in BUILDER_ROUTES, so a builder is
|
|
709
|
+
// refused by the confinement middleware). Blocks NEW installs; existing scopes
|
|
710
|
+
// keep serving. Orthogonal to /listing (visibility).
|
|
711
|
+
app.post('/verticals/:slug/install-block', async (c) => {
|
|
712
|
+
const slug = c.req.param('slug');
|
|
713
|
+
const { blocked } = z.object({ blocked: z.boolean() }).parse(await c.req.json());
|
|
714
|
+
await admin.setVerticalInstallsBlocked(c.get('actor'), slug, blocked);
|
|
715
|
+
return c.json({ slug, installsBlocked: blocked });
|
|
716
|
+
});
|
|
717
|
+
// Delete a vertical + its versions and channels (staff-only, same confinement).
|
|
718
|
+
// Refused below the seam while any scope is still bound — surfaces as a 4xx via
|
|
719
|
+
// mapError, naming the count. Dispatch scripts become orphans for cleanup (#248).
|
|
720
|
+
app.delete('/verticals/:slug', async (c) => {
|
|
721
|
+
const slug = c.req.param('slug');
|
|
722
|
+
await admin.deleteVertical(c.get('actor'), slug);
|
|
723
|
+
return c.json({ slug, deleted: true });
|
|
724
|
+
});
|
|
392
725
|
app.get('/verticals/:slug/channels', async (c) => {
|
|
393
726
|
const p = c.get('principal');
|
|
394
727
|
const slug = effectiveSlug(p, c.req.param('slug'));
|
|
@@ -397,18 +730,35 @@ export function createControlPlaneApi(options) {
|
|
|
397
730
|
}
|
|
398
731
|
return c.json(await admin.listChannels(c.get('actor'), slug));
|
|
399
732
|
});
|
|
733
|
+
// The promotion timeline (newest first) — what a rollback UI picks a target from.
|
|
734
|
+
// Owner-narrowed like the channel read above: a builder sees only its own verticals'
|
|
735
|
+
// history, and a foreign slug 404s indistinguishably from an absent one.
|
|
736
|
+
app.get('/verticals/:slug/channels/:channel/history', async (c) => {
|
|
737
|
+
const p = c.get('principal');
|
|
738
|
+
const slug = effectiveSlug(p, c.req.param('slug'));
|
|
739
|
+
const channel = channelName.parse(c.req.param('channel'));
|
|
740
|
+
if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
|
|
741
|
+
return c.json({ error: 'not found' }, 404);
|
|
742
|
+
}
|
|
743
|
+
return c.json(await admin.listChannelHistory(c.get('actor'), slug, channel));
|
|
744
|
+
});
|
|
400
745
|
app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
|
|
401
746
|
const p = c.get('principal');
|
|
402
747
|
const slug = effectiveSlug(p, c.req.param('slug'));
|
|
403
748
|
const channel = channelName.parse(c.req.param('channel'));
|
|
404
749
|
if (p.kind === 'builder') {
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
750
|
+
// A builder promotes only verticals it owns — and prod only while the vertical
|
|
751
|
+
// is PRIVATE (not listed). A private vertical's blast radius is the owning
|
|
752
|
+
// tenant itself, and dev/staging already run the same bundle in the same
|
|
753
|
+
// sandbox, so a staff prod gate there protected nothing; it returns the moment
|
|
754
|
+
// the audience widens (publish flips `listed`, and prod becomes staff-only
|
|
755
|
+
// again — the trust boundary marketplace-publish.md §2 draws).
|
|
756
|
+
const v = await verticalOf(p.actor, slug);
|
|
757
|
+
if (!v || v.ownerTenant !== p.tenantId)
|
|
411
758
|
return c.json({ error: 'forbidden' }, 403);
|
|
759
|
+
if (channel === 'prod' && v.listed) {
|
|
760
|
+
return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
|
|
761
|
+
}
|
|
412
762
|
}
|
|
413
763
|
const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
|
|
414
764
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
@@ -420,8 +770,9 @@ export function createControlPlaneApi(options) {
|
|
|
420
770
|
// The deploy seam (self-serve-deploy.md): a `substrat push` uploads a built bundle
|
|
421
771
|
// here. The order is upload → record, deliberately: a failed record leaves an
|
|
422
772
|
// orphaned namespace script (invisible, GC'able) rather than a directory row
|
|
423
|
-
// pointing at a deployment that is not there. The version lands PENDING — a
|
|
424
|
-
//
|
|
773
|
+
// pointing at a deployment that is not there. The version lands PENDING — except a
|
|
774
|
+
// PRIVATE vertical's, which self-admits below the seam (its blast radius is its own
|
|
775
|
+
// tenant); for everything else admission still gates serving.
|
|
425
776
|
app.post('/verticals/:slug/deploy', async (c) => {
|
|
426
777
|
if (!options.deployVertical) {
|
|
427
778
|
return c.json({ error: 'deploy is not configured on this control plane' }, 501);
|
|
@@ -490,6 +841,15 @@ export function createControlPlaneApi(options) {
|
|
|
490
841
|
name: manifest.name ?? slug,
|
|
491
842
|
source: 'cli',
|
|
492
843
|
ownerTenant: p.kind === 'builder' ? p.tenantId : (existingOwner ?? null),
|
|
844
|
+
// The vertical's declared config surface rides to the registry, so the dashboard
|
|
845
|
+
// renders a settings form for a pushed vertical exactly like a builtin.
|
|
846
|
+
...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
|
|
847
|
+
// Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
|
|
848
|
+
// installs a pushed vertical without a hardcoded catalog entry.
|
|
849
|
+
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
850
|
+
...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
|
|
851
|
+
...(manifest.provides ? { provides: manifest.provides } : {}),
|
|
852
|
+
...(manifest.requires ? { requires: manifest.requires } : {}),
|
|
493
853
|
});
|
|
494
854
|
await admin.publishVersion(c.get('actor'), {
|
|
495
855
|
id,
|
|
@@ -503,6 +863,61 @@ export function createControlPlaneApi(options) {
|
|
|
503
863
|
const version = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
|
|
504
864
|
return c.json(version, 201);
|
|
505
865
|
});
|
|
866
|
+
// -- observability (design/observability.md §4.1) --------------------------
|
|
867
|
+
// Proxied Cloudflare-native reads: the console's fleet view and (later, owner-
|
|
868
|
+
// narrowed) the dashboard's builder view. STAFF-ONLY — not in BUILDER_ROUTES; see
|
|
869
|
+
// the option's doc for why. Tier-3 numbers (master-plan §5.3): sampled, approximate,
|
|
870
|
+
// never money.
|
|
871
|
+
app.get('/observability/metrics', async (c) => {
|
|
872
|
+
if (!options.observability) {
|
|
873
|
+
return c.json({ error: 'observability is not configured on this control plane' }, 501);
|
|
874
|
+
}
|
|
875
|
+
const { hours } = z
|
|
876
|
+
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
877
|
+
.parse({ hours: c.req.query('hours') });
|
|
878
|
+
return c.json(await options.observability.serviceMetrics({ hours }));
|
|
879
|
+
});
|
|
880
|
+
app.get('/observability/logs', async (c) => {
|
|
881
|
+
if (!options.observability) {
|
|
882
|
+
return c.json({ error: 'observability is not configured on this control plane' }, 501);
|
|
883
|
+
}
|
|
884
|
+
const input = z
|
|
885
|
+
.object({
|
|
886
|
+
service: z.string().min(1).max(200).optional(),
|
|
887
|
+
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
888
|
+
hours: z.coerce.number().int().min(1).max(72).default(1),
|
|
889
|
+
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
890
|
+
})
|
|
891
|
+
.parse({
|
|
892
|
+
service: c.req.query('service') || undefined,
|
|
893
|
+
level: c.req.query('level') || undefined,
|
|
894
|
+
hours: c.req.query('hours'),
|
|
895
|
+
limit: c.req.query('limit'),
|
|
896
|
+
});
|
|
897
|
+
return c.json(await options.observability.recentLogs(input));
|
|
898
|
+
});
|
|
899
|
+
// -- push tokens (push-token.ts) -------------------------------------------
|
|
900
|
+
// Mint a tenant-scoped CI credential. STAFF-ONLY by the builder allowlist (not in
|
|
901
|
+
// BUILDER_ROUTES): the dashboard mints over its service token during git-import
|
|
902
|
+
// setup; a builder session cannot mint (their own session already authenticates
|
|
903
|
+
// them, and a self-serve mint surface deserves its own decision, not a side door).
|
|
904
|
+
// The token authenticates as a BUILDER for the named tenant — everything a builder
|
|
905
|
+
// can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
|
|
906
|
+
app.post('/push-tokens', async (c) => {
|
|
907
|
+
if (!options.pushTokenSecret) {
|
|
908
|
+
return c.json({ error: 'push tokens are not configured on this control plane' }, 501);
|
|
909
|
+
}
|
|
910
|
+
const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
|
|
911
|
+
const tenant = await admin.getTenant(c.get('actor'), tenantId);
|
|
912
|
+
if (!tenant)
|
|
913
|
+
return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
|
|
914
|
+
const token = await mintPushToken(options.pushTokenSecret, {
|
|
915
|
+
actor: await pushActorFor(tenantId),
|
|
916
|
+
tenantId,
|
|
917
|
+
tenantSlug: tenant.slug,
|
|
918
|
+
});
|
|
919
|
+
return c.json({ token, tenantSlug: tenant.slug }, 201);
|
|
920
|
+
});
|
|
506
921
|
// -- the hostname map (§4.7, K-26) -----------------------------------------
|
|
507
922
|
// The three STAFF actions land here. `resolveHostname` deliberately does NOT:
|
|
508
923
|
// it is the router's per-request machine path, unaudited by design (K-24), and
|
|
@@ -531,6 +946,14 @@ export function createControlPlaneApi(options) {
|
|
|
531
946
|
const row = (await admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
|
|
532
947
|
return c.json(row);
|
|
533
948
|
});
|
|
949
|
+
// Unbind (hard-delete) a hostname row — what the orphan cleanup uses on rows
|
|
950
|
+
// whose scope is archived or gone. Staff-only (not in BUILDER_ROUTES), audited
|
|
951
|
+
// below the seam, idempotent: an unknown hostname deletes nothing and still 200s.
|
|
952
|
+
app.delete('/hostnames/:hostname', async (c) => {
|
|
953
|
+
const name = c.req.param('hostname');
|
|
954
|
+
await admin.unbindHostname(c.get('actor'), name);
|
|
955
|
+
return c.json({ deleted: name.toLowerCase() });
|
|
956
|
+
});
|
|
534
957
|
// -- roles, read only (§4.5 console item 4) --------------------------------
|
|
535
958
|
// The READ lands; `defineRole` deliberately does not. Creating a role over
|
|
536
959
|
// HTTP is a permission change, and the permission diff is a human checkpoint
|