@substrat-run/control-plane-api 0.178.0 → 0.179.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/aggregate-reads.d.ts.map +1 -1
- package/dist/aggregate-reads.js +4 -3
- package/dist/aggregate-reads.js.map +1 -1
- package/dist/aggregate-source.d.ts +8 -1
- package/dist/aggregate-source.d.ts.map +1 -1
- package/dist/aggregate-source.js +2 -1
- package/dist/aggregate-source.js.map +1 -1
- package/dist/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +223 -148
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +28 -5
- package/dist/backups.d.ts.map +1 -1
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +26 -16
- package/dist/cf-observability.js.map +1 -1
- package/dist/observability.d.ts +6 -1
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +8 -1
- package/dist/observability.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +1 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +2 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +20 -22
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +16 -19
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +49 -13
- package/dist/r2-backups.js.map +1 -1
- package/dist/store-entry.generated.d.ts +2 -2
- package/dist/store-entry.generated.d.ts.map +1 -1
- package/dist/store-entry.generated.js +5 -5
- package/dist/store-entry.generated.js.map +1 -1
- package/dist/store-move-ends.d.ts +59 -0
- package/dist/store-move-ends.d.ts.map +1 -0
- package/dist/store-move-ends.js +114 -0
- package/dist/store-move-ends.js.map +1 -0
- package/dist/store-move.d.ts +65 -0
- package/dist/store-move.d.ts.map +1 -0
- package/dist/store-move.js +107 -0
- package/dist/store-move.js.map +1 -0
- package/dist/tenant-request-query.d.ts.map +1 -1
- package/dist/tenant-request-query.js +5 -0
- package/dist/tenant-request-query.js.map +1 -1
- package/package.json +8 -8
package/dist/api.js
CHANGED
|
@@ -14,7 +14,7 @@ import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
|
14
14
|
import { mintTenantToken } from './tenant-token.js';
|
|
15
15
|
import { abandonCopy, beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
|
|
16
16
|
import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
|
|
17
|
-
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
17
|
+
import { exportStoreDumps, runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
18
18
|
import { liveApprovalOf } from './store-script.js';
|
|
19
19
|
import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
|
|
20
20
|
import { PUSH_HOLD_TTL_MS, STORE_RELEASE_STORED_MAX_BYTES, approveStoredDriverRelease, readStoreDriverApprovals, runStoreDriverRevoke, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
|
|
@@ -68,6 +68,8 @@ import { mintPushToken, pushActorFor } from './push-token.js';
|
|
|
68
68
|
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS, LOGS_INCOMPLETE_HEADER } from './observability.js';
|
|
69
69
|
import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, refuseSearchOnCounts, parseTenantLineQuery, REQUEST_WHERE_VALUES_MAX, scriptFamiliesOfScopes, } from './tenant-request-query.js';
|
|
70
70
|
import { namespacesForScript } from './do-namespaces.js';
|
|
71
|
+
import { discardMoves, moveStores } from './store-move.js';
|
|
72
|
+
import { backupEnds, commitStages, inlineStoreSource, restoreEnds, snapshotEnds, StoreLandFailure, storeHoldsRows, } from './store-move-ends.js';
|
|
71
73
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
72
74
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
73
75
|
import { extractedMigrationsDigest, extractedMigrationsProblem, extractedMigrationsRefusal, MigrationExtractionError, MigrationLoaderUnavailable, } from './migration-extract.js';
|
|
@@ -1016,7 +1018,7 @@ export function createControlPlaneApi(options) {
|
|
|
1016
1018
|
/** `substrat.wipe` on each declared store, before the scope's own bytes go. */
|
|
1017
1019
|
const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
|
|
1018
1020
|
/**
|
|
1019
|
-
*
|
|
1021
|
+
* The stores a backup, a full pull or a fork copies: those the scope's running version declares.
|
|
1020
1022
|
*
|
|
1021
1023
|
* A dump is only worth taking if it restores, and a restore imports only what the destination's
|
|
1022
1024
|
* version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
|
|
@@ -1025,24 +1027,29 @@ export function createControlPlaneApi(options) {
|
|
|
1025
1027
|
* way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
|
|
1026
1028
|
* never export, so they reach such a store regardless.
|
|
1027
1029
|
*/
|
|
1028
|
-
const
|
|
1030
|
+
const platformStoreReach = async (c, scope) => {
|
|
1029
1031
|
const caller = options.platformScopeStores;
|
|
1030
1032
|
if (!caller || !scope.vertical)
|
|
1031
1033
|
return undefined;
|
|
1032
1034
|
const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
|
|
1033
1035
|
if (all.length === 0)
|
|
1034
1036
|
return undefined;
|
|
1035
|
-
const
|
|
1037
|
+
const place = { tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical };
|
|
1036
1038
|
const current = new Set(running.map((store) => store.binding));
|
|
1037
|
-
const dropped
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
throw new ControlPlaneError(409, `scope ${scope.id}'s store '${
|
|
1039
|
+
for (const dropped of all.filter((store) => !current.has(store.binding))) {
|
|
1040
|
+
if (!(await storeHoldsRows(caller, place, dropped)))
|
|
1041
|
+
continue;
|
|
1042
|
+
throw new ControlPlaneError(409, `scope ${scope.id}'s store '${dropped.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
|
|
1041
1043
|
`still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
|
|
1042
|
-
`restored. Declare '${
|
|
1044
|
+
`restored. Declare '${dropped.binding}' as a '${dropped.driver}' again in a version the scope runs, then export; ` +
|
|
1043
1045
|
`or reap the scope without a backup, which wipes it`);
|
|
1044
1046
|
}
|
|
1045
|
-
return
|
|
1047
|
+
return { caller, place, stores: running };
|
|
1048
|
+
};
|
|
1049
|
+
/** The stores a pull or a tenant export answers inline, each read whole (`exportStoreDumps`). */
|
|
1050
|
+
const exportPlatformStores = async (c, scope) => {
|
|
1051
|
+
const reach = await platformStoreReach(c, scope);
|
|
1052
|
+
return reach && exportStoreDumps(reach.caller, reach.place, reach.stores);
|
|
1046
1053
|
};
|
|
1047
1054
|
/**
|
|
1048
1055
|
* `substrat.erase` on the destination's stores for each subject tombstoned in the destination
|
|
@@ -1264,13 +1271,23 @@ export function createControlPlaneApi(options) {
|
|
|
1264
1271
|
// #977: a tenant credential minted for a person writes through a view that names
|
|
1265
1272
|
// them on every admin row. Only the credential's own claim can supply the person —
|
|
1266
1273
|
// never a header or a body — and only the staff mint writes that claim.
|
|
1267
|
-
const view = principal
|
|
1268
|
-
? host.attributed(principal.onBehalfOf)
|
|
1269
|
-
: host;
|
|
1274
|
+
const view = viewOf(principal);
|
|
1270
1275
|
c.set('host', view);
|
|
1271
1276
|
c.set('admin', view.admin);
|
|
1272
1277
|
await next();
|
|
1273
1278
|
});
|
|
1279
|
+
/**
|
|
1280
|
+
* #977: the host a principal's request writes through — the attributed view for a tenant
|
|
1281
|
+
* credential minted for a person, the configured host otherwise. The middleware sets it on
|
|
1282
|
+
* the request; a helper handed only the principal derives the same view here (#2860), so its
|
|
1283
|
+
* access rows name the person too.
|
|
1284
|
+
*/
|
|
1285
|
+
function viewOf(principal) {
|
|
1286
|
+
return principal.kind === 'tenant' && principal.onBehalfOf && host.attributed
|
|
1287
|
+
? host.attributed(principal.onBehalfOf)
|
|
1288
|
+
: host;
|
|
1289
|
+
}
|
|
1290
|
+
const adminFor = (principal) => viewOf(principal).admin;
|
|
1274
1291
|
// Confine a BUILDER to the vertical-management surface, fail-CLOSED (builder-plane.md
|
|
1275
1292
|
// §4). Default-deny by design: a builder reaches only the routes on this allowlist,
|
|
1276
1293
|
// and only for verticals its tenant owns (the per-route ownership checks below). A
|
|
@@ -1547,7 +1564,7 @@ export function createControlPlaneApi(options) {
|
|
|
1547
1564
|
const vertical = await resolveVerticalId(c, raw);
|
|
1548
1565
|
const owner = (pathTenant ?? confined)
|
|
1549
1566
|
? null
|
|
1550
|
-
: ((await verticalOf(c.get('actor'), vertical))?.ownerTenant ?? null);
|
|
1567
|
+
: ((await verticalOf(c.var.admin, c.get('actor'), vertical))?.ownerTenant ?? null);
|
|
1551
1568
|
return { vertical, tenantId: pathTenant ?? confined ?? owner };
|
|
1552
1569
|
}
|
|
1553
1570
|
catch {
|
|
@@ -2949,7 +2966,7 @@ export function createControlPlaneApi(options) {
|
|
|
2949
2966
|
resolveVertical: options.resolveVertical,
|
|
2950
2967
|
resolveVerticalVersion: options.resolveVerticalVersion,
|
|
2951
2968
|
resolveVerticalRef: options.resolveVerticalRef,
|
|
2952
|
-
ownerOf,
|
|
2969
|
+
ownerOf: (actor, slug) => ownerOf(c.var.admin, actor, slug),
|
|
2953
2970
|
tenantSlugOf: async (actor, tenantId) => (await c.var.admin.getTenant(actor, tenantId).catch(() => null))?.slug ?? null,
|
|
2954
2971
|
}, c.get('actor'), scope);
|
|
2955
2972
|
/**
|
|
@@ -2964,11 +2981,11 @@ export function createControlPlaneApi(options) {
|
|
|
2964
2981
|
* exact split so it is a seconds-long diagnosis, not a multi-hour hunt. A couple of
|
|
2965
2982
|
* directory reads only when a call has already failed to resolve.
|
|
2966
2983
|
*/
|
|
2967
|
-
const diagnoseUnboundScope = async (actor, scope) => {
|
|
2984
|
+
const diagnoseUnboundScope = async (admin, actor, scope) => {
|
|
2968
2985
|
const slug = scope.vertical;
|
|
2969
2986
|
if (!slug)
|
|
2970
2987
|
return 'scope has no vertical bound — nothing to deliver to';
|
|
2971
|
-
const versions = await
|
|
2988
|
+
const versions = await admin.listVersions(actor, slug).catch(() => []);
|
|
2972
2989
|
const boundId = scope.verticalVersionId;
|
|
2973
2990
|
if (boundId && !versions.some((v) => v.id === boundId)) {
|
|
2974
2991
|
return (`scope is bound to version '${boundId}', which is not among vertical '${slug}'’s ${versions.length} ` +
|
|
@@ -2981,7 +2998,7 @@ export function createControlPlaneApi(options) {
|
|
|
2981
2998
|
`pushes are on different slugs (a lineage fork). Push under '${slug}' by setting \`substrat.slug\`, ` +
|
|
2982
2999
|
`or rebind the install to the slug the versions live under.`);
|
|
2983
3000
|
}
|
|
2984
|
-
const serving = await
|
|
3001
|
+
const serving = await admin.verticalServing(actor, slug).catch(() => null);
|
|
2985
3002
|
if (!serving) {
|
|
2986
3003
|
return `vertical '${slug}' has ${versions.length} version(s) but none is promoted to a serving channel — promote one to prod.`;
|
|
2987
3004
|
}
|
|
@@ -2995,10 +3012,10 @@ export function createControlPlaneApi(options) {
|
|
|
2995
3012
|
* `servingRef` is the route the scope takes in the same act (an adopt). The host's read also
|
|
2996
3013
|
* refuses, whatever is acknowledged, a move that strands people a link bound (#2178).
|
|
2997
3014
|
*/
|
|
2998
|
-
const refuseOnBreaks = async (actor, tenantId, scopeId, versionId, opts) => {
|
|
3015
|
+
const refuseOnBreaks = async (admin, actor, tenantId, scopeId, versionId, opts) => {
|
|
2999
3016
|
// Asked even when acknowledged: the read also refuses a move that strands people a link
|
|
3000
3017
|
// bound (#2178), which no acknowledgement passes.
|
|
3001
|
-
const breaks = await
|
|
3018
|
+
const breaks = await admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
|
|
3002
3019
|
if (opts.acknowledge?.exportBreak)
|
|
3003
3020
|
return;
|
|
3004
3021
|
if (breaks.length > 0)
|
|
@@ -3030,7 +3047,7 @@ export function createControlPlaneApi(options) {
|
|
|
3030
3047
|
* as it stood before the failed attempt. Every caller still re-asserts after it (the
|
|
3031
3048
|
* fallback), passing what this reports.
|
|
3032
3049
|
*/
|
|
3033
|
-
const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables,
|
|
3050
|
+
const restoreCarryingSwitches = async (admin, actor, dest, tenantId, scopeId, tables,
|
|
3034
3051
|
/** #1869: the scope `tables` were captured from — `scopeId` itself unless the copy moves —
|
|
3035
3052
|
* and `exact` when the platform exported them itself rather than a caller supplying them.
|
|
3036
3053
|
* #1722: `loadStamp`, the stamp a carry leaves on the copy it lands, and `expect`, the marker
|
|
@@ -3040,10 +3057,10 @@ export function createControlPlaneApi(options) {
|
|
|
3040
3057
|
// the destination is not primary, the vertical marks it a copy in its own storage — a carry
|
|
3041
3058
|
// of a copy made before every copy carried the marker brings none, and a CP-less host reads
|
|
3042
3059
|
// the marker for primacy. Idempotent there; an install is never marked.
|
|
3043
|
-
const rec = await
|
|
3060
|
+
const rec = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3044
3061
|
const markCopy = rec ? copyLineageOf(rec) : undefined;
|
|
3045
3062
|
return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, {
|
|
3046
|
-
...(await switchCarryFor(
|
|
3063
|
+
...(await switchCarryFor(admin, actor, { tenantId, scopeId })),
|
|
3047
3064
|
sourceScopeId: source.scopeId,
|
|
3048
3065
|
exact: source.exact,
|
|
3049
3066
|
loadStamp: source.loadStamp,
|
|
@@ -3077,7 +3094,7 @@ export function createControlPlaneApi(options) {
|
|
|
3077
3094
|
}
|
|
3078
3095
|
// #1756: onto the serving script, the scope runs the served version from the moment routing
|
|
3079
3096
|
// flips — whatever its own version exported. Asked before the export below moves anything.
|
|
3080
|
-
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
3097
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
|
|
3081
3098
|
servingRef: serving.ref,
|
|
3082
3099
|
acknowledge: opts.acknowledge,
|
|
3083
3100
|
});
|
|
@@ -3120,7 +3137,7 @@ export function createControlPlaneApi(options) {
|
|
|
3120
3137
|
const serving = await c.var.admin.verticalServing(actor, slug);
|
|
3121
3138
|
if (!serving)
|
|
3122
3139
|
return none; // embedded / not dispatch-backed — the host cascade handled rebinds
|
|
3123
|
-
const v = await verticalOf(actor, slug);
|
|
3140
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
3124
3141
|
if (!v || v.ownerTenant === null || v.listed)
|
|
3125
3142
|
return none; // private only, like the host cascade
|
|
3126
3143
|
const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
|
|
@@ -3218,7 +3235,7 @@ export function createControlPlaneApi(options) {
|
|
|
3218
3235
|
const { review, ...bindAck } = opts.acknowledge ?? {};
|
|
3219
3236
|
// #1756: within one lineage this is an adopt, and judged as one before anything moves. A
|
|
3220
3237
|
// crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
|
|
3221
|
-
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
3238
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
|
|
3222
3239
|
servingRef: serving.ref,
|
|
3223
3240
|
acknowledge: bindAck,
|
|
3224
3241
|
});
|
|
@@ -3324,7 +3341,7 @@ export function createControlPlaneApi(options) {
|
|
|
3324
3341
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, scopeId);
|
|
3325
3342
|
// #2450: a page at a time into the destination's stage, so a scope of any size moves.
|
|
3326
3343
|
const { dump, loadStamp: sourceStamp, revision: sourceRevision } = await transferScope(source, dest, { from: scopeId }, { retry: retryTransient });
|
|
3327
|
-
const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, {
|
|
3344
|
+
const restored = await restoreCarryingSwitches(c.var.admin, actor, dest, tenantId, scopeId, dump, {
|
|
3328
3345
|
scopeId, exact: true, loadStamp: restoredStamp,
|
|
3329
3346
|
...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
|
|
3330
3347
|
});
|
|
@@ -3749,7 +3766,7 @@ export function createControlPlaneApi(options) {
|
|
|
3749
3766
|
throw new ControlPlaneError(412, `the copy of scope ${scope.id} in '${from}' was already carried to another script and wiped, so nothing ` +
|
|
3750
3767
|
`was moved or bound (#1722). Reload the scope and retry.`);
|
|
3751
3768
|
}
|
|
3752
|
-
const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump, {
|
|
3769
|
+
const restored = await restoreCarryingSwitches(c.var.admin, actor, dest, scope.tenantId, scope.id, dump, {
|
|
3753
3770
|
scopeId: scope.id,
|
|
3754
3771
|
exact: true,
|
|
3755
3772
|
loadStamp: restoredStamp,
|
|
@@ -4116,7 +4133,7 @@ export function createControlPlaneApi(options) {
|
|
|
4116
4133
|
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, recarryId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
4117
4134
|
try {
|
|
4118
4135
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
|
|
4119
|
-
await restoreCarryingSwitches(actor, carried.dest, scope.tenantId, scope.id, dump, {
|
|
4136
|
+
await restoreCarryingSwitches(c.var.admin, actor, carried.dest, scope.tenantId, scope.id, dump, {
|
|
4120
4137
|
scopeId: scope.id,
|
|
4121
4138
|
exact: true,
|
|
4122
4139
|
loadStamp: restoredStamp,
|
|
@@ -4282,13 +4299,13 @@ export function createControlPlaneApi(options) {
|
|
|
4282
4299
|
const p = c.get('principal');
|
|
4283
4300
|
const scopePin = confinedTenant(p);
|
|
4284
4301
|
if (scopePin) {
|
|
4285
|
-
const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
|
|
4302
|
+
const v = scope.vertical ? await verticalOf(c.var.admin, actor, scope.vertical) : undefined;
|
|
4286
4303
|
if (!v || v.ownerTenant !== scopePin)
|
|
4287
4304
|
return refuse(c, 'forbidden', 'forbidden');
|
|
4288
4305
|
}
|
|
4289
4306
|
const reached = await deploymentForScope(c, scope);
|
|
4290
4307
|
if (!reached)
|
|
4291
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
4308
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
4292
4309
|
const vertical = reached.client;
|
|
4293
4310
|
const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
|
|
4294
4311
|
// #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
|
|
@@ -4831,7 +4848,7 @@ export function createControlPlaneApi(options) {
|
|
|
4831
4848
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4832
4849
|
const vertical = await verticalForScope(c, scope);
|
|
4833
4850
|
if (!vertical)
|
|
4834
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
4851
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
4835
4852
|
try {
|
|
4836
4853
|
return c.json(await vertical.ownerSeat(tenantId, scopeId));
|
|
4837
4854
|
}
|
|
@@ -4867,7 +4884,7 @@ export function createControlPlaneApi(options) {
|
|
|
4867
4884
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4868
4885
|
const vertical = await verticalForScope(c, scope);
|
|
4869
4886
|
if (!vertical)
|
|
4870
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
4887
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
4871
4888
|
const origin = await instanceOrigin(c.var.admin, actor, scopeId);
|
|
4872
4889
|
if (!origin) {
|
|
4873
4890
|
return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
|
|
@@ -4908,7 +4925,7 @@ export function createControlPlaneApi(options) {
|
|
|
4908
4925
|
return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
|
|
4909
4926
|
const vertical = await verticalForScope(c, scope);
|
|
4910
4927
|
if (!vertical)
|
|
4911
|
-
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
|
|
4928
|
+
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope)) };
|
|
4912
4929
|
return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
|
|
4913
4930
|
};
|
|
4914
4931
|
/**
|
|
@@ -5028,7 +5045,7 @@ export function createControlPlaneApi(options) {
|
|
|
5028
5045
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5029
5046
|
const vertical = await verticalForScope(c, scope);
|
|
5030
5047
|
if (!vertical)
|
|
5031
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
5048
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
5032
5049
|
const operationId = ulid();
|
|
5033
5050
|
// An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
|
|
5034
5051
|
// a hand-over closed without finishing from one that finished.
|
|
@@ -5064,7 +5081,7 @@ export function createControlPlaneApi(options) {
|
|
|
5064
5081
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5065
5082
|
const vertical = await verticalForScope(c, scope);
|
|
5066
5083
|
if (!vertical) {
|
|
5067
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
5084
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
5068
5085
|
}
|
|
5069
5086
|
try {
|
|
5070
5087
|
await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
|
|
@@ -5217,12 +5234,17 @@ export function createControlPlaneApi(options) {
|
|
|
5217
5234
|
}
|
|
5218
5235
|
await retryTransient(() => copier.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId, tenantId }));
|
|
5219
5236
|
// The scope file was copied inside the script. The store objects are per scope, so the
|
|
5220
|
-
// fork gets its own copy
|
|
5221
|
-
// control plane
|
|
5237
|
+
// fork gets its own copy, page by page from the source's store into a stage at the fork's,
|
|
5238
|
+
// signed by the control plane (D-69); every store is staged before any is committed. A
|
|
5239
|
+
// version that declares no stores has nothing to copy.
|
|
5222
5240
|
if (reach) {
|
|
5223
|
-
const
|
|
5224
|
-
|
|
5225
|
-
|
|
5241
|
+
const from = { tenantId, scopeId: scope.id, vertical: reach.vertical };
|
|
5242
|
+
const to = { ...from, scopeId: snapId };
|
|
5243
|
+
const moved = await moveStores(snapshotEnds(reach.caller, from, to), reach.stores, `scope ${scope.id}'s stores`, options.storeMoveBudget);
|
|
5244
|
+
try {
|
|
5245
|
+
await commitStages(reach.caller, to, moved, retryTransient);
|
|
5246
|
+
}
|
|
5247
|
+
finally {
|
|
5226
5248
|
replay = await replayPlatformStoreErasures(c, { ...scope, id: snapId }, scope.id);
|
|
5227
5249
|
}
|
|
5228
5250
|
}
|
|
@@ -5404,9 +5426,11 @@ export function createControlPlaneApi(options) {
|
|
|
5404
5426
|
const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
5405
5427
|
const vertical = await verticalForScope(c, scope);
|
|
5406
5428
|
// Store tables are opaque and stay unsealed. The platform reads them from the store script
|
|
5407
|
-
// (#2207)
|
|
5408
|
-
|
|
5409
|
-
const
|
|
5429
|
+
// (#2207), a page at a time into entries of their own (D-69); a store it cannot name or reach
|
|
5430
|
+
// refuses the backup rather than omitting it. Resolved before anything is written.
|
|
5431
|
+
const reach = await platformStoreReach(c, scope);
|
|
5432
|
+
const writer = store.begin({ vertical: scope.vertical, header: { ...header, stores: reach ? [] : header.stores } });
|
|
5433
|
+
const backup = backupStage(writer, sealerFor(c, tenantId, scope.id));
|
|
5410
5434
|
// #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
|
|
5411
5435
|
// moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
|
|
5412
5436
|
// copied as it was at one moment, and never held whole here. A deployment that predates
|
|
@@ -5416,11 +5440,18 @@ export function createControlPlaneApi(options) {
|
|
|
5416
5440
|
: hostTables;
|
|
5417
5441
|
if (Array.isArray(moved))
|
|
5418
5442
|
await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
|
|
5443
|
+
// The scope, then its stores: two objects, so not one moment (D-69, question 3).
|
|
5444
|
+
let stores = [];
|
|
5419
5445
|
try {
|
|
5420
|
-
|
|
5446
|
+
if (reach) {
|
|
5447
|
+
stores = await moveStores(backupEnds(reach.caller, reach.place, writer), reach.stores, `scope ${scope.id}'s stores`, options.storeMoveBudget);
|
|
5448
|
+
}
|
|
5449
|
+
return await backup.commit(stores.map((s) => ({ binding: s.binding, driver: s.driver, stageId: s.moveId, entries: s.pages, revision: s.revision })));
|
|
5421
5450
|
}
|
|
5422
5451
|
catch (e) {
|
|
5423
5452
|
await backup.discard();
|
|
5453
|
+
for (const s of stores)
|
|
5454
|
+
await writer.discard(s.moveId).catch(() => undefined);
|
|
5424
5455
|
throw e;
|
|
5425
5456
|
}
|
|
5426
5457
|
};
|
|
@@ -5459,7 +5490,7 @@ export function createControlPlaneApi(options) {
|
|
|
5459
5490
|
},
|
|
5460
5491
|
discardStagedScope: (_, stageId) => writer.discard(stageId),
|
|
5461
5492
|
},
|
|
5462
|
-
commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
|
|
5493
|
+
commit: (storeEntries) => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size, storeEntries }),
|
|
5463
5494
|
discard: () => writer.discard(last.stageId),
|
|
5464
5495
|
};
|
|
5465
5496
|
};
|
|
@@ -5499,6 +5530,16 @@ export function createControlPlaneApi(options) {
|
|
|
5499
5530
|
if (held.entries > 1) {
|
|
5500
5531
|
return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} is stored in ${held.entries} entries (its pages, and any writes caught while it was taken), which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
|
|
5501
5532
|
}
|
|
5533
|
+
// A store held in one page is served inline, as `stores` always was; one held in more is a
|
|
5534
|
+
// restore's to apply (D-69), so the backup is refused whole rather than handed back in part.
|
|
5535
|
+
const paged = held.storeEntries.find((store) => store.entries > 1);
|
|
5536
|
+
if (paged) {
|
|
5537
|
+
return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} holds scope store '${paged.binding}' in ${paged.entries} pages, which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
|
|
5538
|
+
}
|
|
5539
|
+
const pagedStores = [];
|
|
5540
|
+
for (const store of held.storeEntries) {
|
|
5541
|
+
pagedStores.push({ binding: store.binding, driver: store.driver, tables: mergeDumpPages([await held.storePage(store, 0)]) });
|
|
5542
|
+
}
|
|
5502
5543
|
const { tables } = await held.entry(0);
|
|
5503
5544
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
5504
5545
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
@@ -5506,7 +5547,7 @@ export function createControlPlaneApi(options) {
|
|
|
5506
5547
|
// working: the ciphertext never left the object, and nothing turns it back into a
|
|
5507
5548
|
// person. A dump taken before sealing existed passes through untouched.
|
|
5508
5549
|
const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
|
|
5509
|
-
return c.json({ ...held.header, tables: opened });
|
|
5550
|
+
return c.json({ ...held.header, stores: [...held.header.stores, ...pagedStores], tables: opened });
|
|
5510
5551
|
});
|
|
5511
5552
|
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
5512
5553
|
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
@@ -5917,7 +5958,7 @@ export function createControlPlaneApi(options) {
|
|
|
5917
5958
|
}
|
|
5918
5959
|
const vertical = await verticalForScope(c, scope);
|
|
5919
5960
|
if (!vertical)
|
|
5920
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
5961
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
5921
5962
|
const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
|
|
5922
5963
|
await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
|
|
5923
5964
|
return c.json({ cleared });
|
|
@@ -6381,9 +6422,15 @@ export function createControlPlaneApi(options) {
|
|
|
6381
6422
|
if (!held)
|
|
6382
6423
|
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
6383
6424
|
const { header } = held;
|
|
6425
|
+
// A backup taken since D-69 holds its stores as store pages; one taken before, inline in its header.
|
|
6426
|
+
const pagedStores = held.storeEntries.map((entry) => ({
|
|
6427
|
+
binding: entry.binding,
|
|
6428
|
+
driver: entry.driver,
|
|
6429
|
+
source: { pages: entry.entries, revision: entry.revision, page: (seq) => held.storePage(entry, seq) },
|
|
6430
|
+
}));
|
|
6384
6431
|
if (options.platformScopeStores) {
|
|
6385
6432
|
try {
|
|
6386
|
-
await refuseUndeclaredDumpStores(c, scope, header.stores);
|
|
6433
|
+
await refuseUndeclaredDumpStores(c, scope, [...header.stores, ...pagedStores]);
|
|
6387
6434
|
}
|
|
6388
6435
|
catch (e) {
|
|
6389
6436
|
if (e instanceof ControlPlaneError)
|
|
@@ -6398,7 +6445,7 @@ export function createControlPlaneApi(options) {
|
|
|
6398
6445
|
if (tables.length === 0) {
|
|
6399
6446
|
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
6400
6447
|
}
|
|
6401
|
-
return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
|
|
6448
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer), pagedStores }));
|
|
6402
6449
|
}
|
|
6403
6450
|
// The host judges the roles the backup seats or defines before anything is replaced (#2555), and
|
|
6404
6451
|
// records the tables it restores. So it is handed every table's schema and the role tables' rows,
|
|
@@ -6432,7 +6479,7 @@ export function createControlPlaneApi(options) {
|
|
|
6432
6479
|
}
|
|
6433
6480
|
return { dump: { stageId }, tables: judged.tables() };
|
|
6434
6481
|
};
|
|
6435
|
-
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
|
|
6482
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage, pagedStores }));
|
|
6436
6483
|
}
|
|
6437
6484
|
catch (e) {
|
|
6438
6485
|
return restoreFailed(c, scope, e);
|
|
@@ -6461,32 +6508,55 @@ export function createControlPlaneApi(options) {
|
|
|
6461
6508
|
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6462
6509
|
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6463
6510
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6464
|
-
|
|
6465
|
-
|
|
6511
|
+
// D-69: every store is staged first, then the scope is loaded, then the stores are committed,
|
|
6512
|
+
// then erasures are replayed. Until the first commit nothing of a store has changed, so a page
|
|
6513
|
+
// that cannot be read, a refused stage or a refused load leaves every store as it was. Once a
|
|
6514
|
+
// commit was attempted the replay runs whatever follows, and a restore run again converges.
|
|
6515
|
+
const storesCaller = options.platformScopeStores;
|
|
6516
|
+
const platform = storesCaller && vertical && scope.vertical
|
|
6517
|
+
? { caller: storesCaller, place: { tenantId, scopeId, vertical: scope.vertical } }
|
|
6518
|
+
: undefined;
|
|
6519
|
+
const pagedStores = input.pagedStores ?? [];
|
|
6520
|
+
if (!platform && pagedStores.length > 0) {
|
|
6521
|
+
throw new ControlPlaneError(409, `the backup holds scope stores in pages, and scope ${scopeId} has no deployment and no route to its stores to load them into`);
|
|
6522
|
+
}
|
|
6523
|
+
// With a route to the stores, an inline store moves as a paged one does; without one the host carries it.
|
|
6524
|
+
const stores = platform ? [...inlineStoreSources(header.stores), ...pagedStores] : [];
|
|
6525
|
+
const sources = new Map(stores.map((store) => [store.binding, store.source]));
|
|
6526
|
+
const storeEnds = platform && restoreEnds(platform.caller, platform.place, (binding) => sources.get(binding));
|
|
6527
|
+
const movedStores = storeEnds && stores.length > 0
|
|
6528
|
+
? await moveStores(storeEnds, stores.map(({ binding, driver }) => ({ binding, driver })), `the backup's stores for scope ${scopeId}`, options.storeMoveBudget)
|
|
6529
|
+
: [];
|
|
6530
|
+
let staged;
|
|
6531
|
+
let committing = false;
|
|
6466
6532
|
try {
|
|
6467
|
-
|
|
6468
|
-
const
|
|
6533
|
+
staged = input.stage && vertical ? await input.stage(vertical) : undefined;
|
|
6534
|
+
const tables = staged?.tables ?? input.tables;
|
|
6535
|
+
const landing = { ...header, tables, stores: platform ? [] : header.stores };
|
|
6469
6536
|
const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
6470
|
-
if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
|
|
6471
|
-
await storesCaller.importStores({
|
|
6472
|
-
tenantId,
|
|
6473
|
-
scopeId,
|
|
6474
|
-
vertical: scope.vertical,
|
|
6475
|
-
stores: header.stores,
|
|
6476
|
-
});
|
|
6477
|
-
}
|
|
6478
|
-
const platformReplay = storesCaller && scope.vertical
|
|
6479
|
-
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
|
|
6480
|
-
: undefined;
|
|
6481
|
-
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
6482
6537
|
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
6483
6538
|
// off in the replay's own event — its own sweeper cannot land in between.
|
|
6484
6539
|
const restored = vertical
|
|
6485
|
-
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6540
|
+
? await restoreCarryingSwitches(c.var.admin, actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6486
6541
|
scopeId: sourceHint ?? origin,
|
|
6487
6542
|
exact: false,
|
|
6488
6543
|
})
|
|
6489
6544
|
: undefined;
|
|
6545
|
+
let landFailure;
|
|
6546
|
+
if (platform && movedStores.length > 0) {
|
|
6547
|
+
committing = true;
|
|
6548
|
+
await commitStages(platform.caller, platform.place, movedStores, retryTransient).catch((e) => {
|
|
6549
|
+
if (!(e instanceof StoreLandFailure))
|
|
6550
|
+
throw e;
|
|
6551
|
+
landFailure = e;
|
|
6552
|
+
});
|
|
6553
|
+
}
|
|
6554
|
+
const platformReplay = storesCaller && scope.vertical
|
|
6555
|
+
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
|
|
6556
|
+
: undefined;
|
|
6557
|
+
if (landFailure)
|
|
6558
|
+
throw landFailure;
|
|
6559
|
+
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
6490
6560
|
// #1674: a backup from before a module was switched off brings it back on; the
|
|
6491
6561
|
// directory's record puts it back off now, not at the next reconcile. Where the
|
|
6492
6562
|
// deployment already did (#1742), this finds it done and audits what it reported.
|
|
@@ -6506,12 +6576,17 @@ export function createControlPlaneApi(options) {
|
|
|
6506
6576
|
};
|
|
6507
6577
|
}
|
|
6508
6578
|
catch (e) {
|
|
6509
|
-
// A stage the refused load left at the deployment goes now rather than waiting for a wipe
|
|
6579
|
+
// A stage the refused load left at the deployment goes now rather than waiting for a wipe,
|
|
6580
|
+
// and so do the stores' stages, none of which was committed.
|
|
6510
6581
|
if (staged && vertical)
|
|
6511
6582
|
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
|
|
6583
|
+
if (storeEnds && !committing)
|
|
6584
|
+
await discardMoves(storeEnds, movedStores);
|
|
6512
6585
|
throw e;
|
|
6513
6586
|
}
|
|
6514
6587
|
};
|
|
6588
|
+
/** A dump's inline stores as restore sources, each cut at its driver's page size (D-69). */
|
|
6589
|
+
const inlineStoreSources = (stores) => stores.map((store) => ({ binding: store.binding, driver: store.driver, source: inlineStoreSource(store) }));
|
|
6515
6590
|
/** How a refused or failed restore answers, on either route. */
|
|
6516
6591
|
const restoreFailed = async (c, scope, e) => {
|
|
6517
6592
|
// #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
|
|
@@ -6633,11 +6708,11 @@ export function createControlPlaneApi(options) {
|
|
|
6633
6708
|
const actor = c.get('actor');
|
|
6634
6709
|
const adoptPin = confinedTenant(p);
|
|
6635
6710
|
if (adoptPin) {
|
|
6636
|
-
const v = await verticalOf(actor, slug);
|
|
6711
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
6637
6712
|
if (!v || v.ownerTenant !== adoptPin)
|
|
6638
6713
|
return refuse(c, 'forbidden', 'forbidden');
|
|
6639
6714
|
}
|
|
6640
|
-
const v = await verticalOf(actor, slug);
|
|
6715
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
6641
6716
|
if (!v)
|
|
6642
6717
|
return refuse(c, 'not_found', `unknown vertical '${slug}'`);
|
|
6643
6718
|
if (v.ownerTenant === null) {
|
|
@@ -6943,7 +7018,7 @@ export function createControlPlaneApi(options) {
|
|
|
6943
7018
|
// #2185: before the load as well as after it, so a throw once the kept copy has landed (the
|
|
6944
7019
|
// re-assert) cannot leave the receipt set over older data.
|
|
6945
7020
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6946
|
-
const restored = await restoreCarryingSwitches(actor, live, tenantId, scopeId, dump, {
|
|
7021
|
+
const restored = await restoreCarryingSwitches(c.var.admin, actor, live, tenantId, scopeId, dump, {
|
|
6947
7022
|
scopeId,
|
|
6948
7023
|
exact: true,
|
|
6949
7024
|
loadStamp: ulid(),
|
|
@@ -7010,7 +7085,7 @@ export function createControlPlaneApi(options) {
|
|
|
7010
7085
|
// first (a version not admitted) the same way. An unknown scope has no impact to read, and
|
|
7011
7086
|
// the bind below refuses it as it always has.
|
|
7012
7087
|
if (scope)
|
|
7013
|
-
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7088
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7014
7089
|
// A refusal throws a ControlPlaneError, which the app's error boundary relays with its
|
|
7015
7090
|
// status and records as an ops failure. An unknown scope carries nothing, and the bind
|
|
7016
7091
|
// below refuses it as it always has.
|
|
@@ -7056,7 +7131,7 @@ export function createControlPlaneApi(options) {
|
|
|
7056
7131
|
try {
|
|
7057
7132
|
// Asked again after the carry: a promote landing since the first question would
|
|
7058
7133
|
// otherwise leave an archive behind for a bind the host then refuses.
|
|
7059
|
-
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7134
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7060
7135
|
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
7061
7136
|
}
|
|
7062
7137
|
catch (e) {
|
|
@@ -7252,14 +7327,14 @@ export function createControlPlaneApi(options) {
|
|
|
7252
7327
|
// registry row's owner_tenant (undefined ⇒ not registered — a claimable slug).
|
|
7253
7328
|
// `listVerticals` returns every row (the adapter does not narrow); the transport is
|
|
7254
7329
|
// where per-principal filtering happens — here and on GET /verticals.
|
|
7255
|
-
const ownerOf = async (actor, slug) => {
|
|
7256
|
-
const v = (await
|
|
7330
|
+
const ownerOf = async (admin, actor, slug) => {
|
|
7331
|
+
const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
7257
7332
|
return v ? v.ownerTenant : undefined;
|
|
7258
7333
|
};
|
|
7259
7334
|
// The full registry row, for the checks that need more than the owner — whether the
|
|
7260
7335
|
// vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
|
|
7261
7336
|
// self-serve below.
|
|
7262
|
-
const verticalOf = async (actor, slug) => (await
|
|
7337
|
+
const verticalOf = async (admin, actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
7263
7338
|
/**
|
|
7264
7339
|
* "This principal is confined, and this vertical is not its tenant's." The one
|
|
7265
7340
|
* predicate every slug-addressed route asks.
|
|
@@ -7271,7 +7346,7 @@ export function createControlPlaneApi(options) {
|
|
|
7271
7346
|
*/
|
|
7272
7347
|
const notOwned = async (p, slug) => {
|
|
7273
7348
|
const pin = confinedTenant(p);
|
|
7274
|
-
return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
|
|
7349
|
+
return pin !== null && (await ownerOf(adminFor(p), p.actor, slug)) !== pin;
|
|
7275
7350
|
};
|
|
7276
7351
|
/**
|
|
7277
7352
|
* The READ twin of `notOwned`, for the routes an installed app's own pages ask
|
|
@@ -7284,7 +7359,7 @@ export function createControlPlaneApi(options) {
|
|
|
7284
7359
|
* the prod history).
|
|
7285
7360
|
*/
|
|
7286
7361
|
/** The bare slug of every registered vertical: what a manager's SKU may not be (#2388). */
|
|
7287
|
-
const verticalSlugsOf = async (actor) => (await
|
|
7362
|
+
const verticalSlugsOf = async (admin, actor) => (await admin.listVerticals(actor)).map((v) => bareSlug(v.slug));
|
|
7288
7363
|
/**
|
|
7289
7364
|
* Whether a CONFINED principal may grant its own tenant `key` (#977): some vertical it
|
|
7290
7365
|
* can read — its own, or a listed one — declares the key, or is the vertical whose
|
|
@@ -7299,8 +7374,8 @@ export function createControlPlaneApi(options) {
|
|
|
7299
7374
|
* the first push). Its own slug and the engine keys are always declarable, so they reserve nothing.
|
|
7300
7375
|
* Only a new slug is judged; the sentence names no other vertical.
|
|
7301
7376
|
*/
|
|
7302
|
-
const slugClaimsSku = async (actor, slug) => {
|
|
7303
|
-
const all = await
|
|
7377
|
+
const slugClaimsSku = async (admin, actor, slug) => {
|
|
7378
|
+
const all = await admin.listVerticals(actor);
|
|
7304
7379
|
if (all.some((v) => v.slug === slug))
|
|
7305
7380
|
return undefined;
|
|
7306
7381
|
const name = bareSlug(slug);
|
|
@@ -7317,7 +7392,7 @@ export function createControlPlaneApi(options) {
|
|
|
7317
7392
|
const pin = confinedTenant(p);
|
|
7318
7393
|
if (pin === null)
|
|
7319
7394
|
return true;
|
|
7320
|
-
const all = await
|
|
7395
|
+
const all = await adminFor(p).listVerticals(p.actor);
|
|
7321
7396
|
const slugs = all.map((v) => bareSlug(v.slug));
|
|
7322
7397
|
const visible = all.filter((v) => v.ownerTenant === pin || v.listed);
|
|
7323
7398
|
return visible.some((v) => {
|
|
@@ -7331,7 +7406,7 @@ export function createControlPlaneApi(options) {
|
|
|
7331
7406
|
const pin = confinedTenant(p);
|
|
7332
7407
|
if (pin === null)
|
|
7333
7408
|
return false;
|
|
7334
|
-
const v = await verticalOf(p.actor, slug);
|
|
7409
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
7335
7410
|
return !v || (v.ownerTenant !== pin && !v.listed);
|
|
7336
7411
|
};
|
|
7337
7412
|
/**
|
|
@@ -7377,10 +7452,10 @@ export function createControlPlaneApi(options) {
|
|
|
7377
7452
|
const workspace = (await c.var.admin.listTenants(actor)).find((t) => t.slug === pin || t.id === pin);
|
|
7378
7453
|
if (!workspace || raw.startsWith(`${workspace.slug}/`))
|
|
7379
7454
|
return raw;
|
|
7380
|
-
if ((await ownerOf(actor, raw)) === workspace.id)
|
|
7455
|
+
if ((await ownerOf(c.var.admin, actor, raw)) === workspace.id)
|
|
7381
7456
|
return raw;
|
|
7382
7457
|
const prefixed = `${workspace.slug}/${raw}`;
|
|
7383
|
-
return (await ownerOf(actor, prefixed)) !== undefined ? prefixed : raw;
|
|
7458
|
+
return (await ownerOf(c.var.admin, actor, prefixed)) !== undefined ? prefixed : raw;
|
|
7384
7459
|
};
|
|
7385
7460
|
app.get('/verticals', async (c) => {
|
|
7386
7461
|
const page = pageParams(c);
|
|
@@ -7428,13 +7503,13 @@ export function createControlPlaneApi(options) {
|
|
|
7428
7503
|
* same enumeration, and #977's observability narrowing must not be a second copy of
|
|
7429
7504
|
* it that can drift when a third ref scheme appears.
|
|
7430
7505
|
*/
|
|
7431
|
-
const tenantServiceRefs = async (actor, tenantId, slug) => {
|
|
7432
|
-
const all = (await
|
|
7506
|
+
const tenantServiceRefs = async (admin, actor, tenantId, slug) => {
|
|
7507
|
+
const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
|
|
7433
7508
|
const considered = slug === undefined ? all : all.filter((v) => v.slug === slug);
|
|
7434
7509
|
const verticalsTruncated = considered.length > MAX_SERVICE_REF_VERTICALS;
|
|
7435
7510
|
const entries = [];
|
|
7436
7511
|
for (const v of considered.slice(0, MAX_SERVICE_REF_VERTICALS)) {
|
|
7437
|
-
const versions = await
|
|
7512
|
+
const versions = await admin.listVersions(actor, v.slug);
|
|
7438
7513
|
const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
|
|
7439
7514
|
// The stable serving script — where real traffic lands. Its version is the
|
|
7440
7515
|
// registry row's own servingVersionId: authoritative, not the scope-derived
|
|
@@ -7474,7 +7549,7 @@ export function createControlPlaneApi(options) {
|
|
|
7474
7549
|
const pin = confinedTenant(p);
|
|
7475
7550
|
if (pin === null)
|
|
7476
7551
|
return null;
|
|
7477
|
-
const refs = await tenantServiceRefs(actor, pin);
|
|
7552
|
+
const refs = await tenantServiceRefs(adminFor(p), actor, pin);
|
|
7478
7553
|
if (refs.verticalsTruncated) {
|
|
7479
7554
|
throw new ControlPlaneError(503, 'service map truncated: too many verticals to resolve in one answer');
|
|
7480
7555
|
}
|
|
@@ -7493,7 +7568,7 @@ export function createControlPlaneApi(options) {
|
|
|
7493
7568
|
.parse({ tenantId, vertical: c.req.query('vertical') });
|
|
7494
7569
|
const actor = c.get('actor');
|
|
7495
7570
|
const slug = query.vertical === undefined ? undefined : await resolveVerticalId(c, query.vertical);
|
|
7496
|
-
const { entries, verticalsTruncated, matched } = await tenantServiceRefs(actor, query.tenantId, slug);
|
|
7571
|
+
const { entries, verticalsTruncated, matched } = await tenantServiceRefs(c.var.admin, actor, query.tenantId, slug);
|
|
7497
7572
|
// Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
|
|
7498
7573
|
if (slug !== undefined && matched === 0)
|
|
7499
7574
|
throw new ControlPlaneError(404, 'not found');
|
|
@@ -7508,17 +7583,17 @@ export function createControlPlaneApi(options) {
|
|
|
7508
7583
|
// the prefix, backstopped by the ownership check). Owner + prefix come from the
|
|
7509
7584
|
// principal, never trusted from the body.
|
|
7510
7585
|
input.slug = effectiveSlug(p, input.slug);
|
|
7511
|
-
const owner = await ownerOf(p.actor, input.slug);
|
|
7586
|
+
const owner = await ownerOf(adminFor(p), p.actor, input.slug);
|
|
7512
7587
|
if (owner !== undefined && owner !== p.tenantId)
|
|
7513
7588
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7514
7589
|
input.ownerTenant = p.tenantId;
|
|
7515
7590
|
}
|
|
7516
|
-
const claimed = await slugClaimsSku(c.get('actor'), input.slug);
|
|
7591
|
+
const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), input.slug);
|
|
7517
7592
|
if (claimed)
|
|
7518
7593
|
return refuseStatus(c, 422, claimed);
|
|
7519
7594
|
// #2388: the same declaration rule as a push, for any principal that is not staff.
|
|
7520
7595
|
if (p.kind !== 'staff') {
|
|
7521
|
-
const foreignKey = entitlementNamespaceRefusal(input.slug, input.entitlements ?? [], input.provisions?.length ? await verticalSlugsOf(c.get('actor')) : undefined);
|
|
7596
|
+
const foreignKey = entitlementNamespaceRefusal(input.slug, input.entitlements ?? [], input.provisions?.length ? await verticalSlugsOf(c.var.admin, c.get('actor')) : undefined);
|
|
7522
7597
|
if (foreignKey)
|
|
7523
7598
|
return refuseStatus(c, 422, foreignKey);
|
|
7524
7599
|
}
|
|
@@ -7606,7 +7681,7 @@ export function createControlPlaneApi(options) {
|
|
|
7606
7681
|
// (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
|
|
7607
7682
|
// does: a driver with no approved release refuses here, and nothing is published. The publish is
|
|
7608
7683
|
// made under the push's shared hold (#2816), so a rollout walks this vertical or waits for it.
|
|
7609
|
-
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null, async (renew) => {
|
|
7684
|
+
await ensureScopeStoresOf(c.var.admin, c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.var.admin, c.get('actor'), slug)) ?? null, async (renew) => {
|
|
7610
7685
|
await renew();
|
|
7611
7686
|
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
7612
7687
|
});
|
|
@@ -7827,7 +7902,7 @@ export function createControlPlaneApi(options) {
|
|
|
7827
7902
|
const slug = c.req.param('slug');
|
|
7828
7903
|
if (await notOwned(c.get('principal'), slug))
|
|
7829
7904
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7830
|
-
const vertical = await verticalOf(c.get('actor'), slug);
|
|
7905
|
+
const vertical = await verticalOf(c.var.admin, c.get('actor'), slug);
|
|
7831
7906
|
if (!vertical)
|
|
7832
7907
|
return refuse(c, 'not_found', 'vertical not found');
|
|
7833
7908
|
return c.json({ slug, emailSender: !!vertical.emailSender });
|
|
@@ -7913,7 +7988,7 @@ export function createControlPlaneApi(options) {
|
|
|
7913
7988
|
* (409) once one has lapsed; `then` calls it right before it records. A failure in `then` is not
|
|
7914
7989
|
* recorded here: it is the caller's to answer.
|
|
7915
7990
|
*/
|
|
7916
|
-
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner, then = async () => undefined) => {
|
|
7991
|
+
const ensureScopeStoresOf = async (admin, actor, operation, slug, stores, owner, then = async () => undefined) => {
|
|
7917
7992
|
if (stores.length === 0)
|
|
7918
7993
|
return then(async () => undefined);
|
|
7919
7994
|
// D-67: a driver whose registration names its verticals serves those only, refused before any
|
|
@@ -7929,7 +8004,7 @@ export function createControlPlaneApi(options) {
|
|
|
7929
8004
|
const held = [];
|
|
7930
8005
|
const renewHolds = async (when) => {
|
|
7931
8006
|
for (const each of held) {
|
|
7932
|
-
if (!(await
|
|
8007
|
+
if (!(await admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
7933
8008
|
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed ${when}; try again`);
|
|
7934
8009
|
}
|
|
7935
8010
|
}
|
|
@@ -7944,13 +8019,13 @@ export function createControlPlaneApi(options) {
|
|
|
7944
8019
|
// a script's release before a rollout moved it would upload the old release back over it.
|
|
7945
8020
|
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
7946
8021
|
for (const driver of drivers) {
|
|
7947
|
-
if (!(await
|
|
8022
|
+
if (!(await admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
7948
8023
|
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
7949
8024
|
}
|
|
7950
8025
|
held.push(driver);
|
|
7951
8026
|
}
|
|
7952
8027
|
const loadRelease = options.storeReleases
|
|
7953
|
-
? storedReleaseLoader({ admin
|
|
8028
|
+
? storedReleaseLoader({ admin, actor, bytes: options.storeReleases.bytes })
|
|
7954
8029
|
: async () => undefined;
|
|
7955
8030
|
// One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
|
|
7956
8031
|
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
@@ -7961,7 +8036,7 @@ export function createControlPlaneApi(options) {
|
|
|
7961
8036
|
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
7962
8037
|
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
7963
8038
|
// before this read is seen, and none is written until this upload is done.
|
|
7964
|
-
const approvals = await readStoreDriverApprovals(
|
|
8039
|
+
const approvals = await readStoreDriverApprovals(admin, actor, registration);
|
|
7965
8040
|
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7966
8041
|
}
|
|
7967
8042
|
}
|
|
@@ -7981,7 +8056,7 @@ export function createControlPlaneApi(options) {
|
|
|
7981
8056
|
}
|
|
7982
8057
|
finally {
|
|
7983
8058
|
for (const driver of held)
|
|
7984
|
-
await
|
|
8059
|
+
await admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
|
|
7985
8060
|
}
|
|
7986
8061
|
};
|
|
7987
8062
|
/**
|
|
@@ -8086,12 +8161,12 @@ export function createControlPlaneApi(options) {
|
|
|
8086
8161
|
const serveVersionInPlace = async (admin, actor, slug, versionId) => {
|
|
8087
8162
|
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
8088
8163
|
return; // not configured: pre-#286 behavior
|
|
8089
|
-
const version = await
|
|
8164
|
+
const version = await admin.getVersion(actor, versionId, slug);
|
|
8090
8165
|
if (!version?.deploymentRef) {
|
|
8091
8166
|
throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
|
|
8092
8167
|
}
|
|
8093
8168
|
const archiveRef = version.deploymentRef;
|
|
8094
|
-
const manifestJson = await
|
|
8169
|
+
const manifestJson = await admin.versionManifest(actor, slug, versionId);
|
|
8095
8170
|
if (!manifestJson) {
|
|
8096
8171
|
throw new ControlPlaneError(502, `version ${versionId} retained no manifest — pushed pre-#286; push it again to serve in place`);
|
|
8097
8172
|
}
|
|
@@ -8101,7 +8176,7 @@ export function createControlPlaneApi(options) {
|
|
|
8101
8176
|
// serving record below all read this one set; the manifest's own `doClasses` would leave
|
|
8102
8177
|
// the record a class short of the script, and the next serve re-declaring a live class.
|
|
8103
8178
|
const doClasses = deployedDoClasses(manifest.doClasses, manifest.platformSweeper);
|
|
8104
|
-
const serving = await
|
|
8179
|
+
const serving = await admin.verticalServing(actor, slug);
|
|
8105
8180
|
const ref = serving?.ref ?? stableDeploymentRefFor(slug);
|
|
8106
8181
|
// #1923: the serving upload is the other door a script name is minted through.
|
|
8107
8182
|
const collision = platformScriptCollision(slug) ?? (PLATFORM_SCRIPT_NAMES.has(ref) ? ref : null);
|
|
@@ -8115,16 +8190,16 @@ export function createControlPlaneApi(options) {
|
|
|
8115
8190
|
// tenant's store. Platform-granted after the §4 sandbox check, like injectSecrets:
|
|
8116
8191
|
// the builder never declared these and never named the ids.
|
|
8117
8192
|
const storeBindings = [
|
|
8118
|
-
...tenantStoreBindings(await
|
|
8193
|
+
...tenantStoreBindings(await admin.listTenantStores(actor, { vertical: slug })),
|
|
8119
8194
|
// #473: the per-tenant blob-store r2_bucket bindings, re-derived from the ledger on
|
|
8120
8195
|
// every serving upload for the same reason — an upload replaces the script's binding
|
|
8121
8196
|
// set, so a re-deploy must never drop a tenant's attachment bucket.
|
|
8122
|
-
...blobStoreBindings(await
|
|
8197
|
+
...blobStoreBindings(await admin.listBlobStores(actor, { vertical: slug })),
|
|
8123
8198
|
].map((b) => b.type === 'd1'
|
|
8124
8199
|
? { type: 'd1', name: b.name, id: b.id }
|
|
8125
8200
|
: { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
8126
8201
|
// #2210: the store scripts first, so the vertical is never served before what it reaches.
|
|
8127
|
-
await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
|
|
8202
|
+
await ensureScopeStoresOf(admin, actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(admin, actor, slug)) ?? null);
|
|
8128
8203
|
const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
|
|
8129
8204
|
entry: manifest.entry,
|
|
8130
8205
|
compatibilityDate: manifest.compatibilityDate,
|
|
@@ -8286,14 +8361,14 @@ export function createControlPlaneApi(options) {
|
|
|
8286
8361
|
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
8287
8362
|
return none; // no in-place serve moves anything
|
|
8288
8363
|
const actor = c.get('actor');
|
|
8289
|
-
const incoming = await
|
|
8364
|
+
const incoming = await c.var.admin.getVersion(actor, versionId, slug);
|
|
8290
8365
|
if (!incoming?.deploymentRef)
|
|
8291
8366
|
return none;
|
|
8292
|
-
const manifestJson = await
|
|
8367
|
+
const manifestJson = await c.var.admin.versionManifest(actor, slug, versionId);
|
|
8293
8368
|
const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
|
|
8294
8369
|
if (!registry || !releaseRolesAskScopes(registry))
|
|
8295
8370
|
return none;
|
|
8296
|
-
const v = await verticalOf(actor, slug);
|
|
8371
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
8297
8372
|
const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
|
|
8298
8373
|
const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
|
|
8299
8374
|
.filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
|
|
@@ -8347,23 +8422,23 @@ export function createControlPlaneApi(options) {
|
|
|
8347
8422
|
* Over-approximates for a host that cannot serve in place, where no adopt runs: an extra line
|
|
8348
8423
|
* costs a read, a missing one a silent stall.
|
|
8349
8424
|
*/
|
|
8350
|
-
const promoteImpactOf = async (actor, slug, channel, versionId) => {
|
|
8351
|
-
const gate = await
|
|
8425
|
+
const promoteImpactOf = async (admin, actor, slug, channel, versionId) => {
|
|
8426
|
+
const gate = await admin.promotionImpact(actor, slug, channel, versionId);
|
|
8352
8427
|
if (channel !== 'prod')
|
|
8353
8428
|
return gate;
|
|
8354
|
-
const v = await verticalOf(actor, slug);
|
|
8429
|
+
const v = await verticalOf(admin, actor, slug);
|
|
8355
8430
|
if (!v || v.ownerTenant === null || v.listed)
|
|
8356
8431
|
return gate;
|
|
8357
|
-
const incoming = await
|
|
8432
|
+
const incoming = await admin.getVersion(actor, versionId, slug);
|
|
8358
8433
|
if (!incoming?.deploymentRef || incoming.admission !== 'admitted')
|
|
8359
8434
|
return gate;
|
|
8360
|
-
const lagging = (await
|
|
8435
|
+
const lagging = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && !s.servingRef && s.verticalVersionId !== versionId);
|
|
8361
8436
|
const seen = new Set(gate.map((b) => `${b.scopeId}|${b.type}`));
|
|
8362
8437
|
const out = [...gate];
|
|
8363
8438
|
for (const s of lagging) {
|
|
8364
8439
|
// After the adopt it runs the promoted version, served in place: that pointer move, from
|
|
8365
8440
|
// what it runs now, is the move to judge.
|
|
8366
|
-
for (const b of await
|
|
8441
|
+
for (const b of await admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
|
|
8367
8442
|
const key = `${b.scopeId}|${b.type}`;
|
|
8368
8443
|
if (!seen.has(key))
|
|
8369
8444
|
(seen.add(key), out.push(b));
|
|
@@ -8382,11 +8457,11 @@ export function createControlPlaneApi(options) {
|
|
|
8382
8457
|
const versionId = z.string().min(1).parse(c.req.query('versionId'));
|
|
8383
8458
|
const pin = confinedTenant(p);
|
|
8384
8459
|
if (pin) {
|
|
8385
|
-
const v = await verticalOf(p.actor, slug);
|
|
8460
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
8386
8461
|
if (!v || v.ownerTenant !== pin)
|
|
8387
8462
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8388
8463
|
}
|
|
8389
|
-
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
8464
|
+
const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
|
|
8390
8465
|
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
8391
8466
|
return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
|
|
8392
8467
|
});
|
|
@@ -8404,7 +8479,7 @@ export function createControlPlaneApi(options) {
|
|
|
8404
8479
|
const promotePin = confinedTenant(p);
|
|
8405
8480
|
if (promotePin) {
|
|
8406
8481
|
// A confined principal promotes only verticals its tenant owns.
|
|
8407
|
-
const v = await verticalOf(p.actor, slug);
|
|
8482
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
8408
8483
|
if (!v || v.ownerTenant !== promotePin)
|
|
8409
8484
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8410
8485
|
// …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
|
|
@@ -8438,7 +8513,7 @@ export function createControlPlaneApi(options) {
|
|
|
8438
8513
|
// counts only the gate's half of them.
|
|
8439
8514
|
let counted = [];
|
|
8440
8515
|
const breaksOf = async () => {
|
|
8441
|
-
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
8516
|
+
const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
|
|
8442
8517
|
counted = breaks;
|
|
8443
8518
|
if (breaks.length === 0)
|
|
8444
8519
|
return null;
|
|
@@ -8609,9 +8684,9 @@ export function createControlPlaneApi(options) {
|
|
|
8609
8684
|
if (c.req.method === 'POST') {
|
|
8610
8685
|
const input = z.object({ slug: z.string().min(1), name: z.string().min(1), repository: z.unknown().optional() }).parse(await c.req.json());
|
|
8611
8686
|
const team = tenants.find((t) => t.id === tenant);
|
|
8612
|
-
const slug = await ownerOf(c.get('actor'), input.slug) === tenant || input.slug.startsWith(`${team.slug}/`) ? input.slug : `${team.slug}/${input.slug}`;
|
|
8687
|
+
const slug = await ownerOf(c.var.admin, c.get('actor'), input.slug) === tenant || input.slug.startsWith(`${team.slug}/`) ? input.slug : `${team.slug}/${input.slug}`;
|
|
8613
8688
|
verticalSlug.parse(slug);
|
|
8614
|
-
const owner = await ownerOf(c.get('actor'), slug);
|
|
8689
|
+
const owner = await ownerOf(c.var.admin, c.get('actor'), slug);
|
|
8615
8690
|
if (owner !== undefined && owner !== tenant)
|
|
8616
8691
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8617
8692
|
body = JSON.stringify({ ...input, slug });
|
|
@@ -8666,9 +8741,9 @@ export function createControlPlaneApi(options) {
|
|
|
8666
8741
|
if (!tenant)
|
|
8667
8742
|
return refuse(c, 'not_found', 'unknown workspace');
|
|
8668
8743
|
sourceTenant = tenant.id;
|
|
8669
|
-
sourceSlug = bare.startsWith(`${tenant.slug}/`) || await ownerOf(c.get('actor'), bare) === tenant.id ? bare : `${tenant.slug}/${bare}`;
|
|
8744
|
+
sourceSlug = bare.startsWith(`${tenant.slug}/`) || await ownerOf(c.var.admin, c.get('actor'), bare) === tenant.id ? bare : `${tenant.slug}/${bare}`;
|
|
8670
8745
|
}
|
|
8671
|
-
const owner = await ownerOf(c.get('actor'), sourceSlug);
|
|
8746
|
+
const owner = await ownerOf(c.var.admin, c.get('actor'), sourceSlug);
|
|
8672
8747
|
if (sourceTenant && owner !== undefined && owner !== sourceTenant)
|
|
8673
8748
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8674
8749
|
sourceTenant ??= owner ?? null;
|
|
@@ -8760,7 +8835,7 @@ export function createControlPlaneApi(options) {
|
|
|
8760
8835
|
}
|
|
8761
8836
|
slug = effectiveSlug(p, bare);
|
|
8762
8837
|
// A builder pushes to a slug it owns, or claims an unregistered one (§3).
|
|
8763
|
-
const existingOwner = await ownerOf(c.get('actor'), slug);
|
|
8838
|
+
const existingOwner = await ownerOf(c.var.admin, c.get('actor'), slug);
|
|
8764
8839
|
if (existingOwner !== undefined && existingOwner !== p.tenantId) {
|
|
8765
8840
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8766
8841
|
}
|
|
@@ -8774,9 +8849,9 @@ export function createControlPlaneApi(options) {
|
|
|
8774
8849
|
// hand-registration predating prefixed claims) stays addressable as itself.
|
|
8775
8850
|
// Otherwise the claim lands under the tenant prefix, exactly like a builder push —
|
|
8776
8851
|
// same namespace, no claim race with other workspaces' bare names.
|
|
8777
|
-
const bareOwner = await ownerOf(c.get('actor'), bare);
|
|
8852
|
+
const bareOwner = await ownerOf(c.var.admin, c.get('actor'), bare);
|
|
8778
8853
|
slug = bare.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
8779
|
-
const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
|
|
8854
|
+
const existingOwner = slug === bare ? bareOwner : await ownerOf(c.var.admin, c.get('actor'), slug);
|
|
8780
8855
|
if (existingOwner !== undefined && existingOwner !== workspace.id) {
|
|
8781
8856
|
return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
|
|
8782
8857
|
}
|
|
@@ -8786,7 +8861,7 @@ export function createControlPlaneApi(options) {
|
|
|
8786
8861
|
// No pin: the raw slug is the identity and an existing owner is preserved
|
|
8787
8862
|
// (null ⇒ platform-owned for a first-party vertical) — unchanged staff behavior.
|
|
8788
8863
|
slug = bare;
|
|
8789
|
-
ownerTenant = (await ownerOf(c.get('actor'), slug)) ?? null;
|
|
8864
|
+
ownerTenant = (await ownerOf(c.var.admin, c.get('actor'), slug)) ?? null;
|
|
8790
8865
|
}
|
|
8791
8866
|
// #1923: a slug that would deploy under one of the platform's own script names is refused
|
|
8792
8867
|
// before anything is uploaded. Registration refuses it too; this is the deploy half, for a
|
|
@@ -8864,10 +8939,10 @@ export function createControlPlaneApi(options) {
|
|
|
8864
8939
|
// is judged here and honoured by the grant paths only for a tenant-provisioner. Earlier versions
|
|
8865
8940
|
// are not re-judged.
|
|
8866
8941
|
// Declaring `provisions` marks a manager: the capability itself is granted after a first push.
|
|
8867
|
-
const foreignKey = entitlementNamespaceRefusal(slug, manifest.entitlements ?? [], manifest.provisions?.length ? await verticalSlugsOf(c.get('actor')) : undefined);
|
|
8942
|
+
const foreignKey = entitlementNamespaceRefusal(slug, manifest.entitlements ?? [], manifest.provisions?.length ? await verticalSlugsOf(c.var.admin, c.get('actor')) : undefined);
|
|
8868
8943
|
if (foreignKey)
|
|
8869
8944
|
return refuseStatus(c, 422, foreignKey);
|
|
8870
|
-
const claimed = await slugClaimsSku(c.get('actor'), slug);
|
|
8945
|
+
const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), slug);
|
|
8871
8946
|
if (claimed)
|
|
8872
8947
|
return refuseStatus(c, 422, claimed);
|
|
8873
8948
|
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
@@ -8938,7 +9013,7 @@ export function createControlPlaneApi(options) {
|
|
|
8938
9013
|
// the publish run under the store push's shared hold (#2816): a rollout that starts before the
|
|
8939
9014
|
// version records its stores is refused, and one after walks this vertical.
|
|
8940
9015
|
const deployVertical = options.deployVertical;
|
|
8941
|
-
const pushed = await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
|
|
9016
|
+
const pushed = await ensureScopeStoresOf(c.var.admin, c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
|
|
8942
9017
|
let wire;
|
|
8943
9018
|
try {
|
|
8944
9019
|
wire = await deployVerticalScript(deployVertical, c.get('actor'), slug, deploymentRef, {
|
|
@@ -9343,7 +9418,7 @@ export function createControlPlaneApi(options) {
|
|
|
9343
9418
|
limit: c.req.query('limit'),
|
|
9344
9419
|
});
|
|
9345
9420
|
// #1877: scoped to the app's own scripts — the scan and trust boundary.
|
|
9346
|
-
const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
|
|
9421
|
+
const services = await appServices(c.var.admin, c.get('actor'), input.tenantId, input.scopeId, input.vertical);
|
|
9347
9422
|
const { operation, principalKind, ...rest } = input;
|
|
9348
9423
|
const { events, incomplete } = await options.observability.tenantLogs({
|
|
9349
9424
|
...rest,
|
|
@@ -9380,7 +9455,7 @@ export function createControlPlaneApi(options) {
|
|
|
9380
9455
|
catch (e) {
|
|
9381
9456
|
throw new ControlPlaneError(400, e.message);
|
|
9382
9457
|
}
|
|
9383
|
-
const scopes = await
|
|
9458
|
+
const scopes = await c.var.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
|
|
9384
9459
|
const installed = scopes.find((s) => s.id === input.scopeId);
|
|
9385
9460
|
if (!installed?.vertical)
|
|
9386
9461
|
return refuse(c, 'not_found', 'app not found');
|
|
@@ -9420,7 +9495,7 @@ export function createControlPlaneApi(options) {
|
|
|
9420
9495
|
* nothing. `listScopes`, not `getScopeRecord`: a dashboard read must not write an access
|
|
9421
9496
|
* row per page view.
|
|
9422
9497
|
*/
|
|
9423
|
-
const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await
|
|
9498
|
+
const appServices = async (admin, actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
|
|
9424
9499
|
app.get('/observability/tenant-request-volume', async (c) => {
|
|
9425
9500
|
if (!options.observability?.tenantRequestVolume) {
|
|
9426
9501
|
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
@@ -9430,7 +9505,7 @@ export function createControlPlaneApi(options) {
|
|
|
9430
9505
|
// A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
|
|
9431
9506
|
// 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
|
|
9432
9507
|
const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
|
|
9433
|
-
const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9508
|
+
const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9434
9509
|
return c.json(await options.observability.tenantRequestVolume({ ...scope, services, buckets }));
|
|
9435
9510
|
});
|
|
9436
9511
|
app.get('/observability/tenant-request-facets', async (c) => {
|
|
@@ -9443,7 +9518,7 @@ export function createControlPlaneApi(options) {
|
|
|
9443
9518
|
// that shows three asks for three.
|
|
9444
9519
|
const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
|
|
9445
9520
|
const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
|
|
9446
|
-
const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9521
|
+
const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9447
9522
|
return c.json(await options.observability.tenantRequestFacets({ ...scope, services, ...(keys ? { keys } : {}) }));
|
|
9448
9523
|
});
|
|
9449
9524
|
// #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
|
|
@@ -9472,7 +9547,7 @@ export function createControlPlaneApi(options) {
|
|
|
9472
9547
|
buckets: narrow.buckets,
|
|
9473
9548
|
...(narrow.level.length ? { level: narrow.level } : {}),
|
|
9474
9549
|
...(narrow.operation.length ? { operation: narrow.operation } : {}),
|
|
9475
|
-
services: await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical),
|
|
9550
|
+
services: await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical),
|
|
9476
9551
|
}));
|
|
9477
9552
|
});
|
|
9478
9553
|
// #2762: the Lines histogram and facets — a tenant's request lines and `ctx.log` lines, counted
|
|
@@ -9483,7 +9558,7 @@ export function createControlPlaneApi(options) {
|
|
|
9483
9558
|
}
|
|
9484
9559
|
const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9485
9560
|
const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
|
|
9486
|
-
const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9561
|
+
const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9487
9562
|
return c.json(await options.observability.tenantLogVolume({ ...scope, services, buckets }));
|
|
9488
9563
|
});
|
|
9489
9564
|
app.get('/observability/tenant-log-facets', async (c) => {
|
|
@@ -9491,7 +9566,7 @@ export function createControlPlaneApi(options) {
|
|
|
9491
9566
|
return refuseStatus(c, 501, 'line aggregation is not configured on this control plane');
|
|
9492
9567
|
}
|
|
9493
9568
|
const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9494
|
-
const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9569
|
+
const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9495
9570
|
return c.json(await options.observability.tenantLogFacets({ ...scope, services }));
|
|
9496
9571
|
});
|
|
9497
9572
|
app.get('/observability/tenant-requests', async (c) => {
|
|
@@ -9500,7 +9575,7 @@ export function createControlPlaneApi(options) {
|
|
|
9500
9575
|
}
|
|
9501
9576
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9502
9577
|
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
|
|
9503
|
-
const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9578
|
+
const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
|
|
9504
9579
|
return c.json(await options.observability.tenantRequests({ ...scope, services, limit }));
|
|
9505
9580
|
});
|
|
9506
9581
|
/**
|
|
@@ -9959,7 +10034,7 @@ export function createControlPlaneApi(options) {
|
|
|
9959
10034
|
if (!parsed)
|
|
9960
10035
|
throw new ControlPlaneError(400, `'${baseHostname}' is not a hostname a preview can be minted beside`);
|
|
9961
10036
|
const hostname = withLabel(parsed, `${parsed.label}${RESERVED_LABEL_SEPARATOR}${tag}`);
|
|
9962
|
-
const existing = (await
|
|
10037
|
+
const existing = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
|
|
9963
10038
|
if (!existing) {
|
|
9964
10039
|
await admin.bindHostname(actor, {
|
|
9965
10040
|
hostname,
|
|
@@ -9970,7 +10045,7 @@ export function createControlPlaneApi(options) {
|
|
|
9970
10045
|
canonical: false,
|
|
9971
10046
|
});
|
|
9972
10047
|
}
|
|
9973
|
-
const bound = (await
|
|
10048
|
+
const bound = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
|
|
9974
10049
|
if (bound)
|
|
9975
10050
|
await runIssuance(admin, actor, bound); // platform mint rides the wildcard cert → active
|
|
9976
10051
|
return hostname;
|
|
@@ -9979,9 +10054,9 @@ export function createControlPlaneApi(options) {
|
|
|
9979
10054
|
* source scope's canonical URL; a clean-room preview (no source, #509 ask (b)) has none,
|
|
9980
10055
|
* so it is built from the platform tenant-app convention `<vertical>-<tenant>.<base>` —
|
|
9981
10056
|
* the same scheme provisioning mints (`callout-sesamy.global.substrat.run`). */
|
|
9982
|
-
const previewBaseHostname = async (actor, source, tenantId, slug, surface) => {
|
|
10057
|
+
const previewBaseHostname = async (admin, actor, source, tenantId, slug, surface) => {
|
|
9983
10058
|
if (source) {
|
|
9984
|
-
const own = await
|
|
10059
|
+
const own = await admin.listHostnames(actor, { scopeId: source.id });
|
|
9985
10060
|
const src = own.find((h) => h.canonical && h.surface === surface) ??
|
|
9986
10061
|
own.find((h) => h.canonical) ??
|
|
9987
10062
|
own[0];
|
|
@@ -10003,7 +10078,7 @@ export function createControlPlaneApi(options) {
|
|
|
10003
10078
|
// bare apex and stranded clean-room previews on a hostname that never resolves.
|
|
10004
10079
|
const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
|
|
10005
10080
|
const jurisdiction = 'global';
|
|
10006
|
-
const tenant = await
|
|
10081
|
+
const tenant = await admin.getTenant(actor, tenantId);
|
|
10007
10082
|
const handle = tenant?.slug ?? tenantId;
|
|
10008
10083
|
return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
|
|
10009
10084
|
};
|
|
@@ -10241,7 +10316,7 @@ export function createControlPlaneApi(options) {
|
|
|
10241
10316
|
: [];
|
|
10242
10317
|
// The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
|
|
10243
10318
|
// convention (clean-room). Computed once so reuse and fresh mint the same URL.
|
|
10244
|
-
const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
|
|
10319
|
+
const baseHostname = await previewBaseHostname(c.var.admin, actor, source, tenantId, slug, surface);
|
|
10245
10320
|
// The founding #559 case lands its durable row HERE, not in onError: the previews
|
|
10246
10321
|
// route answers a ControlPlaneError directly (its own catch, never the app-level
|
|
10247
10322
|
// recorder), and only this frame knows the preview's scopeId (`intoId`) — the key that lets
|
|
@@ -10807,7 +10882,7 @@ export function createControlPlaneApi(options) {
|
|
|
10807
10882
|
const previewVertical = async (c) => {
|
|
10808
10883
|
const p = c.get('principal');
|
|
10809
10884
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
10810
|
-
const v = await verticalOf(c.get('actor'), slug);
|
|
10885
|
+
const v = await verticalOf(c.var.admin, c.get('actor'), slug);
|
|
10811
10886
|
if (!v)
|
|
10812
10887
|
return { refused: 'not_found', error: `unknown vertical '${slug}'` };
|
|
10813
10888
|
if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
|
|
@@ -11588,7 +11663,7 @@ export function createControlPlaneApi(options) {
|
|
|
11588
11663
|
const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
|
|
11589
11664
|
if (!workspace)
|
|
11590
11665
|
return refuse(c, 'not_found', `unknown workspace '${pin}'`);
|
|
11591
|
-
const bareOwner = await ownerOf(c.get('actor'), vertical);
|
|
11666
|
+
const bareOwner = await ownerOf(c.var.admin, c.get('actor'), vertical);
|
|
11592
11667
|
id = vertical.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? vertical : `${workspace.slug}/${vertical}`;
|
|
11593
11668
|
}
|
|
11594
11669
|
const drivers = (options.storeDrivers ?? STORE_DRIVERS).map((d) => d.verticals ? { ...d, verticals: storeDriverServesVertical(d, id) ? [vertical] : [] } : d);
|