@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 +18 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +361 -39
- package/dist/api.js.map +1 -1
- package/dist/custom-hostnames.d.ts +163 -0
- package/dist/custom-hostnames.d.ts.map +1 -0
- package/dist/custom-hostnames.js +193 -0
- package/dist/custom-hostnames.js.map +1 -0
- package/dist/deploy.d.ts +14 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +20 -0
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/wfp.d.ts +9 -0
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +17 -4
- package/dist/wfp.js.map +1 -1
- package/package.json +5 -4
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;
|
|
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
|
-
|
|
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
|
|
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
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
686
|
-
|
|
893
|
+
catch (e) {
|
|
894
|
+
if (e instanceof ControlPlaneError) {
|
|
895
|
+
return c.json({ error: e.message }, e.status);
|
|
896
|
+
}
|
|
897
|
+
throw e;
|
|
687
898
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
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
|
});
|