@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.
Files changed (48) hide show
  1. package/README.md +58 -0
  2. package/dist/api.d.ts +29 -0
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +437 -14
  5. package/dist/api.js.map +1 -1
  6. package/dist/auth.d.ts +2 -0
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +11 -0
  9. package/dist/auth.js.map +1 -1
  10. package/dist/cf-observability.d.ts +22 -0
  11. package/dist/cf-observability.d.ts.map +1 -0
  12. package/dist/cf-observability.js +110 -0
  13. package/dist/cf-observability.js.map +1 -0
  14. package/dist/client.d.ts +7 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +17 -0
  17. package/dist/client.js.map +1 -1
  18. package/dist/deploy.d.ts +3 -32
  19. package/dist/deploy.d.ts.map +1 -1
  20. package/dist/deploy.js +22 -26
  21. package/dist/deploy.js.map +1 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +15 -0
  24. package/dist/errors.js.map +1 -1
  25. package/dist/index.d.ts +6 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/mask.d.ts +4 -0
  30. package/dist/mask.d.ts.map +1 -0
  31. package/dist/mask.js +71 -0
  32. package/dist/mask.js.map +1 -0
  33. package/dist/observability.d.ts +53 -0
  34. package/dist/observability.d.ts.map +1 -0
  35. package/dist/observability.js +17 -0
  36. package/dist/observability.js.map +1 -0
  37. package/dist/push-token.d.ts +33 -0
  38. package/dist/push-token.d.ts.map +1 -0
  39. package/dist/push-token.js +114 -0
  40. package/dist/push-token.js.map +1 -0
  41. package/dist/vertical-client.d.ts +89 -4
  42. package/dist/vertical-client.d.ts.map +1 -1
  43. package/dist/vertical-client.js +97 -0
  44. package/dist/vertical-client.js.map +1 -1
  45. package/dist/wfp.d.ts.map +1 -1
  46. package/dist/wfp.js +4 -0
  47. package/dist/wfp.js.map +1 -1
  48. 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({ versionId: z.string().min(1) });
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
- await admin.bindScopeVersion(c.get('actor'), tenantId, scopeId, versionId);
267
- return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, scopeId));
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
- // Staff keep the prod gate (model B, §2/§4): a builder self-serves dev/staging;
406
- // admission and prod promotion stay a human staff decision (the trust boundary
407
- // self-serve-deploy.md §3 is explicit about). And only on verticals it owns.
408
- if (channel === 'prod')
409
- return c.json({ error: 'promotion to prod is staff-only' }, 403);
410
- if ((await ownerOf(p.actor, slug)) !== p.tenantId)
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 push
424
- // is not a deploy; admission still gates serving.
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