@substrat-run/control-plane-api 0.14.0 → 0.17.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 +25 -3
  3. package/dist/api.d.ts.map +1 -1
  4. package/dist/api.js +381 -17
  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;
@@ -78,9 +96,13 @@ type Vars = {
78
96
  * retrofitted (K-20). Note there is no route here that accepts an `actor`
79
97
  * field at all — it is unrepresentable, not merely ignored.
80
98
  * 2. **Reads are exposed; enforcement writes are not.** defineRole / assignRole /
81
- * grant / grantToOrg / addMember / linkIdentity are on `HostAdmin` but get no
82
- * route: the console's v1 job is the tenant registry, lifecycle, entitlements
83
- * and history. `resolveIdentity` especially stays off it is the auth
99
+ * grant / grantToOrg / addMember are on `HostAdmin` but get no route: the
100
+ * console's v1 job is the tenant registry, lifecycle, entitlements and
101
+ * history. The ONE exception is the identity-mirror pair under
102
+ * `/tenants/:tenantId/identities` (service/staff only): builder auth resolves
103
+ * a CLI session against THIS deployment's directory, but identity links are
104
+ * born in the Dashboard's own deployment — a different DO — so the dashboard
105
+ * mirrors them here. `resolveIdentity` especially stays off — it is the auth
84
106
  * adapter's read path, not an admin surface.
85
107
  */
86
108
  export declare function createControlPlaneApi(options: ControlPlaneApiOptions): Hono<{
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;AAwB5B,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;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA87BhG"}
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, identityLink, principalId as principalIdSchema, 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
@@ -104,9 +122,13 @@ const auditLogQuery = z.object({
104
122
  * retrofitted (K-20). Note there is no route here that accepts an `actor`
105
123
  * field at all — it is unrepresentable, not merely ignored.
106
124
  * 2. **Reads are exposed; enforcement writes are not.** defineRole / assignRole /
107
- * grant / grantToOrg / addMember / linkIdentity are on `HostAdmin` but get no
108
- * route: the console's v1 job is the tenant registry, lifecycle, entitlements
109
- * and history. `resolveIdentity` especially stays off it is the auth
125
+ * grant / grantToOrg / addMember are on `HostAdmin` but get no route: the
126
+ * console's v1 job is the tenant registry, lifecycle, entitlements and
127
+ * history. The ONE exception is the identity-mirror pair under
128
+ * `/tenants/:tenantId/identities` (service/staff only): builder auth resolves
129
+ * a CLI session against THIS deployment's directory, but identity links are
130
+ * born in the Dashboard's own deployment — a different DO — so the dashboard
131
+ * mirrors them here. `resolveIdentity` especially stays off — it is the auth
110
132
  * adapter's read path, not an admin surface.
111
133
  */
112
134
  export function createControlPlaneApi(options) {
@@ -155,6 +177,7 @@ export function createControlPlaneApi(options) {
155
177
  { method: 'GET', re: /\/verticals\/[^/]+\/versions$/ },
156
178
  { method: 'POST', re: /\/verticals\/[^/]+\/versions$/ },
157
179
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
180
+ { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
158
181
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
159
182
  { method: 'POST', re: /\/verticals\/[^/]+\/deploy$/ },
160
183
  // A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5); the
@@ -216,6 +239,28 @@ export function createControlPlaneApi(options) {
216
239
  await admin.revokeEntitlement(c.get('actor'), tenantId, c.req.param('key'));
217
240
  return c.json(await admin.listEntitlements(c.get('actor'), tenantId));
218
241
  });
242
+ // -- identity mirror (builder-plane.md §4) ---------------------------------
243
+ // Builder auth (`whoami`, the CLI session reader) resolves `userId → tenants`
244
+ // against THIS deployment's identity directory, but the links are created at
245
+ // dashboard sign-up in the Dashboard's OWN deployment — a different DO. This
246
+ // pair is the mirror seam the dashboard writes through (idempotent, keyed the
247
+ // same as its local links). Not in BUILDER_ROUTES: a builder cannot write the
248
+ // directory that authenticates builders — service/staff only, fail-closed.
249
+ const mirrorIdentityBody = identityLink.omit({ tenantId: true });
250
+ app.put('/tenants/:tenantId/identities', async (c) => {
251
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
252
+ const link = mirrorIdentityBody.parse(await c.req.json());
253
+ // The pool must exist before a link can land in it (central topology, K-23);
254
+ // registering an existing pool is a no-op.
255
+ await admin.registerIdentityPool(c.get('actor'), { provider: link.provider, topology: 'central', tenantId: null });
256
+ await admin.linkIdentity(c.get('actor'), { ...link, tenantId });
257
+ return c.body(null, 204);
258
+ });
259
+ app.delete('/tenants/:tenantId/identities/:principal', async (c) => {
260
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
261
+ await admin.unlinkIdentity(c.get('actor'), tenantId, principalIdSchema.parse(c.req.param('principal')));
262
+ return c.body(null, 204);
263
+ });
219
264
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
220
265
  app.get('/scopes', async (c) => {
221
266
  const filter = listScopesQuery.parse({
@@ -293,6 +338,53 @@ export function createControlPlaneApi(options) {
293
338
  ? await vertical.readScopeTable(scopeId, input)
294
339
  : await admin.readScopeTable(c.get('actor'), tenantId, scopeId, input));
295
340
  });
341
+ // The SQL console (#219): one read-only statement, POSTed because SQL does not
342
+ // belong in a URL. Same delegation as the table reads; the gate's refusal maps to
343
+ // 400 (errors.ts), and a vertical that cannot answer safely (auth-server, whose
344
+ // DO redacts secret columns on table reads — arbitrary SQL would walk around the
345
+ // redaction) refuses via its own 501, relayed verbatim.
346
+ app.post('/tenants/:tenantId/scopes/:scopeId/query', async (c) => {
347
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
348
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
349
+ const input = queryScopeInput.parse(await c.req.json());
350
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
351
+ if (!scope)
352
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
353
+ const vertical = await verticalForScope(c, scope);
354
+ return c.json(vertical
355
+ ? await vertical.queryScope(scopeId, input)
356
+ : await admin.queryScope(c.get('actor'), tenantId, scopeId, input));
357
+ });
358
+ // Deliver per-instance CONFIG to the scope's own storage (vertical-auth-detach.md
359
+ // §2.2) — the missing "delivery" step behind the dashboard's Env tab. Same K-3
360
+ // addressing + bound-version resolution as introspection: the scope's DO lives in the
361
+ // deployment of its BOUND version, so that is where its config must land. A scope with
362
+ // no reachable vertical deployment (co-located/contract-test hosts run no vertical
363
+ // code) has nowhere to deliver to — 501, so the caller can tell "authored but not
364
+ // delivered" from "failed". The vertical's own status (e.g. its 501 for no live-config
365
+ // support) propagates rather than collapsing to a 500.
366
+ app.post('/tenants/:tenantId/scopes/:scopeId/configure', async (c) => {
367
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
368
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
369
+ const input = configureInstanceBody.parse(await c.req.json());
370
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
371
+ if (!scope)
372
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
373
+ const vertical = await verticalForScope(c, scope);
374
+ if (!vertical) {
375
+ return c.json({ error: `no deployment is bound for vertical '${scope.vertical ?? '(none)'}'` }, 501);
376
+ }
377
+ try {
378
+ await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
379
+ }
380
+ catch (e) {
381
+ if (e instanceof ControlPlaneError) {
382
+ return c.json({ error: e.message }, e.status);
383
+ }
384
+ throw e;
385
+ }
386
+ return c.json({ applied: input.entries.length });
387
+ });
296
388
  // The four lifecycle transitions, one route each — mirroring the four audited
297
389
  // actions rather than collapsing into a PATCH that would accept a target
298
390
  // status the transition graph forbids. The graph is enforced below the seam;
@@ -312,15 +404,178 @@ export function createControlPlaneApi(options) {
312
404
  return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, scopeId));
313
405
  });
314
406
  }
407
+ // -- snapshots (preview-and-snapshots.md §3/§9) -----------------------------
408
+ // The DATA half of a snapshot runs inside the vertical's own deployment (the
409
+ // scope's bytes never cross the boundary — the §9 property the trust line rests
410
+ // on); the DIRECTORY half — provenance row, activation, version bind — runs here.
411
+ // With no vertical client resolved (co-located host, tests, self-host) the host's
412
+ // in-process snapshotScope does both halves against its own SCOPE namespace.
413
+ const orchestratedSnapshot = async (c, tenantId, scope, opts) => {
414
+ const actor = c.get('actor');
415
+ const vertical = await verticalForScope(c, scope);
416
+ if (!vertical)
417
+ return options.host.snapshotScope(actor, tenantId, scope.id, opts);
418
+ const snapId = scopeIdSchema.parse(ulid());
419
+ // Directory row FIRST, as `provisioning` (K-31's two-phase shape, used as
420
+ // intended): a crash between the row and the data copy leaves an inert
421
+ // provisioning row — which, carrying provenance and an expiry, the GC sweep
422
+ // eventually reaps — never copied data with no record.
423
+ await options.host.provisionScope(actor, {
424
+ tenantId,
425
+ scopeId: snapId,
426
+ kind: opts.kind ?? 'archive',
427
+ vertical: scope.vertical,
428
+ jurisdiction: scope.jurisdiction,
429
+ forkedFrom: scope.id,
430
+ forkedAt: new Date().toISOString(),
431
+ expiresAt: opts.expiresAt,
432
+ });
433
+ await vertical.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId });
434
+ await admin.activateScope(actor, tenantId, snapId);
435
+ // Bound to the SOURCE's current version: source and fork share a deployment, so
436
+ // the fork resolves to the DO namespace its bytes actually live in.
437
+ if (scope.verticalVersionId) {
438
+ await admin.bindScopeVersion(actor, tenantId, snapId, scope.verticalVersionId);
439
+ }
440
+ return snapId;
441
+ };
442
+ // The forks OF one scope — what a Snapshots UI lists. A directory read (kind,
443
+ // provenance, expiry all live on the scope row); newest first.
444
+ app.get('/tenants/:tenantId/scopes/:scopeId/snapshots', async (c) => {
445
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
446
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
447
+ const scopes = await admin.listScopes(c.get('actor'), { tenantId });
448
+ return c.json(scopes
449
+ .filter((s) => s.forkedFrom === scopeId)
450
+ .sort((a, b) => (a.forkedAt < b.forkedAt ? 1 : -1)));
451
+ });
452
+ app.post('/tenants/:tenantId/scopes/:scopeId/snapshots', async (c) => {
453
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
454
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
455
+ const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
456
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
457
+ if (!scope)
458
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
459
+ try {
460
+ const snapId = await orchestratedSnapshot(c, tenantId, scope, body);
461
+ return c.json(await admin.getScopeRecord(c.get('actor'), tenantId, snapId), 201);
462
+ }
463
+ catch (e) {
464
+ if (e instanceof ControlPlaneError) {
465
+ return c.json({ error: e.message }, e.status);
466
+ }
467
+ throw e;
468
+ }
469
+ });
470
+ // Reap a fork. The fork-only refusal is surfaced HERE, before any delegation —
471
+ // the vertical must never even be asked to wipe a primary scope — and re-checked
472
+ // below the seam by deleteSnapshot, which also wipes the co-located storage,
473
+ // removes hostnames + the directory row, and writes the audit entry.
474
+ app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
475
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
476
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
477
+ const actor = c.get('actor');
478
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
479
+ if (!scope)
480
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
481
+ if (!scope.forkedFrom) {
482
+ return c.json({ error: `scope ${scopeId} is not a fork — only snapshots may be deleted` }, 409);
483
+ }
484
+ try {
485
+ // Vertical's storage first, then the in-process delete (refusal re-check,
486
+ // local/placeholder wipe, hostnames + directory row, audit) — the same
487
+ // storage-before-row ordering deleteSnapshot itself keeps, so a crash
488
+ // between the two converges on retry.
489
+ const vertical = await verticalForScope(c, scope);
490
+ if (vertical)
491
+ await vertical.deleteScope({ scopeId });
492
+ await options.host.deleteSnapshot(actor, tenantId, scopeId);
493
+ return c.json({ deleted: scopeId });
494
+ }
495
+ catch (e) {
496
+ if (e instanceof ControlPlaneError) {
497
+ return c.json({ error: e.message }, e.status);
498
+ }
499
+ throw e;
500
+ }
501
+ });
502
+ // The governed pull (preview-and-snapshots.md §6/§8) — the ONE route that
503
+ // deliberately hands scope BYTES to the caller, which is why every §6 layer sits
504
+ // on it: staff-only (not in BUILDER_ROUTES), K-3 cross-checked, K-24 audited (the
505
+ // exportScope access-log entry), jurisdiction-gated, and MASKED by default —
506
+ // `?full=true` is the explicit break-glass. Dumps are JSON-safe today (no BLOB
507
+ // columns exist in any schema); a vertical that adds one needs an encoding here.
508
+ app.get('/tenants/:tenantId/scopes/:scopeId/export', async (c) => {
509
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
510
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
511
+ const actor = c.get('actor');
512
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
513
+ if (!scope)
514
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
515
+ // Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
516
+ // lands the data on a machine outside the platform's control, so anything
517
+ // pinned tighter than `global` is refused until a compliant path exists.
518
+ if (scope.jurisdiction !== 'global') {
519
+ return c.json({
520
+ error: `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
521
+ `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
522
+ }, 403);
523
+ }
524
+ const full = c.req.query('full') === 'true';
525
+ try {
526
+ // The canonical export first: it writes the K-24 access-log entry and is the
527
+ // bytes when the host is co-located. When the scope's data lives in a vertical
528
+ // deployment, its dump OVERLAYS the (placeholder) tables — audit stays on the
529
+ // one canonical path either way.
530
+ const dump = await admin.exportScope(actor, tenantId, scopeId);
531
+ const vertical = await verticalForScope(c, scope);
532
+ const tables = vertical ? await vertical.exportScope(scopeId) : dump.tables;
533
+ return c.json({ ...dump, tables: full ? tables : maskDump(tables), masked: !full });
534
+ }
535
+ catch (e) {
536
+ if (e instanceof ControlPlaneError) {
537
+ return c.json({ error: e.message }, e.status);
538
+ }
539
+ throw e;
540
+ }
541
+ });
315
542
  // Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
316
543
  // 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.
544
+ // exist. A scope operation, so it keeps the scope route shape. `snapshot: true`
545
+ // opts into fork-before-promote (§4): on a migration-digest-crossing bind the
546
+ // pre-migration data is snapshotted first — orchestrated through the vertical
547
+ // when one resolves, in-process otherwise.
318
548
  app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
319
549
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
320
550
  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));
551
+ const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
552
+ const actor = c.get('actor');
553
+ if (snapshot) {
554
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
555
+ if (!scope) {
556
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
557
+ }
558
+ const vertical = await verticalForScope(c, scope);
559
+ if (vertical) {
560
+ // Delegated path: the digest compare lives here (the in-process path does
561
+ // it below the seam). Snapshot only a migration-crossing bind.
562
+ if (scope.vertical && scope.verticalVersionId) {
563
+ const versions = await admin.listVersions(actor, scope.vertical);
564
+ const current = versions.find((v) => v.id === scope.verticalVersionId);
565
+ const incoming = versions.find((v) => v.id === versionId);
566
+ if (current && incoming && current.migrationDigest !== incoming.migrationDigest) {
567
+ await orchestratedSnapshot(c, tenantId, scope, {});
568
+ }
569
+ }
570
+ await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
571
+ }
572
+ else {
573
+ await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
574
+ }
575
+ return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
576
+ }
577
+ await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
578
+ return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
324
579
  });
325
580
  // -- instances (K-31) -------------------------------------------------------
326
581
  // The one place this surface calls OUT rather than sitting over `HostAdmin`, and
@@ -334,6 +589,13 @@ export function createControlPlaneApi(options) {
334
589
  // in-between properly, and it is still unused — see the PR.
335
590
  app.post('/verticals/:slug/instances', async (c) => {
336
591
  const slug = c.req.param('slug');
592
+ // The install kill-switch: a blocked vertical takes no NEW instances, for anyone
593
+ // including its owner. Refused before deployment resolution so the answer is
594
+ // uniform whether or not anything is deployed. Existing scopes keep serving.
595
+ const registered = (await admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
596
+ if (registered?.installsBlocked) {
597
+ return c.json({ error: `new installs of vertical '${slug}' are blocked` }, 403);
598
+ }
337
599
  // Static binding first (milestone-one shape), then the dispatch resolver for a
338
600
  // pushed vertical — the provisioning mirror of the router's verticalFor.
339
601
  const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
@@ -370,6 +632,10 @@ export function createControlPlaneApi(options) {
370
632
  const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
371
633
  return v ? v.ownerTenant : undefined;
372
634
  };
635
+ // The full registry row, for the checks that need more than the owner — whether the
636
+ // vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
637
+ // self-serve below.
638
+ const verticalOf = async (actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
373
639
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
374
640
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
375
641
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -465,6 +731,23 @@ export function createControlPlaneApi(options) {
465
731
  await admin.setVerticalListed(c.get('actor'), slug, listed);
466
732
  return c.json({ slug, listed });
467
733
  });
734
+ // The install kill-switch (staff-only — not in BUILDER_ROUTES, so a builder is
735
+ // refused by the confinement middleware). Blocks NEW installs; existing scopes
736
+ // keep serving. Orthogonal to /listing (visibility).
737
+ app.post('/verticals/:slug/install-block', async (c) => {
738
+ const slug = c.req.param('slug');
739
+ const { blocked } = z.object({ blocked: z.boolean() }).parse(await c.req.json());
740
+ await admin.setVerticalInstallsBlocked(c.get('actor'), slug, blocked);
741
+ return c.json({ slug, installsBlocked: blocked });
742
+ });
743
+ // Delete a vertical + its versions and channels (staff-only, same confinement).
744
+ // Refused below the seam while any scope is still bound — surfaces as a 4xx via
745
+ // mapError, naming the count. Dispatch scripts become orphans for cleanup (#248).
746
+ app.delete('/verticals/:slug', async (c) => {
747
+ const slug = c.req.param('slug');
748
+ await admin.deleteVertical(c.get('actor'), slug);
749
+ return c.json({ slug, deleted: true });
750
+ });
468
751
  app.get('/verticals/:slug/channels', async (c) => {
469
752
  const p = c.get('principal');
470
753
  const slug = effectiveSlug(p, c.req.param('slug'));
@@ -473,18 +756,35 @@ export function createControlPlaneApi(options) {
473
756
  }
474
757
  return c.json(await admin.listChannels(c.get('actor'), slug));
475
758
  });
759
+ // The promotion timeline (newest first) — what a rollback UI picks a target from.
760
+ // Owner-narrowed like the channel read above: a builder sees only its own verticals'
761
+ // history, and a foreign slug 404s indistinguishably from an absent one.
762
+ app.get('/verticals/:slug/channels/:channel/history', async (c) => {
763
+ const p = c.get('principal');
764
+ const slug = effectiveSlug(p, c.req.param('slug'));
765
+ const channel = channelName.parse(c.req.param('channel'));
766
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
767
+ return c.json({ error: 'not found' }, 404);
768
+ }
769
+ return c.json(await admin.listChannelHistory(c.get('actor'), slug, channel));
770
+ });
476
771
  app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
477
772
  const p = c.get('principal');
478
773
  const slug = effectiveSlug(p, c.req.param('slug'));
479
774
  const channel = channelName.parse(c.req.param('channel'));
480
775
  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)
776
+ // A builder promotes only verticals it owns and prod only while the vertical
777
+ // is PRIVATE (not listed). A private vertical's blast radius is the owning
778
+ // tenant itself, and dev/staging already run the same bundle in the same
779
+ // sandbox, so a staff prod gate there protected nothing; it returns the moment
780
+ // the audience widens (publish flips `listed`, and prod becomes staff-only
781
+ // again the trust boundary marketplace-publish.md §2 draws).
782
+ const v = await verticalOf(p.actor, slug);
783
+ if (!v || v.ownerTenant !== p.tenantId)
487
784
  return c.json({ error: 'forbidden' }, 403);
785
+ if (channel === 'prod' && v.listed) {
786
+ return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
787
+ }
488
788
  }
489
789
  const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
490
790
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
@@ -496,8 +796,9 @@ export function createControlPlaneApi(options) {
496
796
  // The deploy seam (self-serve-deploy.md): a `substrat push` uploads a built bundle
497
797
  // here. The order is upload → record, deliberately: a failed record leaves an
498
798
  // 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.
799
+ // pointing at a deployment that is not there. The version lands PENDING — except a
800
+ // PRIVATE vertical's, which self-admits below the seam (its blast radius is its own
801
+ // tenant); for everything else admission still gates serving.
501
802
  app.post('/verticals/:slug/deploy', async (c) => {
502
803
  if (!options.deployVertical) {
503
804
  return c.json({ error: 'deploy is not configured on this control plane' }, 501);
@@ -588,6 +889,61 @@ export function createControlPlaneApi(options) {
588
889
  const version = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
589
890
  return c.json(version, 201);
590
891
  });
892
+ // -- observability (design/observability.md §4.1) --------------------------
893
+ // Proxied Cloudflare-native reads: the console's fleet view and (later, owner-
894
+ // narrowed) the dashboard's builder view. STAFF-ONLY — not in BUILDER_ROUTES; see
895
+ // the option's doc for why. Tier-3 numbers (master-plan §5.3): sampled, approximate,
896
+ // never money.
897
+ app.get('/observability/metrics', async (c) => {
898
+ if (!options.observability) {
899
+ return c.json({ error: 'observability is not configured on this control plane' }, 501);
900
+ }
901
+ const { hours } = z
902
+ .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
903
+ .parse({ hours: c.req.query('hours') });
904
+ return c.json(await options.observability.serviceMetrics({ hours }));
905
+ });
906
+ app.get('/observability/logs', async (c) => {
907
+ if (!options.observability) {
908
+ return c.json({ error: 'observability is not configured on this control plane' }, 501);
909
+ }
910
+ const input = z
911
+ .object({
912
+ service: z.string().min(1).max(200).optional(),
913
+ level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
914
+ hours: z.coerce.number().int().min(1).max(72).default(1),
915
+ limit: z.coerce.number().int().min(1).max(500).default(100),
916
+ })
917
+ .parse({
918
+ service: c.req.query('service') || undefined,
919
+ level: c.req.query('level') || undefined,
920
+ hours: c.req.query('hours'),
921
+ limit: c.req.query('limit'),
922
+ });
923
+ return c.json(await options.observability.recentLogs(input));
924
+ });
925
+ // -- push tokens (push-token.ts) -------------------------------------------
926
+ // Mint a tenant-scoped CI credential. STAFF-ONLY by the builder allowlist (not in
927
+ // BUILDER_ROUTES): the dashboard mints over its service token during git-import
928
+ // setup; a builder session cannot mint (their own session already authenticates
929
+ // them, and a self-serve mint surface deserves its own decision, not a side door).
930
+ // The token authenticates as a BUILDER for the named tenant — everything a builder
931
+ // can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
932
+ app.post('/push-tokens', async (c) => {
933
+ if (!options.pushTokenSecret) {
934
+ return c.json({ error: 'push tokens are not configured on this control plane' }, 501);
935
+ }
936
+ const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
937
+ const tenant = await admin.getTenant(c.get('actor'), tenantId);
938
+ if (!tenant)
939
+ return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
940
+ const token = await mintPushToken(options.pushTokenSecret, {
941
+ actor: await pushActorFor(tenantId),
942
+ tenantId,
943
+ tenantSlug: tenant.slug,
944
+ });
945
+ return c.json({ token, tenantSlug: tenant.slug }, 201);
946
+ });
591
947
  // -- the hostname map (§4.7, K-26) -----------------------------------------
592
948
  // The three STAFF actions land here. `resolveHostname` deliberately does NOT:
593
949
  // it is the router's per-request machine path, unaudited by design (K-24), and
@@ -616,6 +972,14 @@ export function createControlPlaneApi(options) {
616
972
  const row = (await admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
617
973
  return c.json(row);
618
974
  });
975
+ // Unbind (hard-delete) a hostname row — what the orphan cleanup uses on rows
976
+ // whose scope is archived or gone. Staff-only (not in BUILDER_ROUTES), audited
977
+ // below the seam, idempotent: an unknown hostname deletes nothing and still 200s.
978
+ app.delete('/hostnames/:hostname', async (c) => {
979
+ const name = c.req.param('hostname');
980
+ await admin.unbindHostname(c.get('actor'), name);
981
+ return c.json({ deleted: name.toLowerCase() });
982
+ });
619
983
  // -- roles, read only (§4.5 console item 4) --------------------------------
620
984
  // The READ lands; `defineRole` deliberately does not. Creating a role over
621
985
  // HTTP is a permission change, and the permission diff is a human checkpoint