@substrat-run/control-plane-api 0.24.0 → 0.26.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.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
5
5
  import type { VerticalClient } from './vertical-client.js';
6
6
  import type { DeployVerticalFn, FetchVerticalModulesFn } from './deploy.js';
7
7
  import type { ObservabilityReader } from './observability.js';
8
+ import { type CustomHostnameProvisioner } from './custom-hostnames.js';
8
9
  export interface ControlPlaneApiOptions {
9
10
  host: ScopeHost;
10
11
  /**
@@ -89,6 +90,23 @@ export interface ControlPlaneApiOptions {
89
90
  * means forgetting that costs a feature, never a leak.
90
91
  */
91
92
  observability?: ObservabilityReader;
93
+ /**
94
+ * Issues + polls Cloudflare-for-SaaS custom hostnames (#305, §4.7) — host-injected
95
+ * like `deployVertical`, so this package holds no Cloudflare credential (D-34). When
96
+ * present, binding a CUSTOM domain kicks off issuance (create → `verifying` + DNS
97
+ * records) instead of leaving a bare `pending` row that only a manual status flip
98
+ * could clear. Absent ⇒ a custom bind records `pending` and issuance never runs (the
99
+ * self-host / dev shape, where there is no CF-for-SaaS zone).
100
+ */
101
+ provisionHostname?: CustomHostnameProvisioner;
102
+ /**
103
+ * The platform's base domains — the wildcard-covered zones a PLATFORM hostname is
104
+ * minted under (`substrat.run`, `global.substrat.run`, …). A bind AT or UNDER one of
105
+ * these rides the wildcard cert and goes straight to `active`; anything else is a
106
+ * custom domain and walks issuance. Empty/absent ⇒ every bind is treated as custom
107
+ * (correct for a deployment that mints no platform hostnames).
108
+ */
109
+ platformBaseDomains?: string[];
92
110
  }
93
111
  type Vars = {
94
112
  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;AA2B5B,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;AAY3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAE5E,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;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;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;AAsH7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAkzChG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA2B5B,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;AAa3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAE5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,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;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;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;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA4H7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA0nDhG"}
package/dist/api.js CHANGED
@@ -4,8 +4,9 @@ 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
+ import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
9
10
  // -- request schemas ---------------------------------------------------------
10
11
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
11
12
  // CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
@@ -101,7 +102,13 @@ const auditLogQuery = z.object({
101
102
  action: z.array(adminAction).optional(),
102
103
  since: z.string().optional(),
103
104
  until: z.string().optional(),
104
- limit: z.coerce.number().int().positive().max(1000).optional(),
105
+ // Defaulted, not merely capped: the admin log is append-only and never swept (the
106
+ // retention decision — it is the compliance witness, control-plane.md §4.4/§4.8), so
107
+ // it only grows. An unbounded `GET /admin-log` would dump the whole table; a default
108
+ // page keeps the external read bounded while `nextCursor` still walks the entire log.
109
+ // The KERNEL call stays deliberately unbounded (an in-process caller that wants
110
+ // everything asks for everything) — only this HTTP egress vector is bounded by default.
111
+ limit: z.coerce.number().int().positive().max(1000).default(200),
105
112
  cursor: z.string().optional(),
106
113
  order: z.enum(['asc', 'desc']).optional(),
107
114
  });
@@ -191,6 +198,8 @@ export function createControlPlaneApi(options) {
191
198
  { method: 'GET', re: /\/hostnames$/ },
192
199
  { method: 'POST', re: /\/hostnames$/ },
193
200
  { method: 'PATCH', re: /\/hostnames\/[^/]+\/status$/ },
201
+ // Re-poll issuance ("check again") — self-serve for the scope's own tenant (#305).
202
+ { method: 'POST', re: /\/hostnames\/[^/]+\/verify$/ },
194
203
  { method: 'DELETE', re: /\/hostnames\/[^/]+$/ },
195
204
  ];
196
205
  app.use('*', async (c, next) => {
@@ -209,6 +218,20 @@ export function createControlPlaneApi(options) {
209
218
  return c.json({ error: 'invalid request', issues: err.issues }, 400);
210
219
  }
211
220
  const { status, body } = mapError(err);
221
+ // A 500 is, by definition, a throw whose message `mapError` did not recognise — so the
222
+ // client gets a GENERIC body that discloses nothing, and until now nothing recorded WHAT
223
+ // threw either. That left every unmapped failure (e.g. a raw SQLite constraint from a
224
+ // registry write, or a deploy that 500s with no detail) undiagnosable without reproducing
225
+ // it. Log the real error + the request that provoked it, server-side only, so the worker
226
+ // tail names the cause. Mapped 4xx are honest refusals — no log needed.
227
+ if (status >= 500) {
228
+ console.error('control-plane.unhandled', {
229
+ method: c.req.method,
230
+ path: c.req.path,
231
+ detail: err instanceof Error ? err.message : String(err),
232
+ stack: err instanceof Error ? err.stack : undefined,
233
+ });
234
+ }
212
235
  return c.json(body, status);
213
236
  });
214
237
  // -- tenant registry (§4.1) ------------------------------------------------
@@ -246,6 +269,47 @@ export function createControlPlaneApi(options) {
246
269
  await admin.setTenantStatus(c.get('actor'), tenantId, status);
247
270
  return c.json(await admin.getTenant(c.get('actor'), tenantId));
248
271
  });
272
+ // Reap a DELETING tenant now (control-plane.md §4.8) — the staff "reap now" that skips
273
+ // the grace window, the tenant analogue of the scope reap route below. Refuses a tenant
274
+ // that is not `deleting` (409): a reap only ever follows the reversible delete state, and
275
+ // starting/reversing it is the ordinary `PATCH …/status` transition above. Every scope is
276
+ // reaped FIRST (archive-if-needed → the vertical wipes its co-located DO → `reapScope`,
277
+ // the same storage-before-row ordering the scope route keeps) so no scope's bytes outlive
278
+ // the tenant, then `reapTenant` clears the directory. Staff/service only (not in
279
+ // BUILDER_ROUTES). Idempotent: a crash mid-reap leaves the tenant `deleting`, and a retry
280
+ // (or the grace-window sweep) converges — reaped scopes are skipped, reapTenant re-checks.
281
+ app.post('/tenants/:tenantId/reap', async (c) => {
282
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
283
+ const actor = c.get('actor');
284
+ const tenant = await admin.getTenant(actor, tenantId);
285
+ if (!tenant)
286
+ return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
287
+ if (tenant.status !== 'deleting') {
288
+ return c.json({
289
+ error: `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`,
290
+ }, 409);
291
+ }
292
+ try {
293
+ for (const scope of await admin.listScopes(actor, { tenantId })) {
294
+ if (scope.status === 'reaped')
295
+ continue;
296
+ if (scope.status !== 'archived')
297
+ await admin.archiveScope(actor, tenantId, scope.id);
298
+ const vertical = await verticalForScope(c, scope);
299
+ if (vertical)
300
+ await vertical.deleteScope({ scopeId: scope.id });
301
+ await admin.reapScope(actor, tenantId, scope.id);
302
+ }
303
+ await admin.reapTenant(actor, tenantId);
304
+ return c.json(await admin.getTenant(actor, tenantId));
305
+ }
306
+ catch (e) {
307
+ if (e instanceof ControlPlaneError) {
308
+ return c.json({ error: e.message }, e.status);
309
+ }
310
+ throw e;
311
+ }
312
+ });
249
313
  // -- entitlements (§4.3) ---------------------------------------------------
250
314
  app.get('/tenants/:tenantId/entitlements', async (c) => c.json(await admin.listEntitlements(c.get('actor'), tenantIdSchema.parse(c.req.param('tenantId')))));
251
315
  app.put('/tenants/:tenantId/entitlements/:key', async (c) => {
@@ -356,6 +420,82 @@ export function createControlPlaneApi(options) {
356
420
  }
357
421
  return options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, actor));
358
422
  };
423
+ /**
424
+ * Move ONE legacy scope's data off its per-version dispatch script onto its vertical's
425
+ * stable serving script (#286/#321), then flip routing. The one primitive behind both
426
+ * the explicit `adopt-serving` endpoint and the automatic adoption a prod promote runs.
427
+ *
428
+ * Ordering is data-first: export from the script that holds the data TODAY (the scope's
429
+ * current dispatch — resolved BEFORE any version rebind), restore into the serving
430
+ * script (which re-projects the vertical's roles), and only then `setScopeServingRef` +
431
+ * advance the version pointer. A crash before the flip leaves the scope serving its old
432
+ * script intact, and the adopt retries idempotently. Already-adopted scopes short-circuit.
433
+ * Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
434
+ */
435
+ const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
436
+ const actor = c.get('actor');
437
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
438
+ if (!scope) {
439
+ throw new ControlPlaneError(404, `unknown scope for tenant: (${tenantId}, ${scopeId})`);
440
+ }
441
+ if (scope.servingRef)
442
+ return { servingRef: scope.servingRef, alreadyAdopted: true };
443
+ if (!scope.vertical) {
444
+ throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
445
+ }
446
+ const serving = await admin.verticalServing(actor, scope.vertical);
447
+ if (!serving) {
448
+ throw new ControlPlaneError(409, `vertical '${scope.vertical}' has no serving script yet — promote a version to prod first`);
449
+ }
450
+ const source = await verticalForScope(c, scope);
451
+ const dest = await options.resolveVerticalRef?.(serving.ref);
452
+ if (!source || !dest) {
453
+ throw new ControlPlaneError(501, 'adopt-serving needs dispatch resolution for both ends');
454
+ }
455
+ const dump = await source.exportScope(scopeId);
456
+ const restored = await dest.restoreScope(tenantId, scopeId, dump);
457
+ // Data landed — only now flip routing and move the version pointer.
458
+ await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
459
+ await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
460
+ return { servingRef: serving.ref, tables: restored.tables };
461
+ };
462
+ /**
463
+ * After a prod in-place serve, own the owned-scope adopt+rebind the host cascade
464
+ * delegated to us for a dispatch-backed vertical (#321): adopt any still-legacy scope
465
+ * onto the serving script (data survives), and advance every owned scope's version
466
+ * pointer to the promoted version so Update stops offering a crossing already made.
467
+ *
468
+ * Gated exactly where the host cascade would have run: PRIVATE (owned, unlisted) only,
469
+ * active non-fork scopes only. Runs only when a serving script exists — i.e. the serve
470
+ * actually happened (dispatch-backed + deploy configured); for an embedded vertical the
471
+ * host cascade already rebound, and `verticalServing` is null, so this is a no-op.
472
+ * Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
473
+ * is still findable on a retry after a failed serve.
474
+ */
475
+ const adoptAndRebindOwnedScopes = async (c, slug, versionId) => {
476
+ const actor = c.get('actor');
477
+ const serving = await admin.verticalServing(actor, slug);
478
+ if (!serving)
479
+ return; // embedded / not dispatch-backed — the host cascade handled rebinds
480
+ const v = await verticalOf(actor, slug);
481
+ if (!v || v.ownerTenant === null || v.listed)
482
+ return; // private only, like the host cascade
483
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom);
484
+ for (const s of owned) {
485
+ if (!s.servingRef) {
486
+ // Adopt: export from the scope's current (un-rebound) dispatch → serving script,
487
+ // then bind to the serving version. Data-first, so a failure here leaves the
488
+ // scope intact on its old script for the next promote to retry.
489
+ await adoptScopeOntoServing(c, s.tenantId, s.id);
490
+ }
491
+ else if (s.verticalVersionId !== versionId) {
492
+ // Already on the serving script (born there, or adopted earlier): routing is
493
+ // pinned to servingRef, so advancing the version pointer only affects Update
494
+ // offers. Snapshot on a migration-digest crossing (fork-before-promote, §4).
495
+ await admin.bindScopeVersion(actor, s.tenantId, s.id, versionId, { snapshot: true });
496
+ }
497
+ }
498
+ };
359
499
  app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
360
500
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
361
501
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -367,6 +507,36 @@ export function createControlPlaneApi(options) {
367
507
  ? await vertical.listScopeTables(scopeId)
368
508
  : await admin.listScopeTables(c.get('actor'), tenantId, scopeId));
369
509
  });
510
+ // Scope health (#321, criterion #3). The silent failure the field report chased was an
511
+ // ACTIVE scope serving traffic from a DO whose `_substrat_roles` projection is EMPTY:
512
+ // identity resolves, every permission check denies, and it reads as a per-app 403 rather
513
+ // than a platform condition. Surface it as one. The role count comes from the SAME
514
+ // introspection the Data view uses (the serving script the router actually dispatches to),
515
+ // so it reflects the DO in front of live traffic — reusing existing plumbing rather than
516
+ // a new scope-DO route. `roleProjectionEmpty` on an active scope is the flag a console
517
+ // fleet view raises; a scope whose roles live off-DO (adapter-sqlite's directory) reports
518
+ // a null count and is not flagged.
519
+ app.get('/tenants/:tenantId/scopes/:scopeId/health', async (c) => {
520
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
521
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
522
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
523
+ if (!scope)
524
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
525
+ const vertical = await verticalForScope(c, scope);
526
+ const tables = vertical
527
+ ? await vertical.listScopeTables(scopeId)
528
+ : await admin.listScopeTables(c.get('actor'), tenantId, scopeId);
529
+ const roles = tables.find((t) => t.name === '_substrat_roles');
530
+ const roleCount = roles ? roles.rowCount : null;
531
+ const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
532
+ return c.json({
533
+ scopeId,
534
+ status: scope.status,
535
+ servingRef: scope.servingRef ?? null,
536
+ roleCount,
537
+ roleProjectionEmpty,
538
+ });
539
+ });
370
540
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
371
541
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
372
542
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -544,6 +714,39 @@ export function createControlPlaneApi(options) {
544
714
  throw e;
545
715
  }
546
716
  });
717
+ // Reap an ARCHIVED primary scope (control-plane.md §4.4): free its DO storage —
718
+ // Cloudflare never garbage-collects a Durable Object, so a deleted app's bytes persist
719
+ // forever otherwise — while keeping the directory row as a tombstone. A POST verb, not
720
+ // DELETE: DELETE means "remove the record" (and is already the fork hard-delete above),
721
+ // whereas reap KEEPS the row and just moves it to `reaped`. Staff/service only — not in
722
+ // BUILDER_ROUTES. The archived-only refusal is surfaced HERE before any delegation (the
723
+ // vertical must never be asked to wipe a live scope) and re-checked below the seam by
724
+ // reapScope. Same storage-before-row ordering as deleteSnapshot: the vertical wipes its
725
+ // co-located DO first, then the in-process reapScope flips the status and audits.
726
+ app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
727
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
728
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
729
+ const actor = c.get('actor');
730
+ const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
731
+ if (!scope)
732
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
733
+ if (scope.status !== 'archived') {
734
+ return c.json({ error: `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped` }, 409);
735
+ }
736
+ try {
737
+ const vertical = await verticalForScope(c, scope);
738
+ if (vertical)
739
+ await vertical.deleteScope({ scopeId });
740
+ await admin.reapScope(actor, tenantId, scopeId);
741
+ return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
742
+ }
743
+ catch (e) {
744
+ if (e instanceof ControlPlaneError) {
745
+ return c.json({ error: e.message }, e.status);
746
+ }
747
+ throw e;
748
+ }
749
+ });
547
750
  // The governed pull (preview-and-snapshots.md §6/§8) — the ONE route that
548
751
  // deliberately hands scope BYTES to the caller, which is why every §6 layer sits
549
752
  // on it: staff-only (not in BUILDER_ROUTES), K-3 cross-checked, K-24 audited (the
@@ -601,6 +804,11 @@ export function createControlPlaneApi(options) {
601
804
  if (!scope)
602
805
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
603
806
  const dump = scopeDump.parse(await c.req.json());
807
+ // A backup with no tables is not a scope dump — name that plainly rather than letting
808
+ // the empty replay reach the checker and surface as a bare `internal error` (#321).
809
+ if (dump.tables.length === 0) {
810
+ return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
811
+ }
604
812
  try {
605
813
  await host.restoreScope(actor, tenantId, scopeId, dump);
606
814
  const vertical = await verticalForScope(c, scope);
@@ -612,7 +820,13 @@ export function createControlPlaneApi(options) {
612
820
  if (e instanceof ControlPlaneError) {
613
821
  return c.json({ error: e.message }, e.status);
614
822
  }
615
- throw e;
823
+ // A restore throw is driven by the caller-supplied dump (a shape the target cannot
824
+ // load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
825
+ // collapsing to the generic 500 `internal error` mapError would produce (#321,
826
+ // secondary obs #2). The route is staff/owner-gated and the detail is about the
827
+ // dump the caller sent, not another tenant's state.
828
+ const detail = e instanceof Error ? e.message : String(e);
829
+ return c.json({ error: 'restore failed — the backup could not be loaded', detail }, 422);
616
830
  }
617
831
  });
618
832
  // #286: the PITR bookmarks a scope recorded before its migration passes — the
@@ -664,50 +878,61 @@ export function createControlPlaneApi(options) {
664
878
  throw e;
665
879
  }
666
880
  });
667
- // #286: adopt a LEGACY scope onto its vertical's stable serving script — the
668
- // one-time data hop off per-version dispatch. Export from the script that holds
669
- // the data today (the bound version's), restore into the serving script (which
670
- // re-projects the vertical's roles), then flip routing. Ordering is data-first:
671
- // a crash before the flip leaves the scope serving from its old script, intact,
672
- // and the adopt retries idempotently. The scope's version pointer moves to the
673
- // serving version in the same act, so Update stops offering a crossing it
674
- // already made.
881
+ // #286/#321: adopt a LEGACY scope onto its vertical's stable serving script — the
882
+ // one-time data hop off per-version dispatch, and the builder-triggerable backfill for
883
+ // installs that predate the in-place serve. The whole body lives in
884
+ // `adoptScopeOntoServing` (shared with the automatic adoption a prod promote runs);
885
+ // here it is just mapped to a JSON response.
675
886
  app.post('/tenants/:tenantId/scopes/:scopeId/adopt-serving', async (c) => {
676
887
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
677
888
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
678
- const actor = c.get('actor');
679
- const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
680
- if (!scope)
681
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
682
- if (scope.servingRef) {
683
- return c.json({ adopted: scopeId, servingRef: scope.servingRef, alreadyAdopted: true });
889
+ try {
890
+ const r = await adoptScopeOntoServing(c, tenantId, scopeId);
891
+ return c.json({ adopted: scopeId, ...r });
684
892
  }
685
- if (!scope.vertical) {
686
- return c.json({ error: 'scope has no vertical — nothing to adopt onto' }, 409);
893
+ catch (e) {
894
+ if (e instanceof ControlPlaneError) {
895
+ return c.json({ error: e.message }, e.status);
896
+ }
897
+ throw e;
687
898
  }
688
- const serving = await admin.verticalServing(actor, scope.vertical);
689
- if (!serving) {
690
- return c.json({ error: `vertical '${scope.vertical}' has no serving script yet promote a version to prod first` }, 409);
899
+ });
900
+ // Backfill EVERY still-legacy active scope of a vertical in one call — the vertical-wide
901
+ // trigger for an install that predates the in-place serve. Owner or staff (owned-slug
902
+ // checked by the confinement middleware). Idempotent: already-adopted scopes are skipped
903
+ // and reported. A per-scope failure stops the run and surfaces which scope failed, so a
904
+ // re-run resumes from there (each adopt is data-first and retry-safe).
905
+ app.post('/verticals/:slug/adopt-serving', async (c) => {
906
+ const p = c.get('principal');
907
+ const slug = effectiveSlug(p, c.req.param('slug'));
908
+ const actor = c.get('actor');
909
+ if (p.kind === 'builder') {
910
+ const v = await verticalOf(actor, slug);
911
+ if (!v || v.ownerTenant !== p.tenantId)
912
+ return c.json({ error: 'forbidden' }, 403);
691
913
  }
692
- const source = await verticalForScope(c, scope);
693
- const dest = await options.resolveVerticalRef?.(serving.ref);
694
- if (!source || !dest) {
695
- return c.json({ error: 'adopt-serving needs dispatch resolution for both ends' }, 501);
914
+ const v = await verticalOf(actor, slug);
915
+ if (!v)
916
+ return c.json({ error: `unknown vertical '${slug}'` }, 404);
917
+ if (v.ownerTenant === null) {
918
+ return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
696
919
  }
920
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.status === 'active');
921
+ const adopted = [];
922
+ const alreadyAdopted = [];
697
923
  try {
698
- const dump = await source.exportScope(scopeId);
699
- const restored = await dest.restoreScope(tenantId, scopeId, dump);
700
- // Data landed only now flip routing and move the version pointer.
701
- await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
702
- await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
703
- return c.json({ adopted: scopeId, servingRef: serving.ref, tables: restored.tables });
924
+ for (const s of owned) {
925
+ const r = await adoptScopeOntoServing(c, s.tenantId, s.id);
926
+ (r.alreadyAdopted ? alreadyAdopted : adopted).push(s.id);
927
+ }
704
928
  }
705
929
  catch (e) {
706
930
  if (e instanceof ControlPlaneError) {
707
- return c.json({ error: e.message }, e.status);
931
+ return c.json({ error: e.message, adopted, alreadyAdopted }, e.status);
708
932
  }
709
933
  throw e;
710
934
  }
935
+ return c.json({ vertical: slug, adopted, alreadyAdopted });
711
936
  });
712
937
  // Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
713
938
  // non-admitted version below the seam — that refusal is the registry's reason to
@@ -1037,6 +1262,12 @@ export function createControlPlaneApi(options) {
1037
1262
  if (channel === 'prod') {
1038
1263
  try {
1039
1264
  await serveVersionInPlace(c.get('actor'), slug, versionId);
1265
+ // Adopt any still-legacy owned scope onto the serving script and advance every
1266
+ // owned scope's version — the rebind the host cascade delegated to us for a
1267
+ // dispatch-backed vertical (#321), in the correct order (serve → adopt → rebind),
1268
+ // so a legacy scope's data survives the promote instead of being stranded on a
1269
+ // fresh per-version script. Retry-safe: nothing rebound these scopes yet.
1270
+ await adoptAndRebindOwnedScopes(c, slug, versionId);
1040
1271
  }
1041
1272
  catch (e) {
1042
1273
  const detail = e instanceof Error ? e.message : String(e);
@@ -1147,13 +1378,22 @@ export function createControlPlaneApi(options) {
1147
1378
  });
1148
1379
  }
1149
1380
  catch (e) {
1150
- // The upload to the runtime failed (e.g. Cloudflare rejected the script). This is
1151
- // a platform/runtime error, not a bad request — surface the detail (the builder is
1381
+ // The upload to the runtime failed. Surface the detail (the builder is
1152
1382
  // authenticated) rather than the anonymous 500 the generic handler would give, so a
1153
- // push failure is diagnosable without reading worker logs.
1383
+ // push failure is diagnosable without reading worker logs. The version label is NOT
1384
+ // consumed here: registration/publish happen only AFTER a successful upload (below),
1385
+ // so a failed push leaves the same --version reusable (#307).
1386
+ //
1387
+ // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
1388
+ // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
1389
+ // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
1390
+ // throw with no upstream status) is a platform failure and stays a 502.
1154
1391
  const detail = e instanceof Error ? e.message : String(e);
1155
- console.error('deploy.upload.failed', { slug, deploymentRef, detail });
1156
- return c.json({ error: 'deploy upload failed', detail }, 502);
1392
+ const upstream = upstreamStatusOf(e);
1393
+ console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
1394
+ return upstream !== undefined && upstream >= 400 && upstream < 500
1395
+ ? c.json({ error: 'deploy rejected', detail }, 422)
1396
+ : c.json({ error: 'deploy upload failed', detail }, 502);
1157
1397
  }
1158
1398
  // Register-then-publish, both idempotent-ish below the seam: a first push of a
1159
1399
  // slug registers it; publishVersion lands the version pending with deploymentRef
@@ -1291,6 +1531,53 @@ export function createControlPlaneApi(options) {
1291
1531
  filter.tenantId = p.tenantId;
1292
1532
  return c.json(await admin.listHostnames(c.get('actor'), filter));
1293
1533
  });
1534
+ // Which base domains ride the platform wildcard cert (go straight to `active`);
1535
+ // everything else is a custom domain that walks Cloudflare-for-SaaS issuance (§4.7).
1536
+ const platformBaseDomains = options.platformBaseDomains ?? [];
1537
+ const custom = (hostname) => isCustomHostname(hostname, platformBaseDomains);
1538
+ /**
1539
+ * Drive a freshly-bound (or re-checked) hostname through issuance (§4.7):
1540
+ *
1541
+ * - a PLATFORM mint rides the wildcard cert → flip straight to `active`;
1542
+ * - a CUSTOM domain, when a provisioner is configured → `create` (on first bind) or
1543
+ * `check` (a re-verify), persisting status + DNS records + the CF id;
1544
+ * - a custom domain with NO provisioner (self-host/dev) → left `pending`.
1545
+ *
1546
+ * Failures are swallowed to a `failed`/`pending` note rather than thrown: a bind that
1547
+ * recorded the row must still return 201, and the reconcile sweep retries.
1548
+ */
1549
+ const runIssuance = async (actor, row) => {
1550
+ if (!custom(row.hostname)) {
1551
+ // Platform mint: rides *.<base> — no per-hostname CF object, immediately servable.
1552
+ if (row.status !== 'active')
1553
+ await admin.setHostnameStatus(actor, row.hostname, 'active');
1554
+ return;
1555
+ }
1556
+ if (!options.provisionHostname)
1557
+ return; // no CF-for-SaaS zone here; stays pending
1558
+ try {
1559
+ const issuance = row.customHostnameId
1560
+ ? await options.provisionHostname.check(row.customHostnameId)
1561
+ : await options.provisionHostname.create(row.hostname);
1562
+ await admin.setHostnameIssuance(actor, row.hostname, {
1563
+ status: issuance.status,
1564
+ note: issuance.note,
1565
+ // Only write the id on create; a re-check leaves it untouched (undefined).
1566
+ customHostnameId: row.customHostnameId ? undefined : issuance.customHostnameId,
1567
+ validationRecords: issuance.records,
1568
+ });
1569
+ }
1570
+ catch (err) {
1571
+ // Record the failure as a note but keep the row — the sweep re-attempts. Never
1572
+ // turn a transient CF error into a lost binding.
1573
+ await admin.setHostnameIssuance(actor, row.hostname, {
1574
+ status: 'failed',
1575
+ note: err instanceof Error ? err.message : String(err),
1576
+ validationRecords: row.validationRecords,
1577
+ });
1578
+ }
1579
+ };
1580
+ const hostnameRow = async (c, name) => (await admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
1294
1581
  app.post('/hostnames', async (c) => {
1295
1582
  const p = c.get('principal');
1296
1583
  const input = bindHostnameBody.parse(await c.req.json());
@@ -1304,9 +1591,36 @@ export function createControlPlaneApi(options) {
1304
1591
  return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
1305
1592
  }
1306
1593
  }
1594
+ // Registrable-suffix guard (#305, D-35): a custom domain must be a real registrable
1595
+ // name, never a bare public suffix whose cookie would span tenants. Platform mints
1596
+ // skip it — they are the platform's own registrable domain by construction.
1597
+ if (custom(input.hostname)) {
1598
+ const bad = validateBindableHostname(input.hostname);
1599
+ if (bad)
1600
+ return c.json({ error: bad }, 422);
1601
+ }
1307
1602
  await admin.bindHostname(c.get('actor'), input);
1308
1603
  const bound = (await admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
1309
- return c.json(bound, 201);
1604
+ if (bound)
1605
+ await runIssuance(c.get('actor'), bound);
1606
+ // Re-read so the response carries the post-issuance status + DNS records the caller
1607
+ // (dashboard / CLI) renders — a custom bind comes back `verifying` with records.
1608
+ const out = (await admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
1609
+ return c.json(out ?? bound, 201);
1610
+ });
1611
+ // Re-poll a custom hostname's issuance ("check again" in the dashboard). Tenant-narrowed
1612
+ // like the other hostname routes; a platform mint or an already-active row is a no-op
1613
+ // re-affirm. Idempotent and safe to hammer — it just reflects Cloudflare's current state.
1614
+ app.post('/hostnames/:hostname/verify', async (c) => {
1615
+ const name = c.req.param('hostname');
1616
+ if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
1617
+ return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
1618
+ }
1619
+ const row = await hostnameRow(c, name);
1620
+ if (!row)
1621
+ return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
1622
+ await runIssuance(c.get('actor'), row);
1623
+ return c.json(await hostnameRow(c, name));
1310
1624
  });
1311
1625
  app.patch('/hostnames/:hostname/status', async (c) => {
1312
1626
  const { status, note } = setHostnameStatusBody.parse(await c.req.json());
@@ -1330,6 +1644,14 @@ export function createControlPlaneApi(options) {
1330
1644
  if (c.get('principal').kind === 'builder' && !(await tenantHostname(c, name))) {
1331
1645
  return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
1332
1646
  }
1647
+ // Release the Cloudflare custom hostname before dropping the row, or the CF object
1648
+ // leaks (billable, and it would block a future rebind of the same name). Best-effort:
1649
+ // a CF failure must not strand the unbind — the row still goes, and a leaked CF
1650
+ // hostname is a cleanup nuisance, not a routing hazard. `remove` already tolerates 404.
1651
+ const row = await hostnameRow(c, name);
1652
+ if (row?.customHostnameId && options.provisionHostname) {
1653
+ await options.provisionHostname.remove(row.customHostnameId).catch(() => { });
1654
+ }
1333
1655
  await admin.unbindHostname(c.get('actor'), name);
1334
1656
  return c.json({ deleted: name.toLowerCase() });
1335
1657
  });