@substrat-run/control-plane-api 0.177.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 +4 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +435 -296
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +58 -13
- 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/directory-backup.d.ts +4 -0
- package/dist/directory-backup.d.ts.map +1 -1
- package/dist/directory-backup.js +60 -7
- package/dist/directory-backup.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lake-gateway.d.ts +9 -0
- package/dist/lake-gateway.d.ts.map +1 -1
- package/dist/lake-gateway.js +18 -1
- package/dist/lake-gateway.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 +22 -17
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +173 -34
- package/dist/r2-backups.js.map +1 -1
- package/dist/store-entry.d.ts.map +1 -1
- package/dist/store-entry.generated.d.ts +2 -7
- package/dist/store-entry.generated.d.ts.map +1 -1
- package/dist/store-entry.generated.js +5 -10
- package/dist/store-entry.generated.js.map +1 -1
- package/dist/store-entry.js +3 -2
- package/dist/store-entry.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/store-releases.d.ts +18 -4
- package/dist/store-releases.d.ts.map +1 -1
- package/dist/store-releases.js +74 -11
- package/dist/store-releases.js.map +1 -1
- package/dist/store-script.d.ts +12 -2
- package/dist/store-script.d.ts.map +1 -1
- package/dist/store-script.js +47 -9
- package/dist/store-script.js.map +1 -1
- 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 +9 -9
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';
|
|
@@ -75,7 +77,7 @@ import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
|
|
|
75
77
|
import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
|
|
76
78
|
import { wireKeyDropSince, wireReadinessReport, WIRE_READINESS_LIMIT_DEFAULT, WIRE_READINESS_LIMIT_MAX } from './wire-readiness.js';
|
|
77
79
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
78
|
-
import { queryLakeEvents } from './lake-gateway.js';
|
|
80
|
+
import { queryLakeEvents, teamWindowRefusal } from './lake-gateway.js';
|
|
79
81
|
import { LakeQueryError } from './r2-sql-engine.js';
|
|
80
82
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
81
83
|
import { isSocialRelayVertical, reconcileScopeSocialRelay, relayHostnames, } from './social-relay.js';
|
|
@@ -780,6 +782,8 @@ const TENANT_ROUTES = [
|
|
|
780
782
|
// grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
|
|
781
783
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
|
|
782
784
|
{ method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
|
|
785
|
+
// #1334: the team's own lake events. The handler adds the window rule for this credential.
|
|
786
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/lake\/events$/, pin: 'path' },
|
|
783
787
|
// Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
|
|
784
788
|
// checks WHO inside the tenant may read or act (its findings permissions) before calling.
|
|
785
789
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
|
|
@@ -1014,7 +1018,7 @@ export function createControlPlaneApi(options) {
|
|
|
1014
1018
|
/** `substrat.wipe` on each declared store, before the scope's own bytes go. */
|
|
1015
1019
|
const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
|
|
1016
1020
|
/**
|
|
1017
|
-
*
|
|
1021
|
+
* The stores a backup, a full pull or a fork copies: those the scope's running version declares.
|
|
1018
1022
|
*
|
|
1019
1023
|
* A dump is only worth taking if it restores, and a restore imports only what the destination's
|
|
1020
1024
|
* version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
|
|
@@ -1023,24 +1027,29 @@ export function createControlPlaneApi(options) {
|
|
|
1023
1027
|
* way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
|
|
1024
1028
|
* never export, so they reach such a store regardless.
|
|
1025
1029
|
*/
|
|
1026
|
-
const
|
|
1030
|
+
const platformStoreReach = async (c, scope) => {
|
|
1027
1031
|
const caller = options.platformScopeStores;
|
|
1028
1032
|
if (!caller || !scope.vertical)
|
|
1029
1033
|
return undefined;
|
|
1030
1034
|
const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
|
|
1031
1035
|
if (all.length === 0)
|
|
1032
1036
|
return undefined;
|
|
1033
|
-
const
|
|
1037
|
+
const place = { tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical };
|
|
1034
1038
|
const current = new Set(running.map((store) => store.binding));
|
|
1035
|
-
const dropped
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
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 ` +
|
|
1039
1043
|
`still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
|
|
1040
|
-
`restored. Declare '${
|
|
1044
|
+
`restored. Declare '${dropped.binding}' as a '${dropped.driver}' again in a version the scope runs, then export; ` +
|
|
1041
1045
|
`or reap the scope without a backup, which wipes it`);
|
|
1042
1046
|
}
|
|
1043
|
-
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);
|
|
1044
1053
|
};
|
|
1045
1054
|
/**
|
|
1046
1055
|
* `substrat.erase` on the destination's stores for each subject tombstoned in the destination
|
|
@@ -1262,13 +1271,23 @@ export function createControlPlaneApi(options) {
|
|
|
1262
1271
|
// #977: a tenant credential minted for a person writes through a view that names
|
|
1263
1272
|
// them on every admin row. Only the credential's own claim can supply the person —
|
|
1264
1273
|
// never a header or a body — and only the staff mint writes that claim.
|
|
1265
|
-
const view = principal
|
|
1266
|
-
? host.attributed(principal.onBehalfOf)
|
|
1267
|
-
: host;
|
|
1274
|
+
const view = viewOf(principal);
|
|
1268
1275
|
c.set('host', view);
|
|
1269
1276
|
c.set('admin', view.admin);
|
|
1270
1277
|
await next();
|
|
1271
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;
|
|
1272
1291
|
// Confine a BUILDER to the vertical-management surface, fail-CLOSED (builder-plane.md
|
|
1273
1292
|
// §4). Default-deny by design: a builder reaches only the routes on this allowlist,
|
|
1274
1293
|
// and only for verticals its tenant owns (the per-route ownership checks below). A
|
|
@@ -1299,6 +1318,9 @@ export function createControlPlaneApi(options) {
|
|
|
1299
1318
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
|
|
1300
1319
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/concept$/ },
|
|
1301
1320
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
|
|
1321
|
+
// The scope-store drivers one vertical may use (#2813), so a push judges its stores as the
|
|
1322
|
+
// upload will. `?vertical=` required; the answer names no other vertical.
|
|
1323
|
+
{ method: 'GET', re: /\/store-drivers$/ },
|
|
1302
1324
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
|
|
1303
1325
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
|
|
1304
1326
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
|
|
@@ -1542,7 +1564,7 @@ export function createControlPlaneApi(options) {
|
|
|
1542
1564
|
const vertical = await resolveVerticalId(c, raw);
|
|
1543
1565
|
const owner = (pathTenant ?? confined)
|
|
1544
1566
|
? null
|
|
1545
|
-
: ((await verticalOf(c.get('actor'), vertical))?.ownerTenant ?? null);
|
|
1567
|
+
: ((await verticalOf(c.var.admin, c.get('actor'), vertical))?.ownerTenant ?? null);
|
|
1546
1568
|
return { vertical, tenantId: pathTenant ?? confined ?? owner };
|
|
1547
1569
|
}
|
|
1548
1570
|
catch {
|
|
@@ -2944,7 +2966,7 @@ export function createControlPlaneApi(options) {
|
|
|
2944
2966
|
resolveVertical: options.resolveVertical,
|
|
2945
2967
|
resolveVerticalVersion: options.resolveVerticalVersion,
|
|
2946
2968
|
resolveVerticalRef: options.resolveVerticalRef,
|
|
2947
|
-
ownerOf,
|
|
2969
|
+
ownerOf: (actor, slug) => ownerOf(c.var.admin, actor, slug),
|
|
2948
2970
|
tenantSlugOf: async (actor, tenantId) => (await c.var.admin.getTenant(actor, tenantId).catch(() => null))?.slug ?? null,
|
|
2949
2971
|
}, c.get('actor'), scope);
|
|
2950
2972
|
/**
|
|
@@ -2959,11 +2981,11 @@ export function createControlPlaneApi(options) {
|
|
|
2959
2981
|
* exact split so it is a seconds-long diagnosis, not a multi-hour hunt. A couple of
|
|
2960
2982
|
* directory reads only when a call has already failed to resolve.
|
|
2961
2983
|
*/
|
|
2962
|
-
const diagnoseUnboundScope = async (actor, scope) => {
|
|
2984
|
+
const diagnoseUnboundScope = async (admin, actor, scope) => {
|
|
2963
2985
|
const slug = scope.vertical;
|
|
2964
2986
|
if (!slug)
|
|
2965
2987
|
return 'scope has no vertical bound — nothing to deliver to';
|
|
2966
|
-
const versions = await
|
|
2988
|
+
const versions = await admin.listVersions(actor, slug).catch(() => []);
|
|
2967
2989
|
const boundId = scope.verticalVersionId;
|
|
2968
2990
|
if (boundId && !versions.some((v) => v.id === boundId)) {
|
|
2969
2991
|
return (`scope is bound to version '${boundId}', which is not among vertical '${slug}'’s ${versions.length} ` +
|
|
@@ -2976,7 +2998,7 @@ export function createControlPlaneApi(options) {
|
|
|
2976
2998
|
`pushes are on different slugs (a lineage fork). Push under '${slug}' by setting \`substrat.slug\`, ` +
|
|
2977
2999
|
`or rebind the install to the slug the versions live under.`);
|
|
2978
3000
|
}
|
|
2979
|
-
const serving = await
|
|
3001
|
+
const serving = await admin.verticalServing(actor, slug).catch(() => null);
|
|
2980
3002
|
if (!serving) {
|
|
2981
3003
|
return `vertical '${slug}' has ${versions.length} version(s) but none is promoted to a serving channel — promote one to prod.`;
|
|
2982
3004
|
}
|
|
@@ -2990,10 +3012,10 @@ export function createControlPlaneApi(options) {
|
|
|
2990
3012
|
* `servingRef` is the route the scope takes in the same act (an adopt). The host's read also
|
|
2991
3013
|
* refuses, whatever is acknowledged, a move that strands people a link bound (#2178).
|
|
2992
3014
|
*/
|
|
2993
|
-
const refuseOnBreaks = async (actor, tenantId, scopeId, versionId, opts) => {
|
|
3015
|
+
const refuseOnBreaks = async (admin, actor, tenantId, scopeId, versionId, opts) => {
|
|
2994
3016
|
// Asked even when acknowledged: the read also refuses a move that strands people a link
|
|
2995
3017
|
// bound (#2178), which no acknowledgement passes.
|
|
2996
|
-
const breaks = await
|
|
3018
|
+
const breaks = await admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
|
|
2997
3019
|
if (opts.acknowledge?.exportBreak)
|
|
2998
3020
|
return;
|
|
2999
3021
|
if (breaks.length > 0)
|
|
@@ -3025,7 +3047,7 @@ export function createControlPlaneApi(options) {
|
|
|
3025
3047
|
* as it stood before the failed attempt. Every caller still re-asserts after it (the
|
|
3026
3048
|
* fallback), passing what this reports.
|
|
3027
3049
|
*/
|
|
3028
|
-
const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables,
|
|
3050
|
+
const restoreCarryingSwitches = async (admin, actor, dest, tenantId, scopeId, tables,
|
|
3029
3051
|
/** #1869: the scope `tables` were captured from — `scopeId` itself unless the copy moves —
|
|
3030
3052
|
* and `exact` when the platform exported them itself rather than a caller supplying them.
|
|
3031
3053
|
* #1722: `loadStamp`, the stamp a carry leaves on the copy it lands, and `expect`, the marker
|
|
@@ -3035,10 +3057,10 @@ export function createControlPlaneApi(options) {
|
|
|
3035
3057
|
// the destination is not primary, the vertical marks it a copy in its own storage — a carry
|
|
3036
3058
|
// of a copy made before every copy carried the marker brings none, and a CP-less host reads
|
|
3037
3059
|
// the marker for primacy. Idempotent there; an install is never marked.
|
|
3038
|
-
const rec = await
|
|
3060
|
+
const rec = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3039
3061
|
const markCopy = rec ? copyLineageOf(rec) : undefined;
|
|
3040
3062
|
return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, {
|
|
3041
|
-
...(await switchCarryFor(
|
|
3063
|
+
...(await switchCarryFor(admin, actor, { tenantId, scopeId })),
|
|
3042
3064
|
sourceScopeId: source.scopeId,
|
|
3043
3065
|
exact: source.exact,
|
|
3044
3066
|
loadStamp: source.loadStamp,
|
|
@@ -3072,7 +3094,7 @@ export function createControlPlaneApi(options) {
|
|
|
3072
3094
|
}
|
|
3073
3095
|
// #1756: onto the serving script, the scope runs the served version from the moment routing
|
|
3074
3096
|
// flips — whatever its own version exported. Asked before the export below moves anything.
|
|
3075
|
-
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
3097
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
|
|
3076
3098
|
servingRef: serving.ref,
|
|
3077
3099
|
acknowledge: opts.acknowledge,
|
|
3078
3100
|
});
|
|
@@ -3115,7 +3137,7 @@ export function createControlPlaneApi(options) {
|
|
|
3115
3137
|
const serving = await c.var.admin.verticalServing(actor, slug);
|
|
3116
3138
|
if (!serving)
|
|
3117
3139
|
return none; // embedded / not dispatch-backed — the host cascade handled rebinds
|
|
3118
|
-
const v = await verticalOf(actor, slug);
|
|
3140
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
3119
3141
|
if (!v || v.ownerTenant === null || v.listed)
|
|
3120
3142
|
return none; // private only, like the host cascade
|
|
3121
3143
|
const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
|
|
@@ -3213,7 +3235,7 @@ export function createControlPlaneApi(options) {
|
|
|
3213
3235
|
const { review, ...bindAck } = opts.acknowledge ?? {};
|
|
3214
3236
|
// #1756: within one lineage this is an adopt, and judged as one before anything moves. A
|
|
3215
3237
|
// crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
|
|
3216
|
-
await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
|
|
3238
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
|
|
3217
3239
|
servingRef: serving.ref,
|
|
3218
3240
|
acknowledge: bindAck,
|
|
3219
3241
|
});
|
|
@@ -3319,7 +3341,7 @@ export function createControlPlaneApi(options) {
|
|
|
3319
3341
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, scopeId);
|
|
3320
3342
|
// #2450: a page at a time into the destination's stage, so a scope of any size moves.
|
|
3321
3343
|
const { dump, loadStamp: sourceStamp, revision: sourceRevision } = await transferScope(source, dest, { from: scopeId }, { retry: retryTransient });
|
|
3322
|
-
const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, {
|
|
3344
|
+
const restored = await restoreCarryingSwitches(c.var.admin, actor, dest, tenantId, scopeId, dump, {
|
|
3323
3345
|
scopeId, exact: true, loadStamp: restoredStamp,
|
|
3324
3346
|
...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
|
|
3325
3347
|
});
|
|
@@ -3744,7 +3766,7 @@ export function createControlPlaneApi(options) {
|
|
|
3744
3766
|
throw new ControlPlaneError(412, `the copy of scope ${scope.id} in '${from}' was already carried to another script and wiped, so nothing ` +
|
|
3745
3767
|
`was moved or bound (#1722). Reload the scope and retry.`);
|
|
3746
3768
|
}
|
|
3747
|
-
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, {
|
|
3748
3770
|
scopeId: scope.id,
|
|
3749
3771
|
exact: true,
|
|
3750
3772
|
loadStamp: restoredStamp,
|
|
@@ -4111,7 +4133,7 @@ export function createControlPlaneApi(options) {
|
|
|
4111
4133
|
await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, recarryId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
|
|
4112
4134
|
try {
|
|
4113
4135
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
|
|
4114
|
-
await restoreCarryingSwitches(actor, carried.dest, scope.tenantId, scope.id, dump, {
|
|
4136
|
+
await restoreCarryingSwitches(c.var.admin, actor, carried.dest, scope.tenantId, scope.id, dump, {
|
|
4115
4137
|
scopeId: scope.id,
|
|
4116
4138
|
exact: true,
|
|
4117
4139
|
loadStamp: restoredStamp,
|
|
@@ -4277,13 +4299,13 @@ export function createControlPlaneApi(options) {
|
|
|
4277
4299
|
const p = c.get('principal');
|
|
4278
4300
|
const scopePin = confinedTenant(p);
|
|
4279
4301
|
if (scopePin) {
|
|
4280
|
-
const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
|
|
4302
|
+
const v = scope.vertical ? await verticalOf(c.var.admin, actor, scope.vertical) : undefined;
|
|
4281
4303
|
if (!v || v.ownerTenant !== scopePin)
|
|
4282
4304
|
return refuse(c, 'forbidden', 'forbidden');
|
|
4283
4305
|
}
|
|
4284
4306
|
const reached = await deploymentForScope(c, scope);
|
|
4285
4307
|
if (!reached)
|
|
4286
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
4308
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
4287
4309
|
const vertical = reached.client;
|
|
4288
4310
|
const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
|
|
4289
4311
|
// #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
|
|
@@ -4826,7 +4848,7 @@ export function createControlPlaneApi(options) {
|
|
|
4826
4848
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4827
4849
|
const vertical = await verticalForScope(c, scope);
|
|
4828
4850
|
if (!vertical)
|
|
4829
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
4851
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
4830
4852
|
try {
|
|
4831
4853
|
return c.json(await vertical.ownerSeat(tenantId, scopeId));
|
|
4832
4854
|
}
|
|
@@ -4862,7 +4884,7 @@ export function createControlPlaneApi(options) {
|
|
|
4862
4884
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4863
4885
|
const vertical = await verticalForScope(c, scope);
|
|
4864
4886
|
if (!vertical)
|
|
4865
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
4887
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
4866
4888
|
const origin = await instanceOrigin(c.var.admin, actor, scopeId);
|
|
4867
4889
|
if (!origin) {
|
|
4868
4890
|
return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
|
|
@@ -4903,7 +4925,7 @@ export function createControlPlaneApi(options) {
|
|
|
4903
4925
|
return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
|
|
4904
4926
|
const vertical = await verticalForScope(c, scope);
|
|
4905
4927
|
if (!vertical)
|
|
4906
|
-
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
|
|
4928
|
+
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope)) };
|
|
4907
4929
|
return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
|
|
4908
4930
|
};
|
|
4909
4931
|
/**
|
|
@@ -5023,7 +5045,7 @@ export function createControlPlaneApi(options) {
|
|
|
5023
5045
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5024
5046
|
const vertical = await verticalForScope(c, scope);
|
|
5025
5047
|
if (!vertical)
|
|
5026
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
5048
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
5027
5049
|
const operationId = ulid();
|
|
5028
5050
|
// An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
|
|
5029
5051
|
// a hand-over closed without finishing from one that finished.
|
|
@@ -5059,7 +5081,7 @@ export function createControlPlaneApi(options) {
|
|
|
5059
5081
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5060
5082
|
const vertical = await verticalForScope(c, scope);
|
|
5061
5083
|
if (!vertical) {
|
|
5062
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
5084
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
|
|
5063
5085
|
}
|
|
5064
5086
|
try {
|
|
5065
5087
|
await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
|
|
@@ -5212,12 +5234,17 @@ export function createControlPlaneApi(options) {
|
|
|
5212
5234
|
}
|
|
5213
5235
|
await retryTransient(() => copier.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId, tenantId }));
|
|
5214
5236
|
// The scope file was copied inside the script. The store objects are per scope, so the
|
|
5215
|
-
// fork gets its own copy
|
|
5216
|
-
// 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.
|
|
5217
5240
|
if (reach) {
|
|
5218
|
-
const
|
|
5219
|
-
|
|
5220
|
-
|
|
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 {
|
|
5221
5248
|
replay = await replayPlatformStoreErasures(c, { ...scope, id: snapId }, scope.id);
|
|
5222
5249
|
}
|
|
5223
5250
|
}
|
|
@@ -5399,9 +5426,11 @@ export function createControlPlaneApi(options) {
|
|
|
5399
5426
|
const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
5400
5427
|
const vertical = await verticalForScope(c, scope);
|
|
5401
5428
|
// Store tables are opaque and stay unsealed. The platform reads them from the store script
|
|
5402
|
-
// (#2207)
|
|
5403
|
-
|
|
5404
|
-
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));
|
|
5405
5434
|
// #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
|
|
5406
5435
|
// moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
|
|
5407
5436
|
// copied as it was at one moment, and never held whole here. A deployment that predates
|
|
@@ -5411,11 +5440,18 @@ export function createControlPlaneApi(options) {
|
|
|
5411
5440
|
: hostTables;
|
|
5412
5441
|
if (Array.isArray(moved))
|
|
5413
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 = [];
|
|
5414
5445
|
try {
|
|
5415
|
-
|
|
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 })));
|
|
5416
5450
|
}
|
|
5417
5451
|
catch (e) {
|
|
5418
5452
|
await backup.discard();
|
|
5453
|
+
for (const s of stores)
|
|
5454
|
+
await writer.discard(s.moveId).catch(() => undefined);
|
|
5419
5455
|
throw e;
|
|
5420
5456
|
}
|
|
5421
5457
|
};
|
|
@@ -5454,7 +5490,7 @@ export function createControlPlaneApi(options) {
|
|
|
5454
5490
|
},
|
|
5455
5491
|
discardStagedScope: (_, stageId) => writer.discard(stageId),
|
|
5456
5492
|
},
|
|
5457
|
-
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 }),
|
|
5458
5494
|
discard: () => writer.discard(last.stageId),
|
|
5459
5495
|
};
|
|
5460
5496
|
};
|
|
@@ -5494,6 +5530,16 @@ export function createControlPlaneApi(options) {
|
|
|
5494
5530
|
if (held.entries > 1) {
|
|
5495
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`);
|
|
5496
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
|
+
}
|
|
5497
5543
|
const { tables } = await held.entry(0);
|
|
5498
5544
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
5499
5545
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
@@ -5501,7 +5547,7 @@ export function createControlPlaneApi(options) {
|
|
|
5501
5547
|
// working: the ciphertext never left the object, and nothing turns it back into a
|
|
5502
5548
|
// person. A dump taken before sealing existed passes through untouched.
|
|
5503
5549
|
const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
|
|
5504
|
-
return c.json({ ...held.header, tables: opened });
|
|
5550
|
+
return c.json({ ...held.header, stores: [...held.header.stores, ...pagedStores], tables: opened });
|
|
5505
5551
|
});
|
|
5506
5552
|
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
5507
5553
|
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
@@ -5912,7 +5958,7 @@ export function createControlPlaneApi(options) {
|
|
|
5912
5958
|
}
|
|
5913
5959
|
const vertical = await verticalForScope(c, scope);
|
|
5914
5960
|
if (!vertical)
|
|
5915
|
-
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
5961
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
|
|
5916
5962
|
const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
|
|
5917
5963
|
await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
|
|
5918
5964
|
return c.json({ cleared });
|
|
@@ -6012,9 +6058,10 @@ export function createControlPlaneApi(options) {
|
|
|
6012
6058
|
// closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
|
|
6013
6059
|
// read whole. Envelope only — no payload until erasure reaches the lake.
|
|
6014
6060
|
//
|
|
6015
|
-
// Staff
|
|
6016
|
-
//
|
|
6017
|
-
//
|
|
6061
|
+
// Staff, and a team's own credential (TENANT_ROUTES, path-pinned). The dashboard checks
|
|
6062
|
+
// `dashboard:read-event-lake` before it calls. A team's read must name a window of at most
|
|
6063
|
+
// `LAKE_TEAM_WINDOW_DAYS` (`teamWindowRefusal`): nothing below the gateway prunes by tenant,
|
|
6064
|
+
// so the window is what bounds the scan. Builders stay refused (not in BUILDER_ROUTES).
|
|
6018
6065
|
//
|
|
6019
6066
|
// K-24: the access row is written AFTER the engine answers and BEFORE anything is
|
|
6020
6067
|
// returned, and a failed write fails the request — rows whose disclosure went unrecorded
|
|
@@ -6035,6 +6082,11 @@ export function createControlPlaneApi(options) {
|
|
|
6035
6082
|
issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
|
|
6036
6083
|
});
|
|
6037
6084
|
}
|
|
6085
|
+
if (c.get('principal').kind === 'tenant') {
|
|
6086
|
+
const refusal = teamWindowRefusal(parsed.data, Date.now());
|
|
6087
|
+
if (refusal)
|
|
6088
|
+
return refuse(c, 'validation_failed', refusal);
|
|
6089
|
+
}
|
|
6038
6090
|
let page;
|
|
6039
6091
|
try {
|
|
6040
6092
|
page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
|
|
@@ -6370,9 +6422,15 @@ export function createControlPlaneApi(options) {
|
|
|
6370
6422
|
if (!held)
|
|
6371
6423
|
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
6372
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
|
+
}));
|
|
6373
6431
|
if (options.platformScopeStores) {
|
|
6374
6432
|
try {
|
|
6375
|
-
await refuseUndeclaredDumpStores(c, scope, header.stores);
|
|
6433
|
+
await refuseUndeclaredDumpStores(c, scope, [...header.stores, ...pagedStores]);
|
|
6376
6434
|
}
|
|
6377
6435
|
catch (e) {
|
|
6378
6436
|
if (e instanceof ControlPlaneError)
|
|
@@ -6387,7 +6445,7 @@ export function createControlPlaneApi(options) {
|
|
|
6387
6445
|
if (tables.length === 0) {
|
|
6388
6446
|
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
6389
6447
|
}
|
|
6390
|
-
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 }));
|
|
6391
6449
|
}
|
|
6392
6450
|
// The host judges the roles the backup seats or defines before anything is replaced (#2555), and
|
|
6393
6451
|
// records the tables it restores. So it is handed every table's schema and the role tables' rows,
|
|
@@ -6421,7 +6479,7 @@ export function createControlPlaneApi(options) {
|
|
|
6421
6479
|
}
|
|
6422
6480
|
return { dump: { stageId }, tables: judged.tables() };
|
|
6423
6481
|
};
|
|
6424
|
-
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
|
|
6482
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage, pagedStores }));
|
|
6425
6483
|
}
|
|
6426
6484
|
catch (e) {
|
|
6427
6485
|
return restoreFailed(c, scope, e);
|
|
@@ -6450,32 +6508,55 @@ export function createControlPlaneApi(options) {
|
|
|
6450
6508
|
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6451
6509
|
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6452
6510
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6453
|
-
|
|
6454
|
-
|
|
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;
|
|
6455
6532
|
try {
|
|
6456
|
-
|
|
6457
|
-
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 };
|
|
6458
6536
|
const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
6459
|
-
if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
|
|
6460
|
-
await storesCaller.importStores({
|
|
6461
|
-
tenantId,
|
|
6462
|
-
scopeId,
|
|
6463
|
-
vertical: scope.vertical,
|
|
6464
|
-
stores: header.stores,
|
|
6465
|
-
});
|
|
6466
|
-
}
|
|
6467
|
-
const platformReplay = storesCaller && scope.vertical
|
|
6468
|
-
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
|
|
6469
|
-
: undefined;
|
|
6470
|
-
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
6471
6537
|
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
6472
6538
|
// off in the replay's own event — its own sweeper cannot land in between.
|
|
6473
6539
|
const restored = vertical
|
|
6474
|
-
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6540
|
+
? await restoreCarryingSwitches(c.var.admin, actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6475
6541
|
scopeId: sourceHint ?? origin,
|
|
6476
6542
|
exact: false,
|
|
6477
6543
|
})
|
|
6478
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);
|
|
6479
6560
|
// #1674: a backup from before a module was switched off brings it back on; the
|
|
6480
6561
|
// directory's record puts it back off now, not at the next reconcile. Where the
|
|
6481
6562
|
// deployment already did (#1742), this finds it done and audits what it reported.
|
|
@@ -6495,12 +6576,17 @@ export function createControlPlaneApi(options) {
|
|
|
6495
6576
|
};
|
|
6496
6577
|
}
|
|
6497
6578
|
catch (e) {
|
|
6498
|
-
// 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.
|
|
6499
6581
|
if (staged && vertical)
|
|
6500
6582
|
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
|
|
6583
|
+
if (storeEnds && !committing)
|
|
6584
|
+
await discardMoves(storeEnds, movedStores);
|
|
6501
6585
|
throw e;
|
|
6502
6586
|
}
|
|
6503
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) }));
|
|
6504
6590
|
/** How a refused or failed restore answers, on either route. */
|
|
6505
6591
|
const restoreFailed = async (c, scope, e) => {
|
|
6506
6592
|
// #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
|
|
@@ -6622,11 +6708,11 @@ export function createControlPlaneApi(options) {
|
|
|
6622
6708
|
const actor = c.get('actor');
|
|
6623
6709
|
const adoptPin = confinedTenant(p);
|
|
6624
6710
|
if (adoptPin) {
|
|
6625
|
-
const v = await verticalOf(actor, slug);
|
|
6711
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
6626
6712
|
if (!v || v.ownerTenant !== adoptPin)
|
|
6627
6713
|
return refuse(c, 'forbidden', 'forbidden');
|
|
6628
6714
|
}
|
|
6629
|
-
const v = await verticalOf(actor, slug);
|
|
6715
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
6630
6716
|
if (!v)
|
|
6631
6717
|
return refuse(c, 'not_found', `unknown vertical '${slug}'`);
|
|
6632
6718
|
if (v.ownerTenant === null) {
|
|
@@ -6932,7 +7018,7 @@ export function createControlPlaneApi(options) {
|
|
|
6932
7018
|
// #2185: before the load as well as after it, so a throw once the kept copy has landed (the
|
|
6933
7019
|
// re-assert) cannot leave the receipt set over older data.
|
|
6934
7020
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6935
|
-
const restored = await restoreCarryingSwitches(actor, live, tenantId, scopeId, dump, {
|
|
7021
|
+
const restored = await restoreCarryingSwitches(c.var.admin, actor, live, tenantId, scopeId, dump, {
|
|
6936
7022
|
scopeId,
|
|
6937
7023
|
exact: true,
|
|
6938
7024
|
loadStamp: ulid(),
|
|
@@ -6999,7 +7085,7 @@ export function createControlPlaneApi(options) {
|
|
|
6999
7085
|
// first (a version not admitted) the same way. An unknown scope has no impact to read, and
|
|
7000
7086
|
// the bind below refuses it as it always has.
|
|
7001
7087
|
if (scope)
|
|
7002
|
-
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7088
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7003
7089
|
// A refusal throws a ControlPlaneError, which the app's error boundary relays with its
|
|
7004
7090
|
// status and records as an ops failure. An unknown scope carries nothing, and the bind
|
|
7005
7091
|
// below refuses it as it always has.
|
|
@@ -7045,7 +7131,7 @@ export function createControlPlaneApi(options) {
|
|
|
7045
7131
|
try {
|
|
7046
7132
|
// Asked again after the carry: a promote landing since the first question would
|
|
7047
7133
|
// otherwise leave an archive behind for a bind the host then refuses.
|
|
7048
|
-
await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7134
|
+
await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
|
|
7049
7135
|
await orchestratedSnapshot(c, tenantId, scope, {});
|
|
7050
7136
|
}
|
|
7051
7137
|
catch (e) {
|
|
@@ -7241,14 +7327,14 @@ export function createControlPlaneApi(options) {
|
|
|
7241
7327
|
// registry row's owner_tenant (undefined ⇒ not registered — a claimable slug).
|
|
7242
7328
|
// `listVerticals` returns every row (the adapter does not narrow); the transport is
|
|
7243
7329
|
// where per-principal filtering happens — here and on GET /verticals.
|
|
7244
|
-
const ownerOf = async (actor, slug) => {
|
|
7245
|
-
const v = (await
|
|
7330
|
+
const ownerOf = async (admin, actor, slug) => {
|
|
7331
|
+
const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
7246
7332
|
return v ? v.ownerTenant : undefined;
|
|
7247
7333
|
};
|
|
7248
7334
|
// The full registry row, for the checks that need more than the owner — whether the
|
|
7249
7335
|
// vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
|
|
7250
7336
|
// self-serve below.
|
|
7251
|
-
const verticalOf = async (actor, slug) => (await
|
|
7337
|
+
const verticalOf = async (admin, actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
|
|
7252
7338
|
/**
|
|
7253
7339
|
* "This principal is confined, and this vertical is not its tenant's." The one
|
|
7254
7340
|
* predicate every slug-addressed route asks.
|
|
@@ -7260,7 +7346,7 @@ export function createControlPlaneApi(options) {
|
|
|
7260
7346
|
*/
|
|
7261
7347
|
const notOwned = async (p, slug) => {
|
|
7262
7348
|
const pin = confinedTenant(p);
|
|
7263
|
-
return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
|
|
7349
|
+
return pin !== null && (await ownerOf(adminFor(p), p.actor, slug)) !== pin;
|
|
7264
7350
|
};
|
|
7265
7351
|
/**
|
|
7266
7352
|
* The READ twin of `notOwned`, for the routes an installed app's own pages ask
|
|
@@ -7273,7 +7359,7 @@ export function createControlPlaneApi(options) {
|
|
|
7273
7359
|
* the prod history).
|
|
7274
7360
|
*/
|
|
7275
7361
|
/** The bare slug of every registered vertical: what a manager's SKU may not be (#2388). */
|
|
7276
|
-
const verticalSlugsOf = async (actor) => (await
|
|
7362
|
+
const verticalSlugsOf = async (admin, actor) => (await admin.listVerticals(actor)).map((v) => bareSlug(v.slug));
|
|
7277
7363
|
/**
|
|
7278
7364
|
* Whether a CONFINED principal may grant its own tenant `key` (#977): some vertical it
|
|
7279
7365
|
* can read — its own, or a listed one — declares the key, or is the vertical whose
|
|
@@ -7288,8 +7374,8 @@ export function createControlPlaneApi(options) {
|
|
|
7288
7374
|
* the first push). Its own slug and the engine keys are always declarable, so they reserve nothing.
|
|
7289
7375
|
* Only a new slug is judged; the sentence names no other vertical.
|
|
7290
7376
|
*/
|
|
7291
|
-
const slugClaimsSku = async (actor, slug) => {
|
|
7292
|
-
const all = await
|
|
7377
|
+
const slugClaimsSku = async (admin, actor, slug) => {
|
|
7378
|
+
const all = await admin.listVerticals(actor);
|
|
7293
7379
|
if (all.some((v) => v.slug === slug))
|
|
7294
7380
|
return undefined;
|
|
7295
7381
|
const name = bareSlug(slug);
|
|
@@ -7306,7 +7392,7 @@ export function createControlPlaneApi(options) {
|
|
|
7306
7392
|
const pin = confinedTenant(p);
|
|
7307
7393
|
if (pin === null)
|
|
7308
7394
|
return true;
|
|
7309
|
-
const all = await
|
|
7395
|
+
const all = await adminFor(p).listVerticals(p.actor);
|
|
7310
7396
|
const slugs = all.map((v) => bareSlug(v.slug));
|
|
7311
7397
|
const visible = all.filter((v) => v.ownerTenant === pin || v.listed);
|
|
7312
7398
|
return visible.some((v) => {
|
|
@@ -7320,7 +7406,7 @@ export function createControlPlaneApi(options) {
|
|
|
7320
7406
|
const pin = confinedTenant(p);
|
|
7321
7407
|
if (pin === null)
|
|
7322
7408
|
return false;
|
|
7323
|
-
const v = await verticalOf(p.actor, slug);
|
|
7409
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
7324
7410
|
return !v || (v.ownerTenant !== pin && !v.listed);
|
|
7325
7411
|
};
|
|
7326
7412
|
/**
|
|
@@ -7366,10 +7452,10 @@ export function createControlPlaneApi(options) {
|
|
|
7366
7452
|
const workspace = (await c.var.admin.listTenants(actor)).find((t) => t.slug === pin || t.id === pin);
|
|
7367
7453
|
if (!workspace || raw.startsWith(`${workspace.slug}/`))
|
|
7368
7454
|
return raw;
|
|
7369
|
-
if ((await ownerOf(actor, raw)) === workspace.id)
|
|
7455
|
+
if ((await ownerOf(c.var.admin, actor, raw)) === workspace.id)
|
|
7370
7456
|
return raw;
|
|
7371
7457
|
const prefixed = `${workspace.slug}/${raw}`;
|
|
7372
|
-
return (await ownerOf(actor, prefixed)) !== undefined ? prefixed : raw;
|
|
7458
|
+
return (await ownerOf(c.var.admin, actor, prefixed)) !== undefined ? prefixed : raw;
|
|
7373
7459
|
};
|
|
7374
7460
|
app.get('/verticals', async (c) => {
|
|
7375
7461
|
const page = pageParams(c);
|
|
@@ -7417,13 +7503,13 @@ export function createControlPlaneApi(options) {
|
|
|
7417
7503
|
* same enumeration, and #977's observability narrowing must not be a second copy of
|
|
7418
7504
|
* it that can drift when a third ref scheme appears.
|
|
7419
7505
|
*/
|
|
7420
|
-
const tenantServiceRefs = async (actor, tenantId, slug) => {
|
|
7421
|
-
const all = (await
|
|
7506
|
+
const tenantServiceRefs = async (admin, actor, tenantId, slug) => {
|
|
7507
|
+
const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
|
|
7422
7508
|
const considered = slug === undefined ? all : all.filter((v) => v.slug === slug);
|
|
7423
7509
|
const verticalsTruncated = considered.length > MAX_SERVICE_REF_VERTICALS;
|
|
7424
7510
|
const entries = [];
|
|
7425
7511
|
for (const v of considered.slice(0, MAX_SERVICE_REF_VERTICALS)) {
|
|
7426
|
-
const versions = await
|
|
7512
|
+
const versions = await admin.listVersions(actor, v.slug);
|
|
7427
7513
|
const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
|
|
7428
7514
|
// The stable serving script — where real traffic lands. Its version is the
|
|
7429
7515
|
// registry row's own servingVersionId: authoritative, not the scope-derived
|
|
@@ -7463,7 +7549,7 @@ export function createControlPlaneApi(options) {
|
|
|
7463
7549
|
const pin = confinedTenant(p);
|
|
7464
7550
|
if (pin === null)
|
|
7465
7551
|
return null;
|
|
7466
|
-
const refs = await tenantServiceRefs(actor, pin);
|
|
7552
|
+
const refs = await tenantServiceRefs(adminFor(p), actor, pin);
|
|
7467
7553
|
if (refs.verticalsTruncated) {
|
|
7468
7554
|
throw new ControlPlaneError(503, 'service map truncated: too many verticals to resolve in one answer');
|
|
7469
7555
|
}
|
|
@@ -7482,7 +7568,7 @@ export function createControlPlaneApi(options) {
|
|
|
7482
7568
|
.parse({ tenantId, vertical: c.req.query('vertical') });
|
|
7483
7569
|
const actor = c.get('actor');
|
|
7484
7570
|
const slug = query.vertical === undefined ? undefined : await resolveVerticalId(c, query.vertical);
|
|
7485
|
-
const { entries, verticalsTruncated, matched } = await tenantServiceRefs(actor, query.tenantId, slug);
|
|
7571
|
+
const { entries, verticalsTruncated, matched } = await tenantServiceRefs(c.var.admin, actor, query.tenantId, slug);
|
|
7486
7572
|
// Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
|
|
7487
7573
|
if (slug !== undefined && matched === 0)
|
|
7488
7574
|
throw new ControlPlaneError(404, 'not found');
|
|
@@ -7497,17 +7583,17 @@ export function createControlPlaneApi(options) {
|
|
|
7497
7583
|
// the prefix, backstopped by the ownership check). Owner + prefix come from the
|
|
7498
7584
|
// principal, never trusted from the body.
|
|
7499
7585
|
input.slug = effectiveSlug(p, input.slug);
|
|
7500
|
-
const owner = await ownerOf(p.actor, input.slug);
|
|
7586
|
+
const owner = await ownerOf(adminFor(p), p.actor, input.slug);
|
|
7501
7587
|
if (owner !== undefined && owner !== p.tenantId)
|
|
7502
7588
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7503
7589
|
input.ownerTenant = p.tenantId;
|
|
7504
7590
|
}
|
|
7505
|
-
const claimed = await slugClaimsSku(c.get('actor'), input.slug);
|
|
7591
|
+
const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), input.slug);
|
|
7506
7592
|
if (claimed)
|
|
7507
7593
|
return refuseStatus(c, 422, claimed);
|
|
7508
7594
|
// #2388: the same declaration rule as a push, for any principal that is not staff.
|
|
7509
7595
|
if (p.kind !== 'staff') {
|
|
7510
|
-
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);
|
|
7511
7597
|
if (foreignKey)
|
|
7512
7598
|
return refuseStatus(c, 422, foreignKey);
|
|
7513
7599
|
}
|
|
@@ -7593,9 +7679,12 @@ export function createControlPlaneApi(options) {
|
|
|
7593
7679
|
}
|
|
7594
7680
|
// #2210: publishing records the version's store bindings for the whole vertical's lifecycle
|
|
7595
7681
|
// (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
|
|
7596
|
-
// does: a driver with no approved release refuses here, and nothing is published.
|
|
7597
|
-
|
|
7598
|
-
await c.var.admin.
|
|
7682
|
+
// does: a driver with no approved release refuses here, and nothing is published. The publish is
|
|
7683
|
+
// made under the push's shared hold (#2816), so a rollout walks this vertical or waits for it.
|
|
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) => {
|
|
7685
|
+
await renew();
|
|
7686
|
+
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
7687
|
+
});
|
|
7599
7688
|
const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
|
|
7600
7689
|
return c.json(version, 201);
|
|
7601
7690
|
});
|
|
@@ -7813,7 +7902,7 @@ export function createControlPlaneApi(options) {
|
|
|
7813
7902
|
const slug = c.req.param('slug');
|
|
7814
7903
|
if (await notOwned(c.get('principal'), slug))
|
|
7815
7904
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7816
|
-
const vertical = await verticalOf(c.get('actor'), slug);
|
|
7905
|
+
const vertical = await verticalOf(c.var.admin, c.get('actor'), slug);
|
|
7817
7906
|
if (!vertical)
|
|
7818
7907
|
return refuse(c, 'not_found', 'vertical not found');
|
|
7819
7908
|
return c.json({ slug, emailSender: !!vertical.emailSender });
|
|
@@ -7892,10 +7981,16 @@ export function createControlPlaneApi(options) {
|
|
|
7892
7981
|
* (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
|
|
7893
7982
|
* The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
|
|
7894
7983
|
* path reads the owner only once something has failed.
|
|
7984
|
+
*
|
|
7985
|
+
* `then` runs after the uploads, still under the shared holds (#2816): a push or a publish passes
|
|
7986
|
+
* the step that records the version's store declarations, so a rollout either finds the vertical
|
|
7987
|
+
* in what it walks or is refused until that step is done. `renew` renews every hold, and refuses
|
|
7988
|
+
* (409) once one has lapsed; `then` calls it right before it records. A failure in `then` is not
|
|
7989
|
+
* recorded here: it is the caller's to answer.
|
|
7895
7990
|
*/
|
|
7896
|
-
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
|
|
7991
|
+
const ensureScopeStoresOf = async (admin, actor, operation, slug, stores, owner, then = async () => undefined) => {
|
|
7897
7992
|
if (stores.length === 0)
|
|
7898
|
-
return;
|
|
7993
|
+
return then(async () => undefined);
|
|
7899
7994
|
// D-67: a driver whose registration names its verticals serves those only, refused before any
|
|
7900
7995
|
// hold is taken or anything is uploaded.
|
|
7901
7996
|
for (const store of stores) {
|
|
@@ -7907,54 +8002,61 @@ export function createControlPlaneApi(options) {
|
|
|
7907
8002
|
const holder = ulid();
|
|
7908
8003
|
const drivers = [...new Set(stores.map((store) => store.driver))].sort();
|
|
7909
8004
|
const held = [];
|
|
8005
|
+
const renewHolds = async (when) => {
|
|
8006
|
+
for (const each of held) {
|
|
8007
|
+
if (!(await admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
8008
|
+
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed ${when}; try again`);
|
|
8009
|
+
}
|
|
8010
|
+
}
|
|
8011
|
+
};
|
|
8012
|
+
const renew = () => renewHolds('during the upload');
|
|
7910
8013
|
try {
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
// A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
7915
|
-
// a script's release before a rollout moved it would upload the old release back over it.
|
|
7916
|
-
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
7917
|
-
for (const driver of drivers) {
|
|
7918
|
-
if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
7919
|
-
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
8014
|
+
try {
|
|
8015
|
+
if (!options.ensureScopeStores) {
|
|
8016
|
+
throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
|
|
7920
8017
|
}
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
7928
|
-
for (const driver of drivers) {
|
|
7929
|
-
for (const each of held) {
|
|
7930
|
-
if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
7931
|
-
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
|
|
8018
|
+
// A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
8019
|
+
// a script's release before a rollout moved it would upload the old release back over it.
|
|
8020
|
+
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
8021
|
+
for (const driver of drivers) {
|
|
8022
|
+
if (!(await admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
8023
|
+
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
7932
8024
|
}
|
|
8025
|
+
held.push(driver);
|
|
8026
|
+
}
|
|
8027
|
+
const loadRelease = options.storeReleases
|
|
8028
|
+
? storedReleaseLoader({ admin, actor, bytes: options.storeReleases.bytes })
|
|
8029
|
+
: async () => undefined;
|
|
8030
|
+
// One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
|
|
8031
|
+
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
8032
|
+
for (const driver of drivers) {
|
|
8033
|
+
await renew();
|
|
8034
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
8035
|
+
if (!registration)
|
|
8036
|
+
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
8037
|
+
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
8038
|
+
// before this read is seen, and none is written until this upload is done.
|
|
8039
|
+
const approvals = await readStoreDriverApprovals(admin, actor, registration);
|
|
8040
|
+
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7933
8041
|
}
|
|
7934
|
-
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
7935
|
-
if (!registration)
|
|
7936
|
-
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
7937
|
-
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
7938
|
-
// before this read is seen, and none is written until this upload is done.
|
|
7939
|
-
const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
|
|
7940
|
-
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7941
8042
|
}
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
|
|
8043
|
+
catch (e) {
|
|
8044
|
+
recordFailure({
|
|
8045
|
+
actor,
|
|
8046
|
+
operation: `${operation}.store-upload`,
|
|
8047
|
+
stage: 'store-upload',
|
|
8048
|
+
vertical: slug,
|
|
8049
|
+
tenantId: await owner().catch(() => null),
|
|
8050
|
+
status: e instanceof ControlPlaneError ? e.status : 502,
|
|
8051
|
+
message: e instanceof Error ? e.message : String(e),
|
|
8052
|
+
}, e);
|
|
8053
|
+
throw e;
|
|
8054
|
+
}
|
|
8055
|
+
return await then(() => renewHolds('before the version was recorded'));
|
|
7954
8056
|
}
|
|
7955
8057
|
finally {
|
|
7956
8058
|
for (const driver of held)
|
|
7957
|
-
await
|
|
8059
|
+
await admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
|
|
7958
8060
|
}
|
|
7959
8061
|
};
|
|
7960
8062
|
/**
|
|
@@ -8059,12 +8161,12 @@ export function createControlPlaneApi(options) {
|
|
|
8059
8161
|
const serveVersionInPlace = async (admin, actor, slug, versionId) => {
|
|
8060
8162
|
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
8061
8163
|
return; // not configured: pre-#286 behavior
|
|
8062
|
-
const version = await
|
|
8164
|
+
const version = await admin.getVersion(actor, versionId, slug);
|
|
8063
8165
|
if (!version?.deploymentRef) {
|
|
8064
8166
|
throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
|
|
8065
8167
|
}
|
|
8066
8168
|
const archiveRef = version.deploymentRef;
|
|
8067
|
-
const manifestJson = await
|
|
8169
|
+
const manifestJson = await admin.versionManifest(actor, slug, versionId);
|
|
8068
8170
|
if (!manifestJson) {
|
|
8069
8171
|
throw new ControlPlaneError(502, `version ${versionId} retained no manifest — pushed pre-#286; push it again to serve in place`);
|
|
8070
8172
|
}
|
|
@@ -8074,7 +8176,7 @@ export function createControlPlaneApi(options) {
|
|
|
8074
8176
|
// serving record below all read this one set; the manifest's own `doClasses` would leave
|
|
8075
8177
|
// the record a class short of the script, and the next serve re-declaring a live class.
|
|
8076
8178
|
const doClasses = deployedDoClasses(manifest.doClasses, manifest.platformSweeper);
|
|
8077
|
-
const serving = await
|
|
8179
|
+
const serving = await admin.verticalServing(actor, slug);
|
|
8078
8180
|
const ref = serving?.ref ?? stableDeploymentRefFor(slug);
|
|
8079
8181
|
// #1923: the serving upload is the other door a script name is minted through.
|
|
8080
8182
|
const collision = platformScriptCollision(slug) ?? (PLATFORM_SCRIPT_NAMES.has(ref) ? ref : null);
|
|
@@ -8088,16 +8190,16 @@ export function createControlPlaneApi(options) {
|
|
|
8088
8190
|
// tenant's store. Platform-granted after the §4 sandbox check, like injectSecrets:
|
|
8089
8191
|
// the builder never declared these and never named the ids.
|
|
8090
8192
|
const storeBindings = [
|
|
8091
|
-
...tenantStoreBindings(await
|
|
8193
|
+
...tenantStoreBindings(await admin.listTenantStores(actor, { vertical: slug })),
|
|
8092
8194
|
// #473: the per-tenant blob-store r2_bucket bindings, re-derived from the ledger on
|
|
8093
8195
|
// every serving upload for the same reason — an upload replaces the script's binding
|
|
8094
8196
|
// set, so a re-deploy must never drop a tenant's attachment bucket.
|
|
8095
|
-
...blobStoreBindings(await
|
|
8197
|
+
...blobStoreBindings(await admin.listBlobStores(actor, { vertical: slug })),
|
|
8096
8198
|
].map((b) => b.type === 'd1'
|
|
8097
8199
|
? { type: 'd1', name: b.name, id: b.id }
|
|
8098
8200
|
: { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
8099
8201
|
// #2210: the store scripts first, so the vertical is never served before what it reaches.
|
|
8100
|
-
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);
|
|
8101
8203
|
const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
|
|
8102
8204
|
entry: manifest.entry,
|
|
8103
8205
|
compatibilityDate: manifest.compatibilityDate,
|
|
@@ -8259,14 +8361,14 @@ export function createControlPlaneApi(options) {
|
|
|
8259
8361
|
if (!options.deployVertical || !options.fetchVerticalModules)
|
|
8260
8362
|
return none; // no in-place serve moves anything
|
|
8261
8363
|
const actor = c.get('actor');
|
|
8262
|
-
const incoming = await
|
|
8364
|
+
const incoming = await c.var.admin.getVersion(actor, versionId, slug);
|
|
8263
8365
|
if (!incoming?.deploymentRef)
|
|
8264
8366
|
return none;
|
|
8265
|
-
const manifestJson = await
|
|
8367
|
+
const manifestJson = await c.var.admin.versionManifest(actor, slug, versionId);
|
|
8266
8368
|
const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
|
|
8267
8369
|
if (!registry || !releaseRolesAskScopes(registry))
|
|
8268
8370
|
return none;
|
|
8269
|
-
const v = await verticalOf(actor, slug);
|
|
8371
|
+
const v = await verticalOf(c.var.admin, actor, slug);
|
|
8270
8372
|
const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
|
|
8271
8373
|
const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
|
|
8272
8374
|
.filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
|
|
@@ -8320,23 +8422,23 @@ export function createControlPlaneApi(options) {
|
|
|
8320
8422
|
* Over-approximates for a host that cannot serve in place, where no adopt runs: an extra line
|
|
8321
8423
|
* costs a read, a missing one a silent stall.
|
|
8322
8424
|
*/
|
|
8323
|
-
const promoteImpactOf = async (actor, slug, channel, versionId) => {
|
|
8324
|
-
const gate = await
|
|
8425
|
+
const promoteImpactOf = async (admin, actor, slug, channel, versionId) => {
|
|
8426
|
+
const gate = await admin.promotionImpact(actor, slug, channel, versionId);
|
|
8325
8427
|
if (channel !== 'prod')
|
|
8326
8428
|
return gate;
|
|
8327
|
-
const v = await verticalOf(actor, slug);
|
|
8429
|
+
const v = await verticalOf(admin, actor, slug);
|
|
8328
8430
|
if (!v || v.ownerTenant === null || v.listed)
|
|
8329
8431
|
return gate;
|
|
8330
|
-
const incoming = await
|
|
8432
|
+
const incoming = await admin.getVersion(actor, versionId, slug);
|
|
8331
8433
|
if (!incoming?.deploymentRef || incoming.admission !== 'admitted')
|
|
8332
8434
|
return gate;
|
|
8333
|
-
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);
|
|
8334
8436
|
const seen = new Set(gate.map((b) => `${b.scopeId}|${b.type}`));
|
|
8335
8437
|
const out = [...gate];
|
|
8336
8438
|
for (const s of lagging) {
|
|
8337
8439
|
// After the adopt it runs the promoted version, served in place: that pointer move, from
|
|
8338
8440
|
// what it runs now, is the move to judge.
|
|
8339
|
-
for (const b of await
|
|
8441
|
+
for (const b of await admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
|
|
8340
8442
|
const key = `${b.scopeId}|${b.type}`;
|
|
8341
8443
|
if (!seen.has(key))
|
|
8342
8444
|
(seen.add(key), out.push(b));
|
|
@@ -8355,11 +8457,11 @@ export function createControlPlaneApi(options) {
|
|
|
8355
8457
|
const versionId = z.string().min(1).parse(c.req.query('versionId'));
|
|
8356
8458
|
const pin = confinedTenant(p);
|
|
8357
8459
|
if (pin) {
|
|
8358
|
-
const v = await verticalOf(p.actor, slug);
|
|
8460
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
8359
8461
|
if (!v || v.ownerTenant !== pin)
|
|
8360
8462
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8361
8463
|
}
|
|
8362
|
-
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
8464
|
+
const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
|
|
8363
8465
|
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
8364
8466
|
return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
|
|
8365
8467
|
});
|
|
@@ -8377,7 +8479,7 @@ export function createControlPlaneApi(options) {
|
|
|
8377
8479
|
const promotePin = confinedTenant(p);
|
|
8378
8480
|
if (promotePin) {
|
|
8379
8481
|
// A confined principal promotes only verticals its tenant owns.
|
|
8380
|
-
const v = await verticalOf(p.actor, slug);
|
|
8482
|
+
const v = await verticalOf(adminFor(p), p.actor, slug);
|
|
8381
8483
|
if (!v || v.ownerTenant !== promotePin)
|
|
8382
8484
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8383
8485
|
// …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
|
|
@@ -8411,7 +8513,7 @@ export function createControlPlaneApi(options) {
|
|
|
8411
8513
|
// counts only the gate's half of them.
|
|
8412
8514
|
let counted = [];
|
|
8413
8515
|
const breaksOf = async () => {
|
|
8414
|
-
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
8516
|
+
const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
|
|
8415
8517
|
counted = breaks;
|
|
8416
8518
|
if (breaks.length === 0)
|
|
8417
8519
|
return null;
|
|
@@ -8582,9 +8684,9 @@ export function createControlPlaneApi(options) {
|
|
|
8582
8684
|
if (c.req.method === 'POST') {
|
|
8583
8685
|
const input = z.object({ slug: z.string().min(1), name: z.string().min(1), repository: z.unknown().optional() }).parse(await c.req.json());
|
|
8584
8686
|
const team = tenants.find((t) => t.id === tenant);
|
|
8585
|
-
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}`;
|
|
8586
8688
|
verticalSlug.parse(slug);
|
|
8587
|
-
const owner = await ownerOf(c.get('actor'), slug);
|
|
8689
|
+
const owner = await ownerOf(c.var.admin, c.get('actor'), slug);
|
|
8588
8690
|
if (owner !== undefined && owner !== tenant)
|
|
8589
8691
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8590
8692
|
body = JSON.stringify({ ...input, slug });
|
|
@@ -8639,9 +8741,9 @@ export function createControlPlaneApi(options) {
|
|
|
8639
8741
|
if (!tenant)
|
|
8640
8742
|
return refuse(c, 'not_found', 'unknown workspace');
|
|
8641
8743
|
sourceTenant = tenant.id;
|
|
8642
|
-
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}`;
|
|
8643
8745
|
}
|
|
8644
|
-
const owner = await ownerOf(c.get('actor'), sourceSlug);
|
|
8746
|
+
const owner = await ownerOf(c.var.admin, c.get('actor'), sourceSlug);
|
|
8645
8747
|
if (sourceTenant && owner !== undefined && owner !== sourceTenant)
|
|
8646
8748
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8647
8749
|
sourceTenant ??= owner ?? null;
|
|
@@ -8733,7 +8835,7 @@ export function createControlPlaneApi(options) {
|
|
|
8733
8835
|
}
|
|
8734
8836
|
slug = effectiveSlug(p, bare);
|
|
8735
8837
|
// A builder pushes to a slug it owns, or claims an unregistered one (§3).
|
|
8736
|
-
const existingOwner = await ownerOf(c.get('actor'), slug);
|
|
8838
|
+
const existingOwner = await ownerOf(c.var.admin, c.get('actor'), slug);
|
|
8737
8839
|
if (existingOwner !== undefined && existingOwner !== p.tenantId) {
|
|
8738
8840
|
return refuse(c, 'forbidden', 'forbidden');
|
|
8739
8841
|
}
|
|
@@ -8747,9 +8849,9 @@ export function createControlPlaneApi(options) {
|
|
|
8747
8849
|
// hand-registration predating prefixed claims) stays addressable as itself.
|
|
8748
8850
|
// Otherwise the claim lands under the tenant prefix, exactly like a builder push —
|
|
8749
8851
|
// same namespace, no claim race with other workspaces' bare names.
|
|
8750
|
-
const bareOwner = await ownerOf(c.get('actor'), bare);
|
|
8852
|
+
const bareOwner = await ownerOf(c.var.admin, c.get('actor'), bare);
|
|
8751
8853
|
slug = bare.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
8752
|
-
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);
|
|
8753
8855
|
if (existingOwner !== undefined && existingOwner !== workspace.id) {
|
|
8754
8856
|
return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
|
|
8755
8857
|
}
|
|
@@ -8759,7 +8861,7 @@ export function createControlPlaneApi(options) {
|
|
|
8759
8861
|
// No pin: the raw slug is the identity and an existing owner is preserved
|
|
8760
8862
|
// (null ⇒ platform-owned for a first-party vertical) — unchanged staff behavior.
|
|
8761
8863
|
slug = bare;
|
|
8762
|
-
ownerTenant = (await ownerOf(c.get('actor'), slug)) ?? null;
|
|
8864
|
+
ownerTenant = (await ownerOf(c.var.admin, c.get('actor'), slug)) ?? null;
|
|
8763
8865
|
}
|
|
8764
8866
|
// #1923: a slug that would deploy under one of the platform's own script names is refused
|
|
8765
8867
|
// before anything is uploaded. Registration refuses it too; this is the deploy half, for a
|
|
@@ -8837,10 +8939,10 @@ export function createControlPlaneApi(options) {
|
|
|
8837
8939
|
// is judged here and honoured by the grant paths only for a tenant-provisioner. Earlier versions
|
|
8838
8940
|
// are not re-judged.
|
|
8839
8941
|
// Declaring `provisions` marks a manager: the capability itself is granted after a first push.
|
|
8840
|
-
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);
|
|
8841
8943
|
if (foreignKey)
|
|
8842
8944
|
return refuseStatus(c, 422, foreignKey);
|
|
8843
|
-
const claimed = await slugClaimsSku(c.get('actor'), slug);
|
|
8945
|
+
const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), slug);
|
|
8844
8946
|
if (claimed)
|
|
8845
8947
|
return refuseStatus(c, 422, claimed);
|
|
8846
8948
|
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
@@ -8907,117 +9009,128 @@ export function createControlPlaneApi(options) {
|
|
|
8907
9009
|
const id = ulid();
|
|
8908
9010
|
const deploymentRef = deploymentRefFor(slug, id);
|
|
8909
9011
|
// #2210: the store scripts first, outside the vertical upload's try, so a store failure
|
|
8910
|
-
// answers as itself and the vertical is not uploaded at all.
|
|
8911
|
-
|
|
8912
|
-
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
9012
|
+
// answers as itself and the vertical is not uploaded at all. The upload, the registration and
|
|
9013
|
+
// the publish run under the store push's shared hold (#2816): a rollout that starts before the
|
|
9014
|
+
// version records its stores is refused, and one after walks this vertical.
|
|
9015
|
+
const deployVertical = options.deployVertical;
|
|
9016
|
+
const pushed = await ensureScopeStoresOf(c.var.admin, c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
|
|
9017
|
+
let wire;
|
|
9018
|
+
try {
|
|
9019
|
+
wire = await deployVerticalScript(deployVertical, c.get('actor'), slug, deploymentRef, {
|
|
9020
|
+
entry: manifest.entry,
|
|
9021
|
+
compatibilityDate: manifest.compatibilityDate,
|
|
9022
|
+
compatibilityFlags: manifest.compatibilityFlags,
|
|
9023
|
+
modules,
|
|
9024
|
+
doClasses: manifest.doClasses,
|
|
9025
|
+
bindings: verticalUploadBindings({
|
|
9026
|
+
vertical: slug,
|
|
9027
|
+
manifest,
|
|
9028
|
+
drivers: options.storeDrivers,
|
|
9029
|
+
// #2349: the migrations this push declares, which are the ones the version stores.
|
|
9030
|
+
platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
|
|
9031
|
+
}),
|
|
9032
|
+
// #1242: the freshly minted version id — this archive script IS that version,
|
|
9033
|
+
// and a preview routed at it stamps the signals `version` dimension honestly.
|
|
9034
|
+
versionId: id,
|
|
9035
|
+
// #1902: the platform's scope sweeper, as decided above and recorded with the version.
|
|
9036
|
+
...(sweeper.supply ? { supplySweeper: true } : {}),
|
|
9037
|
+
// #340: the verified bytes go up with the bundle. The manifest's routing config
|
|
9038
|
+
// rides along untouched — it decides what the RUNTIME does with paths, and carries
|
|
9039
|
+
// no reach, so there is nothing in it for the sandbox contract to refuse.
|
|
9040
|
+
...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
|
|
9041
|
+
});
|
|
9042
|
+
}
|
|
9043
|
+
catch (e) {
|
|
9044
|
+
// The upload to the runtime failed. Surface the detail (the builder is
|
|
9045
|
+
// authenticated) rather than the anonymous 500 the generic handler would give, so a
|
|
9046
|
+
// push failure is diagnosable without reading worker logs. The version label is NOT
|
|
9047
|
+
// consumed here: registration/publish happen only AFTER a successful upload (below),
|
|
9048
|
+
// so a failed push leaves the same --version reusable (#307).
|
|
9049
|
+
//
|
|
9050
|
+
// Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
|
|
9051
|
+
// builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
|
|
9052
|
+
// but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
|
|
9053
|
+
// throw with no upstream status) is a platform failure and stays a 502.
|
|
9054
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
9055
|
+
const upstream = upstreamStatusOf(e);
|
|
9056
|
+
console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
|
|
9057
|
+
const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
|
|
9058
|
+
// Both outcomes land an ops-failure row (#559): the 502 is the platform's to
|
|
9059
|
+
// explain, and the 422 is what the builder-facing failure view (step 5) will
|
|
9060
|
+
// list — a red push should be explainable from a durable record either way.
|
|
9061
|
+
recordFailure({
|
|
9062
|
+
actor: c.get('actor'),
|
|
9063
|
+
operation: 'deploy.upload',
|
|
9064
|
+
stage: 'wfp-upload',
|
|
8921
9065
|
vertical: slug,
|
|
8922
|
-
|
|
8923
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
8926
|
-
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
9066
|
+
tenantId: ownerTenant ?? null,
|
|
9067
|
+
status: rejected ? 422 : 502,
|
|
9068
|
+
message: detail,
|
|
9069
|
+
}, e);
|
|
9070
|
+
c.set('failureRecorded', true);
|
|
9071
|
+
return rejected
|
|
9072
|
+
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
9073
|
+
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
9074
|
+
}
|
|
9075
|
+
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
9076
|
+
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
9077
|
+
// (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
|
|
9078
|
+
// was resolved with the slug above: the builder's tenant, the pinned workspace, or
|
|
9079
|
+
// the preserved existing owner for an unpinned staff push. An upload that outlasted the
|
|
9080
|
+
// store hold refuses here (409) before the vertical or its version is registered, so the
|
|
9081
|
+
// label stays reusable; unlike a failed upload, it lands no ops-failure row.
|
|
9082
|
+
await renew();
|
|
9083
|
+
await c.var.admin.registerVertical(c.get('actor'), {
|
|
9084
|
+
slug,
|
|
9085
|
+
name: manifest.name ?? slug,
|
|
9086
|
+
source: 'cli',
|
|
9087
|
+
ownerTenant,
|
|
9088
|
+
// The vertical's declared config surface rides to the registry, so the dashboard
|
|
9089
|
+
// renders a settings form for a pushed vertical exactly like a builtin.
|
|
9090
|
+
...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
|
|
9091
|
+
// Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
|
|
9092
|
+
// installs a pushed vertical without a hardcoded catalog entry.
|
|
9093
|
+
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
9094
|
+
...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
|
|
9095
|
+
...(manifest.provides ? { provides: manifest.provides } : {}),
|
|
9096
|
+
...(manifest.requires ? { requires: manifest.requires } : {}),
|
|
9097
|
+
// The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
|
|
9098
|
+
// door renders. The proxy never reads this copy; it reads the running version's manifest.
|
|
9099
|
+
...(manifest.connectors ? { connectors: manifest.connectors } : {}),
|
|
9100
|
+
// The declared provisioner intent (#455) — a REQUEST the console reviews; the
|
|
9101
|
+
// tenant-provisioner grant itself is never touched by a push. Refreshes on every
|
|
9102
|
+
// re-push like the rest of the install spec, exactly because it grants nothing.
|
|
9103
|
+
...(manifest.provisions ? { provisions: manifest.provisions } : {}),
|
|
9104
|
+
// The declared email-sender intent (#303) — a REQUEST the console reviews; the
|
|
9105
|
+
// `emailSender` grant itself is never touched by a push. Refreshes on every re-push
|
|
9106
|
+
// like the rest of the install spec, exactly because it grants nothing.
|
|
9107
|
+
...(manifest.sendsEmail ? { sendsEmail: true } : {}),
|
|
9108
|
+
// Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
|
|
9109
|
+
// the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
|
|
9110
|
+
...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
|
|
9111
|
+
// The declared surfaces (K-26) ride like envSpec: registry metadata for the
|
|
9112
|
+
// hostname-binding picker, never behavior. Not part of any admission digest.
|
|
9113
|
+
...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
|
|
8936
9114
|
});
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
|
|
8953
|
-
// Both outcomes land an ops-failure row (#559): the 502 is the platform's to
|
|
8954
|
-
// explain, and the 422 is what the builder-facing failure view (step 5) will
|
|
8955
|
-
// list — a red push should be explainable from a durable record either way.
|
|
8956
|
-
recordFailure({
|
|
8957
|
-
actor: c.get('actor'),
|
|
8958
|
-
operation: 'deploy.upload',
|
|
8959
|
-
stage: 'wfp-upload',
|
|
8960
|
-
vertical: slug,
|
|
8961
|
-
tenantId: ownerTenant ?? null,
|
|
8962
|
-
status: rejected ? 422 : 502,
|
|
8963
|
-
message: detail,
|
|
8964
|
-
}, e);
|
|
8965
|
-
c.set('failureRecorded', true);
|
|
8966
|
-
return rejected
|
|
8967
|
-
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
8968
|
-
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
8969
|
-
}
|
|
8970
|
-
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
8971
|
-
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
8972
|
-
// (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
|
|
8973
|
-
// was resolved with the slug above: the builder's tenant, the pinned workspace, or
|
|
8974
|
-
// the preserved existing owner for an unpinned staff push.
|
|
8975
|
-
await c.var.admin.registerVertical(c.get('actor'), {
|
|
8976
|
-
slug,
|
|
8977
|
-
name: manifest.name ?? slug,
|
|
8978
|
-
source: 'cli',
|
|
8979
|
-
ownerTenant,
|
|
8980
|
-
// The vertical's declared config surface rides to the registry, so the dashboard
|
|
8981
|
-
// renders a settings form for a pushed vertical exactly like a builtin.
|
|
8982
|
-
...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
|
|
8983
|
-
// Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
|
|
8984
|
-
// installs a pushed vertical without a hardcoded catalog entry.
|
|
8985
|
-
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
8986
|
-
...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
|
|
8987
|
-
...(manifest.provides ? { provides: manifest.provides } : {}),
|
|
8988
|
-
...(manifest.requires ? { requires: manifest.requires } : {}),
|
|
8989
|
-
// The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
|
|
8990
|
-
// door renders. The proxy never reads this copy; it reads the running version's manifest.
|
|
8991
|
-
...(manifest.connectors ? { connectors: manifest.connectors } : {}),
|
|
8992
|
-
// The declared provisioner intent (#455) — a REQUEST the console reviews; the
|
|
8993
|
-
// tenant-provisioner grant itself is never touched by a push. Refreshes on every
|
|
8994
|
-
// re-push like the rest of the install spec, exactly because it grants nothing.
|
|
8995
|
-
...(manifest.provisions ? { provisions: manifest.provisions } : {}),
|
|
8996
|
-
// The declared email-sender intent (#303) — a REQUEST the console reviews; the
|
|
8997
|
-
// `emailSender` grant itself is never touched by a push. Refreshes on every re-push
|
|
8998
|
-
// like the rest of the install spec, exactly because it grants nothing.
|
|
8999
|
-
...(manifest.sendsEmail ? { sendsEmail: true } : {}),
|
|
9000
|
-
// Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
|
|
9001
|
-
// the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
|
|
9002
|
-
...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
|
|
9003
|
-
// The declared surfaces (K-26) ride like envSpec: registry metadata for the
|
|
9004
|
-
// hostname-binding picker, never behavior. Not part of any admission digest.
|
|
9005
|
-
...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
|
|
9006
|
-
});
|
|
9007
|
-
await c.var.admin.publishVersion(c.get('actor'), {
|
|
9008
|
-
id,
|
|
9009
|
-
verticalSlug: slug,
|
|
9010
|
-
version: manifest.version,
|
|
9011
|
-
manifestDigest: manifest.digests.manifest,
|
|
9012
|
-
permissionDigest: manifest.digests.permission,
|
|
9013
|
-
migrationDigest: manifest.digests.migration,
|
|
9014
|
-
deploymentRef,
|
|
9015
|
-
...(origin ? { origin } : {}),
|
|
9016
|
-
// Retained for the serving upload (#286): the archive script keeps the module
|
|
9017
|
-
// bytes, this keeps their shape (entry, compat, doClasses, bindings).
|
|
9018
|
-
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
9019
|
-
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
9115
|
+
await c.var.admin.publishVersion(c.get('actor'), {
|
|
9116
|
+
id,
|
|
9117
|
+
verticalSlug: slug,
|
|
9118
|
+
version: manifest.version,
|
|
9119
|
+
manifestDigest: manifest.digests.manifest,
|
|
9120
|
+
permissionDigest: manifest.digests.permission,
|
|
9121
|
+
migrationDigest: manifest.digests.migration,
|
|
9122
|
+
deploymentRef,
|
|
9123
|
+
...(origin ? { origin } : {}),
|
|
9124
|
+
// Retained for the serving upload (#286): the archive script keeps the module
|
|
9125
|
+
// bytes, this keeps their shape (entry, compat, doClasses, bindings).
|
|
9126
|
+
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
9127
|
+
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
9128
|
+
});
|
|
9129
|
+
return { wire };
|
|
9020
9130
|
});
|
|
9131
|
+
if (pushed instanceof Response)
|
|
9132
|
+
return pushed;
|
|
9133
|
+
const { wire } = pushed;
|
|
9021
9134
|
await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
|
|
9022
9135
|
if (migrationsExtracted)
|
|
9023
9136
|
await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
|
|
@@ -9305,7 +9418,7 @@ export function createControlPlaneApi(options) {
|
|
|
9305
9418
|
limit: c.req.query('limit'),
|
|
9306
9419
|
});
|
|
9307
9420
|
// #1877: scoped to the app's own scripts — the scan and trust boundary.
|
|
9308
|
-
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);
|
|
9309
9422
|
const { operation, principalKind, ...rest } = input;
|
|
9310
9423
|
const { events, incomplete } = await options.observability.tenantLogs({
|
|
9311
9424
|
...rest,
|
|
@@ -9342,7 +9455,7 @@ export function createControlPlaneApi(options) {
|
|
|
9342
9455
|
catch (e) {
|
|
9343
9456
|
throw new ControlPlaneError(400, e.message);
|
|
9344
9457
|
}
|
|
9345
|
-
const scopes = await
|
|
9458
|
+
const scopes = await c.var.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
|
|
9346
9459
|
const installed = scopes.find((s) => s.id === input.scopeId);
|
|
9347
9460
|
if (!installed?.vertical)
|
|
9348
9461
|
return refuse(c, 'not_found', 'app not found');
|
|
@@ -9382,7 +9495,7 @@ export function createControlPlaneApi(options) {
|
|
|
9382
9495
|
* nothing. `listScopes`, not `getScopeRecord`: a dashboard read must not write an access
|
|
9383
9496
|
* row per page view.
|
|
9384
9497
|
*/
|
|
9385
|
-
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 });
|
|
9386
9499
|
app.get('/observability/tenant-request-volume', async (c) => {
|
|
9387
9500
|
if (!options.observability?.tenantRequestVolume) {
|
|
9388
9501
|
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
@@ -9392,7 +9505,7 @@ export function createControlPlaneApi(options) {
|
|
|
9392
9505
|
// A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
|
|
9393
9506
|
// 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
|
|
9394
9507
|
const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
|
|
9395
|
-
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);
|
|
9396
9509
|
return c.json(await options.observability.tenantRequestVolume({ ...scope, services, buckets }));
|
|
9397
9510
|
});
|
|
9398
9511
|
app.get('/observability/tenant-request-facets', async (c) => {
|
|
@@ -9405,7 +9518,7 @@ export function createControlPlaneApi(options) {
|
|
|
9405
9518
|
// that shows three asks for three.
|
|
9406
9519
|
const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
|
|
9407
9520
|
const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
|
|
9408
|
-
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);
|
|
9409
9522
|
return c.json(await options.observability.tenantRequestFacets({ ...scope, services, ...(keys ? { keys } : {}) }));
|
|
9410
9523
|
});
|
|
9411
9524
|
// #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
|
|
@@ -9434,7 +9547,7 @@ export function createControlPlaneApi(options) {
|
|
|
9434
9547
|
buckets: narrow.buckets,
|
|
9435
9548
|
...(narrow.level.length ? { level: narrow.level } : {}),
|
|
9436
9549
|
...(narrow.operation.length ? { operation: narrow.operation } : {}),
|
|
9437
|
-
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),
|
|
9438
9551
|
}));
|
|
9439
9552
|
});
|
|
9440
9553
|
// #2762: the Lines histogram and facets — a tenant's request lines and `ctx.log` lines, counted
|
|
@@ -9445,7 +9558,7 @@ export function createControlPlaneApi(options) {
|
|
|
9445
9558
|
}
|
|
9446
9559
|
const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9447
9560
|
const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
|
|
9448
|
-
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);
|
|
9449
9562
|
return c.json(await options.observability.tenantLogVolume({ ...scope, services, buckets }));
|
|
9450
9563
|
});
|
|
9451
9564
|
app.get('/observability/tenant-log-facets', async (c) => {
|
|
@@ -9453,7 +9566,7 @@ export function createControlPlaneApi(options) {
|
|
|
9453
9566
|
return refuseStatus(c, 501, 'line aggregation is not configured on this control plane');
|
|
9454
9567
|
}
|
|
9455
9568
|
const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9456
|
-
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);
|
|
9457
9570
|
return c.json(await options.observability.tenantLogFacets({ ...scope, services }));
|
|
9458
9571
|
});
|
|
9459
9572
|
app.get('/observability/tenant-requests', async (c) => {
|
|
@@ -9462,7 +9575,7 @@ export function createControlPlaneApi(options) {
|
|
|
9462
9575
|
}
|
|
9463
9576
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
9464
9577
|
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
|
|
9465
|
-
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);
|
|
9466
9579
|
return c.json(await options.observability.tenantRequests({ ...scope, services, limit }));
|
|
9467
9580
|
});
|
|
9468
9581
|
/**
|
|
@@ -9921,7 +10034,7 @@ export function createControlPlaneApi(options) {
|
|
|
9921
10034
|
if (!parsed)
|
|
9922
10035
|
throw new ControlPlaneError(400, `'${baseHostname}' is not a hostname a preview can be minted beside`);
|
|
9923
10036
|
const hostname = withLabel(parsed, `${parsed.label}${RESERVED_LABEL_SEPARATOR}${tag}`);
|
|
9924
|
-
const existing = (await
|
|
10037
|
+
const existing = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
|
|
9925
10038
|
if (!existing) {
|
|
9926
10039
|
await admin.bindHostname(actor, {
|
|
9927
10040
|
hostname,
|
|
@@ -9932,7 +10045,7 @@ export function createControlPlaneApi(options) {
|
|
|
9932
10045
|
canonical: false,
|
|
9933
10046
|
});
|
|
9934
10047
|
}
|
|
9935
|
-
const bound = (await
|
|
10048
|
+
const bound = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
|
|
9936
10049
|
if (bound)
|
|
9937
10050
|
await runIssuance(admin, actor, bound); // platform mint rides the wildcard cert → active
|
|
9938
10051
|
return hostname;
|
|
@@ -9941,9 +10054,9 @@ export function createControlPlaneApi(options) {
|
|
|
9941
10054
|
* source scope's canonical URL; a clean-room preview (no source, #509 ask (b)) has none,
|
|
9942
10055
|
* so it is built from the platform tenant-app convention `<vertical>-<tenant>.<base>` —
|
|
9943
10056
|
* the same scheme provisioning mints (`callout-sesamy.global.substrat.run`). */
|
|
9944
|
-
const previewBaseHostname = async (actor, source, tenantId, slug, surface) => {
|
|
10057
|
+
const previewBaseHostname = async (admin, actor, source, tenantId, slug, surface) => {
|
|
9945
10058
|
if (source) {
|
|
9946
|
-
const own = await
|
|
10059
|
+
const own = await admin.listHostnames(actor, { scopeId: source.id });
|
|
9947
10060
|
const src = own.find((h) => h.canonical && h.surface === surface) ??
|
|
9948
10061
|
own.find((h) => h.canonical) ??
|
|
9949
10062
|
own[0];
|
|
@@ -9965,7 +10078,7 @@ export function createControlPlaneApi(options) {
|
|
|
9965
10078
|
// bare apex and stranded clean-room previews on a hostname that never resolves.
|
|
9966
10079
|
const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
|
|
9967
10080
|
const jurisdiction = 'global';
|
|
9968
|
-
const tenant = await
|
|
10081
|
+
const tenant = await admin.getTenant(actor, tenantId);
|
|
9969
10082
|
const handle = tenant?.slug ?? tenantId;
|
|
9970
10083
|
return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
|
|
9971
10084
|
};
|
|
@@ -10203,7 +10316,7 @@ export function createControlPlaneApi(options) {
|
|
|
10203
10316
|
: [];
|
|
10204
10317
|
// The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
|
|
10205
10318
|
// convention (clean-room). Computed once so reuse and fresh mint the same URL.
|
|
10206
|
-
const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
|
|
10319
|
+
const baseHostname = await previewBaseHostname(c.var.admin, actor, source, tenantId, slug, surface);
|
|
10207
10320
|
// The founding #559 case lands its durable row HERE, not in onError: the previews
|
|
10208
10321
|
// route answers a ControlPlaneError directly (its own catch, never the app-level
|
|
10209
10322
|
// recorder), and only this frame knows the preview's scopeId (`intoId`) — the key that lets
|
|
@@ -10769,7 +10882,7 @@ export function createControlPlaneApi(options) {
|
|
|
10769
10882
|
const previewVertical = async (c) => {
|
|
10770
10883
|
const p = c.get('principal');
|
|
10771
10884
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
10772
|
-
const v = await verticalOf(c.get('actor'), slug);
|
|
10885
|
+
const v = await verticalOf(c.var.admin, c.get('actor'), slug);
|
|
10773
10886
|
if (!v)
|
|
10774
10887
|
return { refused: 'not_found', error: `unknown vertical '${slug}'` };
|
|
10775
10888
|
if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
|
|
@@ -11530,6 +11643,32 @@ export function createControlPlaneApi(options) {
|
|
|
11530
11643
|
const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
|
|
11531
11644
|
return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
|
|
11532
11645
|
};
|
|
11646
|
+
// The scope-store registry a push is judged by, for ONE vertical (#2813): `?vertical=` is the slug the
|
|
11647
|
+
// push deploys as, required (bare, or already prefixed with the caller's own workspace). Every registration comes back, with `verticals` reduced to
|
|
11648
|
+
// `[vertical]` when it serves that vertical and `[]` when it does not (omitted when it serves
|
|
11649
|
+
// every one), so the CLI's own check can say "does not serve" and no other vertical's slug is
|
|
11650
|
+
// read. "Serves" is judged for the registry id the deploy forms (`<tenant>/<name>` for a builder
|
|
11651
|
+
// or a pinned staff push), the same one `ensureScopeStoresOf` judges. The staff-only
|
|
11652
|
+
// `/store-drivers/:driver` below is the driver's release state and stays as it was.
|
|
11653
|
+
app.get('/store-drivers', async (c) => {
|
|
11654
|
+
const parsed = verticalSlug.safeParse(c.req.query('vertical'));
|
|
11655
|
+
if (!parsed.success)
|
|
11656
|
+
return refuse(c, 'validation_failed', 'vertical is required: the slug the push deploys as');
|
|
11657
|
+
const vertical = parsed.data;
|
|
11658
|
+
const p = c.get('principal');
|
|
11659
|
+
const pin = c.req.header(TENANT_HEADER)?.trim();
|
|
11660
|
+
let id = effectiveSlug(p, vertical);
|
|
11661
|
+
if (p.kind !== 'builder' && pin) {
|
|
11662
|
+
// The pinned-staff branch of the deploy's own slug derivation (`POST /verticals/:slug/deploy`).
|
|
11663
|
+
const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
|
|
11664
|
+
if (!workspace)
|
|
11665
|
+
return refuse(c, 'not_found', `unknown workspace '${pin}'`);
|
|
11666
|
+
const bareOwner = await ownerOf(c.var.admin, c.get('actor'), vertical);
|
|
11667
|
+
id = vertical.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? vertical : `${workspace.slug}/${vertical}`;
|
|
11668
|
+
}
|
|
11669
|
+
const drivers = (options.storeDrivers ?? STORE_DRIVERS).map((d) => d.verticals ? { ...d, verticals: storeDriverServesVertical(d, id) ? [vertical] : [] } : d);
|
|
11670
|
+
return c.json({ drivers });
|
|
11671
|
+
});
|
|
11533
11672
|
app.get('/store-drivers/:driver', async (c) => {
|
|
11534
11673
|
await staffPerson(c, statusWords);
|
|
11535
11674
|
const registration = registeredDriver(c.req.param('driver'));
|