@substrat-run/control-plane-api 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Hono } from 'hono';
2
- import { adminAction, channelName, createTenantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
2
+ import { adminAction, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
3
3
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
4
  import { ControlPlaneError } from './client.js';
5
5
  import { mapError } from './errors.js';
6
6
  import { maskDump } from './mask.js';
7
- import { assertSandboxContract, deployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, } from './deploy.js';
7
+ import { assertSandboxContract, deployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
8
8
  import { mintPushToken, pushActorFor } from './push-token.js';
9
9
  // -- request schemas ---------------------------------------------------------
10
10
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
@@ -101,7 +101,13 @@ const auditLogQuery = z.object({
101
101
  action: z.array(adminAction).optional(),
102
102
  since: z.string().optional(),
103
103
  until: z.string().optional(),
104
- limit: z.coerce.number().int().positive().max(1000).optional(),
104
+ // Defaulted, not merely capped: the admin log is append-only and never swept (the
105
+ // retention decision — it is the compliance witness, control-plane.md §4.4/§4.8), so
106
+ // it only grows. An unbounded `GET /admin-log` would dump the whole table; a default
107
+ // page keeps the external read bounded while `nextCursor` still walks the entire log.
108
+ // The KERNEL call stays deliberately unbounded (an in-process caller that wants
109
+ // everything asks for everything) — only this HTTP egress vector is bounded by default.
110
+ limit: z.coerce.number().int().positive().max(1000).default(200),
105
111
  cursor: z.string().optional(),
106
112
  order: z.enum(['asc', 'desc']).optional(),
107
113
  });
@@ -184,6 +190,14 @@ export function createControlPlaneApi(options) {
184
190
  // A builder REQUESTS publication of a vertical it owns (marketplace-publish.md §5); the
185
191
  // `listing` flip stays staff-only. Ownership is checked in the handler.
186
192
  { method: 'POST', re: /\/verticals\/[^/]+\/publish-request$/ },
193
+ // The hostname map, tenant-narrowed (K-26 multi-surface exposure): a builder manages
194
+ // bindings for ITS OWN scopes — the same power the dashboard already exercises for it
195
+ // over the service token. Each handler narrows to the principal's tenant; an unknown
196
+ // or foreign hostname reads as 404 (existence hiding, like the registry filter).
197
+ { method: 'GET', re: /\/hostnames$/ },
198
+ { method: 'POST', re: /\/hostnames$/ },
199
+ { method: 'PATCH', re: /\/hostnames\/[^/]+\/status$/ },
200
+ { method: 'DELETE', re: /\/hostnames\/[^/]+$/ },
187
201
  ];
188
202
  app.use('*', async (c, next) => {
189
203
  if (c.get('principal').kind === 'builder') {
@@ -238,11 +252,56 @@ export function createControlPlaneApi(options) {
238
252
  await admin.setTenantStatus(c.get('actor'), tenantId, status);
239
253
  return c.json(await admin.getTenant(c.get('actor'), tenantId));
240
254
  });
255
+ // Reap a DELETING tenant now (control-plane.md §4.8) — the staff "reap now" that skips
256
+ // the grace window, the tenant analogue of the scope reap route below. Refuses a tenant
257
+ // that is not `deleting` (409): a reap only ever follows the reversible delete state, and
258
+ // starting/reversing it is the ordinary `PATCH …/status` transition above. Every scope is
259
+ // reaped FIRST (archive-if-needed → the vertical wipes its co-located DO → `reapScope`,
260
+ // the same storage-before-row ordering the scope route keeps) so no scope's bytes outlive
261
+ // the tenant, then `reapTenant` clears the directory. Staff/service only (not in
262
+ // BUILDER_ROUTES). Idempotent: a crash mid-reap leaves the tenant `deleting`, and a retry
263
+ // (or the grace-window sweep) converges — reaped scopes are skipped, reapTenant re-checks.
264
+ app.post('/tenants/:tenantId/reap', async (c) => {
265
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
266
+ const actor = c.get('actor');
267
+ const tenant = await admin.getTenant(actor, tenantId);
268
+ if (!tenant)
269
+ return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
270
+ if (tenant.status !== 'deleting') {
271
+ return c.json({
272
+ error: `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`,
273
+ }, 409);
274
+ }
275
+ try {
276
+ for (const scope of await admin.listScopes(actor, { tenantId })) {
277
+ if (scope.status === 'reaped')
278
+ continue;
279
+ if (scope.status !== 'archived')
280
+ await admin.archiveScope(actor, tenantId, scope.id);
281
+ const vertical = await verticalForScope(c, scope);
282
+ if (vertical)
283
+ await vertical.deleteScope({ scopeId: scope.id });
284
+ await admin.reapScope(actor, tenantId, scope.id);
285
+ }
286
+ await admin.reapTenant(actor, tenantId);
287
+ return c.json(await admin.getTenant(actor, tenantId));
288
+ }
289
+ catch (e) {
290
+ if (e instanceof ControlPlaneError) {
291
+ return c.json({ error: e.message }, e.status);
292
+ }
293
+ throw e;
294
+ }
295
+ });
241
296
  // -- entitlements (§4.3) ---------------------------------------------------
242
297
  app.get('/tenants/:tenantId/entitlements', async (c) => c.json(await admin.listEntitlements(c.get('actor'), tenantIdSchema.parse(c.req.param('tenantId')))));
243
298
  app.put('/tenants/:tenantId/entitlements/:key', async (c) => {
244
299
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
245
- await admin.grantEntitlement(c.get('actor'), tenantId, c.req.param('key'));
300
+ // The body is the plan half (#33) and optional — a bodyless PUT is the
301
+ // pre-widening bare flag grant, and PATCH semantics in the store mean it
302
+ // preserves whatever plan fields the grant already carries.
303
+ const plan = entitlementGrantInput.parse(await c.req.json().catch(() => ({})));
304
+ await admin.grantEntitlement(c.get('actor'), tenantId, c.req.param('key'), plan);
246
305
  return c.json(await admin.listEntitlements(c.get('actor'), tenantId));
247
306
  });
248
307
  app.delete('/tenants/:tenantId/entitlements/:key', async (c) => {
@@ -344,6 +403,82 @@ export function createControlPlaneApi(options) {
344
403
  }
345
404
  return options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
346
405
  };
406
+ /**
407
+ * Move ONE legacy scope's data off its per-version dispatch script onto its vertical's
408
+ * stable serving script (#286/#321), then flip routing. The one primitive behind both
409
+ * the explicit `adopt-serving` endpoint and the automatic adoption a prod promote runs.
410
+ *
411
+ * Ordering is data-first: export from the script that holds the data TODAY (the scope's
412
+ * current dispatch — resolved BEFORE any version rebind), restore into the serving
413
+ * script (which re-projects the vertical's roles), and only then `setScopeServingRef` +
414
+ * advance the version pointer. A crash before the flip leaves the scope serving its old
415
+ * script intact, and the adopt retries idempotently. Already-adopted scopes short-circuit.
416
+ * Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
417
+ */
418
+ const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
419
+ const actor = c.get('actor');
420
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
421
+ if (!scope) {
422
+ throw new ControlPlaneError(404, `unknown scope for tenant: (${tenantId}, ${scopeId})`);
423
+ }
424
+ if (scope.servingRef)
425
+ return { servingRef: scope.servingRef, alreadyAdopted: true };
426
+ if (!scope.vertical) {
427
+ throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
428
+ }
429
+ const serving = await admin.verticalServing(actor, scope.vertical);
430
+ if (!serving) {
431
+ throw new ControlPlaneError(409, `vertical '${scope.vertical}' has no serving script yet — promote a version to prod first`);
432
+ }
433
+ const source = await verticalForScope(c, scope);
434
+ const dest = await options.resolveVerticalRef?.(serving.ref);
435
+ if (!source || !dest) {
436
+ throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
437
+ }
438
+ const dump = await source.exportScope(scopeId);
439
+ const restored = await dest.restoreScope(tenantId, scopeId, dump);
440
+ // Data landed — only now flip routing and move the version pointer.
441
+ await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
442
+ await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
443
+ return { servingRef: serving.ref, tables: restored.tables };
444
+ };
445
+ /**
446
+ * After a prod in-place serve, own the owned-scope adopt+rebind the host cascade
447
+ * delegated to us for a dispatch-backed vertical (#321): adopt any still-legacy scope
448
+ * onto the serving script (data survives), and advance every owned scope's version
449
+ * pointer to the promoted version so Update stops offering a crossing already made.
450
+ *
451
+ * Gated exactly where the host cascade would have run: PRIVATE (owned, unlisted) only,
452
+ * active non-fork scopes only. Runs only when a serving script exists — i.e. the serve
453
+ * actually happened (dispatch-backed + deploy configured); for an embedded vertical the
454
+ * host cascade already rebound, and `verticalServing` is null, so this is a no-op.
455
+ * Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
456
+ * is still findable on a retry after a failed serve.
457
+ */
458
+ const adoptAndRebindOwnedScopes = async (c, slug, versionId) => {
459
+ const actor = c.get('actor');
460
+ const serving = await admin.verticalServing(actor, slug);
461
+ if (!serving)
462
+ return; // embedded / not dispatch-backed — the host cascade handled rebinds
463
+ const v = await verticalOf(actor, slug);
464
+ if (!v || v.ownerTenant === null || v.listed)
465
+ return; // private only, like the host cascade
466
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom);
467
+ for (const s of owned) {
468
+ if (!s.servingRef) {
469
+ // Adopt: export from the scope's current (un-rebound) dispatch → serving script,
470
+ // then bind to the serving version. Data-first, so a failure here leaves the
471
+ // scope intact on its old script for the next promote to retry.
472
+ await adoptScopeOntoServing(c, s.tenantId, s.id);
473
+ }
474
+ else if (s.verticalVersionId !== versionId) {
475
+ // Already on the serving script (born there, or adopted earlier): routing is
476
+ // pinned to servingRef, so advancing the version pointer only affects Update
477
+ // offers. Snapshot on a migration-digest crossing (fork-before-promote, §4).
478
+ await admin.bindScopeVersion(actor, s.tenantId, s.id, versionId, { snapshot: true });
479
+ }
480
+ }
481
+ };
347
482
  app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
348
483
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
349
484
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -355,6 +490,36 @@ export function createControlPlaneApi(options) {
355
490
  ? await vertical.listScopeTables(scopeId)
356
491
  : await admin.listScopeTables(c.get('actor'), tenantId, scopeId));
357
492
  });
493
+ // Scope health (#321, criterion #3). The silent failure the field report chased was an
494
+ // ACTIVE scope serving traffic from a DO whose `_substrat_roles` projection is EMPTY:
495
+ // identity resolves, every permission check denies, and it reads as a per-app 403 rather
496
+ // than a platform condition. Surface it as one. The role count comes from the SAME
497
+ // introspection the Data view uses (the serving script the router actually dispatches to),
498
+ // so it reflects the DO in front of live traffic — reusing existing plumbing rather than
499
+ // a new scope-DO route. `roleProjectionEmpty` on an active scope is the flag a console
500
+ // fleet view raises; a scope whose roles live off-DO (adapter-sqlite's directory) reports
501
+ // a null count and is not flagged.
502
+ app.get('/tenants/:tenantId/scopes/:scopeId/health', async (c) => {
503
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
504
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
505
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
506
+ if (!scope)
507
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
508
+ const vertical = await verticalForScope(c, scope);
509
+ const tables = vertical
510
+ ? await vertical.listScopeTables(scopeId)
511
+ : await admin.listScopeTables(c.get('actor'), tenantId, scopeId);
512
+ const roles = tables.find((t) => t.name === '_substrat_roles');
513
+ const roleCount = roles ? roles.rowCount : null;
514
+ const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
515
+ return c.json({
516
+ scopeId,
517
+ status: scope.status,
518
+ servingRef: scope.servingRef ?? null,
519
+ roleCount,
520
+ roleProjectionEmpty,
521
+ });
522
+ });
358
523
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
359
524
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
360
525
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -532,6 +697,39 @@ export function createControlPlaneApi(options) {
532
697
  throw e;
533
698
  }
534
699
  });
700
+ // Reap an ARCHIVED primary scope (control-plane.md §4.4): free its DO storage —
701
+ // Cloudflare never garbage-collects a Durable Object, so a deleted app's bytes persist
702
+ // forever otherwise — while keeping the directory row as a tombstone. A POST verb, not
703
+ // DELETE: DELETE means "remove the record" (and is already the fork hard-delete above),
704
+ // whereas reap KEEPS the row and just moves it to `reaped`. Staff/service only — not in
705
+ // BUILDER_ROUTES. The archived-only refusal is surfaced HERE before any delegation (the
706
+ // vertical must never be asked to wipe a live scope) and re-checked below the seam by
707
+ // reapScope. Same storage-before-row ordering as deleteSnapshot: the vertical wipes its
708
+ // co-located DO first, then the in-process reapScope flips the status and audits.
709
+ app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
710
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
711
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
712
+ const actor = c.get('actor');
713
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
714
+ if (!scope)
715
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
716
+ if (scope.status !== 'archived') {
717
+ return c.json({ error: `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped` }, 409);
718
+ }
719
+ try {
720
+ const vertical = await verticalForScope(c, scope);
721
+ if (vertical)
722
+ await vertical.deleteScope({ scopeId });
723
+ await admin.reapScope(actor, tenantId, scopeId);
724
+ return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
725
+ }
726
+ catch (e) {
727
+ if (e instanceof ControlPlaneError) {
728
+ return c.json({ error: e.message }, e.status);
729
+ }
730
+ throw e;
731
+ }
732
+ });
535
733
  // The governed pull (preview-and-snapshots.md §6/§8) — the ONE route that
536
734
  // deliberately hands scope BYTES to the caller, which is why every §6 layer sits
537
735
  // on it: staff-only (not in BUILDER_ROUTES), K-3 cross-checked, K-24 audited (the
@@ -589,6 +787,11 @@ export function createControlPlaneApi(options) {
589
787
  if (!scope)
590
788
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
591
789
  const dump = scopeDump.parse(await c.req.json());
790
+ // A backup with no tables is not a scope dump — name that plainly rather than letting
791
+ // the empty replay reach the checker and surface as a bare `internal error` (#321).
792
+ if (dump.tables.length === 0) {
793
+ return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
794
+ }
592
795
  try {
593
796
  await host.restoreScope(actor, tenantId, scopeId, dump);
594
797
  const vertical = await verticalForScope(c, scope);
@@ -600,7 +803,13 @@ export function createControlPlaneApi(options) {
600
803
  if (e instanceof ControlPlaneError) {
601
804
  return c.json({ error: e.message }, e.status);
602
805
  }
603
- throw e;
806
+ // A restore throw is driven by the caller-supplied dump (a shape the target cannot
807
+ // load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
808
+ // collapsing to the generic 500 `internal error` mapError would produce (#321,
809
+ // secondary obs #2). The route is staff/owner-gated and the detail is about the
810
+ // dump the caller sent, not another tenant's state.
811
+ const detail = e instanceof Error ? e.message : String(e);
812
+ return c.json({ error: 'restore failed — the backup could not be loaded', detail }, 422);
604
813
  }
605
814
  });
606
815
  // #286: the PITR bookmarks a scope recorded before its migration passes — the
@@ -652,50 +861,61 @@ export function createControlPlaneApi(options) {
652
861
  throw e;
653
862
  }
654
863
  });
655
- // #286: adopt a LEGACY scope onto its vertical's stable serving script — the
656
- // one-time data hop off per-version dispatch. Export from the script that holds
657
- // the data today (the bound version's), restore into the serving script (which
658
- // re-projects the vertical's roles), then flip routing. Ordering is data-first:
659
- // a crash before the flip leaves the scope serving from its old script, intact,
660
- // and the adopt retries idempotently. The scope's version pointer moves to the
661
- // serving version in the same act, so Update stops offering a crossing it
662
- // already made.
864
+ // #286/#321: adopt a LEGACY scope onto its vertical's stable serving script — the
865
+ // one-time data hop off per-version dispatch, and the builder-triggerable backfill for
866
+ // installs that predate the in-place serve. The whole body lives in
867
+ // `adoptScopeOntoServing` (shared with the automatic adoption a prod promote runs);
868
+ // here it is just mapped to a JSON response.
663
869
  app.post('/tenants/:tenantId/scopes/:scopeId/adopt-serving', async (c) => {
664
870
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
665
871
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
666
- const actor = c.get('actor');
667
- const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
668
- if (!scope)
669
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
670
- if (scope.servingRef) {
671
- return c.json({ adopted: scopeId, servingRef: scope.servingRef, alreadyAdopted: true });
872
+ try {
873
+ const r = await adoptScopeOntoServing(c, tenantId, scopeId);
874
+ return c.json({ adopted: scopeId, ...r });
672
875
  }
673
- if (!scope.vertical) {
674
- return c.json({ error: 'scope has no vertical — nothing to adopt onto' }, 409);
876
+ catch (e) {
877
+ if (e instanceof ControlPlaneError) {
878
+ return c.json({ error: e.message }, e.status);
879
+ }
880
+ throw e;
675
881
  }
676
- const serving = await admin.verticalServing(actor, scope.vertical);
677
- if (!serving) {
678
- return c.json({ error: `vertical '${scope.vertical}' has no serving script yet promote a version to prod first` }, 409);
882
+ });
883
+ // Backfill EVERY still-legacy active scope of a vertical in one call — the vertical-wide
884
+ // trigger for an install that predates the in-place serve. Owner or staff (owned-slug
885
+ // checked by the confinement middleware). Idempotent: already-adopted scopes are skipped
886
+ // and reported. A per-scope failure stops the run and surfaces which scope failed, so a
887
+ // re-run resumes from there (each adopt is data-first and retry-safe).
888
+ app.post('/verticals/:slug/adopt-serving', async (c) => {
889
+ const p = c.get('principal');
890
+ const slug = effectiveSlug(p, c.req.param('slug'));
891
+ const actor = c.get('actor');
892
+ if (p.kind === 'builder') {
893
+ const v = await verticalOf(actor, slug);
894
+ if (!v || v.ownerTenant !== p.tenantId)
895
+ return c.json({ error: 'forbidden' }, 403);
679
896
  }
680
- const source = await verticalForScope(c, scope);
681
- const dest = await options.resolveVerticalRef?.(serving.ref);
682
- if (!source || !dest) {
683
- return c.json({ error: 'adopt-serving needs dispatch resolution for both ends' }, 501);
897
+ const v = await verticalOf(actor, slug);
898
+ if (!v)
899
+ return c.json({ error: `unknown vertical '${slug}'` }, 404);
900
+ if (v.ownerTenant === null) {
901
+ return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
684
902
  }
903
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.status === 'active');
904
+ const adopted = [];
905
+ const alreadyAdopted = [];
685
906
  try {
686
- const dump = await source.exportScope(scopeId);
687
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
688
- // Data landed only now flip routing and move the version pointer.
689
- await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
690
- await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
691
- return c.json({ adopted: scopeId, servingRef: serving.ref, tables: restored.tables });
907
+ for (const s of owned) {
908
+ const r = await adoptScopeOntoServing(c, s.tenantId, s.id);
909
+ (r.alreadyAdopted ? alreadyAdopted : adopted).push(s.id);
910
+ }
692
911
  }
693
912
  catch (e) {
694
913
  if (e instanceof ControlPlaneError) {
695
- return c.json({ error: e.message }, e.status);
914
+ return c.json({ error: e.message, adopted, alreadyAdopted }, e.status);
696
915
  }
697
916
  throw e;
698
917
  }
918
+ return c.json({ vertical: slug, adopted, alreadyAdopted });
699
919
  });
700
920
  // Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
701
921
  // non-admitted version below the seam — that refusal is the registry's reason to
@@ -761,8 +981,18 @@ export function createControlPlaneApi(options) {
761
981
  return c.json({ error: `no deployment is bound for vertical '${slug}'` }, 501);
762
982
  }
763
983
  const input = provisionInstanceBody.parse(await c.req.json());
984
+ // #310: the platform is the authoritative source of the tenant's entitlements — it
985
+ // gathers them HERE (not from the caller's body) and delivers them WITH provisioning so
986
+ // the CP-less vertical projects them and enforces plan/quota/expiry at request time
987
+ // (#304). A vertical predating the field ignores it; grant/revoke AFTER provision ride
988
+ // a re-provision (this endpoint is idempotent, K-31), meanwhile expiry still enforces
989
+ // locally because the projected row carries it.
990
+ const entitlements = await admin.listEntitlements(c.get('actor'), input.tenantId);
764
991
  try {
765
- const instance = await vertical.provisionInstance(input);
992
+ const instance = await vertical.provisionInstance({
993
+ ...input,
994
+ entitlements,
995
+ });
766
996
  return c.json(instance, 201);
767
997
  }
768
998
  catch (e) {
@@ -798,10 +1028,13 @@ export function createControlPlaneApi(options) {
798
1028
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
799
1029
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
800
1030
  // global claim race, and a builder can never name another tenant's namespace (their
801
- // prefix is fixed by auth). Staff address a vertical by its full id, so for them the raw
802
- // slug is the identity. A builder slug that already contains `/` yields a two-slash id
803
- // that fails `verticalSlug` validation / the ownership check downstream fail-closed.
804
- const effectiveSlug = (p, raw) => p.kind === 'builder' ? `${p.tenantSlug}/${raw}` : raw;
1031
+ // prefix is fixed by auth). Idempotent: a builder addressing its own FULL id (e.g. the
1032
+ // `verticalSlug` a deploy response returned) is not double-prefixed the prefix is
1033
+ // auth-derived either way, so this can never reach another tenant's namespace. Staff
1034
+ // address a vertical by its full id, so for them the raw slug is the identity. A builder
1035
+ // slug carrying any OTHER tenant's prefix yields a two-slash id that fails
1036
+ // `verticalSlug` validation / the ownership check downstream — fail-closed.
1037
+ const effectiveSlug = (p, raw) => p.kind === 'builder' ? (raw.startsWith(`${p.tenantSlug}/`) ? raw : `${p.tenantSlug}/${raw}`) : raw;
805
1038
  app.get('/verticals', async (c) => {
806
1039
  const all = await admin.listVerticals(c.get('actor'));
807
1040
  const p = c.get('principal');
@@ -1012,6 +1245,12 @@ export function createControlPlaneApi(options) {
1012
1245
  if (channel === 'prod') {
1013
1246
  try {
1014
1247
  await serveVersionInPlace(c.get('actor'), slug, versionId);
1248
+ // Adopt any still-legacy owned scope onto the serving script and advance every
1249
+ // owned scope's version — the rebind the host cascade delegated to us for a
1250
+ // dispatch-backed vertical (#321), in the correct order (serve → adopt → rebind),
1251
+ // so a legacy scope's data survives the promote instead of being stranded on a
1252
+ // fresh per-version script. Retry-safe: nothing rebound these scopes yet.
1253
+ await adoptAndRebindOwnedScopes(c, slug, versionId);
1015
1254
  }
1016
1255
  catch (e) {
1017
1256
  const detail = e instanceof Error ? e.message : String(e);
@@ -1035,15 +1274,6 @@ export function createControlPlaneApi(options) {
1035
1274
  return c.json({ error: 'deploy is not configured on this control plane' }, 501);
1036
1275
  }
1037
1276
  const p = c.get('principal');
1038
- // A builder pushes a bare `--slug`; the registry id is `<tenantSlug>/<name>` (§5).
1039
- const slug = effectiveSlug(p, c.req.param('slug'));
1040
- // A builder pushes to a slug it owns, or claims an unregistered one (§3). Checked
1041
- // BEFORE the upload so a refused push never leaves an orphaned namespace script.
1042
- // `existingOwner` also lets a staff push stay ownership-idempotent (below).
1043
- const existingOwner = await ownerOf(c.get('actor'), slug);
1044
- if (p.kind === 'builder' && existingOwner !== undefined && existingOwner !== p.tenantId) {
1045
- return c.json({ error: 'forbidden' }, 403);
1046
- }
1047
1277
  const form = await c.req.formData();
1048
1278
  const raw = form.get('manifest');
1049
1279
  if (typeof raw !== 'string')
@@ -1051,6 +1281,56 @@ export function createControlPlaneApi(options) {
1051
1281
  const manifest = deployManifest.parse(JSON.parse(raw));
1052
1282
  // §4 sandbox contract, before anything reaches the namespace.
1053
1283
  assertSandboxContract(manifest);
1284
+ // The workspace this push is FOR — the project's pin (package.json `substrat.tenant`),
1285
+ // sent by the CLI alongside the bundle. The pin is intent, and intent is honored or
1286
+ // refused, never silently reinterpreted: a BUILDER's workspace is already fixed by
1287
+ // auth, so a pin naming a different one is a 403 rather than a push that lands
1288
+ // somewhere the project didn't say; STAFF have no workspace of their own, so the pin
1289
+ // is what makes their push land as the tenant's — prefixed and owned exactly as the
1290
+ // equivalent builder push — instead of claiming the slug platform-owned (unowned ⇒
1291
+ // invisible in every workspace dashboard, and never self-admitting) with the pin
1292
+ // silently dropped. An old CLI that sends no pin keeps today's behavior on all paths.
1293
+ const pinField = form.get('tenant');
1294
+ const pin = typeof pinField === 'string' && pinField.length > 0 ? pinField : null;
1295
+ // Resolve the registry id + owner this push acts on. Checked BEFORE the upload so a
1296
+ // refused push never leaves an orphaned namespace script.
1297
+ const bare = c.req.param('slug');
1298
+ let slug;
1299
+ let ownerTenant;
1300
+ if (p.kind === 'builder') {
1301
+ if (pin && pin !== p.tenantSlug && pin !== p.tenantId) {
1302
+ return c.json({ error: `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'` }, 403);
1303
+ }
1304
+ slug = effectiveSlug(p, bare);
1305
+ // A builder pushes to a slug it owns, or claims an unregistered one (§3).
1306
+ const existingOwner = await ownerOf(c.get('actor'), slug);
1307
+ if (existingOwner !== undefined && existingOwner !== p.tenantId) {
1308
+ return c.json({ error: 'forbidden' }, 403);
1309
+ }
1310
+ ownerTenant = p.tenantId;
1311
+ }
1312
+ else if (pin) {
1313
+ const workspace = (await admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
1314
+ if (!workspace)
1315
+ return c.json({ error: `unknown workspace '${pin}'` }, 404);
1316
+ // Back-compat: a BARE slug already registered as the pinned tenant's (a staff
1317
+ // hand-registration predating prefixed claims) stays addressable as itself.
1318
+ // Otherwise the claim lands under the tenant prefix, exactly like a builder push —
1319
+ // same namespace, no claim race with other workspaces' bare names.
1320
+ const bareOwner = await ownerOf(c.get('actor'), bare);
1321
+ slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
1322
+ const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
1323
+ if (existingOwner !== undefined && existingOwner !== workspace.id) {
1324
+ return c.json({ error: `vertical '${slug}' is not owned by workspace '${pin}'` }, 403);
1325
+ }
1326
+ ownerTenant = workspace.id;
1327
+ }
1328
+ else {
1329
+ // No pin: the raw slug is the identity and an existing owner is preserved
1330
+ // (null ⇒ platform-owned for a first-party vertical) — unchanged staff behavior.
1331
+ slug = bare;
1332
+ ownerTenant = (await ownerOf(c.get('actor'), slug)) ?? null;
1333
+ }
1054
1334
  const modules = [];
1055
1335
  for (const [name, value] of form.entries()) {
1056
1336
  if (name === 'manifest')
@@ -1081,23 +1361,33 @@ export function createControlPlaneApi(options) {
1081
1361
  });
1082
1362
  }
1083
1363
  catch (e) {
1084
- // The upload to the runtime failed (e.g. Cloudflare rejected the script). This is
1085
- // a platform/runtime error, not a bad request — surface the detail (the builder is
1364
+ // The upload to the runtime failed. Surface the detail (the builder is
1086
1365
  // authenticated) rather than the anonymous 500 the generic handler would give, so a
1087
- // push failure is diagnosable without reading worker logs.
1366
+ // push failure is diagnosable without reading worker logs. The version label is NOT
1367
+ // consumed here: registration/publish happen only AFTER a successful upload (below),
1368
+ // so a failed push leaves the same --version reusable (#307).
1369
+ //
1370
+ // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
1371
+ // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
1372
+ // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
1373
+ // throw with no upstream status) is a platform failure and stays a 502.
1088
1374
  const detail = e instanceof Error ? e.message : String(e);
1089
- console.error('deploy.upload.failed', { slug, deploymentRef, detail });
1090
- return c.json({ error: 'deploy upload failed', detail }, 502);
1375
+ const upstream = upstreamStatusOf(e);
1376
+ console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
1377
+ return upstream !== undefined && upstream >= 400 && upstream < 500
1378
+ ? c.json({ error: 'deploy rejected', detail }, 422)
1379
+ : c.json({ error: 'deploy upload failed', detail }, 502);
1091
1380
  }
1092
1381
  // Register-then-publish, both idempotent-ish below the seam: a first push of a
1093
- // slug registers it; publishVersion lands the version pending with deploymentRef.
1094
- // A builder push claims the slug for its tenant; a staff push preserves the existing
1095
- // owner (null platform-owned for a first-party vertical) rather than clobbering it.
1382
+ // slug registers it; publishVersion lands the version pending with deploymentRef
1383
+ // (or admitted, for a PRIVATE vertical the registry's self-admit rule). The owner
1384
+ // was resolved with the slug above: the builder's tenant, the pinned workspace, or
1385
+ // the preserved existing owner for an unpinned staff push.
1096
1386
  await admin.registerVertical(c.get('actor'), {
1097
1387
  slug,
1098
1388
  name: manifest.name ?? slug,
1099
1389
  source: 'cli',
1100
- ownerTenant: p.kind === 'builder' ? p.tenantId : (existingOwner ?? null),
1390
+ ownerTenant,
1101
1391
  // The vertical's declared config surface rides to the registry, so the dashboard
1102
1392
  // renders a settings form for a pushed vertical exactly like a builtin.
1103
1393
  ...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
@@ -1107,6 +1397,9 @@ export function createControlPlaneApi(options) {
1107
1397
  ...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
1108
1398
  ...(manifest.provides ? { provides: manifest.provides } : {}),
1109
1399
  ...(manifest.requires ? { requires: manifest.requires } : {}),
1400
+ // The declared surfaces (K-26) ride like envSpec: registry metadata for the
1401
+ // hostname-binding picker, never behavior. Not part of any admission digest.
1402
+ ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
1110
1403
  });
1111
1404
  await admin.publishVersion(c.get('actor'), {
1112
1405
  id,
@@ -1120,8 +1413,23 @@ export function createControlPlaneApi(options) {
1120
1413
  // bytes, this keeps their shape (entry, compat, doClasses, bindings).
1121
1414
  manifestJson: JSON.stringify(manifest),
1122
1415
  });
1416
+ // Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
1417
+ // surfaces, name any surface that hostnames are still bound to but the declaration
1418
+ // dropped — the URL keeps resolving (routing never keys on the declaration), it just
1419
+ // serves whatever the vertical does for an unknown surface. A push declaring nothing
1420
+ // opts out of the check entirely.
1421
+ const warnings = [];
1422
+ if (manifest.surfaces?.length) {
1423
+ const declared = new Set(manifest.surfaces.map((s) => s.name));
1424
+ const bound = await admin.listHostnames(c.get('actor'), {});
1425
+ for (const h of bound) {
1426
+ if (h.verticalSlug === slug && !declared.has(h.surface)) {
1427
+ warnings.push(`hostname '${h.hostname}' is bound to surface '${h.surface}', which this version no longer declares`);
1428
+ }
1429
+ }
1430
+ }
1123
1431
  const version = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
1124
- return c.json(version, 201);
1432
+ return c.json({ ...version, ...(warnings.length ? { warnings } : {}) }, 201);
1125
1433
  });
1126
1434
  // -- observability (design/observability.md §4.1) --------------------------
1127
1435
  // Proxied Cloudflare-native reads: the console's fleet view and (later, owner-
@@ -1179,20 +1487,46 @@ export function createControlPlaneApi(options) {
1179
1487
  return c.json({ token, tenantSlug: tenant.slug }, 201);
1180
1488
  });
1181
1489
  // -- the hostname map (§4.7, K-26) -----------------------------------------
1182
- // The three STAFF actions land here. `resolveHostname` deliberately does NOT:
1183
- // it is the router's per-request machine path, unaudited by design (K-24), and
1184
- // putting it on the audited staff surface would either flood the log or quietly
1185
- // create an unaudited route on a surface whose whole claim is that it is audited.
1186
- // The router reads the directory directly; it does not come through here.
1490
+ // Staff actions, PLUS a tenant-narrowed builder view (multi-surface exposure
1491
+ // binding an EKA-style second surface is self-serve for the scope's own tenant).
1492
+ // `resolveHostname` deliberately does NOT land here: it is the router's per-request
1493
+ // machine path, unaudited by design (K-24), and putting it on the audited staff
1494
+ // surface would either flood the log or quietly create an unaudited route on a
1495
+ // surface whose whole claim is that it is audited. The router reads the directory
1496
+ // directly; it does not come through here.
1497
+ // A builder's view of one hostname: the row when it belongs to their tenant,
1498
+ // undefined otherwise — a foreign hostname must read as nonexistent, never as 403
1499
+ // (which would confirm the name is taken by someone).
1500
+ const tenantHostname = async (c, name) => {
1501
+ const p = c.get('principal');
1502
+ const filter = p.kind === 'builder' ? { tenantId: p.tenantId } : {};
1503
+ return (await admin.listHostnames(c.get('actor'), filter)).find((h) => h.hostname === name.toLowerCase());
1504
+ };
1187
1505
  app.get('/hostnames', async (c) => {
1506
+ const p = c.get('principal');
1188
1507
  const filter = listHostnamesQuery.parse({
1189
1508
  tenantId: c.req.query('tenantId'),
1190
1509
  scopeId: c.req.query('scopeId'),
1191
1510
  });
1511
+ // A builder's list is ALWAYS its own tenant's — the query may narrow further
1512
+ // (scopeId) but never widen; a foreign tenantId in the query loses silently.
1513
+ if (p.kind === 'builder')
1514
+ filter.tenantId = p.tenantId;
1192
1515
  return c.json(await admin.listHostnames(c.get('actor'), filter));
1193
1516
  });
1194
1517
  app.post('/hostnames', async (c) => {
1518
+ const p = c.get('principal');
1195
1519
  const input = bindHostnameBody.parse(await c.req.json());
1520
+ if (p.kind === 'builder') {
1521
+ // The body names the tenant the binding lands under; a builder may only name
1522
+ // its own (the adapter then verifies the scope belongs to it, K-3).
1523
+ if (input.tenantId !== p.tenantId)
1524
+ return c.json({ error: 'forbidden' }, 403);
1525
+ // The region column is an EU-residency claim (K-30) — never builder-suppliable.
1526
+ if (input.region !== null) {
1527
+ return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
1528
+ }
1529
+ }
1196
1530
  await admin.bindHostname(c.get('actor'), input);
1197
1531
  const bound = (await admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
1198
1532
  return c.json(bound, 201);
@@ -1202,15 +1536,23 @@ export function createControlPlaneApi(options) {
1202
1536
  // Not path-parsed through the schema: a hostname is the path segment here, and
1203
1537
  // `setHostnameStatus` normalizes and 404s an unknown one below the seam.
1204
1538
  const name = c.req.param('hostname');
1539
+ if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
1540
+ return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
1541
+ }
1205
1542
  await admin.setHostnameStatus(c.get('actor'), name, status, note);
1206
1543
  const row = (await admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
1207
1544
  return c.json(row);
1208
1545
  });
1209
1546
  // Unbind (hard-delete) a hostname row — what the orphan cleanup uses on rows
1210
- // whose scope is archived or gone. Staff-only (not in BUILDER_ROUTES), audited
1211
- // below the seam, idempotent: an unknown hostname deletes nothing and still 200s.
1547
+ // whose scope is archived or gone, and what an operator uses to retire a surface
1548
+ // URL. Audited below the seam, idempotent for staff: an unknown hostname deletes
1549
+ // nothing and still 200s. For a builder a hostname outside their tenant is a 404 —
1550
+ // idempotency yields to existence hiding at the tenant boundary.
1212
1551
  app.delete('/hostnames/:hostname', async (c) => {
1213
1552
  const name = c.req.param('hostname');
1553
+ if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
1554
+ return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
1555
+ }
1214
1556
  await admin.unbindHostname(c.get('actor'), name);
1215
1557
  return c.json({ deleted: name.toLowerCase() });
1216
1558
  });