@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.d.ts.map +1 -1
- package/dist/api.js +408 -66
- package/dist/api.js.map +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +5 -2
- package/dist/client.js.map +1 -1
- package/dist/deploy.d.ts +40 -19
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +72 -24
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/vertical-client.d.ts +11 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js.map +1 -1
- package/dist/wfp.d.ts +12 -1
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +43 -7
- package/dist/wfp.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
657
|
-
//
|
|
658
|
-
//
|
|
659
|
-
//
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
674
|
-
|
|
876
|
+
catch (e) {
|
|
877
|
+
if (e instanceof ControlPlaneError) {
|
|
878
|
+
return c.json({ error: e.message }, e.status);
|
|
879
|
+
}
|
|
880
|
+
throw e;
|
|
675
881
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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(
|
|
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).
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
|
|
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
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
-
//
|
|
1095
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1183
|
-
//
|
|
1184
|
-
//
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
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
|
|
1211
|
-
// below the seam, idempotent: an unknown hostname deletes
|
|
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
|
});
|