@substrat-run/control-plane-api 0.176.0 → 0.178.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +612 -210
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +80 -16
- package/dist/backups.d.ts.map +1 -1
- package/dist/backups.js +3 -0
- package/dist/backups.js.map +1 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/builder-turns.d.ts +16 -0
- package/dist/builder-turns.d.ts.map +1 -0
- package/dist/builder-turns.js +43 -0
- package/dist/builder-turns.js.map +1 -0
- package/dist/connection-binding-relay.d.ts +28 -1
- package/dist/connection-binding-relay.d.ts.map +1 -1
- package/dist/connection-binding-relay.js +87 -2
- package/dist/connection-binding-relay.js.map +1 -1
- package/dist/connection-relay.d.ts +2 -2
- package/dist/connection-relay.d.ts.map +1 -1
- package/dist/connection-relay.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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/migration-extract.d.ts +92 -0
- package/dist/migration-extract.d.ts.map +1 -0
- package/dist/migration-extract.js +217 -0
- package/dist/migration-extract.js.map +1 -0
- package/dist/platform-entry.d.ts +2 -0
- package/dist/platform-entry.d.ts.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-entry.js +2 -1
- package/dist/platform-entry.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +10 -12
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +47 -24
- package/dist/platform-scope-stores.js.map +1 -1
- package/dist/r2-backups.d.ts +8 -5
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +232 -56
- package/dist/r2-backups.js.map +1 -1
- package/dist/seal.d.ts +5 -2
- package/dist/seal.d.ts.map +1 -1
- package/dist/seal.js +3 -0
- package/dist/seal.js.map +1 -1
- package/dist/staff-client.d.ts +10 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +4 -2
- package/dist/staff-client.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-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/vertical-client.d.ts +6 -0
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +16 -0
- package/dist/vertical-client.js.map +1 -1
- package/package.json +9 -9
package/dist/api.js
CHANGED
|
@@ -3,7 +3,7 @@ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
|
3
3
|
import { bindingChangeRefusal, bindingOptionsRefusal, connectionBindingOptions, refusesBindingChange } from './connection-binding-options.js';
|
|
4
4
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
5
5
|
import { Hono } from 'hono';
|
|
6
|
-
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
|
|
6
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeDriverErasesSubjects, storeDriverServesVertical, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, mergeDumpPages, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
|
|
7
7
|
import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
|
|
8
8
|
import { ENGINE_ENTITLEMENT_KEYS, bareSlug, declarableEntitlementKey, entitlementNamespaceRefusal, grantableEntitlements } from '@substrat-run/contracts';
|
|
9
9
|
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
|
|
@@ -70,11 +70,12 @@ import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, re
|
|
|
70
70
|
import { namespacesForScript } from './do-namespaces.js';
|
|
71
71
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
72
72
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
73
|
+
import { extractedMigrationsDigest, extractedMigrationsProblem, extractedMigrationsRefusal, MigrationExtractionError, MigrationLoaderUnavailable, } from './migration-extract.js';
|
|
73
74
|
import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
|
|
74
75
|
import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
|
|
75
76
|
import { wireKeyDropSince, wireReadinessReport, WIRE_READINESS_LIMIT_DEFAULT, WIRE_READINESS_LIMIT_MAX } from './wire-readiness.js';
|
|
76
77
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
77
|
-
import { queryLakeEvents } from './lake-gateway.js';
|
|
78
|
+
import { queryLakeEvents, teamWindowRefusal } from './lake-gateway.js';
|
|
78
79
|
import { LakeQueryError } from './r2-sql-engine.js';
|
|
79
80
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
80
81
|
import { isSocialRelayVertical, reconcileScopeSocialRelay, relayHostnames, } from './social-relay.js';
|
|
@@ -441,13 +442,62 @@ const restoreDirectoryBody = z.object({
|
|
|
441
442
|
overwrite: z.boolean().optional(),
|
|
442
443
|
});
|
|
443
444
|
/**
|
|
444
|
-
* How a stored backup is named in the admin log and to callers: the route that
|
|
445
|
-
* it
|
|
446
|
-
*
|
|
447
|
-
*
|
|
445
|
+
* How a stored backup is named in the admin log and to callers: the route that restores
|
|
446
|
+
* it (#2839), which every backup can take, whereas only one stored in a single entry can be
|
|
447
|
+
* read whole. Store-neutral by construction — the R2 key scheme stays private to the store —
|
|
448
|
+
* so an operator reading a reap entry gets an address they can use, rather than a bucket
|
|
449
|
+
* path they would have to know the platform's internals to use.
|
|
448
450
|
*/
|
|
449
451
|
function backupRefOf(b) {
|
|
450
|
-
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}`;
|
|
452
|
+
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}/restore`;
|
|
453
|
+
}
|
|
454
|
+
/** The tables whose rows a host judges a load's roles by (#2555): the role definitions and their seats. */
|
|
455
|
+
const ROLES_TABLE = '_substrat_roles';
|
|
456
|
+
const TUPLES_TABLE = '_substrat_tuples';
|
|
457
|
+
/**
|
|
458
|
+
* What a host judges a backup restored from its entries by (#2839): every table's schema, with the
|
|
459
|
+
* rows of the role definitions and of the role seats only, as the entries leave them in order (a
|
|
460
|
+
* change batch replaces a row by its rowid, deletes by rowid, or replaces a table read whole). The
|
|
461
|
+
* scope's data stays at the deployment; this holds no more than the roles a load would apply.
|
|
462
|
+
*/
|
|
463
|
+
function rolesJudgedOf() {
|
|
464
|
+
const schemas = new Map();
|
|
465
|
+
const rows = new Map();
|
|
466
|
+
let unkeyed = 0;
|
|
467
|
+
const judged = (t, row) => t.name === ROLES_TABLE || (t.name === TUPLES_TABLE && String(row[t.columns.indexOf('relation')]).startsWith('role:'));
|
|
468
|
+
const take = (t, replace) => {
|
|
469
|
+
if (t.name !== ROLES_TABLE && t.name !== TUPLES_TABLE)
|
|
470
|
+
return;
|
|
471
|
+
const held = replace ? new Map() : (rows.get(t.name) ?? new Map());
|
|
472
|
+
t.rows.forEach((row, i) => {
|
|
473
|
+
const key = t.rowids?.[i] ?? `u${unkeyed++}`;
|
|
474
|
+
if (judged(t, row))
|
|
475
|
+
held.set(key, row);
|
|
476
|
+
else
|
|
477
|
+
held.delete(key);
|
|
478
|
+
});
|
|
479
|
+
rows.set(t.name, held);
|
|
480
|
+
};
|
|
481
|
+
return {
|
|
482
|
+
page(tables) {
|
|
483
|
+
for (const { rows: _, rowids: __, ...schema } of tables) {
|
|
484
|
+
if (!schemas.has(schema.name))
|
|
485
|
+
schemas.set(schema.name, { ...schema, rows: [] });
|
|
486
|
+
}
|
|
487
|
+
for (const t of tables)
|
|
488
|
+
take(t, false);
|
|
489
|
+
},
|
|
490
|
+
changes(tables) {
|
|
491
|
+
for (const { deleted, ...t } of tables) {
|
|
492
|
+
for (const id of deleted)
|
|
493
|
+
rows.get(t.name)?.delete(id);
|
|
494
|
+
take(t, !t.rowids);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
tables() {
|
|
498
|
+
return [...schemas.values()].map((t) => ({ ...t, rows: [...(rows.get(t.name)?.values() ?? [])] }));
|
|
499
|
+
},
|
|
500
|
+
};
|
|
451
501
|
}
|
|
452
502
|
// A per-PR preview request (preview-and-snapshots.md §2/§9 — the "run a new version
|
|
453
503
|
// against a fork of prod" slice). `tag` is a short DNS-safe label (`pr-123`): it names
|
|
@@ -730,6 +780,8 @@ const TENANT_ROUTES = [
|
|
|
730
780
|
// grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
|
|
731
781
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
|
|
732
782
|
{ method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
|
|
783
|
+
// #1334: the team's own lake events. The handler adds the window rule for this credential.
|
|
784
|
+
{ method: 'GET', re: /\/tenants\/[^/]+\/lake\/events$/, pin: 'path' },
|
|
733
785
|
// Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
|
|
734
786
|
// checks WHO inside the tenant may read or act (its findings permissions) before calling.
|
|
735
787
|
{ method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
|
|
@@ -964,7 +1016,7 @@ export function createControlPlaneApi(options) {
|
|
|
964
1016
|
/** `substrat.wipe` on each declared store, before the scope's own bytes go. */
|
|
965
1017
|
const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
|
|
966
1018
|
/**
|
|
967
|
-
* `substrat.export`
|
|
1019
|
+
* A dump in pages (`substrat.export-page`) of each store the lifecycle reaches, for a backup, a full pull or a fork.
|
|
968
1020
|
*
|
|
969
1021
|
* A dump is only worth taking if it restores, and a restore imports only what the destination's
|
|
970
1022
|
* version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
|
|
@@ -1249,6 +1301,9 @@ export function createControlPlaneApi(options) {
|
|
|
1249
1301
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
|
|
1250
1302
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/concept$/ },
|
|
1251
1303
|
{ method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
|
|
1304
|
+
// The scope-store drivers one vertical may use (#2813), so a push judges its stores as the
|
|
1305
|
+
// upload will. `?vertical=` required; the answer names no other vertical.
|
|
1306
|
+
{ method: 'GET', re: /\/store-drivers$/ },
|
|
1252
1307
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
|
|
1253
1308
|
{ method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
|
|
1254
1309
|
{ method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
|
|
@@ -5346,20 +5401,67 @@ export function createControlPlaneApi(options) {
|
|
|
5346
5401
|
`store is global, so a backup would move its data out of that jurisdiction ` +
|
|
5347
5402
|
`(K-32); refused until a per-jurisdiction store exists`);
|
|
5348
5403
|
}
|
|
5349
|
-
const
|
|
5404
|
+
const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
5350
5405
|
const vertical = await verticalForScope(c, scope);
|
|
5351
|
-
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
5352
5406
|
// Store tables are opaque and stay unsealed. The platform reads them from the store script
|
|
5353
5407
|
// (#2207); a store it cannot name or reach refuses the backup rather than omitting it.
|
|
5354
|
-
const stores = (await exportPlatformStores(c, scope)) ??
|
|
5355
|
-
|
|
5356
|
-
//
|
|
5357
|
-
//
|
|
5358
|
-
//
|
|
5359
|
-
//
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5408
|
+
const stores = (await exportPlatformStores(c, scope)) ?? header.stores;
|
|
5409
|
+
const backup = backupStage(store.begin({ vertical: scope.vertical, header: { ...header, stores } }), sealerFor(c, tenantId, scope.id));
|
|
5410
|
+
// #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
|
|
5411
|
+
// moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
|
|
5412
|
+
// copied as it was at one moment, and never held whole here. A deployment that predates
|
|
5413
|
+
// paging answers whole, and that is stored as one entry, as the co-located host's tables are.
|
|
5414
|
+
const moved = vertical
|
|
5415
|
+
? (await transferScope(vertical, backup.dest, { from: scope.id }, { retry: retryTransient, stamp: false, alwaysStage: true })).dump
|
|
5416
|
+
: hostTables;
|
|
5417
|
+
if (Array.isArray(moved))
|
|
5418
|
+
await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
|
|
5419
|
+
try {
|
|
5420
|
+
return await backup.commit();
|
|
5421
|
+
}
|
|
5422
|
+
catch (e) {
|
|
5423
|
+
await backup.discard();
|
|
5424
|
+
throw e;
|
|
5425
|
+
}
|
|
5426
|
+
};
|
|
5427
|
+
/**
|
|
5428
|
+
* A backup being written, as the destination of a paged copy (#2839): each page and each batch of
|
|
5429
|
+
* captured changes the copy stages becomes one entry, sealed per subject on the way in (#37).
|
|
5430
|
+
* This copy is full-fidelity and the platform keeps it, which is precisely why an erasure could
|
|
5431
|
+
* never reach it: a redaction fixes the live scope and does nothing to the object already in R2.
|
|
5432
|
+
* Sealed here, destroying one subject's key reaches backwards into every copy already taken.
|
|
5433
|
+
* Restores open it again, except for subjects shredded in the meantime, which is the mechanism
|
|
5434
|
+
* working rather than the copy being damaged.
|
|
5435
|
+
*
|
|
5436
|
+
* A copy that starts again discards its stage and stages anew, so the backup is the stage written
|
|
5437
|
+
* last.
|
|
5438
|
+
*/
|
|
5439
|
+
const backupStage = (writer, sealer) => {
|
|
5440
|
+
let last = { stageId: '', entries: 0, tables: new Set() };
|
|
5441
|
+
const took = (stageId, seq, tables) => {
|
|
5442
|
+
if (stageId !== last.stageId)
|
|
5443
|
+
last = { stageId, entries: 0, tables: new Set() };
|
|
5444
|
+
last.entries = Math.max(last.entries, seq + 1);
|
|
5445
|
+
for (const t of tables)
|
|
5446
|
+
last.tables.add(t.name);
|
|
5447
|
+
};
|
|
5448
|
+
return {
|
|
5449
|
+
dest: {
|
|
5450
|
+
stageScopePage: async (_, stageId, seq, tables) => {
|
|
5451
|
+
await writer.put(stageId, seq, { kind: 'page', tables: await sealDump(tables, sealer) });
|
|
5452
|
+
took(stageId, seq, tables);
|
|
5453
|
+
return { staged: true };
|
|
5454
|
+
},
|
|
5455
|
+
stageScopeChanges: async (_, stageId, seq, tables) => {
|
|
5456
|
+
await writer.put(stageId, seq, { kind: 'changes', tables: await sealDump(tables, sealer) });
|
|
5457
|
+
took(stageId, seq, tables);
|
|
5458
|
+
return { staged: true };
|
|
5459
|
+
},
|
|
5460
|
+
discardStagedScope: (_, stageId) => writer.discard(stageId),
|
|
5461
|
+
},
|
|
5462
|
+
commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
|
|
5463
|
+
discard: () => writer.discard(last.stageId),
|
|
5464
|
+
};
|
|
5363
5465
|
};
|
|
5364
5466
|
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
5365
5467
|
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
@@ -5377,9 +5479,11 @@ export function createControlPlaneApi(options) {
|
|
|
5377
5479
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5378
5480
|
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
5379
5481
|
});
|
|
5380
|
-
// One backup's DUMP
|
|
5381
|
-
//
|
|
5382
|
-
//
|
|
5482
|
+
// One backup's DUMP. Staff-only like the export it came from (not in BUILDER_ROUTES), and
|
|
5483
|
+
// full-fidelity, so it is the same governed-pull posture: K-3 cross-checked above. Only a
|
|
5484
|
+
// backup stored in one entry is served whole (#2839). One stored in several (more pages, or
|
|
5485
|
+
// writes caught while it was taken) is not a dump until a deployment applies them in order,
|
|
5486
|
+
// so it is answered 413 and `POST …/backups/:capturedAt/restore` loads it back.
|
|
5383
5487
|
app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
|
|
5384
5488
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
5385
5489
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -5389,16 +5493,20 @@ export function createControlPlaneApi(options) {
|
|
|
5389
5493
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
5390
5494
|
if (!scope)
|
|
5391
5495
|
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5392
|
-
const
|
|
5393
|
-
if (!
|
|
5496
|
+
const held = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
5497
|
+
if (!held)
|
|
5394
5498
|
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
5499
|
+
if (held.entries > 1) {
|
|
5500
|
+
return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} is stored in ${held.entries} entries (its pages, and any writes caught while it was taken), which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
|
|
5501
|
+
}
|
|
5502
|
+
const { tables } = await held.entry(0);
|
|
5395
5503
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
5396
5504
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
5397
5505
|
// since the copy was taken opens to a null payload, which is exactly the erasure
|
|
5398
5506
|
// working: the ciphertext never left the object, and nothing turns it back into a
|
|
5399
5507
|
// person. A dump taken before sealing existed passes through untouched.
|
|
5400
|
-
const opened = await openDump(
|
|
5401
|
-
return c.json({ ...
|
|
5508
|
+
const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
|
|
5509
|
+
return c.json({ ...held.header, tables: opened });
|
|
5402
5510
|
});
|
|
5403
5511
|
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
5404
5512
|
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
@@ -5458,6 +5566,15 @@ export function createControlPlaneApi(options) {
|
|
|
5458
5566
|
// version that declares none is not asked. One that cannot be resolved while a driver is
|
|
5459
5567
|
// registered refuses (503): the key must not be destroyed while a store may hold the subject.
|
|
5460
5568
|
const reach = await platformStoresOf(c, scope, running);
|
|
5569
|
+
// A store whose driver cannot erase a subject yet would answer `{ erased: 0 }` while it still
|
|
5570
|
+
// holds them. Refused before anything is erased, so the key is kept and a retry can converge.
|
|
5571
|
+
const unerasable = reach?.stores.find((store) => {
|
|
5572
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
|
|
5573
|
+
return registration !== undefined && !storeDriverErasesSubjects(registration);
|
|
5574
|
+
});
|
|
5575
|
+
if (unerasable) {
|
|
5576
|
+
throw new ControlPlaneError(501, `scope ${scopeId}'s store '${unerasable.binding}' runs driver '${unerasable.driver}', which cannot erase a subject yet; nothing was erased`);
|
|
5577
|
+
}
|
|
5461
5578
|
/**
|
|
5462
5579
|
* L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
|
|
5463
5580
|
* would run a version whose stores this erasure did not reach. Both arms take one atomic claim
|
|
@@ -5900,9 +6017,10 @@ export function createControlPlaneApi(options) {
|
|
|
5900
6017
|
// closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
|
|
5901
6018
|
// read whole. Envelope only — no payload until erasure reaches the lake.
|
|
5902
6019
|
//
|
|
5903
|
-
// Staff
|
|
5904
|
-
//
|
|
5905
|
-
//
|
|
6020
|
+
// Staff, and a team's own credential (TENANT_ROUTES, path-pinned). The dashboard checks
|
|
6021
|
+
// `dashboard:read-event-lake` before it calls. A team's read must name a window of at most
|
|
6022
|
+
// `LAKE_TEAM_WINDOW_DAYS` (`teamWindowRefusal`): nothing below the gateway prunes by tenant,
|
|
6023
|
+
// so the window is what bounds the scan. Builders stay refused (not in BUILDER_ROUTES).
|
|
5906
6024
|
//
|
|
5907
6025
|
// K-24: the access row is written AFTER the engine answers and BEFORE anything is
|
|
5908
6026
|
// returned, and a failed write fails the request — rows whose disclosure went unrecorded
|
|
@@ -5923,6 +6041,11 @@ export function createControlPlaneApi(options) {
|
|
|
5923
6041
|
issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
|
|
5924
6042
|
});
|
|
5925
6043
|
}
|
|
6044
|
+
if (c.get('principal').kind === 'tenant') {
|
|
6045
|
+
const refusal = teamWindowRefusal(parsed.data, Date.now());
|
|
6046
|
+
if (refusal)
|
|
6047
|
+
return refuse(c, 'validation_failed', refusal);
|
|
6048
|
+
}
|
|
5926
6049
|
let page;
|
|
5927
6050
|
try {
|
|
5928
6051
|
page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
|
|
@@ -6232,32 +6355,135 @@ export function createControlPlaneApi(options) {
|
|
|
6232
6355
|
// keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
|
|
6233
6356
|
// a different scope, a world loaded sideways — must be opened with A's keys. Using
|
|
6234
6357
|
// the destination's would find no key, null every payload, and call it a restore.
|
|
6235
|
-
const
|
|
6236
|
-
|
|
6237
|
-
|
|
6358
|
+
const tables = await openDump(dump.tables, sealerFor(c, tenantIdSchema.parse(dump.tenantId), scopeIdSchema.parse(dump.scopeId)));
|
|
6359
|
+
return c.json(await loadIntoScope(c, scope, { header: dump, tables, sourceHint }));
|
|
6360
|
+
}
|
|
6361
|
+
catch (e) {
|
|
6362
|
+
return restoreFailed(c, scope, e);
|
|
6363
|
+
}
|
|
6364
|
+
});
|
|
6365
|
+
// Restore a held backup into its scope, read from the store here rather than handed in (#2839):
|
|
6366
|
+
// a backup stored in parts is staged at the deployment one entry at a time, opened with the
|
|
6367
|
+
// backup's own provenance, and loaded from the stage, so no call holds the whole backup. One
|
|
6368
|
+
// stored whole loads as `POST …/restore` loads a dump, through the same path. Staff-only, as the
|
|
6369
|
+
// backup routes are (not in TENANT_ROUTES or BUILDER_ROUTES).
|
|
6370
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt/restore', async (c) => {
|
|
6371
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
6372
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
6373
|
+
const capturedAt = c.req.param('capturedAt');
|
|
6374
|
+
const store = options.scopeBackups;
|
|
6375
|
+
if (!store)
|
|
6376
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
6377
|
+
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
6378
|
+
if (!scope)
|
|
6379
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
6380
|
+
const held = await store.get({ tenantId, scopeId, capturedAt });
|
|
6381
|
+
if (!held)
|
|
6382
|
+
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
6383
|
+
const { header } = held;
|
|
6384
|
+
if (options.platformScopeStores) {
|
|
6385
|
+
try {
|
|
6386
|
+
await refuseUndeclaredDumpStores(c, scope, header.stores);
|
|
6387
|
+
}
|
|
6388
|
+
catch (e) {
|
|
6389
|
+
if (e instanceof ControlPlaneError)
|
|
6390
|
+
return relayRefusal(c, e);
|
|
6391
|
+
throw e;
|
|
6392
|
+
}
|
|
6393
|
+
}
|
|
6394
|
+
const sealer = sealerFor(c, tenantIdSchema.parse(header.tenantId), scopeIdSchema.parse(header.scopeId));
|
|
6395
|
+
try {
|
|
6396
|
+
if (held.entries === 1) {
|
|
6397
|
+
const { tables } = await held.entry(0);
|
|
6398
|
+
if (tables.length === 0) {
|
|
6399
|
+
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
6400
|
+
}
|
|
6401
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
|
|
6402
|
+
}
|
|
6403
|
+
// The host judges the roles the backup seats or defines before anything is replaced (#2555), and
|
|
6404
|
+
// records the tables it restores. So it is handed every table's schema and the role tables' rows,
|
|
6405
|
+
// gathered while the entries are staged: its whole part of a load into a deployment, whose
|
|
6406
|
+
// placeholder is not the scope's store.
|
|
6407
|
+
const stage = async (vertical) => {
|
|
6408
|
+
const stageId = ulid();
|
|
6409
|
+
const judged = rolesJudgedOf();
|
|
6410
|
+
try {
|
|
6411
|
+
for (let index = 0; index < held.entries; index++) {
|
|
6412
|
+
const entry = await held.entry(index);
|
|
6413
|
+
let staged;
|
|
6414
|
+
if (entry.kind === 'page') {
|
|
6415
|
+
const tables = await openDump(entry.tables, sealer);
|
|
6416
|
+
judged.page(tables);
|
|
6417
|
+
staged = await retryTransient(() => vertical.stageScopePage(scopeId, stageId, index, tables));
|
|
6418
|
+
}
|
|
6419
|
+
else {
|
|
6420
|
+
const tables = await openDump(entry.tables, sealer);
|
|
6421
|
+
judged.changes(tables);
|
|
6422
|
+
staged = await retryTransient(() => vertical.stageScopeChanges(scopeId, stageId, index, tables));
|
|
6423
|
+
}
|
|
6424
|
+
if (staged === 'predates') {
|
|
6425
|
+
throw new ControlPlaneError(409, `backup ${capturedAt} is stored in ${held.entries} parts, and the deployment scope ${scopeId} runs cannot load one in parts; push a newer version of the vertical and restore again`);
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
catch (e) {
|
|
6430
|
+
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId });
|
|
6431
|
+
throw e;
|
|
6432
|
+
}
|
|
6433
|
+
return { dump: { stageId }, tables: judged.tables() };
|
|
6434
|
+
};
|
|
6435
|
+
return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
|
|
6436
|
+
}
|
|
6437
|
+
catch (e) {
|
|
6438
|
+
return restoreFailed(c, scope, e);
|
|
6439
|
+
}
|
|
6440
|
+
});
|
|
6441
|
+
/**
|
|
6442
|
+
* Load a dump into an existing scope: the one path `POST …/restore` and a backup's restore share,
|
|
6443
|
+
* so both write the same audit row, judge the same roles and stores, replay the same erasures and
|
|
6444
|
+
* clear the same receipt. `tables` are opened (#37). `stage`, for a backup stored in several
|
|
6445
|
+
* entries (#2839), stages it at the scope's deployment, which then loads the stage, and answers
|
|
6446
|
+
* the tables the host's audited restore judges and records in place of `tables`. Those are every
|
|
6447
|
+
* table's schema with the role rows only, so after a staged restore the host's placeholder holds
|
|
6448
|
+
* no data, and its `_substrat_migrations` and `_substrat_meta` journals are empty, where a whole
|
|
6449
|
+
* dump's restore leaves them full. The scope's own journals are the deployment's.
|
|
6450
|
+
*/
|
|
6451
|
+
const loadIntoScope = async (c, scope, input) => {
|
|
6452
|
+
const actor = c.get('actor');
|
|
6453
|
+
const tenantId = tenantIdSchema.parse(scope.tenantId);
|
|
6454
|
+
const scopeId = scopeIdSchema.parse(scope.id);
|
|
6455
|
+
const { header, sourceHint } = input;
|
|
6456
|
+
const origin = scopeIdSchema.parse(header.scopeId);
|
|
6457
|
+
const vertical = await verticalForScope(c, scope);
|
|
6458
|
+
if (input.stage && !vertical) {
|
|
6459
|
+
throw new ControlPlaneError(409, `scope ${scopeId} has no deployment to load a backup stored in parts into`);
|
|
6460
|
+
}
|
|
6461
|
+
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6462
|
+
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6463
|
+
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6464
|
+
const staged = input.stage && vertical ? await input.stage(vertical) : undefined;
|
|
6465
|
+
const tables = staged?.tables ?? input.tables;
|
|
6466
|
+
try {
|
|
6238
6467
|
const storesCaller = options.platformScopeStores;
|
|
6239
|
-
const landing = { ...
|
|
6240
|
-
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
6241
|
-
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
6242
|
-
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6468
|
+
const landing = { ...header, tables, stores: storesCaller && vertical ? [] : header.stores };
|
|
6243
6469
|
const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
6244
|
-
if (storesCaller && vertical && scope.vertical &&
|
|
6470
|
+
if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
|
|
6245
6471
|
await storesCaller.importStores({
|
|
6246
6472
|
tenantId,
|
|
6247
6473
|
scopeId,
|
|
6248
6474
|
vertical: scope.vertical,
|
|
6249
|
-
stores:
|
|
6475
|
+
stores: header.stores,
|
|
6250
6476
|
});
|
|
6251
6477
|
}
|
|
6252
6478
|
const platformReplay = storesCaller && scope.vertical
|
|
6253
|
-
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin
|
|
6479
|
+
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
|
|
6254
6480
|
: undefined;
|
|
6255
6481
|
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
6256
6482
|
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
6257
6483
|
// off in the replay's own event — its own sweeper cannot land in between.
|
|
6258
6484
|
const restored = vertical
|
|
6259
|
-
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables, {
|
|
6260
|
-
scopeId: sourceHint ?? origin
|
|
6485
|
+
? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
|
|
6486
|
+
scopeId: sourceHint ?? origin,
|
|
6261
6487
|
exact: false,
|
|
6262
6488
|
})
|
|
6263
6489
|
: undefined;
|
|
@@ -6271,31 +6497,37 @@ export function createControlPlaneApi(options) {
|
|
|
6271
6497
|
// finished: the install reads as not reconciled, and the sweep's next pass reconciles it.
|
|
6272
6498
|
// Again here, after the load, for a sweep reconcile that wrote the receipt while it ran.
|
|
6273
6499
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
6274
|
-
return
|
|
6500
|
+
return {
|
|
6275
6501
|
restored: scopeId,
|
|
6276
6502
|
tables: tables.length,
|
|
6277
6503
|
// #2820: what the deployment serving the scope did with the dump's own indexes and triggers.
|
|
6278
6504
|
...(restored ? { moduleObjects: restored.moduleObjects } : {}),
|
|
6279
6505
|
...storeReplayFields(replays),
|
|
6280
|
-
}
|
|
6506
|
+
};
|
|
6281
6507
|
}
|
|
6282
6508
|
catch (e) {
|
|
6283
|
-
//
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
if (e instanceof ControlPlaneError) {
|
|
6288
|
-
return relayRefusal(c, e);
|
|
6289
|
-
}
|
|
6290
|
-
// A restore throw is driven by the caller-supplied dump (a shape the target cannot
|
|
6291
|
-
// load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
|
|
6292
|
-
// collapsing to the generic 500 `internal error` mapError would produce (#321,
|
|
6293
|
-
// secondary obs #2). The route is staff- or own-tenant-gated and the detail is about the
|
|
6294
|
-
// dump the caller sent, not another tenant's state.
|
|
6295
|
-
const detail = e instanceof Error ? e.message : String(e);
|
|
6296
|
-
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
6509
|
+
// A stage the refused load left at the deployment goes now rather than waiting for a wipe.
|
|
6510
|
+
if (staged && vertical)
|
|
6511
|
+
await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
|
|
6512
|
+
throw e;
|
|
6297
6513
|
}
|
|
6298
|
-
}
|
|
6514
|
+
};
|
|
6515
|
+
/** How a refused or failed restore answers, on either route. */
|
|
6516
|
+
const restoreFailed = async (c, scope, e) => {
|
|
6517
|
+
// #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
|
|
6518
|
+
// receipt between the clear before the load and the throw. Clearing is always safe (at worst
|
|
6519
|
+
// one more reconcile); a clear that fails here must not hide the restore's own error.
|
|
6520
|
+
await c.var.admin.markScopeProvisioned(c.get('actor'), tenantIdSchema.parse(scope.tenantId), scopeIdSchema.parse(scope.id), null).catch(() => undefined);
|
|
6521
|
+
if (e instanceof ControlPlaneError) {
|
|
6522
|
+
return relayRefusal(c, e);
|
|
6523
|
+
}
|
|
6524
|
+
// A restore throw is driven by the dump (a shape the target cannot load, a DDL the engine
|
|
6525
|
+
// rejects), so DISCLOSE it as an actionable 422 rather than collapsing to the generic 500
|
|
6526
|
+
// `internal error` mapError would produce (#321, secondary obs #2). The routes are staff- or
|
|
6527
|
+
// own-tenant-gated and the detail is about the dump, not another tenant's state.
|
|
6528
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
6529
|
+
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
6530
|
+
};
|
|
6299
6531
|
// #1236: when this scope's migrations actually ran — the schema-change history
|
|
6300
6532
|
// whose instants `_substrat_migrations.applied_at` has held all along with no
|
|
6301
6533
|
// reader. Read through the vertical that holds the scope's data when one
|
|
@@ -7322,6 +7554,7 @@ export function createControlPlaneApi(options) {
|
|
|
7322
7554
|
return refuse(c, 'forbidden', 'forbidden');
|
|
7323
7555
|
}
|
|
7324
7556
|
input.verticalSlug = slug;
|
|
7557
|
+
let scopeStores = [];
|
|
7325
7558
|
// `manifestJson` is a string the schema cannot look inside, and every reader parses it
|
|
7326
7559
|
// with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
|
|
7327
7560
|
// (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
|
|
@@ -7367,8 +7600,16 @@ export function createControlPlaneApi(options) {
|
|
|
7367
7600
|
const connectorProblems = pushedConnectorProblems(checked.data);
|
|
7368
7601
|
if (connectorProblems.length > 0)
|
|
7369
7602
|
return refuseManifest({ path: ['connectors'], message: connectorProblems.join('; ') });
|
|
7370
|
-
|
|
7371
|
-
|
|
7603
|
+
scopeStores = checked.data.scopeStores;
|
|
7604
|
+
}
|
|
7605
|
+
// #2210: publishing records the version's store bindings for the whole vertical's lifecycle
|
|
7606
|
+
// (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
|
|
7607
|
+
// does: a driver with no approved release refuses here, and nothing is published. The publish is
|
|
7608
|
+
// made under the push's shared hold (#2816), so a rollout walks this vertical or waits for it.
|
|
7609
|
+
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null, async (renew) => {
|
|
7610
|
+
await renew();
|
|
7611
|
+
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
7612
|
+
});
|
|
7372
7613
|
const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
|
|
7373
7614
|
return c.json(version, 201);
|
|
7374
7615
|
});
|
|
@@ -7425,7 +7666,11 @@ export function createControlPlaneApi(options) {
|
|
|
7425
7666
|
base: base ?? null,
|
|
7426
7667
|
digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
|
|
7427
7668
|
};
|
|
7428
|
-
|
|
7669
|
+
// #2351: whether the incoming set was read from the version's bundle, or only declared by its
|
|
7670
|
+
// push (pushed before the read, or a bundle that exposes no registrations). A version storing none was
|
|
7671
|
+
// compared with nothing, whatever it recorded.
|
|
7672
|
+
const readFromBundle = incoming !== null && !!(await c.var.admin.versionMigrationsExtracted?.(c.get('actor'), slug, incomingId));
|
|
7673
|
+
return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review, readFromBundle });
|
|
7429
7674
|
});
|
|
7430
7675
|
// The emitted entity model (#1214) one version ships: entities, field schemas, parent
|
|
7431
7676
|
// edges, declared lifecycles — read out of the same retained manifest as the registry
|
|
@@ -7661,57 +7906,78 @@ export function createControlPlaneApi(options) {
|
|
|
7661
7906
|
* (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
|
|
7662
7907
|
* The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
|
|
7663
7908
|
* path reads the owner only once something has failed.
|
|
7909
|
+
*
|
|
7910
|
+
* `then` runs after the uploads, still under the shared holds (#2816): a push or a publish passes
|
|
7911
|
+
* the step that records the version's store declarations, so a rollout either finds the vertical
|
|
7912
|
+
* in what it walks or is refused until that step is done. `renew` renews every hold, and refuses
|
|
7913
|
+
* (409) once one has lapsed; `then` calls it right before it records. A failure in `then` is not
|
|
7914
|
+
* recorded here: it is the caller's to answer.
|
|
7664
7915
|
*/
|
|
7665
|
-
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
|
|
7916
|
+
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner, then = async () => undefined) => {
|
|
7666
7917
|
if (stores.length === 0)
|
|
7667
|
-
return;
|
|
7918
|
+
return then(async () => undefined);
|
|
7919
|
+
// D-67: a driver whose registration names its verticals serves those only, refused before any
|
|
7920
|
+
// hold is taken or anything is uploaded.
|
|
7921
|
+
for (const store of stores) {
|
|
7922
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
|
|
7923
|
+
if (registration && !storeDriverServesVertical(registration, slug)) {
|
|
7924
|
+
throw new ControlPlaneError(403, `${operation} refused: scope store driver '${store.driver}' does not serve vertical '${slug}'`);
|
|
7925
|
+
}
|
|
7926
|
+
}
|
|
7668
7927
|
const holder = ulid();
|
|
7669
7928
|
const drivers = [...new Set(stores.map((store) => store.driver))].sort();
|
|
7670
7929
|
const held = [];
|
|
7930
|
+
const renewHolds = async (when) => {
|
|
7931
|
+
for (const each of held) {
|
|
7932
|
+
if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
7933
|
+
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed ${when}; try again`);
|
|
7934
|
+
}
|
|
7935
|
+
}
|
|
7936
|
+
};
|
|
7937
|
+
const renew = () => renewHolds('during the upload');
|
|
7671
7938
|
try {
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
// A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
7676
|
-
// a script's release before a rollout moved it would upload the old release back over it.
|
|
7677
|
-
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
7678
|
-
for (const driver of drivers) {
|
|
7679
|
-
if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
7680
|
-
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
7939
|
+
try {
|
|
7940
|
+
if (!options.ensureScopeStores) {
|
|
7941
|
+
throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
|
|
7681
7942
|
}
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
7689
|
-
for (const driver of drivers) {
|
|
7690
|
-
for (const each of held) {
|
|
7691
|
-
if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
7692
|
-
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
|
|
7943
|
+
// A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
7944
|
+
// a script's release before a rollout moved it would upload the old release back over it.
|
|
7945
|
+
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
7946
|
+
for (const driver of drivers) {
|
|
7947
|
+
if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
7948
|
+
throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
|
|
7693
7949
|
}
|
|
7950
|
+
held.push(driver);
|
|
7951
|
+
}
|
|
7952
|
+
const loadRelease = options.storeReleases
|
|
7953
|
+
? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
|
|
7954
|
+
: async () => undefined;
|
|
7955
|
+
// One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
|
|
7956
|
+
// stops (409) rather than upload after a rollout could have taken the driver.
|
|
7957
|
+
for (const driver of drivers) {
|
|
7958
|
+
await renew();
|
|
7959
|
+
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
7960
|
+
if (!registration)
|
|
7961
|
+
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
7962
|
+
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
7963
|
+
// before this read is seen, and none is written until this upload is done.
|
|
7964
|
+
const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
|
|
7965
|
+
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7694
7966
|
}
|
|
7695
|
-
const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
|
|
7696
|
-
if (!registration)
|
|
7697
|
-
throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
|
|
7698
|
-
// Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
|
|
7699
|
-
// before this read is seen, and none is written until this upload is done.
|
|
7700
|
-
const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
|
|
7701
|
-
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
|
|
7702
7967
|
}
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7968
|
+
catch (e) {
|
|
7969
|
+
recordFailure({
|
|
7970
|
+
actor,
|
|
7971
|
+
operation: `${operation}.store-upload`,
|
|
7972
|
+
stage: 'store-upload',
|
|
7973
|
+
vertical: slug,
|
|
7974
|
+
tenantId: await owner().catch(() => null),
|
|
7975
|
+
status: e instanceof ControlPlaneError ? e.status : 502,
|
|
7976
|
+
message: e instanceof Error ? e.message : String(e),
|
|
7977
|
+
}, e);
|
|
7978
|
+
throw e;
|
|
7979
|
+
}
|
|
7980
|
+
return await then(() => renewHolds('before the version was recorded'));
|
|
7715
7981
|
}
|
|
7716
7982
|
finally {
|
|
7717
7983
|
for (const driver of held)
|
|
@@ -7752,6 +8018,59 @@ export function createControlPlaneApi(options) {
|
|
|
7752
8018
|
recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
|
|
7753
8019
|
}
|
|
7754
8020
|
};
|
|
8021
|
+
const readBundleMigrations = async (bundle, declared) => {
|
|
8022
|
+
if (!options.extractMigrations)
|
|
8023
|
+
return { kind: 'unmarked' };
|
|
8024
|
+
let registered;
|
|
8025
|
+
try {
|
|
8026
|
+
registered = await options.extractMigrations(bundle);
|
|
8027
|
+
}
|
|
8028
|
+
catch (e) {
|
|
8029
|
+
if (e instanceof MigrationLoaderUnavailable)
|
|
8030
|
+
return { kind: 'unavailable', message: e.message };
|
|
8031
|
+
if (!(e instanceof MigrationExtractionError))
|
|
8032
|
+
throw e;
|
|
8033
|
+
return { kind: 'failed', message: e.message };
|
|
8034
|
+
}
|
|
8035
|
+
if (!registered)
|
|
8036
|
+
return { kind: 'unmarked' };
|
|
8037
|
+
const problem = declared && extractedMigrationsProblem(declared, registered);
|
|
8038
|
+
if (problem)
|
|
8039
|
+
return { kind: 'differs', message: extractedMigrationsRefusal(problem) };
|
|
8040
|
+
return { kind: 'read', digest: await extractedMigrationsDigest(registered) };
|
|
8041
|
+
};
|
|
8042
|
+
/**
|
|
8043
|
+
* #2351: a version pushed before its bundle was read is read once, at its first promote, from
|
|
8044
|
+
* its archive script. Same set: recorded, and the promote goes on. Another set: the refusal,
|
|
8045
|
+
* before anything moves. No registrations, or a bundle that cannot be read here (it was
|
|
8046
|
+
* accepted at push, before this read existed): the promote goes on as it did, unrecorded, so such
|
|
8047
|
+
* a version is read again at every promote. A version storing no migrations has nothing to
|
|
8048
|
+
* compare, and one that is not admitted is refused by the promote without a read.
|
|
8049
|
+
*/
|
|
8050
|
+
const bundleMigrationsRefusal = async (admin, actor, slug, versionId) => {
|
|
8051
|
+
if (!options.extractMigrations || !options.fetchVerticalModules || !admin.versionMigrationsExtracted || !admin.recordMigrationsExtracted) {
|
|
8052
|
+
return null;
|
|
8053
|
+
}
|
|
8054
|
+
const [extracted, declared, version, manifestJson] = await Promise.all([
|
|
8055
|
+
admin.versionMigrationsExtracted(actor, slug, versionId),
|
|
8056
|
+
admin.versionMigrations(actor, slug, versionId),
|
|
8057
|
+
admin.getVersion(actor, versionId, slug),
|
|
8058
|
+
admin.versionManifest(actor, slug, versionId),
|
|
8059
|
+
]);
|
|
8060
|
+
if (extracted !== null || declared === null || version?.admission !== 'admitted' || !version.deploymentRef || !manifestJson)
|
|
8061
|
+
return null;
|
|
8062
|
+
const manifest = storedDeployManifest.parse(JSON.parse(manifestJson));
|
|
8063
|
+
// An archive that cannot be read back is the serve's to report, after the promote, as it is today.
|
|
8064
|
+
const modules = await options.fetchVerticalModules(version.deploymentRef).catch(() => null);
|
|
8065
|
+
if (!modules)
|
|
8066
|
+
return null;
|
|
8067
|
+
const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, declared);
|
|
8068
|
+
if (read.kind === 'differs')
|
|
8069
|
+
return read.message;
|
|
8070
|
+
if (read.kind === 'read')
|
|
8071
|
+
await admin.recordMigrationsExtracted(actor, slug, versionId, read.digest);
|
|
8072
|
+
return null;
|
|
8073
|
+
};
|
|
7755
8074
|
/**
|
|
7756
8075
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
7757
8076
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -8144,6 +8463,10 @@ export function createControlPlaneApi(options) {
|
|
|
8144
8463
|
const answer = roleStateAnswer(roles, confinedTenant(p));
|
|
8145
8464
|
if (answer)
|
|
8146
8465
|
return refuseStatus(c, answer.status, answer.message, answer.extra);
|
|
8466
|
+
// #2351: a version whose bundle was never read is read now, before anything moves.
|
|
8467
|
+
const bundleRefusal = await bundleMigrationsRefusal(c.var.admin, c.get('actor'), slug, versionId);
|
|
8468
|
+
if (bundleRefusal)
|
|
8469
|
+
return refuseStatus(c, 422, bundleRefusal);
|
|
8147
8470
|
// The blast-radius moment: refuses a changed digest without the acknowledgement,
|
|
8148
8471
|
// and refuses a non-admitted version. Both are enforced below the seam and
|
|
8149
8472
|
// surface as a 4xx through mapError, not a 500.
|
|
@@ -8592,123 +8915,150 @@ export function createControlPlaneApi(options) {
|
|
|
8592
8915
|
const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
|
|
8593
8916
|
if ('refuse' in sweeper)
|
|
8594
8917
|
return refuseStatus(c, 422, sweeper.refuse);
|
|
8918
|
+
// #2351: the migrations the bundle registers, read before anything is uploaded. Where the
|
|
8919
|
+
// manifest carries migrations they must be the registered ones, or the push is refused, and
|
|
8920
|
+
// so is a bundle that cannot be evaluated or does not settle: the stored set is then provably
|
|
8921
|
+
// what the version runs. A declared `[]` is a set like any other: the bundle must register
|
|
8922
|
+
// none. A loader that fails is the platform's, so that push answers 503 and may be retried. A
|
|
8923
|
+
// manifest that leaves the field off (over the caps a manifest carries) stores none, so nothing
|
|
8924
|
+
// is gated: its push is accepted however the read went, and records the digest of what was
|
|
8925
|
+
// read, if anything was.
|
|
8926
|
+
const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, manifest.migrations ?? null);
|
|
8927
|
+
if (read.kind === 'differs' || (read.kind === 'failed' && manifest.migrations))
|
|
8928
|
+
return refuseStatus(c, 422, read.message);
|
|
8929
|
+
if (read.kind === 'unavailable' && manifest.migrations)
|
|
8930
|
+
return refuseStatus(c, 503, read.message);
|
|
8931
|
+
const migrationsExtracted = read.kind === 'read' ? read.digest : null;
|
|
8595
8932
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
8596
8933
|
// on it, so it is CF-valid and unique per version.
|
|
8597
8934
|
const id = ulid();
|
|
8598
8935
|
const deploymentRef = deploymentRefFor(slug, id);
|
|
8599
8936
|
// #2210: the store scripts first, outside the vertical upload's try, so a store failure
|
|
8600
|
-
// answers as itself and the vertical is not uploaded at all.
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8937
|
+
// answers as itself and the vertical is not uploaded at all. The upload, the registration and
|
|
8938
|
+
// the publish run under the store push's shared hold (#2816): a rollout that starts before the
|
|
8939
|
+
// version records its stores is refused, and one after walks this vertical.
|
|
8940
|
+
const deployVertical = options.deployVertical;
|
|
8941
|
+
const pushed = await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
|
|
8942
|
+
let wire;
|
|
8943
|
+
try {
|
|
8944
|
+
wire = await deployVerticalScript(deployVertical, c.get('actor'), slug, deploymentRef, {
|
|
8945
|
+
entry: manifest.entry,
|
|
8946
|
+
compatibilityDate: manifest.compatibilityDate,
|
|
8947
|
+
compatibilityFlags: manifest.compatibilityFlags,
|
|
8948
|
+
modules,
|
|
8949
|
+
doClasses: manifest.doClasses,
|
|
8950
|
+
bindings: verticalUploadBindings({
|
|
8951
|
+
vertical: slug,
|
|
8952
|
+
manifest,
|
|
8953
|
+
drivers: options.storeDrivers,
|
|
8954
|
+
// #2349: the migrations this push declares, which are the ones the version stores.
|
|
8955
|
+
platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
|
|
8956
|
+
}),
|
|
8957
|
+
// #1242: the freshly minted version id — this archive script IS that version,
|
|
8958
|
+
// and a preview routed at it stamps the signals `version` dimension honestly.
|
|
8959
|
+
versionId: id,
|
|
8960
|
+
// #1902: the platform's scope sweeper, as decided above and recorded with the version.
|
|
8961
|
+
...(sweeper.supply ? { supplySweeper: true } : {}),
|
|
8962
|
+
// #340: the verified bytes go up with the bundle. The manifest's routing config
|
|
8963
|
+
// rides along untouched — it decides what the RUNTIME does with paths, and carries
|
|
8964
|
+
// no reach, so there is nothing in it for the sandbox contract to refuse.
|
|
8965
|
+
...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
|
|
8966
|
+
});
|
|
8967
|
+
}
|
|
8968
|
+
catch (e) {
|
|
8969
|
+
// The upload to the runtime failed. Surface the detail (the builder is
|
|
8970
|
+
// authenticated) rather than the anonymous 500 the generic handler would give, so a
|
|
8971
|
+
// push failure is diagnosable without reading worker logs. The version label is NOT
|
|
8972
|
+
// consumed here: registration/publish happen only AFTER a successful upload (below),
|
|
8973
|
+
// so a failed push leaves the same --version reusable (#307).
|
|
8974
|
+
//
|
|
8975
|
+
// Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
|
|
8976
|
+
// builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
|
|
8977
|
+
// but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
|
|
8978
|
+
// throw with no upstream status) is a platform failure and stays a 502.
|
|
8979
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
8980
|
+
const upstream = upstreamStatusOf(e);
|
|
8981
|
+
console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
|
|
8982
|
+
const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
|
|
8983
|
+
// Both outcomes land an ops-failure row (#559): the 502 is the platform's to
|
|
8984
|
+
// explain, and the 422 is what the builder-facing failure view (step 5) will
|
|
8985
|
+
// list — a red push should be explainable from a durable record either way.
|
|
8986
|
+
recordFailure({
|
|
8987
|
+
actor: c.get('actor'),
|
|
8988
|
+
operation: 'deploy.upload',
|
|
8989
|
+
stage: 'wfp-upload',
|
|
8611
8990
|
vertical: slug,
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8991
|
+
tenantId: ownerTenant ?? null,
|
|
8992
|
+
status: rejected ? 422 : 502,
|
|
8993
|
+
message: detail,
|
|
8994
|
+
}, e);
|
|
8995
|
+
c.set('failureRecorded', true);
|
|
8996
|
+
return rejected
|
|
8997
|
+
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
8998
|
+
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
8999
|
+
}
|
|
9000
|
+
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
9001
|
+
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
9002
|
+
// (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
|
|
9003
|
+
// was resolved with the slug above: the builder's tenant, the pinned workspace, or
|
|
9004
|
+
// the preserved existing owner for an unpinned staff push. An upload that outlasted the
|
|
9005
|
+
// store hold refuses here (409) before the vertical or its version is registered, so the
|
|
9006
|
+
// label stays reusable; unlike a failed upload, it lands no ops-failure row.
|
|
9007
|
+
await renew();
|
|
9008
|
+
await c.var.admin.registerVertical(c.get('actor'), {
|
|
9009
|
+
slug,
|
|
9010
|
+
name: manifest.name ?? slug,
|
|
9011
|
+
source: 'cli',
|
|
9012
|
+
ownerTenant,
|
|
9013
|
+
// The vertical's declared config surface rides to the registry, so the dashboard
|
|
9014
|
+
// renders a settings form for a pushed vertical exactly like a builtin.
|
|
9015
|
+
...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
|
|
9016
|
+
// Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
|
|
9017
|
+
// installs a pushed vertical without a hardcoded catalog entry.
|
|
9018
|
+
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
9019
|
+
...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
|
|
9020
|
+
...(manifest.provides ? { provides: manifest.provides } : {}),
|
|
9021
|
+
...(manifest.requires ? { requires: manifest.requires } : {}),
|
|
9022
|
+
// The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
|
|
9023
|
+
// door renders. The proxy never reads this copy; it reads the running version's manifest.
|
|
9024
|
+
...(manifest.connectors ? { connectors: manifest.connectors } : {}),
|
|
9025
|
+
// The declared provisioner intent (#455) — a REQUEST the console reviews; the
|
|
9026
|
+
// tenant-provisioner grant itself is never touched by a push. Refreshes on every
|
|
9027
|
+
// re-push like the rest of the install spec, exactly because it grants nothing.
|
|
9028
|
+
...(manifest.provisions ? { provisions: manifest.provisions } : {}),
|
|
9029
|
+
// The declared email-sender intent (#303) — a REQUEST the console reviews; the
|
|
9030
|
+
// `emailSender` grant itself is never touched by a push. Refreshes on every re-push
|
|
9031
|
+
// like the rest of the install spec, exactly because it grants nothing.
|
|
9032
|
+
...(manifest.sendsEmail ? { sendsEmail: true } : {}),
|
|
9033
|
+
// Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
|
|
9034
|
+
// the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
|
|
9035
|
+
...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
|
|
9036
|
+
// The declared surfaces (K-26) ride like envSpec: registry metadata for the
|
|
9037
|
+
// hostname-binding picker, never behavior. Not part of any admission digest.
|
|
9038
|
+
...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
|
|
8626
9039
|
});
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
8642
|
-
const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
|
|
8643
|
-
// Both outcomes land an ops-failure row (#559): the 502 is the platform's to
|
|
8644
|
-
// explain, and the 422 is what the builder-facing failure view (step 5) will
|
|
8645
|
-
// list — a red push should be explainable from a durable record either way.
|
|
8646
|
-
recordFailure({
|
|
8647
|
-
actor: c.get('actor'),
|
|
8648
|
-
operation: 'deploy.upload',
|
|
8649
|
-
stage: 'wfp-upload',
|
|
8650
|
-
vertical: slug,
|
|
8651
|
-
tenantId: ownerTenant ?? null,
|
|
8652
|
-
status: rejected ? 422 : 502,
|
|
8653
|
-
message: detail,
|
|
8654
|
-
}, e);
|
|
8655
|
-
c.set('failureRecorded', true);
|
|
8656
|
-
return rejected
|
|
8657
|
-
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
8658
|
-
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
8659
|
-
}
|
|
8660
|
-
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
8661
|
-
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
8662
|
-
// (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
|
|
8663
|
-
// was resolved with the slug above: the builder's tenant, the pinned workspace, or
|
|
8664
|
-
// the preserved existing owner for an unpinned staff push.
|
|
8665
|
-
await c.var.admin.registerVertical(c.get('actor'), {
|
|
8666
|
-
slug,
|
|
8667
|
-
name: manifest.name ?? slug,
|
|
8668
|
-
source: 'cli',
|
|
8669
|
-
ownerTenant,
|
|
8670
|
-
// The vertical's declared config surface rides to the registry, so the dashboard
|
|
8671
|
-
// renders a settings form for a pushed vertical exactly like a builtin.
|
|
8672
|
-
...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
|
|
8673
|
-
// Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
|
|
8674
|
-
// installs a pushed vertical without a hardcoded catalog entry.
|
|
8675
|
-
...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
|
|
8676
|
-
...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
|
|
8677
|
-
...(manifest.provides ? { provides: manifest.provides } : {}),
|
|
8678
|
-
...(manifest.requires ? { requires: manifest.requires } : {}),
|
|
8679
|
-
// The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
|
|
8680
|
-
// door renders. The proxy never reads this copy; it reads the running version's manifest.
|
|
8681
|
-
...(manifest.connectors ? { connectors: manifest.connectors } : {}),
|
|
8682
|
-
// The declared provisioner intent (#455) — a REQUEST the console reviews; the
|
|
8683
|
-
// tenant-provisioner grant itself is never touched by a push. Refreshes on every
|
|
8684
|
-
// re-push like the rest of the install spec, exactly because it grants nothing.
|
|
8685
|
-
...(manifest.provisions ? { provisions: manifest.provisions } : {}),
|
|
8686
|
-
// The declared email-sender intent (#303) — a REQUEST the console reviews; the
|
|
8687
|
-
// `emailSender` grant itself is never touched by a push. Refreshes on every re-push
|
|
8688
|
-
// like the rest of the install spec, exactly because it grants nothing.
|
|
8689
|
-
...(manifest.sendsEmail ? { sendsEmail: true } : {}),
|
|
8690
|
-
// Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
|
|
8691
|
-
// the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
|
|
8692
|
-
...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
|
|
8693
|
-
// The declared surfaces (K-26) ride like envSpec: registry metadata for the
|
|
8694
|
-
// hostname-binding picker, never behavior. Not part of any admission digest.
|
|
8695
|
-
...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
|
|
8696
|
-
});
|
|
8697
|
-
await c.var.admin.publishVersion(c.get('actor'), {
|
|
8698
|
-
id,
|
|
8699
|
-
verticalSlug: slug,
|
|
8700
|
-
version: manifest.version,
|
|
8701
|
-
manifestDigest: manifest.digests.manifest,
|
|
8702
|
-
permissionDigest: manifest.digests.permission,
|
|
8703
|
-
migrationDigest: manifest.digests.migration,
|
|
8704
|
-
deploymentRef,
|
|
8705
|
-
...(origin ? { origin } : {}),
|
|
8706
|
-
// Retained for the serving upload (#286): the archive script keeps the module
|
|
8707
|
-
// bytes, this keeps their shape (entry, compat, doClasses, bindings).
|
|
8708
|
-
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
8709
|
-
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
9040
|
+
await c.var.admin.publishVersion(c.get('actor'), {
|
|
9041
|
+
id,
|
|
9042
|
+
verticalSlug: slug,
|
|
9043
|
+
version: manifest.version,
|
|
9044
|
+
manifestDigest: manifest.digests.manifest,
|
|
9045
|
+
permissionDigest: manifest.digests.permission,
|
|
9046
|
+
migrationDigest: manifest.digests.migration,
|
|
9047
|
+
deploymentRef,
|
|
9048
|
+
...(origin ? { origin } : {}),
|
|
9049
|
+
// Retained for the serving upload (#286): the archive script keeps the module
|
|
9050
|
+
// bytes, this keeps their shape (entry, compat, doClasses, bindings).
|
|
9051
|
+
// #1902: with the sweeper decision the push made, which no later upload re-makes.
|
|
9052
|
+
manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
|
|
9053
|
+
});
|
|
9054
|
+
return { wire };
|
|
8710
9055
|
});
|
|
9056
|
+
if (pushed instanceof Response)
|
|
9057
|
+
return pushed;
|
|
9058
|
+
const { wire } = pushed;
|
|
8711
9059
|
await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
|
|
9060
|
+
if (migrationsExtracted)
|
|
9061
|
+
await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
|
|
8712
9062
|
// Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
|
|
8713
9063
|
// surfaces, name any surface that hostnames are still bound to but the declaration
|
|
8714
9064
|
// dropped — the URL keeps resolving (routing never keys on the declaration), it just
|
|
@@ -10869,6 +11219,32 @@ export function createControlPlaneApi(options) {
|
|
|
10869
11219
|
});
|
|
10870
11220
|
return c.json(pageOf(entries, filter.limit, (e) => e.id));
|
|
10871
11221
|
});
|
|
11222
|
+
// -- builder turns: one summary per builder-studio turn, every team -------------
|
|
11223
|
+
// Staff only, and stated here rather than left to the allowlists' default-deny: a
|
|
11224
|
+
// summary names a team and a project, and the turn behind it carries what someone
|
|
11225
|
+
// typed into the studio. The builder answers both; this relays and re-parses.
|
|
11226
|
+
app.get('/builder-turns', async (c) => {
|
|
11227
|
+
if (!staffCaller(c))
|
|
11228
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
11229
|
+
if (!options.builderTurns)
|
|
11230
|
+
return refuseStatus(c, 501, 'the builder studio is not configured');
|
|
11231
|
+
const url = new URL(c.req.url);
|
|
11232
|
+
const forwarded = new URLSearchParams();
|
|
11233
|
+
for (const key of ['tenantId', 'projectId', 'outcome', 'redGate', 'troubled', 'since', 'limit', 'cursor']) {
|
|
11234
|
+
const value = url.searchParams.get(key);
|
|
11235
|
+
if (value)
|
|
11236
|
+
forwarded.set(key, value);
|
|
11237
|
+
}
|
|
11238
|
+
return c.json(await options.builderTurns.list(forwarded));
|
|
11239
|
+
});
|
|
11240
|
+
app.get('/builder-turns/:turnId', async (c) => {
|
|
11241
|
+
if (!staffCaller(c))
|
|
11242
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
11243
|
+
if (!options.builderTurns)
|
|
11244
|
+
return refuseStatus(c, 501, 'the builder studio is not configured');
|
|
11245
|
+
const turn = await options.builderTurns.get(c.req.param('turnId'));
|
|
11246
|
+
return turn ? c.json(turn) : refuse(c, 'not_found', 'no such turn');
|
|
11247
|
+
});
|
|
10872
11248
|
// -- sweep runs (#1232): the durable record of what a sweep pass touched -------
|
|
10873
11249
|
// Staff read fleet-wide (the console's later view); a builder's tenant is forced
|
|
10874
11250
|
// from the principal, the /ops-failures posture exactly.
|
|
@@ -11192,6 +11568,32 @@ export function createControlPlaneApi(options) {
|
|
|
11192
11568
|
const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
|
|
11193
11569
|
return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
|
|
11194
11570
|
};
|
|
11571
|
+
// The scope-store registry a push is judged by, for ONE vertical (#2813): `?vertical=` is the slug the
|
|
11572
|
+
// push deploys as, required (bare, or already prefixed with the caller's own workspace). Every registration comes back, with `verticals` reduced to
|
|
11573
|
+
// `[vertical]` when it serves that vertical and `[]` when it does not (omitted when it serves
|
|
11574
|
+
// every one), so the CLI's own check can say "does not serve" and no other vertical's slug is
|
|
11575
|
+
// read. "Serves" is judged for the registry id the deploy forms (`<tenant>/<name>` for a builder
|
|
11576
|
+
// or a pinned staff push), the same one `ensureScopeStoresOf` judges. The staff-only
|
|
11577
|
+
// `/store-drivers/:driver` below is the driver's release state and stays as it was.
|
|
11578
|
+
app.get('/store-drivers', async (c) => {
|
|
11579
|
+
const parsed = verticalSlug.safeParse(c.req.query('vertical'));
|
|
11580
|
+
if (!parsed.success)
|
|
11581
|
+
return refuse(c, 'validation_failed', 'vertical is required: the slug the push deploys as');
|
|
11582
|
+
const vertical = parsed.data;
|
|
11583
|
+
const p = c.get('principal');
|
|
11584
|
+
const pin = c.req.header(TENANT_HEADER)?.trim();
|
|
11585
|
+
let id = effectiveSlug(p, vertical);
|
|
11586
|
+
if (p.kind !== 'builder' && pin) {
|
|
11587
|
+
// The pinned-staff branch of the deploy's own slug derivation (`POST /verticals/:slug/deploy`).
|
|
11588
|
+
const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
|
|
11589
|
+
if (!workspace)
|
|
11590
|
+
return refuse(c, 'not_found', `unknown workspace '${pin}'`);
|
|
11591
|
+
const bareOwner = await ownerOf(c.get('actor'), vertical);
|
|
11592
|
+
id = vertical.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? vertical : `${workspace.slug}/${vertical}`;
|
|
11593
|
+
}
|
|
11594
|
+
const drivers = (options.storeDrivers ?? STORE_DRIVERS).map((d) => d.verticals ? { ...d, verticals: storeDriverServesVertical(d, id) ? [vertical] : [] } : d);
|
|
11595
|
+
return c.json({ drivers });
|
|
11596
|
+
});
|
|
11195
11597
|
app.get('/store-drivers/:driver', async (c) => {
|
|
11196
11598
|
await staffPerson(c, statusWords);
|
|
11197
11599
|
const registration = registeredDriver(c.req.param('driver'));
|