@substrat-run/control-plane-api 0.146.0 → 0.148.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 +69 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +986 -383
- package/dist/api.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/deploy.d.ts +46 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +53 -1
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts +19 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -4
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/lake-gateway.d.ts +54 -0
- package/dist/lake-gateway.d.ts.map +1 -0
- package/dist/lake-gateway.js +158 -0
- package/dist/lake-gateway.js.map +1 -0
- package/dist/platform-entry.d.ts +23 -1
- package/dist/platform-entry.d.ts.map +1 -1
- package/dist/platform-entry.generated.d.ts +9 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +10 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-entry.js +31 -1
- package/dist/platform-entry.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +151 -0
- package/dist/platform-scope-stores.d.ts.map +1 -0
- package/dist/platform-scope-stores.js +241 -0
- package/dist/platform-scope-stores.js.map +1 -0
- package/dist/r2-sql-engine.d.ts +50 -0
- package/dist/r2-sql-engine.d.ts.map +1 -0
- package/dist/r2-sql-engine.js +126 -0
- package/dist/r2-sql-engine.js.map +1 -0
- package/dist/scope-copy-cleanup.d.ts +12 -0
- package/dist/scope-copy-cleanup.d.ts.map +1 -1
- package/dist/scope-copy-cleanup.js +14 -3
- package/dist/scope-copy-cleanup.js.map +1 -1
- package/dist/staff-client.d.ts +9 -1
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/store-entry.d.ts +90 -0
- package/dist/store-entry.d.ts.map +1 -0
- package/dist/store-entry.generated.d.ts +60 -0
- package/dist/store-entry.generated.d.ts.map +1 -0
- package/dist/store-entry.generated.js +62 -0
- package/dist/store-entry.generated.js.map +1 -0
- package/dist/store-entry.js +178 -0
- package/dist/store-entry.js.map +1 -0
- package/dist/store-releases.d.ts +147 -0
- package/dist/store-releases.d.ts.map +1 -0
- package/dist/store-releases.js +298 -0
- package/dist/store-releases.js.map +1 -0
- package/dist/store-script.d.ts +80 -0
- package/dist/store-script.d.ts.map +1 -0
- package/dist/store-script.js +173 -0
- package/dist/store-script.js.map +1 -0
- package/dist/vertical-client.d.ts +19 -2
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +10 -1
- package/dist/vertical-client.js.map +1 -1
- package/dist/wfp.js +1 -1
- package/dist/wfp.js.map +1 -1
- package/package.json +7 -6
package/dist/api.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
|
|
1
2
|
import { resolveObservabilityWindow } from './observability-window.js';
|
|
2
3
|
import { Hono } from 'hono';
|
|
3
|
-
import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, 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, 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, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, 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, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
|
|
4
|
-
import { SCOPE_COPY_LEASE_MS, copyRestoreFence } from '@substrat-run/kernel';
|
|
4
|
+
import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_KINDS, STORE_RELEASE_MAX_BYTES, 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, 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, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, 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, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
|
|
5
|
+
import { SCOPE_COPY_LEASE_MS, copyRestoreFence, replayStoreErasures } from '@substrat-run/kernel';
|
|
5
6
|
import { attributeFailure } from './failure-attribution.js';
|
|
6
7
|
import { auditedChange, withAuditedOutcomes } from './audited-change.js';
|
|
7
8
|
import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
|
|
8
9
|
import { TENANT_HEADER, confinedTenant } from './auth.js';
|
|
9
10
|
import { mintTenantToken } from './tenant-token.js';
|
|
10
11
|
import { connectionGrantsForScope } from './vertical-client.js';
|
|
11
|
-
import {
|
|
12
|
+
import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
|
|
13
|
+
import { PUSH_HOLD_TTL_MS, runStoreKindRollout, storeKindRelease, storedReleaseLoader } from './store-releases.js';
|
|
14
|
+
import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
|
|
12
15
|
import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
|
|
13
16
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
14
17
|
import { scopeDeployment, versionReachedAt } from './scope-deployment.js';
|
|
@@ -17,11 +20,11 @@ import { reconcileOutcomeOf, reconcileThenReassert, reviewedEntityGrants, switch
|
|
|
17
20
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
18
21
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
19
22
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
20
|
-
import { mapError } from './errors.js';
|
|
23
|
+
import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
|
|
21
24
|
import { maskDump, maskRecords } from './mask.js';
|
|
22
25
|
import { createPseudonymizer } from './pseudonymize.js';
|
|
23
26
|
import { openDump, sealDump } from './seal.js';
|
|
24
|
-
import { assertSandboxContract, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
27
|
+
import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
25
28
|
/**
|
|
26
29
|
* Answer with a problem document (#113 phase 4).
|
|
27
30
|
*
|
|
@@ -29,12 +32,30 @@ import { assertSandboxContract, deployManifest, storedDeployManifest, pushedPerm
|
|
|
29
32
|
* labelled `application/json` is not an RFC 9457 problem however well-shaped its body is,
|
|
30
33
|
* and `/openapi.json` has documented `application/problem+json` on every error response
|
|
31
34
|
* since phase 1 — this is the half that makes the document true.
|
|
35
|
+
*
|
|
36
|
+
* `carried` is what a route answered beside `error` before it answered a problem document
|
|
37
|
+
* (a connector's `probe`, a preview's `operationId`, …), kept verbatim so no client loses a
|
|
38
|
+
* member. RFC 9457 §3.2 permits extension members; these are not declared per code in
|
|
39
|
+
* `PROBLEM_EXTENSIONS` yet, which is why they are spelled out at each route rather than
|
|
40
|
+
* passed through by a mapper. Spread last on purpose: where a route's own `detail` meant the
|
|
41
|
+
* downstream's reason, it still does.
|
|
32
42
|
*/
|
|
33
|
-
function problem(c, answer) {
|
|
34
|
-
return c.body(JSON.stringify(answer.body), answer.status, {
|
|
43
|
+
function problem(c, answer, carried) {
|
|
44
|
+
return c.body(JSON.stringify(carried ? { ...answer.body, ...carried } : answer.body), answer.status, {
|
|
35
45
|
'content-type': PROBLEM_CONTENT_TYPE,
|
|
36
46
|
});
|
|
37
47
|
}
|
|
48
|
+
/** A refusal this route decided, by its code (#113): the status follows from the catalog. */
|
|
49
|
+
function refuse(c, code, detail, carried) {
|
|
50
|
+
return problem(c, coded(code, detail), carried);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A refusal answered by status alone, as `about:blank`: one relayed from downstream (a
|
|
54
|
+
* `ControlPlaneError`'s status), or one the closed taxonomy has no code for (501, 422, 502).
|
|
55
|
+
*/
|
|
56
|
+
function refuseStatus(c, status, detail, carried) {
|
|
57
|
+
return problem(c, relayed(status, detail), carried);
|
|
58
|
+
}
|
|
38
59
|
import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
|
|
39
60
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
40
61
|
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
|
|
@@ -43,6 +64,8 @@ import { namespacesForScript } from './do-namespaces.js';
|
|
|
43
64
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
44
65
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
45
66
|
import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
|
|
67
|
+
import { queryLakeEvents } from './lake-gateway.js';
|
|
68
|
+
import { LakeQueryError } from './r2-sql-engine.js';
|
|
46
69
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
47
70
|
/**
|
|
48
71
|
* Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
|
|
@@ -103,9 +126,9 @@ class CarrySourceChanged extends ControlPlaneError {
|
|
|
103
126
|
this.attempts = attempts;
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
|
-
/**
|
|
107
|
-
function
|
|
108
|
-
return
|
|
129
|
+
/** What a route answers beside the sentence of an `ExportBreakRefused`: the listing. */
|
|
130
|
+
function exportBreaksOf(e) {
|
|
131
|
+
return e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {};
|
|
109
132
|
}
|
|
110
133
|
/**
|
|
111
134
|
* A delegated facet answer, normalized — and refused when the vertical that produced it
|
|
@@ -781,11 +804,15 @@ function messageOfThrow(err) {
|
|
|
781
804
|
/** What a 5xx answered with no error object said: the JSON body's `error` (and `detail`), else the status. */
|
|
782
805
|
async function answeredMessage(res) {
|
|
783
806
|
const body = (await res.clone().json().catch(() => null));
|
|
784
|
-
const
|
|
785
|
-
const
|
|
786
|
-
|
|
807
|
+
const str = (k) => (typeof body?.[k] === 'string' && body[k] !== '' ? body[k] : null);
|
|
808
|
+
const error = str('error');
|
|
809
|
+
const detail = str('detail');
|
|
810
|
+
// A headline beside a reason (`deploy upload failed` + the upstream's sentence) reads as
|
|
811
|
+
// both. A problem document's `error` is only the deprecated duplicate of its `detail`
|
|
812
|
+
// (#113), so it adds nothing, and once it is gone `detail` alone still names the occurrence.
|
|
813
|
+
if (error && detail && error !== detail)
|
|
787
814
|
return `${error}: ${detail}`;
|
|
788
|
-
return error ??
|
|
815
|
+
return detail ?? error ?? str('title') ?? `HTTP ${res.status}`;
|
|
789
816
|
}
|
|
790
817
|
/**
|
|
791
818
|
* The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
|
|
@@ -817,7 +844,160 @@ async function answeredMessage(res) {
|
|
|
817
844
|
export function createControlPlaneApi(options) {
|
|
818
845
|
const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
|
|
819
846
|
const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
|
|
847
|
+
/** The stores of the version the scope's deployment runs (`scopeStoresOf`, #2207). */
|
|
848
|
+
const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds, running);
|
|
849
|
+
/**
|
|
850
|
+
* The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
|
|
851
|
+
* own export, restore, wipe and erasure carry its stores, and a scope that declares none has
|
|
852
|
+
* none to reach. A hosted control plane always passes a caller (`unreachablePlatformScopeStores`
|
|
853
|
+
* when it has no route), so a declared store is never skipped there.
|
|
854
|
+
*/
|
|
855
|
+
const platformStoresOf = async (c, scope, running) => {
|
|
856
|
+
const caller = options.platformScopeStores;
|
|
857
|
+
if (!caller || !scope.vertical)
|
|
858
|
+
return undefined;
|
|
859
|
+
const stores = await manifestStores(c, scope, running);
|
|
860
|
+
return stores.length > 0 ? { caller, vertical: scope.vertical, stores } : undefined;
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* `substrat.wipe` on each declared store, resolved now and called later: a reap resolves its
|
|
864
|
+
* stores, which can refuse (503), before it unroutes the scope, and wipes after (#2285).
|
|
865
|
+
*/
|
|
866
|
+
const platformStoresWipe = async (c, scope) => {
|
|
867
|
+
const reach = await platformStoresOf(c, scope);
|
|
868
|
+
return async () => {
|
|
869
|
+
if (!reach)
|
|
870
|
+
return;
|
|
871
|
+
await reach.caller.wipeStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
|
|
872
|
+
};
|
|
873
|
+
};
|
|
874
|
+
/** `substrat.wipe` on each declared store, before the scope's own bytes go. */
|
|
875
|
+
const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
|
|
876
|
+
/**
|
|
877
|
+
* `substrat.export` on each store the lifecycle reaches, for a backup, a full pull or a fork.
|
|
878
|
+
*
|
|
879
|
+
* A dump is only worth taking if it restores, and a restore imports only what the destination's
|
|
880
|
+
* version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
|
|
881
|
+
* dump when its export is empty, which is what an object that was never written, or one already
|
|
882
|
+
* wiped, answers. One that still holds rows refuses the export (409), naming the binding and the
|
|
883
|
+
* way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
|
|
884
|
+
* never export, so they reach such a store regardless.
|
|
885
|
+
*/
|
|
886
|
+
const exportPlatformStores = async (c, scope) => {
|
|
887
|
+
const caller = options.platformScopeStores;
|
|
888
|
+
if (!caller || !scope.vertical)
|
|
889
|
+
return undefined;
|
|
890
|
+
const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeKinds);
|
|
891
|
+
if (all.length === 0)
|
|
892
|
+
return undefined;
|
|
893
|
+
const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
|
|
894
|
+
const current = new Set(running.map((store) => store.binding));
|
|
895
|
+
const dropped = exported.filter((store) => !current.has(store.binding));
|
|
896
|
+
const held = dropped.find((store) => store.tables.some((table) => table.rows.length > 0));
|
|
897
|
+
if (held) {
|
|
898
|
+
throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
|
|
899
|
+
`still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
|
|
900
|
+
`restored. Declare '${held.binding}' as a '${held.kind}' again in a version the scope runs, then export; ` +
|
|
901
|
+
`or reap the scope without a backup, which wipes it`);
|
|
902
|
+
}
|
|
903
|
+
return exported.filter((store) => current.has(store.binding));
|
|
904
|
+
};
|
|
905
|
+
/**
|
|
906
|
+
* `substrat.erase` on the destination's stores for each subject tombstoned in the destination
|
|
907
|
+
* or the source, each subject once (`replayStoreErasures`, #2207): one store call per subject
|
|
908
|
+
* per declared binding, all inside this request. A failed call marks the replay incomplete,
|
|
909
|
+
* and the next restore repeats every call from the first subject, so it converges only while
|
|
910
|
+
* the whole replay fits one request's subrequest and duration budget. Nothing when the platform
|
|
911
|
+
* calls no stores for the destination.
|
|
912
|
+
*/
|
|
913
|
+
const replayPlatformStoreErasures = async (c, destination, source) => {
|
|
914
|
+
// Called once the data has landed: a store list that cannot be resolved is reported as an
|
|
915
|
+
// incomplete replay, never thrown past a restore that already wrote.
|
|
916
|
+
let reach;
|
|
917
|
+
try {
|
|
918
|
+
reach = await platformStoresOf(c, destination);
|
|
919
|
+
}
|
|
920
|
+
catch (e) {
|
|
921
|
+
if (e instanceof ControlPlaneError && e.status === 503)
|
|
922
|
+
return { storeErasuresReplayed: 0, storeErasureIncomplete: true };
|
|
923
|
+
throw e;
|
|
924
|
+
}
|
|
925
|
+
if (!reach)
|
|
926
|
+
return undefined;
|
|
927
|
+
const { caller, vertical, stores } = reach;
|
|
928
|
+
return replayStoreErasures({
|
|
929
|
+
admin: c.var.admin,
|
|
930
|
+
actor: c.get('actor'),
|
|
931
|
+
tenantId: destination.tenantId,
|
|
932
|
+
target: destination.id,
|
|
933
|
+
source,
|
|
934
|
+
erase: async (subjectId) => (await caller.eraseSubject({ tenantId: destination.tenantId, scopeId: destination.id, vertical, subjectId, stores })).length,
|
|
935
|
+
});
|
|
936
|
+
};
|
|
937
|
+
/**
|
|
938
|
+
* The store replay on a route's answer (#2207). Present only with something to report, as
|
|
939
|
+
* `StoreErasureReplay` is. `sourceUnknown`: the source is not a scope of this tenant here,
|
|
940
|
+
* so its marks were not read, and a retry does not change that.
|
|
941
|
+
*/
|
|
942
|
+
const storeReplayFields = (replays) => replays.length === 0
|
|
943
|
+
? {}
|
|
944
|
+
: {
|
|
945
|
+
storeErasuresReplayed: replays.reduce((n, r) => n + r.storeErasuresReplayed, 0),
|
|
946
|
+
...(replays.some((r) => r.storeErasureIncomplete) ? { incomplete: true } : {}),
|
|
947
|
+
...(replays.some((r) => r.storeErasureSourceUnknown) ? { sourceUnknown: true } : {}),
|
|
948
|
+
};
|
|
949
|
+
/**
|
|
950
|
+
* A dump's stores, checked against what the destination's version declares (#2207), before
|
|
951
|
+
* any write: a binding it does not declare, or one of another kind, would be imported into an
|
|
952
|
+
* object nothing reads, or into one of the wrong shape.
|
|
953
|
+
*/
|
|
954
|
+
const refuseUndeclaredDumpStores = async (c, scope, stores) => {
|
|
955
|
+
if (stores.length === 0)
|
|
956
|
+
return;
|
|
957
|
+
if (!scope.vertical)
|
|
958
|
+
throw new ControlPlaneError(422, 'restore refused: the dump names stores and the scope names no vertical');
|
|
959
|
+
// The destination's running version only (L8): a binding only an earlier version declared
|
|
960
|
+
// would be imported into an object nothing in the code the scope runs reads.
|
|
961
|
+
const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds))
|
|
962
|
+
.map((d) => [d.binding, d.kind]));
|
|
963
|
+
for (const store of stores) {
|
|
964
|
+
const kind = declared.get(store.binding);
|
|
965
|
+
if (kind === undefined) {
|
|
966
|
+
throw new ControlPlaneError(422, `restore refused: the dump names store '${store.binding}', which this scope's version does not declare`);
|
|
967
|
+
}
|
|
968
|
+
if (kind !== store.kind) {
|
|
969
|
+
throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.kind}', and this scope's version declares a '${kind}'`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
};
|
|
820
973
|
const app = new Hono();
|
|
974
|
+
// Every refusal is a problem document (#113): the tripwire. A route answers a failure
|
|
975
|
+
// through `refuse`/`refuseStatus`/`problem`, and anything else — a hand-rolled
|
|
976
|
+
// `c.json({ error }, 4xx)`, a sub-app's text 404 — is logged here as
|
|
977
|
+
// `control-plane.non-problem-refusal` with the method, the route PATTERN (never the raw
|
|
978
|
+
// path, which carries tenant and scope ids) and the status. It reads one header and never
|
|
979
|
+
// the body, so it costs nothing on the success path and discloses nothing on the failure
|
|
980
|
+
// one. Two mechanisms hold the rule, and they cover different things: this package's test
|
|
981
|
+
// setup fails any test in which the tripwire fires, which covers every refusal a test
|
|
982
|
+
// REACHES; `test/problem-source.test.ts` scans this file, reached or not, and refuses any
|
|
983
|
+
// response it builds with a failure status outside the problem helpers — and any construct
|
|
984
|
+
// whose status it could not judge (`c.status(`, `c.newResponse(`, `c.html(`, a generic
|
|
985
|
+
// `c.json<T>(`, a `Response` init it cannot read). In production the tripwire is the line
|
|
986
|
+
// an operator searches the worker's logs for, should a client report a refusal it could
|
|
987
|
+
// not parse.
|
|
988
|
+
//
|
|
989
|
+
// Registered first, so it sees the answer every other layer settled on, `onError`'s included.
|
|
990
|
+
app.use('*', async (c, next) => {
|
|
991
|
+
await next();
|
|
992
|
+
if (c.res.status >= 400 && !(c.res.headers.get('content-type') ?? '').startsWith(PROBLEM_CONTENT_TYPE)) {
|
|
993
|
+
console.warn(NON_PROBLEM_REFUSAL, { method: c.req.method, route: c.req.routePath, status: c.res.status });
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
// A request no route matches is a refusal too, and Hono's default answers it as text. This
|
|
997
|
+
// answers only where this app is the top-level one (the node server, a self-host): Hono never
|
|
998
|
+
// calls a MOUNTED sub-app's `notFound`, so under the hosted worker's `app.route('/api', …)`
|
|
999
|
+
// the worker's own fallback still answers an unmatched path.
|
|
1000
|
+
app.notFound((c) => refuse(c, 'not_found', 'no such route'));
|
|
821
1001
|
// The 5xx backstop: every 5xx this API answers lands an ops-failure row, however it was
|
|
822
1002
|
// answered. A 5xx is the PLATFORM failing (an unmapped throw, or a downstream vertical's own
|
|
823
1003
|
// 5xx passing through, #559), so the console must be able to list it and a `reference = <id>`
|
|
@@ -825,7 +1005,7 @@ export function createControlPlaneApi(options) {
|
|
|
825
1005
|
// that catch a ControlPlaneError and answer it themselves: a preview re-push whose bind failed
|
|
826
1006
|
// after a clean carry answered 502 with a reference and left no row behind.
|
|
827
1007
|
//
|
|
828
|
-
// Registered
|
|
1008
|
+
// Registered ahead of every middleware but the tripwire, so it wraps every route. 501 stays out: an honest
|
|
829
1009
|
// not-implemented is a capability statement, not a failure. 4xx stay out too: they are
|
|
830
1010
|
// refusals the caller can already read. Actor is unset only when the request failed before
|
|
831
1011
|
// authentication, where nothing worth recording refuses. A throw a step already recorded,
|
|
@@ -918,7 +1098,7 @@ export function createControlPlaneApi(options) {
|
|
|
918
1098
|
}
|
|
919
1099
|
}
|
|
920
1100
|
if (!principal)
|
|
921
|
-
return c
|
|
1101
|
+
return refuse(c, 'unauthenticated', 'unauthenticated');
|
|
922
1102
|
c.set('principal', principal);
|
|
923
1103
|
c.set('actor', principal.actor);
|
|
924
1104
|
// #977: a tenant credential minted for a person writes through a view that names
|
|
@@ -1032,7 +1212,7 @@ export function createControlPlaneApi(options) {
|
|
|
1032
1212
|
if (c.get('principal').kind === 'builder') {
|
|
1033
1213
|
const allowed = BUILDER_ROUTES.some((r) => r.method === c.req.method && r.re.test(c.req.path));
|
|
1034
1214
|
if (!allowed)
|
|
1035
|
-
return c
|
|
1215
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
1036
1216
|
}
|
|
1037
1217
|
await next();
|
|
1038
1218
|
});
|
|
@@ -1042,25 +1222,25 @@ export function createControlPlaneApi(options) {
|
|
|
1042
1222
|
return next();
|
|
1043
1223
|
const pin = tenantCredentialPin(c.req.method, c.req.path);
|
|
1044
1224
|
if (!pin)
|
|
1045
|
-
return c
|
|
1225
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
1046
1226
|
// The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
|
|
1047
1227
|
// included, which is what stops the pre-#977 shape — a fleet credential plus a
|
|
1048
1228
|
// caller-asserted tenant header — from surviving as a way to ask for another.
|
|
1049
1229
|
if (namedTenants(c).some((t) => t !== p.tenantId)) {
|
|
1050
|
-
return c
|
|
1230
|
+
return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
|
|
1051
1231
|
}
|
|
1052
1232
|
// The route's own pin must be PRESENT, not merely consistent. A missing filter is
|
|
1053
1233
|
// what makes these routes answer fleet-wide, so absence is the refusal.
|
|
1054
1234
|
if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
|
|
1055
|
-
return c
|
|
1235
|
+
return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
|
|
1056
1236
|
}
|
|
1057
1237
|
if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
|
|
1058
|
-
return c
|
|
1238
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1059
1239
|
}
|
|
1060
1240
|
if (pin === 'catalog') {
|
|
1061
1241
|
const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
|
|
1062
1242
|
if (named !== p.tenantId) {
|
|
1063
|
-
return c
|
|
1243
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1064
1244
|
}
|
|
1065
1245
|
}
|
|
1066
1246
|
if (pin === 'body') {
|
|
@@ -1070,7 +1250,7 @@ export function createControlPlaneApi(options) {
|
|
|
1070
1250
|
const body = (await c.req.json().catch(() => null));
|
|
1071
1251
|
const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
|
|
1072
1252
|
if (named !== p.tenantId) {
|
|
1073
|
-
return c
|
|
1253
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1074
1254
|
}
|
|
1075
1255
|
}
|
|
1076
1256
|
await next();
|
|
@@ -1140,6 +1320,8 @@ export function createControlPlaneApi(options) {
|
|
|
1140
1320
|
c.set('failure', e);
|
|
1141
1321
|
return e.status;
|
|
1142
1322
|
};
|
|
1323
|
+
/** A caught `ControlPlaneError` answered as itself — its status and sentence, `about:blank` — and marked for the backstop. */
|
|
1324
|
+
const relayRefusal = (c, e, carried) => refuseStatus(c, failedWith(c, e), e.message, carried);
|
|
1143
1325
|
// Rides out a transient downstream window: the install path's binding-attach →
|
|
1144
1326
|
// script-settings propagation race (#424 case 2), and a one-shot DO storage blip
|
|
1145
1327
|
// during an export→restore or snapshot copy (#559 (2)). Retrying is cheap at THESE
|
|
@@ -1177,9 +1359,9 @@ export function createControlPlaneApi(options) {
|
|
|
1177
1359
|
return problem(c, { status: 400, body: toProblem(err, c.req.path) });
|
|
1178
1360
|
}
|
|
1179
1361
|
if (err instanceof ExportBreakRefused)
|
|
1180
|
-
return c.
|
|
1362
|
+
return refuse(c, 'conflict', err.message, exportBreaksOf(err));
|
|
1181
1363
|
if (err instanceof CarrySourceChanged) {
|
|
1182
|
-
return c
|
|
1364
|
+
return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
|
|
1183
1365
|
}
|
|
1184
1366
|
const { status, body } = mapError(err);
|
|
1185
1367
|
// The 5xx backstop (registered first, above) records it, with this throw as the cause.
|
|
@@ -1223,7 +1405,7 @@ export function createControlPlaneApi(options) {
|
|
|
1223
1405
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1224
1406
|
const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
|
|
1225
1407
|
if (!tenant)
|
|
1226
|
-
return c
|
|
1408
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
1227
1409
|
return c.json(tenant);
|
|
1228
1410
|
});
|
|
1229
1411
|
// Display rename only — the slug stays put (registry ids `<tenantSlug>/<name>` and
|
|
@@ -1260,11 +1442,9 @@ export function createControlPlaneApi(options) {
|
|
|
1260
1442
|
const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
|
|
1261
1443
|
const tenant = await c.var.admin.getTenant(actor, tenantId);
|
|
1262
1444
|
if (!tenant)
|
|
1263
|
-
return c
|
|
1445
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
1264
1446
|
if (tenant.status !== 'deleting') {
|
|
1265
|
-
return c.
|
|
1266
|
-
error: `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`,
|
|
1267
|
-
}, 409);
|
|
1447
|
+
return refuse(c, 'conflict', `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`);
|
|
1268
1448
|
}
|
|
1269
1449
|
try {
|
|
1270
1450
|
for (const scope of await c.var.admin.listScopes(actor, { tenantId })) {
|
|
@@ -1280,6 +1460,7 @@ export function createControlPlaneApi(options) {
|
|
|
1280
1460
|
// retiring (not erasing) a tenant pass `backup: true` deliberately.
|
|
1281
1461
|
const backup = wantsBackup === true ? await backupScope(c, tenantId, scope) : null;
|
|
1282
1462
|
const vertical = await verticalForScope(c, scope);
|
|
1463
|
+
await wipePlatformStores(c, scope);
|
|
1283
1464
|
if (vertical)
|
|
1284
1465
|
await vertical.deleteScope({ tenantId, scopeId: scope.id });
|
|
1285
1466
|
// Tenant teardown reaps every scope and releases every name by design — force past
|
|
@@ -1294,7 +1475,7 @@ export function createControlPlaneApi(options) {
|
|
|
1294
1475
|
}
|
|
1295
1476
|
catch (e) {
|
|
1296
1477
|
if (e instanceof ControlPlaneError) {
|
|
1297
|
-
return
|
|
1478
|
+
return relayRefusal(c, e);
|
|
1298
1479
|
}
|
|
1299
1480
|
throw e;
|
|
1300
1481
|
}
|
|
@@ -1316,10 +1497,10 @@ export function createControlPlaneApi(options) {
|
|
|
1316
1497
|
// `builder`, a plan-gated feature, a key no vertical asked for. And never the plan
|
|
1317
1498
|
// half: an expiry, a quota or a plan name is pricing, which is not the tenant's.
|
|
1318
1499
|
if (plan.expiresAt != null || plan.quota != null || plan.plan != null) {
|
|
1319
|
-
return c
|
|
1500
|
+
return refuse(c, 'forbidden', 'a tenant credential grants entitlements without plan fields');
|
|
1320
1501
|
}
|
|
1321
1502
|
if (!(await tenantGrantable(p, key))) {
|
|
1322
|
-
return c
|
|
1503
|
+
return refuse(c, 'forbidden', `entitlement '${key}' is not declared by a vertical this tenant can install`);
|
|
1323
1504
|
}
|
|
1324
1505
|
}
|
|
1325
1506
|
await c.var.admin.grantEntitlement(c.get('actor'), tenantId, key, plan);
|
|
@@ -1570,7 +1751,7 @@ export function createControlPlaneApi(options) {
|
|
|
1570
1751
|
// worse this week" is the question it exists for.
|
|
1571
1752
|
app.get('/connections/calls', async (c) => {
|
|
1572
1753
|
if (!options.observability?.connectorCallsSeries) {
|
|
1573
|
-
return c
|
|
1754
|
+
return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
|
|
1574
1755
|
}
|
|
1575
1756
|
const input = z
|
|
1576
1757
|
.object({
|
|
@@ -1587,7 +1768,7 @@ export function createControlPlaneApi(options) {
|
|
|
1587
1768
|
// credential can reach only this path, never the fleet-wide sibling above.
|
|
1588
1769
|
app.get('/tenants/:tenantId/connections/calls', async (c) => {
|
|
1589
1770
|
if (!options.observability?.connectorCallsSeries) {
|
|
1590
|
-
return c
|
|
1771
|
+
return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
|
|
1591
1772
|
}
|
|
1592
1773
|
const input = z.object({
|
|
1593
1774
|
tenantId: tenantIdSchema,
|
|
@@ -1684,7 +1865,7 @@ export function createControlPlaneApi(options) {
|
|
|
1684
1865
|
const raw = await c.req.json();
|
|
1685
1866
|
const body = typeof raw === 'object' && raw !== null ? raw : {};
|
|
1686
1867
|
if (body.tenantId !== undefined && body.tenantId !== tenantId) {
|
|
1687
|
-
return c
|
|
1868
|
+
return refuse(c, 'validation_failed', 'body tenantId disagrees with the route tenant');
|
|
1688
1869
|
}
|
|
1689
1870
|
try {
|
|
1690
1871
|
return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
|
|
@@ -1693,7 +1874,7 @@ export function createControlPlaneApi(options) {
|
|
|
1693
1874
|
if (err instanceof ConnectionRelayError && err.status < 500) {
|
|
1694
1875
|
// A refusal carries the provider's own answer, so a console can show WHY rather
|
|
1695
1876
|
// than "save failed" — the whole point of checking before writing.
|
|
1696
|
-
return c.
|
|
1877
|
+
return refuseStatus(c, err.status, err.message, err.probe ? { probe: err.probe } : undefined);
|
|
1697
1878
|
}
|
|
1698
1879
|
// A 5xx relay refusal (#603: this deployment holds no seal key) is the PLATFORM
|
|
1699
1880
|
// failing, not the caller — rethrown so the shared boundary maps it (still a 503
|
|
@@ -1712,7 +1893,7 @@ export function createControlPlaneApi(options) {
|
|
|
1712
1893
|
const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId, includeRevoked: true });
|
|
1713
1894
|
const row = rows.find((r) => r.id === id);
|
|
1714
1895
|
if (!row)
|
|
1715
|
-
return c
|
|
1896
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1716
1897
|
if (row.status !== 'revoked')
|
|
1717
1898
|
await c.var.admin.revokeConnection(c.get('actor'), row.id);
|
|
1718
1899
|
return c.body(null, 204);
|
|
@@ -1750,7 +1931,7 @@ export function createControlPlaneApi(options) {
|
|
|
1750
1931
|
scopeId,
|
|
1751
1932
|
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1752
1933
|
});
|
|
1753
|
-
return link ? c.json(link) : c
|
|
1934
|
+
return link ? c.json(link) : refuse(c, 'not_found', 'unknown connect link');
|
|
1754
1935
|
});
|
|
1755
1936
|
// The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
|
|
1756
1937
|
// "already used" is the answer to the question asked, and the callback renders each one.
|
|
@@ -1789,7 +1970,7 @@ export function createControlPlaneApi(options) {
|
|
|
1789
1970
|
scopeId,
|
|
1790
1971
|
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1791
1972
|
});
|
|
1792
|
-
return link ? c.body(null, 204) : c
|
|
1973
|
+
return link ? c.body(null, 204) : refuse(c, 'not_found', 'unknown connect link');
|
|
1793
1974
|
});
|
|
1794
1975
|
/**
|
|
1795
1976
|
* The tenant-scoped connection lookup the two inspection routes share. Same K-3
|
|
@@ -1811,6 +1992,79 @@ export function createControlPlaneApi(options) {
|
|
|
1811
1992
|
const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId });
|
|
1812
1993
|
return rows.find((r) => r.id === id);
|
|
1813
1994
|
};
|
|
1995
|
+
// Staff/service only: deliberately absent from BUILDER_ROUTES and TENANT_ROUTES.
|
|
1996
|
+
// No generic connector-state write and no grant escalation: the connector verifies the
|
|
1997
|
+
// scope's delivered permission again before writing its own binding record.
|
|
1998
|
+
app.get('/tenants/:tenantId/connections/:id/bindings', async (c) => {
|
|
1999
|
+
const row = await inspectableConnection(c);
|
|
2000
|
+
if (!row)
|
|
2001
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
2002
|
+
const read = options.connectionInspectors?.[row.provider]?.bindings;
|
|
2003
|
+
if (!read)
|
|
2004
|
+
return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
|
|
2005
|
+
const bindings = await read(c.var.host, row);
|
|
2006
|
+
return c.json(await Promise.all(bindings.map(async (binding) => {
|
|
2007
|
+
const scope = scopeIdSchema.parse(binding.scopeId);
|
|
2008
|
+
try {
|
|
2009
|
+
const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scope);
|
|
2010
|
+
return { ...binding, permissionHeld: grants.some((g) => g.connectionId === row.id && g.permission === binding.permission), permissionInspectionError: undefined };
|
|
2011
|
+
}
|
|
2012
|
+
catch (error) {
|
|
2013
|
+
// A stored target remains inspectable when its scope is inactive or its
|
|
2014
|
+
// deployment is unavailable. Unknown authority must not read as a missing grant.
|
|
2015
|
+
return { ...binding, permissionHeld: undefined, permissionInspectionError: error instanceof Error ? error.message : String(error) };
|
|
2016
|
+
}
|
|
2017
|
+
})));
|
|
2018
|
+
});
|
|
2019
|
+
app.put('/tenants/:tenantId/connections/:id/bindings/:scopeId', async (c) => {
|
|
2020
|
+
const row = await inspectableConnection(c);
|
|
2021
|
+
if (!row)
|
|
2022
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
2023
|
+
const bind = options.connectionInspectors?.[row.provider]?.bindScope;
|
|
2024
|
+
if (!bind)
|
|
2025
|
+
return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
|
|
2026
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
2027
|
+
const target = await c.var.admin.getScopeRecord(c.get('actor'), row.tenantId, scopeId);
|
|
2028
|
+
if (!target)
|
|
2029
|
+
return refuse(c, 'not_found', 'unknown scope');
|
|
2030
|
+
if (!isPrimaryScope(target))
|
|
2031
|
+
return refuseStatus(c, 403, PREVIEW_CONNECTIONS_REFUSAL);
|
|
2032
|
+
if (target.status !== 'active' || !target.vertical || target.vertical !== row.vertical) {
|
|
2033
|
+
return refuseStatus(c, 409, 'connection and target must share an active vertical scope');
|
|
2034
|
+
}
|
|
2035
|
+
const fields = {
|
|
2036
|
+
operation: z.string().regex(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/),
|
|
2037
|
+
permission: permissionKey,
|
|
2038
|
+
};
|
|
2039
|
+
const schema = row.provider === 'planima'
|
|
2040
|
+
? z.object({ ...fields,
|
|
2041
|
+
organizationId: z.number().int().positive().nullable().optional(),
|
|
2042
|
+
currency: z.string().regex(/^[A-Z]{3}$/).optional(),
|
|
2043
|
+
window: z.object({ fromYear: z.number().int(), toYear: z.number().int() })
|
|
2044
|
+
.refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
|
|
2045
|
+
horizonYears: z.number().int().min(0).optional(),
|
|
2046
|
+
}).strict()
|
|
2047
|
+
: z.object(fields).strict();
|
|
2048
|
+
const input = schema.parse(await c.req.json());
|
|
2049
|
+
const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
|
|
2050
|
+
if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
|
|
2051
|
+
return refuseStatus(c, 409, `connection does not hold '${input.permission}' on scope ${scopeId}; grant it through the tenant's authorized connection flow, then bind`);
|
|
2052
|
+
}
|
|
2053
|
+
const operationId = ulid();
|
|
2054
|
+
const done = await auditedChange({
|
|
2055
|
+
flow: 'connection-binding', operationId,
|
|
2056
|
+
run: () => bind(c.var.host, row, { ...input, tenantId: row.tenantId, scopeId, vertical: target.vertical }),
|
|
2057
|
+
refused: () => false,
|
|
2058
|
+
record: (phase) => c.var.admin.recordConnectionBindingChange(c.get('actor'), {
|
|
2059
|
+
tenantId: row.tenantId, scopeId, connectionId: row.id, provider: row.provider,
|
|
2060
|
+
...input,
|
|
2061
|
+
operationId, ...(phase.phase === 'applied' ? { phase: 'applied' } : phase),
|
|
2062
|
+
}),
|
|
2063
|
+
});
|
|
2064
|
+
if ('error' in done)
|
|
2065
|
+
throw done.error;
|
|
2066
|
+
return c.json({ ...done.result, permissionHeld: true, ...('unrecorded' in done ? { auditWarning: done.unrecorded } : {}) });
|
|
2067
|
+
});
|
|
1814
2068
|
/**
|
|
1815
2069
|
* **Verify a credential against the provider** (#605) — the read that turns "stored"
|
|
1816
2070
|
* into "works, and for this account".
|
|
@@ -1828,10 +2082,10 @@ export function createControlPlaneApi(options) {
|
|
|
1828
2082
|
app.post('/tenants/:tenantId/connections/:id/verify', async (c) => {
|
|
1829
2083
|
const row = await inspectableConnection(c);
|
|
1830
2084
|
if (!row)
|
|
1831
|
-
return c
|
|
2085
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1832
2086
|
const probe = options.connectionInspectors?.[row.provider]?.probe;
|
|
1833
2087
|
if (!probe) {
|
|
1834
|
-
return c
|
|
2088
|
+
return refuseStatus(c, 501, `no probe registered for provider '${row.provider}'`);
|
|
1835
2089
|
}
|
|
1836
2090
|
return c.json(connectionProbe.parse(await probe(c.var.host, row)));
|
|
1837
2091
|
});
|
|
@@ -1850,10 +2104,10 @@ export function createControlPlaneApi(options) {
|
|
|
1850
2104
|
app.get('/tenants/:tenantId/connections/:id/activity', async (c) => {
|
|
1851
2105
|
const row = await inspectableConnection(c);
|
|
1852
2106
|
if (!row)
|
|
1853
|
-
return c
|
|
2107
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1854
2108
|
const activity = options.connectionInspectors?.[row.provider]?.activity;
|
|
1855
2109
|
if (!activity) {
|
|
1856
|
-
return c
|
|
2110
|
+
return refuseStatus(c, 501, `no activity view registered for provider '${row.provider}'`);
|
|
1857
2111
|
}
|
|
1858
2112
|
const source = connectionActivitySource.catch('ledger').parse(c.req.query('source'));
|
|
1859
2113
|
const live = c.req.query('live') === '1';
|
|
@@ -1871,10 +2125,10 @@ export function createControlPlaneApi(options) {
|
|
|
1871
2125
|
app.get('/tenants/:tenantId/connections/:id/credential', async (c) => {
|
|
1872
2126
|
const row = await inspectableConnection(c);
|
|
1873
2127
|
if (!row)
|
|
1874
|
-
return c
|
|
2128
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1875
2129
|
const credential = options.connectionInspectors?.[row.provider]?.credential;
|
|
1876
2130
|
if (!credential) {
|
|
1877
|
-
return c
|
|
2131
|
+
return refuseStatus(c, 501, `no credential view registered for provider '${row.provider}'`);
|
|
1878
2132
|
}
|
|
1879
2133
|
return c.json(connectionCredential.parse(await credential(c.var.host, row)));
|
|
1880
2134
|
});
|
|
@@ -1886,10 +2140,10 @@ export function createControlPlaneApi(options) {
|
|
|
1886
2140
|
app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
|
|
1887
2141
|
const row = await inspectableConnection(c);
|
|
1888
2142
|
if (!row)
|
|
1889
|
-
return c
|
|
2143
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1890
2144
|
const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
|
|
1891
2145
|
if (!certificate)
|
|
1892
|
-
return c
|
|
2146
|
+
return refuse(c, 'not_found', 'this connection signs in with no certificate');
|
|
1893
2147
|
return c.json(connectionCertificate.parse(certificate));
|
|
1894
2148
|
});
|
|
1895
2149
|
// -- the scope directory (§3.2/§4.2) ---------------------------------------
|
|
@@ -1947,7 +2201,7 @@ export function createControlPlaneApi(options) {
|
|
|
1947
2201
|
// A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
|
|
1948
2202
|
// never distinguished from "no such scope".
|
|
1949
2203
|
if (outsideTenant(p, tenantId)) {
|
|
1950
|
-
return c
|
|
2204
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})`);
|
|
1951
2205
|
}
|
|
1952
2206
|
// The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
|
|
1953
2207
|
// platform-intent drain's `provision-sibling` handler (platform-intents.md B2).
|
|
@@ -1961,12 +2215,12 @@ export function createControlPlaneApi(options) {
|
|
|
1961
2215
|
owner: input.owner,
|
|
1962
2216
|
});
|
|
1963
2217
|
if (!result.ok)
|
|
1964
|
-
return c
|
|
2218
|
+
return refuseStatus(c, result.status, result.error);
|
|
1965
2219
|
return c.json(await c.var.admin.getScopeRecord(actor, tenantId, input.scopeId), 201);
|
|
1966
2220
|
}
|
|
1967
2221
|
catch (e) {
|
|
1968
2222
|
if (e instanceof ControlPlaneError)
|
|
1969
|
-
return
|
|
2223
|
+
return relayRefusal(c, e);
|
|
1970
2224
|
throw e;
|
|
1971
2225
|
}
|
|
1972
2226
|
});
|
|
@@ -1976,12 +2230,12 @@ export function createControlPlaneApi(options) {
|
|
|
1976
2230
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
1977
2231
|
const p = c.get('principal');
|
|
1978
2232
|
if (outsideTenant(p, tenantId)) {
|
|
1979
|
-
return c
|
|
2233
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1980
2234
|
}
|
|
1981
2235
|
const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1982
2236
|
// Absent, or present under another tenant — indistinguishable on purpose (K-3).
|
|
1983
2237
|
if (!record)
|
|
1984
|
-
return c
|
|
2238
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1985
2239
|
return c.json(record);
|
|
1986
2240
|
});
|
|
1987
2241
|
// Read-only introspection of a scope's own database (kernel-design §5.4 admin-query
|
|
@@ -2927,7 +3181,7 @@ export function createControlPlaneApi(options) {
|
|
|
2927
3181
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
2928
3182
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
2929
3183
|
if (!scope)
|
|
2930
|
-
return c
|
|
3184
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2931
3185
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
|
|
2932
3186
|
viaVertical: (v) => v.listScopeTables(scopeId),
|
|
2933
3187
|
colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
|
|
@@ -2957,11 +3211,11 @@ export function createControlPlaneApi(options) {
|
|
|
2957
3211
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
2958
3212
|
const principal = c.get('principal');
|
|
2959
3213
|
if (outsideTenant(principal, tenantId)) {
|
|
2960
|
-
return c
|
|
3214
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2961
3215
|
}
|
|
2962
3216
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
2963
3217
|
if (!scope)
|
|
2964
|
-
return c
|
|
3218
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2965
3219
|
// Health reads the scope the same way the Data view does, so it leaves the same
|
|
2966
3220
|
// row: an auditor should not be able to tell which route reached the tables.
|
|
2967
3221
|
const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
|
|
@@ -3009,7 +3263,7 @@ export function createControlPlaneApi(options) {
|
|
|
3009
3263
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3010
3264
|
const principal = c.get('principal');
|
|
3011
3265
|
if (outsideTenant(principal, tenantId)) {
|
|
3012
|
-
return c
|
|
3266
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3013
3267
|
}
|
|
3014
3268
|
const filter = platformRequestFilter.parse({
|
|
3015
3269
|
kind: c.req.query('kind'),
|
|
@@ -3018,7 +3272,7 @@ export function createControlPlaneApi(options) {
|
|
|
3018
3272
|
});
|
|
3019
3273
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3020
3274
|
if (!scope)
|
|
3021
|
-
return c
|
|
3275
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3022
3276
|
// Same delegation ladder as the table reads: the intents live in the DO of the scope's BOUND
|
|
3023
3277
|
// version, so a hosted scope is asked through its vertical and a co-located one locally.
|
|
3024
3278
|
const vertical = await verticalForScope(c, scope);
|
|
@@ -3039,17 +3293,17 @@ export function createControlPlaneApi(options) {
|
|
|
3039
3293
|
const actor = c.get('actor');
|
|
3040
3294
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3041
3295
|
if (!scope)
|
|
3042
|
-
return c
|
|
3296
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3043
3297
|
const p = c.get('principal');
|
|
3044
3298
|
const scopePin = confinedTenant(p);
|
|
3045
3299
|
if (scopePin) {
|
|
3046
3300
|
const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
|
|
3047
3301
|
if (!v || v.ownerTenant !== scopePin)
|
|
3048
|
-
return c
|
|
3302
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
3049
3303
|
}
|
|
3050
3304
|
const reached = await deploymentForScope(c, scope);
|
|
3051
3305
|
if (!reached)
|
|
3052
|
-
return c
|
|
3306
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3053
3307
|
const vertical = reached.client;
|
|
3054
3308
|
const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
|
|
3055
3309
|
// #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
|
|
@@ -3173,6 +3427,9 @@ export function createControlPlaneApi(options) {
|
|
|
3173
3427
|
connectionGrants,
|
|
3174
3428
|
connectionKeys,
|
|
3175
3429
|
...(entityGrants ? { entityGrants } : {}),
|
|
3430
|
+
// #2263: a vertical with no owner of record still seats what its version declares, and
|
|
3431
|
+
// answers `owner: null`, which the CLI reports as a lockout NOT repaired.
|
|
3432
|
+
ownerless: 'seat',
|
|
3176
3433
|
...carry,
|
|
3177
3434
|
// Handed over exactly as at provision: a store minted HERE has never been migrated
|
|
3178
3435
|
// by the vertical, so the reconcile must carry it into the same ready-gate — a bound
|
|
@@ -3200,7 +3457,7 @@ export function createControlPlaneApi(options) {
|
|
|
3200
3457
|
// unbound store is exactly what makes a vertical refuse its own reconcile), so
|
|
3201
3458
|
// it must not be the half that gets dropped.
|
|
3202
3459
|
const detail = storeErrors.length ? `${e.message} (stores: ${storeErrors.join('; ')})` : e.message;
|
|
3203
|
-
return c
|
|
3460
|
+
return refuseStatus(c, failedWith(c, e), detail);
|
|
3204
3461
|
}
|
|
3205
3462
|
throw e;
|
|
3206
3463
|
}
|
|
@@ -3222,7 +3479,7 @@ export function createControlPlaneApi(options) {
|
|
|
3222
3479
|
});
|
|
3223
3480
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3224
3481
|
if (!scope)
|
|
3225
|
-
return c
|
|
3482
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3226
3483
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
|
|
3227
3484
|
// #1762: only the vertical's answer needs the guard. The co-located read runs
|
|
3228
3485
|
// this platform's own kernel, which withholds by construction.
|
|
@@ -3291,7 +3548,7 @@ export function createControlPlaneApi(options) {
|
|
|
3291
3548
|
});
|
|
3292
3549
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3293
3550
|
if (!scope)
|
|
3294
|
-
return c
|
|
3551
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3295
3552
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
|
|
3296
3553
|
viaVertical: (v) => v.eventCause(scopeId, input),
|
|
3297
3554
|
colocated: () => c.var.admin.eventCause(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3307,7 +3564,7 @@ export function createControlPlaneApi(options) {
|
|
|
3307
3564
|
});
|
|
3308
3565
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3309
3566
|
if (!scope)
|
|
3310
|
-
return c
|
|
3567
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3311
3568
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
|
|
3312
3569
|
viaVertical: (v) => v.eventEffects(scopeId, input),
|
|
3313
3570
|
colocated: () => c.var.admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3324,7 +3581,7 @@ export function createControlPlaneApi(options) {
|
|
|
3324
3581
|
});
|
|
3325
3582
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3326
3583
|
if (!scope)
|
|
3327
|
-
return c
|
|
3584
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3328
3585
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'invocationEvents', input, {
|
|
3329
3586
|
viaVertical: (v) => v.invocationEvents(scopeId, input),
|
|
3330
3587
|
colocated: () => c.var.admin.invocationEvents(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3341,7 +3598,7 @@ export function createControlPlaneApi(options) {
|
|
|
3341
3598
|
});
|
|
3342
3599
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3343
3600
|
if (!scope)
|
|
3344
|
-
return c
|
|
3601
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3345
3602
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'deadLetters', input, {
|
|
3346
3603
|
viaVertical: (v) => v.deadLetters(scopeId, input),
|
|
3347
3604
|
colocated: () => c.var.admin.deadLetters(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3356,7 +3613,7 @@ export function createControlPlaneApi(options) {
|
|
|
3356
3613
|
const input = lifecycleFlowInput.parse(await c.req.json());
|
|
3357
3614
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3358
3615
|
if (!scope)
|
|
3359
|
-
return c
|
|
3616
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3360
3617
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'lifecycleFlow', input, {
|
|
3361
3618
|
viaVertical: (v) => v.lifecycleFlow(scopeId, input),
|
|
3362
3619
|
colocated: () => c.var.admin.lifecycleFlow(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3371,7 +3628,7 @@ export function createControlPlaneApi(options) {
|
|
|
3371
3628
|
const input = operationSeriesInput.parse(await c.req.json());
|
|
3372
3629
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3373
3630
|
if (!scope)
|
|
3374
|
-
return c
|
|
3631
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3375
3632
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'operationSeries', input, {
|
|
3376
3633
|
viaVertical: (v) => v.operationSeries(scopeId, input),
|
|
3377
3634
|
colocated: () => c.var.admin.operationSeries(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3391,7 +3648,7 @@ export function createControlPlaneApi(options) {
|
|
|
3391
3648
|
});
|
|
3392
3649
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3393
3650
|
if (!scope)
|
|
3394
|
-
return c
|
|
3651
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3395
3652
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
|
|
3396
3653
|
viaVertical: (v) => v.entityHistory(scopeId, input),
|
|
3397
3654
|
colocated: () => c.var.admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3407,7 +3664,7 @@ export function createControlPlaneApi(options) {
|
|
|
3407
3664
|
});
|
|
3408
3665
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3409
3666
|
if (!scope)
|
|
3410
|
-
return c
|
|
3667
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3411
3668
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
|
|
3412
3669
|
viaVertical: (v) => v.readScopeTable(scopeId, input),
|
|
3413
3670
|
colocated: () => c.var.admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3424,7 +3681,7 @@ export function createControlPlaneApi(options) {
|
|
|
3424
3681
|
const input = queryScopeInput.parse(await c.req.json());
|
|
3425
3682
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3426
3683
|
if (!scope)
|
|
3427
|
-
return c
|
|
3684
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3428
3685
|
return c.json(await delegatedRead(
|
|
3429
3686
|
// The SQL is the argument the co-located branch logs, so it is the argument
|
|
3430
3687
|
// here — an audit row for the read-only console that did not carry the
|
|
@@ -3450,12 +3707,12 @@ export function createControlPlaneApi(options) {
|
|
|
3450
3707
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3451
3708
|
const principal = c.get('principal');
|
|
3452
3709
|
if (outsideTenant(principal, tenantId)) {
|
|
3453
|
-
return c
|
|
3710
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3454
3711
|
}
|
|
3455
3712
|
const filter = denialLogQuery.parse(c.req.query());
|
|
3456
3713
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3457
3714
|
if (!scope)
|
|
3458
|
-
return c
|
|
3715
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3459
3716
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
|
|
3460
3717
|
viaVertical: (v) => v.listDenials(scopeId, filter),
|
|
3461
3718
|
colocated: () => c.var.admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3466,12 +3723,12 @@ export function createControlPlaneApi(options) {
|
|
|
3466
3723
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3467
3724
|
const principal = c.get('principal');
|
|
3468
3725
|
if (outsideTenant(principal, tenantId)) {
|
|
3469
|
-
return c
|
|
3726
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3470
3727
|
}
|
|
3471
3728
|
const filter = denialLogQuery.parse(c.req.query());
|
|
3472
3729
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3473
3730
|
if (!scope)
|
|
3474
|
-
return c
|
|
3731
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3475
3732
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
|
|
3476
3733
|
viaVertical: (v) => v.summarizeDenials(scopeId, filter),
|
|
3477
3734
|
colocated: () => c.var.admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3484,14 +3741,14 @@ export function createControlPlaneApi(options) {
|
|
|
3484
3741
|
// does not rest on which list the route is absent from. Delegated like the denial log.
|
|
3485
3742
|
app.get('/tenants/:tenantId/scopes/:scopeId/capabilities', async (c) => {
|
|
3486
3743
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3487
|
-
return c
|
|
3744
|
+
return refuse(c, 'forbidden', 'forbidden: the capability directory is staff-only');
|
|
3488
3745
|
}
|
|
3489
3746
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3490
3747
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3491
3748
|
const filter = capabilityFilterQuery.parse(c.req.query());
|
|
3492
3749
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3493
3750
|
if (!scope)
|
|
3494
|
-
return c
|
|
3751
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3495
3752
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listCapabilities', filter, {
|
|
3496
3753
|
viaVertical: (v) => v.listCapabilities(scopeId, filter),
|
|
3497
3754
|
colocated: () => c.var.admin.listCapabilities(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3505,7 +3762,7 @@ export function createControlPlaneApi(options) {
|
|
|
3505
3762
|
// the scope does not hold is 404; a deployment built before the far end answers 501 "redeploy".
|
|
3506
3763
|
app.post('/tenants/:tenantId/scopes/:scopeId/capabilities/:capabilityId/revoke', async (c) => {
|
|
3507
3764
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3508
|
-
return c
|
|
3765
|
+
return refuse(c, 'forbidden', 'forbidden: revoking a capability is staff-only');
|
|
3509
3766
|
}
|
|
3510
3767
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3511
3768
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3526,20 +3783,20 @@ export function createControlPlaneApi(options) {
|
|
|
3526
3783
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3527
3784
|
const principal = c.get('principal');
|
|
3528
3785
|
if (outsideTenant(principal, tenantId)) {
|
|
3529
|
-
return c
|
|
3786
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3530
3787
|
}
|
|
3531
3788
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3532
3789
|
if (!scope)
|
|
3533
|
-
return c
|
|
3790
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3534
3791
|
const vertical = await verticalForScope(c, scope);
|
|
3535
3792
|
if (!vertical)
|
|
3536
|
-
return c
|
|
3793
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3537
3794
|
try {
|
|
3538
3795
|
return c.json(await vertical.ownerSeat(tenantId, scopeId));
|
|
3539
3796
|
}
|
|
3540
3797
|
catch (e) {
|
|
3541
3798
|
if (e instanceof ControlPlaneError)
|
|
3542
|
-
return
|
|
3799
|
+
return relayRefusal(c, e);
|
|
3543
3800
|
throw e;
|
|
3544
3801
|
}
|
|
3545
3802
|
});
|
|
@@ -3561,25 +3818,25 @@ export function createControlPlaneApi(options) {
|
|
|
3561
3818
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3562
3819
|
const principal = c.get('principal');
|
|
3563
3820
|
if (outsideTenant(principal, tenantId)) {
|
|
3564
|
-
return c
|
|
3821
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3565
3822
|
}
|
|
3566
3823
|
const actor = c.get('actor');
|
|
3567
3824
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3568
3825
|
if (!scope)
|
|
3569
|
-
return c
|
|
3826
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3570
3827
|
const vertical = await verticalForScope(c, scope);
|
|
3571
3828
|
if (!vertical)
|
|
3572
|
-
return c
|
|
3829
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
3573
3830
|
const origin = await instanceOrigin(c.var.admin, actor, scopeId);
|
|
3574
3831
|
if (!origin) {
|
|
3575
|
-
return c
|
|
3832
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
|
|
3576
3833
|
}
|
|
3577
3834
|
try {
|
|
3578
3835
|
return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
|
|
3579
3836
|
}
|
|
3580
3837
|
catch (e) {
|
|
3581
3838
|
if (e instanceof ControlPlaneError)
|
|
3582
|
-
return
|
|
3839
|
+
return relayRefusal(c, e);
|
|
3583
3840
|
throw e;
|
|
3584
3841
|
}
|
|
3585
3842
|
});
|
|
@@ -3607,10 +3864,10 @@ export function createControlPlaneApi(options) {
|
|
|
3607
3864
|
// K-3: another tenant's pair, or no such scope, reads the same — absent.
|
|
3608
3865
|
const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3609
3866
|
if (!scope)
|
|
3610
|
-
return { refusal: c
|
|
3867
|
+
return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
|
|
3611
3868
|
const vertical = await verticalForScope(c, scope);
|
|
3612
3869
|
if (!vertical)
|
|
3613
|
-
return { refusal: c
|
|
3870
|
+
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
|
|
3614
3871
|
return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
|
|
3615
3872
|
};
|
|
3616
3873
|
/**
|
|
@@ -3625,7 +3882,7 @@ export function createControlPlaneApi(options) {
|
|
|
3625
3882
|
const { operationId } = done;
|
|
3626
3883
|
if ('error' in done) {
|
|
3627
3884
|
if (done.error instanceof ControlPlaneError) {
|
|
3628
|
-
return c
|
|
3885
|
+
return relayRefusal(c, done.error, { operationId });
|
|
3629
3886
|
}
|
|
3630
3887
|
throw done.error;
|
|
3631
3888
|
}
|
|
@@ -3664,7 +3921,7 @@ export function createControlPlaneApi(options) {
|
|
|
3664
3921
|
}
|
|
3665
3922
|
catch (e) {
|
|
3666
3923
|
if (e instanceof ControlPlaneError)
|
|
3667
|
-
return
|
|
3924
|
+
return relayRefusal(c, e);
|
|
3668
3925
|
throw e;
|
|
3669
3926
|
}
|
|
3670
3927
|
});
|
|
@@ -3673,11 +3930,11 @@ export function createControlPlaneApi(options) {
|
|
|
3673
3930
|
if ('refusal' in target)
|
|
3674
3931
|
return target.refusal;
|
|
3675
3932
|
if (!target.person)
|
|
3676
|
-
return c
|
|
3933
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3677
3934
|
const body = memberInviteInput.parse(await c.req.json());
|
|
3678
3935
|
const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
|
|
3679
3936
|
if (!origin) {
|
|
3680
|
-
return c
|
|
3937
|
+
return refuse(c, 'conflict', `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on`);
|
|
3681
3938
|
}
|
|
3682
3939
|
const { tenantId, scopeId, person } = target;
|
|
3683
3940
|
const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'invite', caller: person, roleKey: body.roleKey }, () => target.vertical.inviteMember({ tenantId, scopeId, caller: person, origin, roleKey: body.roleKey, email: body.email ?? null }), (link) => ({ principal: link.principal }));
|
|
@@ -3688,7 +3945,7 @@ export function createControlPlaneApi(options) {
|
|
|
3688
3945
|
if ('refusal' in target)
|
|
3689
3946
|
return target.refusal;
|
|
3690
3947
|
if (!target.person)
|
|
3691
|
-
return c
|
|
3948
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3692
3949
|
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3693
3950
|
const body = memberRoleInput.parse(await c.req.json());
|
|
3694
3951
|
const { tenantId, scopeId, person } = target;
|
|
@@ -3700,7 +3957,7 @@ export function createControlPlaneApi(options) {
|
|
|
3700
3957
|
if ('refusal' in target)
|
|
3701
3958
|
return target.refusal;
|
|
3702
3959
|
if (!target.person)
|
|
3703
|
-
return c
|
|
3960
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3704
3961
|
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3705
3962
|
const { tenantId, scopeId, person } = target;
|
|
3706
3963
|
const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'remove', caller: person, principal: member }, () => target.vertical.removeMember({ tenantId, scopeId, caller: person, principal: member }), (removal) => ({ revoked: removal.revoked }));
|
|
@@ -3718,7 +3975,7 @@ export function createControlPlaneApi(options) {
|
|
|
3718
3975
|
// and a team credential cannot show it is that person asking.
|
|
3719
3976
|
app.post('/tenants/:tenantId/scopes/:scopeId/owner-transfer', async (c) => {
|
|
3720
3977
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3721
|
-
return c
|
|
3978
|
+
return refuse(c, 'forbidden', 'forbidden: an owner hand-over is staff-only');
|
|
3722
3979
|
}
|
|
3723
3980
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3724
3981
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3727,10 +3984,10 @@ export function createControlPlaneApi(options) {
|
|
|
3727
3984
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
3728
3985
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3729
3986
|
if (!scope)
|
|
3730
|
-
return c
|
|
3987
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3731
3988
|
const vertical = await verticalForScope(c, scope);
|
|
3732
3989
|
if (!vertical)
|
|
3733
|
-
return c
|
|
3990
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
3734
3991
|
const operationId = ulid();
|
|
3735
3992
|
// An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
|
|
3736
3993
|
// a hand-over closed without finishing from one that finished.
|
|
@@ -3763,17 +4020,17 @@ export function createControlPlaneApi(options) {
|
|
|
3763
4020
|
const input = configureInstanceBody.parse(await c.req.json());
|
|
3764
4021
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3765
4022
|
if (!scope)
|
|
3766
|
-
return c
|
|
4023
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3767
4024
|
const vertical = await verticalForScope(c, scope);
|
|
3768
4025
|
if (!vertical) {
|
|
3769
|
-
return c
|
|
4026
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3770
4027
|
}
|
|
3771
4028
|
try {
|
|
3772
4029
|
await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
|
|
3773
4030
|
}
|
|
3774
4031
|
catch (e) {
|
|
3775
4032
|
if (e instanceof ControlPlaneError) {
|
|
3776
|
-
return
|
|
4033
|
+
return relayRefusal(c, e);
|
|
3777
4034
|
}
|
|
3778
4035
|
throw e;
|
|
3779
4036
|
}
|
|
@@ -3830,7 +4087,7 @@ export function createControlPlaneApi(options) {
|
|
|
3830
4087
|
message: error,
|
|
3831
4088
|
}, failure.cause);
|
|
3832
4089
|
c.set('failureRecorded', true);
|
|
3833
|
-
return c
|
|
4090
|
+
return refuse(c, 'unavailable', error);
|
|
3834
4091
|
}
|
|
3835
4092
|
}
|
|
3836
4093
|
}
|
|
@@ -3864,7 +4121,7 @@ export function createControlPlaneApi(options) {
|
|
|
3864
4121
|
const deployment = await deploymentForScope(c, scope);
|
|
3865
4122
|
const vertical = deployment?.client;
|
|
3866
4123
|
if (!vertical)
|
|
3867
|
-
return c.var.host.snapshotScope(actor, tenantId, scope.id, opts);
|
|
4124
|
+
return { snapId: await c.var.host.snapshotScope(actor, tenantId, scope.id, opts) };
|
|
3868
4125
|
const snapId = scopeIdSchema.parse(ulid());
|
|
3869
4126
|
// #1722: the script the copy lands in (the source's own), named in the ledger before the
|
|
3870
4127
|
// copy, so a bind that fails, or a request that dies, leaves a fork reap and erasure reach.
|
|
@@ -3891,6 +4148,9 @@ export function createControlPlaneApi(options) {
|
|
|
3891
4148
|
ref = null;
|
|
3892
4149
|
}
|
|
3893
4150
|
}
|
|
4151
|
+
// #2207: the stores are resolved before anything is written, so one that cannot be named
|
|
4152
|
+
// refuses the snapshot rather than leave a fork row behind.
|
|
4153
|
+
const reach = await platformStoresOf(c, scope);
|
|
3894
4154
|
// Directory row FIRST, as `provisioning` (K-31's two-phase shape, used as
|
|
3895
4155
|
// intended): a crash between the row and the data copy leaves an inert
|
|
3896
4156
|
// provisioning row — which, carrying provenance and an expiry, the GC sweep
|
|
@@ -3906,6 +4166,7 @@ export function createControlPlaneApi(options) {
|
|
|
3906
4166
|
expiresAt: opts.expiresAt,
|
|
3907
4167
|
});
|
|
3908
4168
|
const moveId = ref ? ulid() : null;
|
|
4169
|
+
let replay;
|
|
3909
4170
|
try {
|
|
3910
4171
|
if (moveId && ref) {
|
|
3911
4172
|
// No load stamp: the vertical's in-script copy leaves none, so a crash-recovery sweep keeps
|
|
@@ -3913,6 +4174,16 @@ export function createControlPlaneApi(options) {
|
|
|
3913
4174
|
await c.var.admin.recordScopeScriptCopy(actor, tenantId, snapId, ref, moveId, { role: 'destination', leaseMs: copyLeaseMs });
|
|
3914
4175
|
}
|
|
3915
4176
|
await retryTransient(() => copier.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId, tenantId }));
|
|
4177
|
+
// The scope file was copied inside the script. The store objects are per scope, so the
|
|
4178
|
+
// fork gets its own copy through the reserved export and import, signed by the
|
|
4179
|
+
// control plane. A version that declares no stores has nothing to copy.
|
|
4180
|
+
if (reach) {
|
|
4181
|
+
const stores = await reach.caller.exportStores({ tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
|
|
4182
|
+
if (stores.length > 0) {
|
|
4183
|
+
await reach.caller.importStores({ tenantId, scopeId: snapId, vertical: reach.vertical, stores });
|
|
4184
|
+
replay = await replayPlatformStoreErasures(c, { ...scope, id: snapId }, scope.id);
|
|
4185
|
+
}
|
|
4186
|
+
}
|
|
3916
4187
|
await c.var.admin.activateScope(actor, tenantId, snapId);
|
|
3917
4188
|
// Bound to the SOURCE's current version: source and fork share a deployment, so
|
|
3918
4189
|
// the fork resolves to the DO namespace its bytes actually live in.
|
|
@@ -3934,7 +4205,7 @@ export function createControlPlaneApi(options) {
|
|
|
3934
4205
|
}
|
|
3935
4206
|
throw e;
|
|
3936
4207
|
}
|
|
3937
|
-
return snapId;
|
|
4208
|
+
return { snapId, replay };
|
|
3938
4209
|
};
|
|
3939
4210
|
// The forks OF one scope — what a Snapshots UI lists. A directory read (kind,
|
|
3940
4211
|
// provenance, expiry all live on the scope row); newest first.
|
|
@@ -3952,14 +4223,16 @@ export function createControlPlaneApi(options) {
|
|
|
3952
4223
|
const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
3953
4224
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3954
4225
|
if (!scope)
|
|
3955
|
-
return c
|
|
4226
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3956
4227
|
try {
|
|
3957
|
-
const snapId = await orchestratedSnapshot(c, tenantId, scope, body);
|
|
3958
|
-
|
|
4228
|
+
const { snapId, replay } = await orchestratedSnapshot(c, tenantId, scope, body);
|
|
4229
|
+
const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, snapId);
|
|
4230
|
+
// #2207: the fork's erasure replay, under the restore's rule: present only with something to report.
|
|
4231
|
+
return c.json({ ...record, ...storeReplayFields(replay ? [replay] : []) }, 201);
|
|
3959
4232
|
}
|
|
3960
4233
|
catch (e) {
|
|
3961
4234
|
if (e instanceof ControlPlaneError) {
|
|
3962
|
-
return
|
|
4235
|
+
return relayRefusal(c, e);
|
|
3963
4236
|
}
|
|
3964
4237
|
throw e;
|
|
3965
4238
|
}
|
|
@@ -3979,11 +4252,11 @@ export function createControlPlaneApi(options) {
|
|
|
3979
4252
|
* 5xx, a timeout) still aborts — an implemented wipe that failed is a retry, not a
|
|
3980
4253
|
* shrug. Returns true when the storage was stranded.
|
|
3981
4254
|
*/
|
|
3982
|
-
const deleteScopeStorageOrStrand = async (
|
|
4255
|
+
const deleteScopeStorageOrStrand = async (c, vertical, scope) => {
|
|
3983
4256
|
if (!vertical)
|
|
3984
4257
|
return false;
|
|
3985
4258
|
try {
|
|
3986
|
-
await vertical.deleteScope({ tenantId, scopeId });
|
|
4259
|
+
await vertical.deleteScope({ tenantId: scope.tenantId, scopeId: scope.id });
|
|
3987
4260
|
return false;
|
|
3988
4261
|
}
|
|
3989
4262
|
catch (e) {
|
|
@@ -4005,22 +4278,34 @@ export function createControlPlaneApi(options) {
|
|
|
4005
4278
|
*/
|
|
4006
4279
|
const reapScopeStorage = async (c, scope) => {
|
|
4007
4280
|
const actor = c.get('actor');
|
|
4281
|
+
const dispatch = options.resolveVerticalRef;
|
|
4282
|
+
// #2285: every refusal that can be decided up front comes before anything is destroyed, the
|
|
4283
|
+
// unroute included: a refused reap leaves the scope routed and whole. Then unroute, wipe, delete.
|
|
4284
|
+
if (dispatch)
|
|
4285
|
+
await claimScopeScriptReap({ admin: c.var.admin, actor }, scope.tenantId, scope.id);
|
|
4286
|
+
else
|
|
4287
|
+
await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
|
|
4288
|
+
const wipeStores = await platformStoresWipe(c, scope);
|
|
4289
|
+
// Stop routing before the wipe: a call that lands between the wipe and the delete would
|
|
4290
|
+
// recreate the store object, and nothing left could reach it.
|
|
4291
|
+
await unrouteScope(c.var.admin, actor, scope);
|
|
4292
|
+
// #2207: the stores first, on every path below: a routed scope's copies are reaped through
|
|
4293
|
+
// their scripts, and a store is per scope, not per script.
|
|
4294
|
+
await wipeStores();
|
|
4008
4295
|
// The deployment the scope's vertical resolves to, deleted through; a 501 there is recorded
|
|
4009
4296
|
// as stranded storage, as `reapScopeScriptCopies` records its own.
|
|
4010
4297
|
const viaDeployment = async () => {
|
|
4011
|
-
const stranded = await deleteScopeStorageOrStrand(await verticalForScope(c, scope), scope
|
|
4298
|
+
const stranded = await deleteScopeStorageOrStrand(c, await verticalForScope(c, scope), scope);
|
|
4012
4299
|
if (stranded) {
|
|
4013
4300
|
await recordStrandedStorage(c.var.admin, actor, scope.tenantId, scope.id, `the deployment its vertical '${scope.vertical}' resolves to`);
|
|
4014
4301
|
}
|
|
4015
4302
|
return stranded;
|
|
4016
4303
|
};
|
|
4017
|
-
if (!
|
|
4018
|
-
await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
|
|
4304
|
+
if (!dispatch)
|
|
4019
4305
|
return viaDeployment();
|
|
4020
|
-
|
|
4021
|
-
const
|
|
4022
|
-
const
|
|
4023
|
-
const bySlug = unrouted ? await viaDeployment() : false;
|
|
4306
|
+
const noScript = (await routeOf(c, scope)) === null;
|
|
4307
|
+
const { storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, scope.tenantId, scope.id);
|
|
4308
|
+
const bySlug = noScript ? await viaDeployment() : false;
|
|
4024
4309
|
return storageStranded || bySlug;
|
|
4025
4310
|
};
|
|
4026
4311
|
app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
|
|
@@ -4029,9 +4314,9 @@ export function createControlPlaneApi(options) {
|
|
|
4029
4314
|
const actor = c.get('actor');
|
|
4030
4315
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4031
4316
|
if (!scope)
|
|
4032
|
-
return c
|
|
4317
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4033
4318
|
if (!scope.forkedFrom) {
|
|
4034
|
-
return c
|
|
4319
|
+
return refuse(c, 'conflict', `scope ${scopeId} is not a fork — only snapshots may be deleted`);
|
|
4035
4320
|
}
|
|
4036
4321
|
try {
|
|
4037
4322
|
// Vertical's storage first, then the in-process delete (refusal re-check,
|
|
@@ -4044,7 +4329,7 @@ export function createControlPlaneApi(options) {
|
|
|
4044
4329
|
}
|
|
4045
4330
|
catch (e) {
|
|
4046
4331
|
if (e instanceof ControlPlaneError) {
|
|
4047
|
-
return
|
|
4332
|
+
return relayRefusal(c, e);
|
|
4048
4333
|
}
|
|
4049
4334
|
throw e;
|
|
4050
4335
|
}
|
|
@@ -4073,6 +4358,9 @@ export function createControlPlaneApi(options) {
|
|
|
4073
4358
|
const dump = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
4074
4359
|
const vertical = await verticalForScope(c, scope);
|
|
4075
4360
|
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
4361
|
+
// Store tables are opaque and stay unsealed. The platform reads them from the store script
|
|
4362
|
+
// (#2207); a store it cannot name or reach refuses the backup rather than omitting it.
|
|
4363
|
+
const stores = (await exportPlatformStores(c, scope)) ?? dump.stores;
|
|
4076
4364
|
// Seal classified payloads per subject on the way in (#37). This copy is full-fidelity
|
|
4077
4365
|
// and the platform keeps it, which is precisely why an erasure could never reach it: a
|
|
4078
4366
|
// redaction fixes the live scope and does nothing to the object already in R2. Sealed
|
|
@@ -4080,7 +4368,7 @@ export function createControlPlaneApi(options) {
|
|
|
4080
4368
|
// Restores open it again (`restoreFromBackup`) — except for subjects shredded in the
|
|
4081
4369
|
// meantime, which is the mechanism working rather than the copy being damaged.
|
|
4082
4370
|
const sealed = await sealDump(tables, sealerFor(c, tenantId, scope.id));
|
|
4083
|
-
return store.put({ vertical: scope.vertical, dump: { ...dump, tables: sealed } });
|
|
4371
|
+
return store.put({ vertical: scope.vertical, dump: { ...dump, tables: sealed, stores } });
|
|
4084
4372
|
};
|
|
4085
4373
|
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
4086
4374
|
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
@@ -4090,12 +4378,12 @@ export function createControlPlaneApi(options) {
|
|
|
4090
4378
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
4091
4379
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4092
4380
|
if (!options.scopeBackups)
|
|
4093
|
-
return c
|
|
4381
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
4094
4382
|
// Reads the directory, not the store, for the tenant cross-check: the store is keyed
|
|
4095
4383
|
// by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
|
|
4096
4384
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4097
4385
|
if (!scope)
|
|
4098
|
-
return c
|
|
4386
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4099
4387
|
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
4100
4388
|
});
|
|
4101
4389
|
// One backup's DUMP — the restore source. Staff-only like the export it came from
|
|
@@ -4106,13 +4394,13 @@ export function createControlPlaneApi(options) {
|
|
|
4106
4394
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4107
4395
|
const capturedAt = c.req.param('capturedAt');
|
|
4108
4396
|
if (!options.scopeBackups)
|
|
4109
|
-
return c
|
|
4397
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
4110
4398
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4111
4399
|
if (!scope)
|
|
4112
|
-
return c
|
|
4400
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4113
4401
|
const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
4114
4402
|
if (!dump)
|
|
4115
|
-
return c
|
|
4403
|
+
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
4116
4404
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
4117
4405
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
4118
4406
|
// since the copy was taken opens to a null payload, which is exactly the erasure
|
|
@@ -4129,13 +4417,13 @@ export function createControlPlaneApi(options) {
|
|
|
4129
4417
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4130
4418
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4131
4419
|
if (!scope)
|
|
4132
|
-
return c
|
|
4420
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4133
4421
|
try {
|
|
4134
4422
|
return c.json(await backupScope(c, tenantId, scope), 201);
|
|
4135
4423
|
}
|
|
4136
4424
|
catch (e) {
|
|
4137
4425
|
if (e instanceof ControlPlaneError) {
|
|
4138
|
-
return
|
|
4426
|
+
return relayRefusal(c, e);
|
|
4139
4427
|
}
|
|
4140
4428
|
throw e;
|
|
4141
4429
|
}
|
|
@@ -4163,7 +4451,7 @@ export function createControlPlaneApi(options) {
|
|
|
4163
4451
|
const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
|
|
4164
4452
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4165
4453
|
if (!scope)
|
|
4166
|
-
return c
|
|
4454
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4167
4455
|
const copies = await listAllScopeScriptCopies(c.var.admin, c.get('actor'), tenantId, scopeId);
|
|
4168
4456
|
// A move in flight can still restore a copy no inventory read now would name. Its entries
|
|
4169
4457
|
// settle when it confirms or fails, or, if it died, when the sweep finds its lease run out.
|
|
@@ -4172,17 +4460,47 @@ export function createControlPlaneApi(options) {
|
|
|
4172
4460
|
}
|
|
4173
4461
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(c.get('actor'), tenantId, scopeId);
|
|
4174
4462
|
const currentRef = await routeOf(c, scope);
|
|
4463
|
+
// The version the deployment runs, read once (#2285): the stores below are judged against it,
|
|
4464
|
+
// and the claim before the key goes requires it, and the serving pointer as read, to still hold.
|
|
4465
|
+
const running = await runningVersionOf(c.var.admin, c.get('actor'), scope);
|
|
4466
|
+
// #2207: the stores of the version the deployment runs, resolved before anything is erased. A
|
|
4467
|
+
// version that declares none is not asked. One that cannot be resolved while a kind is
|
|
4468
|
+
// registered refuses (503): the key must not be destroyed while a store may hold the subject.
|
|
4469
|
+
const reach = await platformStoresOf(c, scope, running);
|
|
4470
|
+
/**
|
|
4471
|
+
* L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
|
|
4472
|
+
* would run a version whose stores this erasure did not reach. Both arms take one atomic claim
|
|
4473
|
+
* on this fence before the key goes, and the claim moves the erasure epoch every bind compares,
|
|
4474
|
+
* so the bind either lands first and the claim refuses (412, the key kept), or it is refused.
|
|
4475
|
+
*/
|
|
4476
|
+
const fence = {
|
|
4477
|
+
versionId: scope.verticalVersionId,
|
|
4478
|
+
servingRef: scope.servingRef ?? null,
|
|
4479
|
+
epoch: erasureEpoch,
|
|
4480
|
+
copyCount: copies.length,
|
|
4481
|
+
...(running.servingPointer !== undefined ? { servingPointer: running.servingPointer } : {}),
|
|
4482
|
+
};
|
|
4483
|
+
// Not asked is `undefined`, not `[]` (#2242): with no platform path the host's own stores
|
|
4484
|
+
// (the pure adapter's co-located runners) are erased in its shred or its finalize.
|
|
4485
|
+
const eraseStores = async () => reach ? reach.caller.eraseSubject({ tenantId, scopeId, vertical: reach.vertical, subjectId, stores: reach.stores }) : undefined;
|
|
4486
|
+
/** The host's own shred, with the platform's store erasure first: a store that errors keeps the key. */
|
|
4487
|
+
const shredHere = async () => {
|
|
4488
|
+
const storeErasures = await eraseStores();
|
|
4489
|
+
const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId, fence).catch(relayHostRefusal);
|
|
4490
|
+
return { ...receipt, storeErasures: [...receipt.storeErasures, ...(storeErasures ?? [])] };
|
|
4491
|
+
};
|
|
4175
4492
|
if (!options.resolveVerticalRef) {
|
|
4176
4493
|
if (currentRef || copies.some((copy) => copy.state !== 'done')) {
|
|
4177
4494
|
throw new ControlPlaneError(502, `scope ${scopeId} has script copies, but this control plane cannot reach their deployments`);
|
|
4178
4495
|
}
|
|
4179
|
-
return c.json(await
|
|
4496
|
+
return c.json(await shredHere());
|
|
4180
4497
|
}
|
|
4181
4498
|
// A scope nothing routes may still keep its store where its vertical resolves by slug (where
|
|
4182
4499
|
// it was provisioned), so erasure reaches that deployment too, as reap does (`reapScopeStorage`).
|
|
4183
|
-
const
|
|
4500
|
+
const reached = currentRef ? undefined : await deploymentForScope(c, scope);
|
|
4501
|
+
const bySlug = reached?.client;
|
|
4184
4502
|
if (!currentRef && !bySlug && copies.every((copy) => copy.state === 'done')) {
|
|
4185
|
-
return c.json(await
|
|
4503
|
+
return c.json(await shredHere());
|
|
4186
4504
|
}
|
|
4187
4505
|
const refs = new Set(copies.filter((copy) => copy.state !== 'done').map((copy) => copy.scriptRef));
|
|
4188
4506
|
if (currentRef)
|
|
@@ -4196,10 +4514,8 @@ export function createControlPlaneApi(options) {
|
|
|
4196
4514
|
}
|
|
4197
4515
|
if (bySlug)
|
|
4198
4516
|
redactions.push(await bySlug.redactSubject(scopeId, subjectId));
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
copyCount: copies.length,
|
|
4202
|
-
}).catch(relayHostRefusal));
|
|
4517
|
+
const storeErasures = await eraseStores();
|
|
4518
|
+
return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, fence, storeErasures).catch(relayHostRefusal));
|
|
4203
4519
|
});
|
|
4204
4520
|
// -- directory backups (#40) -----------------------------------------------
|
|
4205
4521
|
// The platform's own disaster recovery, on the same store posture as the scope
|
|
@@ -4214,7 +4530,7 @@ export function createControlPlaneApi(options) {
|
|
|
4214
4530
|
// question an unrehearsed backup story never has a way to ask.
|
|
4215
4531
|
app.get('/directory/backups', async (c) => {
|
|
4216
4532
|
if (!options.directoryBackups)
|
|
4217
|
-
return c
|
|
4533
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4218
4534
|
return c.json(await options.directoryBackups.list());
|
|
4219
4535
|
});
|
|
4220
4536
|
// Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
|
|
@@ -4222,7 +4538,7 @@ export function createControlPlaneApi(options) {
|
|
|
4222
4538
|
// copy without waiting a day for the cron.
|
|
4223
4539
|
app.post('/directory/backups', async (c) => {
|
|
4224
4540
|
if (!options.directoryBackups)
|
|
4225
|
-
return c
|
|
4541
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4226
4542
|
const result = await backupDirectoryIfDue({
|
|
4227
4543
|
admin: c.var.admin,
|
|
4228
4544
|
store: options.directoryBackups,
|
|
@@ -4237,11 +4553,11 @@ export function createControlPlaneApi(options) {
|
|
|
4237
4553
|
// and audited by `exportDirectory` underneath.
|
|
4238
4554
|
app.get('/directory/backups/:capturedAt', async (c) => {
|
|
4239
4555
|
if (!options.directoryBackups)
|
|
4240
|
-
return c
|
|
4556
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4241
4557
|
const capturedAt = c.req.param('capturedAt');
|
|
4242
4558
|
const dump = await options.directoryBackups.get({ capturedAt });
|
|
4243
4559
|
if (!dump)
|
|
4244
|
-
return c
|
|
4560
|
+
return refuse(c, 'not_found', `no directory backup at ${capturedAt}`);
|
|
4245
4561
|
return c.json(dump);
|
|
4246
4562
|
});
|
|
4247
4563
|
// Break-glass: REPLACE the directory with a stored copy.
|
|
@@ -4255,18 +4571,16 @@ export function createControlPlaneApi(options) {
|
|
|
4255
4571
|
// with nothing in it — needs no such ceremony.
|
|
4256
4572
|
app.post('/directory/restore', async (c) => {
|
|
4257
4573
|
if (!options.directoryBackups)
|
|
4258
|
-
return c
|
|
4574
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4259
4575
|
const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
|
|
4260
4576
|
const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
|
|
4261
4577
|
if (!dump)
|
|
4262
|
-
return c
|
|
4578
|
+
return refuse(c, 'not_found', `no directory backup at ${body.capturedAt}`);
|
|
4263
4579
|
const actor = c.get('actor');
|
|
4264
4580
|
const live = await c.var.admin.listTenants(actor, { limit: 1 });
|
|
4265
4581
|
if (live.length > 0 && !body.overwrite) {
|
|
4266
|
-
return c
|
|
4267
|
-
|
|
4268
|
-
'since this copy was taken would be lost; pass overwrite=true to confirm',
|
|
4269
|
-
}, 409);
|
|
4582
|
+
return refuse(c, 'conflict', 'the directory is not empty — a restore REPLACES it, so anything created ' +
|
|
4583
|
+
'since this copy was taken would be lost; pass overwrite=true to confirm');
|
|
4270
4584
|
}
|
|
4271
4585
|
await c.var.admin.restoreDirectory(actor, dump);
|
|
4272
4586
|
return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
|
|
@@ -4318,7 +4632,7 @@ export function createControlPlaneApi(options) {
|
|
|
4318
4632
|
};
|
|
4319
4633
|
app.post('/previews/repair-serving-pins', async (c) => {
|
|
4320
4634
|
if (!options.resolveVerticalVersion || !options.resolveVerticalRef) {
|
|
4321
|
-
return c
|
|
4635
|
+
return refuseStatus(c, 501, 'repairing serving pins needs dispatch resolution for both ends');
|
|
4322
4636
|
}
|
|
4323
4637
|
const { cursor, limit, dryRun } = await readJsonBody(c, repairServingPinsBody);
|
|
4324
4638
|
const actor = c.get('actor');
|
|
@@ -4489,13 +4803,13 @@ export function createControlPlaneApi(options) {
|
|
|
4489
4803
|
const actor = c.get('actor');
|
|
4490
4804
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4491
4805
|
if (!scope)
|
|
4492
|
-
return c
|
|
4806
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4493
4807
|
if (!isPrimaryScope(scope)) {
|
|
4494
|
-
return c
|
|
4808
|
+
return refuse(c, 'conflict', 'clear-copy-mark refused: the directory says this scope is a copy (a preview or a fork)');
|
|
4495
4809
|
}
|
|
4496
4810
|
const vertical = await verticalForScope(c, scope);
|
|
4497
4811
|
if (!vertical)
|
|
4498
|
-
return c
|
|
4812
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
4499
4813
|
const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
|
|
4500
4814
|
await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
|
|
4501
4815
|
return c.json({ cleared });
|
|
@@ -4521,16 +4835,14 @@ export function createControlPlaneApi(options) {
|
|
|
4521
4835
|
const actor = c.get('actor');
|
|
4522
4836
|
const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
|
|
4523
4837
|
if (!parsed.success) {
|
|
4524
|
-
return c
|
|
4838
|
+
return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen');
|
|
4525
4839
|
}
|
|
4526
4840
|
// Refused at the door rather than left to the adapters: an instant in the future
|
|
4527
4841
|
// reopens rows the drain shipped AFTER the rebuild, which is exactly the double-write
|
|
4528
4842
|
// the required instant exists to prevent. Host code, so it may read the real clock.
|
|
4529
4843
|
if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
|
|
4530
|
-
return c.
|
|
4531
|
-
|
|
4532
|
-
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
|
|
4533
|
-
}, 400);
|
|
4844
|
+
return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would reopen rows ` +
|
|
4845
|
+
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
|
|
4534
4846
|
}
|
|
4535
4847
|
// The input schema also carries `countOnly` (#1545), and this route does not honour it:
|
|
4536
4848
|
// its answer (`redrained`, `more`) is a statement that rows moved. Ignoring the field
|
|
@@ -4539,11 +4851,11 @@ export function createControlPlaneApi(options) {
|
|
|
4539
4851
|
// caller is told where counting lives. Refused whatever its value: on this route the
|
|
4540
4852
|
// field has no meaning at all, and "accepted when false" is a rule nobody can rely on.
|
|
4541
4853
|
if (parsed.data.countOnly !== undefined) {
|
|
4542
|
-
return c
|
|
4854
|
+
return refuse(c, 'validation_failed', 'countOnly is not honoured here — POST the same body to …/redrain-count for a read-only count');
|
|
4543
4855
|
}
|
|
4544
4856
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4545
4857
|
if (!scope)
|
|
4546
|
-
return c
|
|
4858
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4547
4859
|
// The instant alone, never `parsed.data`, so the field cannot reach the adapter from
|
|
4548
4860
|
// this door even if the refusal above is ever relaxed.
|
|
4549
4861
|
const redrained = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
|
|
@@ -4574,26 +4886,72 @@ export function createControlPlaneApi(options) {
|
|
|
4574
4886
|
const actor = c.get('actor');
|
|
4575
4887
|
const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
|
|
4576
4888
|
if (!parsed.success) {
|
|
4577
|
-
return c
|
|
4889
|
+
return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count');
|
|
4578
4890
|
}
|
|
4579
4891
|
// The same future refusal as the reopen. A count of a window that has not closed yet
|
|
4580
4892
|
// is not dangerous, it is meaningless — and a dry run whose instant this door accepts
|
|
4581
4893
|
// and the real run rejects would be the worst kind of rehearsal.
|
|
4582
4894
|
if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
|
|
4583
|
-
return c.
|
|
4584
|
-
|
|
4585
|
-
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
|
|
4586
|
-
}, 400);
|
|
4895
|
+
return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would count rows ` +
|
|
4896
|
+
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
|
|
4587
4897
|
}
|
|
4588
4898
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4589
4899
|
if (!scope)
|
|
4590
|
-
return c
|
|
4900
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4591
4901
|
const redrainable = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
|
|
4592
4902
|
drainedBefore: parsed.data.drainedBefore,
|
|
4593
4903
|
countOnly: true,
|
|
4594
4904
|
});
|
|
4595
4905
|
return c.json({ redrainable, drainedBefore: parsed.data.drainedBefore });
|
|
4596
4906
|
});
|
|
4907
|
+
// #1334 — one tenant's events from the Tier-2 lake, through the gateway that scopes them
|
|
4908
|
+
// (`lake-gateway.ts`): the tenant is this path segment and nothing else, the filters are a
|
|
4909
|
+
// closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
|
|
4910
|
+
// read whole. Envelope only — no payload until erasure reaches the lake.
|
|
4911
|
+
//
|
|
4912
|
+
// Staff only, by default-deny: on neither BUILDER_ROUTES nor TENANT_ROUTES. A tenant's
|
|
4913
|
+
// own credential reaching this is a separate decision, with a dashboard call site and a
|
|
4914
|
+
// member permission in front of it.
|
|
4915
|
+
//
|
|
4916
|
+
// K-24: the access row is written AFTER the engine answers and BEFORE anything is
|
|
4917
|
+
// returned, and a failed write fails the request — rows whose disclosure went unrecorded
|
|
4918
|
+
// are what the access log exists to prevent. A refused or failed query discloses nothing
|
|
4919
|
+
// and leaves no row.
|
|
4920
|
+
app.get('/tenants/:tenantId/lake/events', async (c) => {
|
|
4921
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
4922
|
+
if (!options.lakeQuery) {
|
|
4923
|
+
return refuseStatus(c, 501, 'the Tier-2 lake is not queryable on this control plane');
|
|
4924
|
+
}
|
|
4925
|
+
const parsed = lakeEventsInput.safeParse(c.req.query());
|
|
4926
|
+
if (!parsed.success) {
|
|
4927
|
+
// `errors` is the declared member (#113); `issues`, zod's own shape, is what this route
|
|
4928
|
+
// answered before it and stays for the clients that read it. Carried rather than built by
|
|
4929
|
+
// `coded`, which would answer a field list with its canonical sentence instead of this one.
|
|
4930
|
+
return refuse(c, 'validation_failed', 'invalid lake query', {
|
|
4931
|
+
errors: validationIssuesFrom(parsed.error),
|
|
4932
|
+
issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
|
|
4933
|
+
});
|
|
4934
|
+
}
|
|
4935
|
+
let page;
|
|
4936
|
+
try {
|
|
4937
|
+
page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
|
|
4938
|
+
}
|
|
4939
|
+
catch (err) {
|
|
4940
|
+
if (err instanceof LakeQueryError)
|
|
4941
|
+
return refuseStatus(c, 502, err.message);
|
|
4942
|
+
throw err;
|
|
4943
|
+
}
|
|
4944
|
+
// The filters, not the page: the same projection every other read logs.
|
|
4945
|
+
const { cursor: _cursor, limit: _limit, ...filters } = parsed.data;
|
|
4946
|
+
await c.var.admin.recordDelegatedRead(c.get('actor'), {
|
|
4947
|
+
method: 'lakeEvents',
|
|
4948
|
+
tenantId,
|
|
4949
|
+
scopeId: parsed.data.scopeId ?? null,
|
|
4950
|
+
params: filters,
|
|
4951
|
+
resultCount: page.events.length,
|
|
4952
|
+
});
|
|
4953
|
+
return c.json(page);
|
|
4954
|
+
});
|
|
4597
4955
|
app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
|
|
4598
4956
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
4599
4957
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -4603,9 +4961,9 @@ export function createControlPlaneApi(options) {
|
|
|
4603
4961
|
const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
|
|
4604
4962
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4605
4963
|
if (!scope)
|
|
4606
|
-
return c
|
|
4964
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4607
4965
|
if (scope.status !== 'archived') {
|
|
4608
|
-
return c
|
|
4966
|
+
return refuse(c, 'conflict', `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped`);
|
|
4609
4967
|
}
|
|
4610
4968
|
// Refuse a scope that still resolves a hostname BEFORE any delegation — the vertical's
|
|
4611
4969
|
// `deleteScope` below wipes the hosted DO's bytes, so this must gate ahead of it, not
|
|
@@ -4614,10 +4972,8 @@ export function createControlPlaneApi(options) {
|
|
|
4614
4972
|
// the same guard because releasing every name is the point there.)
|
|
4615
4973
|
const boundNames = await c.var.admin.listHostnames(actor, { scopeId, limit: 1 });
|
|
4616
4974
|
if (boundNames.length > 0) {
|
|
4617
|
-
return c.
|
|
4618
|
-
|
|
4619
|
-
`unbind it before reaping (reap wipes storage and cannot be undone)`,
|
|
4620
|
-
}, 409);
|
|
4975
|
+
return refuse(c, 'conflict', `scope ${scopeId} still resolves hostname '${boundNames[0].hostname}' — ` +
|
|
4976
|
+
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
4621
4977
|
}
|
|
4622
4978
|
// The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
|
|
4623
4979
|
// guarantee: `backupScope` has to have resolved — durably stored — before
|
|
@@ -4636,13 +4992,10 @@ export function createControlPlaneApi(options) {
|
|
|
4636
4992
|
}
|
|
4637
4993
|
catch (e) {
|
|
4638
4994
|
if (e instanceof ControlPlaneError) {
|
|
4639
|
-
return
|
|
4995
|
+
return relayRefusal(c, e);
|
|
4640
4996
|
}
|
|
4641
|
-
return c
|
|
4642
|
-
|
|
4643
|
-
'retry, or reap with backup=false to accept an unrecoverable wipe',
|
|
4644
|
-
detail: e instanceof Error ? e.message : String(e),
|
|
4645
|
-
}, 502);
|
|
4997
|
+
return refuseStatus(c, 502, 'backup failed — the scope was NOT reaped and its data is intact; ' +
|
|
4998
|
+
'retry, or reap with backup=false to accept an unrecoverable wipe', { detail: e instanceof Error ? e.message : String(e) });
|
|
4646
4999
|
}
|
|
4647
5000
|
}
|
|
4648
5001
|
try {
|
|
@@ -4657,7 +5010,7 @@ export function createControlPlaneApi(options) {
|
|
|
4657
5010
|
}
|
|
4658
5011
|
catch (e) {
|
|
4659
5012
|
if (e instanceof ControlPlaneError) {
|
|
4660
|
-
return
|
|
5013
|
+
return relayRefusal(c, e);
|
|
4661
5014
|
}
|
|
4662
5015
|
throw e;
|
|
4663
5016
|
}
|
|
@@ -4674,15 +5027,13 @@ export function createControlPlaneApi(options) {
|
|
|
4674
5027
|
const actor = c.get('actor');
|
|
4675
5028
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4676
5029
|
if (!scope)
|
|
4677
|
-
return c
|
|
5030
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4678
5031
|
// Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
|
|
4679
5032
|
// lands the data on a machine outside the platform's control, so anything
|
|
4680
5033
|
// pinned tighter than `global` is refused until a compliant path exists.
|
|
4681
5034
|
if (scope.jurisdiction !== 'global') {
|
|
4682
|
-
return c.
|
|
4683
|
-
|
|
4684
|
-
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
|
|
4685
|
-
}, 403);
|
|
5035
|
+
return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
|
|
5036
|
+
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
|
|
4686
5037
|
}
|
|
4687
5038
|
const full = c.req.query('full') === 'true';
|
|
4688
5039
|
try {
|
|
@@ -4693,18 +5044,24 @@ export function createControlPlaneApi(options) {
|
|
|
4693
5044
|
const dump = await c.var.admin.exportScope(actor, tenantId, scopeId);
|
|
4694
5045
|
const vertical = await verticalForScope(c, scope);
|
|
4695
5046
|
const tables = vertical ? await vertical.exportScope(scopeId) : dump.tables;
|
|
4696
|
-
if (full)
|
|
4697
|
-
|
|
5047
|
+
if (full) {
|
|
5048
|
+
// Store tables are opaque. The platform reads them from the store script (#2207); a
|
|
5049
|
+
// store it cannot name or reach refuses the pull rather than omitting it.
|
|
5050
|
+
const stores = (await exportPlatformStores(c, scope)) ?? dump.stores;
|
|
5051
|
+
return c.json({ ...dump, tables, stores, masked: false });
|
|
5052
|
+
}
|
|
4698
5053
|
// One pseudonymizer for the whole response (#1034): the same customer has to read
|
|
4699
5054
|
// the same in their own row, in every event payload that quoted them, and in the
|
|
4700
5055
|
// timeline — a per-table generator would break exactly the joins that make a
|
|
4701
5056
|
// masked copy worth pulling.
|
|
4702
5057
|
const mask = await createPseudonymizer(options.maskSalt ?? crypto.randomUUID());
|
|
4703
|
-
|
|
5058
|
+
// A masked pull cannot see inside a scope store, so it ships none rather than
|
|
5059
|
+
// the raw tables beside pseudonymized scope rows.
|
|
5060
|
+
return c.json({ ...dump, tables: await maskDump(tables, mask), stores: [], masked: true });
|
|
4704
5061
|
}
|
|
4705
5062
|
catch (e) {
|
|
4706
5063
|
if (e instanceof ControlPlaneError) {
|
|
4707
|
-
return
|
|
5064
|
+
return relayRefusal(c, e);
|
|
4708
5065
|
}
|
|
4709
5066
|
throw e;
|
|
4710
5067
|
}
|
|
@@ -4731,7 +5088,7 @@ export function createControlPlaneApi(options) {
|
|
|
4731
5088
|
const actor = c.get('actor');
|
|
4732
5089
|
const tenant = await c.var.admin.getTenant(actor, tenantId);
|
|
4733
5090
|
if (!tenant)
|
|
4734
|
-
return c
|
|
5091
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
4735
5092
|
// Every scope, tombstones included: an archived or reaped scope is part of the
|
|
4736
5093
|
// tenant's history, and an export that quietly dropped them would misrepresent what
|
|
4737
5094
|
// the tenant was. Their DATA is a different question, handled below.
|
|
@@ -4743,12 +5100,10 @@ export function createControlPlaneApi(options) {
|
|
|
4743
5100
|
// partial is the failure mode worth avoiding.
|
|
4744
5101
|
const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
|
|
4745
5102
|
if (pinned.length > 0) {
|
|
4746
|
-
return c.
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
`global scopes individually, or wait for a per-jurisdiction path.`,
|
|
4751
|
-
}, 403);
|
|
5103
|
+
return refuse(c, 'forbidden', `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
|
|
5104
|
+
`(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
|
|
5105
|
+
`move that data out of the region it was promised; refused (K-32). Export the ` +
|
|
5106
|
+
`global scopes individually, or wait for a per-jurisdiction path.`);
|
|
4752
5107
|
}
|
|
4753
5108
|
const full = c.req.query('full') === 'true';
|
|
4754
5109
|
try {
|
|
@@ -4780,7 +5135,15 @@ export function createControlPlaneApi(options) {
|
|
|
4780
5135
|
const dump = await c.var.admin.exportScope(actor, tenantId, scope.id);
|
|
4781
5136
|
const vertical = await verticalForScope(c, scope);
|
|
4782
5137
|
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
4783
|
-
data.push({
|
|
5138
|
+
data.push({
|
|
5139
|
+
...dump,
|
|
5140
|
+
tables: mask ? await maskDump(tables, mask) : tables,
|
|
5141
|
+
// A scope store's tables are opaque, so the pseudonymizer cannot see inside them.
|
|
5142
|
+
// A masked export omits them rather than shipping them unmasked. A full one reads them
|
|
5143
|
+
// where they live, as a backup does (#2207): the host's own dump has none on the hosted
|
|
5144
|
+
// plane, and a store that cannot be named refuses the export rather than leave it out.
|
|
5145
|
+
stores: mask ? [] : ((await exportPlatformStores(c, scope)) ?? dump.stores),
|
|
5146
|
+
});
|
|
4784
5147
|
}
|
|
4785
5148
|
// The admin log is FULL-only (#36): it records what STAFF did, so it is not the
|
|
4786
5149
|
// customer's Art. 20 data, and it carries staff actor ids and internal action
|
|
@@ -4819,7 +5182,7 @@ export function createControlPlaneApi(options) {
|
|
|
4819
5182
|
}
|
|
4820
5183
|
catch (e) {
|
|
4821
5184
|
if (e instanceof ControlPlaneError) {
|
|
4822
|
-
return
|
|
5185
|
+
return relayRefusal(c, e);
|
|
4823
5186
|
}
|
|
4824
5187
|
throw e;
|
|
4825
5188
|
}
|
|
@@ -4841,7 +5204,7 @@ export function createControlPlaneApi(options) {
|
|
|
4841
5204
|
const actor = c.get('actor');
|
|
4842
5205
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4843
5206
|
if (!scope)
|
|
4844
|
-
return c
|
|
5207
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4845
5208
|
const raw = await c.req.json();
|
|
4846
5209
|
const dump = scopeDump.parse(raw);
|
|
4847
5210
|
// #1869: where the uploaded file says it came from, when the caller had to set the dump's
|
|
@@ -4851,7 +5214,18 @@ export function createControlPlaneApi(options) {
|
|
|
4851
5214
|
// A backup with no tables is not a scope dump — name that plainly rather than letting
|
|
4852
5215
|
// the empty replay reach the checker and surface as a bare `internal error` (#321).
|
|
4853
5216
|
if (dump.tables.length === 0) {
|
|
4854
|
-
return c
|
|
5217
|
+
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
5218
|
+
}
|
|
5219
|
+
// #2207: the dump's stores against the destination's declaration, before anything is written.
|
|
5220
|
+
if (options.platformScopeStores) {
|
|
5221
|
+
try {
|
|
5222
|
+
await refuseUndeclaredDumpStores(c, scope, dump.stores);
|
|
5223
|
+
}
|
|
5224
|
+
catch (e) {
|
|
5225
|
+
if (e instanceof ControlPlaneError)
|
|
5226
|
+
return relayRefusal(c, e);
|
|
5227
|
+
throw e;
|
|
5228
|
+
}
|
|
4855
5229
|
}
|
|
4856
5230
|
try {
|
|
4857
5231
|
// Belt and braces for #37: a caller who fetched the object bytes by some other route
|
|
@@ -4866,12 +5240,25 @@ export function createControlPlaneApi(options) {
|
|
|
4866
5240
|
// the destination's would find no key, null every payload, and call it a restore.
|
|
4867
5241
|
const origin = { tenantId: tenantIdSchema.parse(dump.tenantId), scopeId: scopeIdSchema.parse(dump.scopeId) };
|
|
4868
5242
|
const tables = await openDump(dump.tables, sealerFor(c, origin.tenantId, origin.scopeId));
|
|
4869
|
-
const
|
|
5243
|
+
const vertical = await verticalForScope(c, scope);
|
|
5244
|
+
const storesCaller = options.platformScopeStores;
|
|
5245
|
+
const landing = { ...dump, tables, stores: storesCaller && vertical ? [] : dump.stores };
|
|
4870
5246
|
// #2185: cleared before the load as well as after it (below): a step after the load can still
|
|
4871
5247
|
// throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
|
|
4872
5248
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
4873
|
-
await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
4874
|
-
|
|
5249
|
+
const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
|
|
5250
|
+
if (storesCaller && vertical && scope.vertical && dump.stores.length > 0) {
|
|
5251
|
+
await storesCaller.importStores({
|
|
5252
|
+
tenantId,
|
|
5253
|
+
scopeId,
|
|
5254
|
+
vertical: scope.vertical,
|
|
5255
|
+
stores: dump.stores,
|
|
5256
|
+
});
|
|
5257
|
+
}
|
|
5258
|
+
const platformReplay = storesCaller && scope.vertical
|
|
5259
|
+
? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin.scopeId)
|
|
5260
|
+
: undefined;
|
|
5261
|
+
const replays = [hostReplay, platformReplay].filter((r) => !!r);
|
|
4875
5262
|
// #1742: the recorded OFF positions ride the restore, so the deployment switches them
|
|
4876
5263
|
// off in the replay's own event — its own sweeper cannot land in between.
|
|
4877
5264
|
const restored = vertical
|
|
@@ -4890,7 +5277,11 @@ export function createControlPlaneApi(options) {
|
|
|
4890
5277
|
// finished: the install reads as not reconciled, and the sweep's next pass reconciles it.
|
|
4891
5278
|
// Again here, after the load, for a sweep reconcile that wrote the receipt while it ran.
|
|
4892
5279
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
|
|
4893
|
-
return c.json({
|
|
5280
|
+
return c.json({
|
|
5281
|
+
restored: scopeId,
|
|
5282
|
+
tables: tables.length,
|
|
5283
|
+
...storeReplayFields(replays),
|
|
5284
|
+
});
|
|
4894
5285
|
}
|
|
4895
5286
|
catch (e) {
|
|
4896
5287
|
// #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
|
|
@@ -4898,7 +5289,7 @@ export function createControlPlaneApi(options) {
|
|
|
4898
5289
|
// reconcile); a clear that fails here must not hide the restore's own error.
|
|
4899
5290
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
|
|
4900
5291
|
if (e instanceof ControlPlaneError) {
|
|
4901
|
-
return
|
|
5292
|
+
return relayRefusal(c, e);
|
|
4902
5293
|
}
|
|
4903
5294
|
// A restore throw is driven by the caller-supplied dump (a shape the target cannot
|
|
4904
5295
|
// load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
|
|
@@ -4906,7 +5297,7 @@ export function createControlPlaneApi(options) {
|
|
|
4906
5297
|
// secondary obs #2). The route is staff/owner-gated and the detail is about the
|
|
4907
5298
|
// dump the caller sent, not another tenant's state.
|
|
4908
5299
|
const detail = e instanceof Error ? e.message : String(e);
|
|
4909
|
-
return c
|
|
5300
|
+
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
4910
5301
|
}
|
|
4911
5302
|
});
|
|
4912
5303
|
// #1236: when this scope's migrations actually ran — the schema-change history
|
|
@@ -4919,7 +5310,7 @@ export function createControlPlaneApi(options) {
|
|
|
4919
5310
|
const actor = c.get('actor');
|
|
4920
5311
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4921
5312
|
if (!scope)
|
|
4922
|
-
return c
|
|
5313
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4923
5314
|
const vertical = await verticalForScope(c, scope);
|
|
4924
5315
|
return c.json(vertical
|
|
4925
5316
|
? await vertical.appliedMigrations(scopeId)
|
|
@@ -4935,7 +5326,7 @@ export function createControlPlaneApi(options) {
|
|
|
4935
5326
|
const actor = c.get('actor');
|
|
4936
5327
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4937
5328
|
if (!scope)
|
|
4938
|
-
return c
|
|
5329
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4939
5330
|
const vertical = await verticalForScope(c, scope);
|
|
4940
5331
|
return c.json(vertical
|
|
4941
5332
|
? await vertical.migrationBookmarks(scopeId)
|
|
@@ -4955,7 +5346,7 @@ export function createControlPlaneApi(options) {
|
|
|
4955
5346
|
const actor = c.get('actor');
|
|
4956
5347
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4957
5348
|
if (!scope)
|
|
4958
|
-
return c
|
|
5349
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4959
5350
|
try {
|
|
4960
5351
|
const vertical = await verticalForScope(c, scope);
|
|
4961
5352
|
const result = await c.var.admin.rewindScope(actor, tenantId, scopeId, bookmark, {
|
|
@@ -4978,7 +5369,7 @@ export function createControlPlaneApi(options) {
|
|
|
4978
5369
|
}
|
|
4979
5370
|
catch (e) {
|
|
4980
5371
|
if (e instanceof ControlPlaneError) {
|
|
4981
|
-
return
|
|
5372
|
+
return relayRefusal(c, e);
|
|
4982
5373
|
}
|
|
4983
5374
|
throw e;
|
|
4984
5375
|
}
|
|
@@ -4998,7 +5389,7 @@ export function createControlPlaneApi(options) {
|
|
|
4998
5389
|
}
|
|
4999
5390
|
catch (e) {
|
|
5000
5391
|
if (e instanceof ControlPlaneError) {
|
|
5001
|
-
return c
|
|
5392
|
+
return relayRefusal(c, e, exportBreaksOf(e));
|
|
5002
5393
|
}
|
|
5003
5394
|
throw e;
|
|
5004
5395
|
}
|
|
@@ -5016,13 +5407,13 @@ export function createControlPlaneApi(options) {
|
|
|
5016
5407
|
if (adoptPin) {
|
|
5017
5408
|
const v = await verticalOf(actor, slug);
|
|
5018
5409
|
if (!v || v.ownerTenant !== adoptPin)
|
|
5019
|
-
return c
|
|
5410
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
5020
5411
|
}
|
|
5021
5412
|
const v = await verticalOf(actor, slug);
|
|
5022
5413
|
if (!v)
|
|
5023
|
-
return c
|
|
5414
|
+
return refuse(c, 'not_found', `unknown vertical '${slug}'`);
|
|
5024
5415
|
if (v.ownerTenant === null) {
|
|
5025
|
-
return c
|
|
5416
|
+
return refuse(c, 'conflict', 'adopt-serving is a private-vertical operation');
|
|
5026
5417
|
}
|
|
5027
5418
|
const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
|
|
5028
5419
|
const { acknowledge } = await readJsonBody(c, adoptVerticalServingBody);
|
|
@@ -5036,7 +5427,7 @@ export function createControlPlaneApi(options) {
|
|
|
5036
5427
|
}
|
|
5037
5428
|
catch (e) {
|
|
5038
5429
|
if (e instanceof ControlPlaneError) {
|
|
5039
|
-
return c
|
|
5430
|
+
return relayRefusal(c, e, { ...exportBreaksOf(e), adopted, alreadyAdopted });
|
|
5040
5431
|
}
|
|
5041
5432
|
throw e;
|
|
5042
5433
|
}
|
|
@@ -5056,7 +5447,7 @@ export function createControlPlaneApi(options) {
|
|
|
5056
5447
|
// another tenant's private vertical, which a confined caller must not even learn exists
|
|
5057
5448
|
// (404, as `GET /verticals/:slug` answers it).
|
|
5058
5449
|
if (await notReadable(c.get('principal'), vertical)) {
|
|
5059
|
-
return c
|
|
5450
|
+
return refuse(c, 'not_found', `unknown vertical: ${vertical}`);
|
|
5060
5451
|
}
|
|
5061
5452
|
try {
|
|
5062
5453
|
const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
|
|
@@ -5068,7 +5459,7 @@ export function createControlPlaneApi(options) {
|
|
|
5068
5459
|
}
|
|
5069
5460
|
catch (e) {
|
|
5070
5461
|
if (e instanceof ControlPlaneError) {
|
|
5071
|
-
return c
|
|
5462
|
+
return relayRefusal(c, e, exportBreaksOf(e));
|
|
5072
5463
|
}
|
|
5073
5464
|
throw e;
|
|
5074
5465
|
}
|
|
@@ -5139,7 +5530,7 @@ export function createControlPlaneApi(options) {
|
|
|
5139
5530
|
const { script } = keptScriptQuery.parse(c.req.query());
|
|
5140
5531
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
5141
5532
|
if (!scope)
|
|
5142
|
-
return c
|
|
5533
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5143
5534
|
const kept = await (await keptHolder(script)).keptCopy(scopeId);
|
|
5144
5535
|
return c.json({ scopeId, script, kept, routesTo: await routeOf(c, scope) });
|
|
5145
5536
|
});
|
|
@@ -5158,17 +5549,15 @@ export function createControlPlaneApi(options) {
|
|
|
5158
5549
|
const actor = c.get('actor');
|
|
5159
5550
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5160
5551
|
if (!scope)
|
|
5161
|
-
return c
|
|
5552
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5162
5553
|
if (scope.jurisdiction !== 'global') {
|
|
5163
|
-
return c.
|
|
5164
|
-
|
|
5165
|
-
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
|
|
5166
|
-
}, 403);
|
|
5554
|
+
return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
|
|
5555
|
+
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
|
|
5167
5556
|
}
|
|
5168
5557
|
const holder = await keptHolder(script);
|
|
5169
5558
|
const kept = await holder.keptCopy(scopeId);
|
|
5170
5559
|
if (!kept)
|
|
5171
|
-
return c
|
|
5560
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${script}'`);
|
|
5172
5561
|
const dump = await c.var.admin.exportScope(actor, tenantId, scopeId);
|
|
5173
5562
|
const tables = await holder.exportScope(scopeId);
|
|
5174
5563
|
if (full === 'true')
|
|
@@ -5183,20 +5572,18 @@ export function createControlPlaneApi(options) {
|
|
|
5183
5572
|
const actor = c.get('actor');
|
|
5184
5573
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5185
5574
|
if (!scope)
|
|
5186
|
-
return c
|
|
5575
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5187
5576
|
const holder = await keptHolder(body.script);
|
|
5188
5577
|
const kept = await holder.keptCopy(scopeId);
|
|
5189
5578
|
if (!kept)
|
|
5190
|
-
return c
|
|
5579
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${body.script}'`);
|
|
5191
5580
|
const route = await routeOf(c, scope);
|
|
5192
5581
|
// `release` is for a kept copy the scope routes to (a rollback bound it after a carry kept
|
|
5193
5582
|
// it); the other two act on a kept copy the scope has left, against the store it runs on.
|
|
5194
5583
|
if (body.action === 'release' ? route !== body.script : !route || route === body.script) {
|
|
5195
|
-
return c.
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
: `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`,
|
|
5199
|
-
}, 409);
|
|
5584
|
+
return refuse(c, 'conflict', body.action === 'release'
|
|
5585
|
+
? `scope ${scopeId} routes to '${route ?? '(nothing)'}', not to the kept copy in '${body.script}'; release is for a kept copy that is live`
|
|
5586
|
+
: `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`);
|
|
5200
5587
|
}
|
|
5201
5588
|
const at = new Date().toISOString();
|
|
5202
5589
|
const revisionOf = async (client) => {
|
|
@@ -5227,7 +5614,7 @@ export function createControlPlaneApi(options) {
|
|
|
5227
5614
|
if (body.action === 'release') {
|
|
5228
5615
|
const out = await holder.releaseKeptCopy({ scopeId, revision: revisionBefore, ...markCopy, ...stampFence });
|
|
5229
5616
|
if ('refused' in out) {
|
|
5230
|
-
return c
|
|
5617
|
+
return refuse(c, 'precondition_failed', `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was released`);
|
|
5231
5618
|
}
|
|
5232
5619
|
await record('release', revisionBefore);
|
|
5233
5620
|
return c.json({ scopeId, script: body.script, action: 'release', revisionBefore });
|
|
@@ -5235,7 +5622,7 @@ export function createControlPlaneApi(options) {
|
|
|
5235
5622
|
if (body.action === 'discard') {
|
|
5236
5623
|
const out = await holder.discardKeptCopy({ scopeId, revision: revisionBefore, carriedTo: route, at, ...markCopy, ...stampFence });
|
|
5237
5624
|
if ('refused' in out) {
|
|
5238
|
-
return c
|
|
5625
|
+
return refuse(c, 'precondition_failed', `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was discarded`);
|
|
5239
5626
|
}
|
|
5240
5627
|
const revisionAfter = await revisionOf(holder);
|
|
5241
5628
|
await record('discard', revisionAfter);
|
|
@@ -5243,10 +5630,8 @@ export function createControlPlaneApi(options) {
|
|
|
5243
5630
|
}
|
|
5244
5631
|
// restore-forward: the acknowledgement names what is replaced, as the marker says it.
|
|
5245
5632
|
if (body.acknowledge.replacesLiveWritesSince !== kept.keptAt) {
|
|
5246
|
-
return c.
|
|
5247
|
-
|
|
5248
|
-
`(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`,
|
|
5249
|
-
}, 409);
|
|
5633
|
+
return refuse(c, 'conflict', `a restore forward replaces what the live store took since ${kept.keptAt}; acknowledge exactly that ` +
|
|
5634
|
+
`(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`);
|
|
5250
5635
|
}
|
|
5251
5636
|
// Fenced on the binding through the whole resolution (Codex #2008 r8): the kept copy goes only
|
|
5252
5637
|
// once its data is in the store the scope still routes to. Every refusal below leaves it kept.
|
|
@@ -5258,21 +5643,19 @@ export function createControlPlaneApi(options) {
|
|
|
5258
5643
|
(now.verticalVersionId ?? null) === (scope.verticalVersionId ?? null) &&
|
|
5259
5644
|
(now.servingRef ?? null) === (scope.servingRef ?? null));
|
|
5260
5645
|
};
|
|
5261
|
-
const moved = () => c
|
|
5262
|
-
|
|
5263
|
-
`and the kept copy in '${body.script}' stays kept; read it again and resolve it again`,
|
|
5264
|
-
}, 409);
|
|
5646
|
+
const moved = () => refuse(c, 'conflict', `scope ${scopeId} was re-pointed while the kept copy was being restored forward, so nothing was resolved ` +
|
|
5647
|
+
`and the kept copy in '${body.script}' stays kept; read it again and resolve it again`);
|
|
5265
5648
|
// The live store as read now, and nothing else, is what gets replaced. The binding read again
|
|
5266
5649
|
// after the marker ties the marker to the live store: a move that landed before this read
|
|
5267
5650
|
// (its wipe minting a tombstone here) changed the binding, and is refused.
|
|
5268
5651
|
const liveMarker = await live.loadMarker(scopeId);
|
|
5269
5652
|
if (liveMarker === 'unfenced') {
|
|
5270
|
-
return c
|
|
5653
|
+
return refuse(c, 'conflict', `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve`);
|
|
5271
5654
|
}
|
|
5272
5655
|
if (!(await bindingHeld()))
|
|
5273
5656
|
return moved();
|
|
5274
5657
|
if ((await isCarriedAway(live, scope)) || (await live.keptCopy(scopeId))) {
|
|
5275
|
-
return c
|
|
5658
|
+
return refuse(c, 'conflict', `the store scope ${scopeId} routes to in '${liveScript}' is not a live store (wiped or kept); nothing was resolved`);
|
|
5276
5659
|
}
|
|
5277
5660
|
const dump = await retryTransient(() => holder.exportScope(scopeId));
|
|
5278
5661
|
// #2185: before the load as well as after it, so a throw once the kept copy has landed (the
|
|
@@ -5311,10 +5694,8 @@ export function createControlPlaneApi(options) {
|
|
|
5311
5694
|
...stampFence,
|
|
5312
5695
|
});
|
|
5313
5696
|
if ('refused' in wiped) {
|
|
5314
|
-
return c.
|
|
5315
|
-
|
|
5316
|
-
`stays kept: read it again and resolve it again`,
|
|
5317
|
-
}, 409);
|
|
5697
|
+
return refuse(c, 'conflict', `restored forward into '${route}', but the kept copy in '${body.script}' changed since it was read, so it ` +
|
|
5698
|
+
`stays kept: read it again and resolve it again`);
|
|
5318
5699
|
}
|
|
5319
5700
|
return c.json({ scopeId, script: body.script, action: 'restore-forward', liveScript, revisionBefore, revisionAfter });
|
|
5320
5701
|
});
|
|
@@ -5372,7 +5753,7 @@ export function createControlPlaneApi(options) {
|
|
|
5372
5753
|
};
|
|
5373
5754
|
if (snapshot) {
|
|
5374
5755
|
if (!scope) {
|
|
5375
|
-
return c
|
|
5756
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5376
5757
|
}
|
|
5377
5758
|
const vertical = await verticalForScope(c, scope);
|
|
5378
5759
|
if (vertical) {
|
|
@@ -5447,13 +5828,13 @@ export function createControlPlaneApi(options) {
|
|
|
5447
5828
|
// uniform whether or not anything is deployed. Existing scopes keep serving.
|
|
5448
5829
|
const registered = (await c.var.admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
|
|
5449
5830
|
if (registered?.installsBlocked) {
|
|
5450
|
-
return c
|
|
5831
|
+
return refuse(c, 'forbidden', `new installs of vertical '${slug}' are blocked`);
|
|
5451
5832
|
}
|
|
5452
5833
|
// Static binding first (milestone-one shape), then the dispatch resolver for a
|
|
5453
5834
|
// pushed vertical — the provisioning mirror of the router's verticalFor.
|
|
5454
5835
|
const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
|
|
5455
5836
|
if (!vertical) {
|
|
5456
|
-
return c
|
|
5837
|
+
return refuseStatus(c, 501, `no deployment is bound for vertical '${slug}'`);
|
|
5457
5838
|
}
|
|
5458
5839
|
const input = provisionInstanceBody.parse(await c.req.json());
|
|
5459
5840
|
// #310: the platform is the authoritative source of the tenant's entitlements — it
|
|
@@ -5572,7 +5953,7 @@ export function createControlPlaneApi(options) {
|
|
|
5572
5953
|
message: e.message,
|
|
5573
5954
|
}, e);
|
|
5574
5955
|
}
|
|
5575
|
-
return
|
|
5956
|
+
return relayRefusal(c, e);
|
|
5576
5957
|
}
|
|
5577
5958
|
throw e;
|
|
5578
5959
|
}
|
|
@@ -5801,7 +6182,7 @@ export function createControlPlaneApi(options) {
|
|
|
5801
6182
|
input.slug = effectiveSlug(p, input.slug);
|
|
5802
6183
|
const owner = await ownerOf(p.actor, input.slug);
|
|
5803
6184
|
if (owner !== undefined && owner !== p.tenantId)
|
|
5804
|
-
return c
|
|
6185
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
5805
6186
|
input.ownerTenant = p.tenantId;
|
|
5806
6187
|
}
|
|
5807
6188
|
await c.var.admin.registerVertical(c.get('actor'), input);
|
|
@@ -5816,7 +6197,7 @@ export function createControlPlaneApi(options) {
|
|
|
5816
6197
|
// A builder reading a vertical it does not own gets 404 — indistinguishable from
|
|
5817
6198
|
// absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
|
|
5818
6199
|
if (await notReadable(p, slug)) {
|
|
5819
|
-
return c
|
|
6200
|
+
return refuse(c, 'not_found', 'not found');
|
|
5820
6201
|
}
|
|
5821
6202
|
const page = pageParams(c);
|
|
5822
6203
|
const entries = await c.var.admin.listVersions(c.get('actor'), slug, page);
|
|
@@ -5828,12 +6209,12 @@ export function createControlPlaneApi(options) {
|
|
|
5828
6209
|
// the same fail-closed cross-check `(tenantId, scopeId)` makes (K-3) — a mismatch is
|
|
5829
6210
|
// a client bug, not a silent publish under the wrong vertical.
|
|
5830
6211
|
if (input.verticalSlug !== c.req.param('slug')) {
|
|
5831
|
-
return c
|
|
6212
|
+
return refuse(c, 'validation_failed', 'verticalSlug does not match the path');
|
|
5832
6213
|
}
|
|
5833
6214
|
const p = c.get('principal');
|
|
5834
6215
|
const slug = await resolveVerticalId(c, input.verticalSlug);
|
|
5835
6216
|
if (await notOwned(p, slug)) {
|
|
5836
|
-
return c
|
|
6217
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
5837
6218
|
}
|
|
5838
6219
|
input.verticalSlug = slug;
|
|
5839
6220
|
// `manifestJson` is a string the schema cannot look inside, and every reader parses it
|
|
@@ -5850,19 +6231,19 @@ export function createControlPlaneApi(options) {
|
|
|
5850
6231
|
raw = JSON.parse(input.manifestJson);
|
|
5851
6232
|
}
|
|
5852
6233
|
catch {
|
|
5853
|
-
return c
|
|
6234
|
+
return refuse(c, 'validation_failed', 'manifestJson is not valid JSON');
|
|
5854
6235
|
}
|
|
5855
|
-
const
|
|
6236
|
+
const refuseManifest = (issue, under = []) => refuse(c, 'validation_failed', `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}`);
|
|
5856
6237
|
const checked = storedDeployManifest.safeParse(raw);
|
|
5857
6238
|
if (!checked.success)
|
|
5858
|
-
return
|
|
6239
|
+
return refuseManifest(checked.error.issues[0]);
|
|
5859
6240
|
// #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
|
|
5860
6241
|
// entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
|
|
5861
6242
|
// An absent registry stays accepted, as above.
|
|
5862
6243
|
if (checked.data.registry) {
|
|
5863
6244
|
const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
|
|
5864
6245
|
if (!registry.success)
|
|
5865
|
-
return
|
|
6246
|
+
return refuseManifest(registry.error.issues[0], ['registry']);
|
|
5866
6247
|
}
|
|
5867
6248
|
}
|
|
5868
6249
|
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
@@ -5879,7 +6260,7 @@ export function createControlPlaneApi(options) {
|
|
|
5879
6260
|
const p = c.get('principal');
|
|
5880
6261
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5881
6262
|
if (await notReadable(p, slug)) {
|
|
5882
|
-
return c
|
|
6263
|
+
return refuse(c, 'not_found', 'not found');
|
|
5883
6264
|
}
|
|
5884
6265
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
5885
6266
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
@@ -5897,7 +6278,7 @@ export function createControlPlaneApi(options) {
|
|
|
5897
6278
|
const p = c.get('principal');
|
|
5898
6279
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5899
6280
|
if (await notOwned(p, slug)) {
|
|
5900
|
-
return c
|
|
6281
|
+
return refuse(c, 'not_found', 'not found');
|
|
5901
6282
|
}
|
|
5902
6283
|
// `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
|
|
5903
6284
|
const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
|
|
@@ -5919,7 +6300,7 @@ export function createControlPlaneApi(options) {
|
|
|
5919
6300
|
const p = c.get('principal');
|
|
5920
6301
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5921
6302
|
if (await notReadable(p, slug)) {
|
|
5922
|
-
return c
|
|
6303
|
+
return refuse(c, 'not_found', 'not found');
|
|
5923
6304
|
}
|
|
5924
6305
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
5925
6306
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -5942,7 +6323,7 @@ export function createControlPlaneApi(options) {
|
|
|
5942
6323
|
const p = c.get('principal');
|
|
5943
6324
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5944
6325
|
if (await notReadable(p, slug)) {
|
|
5945
|
-
return c
|
|
6326
|
+
return refuse(c, 'not_found', 'not found');
|
|
5946
6327
|
}
|
|
5947
6328
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
5948
6329
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -5970,7 +6351,7 @@ export function createControlPlaneApi(options) {
|
|
|
5970
6351
|
const p = c.get('principal');
|
|
5971
6352
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5972
6353
|
if (await notReadable(p, slug)) {
|
|
5973
|
-
return c
|
|
6354
|
+
return refuse(c, 'not_found', 'not found');
|
|
5974
6355
|
}
|
|
5975
6356
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
5976
6357
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -5989,7 +6370,7 @@ export function createControlPlaneApi(options) {
|
|
|
5989
6370
|
const p = c.get('principal');
|
|
5990
6371
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
5991
6372
|
if (await notReadable(p, slug)) {
|
|
5992
|
-
return c
|
|
6373
|
+
return refuse(c, 'not_found', 'not found');
|
|
5993
6374
|
}
|
|
5994
6375
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
5995
6376
|
const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
|
|
@@ -6014,7 +6395,7 @@ export function createControlPlaneApi(options) {
|
|
|
6014
6395
|
const p = c.get('principal');
|
|
6015
6396
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6016
6397
|
if (await notOwned(p, slug)) {
|
|
6017
|
-
return c
|
|
6398
|
+
return refuse(c, 'not_found', 'not found');
|
|
6018
6399
|
}
|
|
6019
6400
|
await c.var.admin.requestPublish(c.get('actor'), slug);
|
|
6020
6401
|
return c.json({ slug, requested: true });
|
|
@@ -6070,7 +6451,7 @@ export function createControlPlaneApi(options) {
|
|
|
6070
6451
|
// 404, not 403: a slug this tenant does not own reads as absent, the same
|
|
6071
6452
|
// existence-hiding reflex the registry's other per-slug reads use (K-3).
|
|
6072
6453
|
if (await notOwned(c.get('principal'), slug))
|
|
6073
|
-
return c
|
|
6454
|
+
return refuse(c, 'not_found', 'not found');
|
|
6074
6455
|
await c.var.admin.deleteVertical(c.get('actor'), slug);
|
|
6075
6456
|
return c.json({ slug, deleted: true });
|
|
6076
6457
|
});
|
|
@@ -6078,7 +6459,7 @@ export function createControlPlaneApi(options) {
|
|
|
6078
6459
|
const p = c.get('principal');
|
|
6079
6460
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6080
6461
|
if (await notReadable(p, slug)) {
|
|
6081
|
-
return c
|
|
6462
|
+
return refuse(c, 'not_found', 'not found');
|
|
6082
6463
|
}
|
|
6083
6464
|
const page = pageParams(c);
|
|
6084
6465
|
const entries = await c.var.admin.listChannels(c.get('actor'), slug, page);
|
|
@@ -6092,16 +6473,74 @@ export function createControlPlaneApi(options) {
|
|
|
6092
6473
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6093
6474
|
const rawChannel = c.req.param('channel');
|
|
6094
6475
|
if (rawChannel !== 'prod') {
|
|
6095
|
-
return c
|
|
6476
|
+
return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — only 'prod' has history`);
|
|
6096
6477
|
}
|
|
6097
6478
|
const channel = channelName.parse(rawChannel);
|
|
6098
6479
|
if (await notOwned(p, slug)) {
|
|
6099
|
-
return c
|
|
6480
|
+
return refuse(c, 'not_found', 'not found');
|
|
6100
6481
|
}
|
|
6101
6482
|
const page = pageParams(c);
|
|
6102
6483
|
const entries = await c.var.admin.listChannelHistory(c.get('actor'), slug, channel, page);
|
|
6103
6484
|
return c.json(pageOf(entries, page.limit, (e) => e.id));
|
|
6104
6485
|
});
|
|
6486
|
+
/**
|
|
6487
|
+
* Puts the store scripts a version's declared stores need in place, before its vertical is
|
|
6488
|
+
* uploaded (#2210). A declaration with no uploader configured is refused (501) rather than
|
|
6489
|
+
* served without its store. A failure is recorded as an ops failure at stage `store-upload`
|
|
6490
|
+
* (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
|
|
6491
|
+
* The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
|
|
6492
|
+
* path reads the owner only once something has failed.
|
|
6493
|
+
*/
|
|
6494
|
+
const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
|
|
6495
|
+
if (stores.length === 0)
|
|
6496
|
+
return;
|
|
6497
|
+
const holder = ulid();
|
|
6498
|
+
const kinds = [...new Set(stores.map((store) => store.kind))].sort();
|
|
6499
|
+
const held = [];
|
|
6500
|
+
try {
|
|
6501
|
+
if (!options.ensureScopeStores) {
|
|
6502
|
+
throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
|
|
6503
|
+
}
|
|
6504
|
+
// A shared hold on each kind (#2300): a rollout holds it exclusively, and a rebuild that read
|
|
6505
|
+
// a script's release before a rollout moved it would upload the old release back over it.
|
|
6506
|
+
// Neither the refusal nor anything else here names another vertical: a push answers its own.
|
|
6507
|
+
for (const kind of kinds) {
|
|
6508
|
+
if (!(await host.admin.holdStoreKind(actor, { kind, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
|
|
6509
|
+
throw new ControlPlaneError(409, `${operation} refused: scope store kind '${kind}' is being rolled out; try again in a few minutes`);
|
|
6510
|
+
}
|
|
6511
|
+
held.push(kind);
|
|
6512
|
+
}
|
|
6513
|
+
const loadRelease = options.storeReleases
|
|
6514
|
+
? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
|
|
6515
|
+
: async () => undefined;
|
|
6516
|
+
// One kind at a time, each renewing every hold first: a push whose uploads outlast a hold
|
|
6517
|
+
// stops (409) rather than upload after a rollout could have taken the kind.
|
|
6518
|
+
for (const kind of kinds) {
|
|
6519
|
+
for (const each of held) {
|
|
6520
|
+
if (!(await host.admin.holdStoreKind(actor, { kind: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
|
|
6521
|
+
throw new ControlPlaneError(409, `${operation} refused: the hold on scope store kind '${each}' lapsed during the upload; try again`);
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.kind === kind), loadRelease });
|
|
6525
|
+
}
|
|
6526
|
+
}
|
|
6527
|
+
catch (e) {
|
|
6528
|
+
recordFailure({
|
|
6529
|
+
actor,
|
|
6530
|
+
operation: `${operation}.store-upload`,
|
|
6531
|
+
stage: 'store-upload',
|
|
6532
|
+
vertical: slug,
|
|
6533
|
+
tenantId: await owner().catch(() => null),
|
|
6534
|
+
status: e instanceof ControlPlaneError ? e.status : 502,
|
|
6535
|
+
message: e instanceof Error ? e.message : String(e),
|
|
6536
|
+
}, e);
|
|
6537
|
+
throw e;
|
|
6538
|
+
}
|
|
6539
|
+
finally {
|
|
6540
|
+
for (const kind of held)
|
|
6541
|
+
await host.admin.releaseStoreKindHold(actor, { kind, holder }).catch(() => undefined);
|
|
6542
|
+
}
|
|
6543
|
+
};
|
|
6105
6544
|
/**
|
|
6106
6545
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
6107
6546
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -6154,13 +6593,17 @@ export function createControlPlaneApi(options) {
|
|
|
6154
6593
|
].map((b) => b.type === 'd1'
|
|
6155
6594
|
? { type: 'd1', name: b.name, id: b.id }
|
|
6156
6595
|
: { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
|
|
6596
|
+
// #2210: the store scripts first, so the vertical is never served before what it reaches.
|
|
6597
|
+
await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
|
|
6157
6598
|
await options.deployVertical(ref, {
|
|
6158
6599
|
entry: manifest.entry,
|
|
6159
6600
|
compatibilityDate: manifest.compatibilityDate,
|
|
6160
6601
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
6161
6602
|
modules,
|
|
6162
6603
|
doClasses,
|
|
6163
|
-
|
|
6604
|
+
// #2210: a declared scope store adds no binding to either upload (L1), held at both call
|
|
6605
|
+
// sites with a kind registered by upload-scope-store-bindings.test.ts.
|
|
6606
|
+
bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: storeBindings }),
|
|
6164
6607
|
// #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
|
|
6165
6608
|
// scope host stamps the signals `version` dimension. The serving script is one
|
|
6166
6609
|
// stable script updated in place, so this refresh is what keeps the binding
|
|
@@ -6307,7 +6750,7 @@ export function createControlPlaneApi(options) {
|
|
|
6307
6750
|
if (pin) {
|
|
6308
6751
|
const v = await verticalOf(p.actor, slug);
|
|
6309
6752
|
if (!v || v.ownerTenant !== pin)
|
|
6310
|
-
return c
|
|
6753
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6311
6754
|
}
|
|
6312
6755
|
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
6313
6756
|
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
@@ -6320,10 +6763,8 @@ export function createControlPlaneApi(options) {
|
|
|
6320
6763
|
// with the honest alternative — a non-prod environment is a scope with data, a preview.
|
|
6321
6764
|
const rawChannel = c.req.param('channel');
|
|
6322
6765
|
if (rawChannel !== 'prod') {
|
|
6323
|
-
return c.
|
|
6324
|
-
|
|
6325
|
-
`against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`,
|
|
6326
|
-
}, 400);
|
|
6766
|
+
return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — 'prod' is the only channel. To run a version ` +
|
|
6767
|
+
`against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`);
|
|
6327
6768
|
}
|
|
6328
6769
|
const channel = channelName.parse(rawChannel);
|
|
6329
6770
|
const promotePin = confinedTenant(p);
|
|
@@ -6331,7 +6772,7 @@ export function createControlPlaneApi(options) {
|
|
|
6331
6772
|
// A confined principal promotes only verticals its tenant owns.
|
|
6332
6773
|
const v = await verticalOf(p.actor, slug);
|
|
6333
6774
|
if (!v || v.ownerTenant !== promotePin)
|
|
6334
|
-
return c
|
|
6775
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6335
6776
|
// …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
|
|
6336
6777
|
// A private vertical's blast radius is the owning tenant itself, and dev/staging
|
|
6337
6778
|
// already run the same bundle in the same sandbox, so a staff prod gate there
|
|
@@ -6345,7 +6786,7 @@ export function createControlPlaneApi(options) {
|
|
|
6345
6786
|
// a tenant may do inside its own. Taking the gate away from them here would be a
|
|
6346
6787
|
// capability change hidden in a security fix.
|
|
6347
6788
|
if (p.kind === 'builder' && channel === 'prod' && v.listed) {
|
|
6348
|
-
return c
|
|
6789
|
+
return refuse(c, 'forbidden', 'promotion to prod is staff-only for a listed vertical');
|
|
6349
6790
|
}
|
|
6350
6791
|
}
|
|
6351
6792
|
const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
|
|
@@ -6383,7 +6824,7 @@ export function createControlPlaneApi(options) {
|
|
|
6383
6824
|
exportBreaks: null,
|
|
6384
6825
|
exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
6385
6826
|
}));
|
|
6386
|
-
return c
|
|
6827
|
+
return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
|
|
6387
6828
|
}
|
|
6388
6829
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
6389
6830
|
// uploading first would deploy to live scopes before the acknowledgement check.
|
|
@@ -6409,10 +6850,7 @@ export function createControlPlaneApi(options) {
|
|
|
6409
6850
|
catch (e) {
|
|
6410
6851
|
const detail = e instanceof Error ? e.message : String(e);
|
|
6411
6852
|
console.error('serve.inplace.failed', { slug, versionId, detail });
|
|
6412
|
-
return c
|
|
6413
|
-
error: 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry',
|
|
6414
|
-
detail,
|
|
6415
|
-
}, 502);
|
|
6853
|
+
return refuseStatus(c, 502, 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry', { detail });
|
|
6416
6854
|
}
|
|
6417
6855
|
}
|
|
6418
6856
|
// #1756: served, and every install that could move has moved and been backfilled, but one
|
|
@@ -6421,9 +6859,8 @@ export function createControlPlaneApi(options) {
|
|
|
6421
6859
|
// version. It is left where it is. Promoting again acknowledged moves it.
|
|
6422
6860
|
if (adopted.refused.length > 0 || adopted.refusal) {
|
|
6423
6861
|
const sentence = adopted.refused.length > 0 ? bindExportBreakRefusal(adopted.refused) : adopted.refusal;
|
|
6424
|
-
return c
|
|
6425
|
-
|
|
6426
|
-
`${sentence}. Promote again with the export-break acknowledgement to move it`,
|
|
6862
|
+
return refuse(c, 'conflict', `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
|
|
6863
|
+
`${sentence}. Promote again with the export-break acknowledgement to move it`, {
|
|
6427
6864
|
...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
|
|
6428
6865
|
// Narrowed as the success answer's is: a builder never reads another tenant's id here.
|
|
6429
6866
|
...(backfill.minted.length || backfill.error
|
|
@@ -6438,7 +6875,7 @@ export function createControlPlaneApi(options) {
|
|
|
6438
6875
|
};
|
|
6439
6876
|
})()
|
|
6440
6877
|
: {}),
|
|
6441
|
-
}
|
|
6878
|
+
});
|
|
6442
6879
|
}
|
|
6443
6880
|
const promoted = (await c.var.admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
|
|
6444
6881
|
// The store backfill rides the response only when it has something to say (#825), so
|
|
@@ -6476,13 +6913,13 @@ export function createControlPlaneApi(options) {
|
|
|
6476
6913
|
// tenant); for everything else admission still gates serving.
|
|
6477
6914
|
app.post('/verticals/:slug/deploy', async (c) => {
|
|
6478
6915
|
if (!options.deployVertical) {
|
|
6479
|
-
return c
|
|
6916
|
+
return refuseStatus(c, 501, 'deploy is not configured on this control plane');
|
|
6480
6917
|
}
|
|
6481
6918
|
const p = c.get('principal');
|
|
6482
6919
|
const form = await c.req.formData();
|
|
6483
6920
|
const raw = form.get('manifest');
|
|
6484
6921
|
if (typeof raw !== 'string')
|
|
6485
|
-
return c
|
|
6922
|
+
return refuse(c, 'validation_failed', 'missing manifest part');
|
|
6486
6923
|
const manifest = deployManifest.parse(JSON.parse(raw));
|
|
6487
6924
|
// §4 sandbox contract, before anything reaches the namespace.
|
|
6488
6925
|
assertSandboxContract(manifest);
|
|
@@ -6519,20 +6956,20 @@ export function createControlPlaneApi(options) {
|
|
|
6519
6956
|
let ownerTenant;
|
|
6520
6957
|
if (p.kind === 'builder') {
|
|
6521
6958
|
if (pin && pin !== p.tenantSlug && pin !== p.tenantId) {
|
|
6522
|
-
return c
|
|
6959
|
+
return refuse(c, 'forbidden', `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'`);
|
|
6523
6960
|
}
|
|
6524
6961
|
slug = effectiveSlug(p, bare);
|
|
6525
6962
|
// A builder pushes to a slug it owns, or claims an unregistered one (§3).
|
|
6526
6963
|
const existingOwner = await ownerOf(c.get('actor'), slug);
|
|
6527
6964
|
if (existingOwner !== undefined && existingOwner !== p.tenantId) {
|
|
6528
|
-
return c
|
|
6965
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6529
6966
|
}
|
|
6530
6967
|
ownerTenant = p.tenantId;
|
|
6531
6968
|
}
|
|
6532
6969
|
else if (pin) {
|
|
6533
6970
|
const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
|
|
6534
6971
|
if (!workspace)
|
|
6535
|
-
return c
|
|
6972
|
+
return refuse(c, 'not_found', `unknown workspace '${pin}'`);
|
|
6536
6973
|
// Back-compat: a BARE slug already registered as the pinned tenant's (a staff
|
|
6537
6974
|
// hand-registration predating prefixed claims) stays addressable as itself.
|
|
6538
6975
|
// Otherwise the claim lands under the tenant prefix, exactly like a builder push —
|
|
@@ -6541,7 +6978,7 @@ export function createControlPlaneApi(options) {
|
|
|
6541
6978
|
slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
6542
6979
|
const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
|
|
6543
6980
|
if (existingOwner !== undefined && existingOwner !== workspace.id) {
|
|
6544
|
-
return c
|
|
6981
|
+
return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
|
|
6545
6982
|
}
|
|
6546
6983
|
ownerTenant = workspace.id;
|
|
6547
6984
|
}
|
|
@@ -6556,7 +6993,7 @@ export function createControlPlaneApi(options) {
|
|
|
6556
6993
|
// registry id that predates the rule, so it can never put a script under a platform name.
|
|
6557
6994
|
const collision = platformScriptCollision(slug);
|
|
6558
6995
|
if (collision)
|
|
6559
|
-
return c
|
|
6996
|
+
return refuseStatus(c, 422, platformScriptRefusal(slug, collision));
|
|
6560
6997
|
// #388: refuse the SILENT lineage fork. A first push of a registry id that does not
|
|
6561
6998
|
// exist yet, whose product name (the slug's tail) matches an existing lineage this
|
|
6562
6999
|
// push could CONFUSE ITSELF WITH, is almost always a mis-identified project — a
|
|
@@ -6591,13 +7028,11 @@ export function createControlPlaneApi(options) {
|
|
|
6591
7028
|
? 'owned by this same workspace'
|
|
6592
7029
|
: `owned by tenant ${v.ownerTenant}`;
|
|
6593
7030
|
const named = siblings.map((v) => `'${v.slug}' (${owner(v)}${v.listed ? ', listed' : ''})`).join(', ');
|
|
6594
|
-
return c.
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
`separate same-named lineage, re-run with --allow-fork.`,
|
|
6600
|
-
}, 409);
|
|
7031
|
+
return refuse(c, 'conflict', `this push would CREATE a new vertical '${slug}' — a separate lineage beside ${named}. ` +
|
|
7032
|
+
`Installs of the existing lineage never update from pushes to '${slug}'. If you meant to ` +
|
|
7033
|
+
`update it, fix the project's identity first: package.json \`substrat.slug\` and ` +
|
|
7034
|
+
`\`substrat.tenant\` (or --tenant) decide where a push lands. To deliberately run a ` +
|
|
7035
|
+
`separate same-named lineage, re-run with --allow-fork.`);
|
|
6601
7036
|
}
|
|
6602
7037
|
}
|
|
6603
7038
|
// Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
|
|
@@ -6622,7 +7057,7 @@ export function createControlPlaneApi(options) {
|
|
|
6622
7057
|
});
|
|
6623
7058
|
}
|
|
6624
7059
|
if (!modules.some((m) => m.name === manifest.entry)) {
|
|
6625
|
-
return c
|
|
7060
|
+
return refuse(c, 'validation_failed', `entry module '${manifest.entry}' is not among the uploaded files`);
|
|
6626
7061
|
}
|
|
6627
7062
|
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
6628
7063
|
// bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
|
|
@@ -6635,19 +7070,17 @@ export function createControlPlaneApi(options) {
|
|
|
6635
7070
|
for (const file of manifest.assets?.files ?? []) {
|
|
6636
7071
|
const part = assetParts.get(file.path);
|
|
6637
7072
|
if (!part) {
|
|
6638
|
-
return c
|
|
7073
|
+
return refuse(c, 'validation_failed', `asset '${file.path}' is named in the manifest but was not uploaded`);
|
|
6639
7074
|
}
|
|
6640
7075
|
const content = new Uint8Array(await part.arrayBuffer());
|
|
6641
7076
|
if (content.byteLength !== file.size) {
|
|
6642
|
-
return c
|
|
7077
|
+
return refuse(c, 'validation_failed', `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived`);
|
|
6643
7078
|
}
|
|
6644
7079
|
const actual = await assetHash(content, file.path);
|
|
6645
7080
|
if (actual !== file.hash) {
|
|
6646
|
-
return c.
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
`so a mismatch is refused rather than stored`,
|
|
6650
|
-
}, 400);
|
|
7081
|
+
return refuse(c, 'validation_failed', `asset '${file.path}' does not match its declared content hash ` +
|
|
7082
|
+
`(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
|
|
7083
|
+
`so a mismatch is refused rather than stored`);
|
|
6651
7084
|
}
|
|
6652
7085
|
assets.push({
|
|
6653
7086
|
path: file.path,
|
|
@@ -6662,9 +7095,7 @@ export function createControlPlaneApi(options) {
|
|
|
6662
7095
|
// An unlisted asset part would be uploaded by nothing and served by nothing; saying so
|
|
6663
7096
|
// beats silently dropping it, because the builder's page would 404 with no explanation.
|
|
6664
7097
|
const extra = [...assetParts.keys()].slice(0, 5).join(', ');
|
|
6665
|
-
return c.
|
|
6666
|
-
error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
|
|
6667
|
-
}, 400);
|
|
7098
|
+
return refuse(c, 'validation_failed', `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`);
|
|
6668
7099
|
}
|
|
6669
7100
|
// #1902: whether this version's uploads carry the platform's scope sweeper — decided here,
|
|
6670
7101
|
// ONCE, before anything is uploaded, and recorded with the version below so every later
|
|
@@ -6672,11 +7103,14 @@ export function createControlPlaneApi(options) {
|
|
|
6672
7103
|
// while it is still a push; it can never become a promote or a backout that refuses.
|
|
6673
7104
|
const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
|
|
6674
7105
|
if ('refuse' in sweeper)
|
|
6675
|
-
return c
|
|
7106
|
+
return refuseStatus(c, 422, sweeper.refuse);
|
|
6676
7107
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
6677
7108
|
// on it, so it is CF-valid and unique per version.
|
|
6678
7109
|
const id = ulid();
|
|
6679
7110
|
const deploymentRef = deploymentRefFor(slug, id);
|
|
7111
|
+
// #2210: the store scripts first, outside the vertical upload's try, so a store failure
|
|
7112
|
+
// answers as itself and the vertical is not uploaded at all.
|
|
7113
|
+
await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
|
|
6680
7114
|
try {
|
|
6681
7115
|
await options.deployVertical(deploymentRef, {
|
|
6682
7116
|
entry: manifest.entry,
|
|
@@ -6684,7 +7118,7 @@ export function createControlPlaneApi(options) {
|
|
|
6684
7118
|
compatibilityFlags: manifest.compatibilityFlags,
|
|
6685
7119
|
modules,
|
|
6686
7120
|
doClasses: manifest.doClasses,
|
|
6687
|
-
bindings: manifest
|
|
7121
|
+
bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: [] }),
|
|
6688
7122
|
// #1242: the freshly minted version id — this archive script IS that version,
|
|
6689
7123
|
// and a preview routed at it stamps the signals `version` dimension honestly.
|
|
6690
7124
|
versionId: id,
|
|
@@ -6725,8 +7159,8 @@ export function createControlPlaneApi(options) {
|
|
|
6725
7159
|
}, e);
|
|
6726
7160
|
c.set('failureRecorded', true);
|
|
6727
7161
|
return rejected
|
|
6728
|
-
? c
|
|
6729
|
-
: c
|
|
7162
|
+
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
7163
|
+
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
6730
7164
|
}
|
|
6731
7165
|
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
6732
7166
|
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
@@ -6813,7 +7247,7 @@ export function createControlPlaneApi(options) {
|
|
|
6813
7247
|
// because "no namespaces in that script" and "I cannot look" are different answers.
|
|
6814
7248
|
app.get('/platform/do-namespaces', async (c) => {
|
|
6815
7249
|
if (!options.doNamespaces) {
|
|
6816
|
-
return c
|
|
7250
|
+
return refuseStatus(c, 501, 'durable-object namespace lookup is not configured on this control plane');
|
|
6817
7251
|
}
|
|
6818
7252
|
const { script } = z.object({ script: z.string().min(1).max(200) }).parse({ script: c.req.query('script') });
|
|
6819
7253
|
return c.json(namespacesForScript(await options.doNamespaces.list(), script));
|
|
@@ -6833,7 +7267,7 @@ export function createControlPlaneApi(options) {
|
|
|
6833
7267
|
// on the route where a mistake is least visible.
|
|
6834
7268
|
app.get('/observability/metrics', async (c) => {
|
|
6835
7269
|
if (!options.observability) {
|
|
6836
|
-
return c
|
|
7270
|
+
return refuseStatus(c, 501, 'observability is not configured on this control plane');
|
|
6837
7271
|
}
|
|
6838
7272
|
const { hours } = z
|
|
6839
7273
|
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
@@ -6850,7 +7284,7 @@ export function createControlPlaneApi(options) {
|
|
|
6850
7284
|
// 501 when the reader cannot bucket — an empty series would render as "quiet".
|
|
6851
7285
|
app.get('/observability/metrics-series', async (c) => {
|
|
6852
7286
|
if (!options.observability?.serviceMetricsSeries) {
|
|
6853
|
-
return c
|
|
7287
|
+
return refuseStatus(c, 501, 'bucketed observability is not configured on this control plane');
|
|
6854
7288
|
}
|
|
6855
7289
|
const { hours } = z
|
|
6856
7290
|
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
@@ -6874,7 +7308,7 @@ export function createControlPlaneApi(options) {
|
|
|
6874
7308
|
});
|
|
6875
7309
|
app.get('/observability/logs', async (c) => {
|
|
6876
7310
|
if (!options.observability) {
|
|
6877
|
-
return c
|
|
7311
|
+
return refuseStatus(c, 501, 'observability is not configured on this control plane');
|
|
6878
7312
|
}
|
|
6879
7313
|
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
6880
7314
|
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
@@ -6920,7 +7354,7 @@ export function createControlPlaneApi(options) {
|
|
|
6920
7354
|
// a forgotten parameter is the exact leak being closed here.
|
|
6921
7355
|
app.get('/observability/tenant-metrics', async (c) => {
|
|
6922
7356
|
if (!options.observability?.tenantMetrics) {
|
|
6923
|
-
return c
|
|
7357
|
+
return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
|
|
6924
7358
|
}
|
|
6925
7359
|
const p = c.get('principal');
|
|
6926
7360
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -6965,7 +7399,7 @@ export function createControlPlaneApi(options) {
|
|
|
6965
7399
|
// buckets, and at least one is required because an unnarrowed ask is not on offer.
|
|
6966
7400
|
app.get('/observability/tenant-metrics-series', async (c) => {
|
|
6967
7401
|
if (!options.observability?.tenantMetricsSeries) {
|
|
6968
|
-
return c
|
|
7402
|
+
return refuseStatus(c, 501, 'bucketed tenant-grain observability is not configured on this control plane');
|
|
6969
7403
|
}
|
|
6970
7404
|
const p = c.get('principal');
|
|
6971
7405
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -6996,7 +7430,7 @@ export function createControlPlaneApi(options) {
|
|
|
6996
7430
|
});
|
|
6997
7431
|
app.get('/observability/tenant-logs', async (c) => {
|
|
6998
7432
|
if (!options.observability?.tenantLogs) {
|
|
6999
|
-
return c
|
|
7433
|
+
return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
|
|
7000
7434
|
}
|
|
7001
7435
|
const p = c.get('principal');
|
|
7002
7436
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -7054,7 +7488,7 @@ export function createControlPlaneApi(options) {
|
|
|
7054
7488
|
// #1331: one app's sampled field reports, joined to the router's own request lines.
|
|
7055
7489
|
app.get('/observability/tenant-field-coverage', async (c) => {
|
|
7056
7490
|
if (!options.observability?.fieldCoverage) {
|
|
7057
|
-
return c
|
|
7491
|
+
return refuseStatus(c, 501, 'field coverage is not configured on this control plane');
|
|
7058
7492
|
}
|
|
7059
7493
|
const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
|
|
7060
7494
|
if (!tenantId)
|
|
@@ -7077,11 +7511,11 @@ export function createControlPlaneApi(options) {
|
|
|
7077
7511
|
const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
|
|
7078
7512
|
const installed = scopes.find((s) => s.id === input.scopeId);
|
|
7079
7513
|
if (!installed?.vertical)
|
|
7080
|
-
return c
|
|
7514
|
+
return refuse(c, 'not_found', 'app not found');
|
|
7081
7515
|
const services = scriptFamiliesOfScopes(scopes, { scopeId: input.scopeId });
|
|
7082
7516
|
const json = await c.var.admin.versionManifest(c.get('actor'), installed.vertical, input.versionId);
|
|
7083
7517
|
if (!json)
|
|
7084
|
-
return c
|
|
7518
|
+
return refuse(c, 'not_found', 'version not found');
|
|
7085
7519
|
const outputSurface = storedDeployManifest.parse(JSON.parse(json)).outputSurface ?? [];
|
|
7086
7520
|
const declared = Object.fromEntries(outputSurface.map((op) => [op.operationId, op.fields]));
|
|
7087
7521
|
return c.json(await options.observability.fieldCoverage({
|
|
@@ -7117,7 +7551,7 @@ export function createControlPlaneApi(options) {
|
|
|
7117
7551
|
const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await host.admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
|
|
7118
7552
|
app.get('/observability/tenant-request-volume', async (c) => {
|
|
7119
7553
|
if (!options.observability?.tenantRequestVolume) {
|
|
7120
|
-
return c
|
|
7554
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7121
7555
|
}
|
|
7122
7556
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7123
7557
|
// A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
|
|
@@ -7128,7 +7562,7 @@ export function createControlPlaneApi(options) {
|
|
|
7128
7562
|
});
|
|
7129
7563
|
app.get('/observability/tenant-request-facets', async (c) => {
|
|
7130
7564
|
if (!options.observability?.tenantRequestFacets) {
|
|
7131
|
-
return c
|
|
7565
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7132
7566
|
}
|
|
7133
7567
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7134
7568
|
// `facet` repeats; absent means every facet. Each one is a backend query, so a panel
|
|
@@ -7141,7 +7575,7 @@ export function createControlPlaneApi(options) {
|
|
|
7141
7575
|
// #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
|
|
7142
7576
|
app.get('/observability/tenant-log-patterns', async (c) => {
|
|
7143
7577
|
if (!options.observability?.tenantLogPatterns) {
|
|
7144
|
-
return c
|
|
7578
|
+
return refuseStatus(c, 501, 'log patterns are not configured on this control plane');
|
|
7145
7579
|
}
|
|
7146
7580
|
const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7147
7581
|
const narrow = z
|
|
@@ -7169,7 +7603,7 @@ export function createControlPlaneApi(options) {
|
|
|
7169
7603
|
});
|
|
7170
7604
|
app.get('/observability/tenant-requests', async (c) => {
|
|
7171
7605
|
if (!options.observability?.tenantRequests) {
|
|
7172
|
-
return c
|
|
7606
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7173
7607
|
}
|
|
7174
7608
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7175
7609
|
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
|
|
@@ -7204,7 +7638,7 @@ export function createControlPlaneApi(options) {
|
|
|
7204
7638
|
*/
|
|
7205
7639
|
app.get('/verticals/:slug/egress', async (c) => {
|
|
7206
7640
|
if (!options.observability?.observedEgress) {
|
|
7207
|
-
return c
|
|
7641
|
+
return refuseStatus(c, 501, 'observed egress is not available on this control plane');
|
|
7208
7642
|
}
|
|
7209
7643
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
7210
7644
|
const { hours, limit } = z
|
|
@@ -7275,12 +7709,12 @@ export function createControlPlaneApi(options) {
|
|
|
7275
7709
|
// can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
|
|
7276
7710
|
app.post('/push-tokens', async (c) => {
|
|
7277
7711
|
if (!options.pushTokenSecret) {
|
|
7278
|
-
return c
|
|
7712
|
+
return refuseStatus(c, 501, 'push tokens are not configured on this control plane');
|
|
7279
7713
|
}
|
|
7280
7714
|
const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
|
|
7281
7715
|
const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
|
|
7282
7716
|
if (!tenant)
|
|
7283
|
-
return c
|
|
7717
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
7284
7718
|
const token = await mintPushToken(options.pushTokenSecret, {
|
|
7285
7719
|
actor: await pushActorFor(tenantId),
|
|
7286
7720
|
tenantId,
|
|
@@ -7297,7 +7731,7 @@ export function createControlPlaneApi(options) {
|
|
|
7297
7731
|
// ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
|
|
7298
7732
|
app.post('/tenant-tokens', async (c) => {
|
|
7299
7733
|
if (!options.tenantTokenSecret) {
|
|
7300
|
-
return c
|
|
7734
|
+
return refuseStatus(c, 501, 'tenant tokens are not configured on this control plane');
|
|
7301
7735
|
}
|
|
7302
7736
|
const { tenantId, principal, impersonation } = z
|
|
7303
7737
|
.object({
|
|
@@ -7424,7 +7858,7 @@ export function createControlPlaneApi(options) {
|
|
|
7424
7858
|
// The body names the tenant the binding lands under; a confined principal may
|
|
7425
7859
|
// only name its own (the adapter then verifies the scope belongs to it, K-3).
|
|
7426
7860
|
if (input.tenantId !== bindPin)
|
|
7427
|
-
return c
|
|
7861
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
7428
7862
|
}
|
|
7429
7863
|
if (p.kind === 'builder') {
|
|
7430
7864
|
// The region column is an EU-residency claim (K-30) — never builder-suppliable.
|
|
@@ -7433,7 +7867,7 @@ export function createControlPlaneApi(options) {
|
|
|
7433
7867
|
// the binding sits, so widening this to #977's credential would be a behaviour
|
|
7434
7868
|
// change wearing a security fix's clothes.
|
|
7435
7869
|
if (input.region !== null) {
|
|
7436
|
-
return c
|
|
7870
|
+
return refuse(c, 'forbidden', 'region is derived from the scope, not chosen on a binding');
|
|
7437
7871
|
}
|
|
7438
7872
|
}
|
|
7439
7873
|
// Registrable-suffix guard (#305, D-35): a custom domain must be a real registrable
|
|
@@ -7442,7 +7876,7 @@ export function createControlPlaneApi(options) {
|
|
|
7442
7876
|
if (custom(input.hostname)) {
|
|
7443
7877
|
const bad = validateBindableHostname(input.hostname);
|
|
7444
7878
|
if (bad)
|
|
7445
|
-
return c
|
|
7879
|
+
return refuseStatus(c, 422, bad);
|
|
7446
7880
|
}
|
|
7447
7881
|
await c.var.admin.bindHostname(c.get('actor'), input);
|
|
7448
7882
|
const bound = (await c.var.admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
|
|
@@ -7459,11 +7893,11 @@ export function createControlPlaneApi(options) {
|
|
|
7459
7893
|
app.post('/hostnames/:hostname/verify', async (c) => {
|
|
7460
7894
|
const name = c.req.param('hostname');
|
|
7461
7895
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7462
|
-
return c
|
|
7896
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7463
7897
|
}
|
|
7464
7898
|
const row = await hostnameRow(c, name);
|
|
7465
7899
|
if (!row)
|
|
7466
|
-
return c
|
|
7900
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7467
7901
|
await runIssuance(c.var.admin, c.get('actor'), row);
|
|
7468
7902
|
return c.json(await hostnameRow(c, name));
|
|
7469
7903
|
});
|
|
@@ -7473,7 +7907,7 @@ export function createControlPlaneApi(options) {
|
|
|
7473
7907
|
// `setHostnameStatus` normalizes and 404s an unknown one below the seam.
|
|
7474
7908
|
const name = c.req.param('hostname');
|
|
7475
7909
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7476
|
-
return c
|
|
7910
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7477
7911
|
}
|
|
7478
7912
|
await c.var.admin.setHostnameStatus(c.get('actor'), name, status, note);
|
|
7479
7913
|
const row = (await c.var.admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
|
|
@@ -7487,7 +7921,7 @@ export function createControlPlaneApi(options) {
|
|
|
7487
7921
|
app.delete('/hostnames/:hostname', async (c) => {
|
|
7488
7922
|
const name = c.req.param('hostname');
|
|
7489
7923
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7490
|
-
return c
|
|
7924
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7491
7925
|
}
|
|
7492
7926
|
// Release the Cloudflare custom hostname before dropping the row, or the CF object
|
|
7493
7927
|
// leaks (billable, and it would block a future rebind of the same name). Best-effort:
|
|
@@ -7537,26 +7971,35 @@ export function createControlPlaneApi(options) {
|
|
|
7537
7971
|
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
7538
7972
|
/** Answers whether some of the preview's storage was stranded (and recorded as such). */
|
|
7539
7973
|
const reapPreview = async (c, preview) => {
|
|
7540
|
-
|
|
7974
|
+
const actor = c.get('actor');
|
|
7975
|
+
const dispatch = options.resolveVerticalRef;
|
|
7976
|
+
// #2285: every refusal that can be decided up front comes before anything is destroyed, so a
|
|
7977
|
+
// refused reap (a copy move in flight, a copy nothing can reach, stores that cannot be named)
|
|
7978
|
+
// leaves the preview routed, signing in, and whole.
|
|
7979
|
+
if (dispatch)
|
|
7980
|
+
await claimScopeScriptReap({ admin: c.var.admin, actor }, preview.tenantId, preview.id);
|
|
7981
|
+
else
|
|
7982
|
+
await assertNoUnreachableScopeCopies(c.var.admin, actor, preview.tenantId, preview.id);
|
|
7983
|
+
const wipeStores = await platformStoresWipe(c, preview);
|
|
7984
|
+
// A preview is still routed until its row goes: unroute before the wipe.
|
|
7985
|
+
await unrouteScope(c.var.admin, actor, preview);
|
|
7986
|
+
// A fork's own sign-in clients go once nothing routes to it, and before the row (#1704): an
|
|
7987
|
+
// unroute that fails leaves the preview signing in, and a failure here leaves the preview in
|
|
7541
7988
|
// place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
|
|
7542
7989
|
// would name those clients again. A clean room never had one.
|
|
7543
7990
|
await retireClientsOfReapedScope(previewAuthDeps(c), preview);
|
|
7991
|
+
await wipeStores();
|
|
7544
7992
|
let storageStranded = false;
|
|
7545
|
-
if (
|
|
7546
|
-
({ storageStranded } = await
|
|
7547
|
-
admin: c.var.admin,
|
|
7548
|
-
actor: c.get('actor'),
|
|
7549
|
-
resolveRef: options.resolveVerticalRef,
|
|
7550
|
-
}, preview.tenantId, preview.id));
|
|
7993
|
+
if (dispatch) {
|
|
7994
|
+
({ storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, preview.tenantId, preview.id));
|
|
7551
7995
|
}
|
|
7552
7996
|
else {
|
|
7553
|
-
await assertNoUnreachableScopeCopies(c.var.admin, c.get('actor'), preview.tenantId, preview.id);
|
|
7554
7997
|
const vertical = await verticalForScope(c, preview);
|
|
7555
7998
|
if (vertical)
|
|
7556
7999
|
await vertical.deleteScope({ tenantId: preview.tenantId, scopeId: preview.id });
|
|
7557
8000
|
}
|
|
7558
8001
|
// Co-located hosts have no script ref; their storage is wiped by deleteSnapshot.
|
|
7559
|
-
await c.var.host.deleteSnapshot(
|
|
8002
|
+
await c.var.host.deleteSnapshot(actor, preview.tenantId, preview.id);
|
|
7560
8003
|
return { storageStranded };
|
|
7561
8004
|
};
|
|
7562
8005
|
/** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
|
|
@@ -7990,23 +8433,23 @@ export function createControlPlaneApi(options) {
|
|
|
7990
8433
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
7991
8434
|
const v = await verticalOf(c.get('actor'), slug);
|
|
7992
8435
|
if (!v)
|
|
7993
|
-
return { error: `unknown vertical '${slug}'
|
|
8436
|
+
return { refused: 'not_found', error: `unknown vertical '${slug}'` };
|
|
7994
8437
|
if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
|
|
7995
|
-
return {
|
|
8438
|
+
return { refused: 'forbidden', error: 'forbidden' };
|
|
7996
8439
|
}
|
|
7997
8440
|
if (v.ownerTenant === null)
|
|
7998
|
-
return { error: `vertical '${slug}' has no owner tenant
|
|
8441
|
+
return { refused: 'conflict', error: `vertical '${slug}' has no owner tenant` };
|
|
7999
8442
|
return { tenantId: v.ownerTenant, slug };
|
|
8000
8443
|
};
|
|
8001
8444
|
app.post('/verticals/:slug/previews', async (c) => {
|
|
8002
8445
|
const gate = await previewVertical(c);
|
|
8003
8446
|
if ('error' in gate)
|
|
8004
|
-
return c
|
|
8447
|
+
return refuse(c, gate.refused, gate.error);
|
|
8005
8448
|
const { tenantId, slug } = gate;
|
|
8006
8449
|
const body = createPreviewBody.parse(await c.req.json());
|
|
8007
8450
|
const actor = c.get('actor');
|
|
8008
8451
|
if (body.empty && body.sourceScopeId) {
|
|
8009
|
-
return c
|
|
8452
|
+
return refuse(c, 'validation_failed', `pass either --empty (clean-room) or a sourceScopeId, not both`);
|
|
8010
8453
|
}
|
|
8011
8454
|
// Clean-room (#509 (b)): a null source provisions an EMPTY scope — no prod scope needed.
|
|
8012
8455
|
// Everything else forks the tenant's own active, non-fork scope for this vertical.
|
|
@@ -8020,12 +8463,12 @@ export function createControlPlaneApi(options) {
|
|
|
8020
8463
|
: null;
|
|
8021
8464
|
if (!source) {
|
|
8022
8465
|
if (body.sourceScopeId) {
|
|
8023
|
-
return c
|
|
8466
|
+
return refuse(c, 'not_found', `no active prod scope ${body.sourceScopeId} for '${slug}'`);
|
|
8024
8467
|
}
|
|
8025
8468
|
if (owned.length === 0) {
|
|
8026
|
-
return c
|
|
8469
|
+
return refuse(c, 'conflict', `no prod scope to fork for '${slug}' — provision one first, or pass empty:true for a clean-room preview`);
|
|
8027
8470
|
}
|
|
8028
|
-
return c
|
|
8471
|
+
return refuse(c, 'conflict', `'${slug}' has several prod scopes — pass sourceScopeId`, { scopes: owned.map((s) => s.id) });
|
|
8029
8472
|
}
|
|
8030
8473
|
}
|
|
8031
8474
|
try {
|
|
@@ -8034,14 +8477,14 @@ export function createControlPlaneApi(options) {
|
|
|
8034
8477
|
}
|
|
8035
8478
|
catch (e) {
|
|
8036
8479
|
if (e instanceof ControlPlaneError)
|
|
8037
|
-
return
|
|
8480
|
+
return relayRefusal(c, e);
|
|
8038
8481
|
throw e;
|
|
8039
8482
|
}
|
|
8040
8483
|
});
|
|
8041
8484
|
app.get('/verticals/:slug/previews', async (c) => {
|
|
8042
8485
|
const gate = await previewVertical(c);
|
|
8043
8486
|
if ('error' in gate)
|
|
8044
|
-
return c
|
|
8487
|
+
return refuse(c, gate.refused, gate.error);
|
|
8045
8488
|
const { tenantId, slug } = gate;
|
|
8046
8489
|
const actor = c.get('actor');
|
|
8047
8490
|
const previews = (await c.var.admin.listScopes(actor, { tenantId, vertical: slug }))
|
|
@@ -8067,7 +8510,7 @@ export function createControlPlaneApi(options) {
|
|
|
8067
8510
|
app.delete('/verticals/:slug/previews/:tag', async (c) => {
|
|
8068
8511
|
const gate = await previewVertical(c);
|
|
8069
8512
|
if ('error' in gate)
|
|
8070
|
-
return c
|
|
8513
|
+
return refuse(c, gate.refused, gate.error);
|
|
8071
8514
|
const { tenantId, slug } = gate;
|
|
8072
8515
|
const tag = c.req.param('tag');
|
|
8073
8516
|
const actor = c.get('actor');
|
|
@@ -8085,7 +8528,7 @@ export function createControlPlaneApi(options) {
|
|
|
8085
8528
|
}
|
|
8086
8529
|
catch (e) {
|
|
8087
8530
|
if (e instanceof ControlPlaneError)
|
|
8088
|
-
return
|
|
8531
|
+
return relayRefusal(c, e);
|
|
8089
8532
|
throw e;
|
|
8090
8533
|
}
|
|
8091
8534
|
});
|
|
@@ -8172,7 +8615,7 @@ export function createControlPlaneApi(options) {
|
|
|
8172
8615
|
// not be able to switch itself back on, and the dashboard has no flow that pulls it.
|
|
8173
8616
|
const switchScheduleRoute = (to) => async (c) => {
|
|
8174
8617
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8175
|
-
return c
|
|
8618
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8176
8619
|
}
|
|
8177
8620
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8178
8621
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -8180,7 +8623,7 @@ export function createControlPlaneApi(options) {
|
|
|
8180
8623
|
const actor = c.get('actor');
|
|
8181
8624
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8182
8625
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8183
|
-
return c
|
|
8626
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8184
8627
|
}
|
|
8185
8628
|
const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
|
|
8186
8629
|
return c.json(to === 'off' ? await c.var.admin.revokeFromSystem(actor, input) : await c.var.admin.restoreToSystem(actor, input));
|
|
@@ -8194,14 +8637,14 @@ export function createControlPlaneApi(options) {
|
|
|
8194
8637
|
// `confinedTenant` check, not a kind test.
|
|
8195
8638
|
app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
|
|
8196
8639
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8197
|
-
return c
|
|
8640
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8198
8641
|
}
|
|
8199
8642
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8200
8643
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8201
8644
|
const actor = c.get('actor');
|
|
8202
8645
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8203
8646
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8204
|
-
return c
|
|
8647
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8205
8648
|
}
|
|
8206
8649
|
return c.json(await c.var.admin.systemGrantsStatus(actor, { tenantId, scopeId }));
|
|
8207
8650
|
});
|
|
@@ -8216,7 +8659,7 @@ export function createControlPlaneApi(options) {
|
|
|
8216
8659
|
// it must not start answering if this path is ever allowlisted for a narrowed read.
|
|
8217
8660
|
app.get('/system-switches', async (c) => {
|
|
8218
8661
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8219
|
-
return c
|
|
8662
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8220
8663
|
}
|
|
8221
8664
|
const q = systemSwitchesQuery.parse({
|
|
8222
8665
|
position: c.req.query('position'),
|
|
@@ -8254,12 +8697,12 @@ export function createControlPlaneApi(options) {
|
|
|
8254
8697
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8255
8698
|
const pin = confinedTenant(c.get('principal'));
|
|
8256
8699
|
if (pin !== null && pin !== tenantId)
|
|
8257
|
-
return c
|
|
8700
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8258
8701
|
const body = peerSwitchBody.parse(await c.req.json());
|
|
8259
8702
|
const actor = c.get('actor');
|
|
8260
8703
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8261
8704
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8262
|
-
return c
|
|
8705
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8263
8706
|
}
|
|
8264
8707
|
const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
|
|
8265
8708
|
return c.json(to === 'off' ? await c.var.admin.revokeFromPeer(actor, input) : await c.var.admin.restoreToPeer(actor, input));
|
|
@@ -8274,10 +8717,10 @@ export function createControlPlaneApi(options) {
|
|
|
8274
8717
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8275
8718
|
const pin = confinedTenant(c.get('principal'));
|
|
8276
8719
|
if (pin !== null && pin !== tenantId)
|
|
8277
|
-
return c
|
|
8720
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8278
8721
|
const actor = c.get('actor');
|
|
8279
8722
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8280
|
-
return c
|
|
8723
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8281
8724
|
}
|
|
8282
8725
|
return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
|
|
8283
8726
|
});
|
|
@@ -8290,10 +8733,10 @@ export function createControlPlaneApi(options) {
|
|
|
8290
8733
|
const vertical = c.req.query('vertical');
|
|
8291
8734
|
const pin = confinedTenant(c.get('principal'));
|
|
8292
8735
|
if (pin !== null && pin !== tenantId)
|
|
8293
|
-
return c
|
|
8736
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8294
8737
|
const actor = c.get('actor');
|
|
8295
8738
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
8296
|
-
return c
|
|
8739
|
+
return refuse(c, 'not_found', 'app not found');
|
|
8297
8740
|
return c.json(vertical === undefined
|
|
8298
8741
|
? await c.var.admin.peerBindings(actor, tenantId, scopeId)
|
|
8299
8742
|
: await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
|
|
@@ -8305,11 +8748,11 @@ export function createControlPlaneApi(options) {
|
|
|
8305
8748
|
// The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
|
|
8306
8749
|
// choose a tenant's site on its behalf.
|
|
8307
8750
|
if (pin !== tenantId)
|
|
8308
|
-
return c
|
|
8751
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8309
8752
|
const body = peerBindingBody.parse(await c.req.json());
|
|
8310
8753
|
const actor = c.get('actor');
|
|
8311
8754
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
8312
|
-
return c
|
|
8755
|
+
return refuse(c, 'not_found', 'app not found');
|
|
8313
8756
|
const operationId = ulid();
|
|
8314
8757
|
const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
|
|
8315
8758
|
const done = await auditedChange({
|
|
@@ -8424,16 +8867,16 @@ export function createControlPlaneApi(options) {
|
|
|
8424
8867
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8425
8868
|
const pin = confinedTenant(c.get('principal'));
|
|
8426
8869
|
if (pin !== null && pin !== tenantId)
|
|
8427
|
-
return c
|
|
8870
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8428
8871
|
const raw = await c.req.json().catch(() => ({}));
|
|
8429
8872
|
const refused = importCursorAcknowledgementMissing(raw);
|
|
8430
8873
|
if (refused)
|
|
8431
|
-
return c
|
|
8874
|
+
return refuse(c, 'validation_failed', refused);
|
|
8432
8875
|
const move = importCursorMove.parse(raw);
|
|
8433
8876
|
const actor = c.get('actor');
|
|
8434
8877
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8435
8878
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8436
|
-
return c
|
|
8879
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8437
8880
|
}
|
|
8438
8881
|
return c.json(await c.var.admin.moveImportCursor(actor, tenantId, scopeId, move));
|
|
8439
8882
|
});
|
|
@@ -8446,7 +8889,7 @@ export function createControlPlaneApi(options) {
|
|
|
8446
8889
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8447
8890
|
const pin = confinedTenant(c.get('principal'));
|
|
8448
8891
|
if (pin !== null && pin !== tenantId)
|
|
8449
|
-
return c
|
|
8892
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8450
8893
|
const focus = c.req.query('scopeId');
|
|
8451
8894
|
return c.json(await crossVerticalHealth(c.var.host, {
|
|
8452
8895
|
actor: c.get('actor'),
|
|
@@ -8478,7 +8921,7 @@ export function createControlPlaneApi(options) {
|
|
|
8478
8921
|
const input = issueStatusUpdate.parse(await c.req.json());
|
|
8479
8922
|
const updated = await c.var.admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
|
|
8480
8923
|
if (!updated)
|
|
8481
|
-
return c
|
|
8924
|
+
return refuse(c, 'not_found', 'unknown issue fingerprint');
|
|
8482
8925
|
return c.json(updated);
|
|
8483
8926
|
});
|
|
8484
8927
|
// -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
|
|
@@ -8501,17 +8944,17 @@ export function createControlPlaneApi(options) {
|
|
|
8501
8944
|
app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
|
|
8502
8945
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8503
8946
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8504
|
-
return c
|
|
8947
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8505
8948
|
const { status } = findingStatusUpdate.parse(await c.req.json());
|
|
8506
8949
|
const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
|
|
8507
8950
|
if (!updated)
|
|
8508
|
-
return c
|
|
8951
|
+
return refuse(c, 'not_found', 'unknown finding');
|
|
8509
8952
|
return c.json(updated);
|
|
8510
8953
|
});
|
|
8511
8954
|
app.get('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8512
8955
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8513
8956
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8514
|
-
return c
|
|
8957
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8515
8958
|
const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
|
|
8516
8959
|
const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
|
|
8517
8960
|
return c.json({ entries });
|
|
@@ -8521,17 +8964,17 @@ export function createControlPlaneApi(options) {
|
|
|
8521
8964
|
app.post('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8522
8965
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8523
8966
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8524
|
-
return c
|
|
8967
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8525
8968
|
const input = findingRuleInput.parse(await c.req.json());
|
|
8526
8969
|
return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
|
|
8527
8970
|
});
|
|
8528
8971
|
app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
|
|
8529
8972
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8530
8973
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8531
|
-
return c
|
|
8974
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8532
8975
|
const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
|
|
8533
8976
|
if (!revoked)
|
|
8534
|
-
return c
|
|
8977
|
+
return refuse(c, 'not_found', 'unknown finding rule');
|
|
8535
8978
|
return c.json(revoked);
|
|
8536
8979
|
});
|
|
8537
8980
|
// -- model usage (#1054): meter 3, the one D-30 could not compute -------------
|
|
@@ -8567,6 +9010,166 @@ export function createControlPlaneApi(options) {
|
|
|
8567
9010
|
const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
|
|
8568
9011
|
return c.json(summary);
|
|
8569
9012
|
});
|
|
9013
|
+
// -- store-kind releases (D-63, #2300) ----------------------------------------
|
|
9014
|
+
// Staff only: none of these is on the builder or the tenant allowlist. A write is also made by a
|
|
9015
|
+
// person: `humanStaffActor` must name the same actor from a human staff sign-in, so the
|
|
9016
|
+
// automation token, which resolves to a staff actor as well, publishes nothing.
|
|
9017
|
+
const registeredKind = (kind) => {
|
|
9018
|
+
const registration = (options.storeKinds ?? STORE_KINDS).find((k) => k.kind === kind);
|
|
9019
|
+
if (!registration)
|
|
9020
|
+
throw new ControlPlaneError(403, `scope store kind '${kind}' is not registered`);
|
|
9021
|
+
return registration;
|
|
9022
|
+
};
|
|
9023
|
+
const releaseFlow = () => {
|
|
9024
|
+
if (!options.storeReleases || !options.ensureScopeStores) {
|
|
9025
|
+
throw new ControlPlaneError(501, 'this control plane keeps no store kind releases');
|
|
9026
|
+
}
|
|
9027
|
+
return { flow: options.storeReleases, ensureScopeStores: options.ensureScopeStores };
|
|
9028
|
+
};
|
|
9029
|
+
/**
|
|
9030
|
+
* A write made by a person, on purpose. The staff session also rides a cookie, which a browser
|
|
9031
|
+
* sends on a same-site request a page fires, so these writes take JSON only (a form or a
|
|
9032
|
+
* `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
|
|
9033
|
+
* browser marks as from another origin (403). A request with neither header is the CLI's.
|
|
9034
|
+
*/
|
|
9035
|
+
const byAPerson = async (c) => {
|
|
9036
|
+
if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
|
|
9037
|
+
throw new ControlPlaneError(415, 'a store kind release write takes application/json');
|
|
9038
|
+
}
|
|
9039
|
+
const origin = c.req.header('origin');
|
|
9040
|
+
const site = c.req.header('sec-fetch-site');
|
|
9041
|
+
if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
|
|
9042
|
+
throw new ControlPlaneError(403, 'a store kind release write is not taken from another origin');
|
|
9043
|
+
}
|
|
9044
|
+
const actor = c.get('actor');
|
|
9045
|
+
const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
|
|
9046
|
+
if (!person || person !== actor) {
|
|
9047
|
+
throw new ControlPlaneError(403, 'a store kind release is published by a person: this takes a staff sign-in, not a service token');
|
|
9048
|
+
}
|
|
9049
|
+
return actor;
|
|
9050
|
+
};
|
|
9051
|
+
/** The pin a release is run under, or a refusal: the platform runs only what this repo pins. */
|
|
9052
|
+
const pinOf = async (c, registration, sha256) => {
|
|
9053
|
+
const stored = await c.var.admin.getStoreKindRelease(c.get('actor'), registration.kind, sha256);
|
|
9054
|
+
if (!stored)
|
|
9055
|
+
throw new ControlPlaneError(404, `release ${sha256} of '${registration.kind}' is not stored`);
|
|
9056
|
+
const pin = registration.releases.find((p) => p.sha256 === sha256);
|
|
9057
|
+
if (!pin) {
|
|
9058
|
+
throw new ControlPlaneError(422, `release ${sha256} of '${registration.kind}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
|
|
9059
|
+
}
|
|
9060
|
+
if (pin.sourceCommit !== stored.sourceCommit) {
|
|
9061
|
+
throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
|
|
9062
|
+
}
|
|
9063
|
+
return pin;
|
|
9064
|
+
};
|
|
9065
|
+
const rollOut = async (c, registration, sha256, action) => {
|
|
9066
|
+
const { flow, ensureScopeStores } = releaseFlow();
|
|
9067
|
+
const actor = c.get('actor');
|
|
9068
|
+
const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
|
|
9069
|
+
const result = await runStoreKindRollout({
|
|
9070
|
+
admin: c.var.admin,
|
|
9071
|
+
actor,
|
|
9072
|
+
registration,
|
|
9073
|
+
sha256,
|
|
9074
|
+
action,
|
|
9075
|
+
ensure: async (input) => {
|
|
9076
|
+
const result = await ensureScopeStores({ ...input, loadRelease });
|
|
9077
|
+
return result ? result.uploaded > 0 : true;
|
|
9078
|
+
},
|
|
9079
|
+
ensureCanary: (to) => flow.ensureCanary({ kind: registration.kind, to, loadRelease }),
|
|
9080
|
+
callCanary: flow.callCanary,
|
|
9081
|
+
});
|
|
9082
|
+
return c.json(result, result.complete ? 200 : 202);
|
|
9083
|
+
};
|
|
9084
|
+
/** Neither a rollout nor a rollback lowers the stored schema: a kind migrates forward only. */
|
|
9085
|
+
const assertSchemaKept = (registration, from, to) => {
|
|
9086
|
+
if (from === null || from === to.sha256)
|
|
9087
|
+
return;
|
|
9088
|
+
const leaving = registration.releases.find((p) => p.sha256 === from);
|
|
9089
|
+
if (!leaving)
|
|
9090
|
+
throw new ControlPlaneError(409, `the current release ${from} of '${registration.kind}' is not pinned, so its schema cannot be judged`);
|
|
9091
|
+
if (to.schema < leaving.schema) {
|
|
9092
|
+
throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
|
|
9093
|
+
}
|
|
9094
|
+
};
|
|
9095
|
+
app.get('/store-kinds/:kind', async (c) => {
|
|
9096
|
+
const registration = registeredKind(c.req.param('kind'));
|
|
9097
|
+
const state = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
|
|
9098
|
+
return c.json({ kind: registration.kind, pins: registration.releases, ...state });
|
|
9099
|
+
});
|
|
9100
|
+
// Store a release's bytes, before or after its pin lands (D-63). Idempotent per bundle.
|
|
9101
|
+
app.put('/store-kinds/:kind/releases', async (c) => {
|
|
9102
|
+
const registration = registeredKind(c.req.param('kind'));
|
|
9103
|
+
const actor = await byAPerson(c);
|
|
9104
|
+
const { flow } = releaseFlow();
|
|
9105
|
+
const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
|
|
9106
|
+
const release = await storeKindRelease({ admin: c.var.admin, actor, bytes: flow.bytes, kind: registration.kind, body });
|
|
9107
|
+
return c.json({ release, pinned: registration.releases.some((p) => p.sha256 === release.sha256 && p.sourceCommit === release.sourceCommit) }, 201);
|
|
9108
|
+
});
|
|
9109
|
+
// Roll a stored, pinned release out: the canary, then every vertical that ever declared the
|
|
9110
|
+
// kind. Posting the same release again converges a rollout that stopped.
|
|
9111
|
+
app.post('/store-kinds/:kind/rollouts', async (c) => {
|
|
9112
|
+
const registration = registeredKind(c.req.param('kind'));
|
|
9113
|
+
await byAPerson(c);
|
|
9114
|
+
const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
|
|
9115
|
+
const pin = await pinOf(c, registration, sha256);
|
|
9116
|
+
const { current } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
|
|
9117
|
+
assertSchemaKept(registration, current?.sha256 ?? null, pin);
|
|
9118
|
+
return rollOut(c, registration, sha256, 'rollout');
|
|
9119
|
+
});
|
|
9120
|
+
// Return to the immediately previous good release (D-63), never further and never a guess. After
|
|
9121
|
+
// a rollout that ended diverged, that is the current good release itself: undoing the bad
|
|
9122
|
+
// rollout converges every script back on the release the fleet was healthy on (kernel
|
|
9123
|
+
// `storeKindStateIn`, `rollbackTarget`).
|
|
9124
|
+
app.post('/store-kinds/:kind/rollback', async (c) => {
|
|
9125
|
+
const registration = registeredKind(c.req.param('kind'));
|
|
9126
|
+
await byAPerson(c);
|
|
9127
|
+
const { current, rollbackTarget } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
|
|
9128
|
+
if (!current || !rollbackTarget)
|
|
9129
|
+
throw new ControlPlaneError(409, `kind '${registration.kind}' has no previous good release to roll back to`);
|
|
9130
|
+
const pin = await pinOf(c, registration, rollbackTarget);
|
|
9131
|
+
assertSchemaKept(registration, current.sha256, pin);
|
|
9132
|
+
return rollOut(c, registration, rollbackTarget, 'rollback');
|
|
9133
|
+
});
|
|
8570
9134
|
return app;
|
|
8571
9135
|
}
|
|
9136
|
+
/**
|
|
9137
|
+
* The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
|
|
9138
|
+
* for the JSON around it. A bigger body is refused (413) before it is read whole.
|
|
9139
|
+
*/
|
|
9140
|
+
const STORE_RELEASE_REQUEST_MAX_BYTES = Math.ceil((STORE_RELEASE_MAX_BYTES * 4) / 3) + 1024 * 1024;
|
|
9141
|
+
/** A JSON body read to at most `max` bytes: a declared length over it, or a stream past it, is 413. */
|
|
9142
|
+
async function readCappedJson(request, max) {
|
|
9143
|
+
const declared = Number(request.headers.get('content-length') ?? NaN);
|
|
9144
|
+
if (Number.isFinite(declared) && declared > max)
|
|
9145
|
+
throw new ControlPlaneError(413, `the body is over ${max} bytes`);
|
|
9146
|
+
if (!request.body)
|
|
9147
|
+
throw new ControlPlaneError(422, 'the body is empty');
|
|
9148
|
+
const reader = request.body.getReader();
|
|
9149
|
+
const chunks = [];
|
|
9150
|
+
let total = 0;
|
|
9151
|
+
for (;;) {
|
|
9152
|
+
const { done, value } = await reader.read();
|
|
9153
|
+
if (done)
|
|
9154
|
+
break;
|
|
9155
|
+
total += value.byteLength;
|
|
9156
|
+
if (total > max) {
|
|
9157
|
+
await reader.cancel().catch(() => undefined);
|
|
9158
|
+
throw new ControlPlaneError(413, `the body is over ${max} bytes`);
|
|
9159
|
+
}
|
|
9160
|
+
chunks.push(value);
|
|
9161
|
+
}
|
|
9162
|
+
const bytes = new Uint8Array(total);
|
|
9163
|
+
let offset = 0;
|
|
9164
|
+
for (const chunk of chunks) {
|
|
9165
|
+
bytes.set(chunk, offset);
|
|
9166
|
+
offset += chunk.byteLength;
|
|
9167
|
+
}
|
|
9168
|
+
try {
|
|
9169
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
9170
|
+
}
|
|
9171
|
+
catch {
|
|
9172
|
+
throw new ControlPlaneError(422, 'the body is not JSON');
|
|
9173
|
+
}
|
|
9174
|
+
}
|
|
8572
9175
|
//# sourceMappingURL=api.js.map
|