@substrat-run/control-plane-api 0.14.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 +18 -0
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +352 -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 +3 -1
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +7 -0
  17. package/dist/client.js.map +1 -1
  18. package/dist/deploy.d.ts +3 -46
  19. package/dist/deploy.d.ts.map +1 -1
  20. package/dist/deploy.js +22 -35
  21. package/dist/deploy.js.map +1 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +13 -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 +76 -5
  42. package/dist/vertical-client.d.ts.map +1 -1
  43. package/dist/vertical-client.js +65 -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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # @substrat-run/control-plane-api
2
+
3
+ The HTTP surface over `HostAdmin` for [Substrat](https://github.com/substrat-run/substrat) —
4
+ the **audited control-plane transport**. It is the seam between the platform's admin
5
+ operations (provision a scope, bind a hostname, grant a role, deploy a vertical) and
6
+ whatever runs them: the console, the CLI, or another service.
7
+
8
+ It is a transport, not a source of truth. Every call lands on `HostAdmin`, every mutation
9
+ is audited, and the same contract runs over any scope host (the pure-SQLite adapter in
10
+ CI, Durable Objects in production).
11
+
12
+ ## What's in the box
13
+
14
+ - **`createControlPlaneApi`** — a [Hono](https://hono.dev) app exposing `HostAdmin` over
15
+ HTTP, with authentication and the audit log wired in.
16
+ - **`ControlPlaneClient`** — the typed client for that surface (what the console and CLI
17
+ call), plus `ControlPlaneError` for structured failures.
18
+ - **`VerticalClient`** — the narrowed, tenant-scoped seam an app uses to provision itself.
19
+ - **`deployManifest` / `createWfpUploader`** — the deploy path: validate a vertical
20
+ bundle against the sandbox contract and upload it to Workers-for-Platforms.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pnpm add @substrat-run/control-plane-api
26
+ ```
27
+
28
+ ```ts
29
+ import { createControlPlaneApi } from '@substrat-run/control-plane-api';
30
+
31
+ const app = createControlPlaneApi({ admin /* HostAdmin */, /* auth, audit, … */ });
32
+ export default app; // a Hono app — serve it on Node, Workers, or in tests
33
+ ```
34
+
35
+ ```ts
36
+ import { ControlPlaneClient } from '@substrat-run/control-plane-api';
37
+
38
+ const cp = new ControlPlaneClient({ baseUrl, token });
39
+ await cp.provisionScope({ tenant, slug });
40
+ ```
41
+
42
+ ## Documentation
43
+
44
+ **https://substrat.net/platform/control-plane** — the admin surface, the audit model,
45
+ authentication, and how the console/CLI/router sit on top of it.
46
+
47
+ ## Related packages
48
+
49
+ - [`@substrat-run/kernel`](https://npmjs.com/package/@substrat-run/kernel) — the
50
+ scope-host + `HostAdmin` contract this exposes
51
+ - [`@substrat-run/adapter-sqlite`](https://npmjs.com/package/@substrat-run/adapter-sqlite) —
52
+ the pure-SQLite host it runs against in CI and self-host
53
+ - [`@substrat-run/cli`](https://npmjs.com/package/@substrat-run/cli) — the deploy tooling
54
+ that drives this surface
55
+
56
+ ## Status
57
+
58
+ Pre-release (0.x): the surface changes without notice until the platform GAs.
package/dist/api.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { ScopeHost } from '@substrat-run/kernel';
4
4
  import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
5
5
  import type { VerticalClient } from './vertical-client.js';
6
6
  import type { DeployVerticalFn } from './deploy.js';
7
+ import type { ObservabilityReader } from './observability.js';
7
8
  export interface ControlPlaneApiOptions {
8
9
  host: ScopeHost;
9
10
  /**
@@ -55,6 +56,23 @@ export interface ControlPlaneApiOptions {
55
56
  * the vertical-management routes and to the verticals their tenant owns.
56
57
  */
57
58
  authenticateBuilder?: BuilderAuth;
59
+ /**
60
+ * Signs tenant-scoped push tokens (push-token.ts) — the CI credential the dashboard
61
+ * mints into a customer repo. Absent ⇒ the mint route 501s. A dedicated secret,
62
+ * never PLATFORM_SECRET (injected into pushed verticals) and never the service
63
+ * token; set once, out of routine rotation (rotating it revokes every issued token).
64
+ */
65
+ pushTokenSecret?: string;
66
+ /**
67
+ * Cloudflare-native observability reads (design/observability.md §4.1) —
68
+ * host-injected like `deployVertical`, so this package holds no credential and the
69
+ * Cloudflare token never leaves the platform (D-34). Absent ⇒ the observability
70
+ * routes 501. Staff-only for now: the routes are deliberately NOT in
71
+ * `BUILDER_ROUTES` — the builder view needs owner-narrowing (only scripts whose
72
+ * registry `ownerTenant` is the caller's) before it can be opened, and default-deny
73
+ * means forgetting that costs a feature, never a leak.
74
+ */
75
+ observability?: ObservabilityReader;
58
76
  }
59
77
  type Vars = {
60
78
  actor: PlatformActorId;
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAqB5B,OAAO,KAAK,EAAE,eAAe,EAAqB,MAAM,yBAAyB,CAAC;AAClF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAK3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;CACnC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAkG7D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAglBhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAsB5B,OAAO,KAAK,EAAE,eAAe,EAA4B,MAAM,yBAAyB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAM3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;CACrC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAoH7D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAq6BhG"}
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, readScopeTableInput, 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,6 +173,7 @@ 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$/ },
160
179
  // A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5); the
@@ -293,6 +312,53 @@ export function createControlPlaneApi(options) {
293
312
  ? await vertical.readScopeTable(scopeId, input)
294
313
  : await admin.readScopeTable(c.get('actor'), tenantId, scopeId, input));
295
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
+ });
296
362
  // The four lifecycle transitions, one route each — mirroring the four audited
297
363
  // actions rather than collapsing into a PATCH that would accept a target
298
364
  // status the transition graph forbids. The graph is enforced below the seam;
@@ -312,15 +378,178 @@ export function createControlPlaneApi(options) {
312
378
  return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, scopeId));
313
379
  });
314
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
+ });
315
516
  // Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
316
517
  // non-admitted version below the seam — that refusal is the registry's reason to
317
- // 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.
318
522
  app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
319
523
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
320
524
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
321
- const { versionId } = bindScopeVersionBody.parse(await c.req.json());
322
- await admin.bindScopeVersion(c.get('actor'), tenantId, scopeId, versionId);
323
- 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));
324
553
  });
325
554
  // -- instances (K-31) -------------------------------------------------------
326
555
  // The one place this surface calls OUT rather than sitting over `HostAdmin`, and
@@ -334,6 +563,13 @@ export function createControlPlaneApi(options) {
334
563
  // in-between properly, and it is still unused — see the PR.
335
564
  app.post('/verticals/:slug/instances', async (c) => {
336
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
+ }
337
573
  // Static binding first (milestone-one shape), then the dispatch resolver for a
338
574
  // pushed vertical — the provisioning mirror of the router's verticalFor.
339
575
  const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
@@ -370,6 +606,10 @@ export function createControlPlaneApi(options) {
370
606
  const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
371
607
  return v ? v.ownerTenant : undefined;
372
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);
373
613
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
374
614
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
375
615
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -465,6 +705,23 @@ export function createControlPlaneApi(options) {
465
705
  await admin.setVerticalListed(c.get('actor'), slug, listed);
466
706
  return c.json({ slug, listed });
467
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
+ });
468
725
  app.get('/verticals/:slug/channels', async (c) => {
469
726
  const p = c.get('principal');
470
727
  const slug = effectiveSlug(p, c.req.param('slug'));
@@ -473,18 +730,35 @@ export function createControlPlaneApi(options) {
473
730
  }
474
731
  return c.json(await admin.listChannels(c.get('actor'), slug));
475
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
+ });
476
745
  app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
477
746
  const p = c.get('principal');
478
747
  const slug = effectiveSlug(p, c.req.param('slug'));
479
748
  const channel = channelName.parse(c.req.param('channel'));
480
749
  if (p.kind === 'builder') {
481
- // Staff keep the prod gate (model B, §2/§4): a builder self-serves dev/staging;
482
- // admission and prod promotion stay a human staff decision (the trust boundary
483
- // self-serve-deploy.md §3 is explicit about). And only on verticals it owns.
484
- if (channel === 'prod')
485
- return c.json({ error: 'promotion to prod is staff-only' }, 403);
486
- 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)
487
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
+ }
488
762
  }
489
763
  const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
490
764
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
@@ -496,8 +770,9 @@ export function createControlPlaneApi(options) {
496
770
  // The deploy seam (self-serve-deploy.md): a `substrat push` uploads a built bundle
497
771
  // here. The order is upload → record, deliberately: a failed record leaves an
498
772
  // orphaned namespace script (invisible, GC'able) rather than a directory row
499
- // pointing at a deployment that is not there. The version lands PENDING — a push
500
- // 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.
501
776
  app.post('/verticals/:slug/deploy', async (c) => {
502
777
  if (!options.deployVertical) {
503
778
  return c.json({ error: 'deploy is not configured on this control plane' }, 501);
@@ -588,6 +863,61 @@ export function createControlPlaneApi(options) {
588
863
  const version = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
589
864
  return c.json(version, 201);
590
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
+ });
591
921
  // -- the hostname map (§4.7, K-26) -----------------------------------------
592
922
  // The three STAFF actions land here. `resolveHostname` deliberately does NOT:
593
923
  // it is the router's per-request machine path, unaudited by design (K-24), and
@@ -616,6 +946,14 @@ export function createControlPlaneApi(options) {
616
946
  const row = (await admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
617
947
  return c.json(row);
618
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
+ });
619
957
  // -- roles, read only (§4.5 console item 4) --------------------------------
620
958
  // The READ lands; `defineRole` deliberately does not. Creating a role over
621
959
  // HTTP is a permission change, and the permission diff is a human checkpoint