@substrat-run/control-plane-api 0.147.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 +56 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +801 -379
- package/dist/api.js.map +1 -1
- package/dist/deploy.d.ts +4 -3
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +3 -3
- 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 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- 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.generated.d.ts +1 -1
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +2 -2
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-scope-stores.d.ts +59 -6
- package/dist/platform-scope-stores.d.ts.map +1 -1
- package/dist/platform-scope-stores.js +117 -16
- package/dist/platform-scope-stores.js.map +1 -1
- 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/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 +4 -0
- 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/package.json +7 -7
package/dist/api.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { 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';
|
|
4
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';
|
|
@@ -8,8 +9,9 @@ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, ca
|
|
|
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 { scopeStoresOf } from './platform-scope-stores.js';
|
|
12
|
-
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';
|
|
13
15
|
import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
|
|
14
16
|
import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
|
|
15
17
|
import { scopeDeployment, versionReachedAt } from './scope-deployment.js';
|
|
@@ -18,7 +20,7 @@ import { reconcileOutcomeOf, reconcileThenReassert, reviewedEntityGrants, switch
|
|
|
18
20
|
import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
|
|
19
21
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
20
22
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
21
|
-
import { mapError } from './errors.js';
|
|
23
|
+
import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
|
|
22
24
|
import { maskDump, maskRecords } from './mask.js';
|
|
23
25
|
import { createPseudonymizer } from './pseudonymize.js';
|
|
24
26
|
import { openDump, sealDump } from './seal.js';
|
|
@@ -30,12 +32,30 @@ import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDe
|
|
|
30
32
|
* labelled `application/json` is not an RFC 9457 problem however well-shaped its body is,
|
|
31
33
|
* and `/openapi.json` has documented `application/problem+json` on every error response
|
|
32
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.
|
|
33
42
|
*/
|
|
34
|
-
function problem(c, answer) {
|
|
35
|
-
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, {
|
|
36
45
|
'content-type': PROBLEM_CONTENT_TYPE,
|
|
37
46
|
});
|
|
38
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
|
+
}
|
|
39
59
|
import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
|
|
40
60
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
41
61
|
import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
|
|
@@ -44,6 +64,8 @@ import { namespacesForScript } from './do-namespaces.js';
|
|
|
44
64
|
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
45
65
|
import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
|
|
46
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';
|
|
47
69
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
48
70
|
/**
|
|
49
71
|
* Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
|
|
@@ -104,9 +126,9 @@ class CarrySourceChanged extends ControlPlaneError {
|
|
|
104
126
|
this.attempts = attempts;
|
|
105
127
|
}
|
|
106
128
|
}
|
|
107
|
-
/**
|
|
108
|
-
function
|
|
109
|
-
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 } } : {};
|
|
110
132
|
}
|
|
111
133
|
/**
|
|
112
134
|
* A delegated facet answer, normalized — and refused when the vertical that produced it
|
|
@@ -782,11 +804,15 @@ function messageOfThrow(err) {
|
|
|
782
804
|
/** What a 5xx answered with no error object said: the JSON body's `error` (and `detail`), else the status. */
|
|
783
805
|
async function answeredMessage(res) {
|
|
784
806
|
const body = (await res.clone().json().catch(() => null));
|
|
785
|
-
const
|
|
786
|
-
const
|
|
787
|
-
|
|
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)
|
|
788
814
|
return `${error}: ${detail}`;
|
|
789
|
-
return error ??
|
|
815
|
+
return detail ?? error ?? str('title') ?? `HTTP ${res.status}`;
|
|
790
816
|
}
|
|
791
817
|
/**
|
|
792
818
|
* The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
|
|
@@ -819,33 +845,62 @@ export function createControlPlaneApi(options) {
|
|
|
819
845
|
const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
|
|
820
846
|
const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
|
|
821
847
|
/** The stores of the version the scope's deployment runs (`scopeStoresOf`, #2207). */
|
|
822
|
-
const manifestStores = (c, scope) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds);
|
|
848
|
+
const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds, running);
|
|
823
849
|
/**
|
|
824
850
|
* The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
|
|
825
851
|
* own export, restore, wipe and erasure carry its stores, and a scope that declares none has
|
|
826
852
|
* none to reach. A hosted control plane always passes a caller (`unreachablePlatformScopeStores`
|
|
827
853
|
* when it has no route), so a declared store is never skipped there.
|
|
828
854
|
*/
|
|
829
|
-
const platformStoresOf = async (c, scope) => {
|
|
855
|
+
const platformStoresOf = async (c, scope, running) => {
|
|
830
856
|
const caller = options.platformScopeStores;
|
|
831
857
|
if (!caller || !scope.vertical)
|
|
832
858
|
return undefined;
|
|
833
|
-
const stores = await manifestStores(c, scope);
|
|
859
|
+
const stores = await manifestStores(c, scope, running);
|
|
834
860
|
return stores.length > 0 ? { caller, vertical: scope.vertical, stores } : undefined;
|
|
835
861
|
};
|
|
836
|
-
/**
|
|
837
|
-
|
|
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) => {
|
|
838
867
|
const reach = await platformStoresOf(c, scope);
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
+
};
|
|
842
873
|
};
|
|
843
|
-
/** `substrat.
|
|
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
|
+
*/
|
|
844
886
|
const exportPlatformStores = async (c, scope) => {
|
|
845
|
-
const
|
|
846
|
-
if (!
|
|
887
|
+
const caller = options.platformScopeStores;
|
|
888
|
+
if (!caller || !scope.vertical)
|
|
847
889
|
return undefined;
|
|
848
|
-
|
|
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));
|
|
849
904
|
};
|
|
850
905
|
/**
|
|
851
906
|
* `substrat.erase` on the destination's stores for each subject tombstoned in the destination
|
|
@@ -901,7 +956,10 @@ export function createControlPlaneApi(options) {
|
|
|
901
956
|
return;
|
|
902
957
|
if (!scope.vertical)
|
|
903
958
|
throw new ControlPlaneError(422, 'restore refused: the dump names stores and the scope names no vertical');
|
|
904
|
-
|
|
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]));
|
|
905
963
|
for (const store of stores) {
|
|
906
964
|
const kind = declared.get(store.binding);
|
|
907
965
|
if (kind === undefined) {
|
|
@@ -913,6 +971,33 @@ export function createControlPlaneApi(options) {
|
|
|
913
971
|
}
|
|
914
972
|
};
|
|
915
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'));
|
|
916
1001
|
// The 5xx backstop: every 5xx this API answers lands an ops-failure row, however it was
|
|
917
1002
|
// answered. A 5xx is the PLATFORM failing (an unmapped throw, or a downstream vertical's own
|
|
918
1003
|
// 5xx passing through, #559), so the console must be able to list it and a `reference = <id>`
|
|
@@ -920,7 +1005,7 @@ export function createControlPlaneApi(options) {
|
|
|
920
1005
|
// that catch a ControlPlaneError and answer it themselves: a preview re-push whose bind failed
|
|
921
1006
|
// after a clean carry answered 502 with a reference and left no row behind.
|
|
922
1007
|
//
|
|
923
|
-
// Registered
|
|
1008
|
+
// Registered ahead of every middleware but the tripwire, so it wraps every route. 501 stays out: an honest
|
|
924
1009
|
// not-implemented is a capability statement, not a failure. 4xx stay out too: they are
|
|
925
1010
|
// refusals the caller can already read. Actor is unset only when the request failed before
|
|
926
1011
|
// authentication, where nothing worth recording refuses. A throw a step already recorded,
|
|
@@ -1013,7 +1098,7 @@ export function createControlPlaneApi(options) {
|
|
|
1013
1098
|
}
|
|
1014
1099
|
}
|
|
1015
1100
|
if (!principal)
|
|
1016
|
-
return c
|
|
1101
|
+
return refuse(c, 'unauthenticated', 'unauthenticated');
|
|
1017
1102
|
c.set('principal', principal);
|
|
1018
1103
|
c.set('actor', principal.actor);
|
|
1019
1104
|
// #977: a tenant credential minted for a person writes through a view that names
|
|
@@ -1127,7 +1212,7 @@ export function createControlPlaneApi(options) {
|
|
|
1127
1212
|
if (c.get('principal').kind === 'builder') {
|
|
1128
1213
|
const allowed = BUILDER_ROUTES.some((r) => r.method === c.req.method && r.re.test(c.req.path));
|
|
1129
1214
|
if (!allowed)
|
|
1130
|
-
return c
|
|
1215
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
1131
1216
|
}
|
|
1132
1217
|
await next();
|
|
1133
1218
|
});
|
|
@@ -1137,25 +1222,25 @@ export function createControlPlaneApi(options) {
|
|
|
1137
1222
|
return next();
|
|
1138
1223
|
const pin = tenantCredentialPin(c.req.method, c.req.path);
|
|
1139
1224
|
if (!pin)
|
|
1140
|
-
return c
|
|
1225
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
1141
1226
|
// The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
|
|
1142
1227
|
// included, which is what stops the pre-#977 shape — a fleet credential plus a
|
|
1143
1228
|
// caller-asserted tenant header — from surviving as a way to ask for another.
|
|
1144
1229
|
if (namedTenants(c).some((t) => t !== p.tenantId)) {
|
|
1145
|
-
return c
|
|
1230
|
+
return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
|
|
1146
1231
|
}
|
|
1147
1232
|
// The route's own pin must be PRESENT, not merely consistent. A missing filter is
|
|
1148
1233
|
// what makes these routes answer fleet-wide, so absence is the refusal.
|
|
1149
1234
|
if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
|
|
1150
|
-
return c
|
|
1235
|
+
return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
|
|
1151
1236
|
}
|
|
1152
1237
|
if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
|
|
1153
|
-
return c
|
|
1238
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1154
1239
|
}
|
|
1155
1240
|
if (pin === 'catalog') {
|
|
1156
1241
|
const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
|
|
1157
1242
|
if (named !== p.tenantId) {
|
|
1158
|
-
return c
|
|
1243
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1159
1244
|
}
|
|
1160
1245
|
}
|
|
1161
1246
|
if (pin === 'body') {
|
|
@@ -1165,7 +1250,7 @@ export function createControlPlaneApi(options) {
|
|
|
1165
1250
|
const body = (await c.req.json().catch(() => null));
|
|
1166
1251
|
const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
|
|
1167
1252
|
if (named !== p.tenantId) {
|
|
1168
|
-
return c
|
|
1253
|
+
return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
|
|
1169
1254
|
}
|
|
1170
1255
|
}
|
|
1171
1256
|
await next();
|
|
@@ -1235,6 +1320,8 @@ export function createControlPlaneApi(options) {
|
|
|
1235
1320
|
c.set('failure', e);
|
|
1236
1321
|
return e.status;
|
|
1237
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);
|
|
1238
1325
|
// Rides out a transient downstream window: the install path's binding-attach →
|
|
1239
1326
|
// script-settings propagation race (#424 case 2), and a one-shot DO storage blip
|
|
1240
1327
|
// during an export→restore or snapshot copy (#559 (2)). Retrying is cheap at THESE
|
|
@@ -1272,9 +1359,9 @@ export function createControlPlaneApi(options) {
|
|
|
1272
1359
|
return problem(c, { status: 400, body: toProblem(err, c.req.path) });
|
|
1273
1360
|
}
|
|
1274
1361
|
if (err instanceof ExportBreakRefused)
|
|
1275
|
-
return c.
|
|
1362
|
+
return refuse(c, 'conflict', err.message, exportBreaksOf(err));
|
|
1276
1363
|
if (err instanceof CarrySourceChanged) {
|
|
1277
|
-
return c
|
|
1364
|
+
return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
|
|
1278
1365
|
}
|
|
1279
1366
|
const { status, body } = mapError(err);
|
|
1280
1367
|
// The 5xx backstop (registered first, above) records it, with this throw as the cause.
|
|
@@ -1318,7 +1405,7 @@ export function createControlPlaneApi(options) {
|
|
|
1318
1405
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1319
1406
|
const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
|
|
1320
1407
|
if (!tenant)
|
|
1321
|
-
return c
|
|
1408
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
1322
1409
|
return c.json(tenant);
|
|
1323
1410
|
});
|
|
1324
1411
|
// Display rename only — the slug stays put (registry ids `<tenantSlug>/<name>` and
|
|
@@ -1355,11 +1442,9 @@ export function createControlPlaneApi(options) {
|
|
|
1355
1442
|
const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
|
|
1356
1443
|
const tenant = await c.var.admin.getTenant(actor, tenantId);
|
|
1357
1444
|
if (!tenant)
|
|
1358
|
-
return c
|
|
1445
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
1359
1446
|
if (tenant.status !== 'deleting') {
|
|
1360
|
-
return c.
|
|
1361
|
-
error: `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`,
|
|
1362
|
-
}, 409);
|
|
1447
|
+
return refuse(c, 'conflict', `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`);
|
|
1363
1448
|
}
|
|
1364
1449
|
try {
|
|
1365
1450
|
for (const scope of await c.var.admin.listScopes(actor, { tenantId })) {
|
|
@@ -1390,7 +1475,7 @@ export function createControlPlaneApi(options) {
|
|
|
1390
1475
|
}
|
|
1391
1476
|
catch (e) {
|
|
1392
1477
|
if (e instanceof ControlPlaneError) {
|
|
1393
|
-
return
|
|
1478
|
+
return relayRefusal(c, e);
|
|
1394
1479
|
}
|
|
1395
1480
|
throw e;
|
|
1396
1481
|
}
|
|
@@ -1412,10 +1497,10 @@ export function createControlPlaneApi(options) {
|
|
|
1412
1497
|
// `builder`, a plan-gated feature, a key no vertical asked for. And never the plan
|
|
1413
1498
|
// half: an expiry, a quota or a plan name is pricing, which is not the tenant's.
|
|
1414
1499
|
if (plan.expiresAt != null || plan.quota != null || plan.plan != null) {
|
|
1415
|
-
return c
|
|
1500
|
+
return refuse(c, 'forbidden', 'a tenant credential grants entitlements without plan fields');
|
|
1416
1501
|
}
|
|
1417
1502
|
if (!(await tenantGrantable(p, key))) {
|
|
1418
|
-
return c
|
|
1503
|
+
return refuse(c, 'forbidden', `entitlement '${key}' is not declared by a vertical this tenant can install`);
|
|
1419
1504
|
}
|
|
1420
1505
|
}
|
|
1421
1506
|
await c.var.admin.grantEntitlement(c.get('actor'), tenantId, key, plan);
|
|
@@ -1666,7 +1751,7 @@ export function createControlPlaneApi(options) {
|
|
|
1666
1751
|
// worse this week" is the question it exists for.
|
|
1667
1752
|
app.get('/connections/calls', async (c) => {
|
|
1668
1753
|
if (!options.observability?.connectorCallsSeries) {
|
|
1669
|
-
return c
|
|
1754
|
+
return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
|
|
1670
1755
|
}
|
|
1671
1756
|
const input = z
|
|
1672
1757
|
.object({
|
|
@@ -1683,7 +1768,7 @@ export function createControlPlaneApi(options) {
|
|
|
1683
1768
|
// credential can reach only this path, never the fleet-wide sibling above.
|
|
1684
1769
|
app.get('/tenants/:tenantId/connections/calls', async (c) => {
|
|
1685
1770
|
if (!options.observability?.connectorCallsSeries) {
|
|
1686
|
-
return c
|
|
1771
|
+
return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
|
|
1687
1772
|
}
|
|
1688
1773
|
const input = z.object({
|
|
1689
1774
|
tenantId: tenantIdSchema,
|
|
@@ -1780,7 +1865,7 @@ export function createControlPlaneApi(options) {
|
|
|
1780
1865
|
const raw = await c.req.json();
|
|
1781
1866
|
const body = typeof raw === 'object' && raw !== null ? raw : {};
|
|
1782
1867
|
if (body.tenantId !== undefined && body.tenantId !== tenantId) {
|
|
1783
|
-
return c
|
|
1868
|
+
return refuse(c, 'validation_failed', 'body tenantId disagrees with the route tenant');
|
|
1784
1869
|
}
|
|
1785
1870
|
try {
|
|
1786
1871
|
return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
|
|
@@ -1789,7 +1874,7 @@ export function createControlPlaneApi(options) {
|
|
|
1789
1874
|
if (err instanceof ConnectionRelayError && err.status < 500) {
|
|
1790
1875
|
// A refusal carries the provider's own answer, so a console can show WHY rather
|
|
1791
1876
|
// than "save failed" — the whole point of checking before writing.
|
|
1792
|
-
return c.
|
|
1877
|
+
return refuseStatus(c, err.status, err.message, err.probe ? { probe: err.probe } : undefined);
|
|
1793
1878
|
}
|
|
1794
1879
|
// A 5xx relay refusal (#603: this deployment holds no seal key) is the PLATFORM
|
|
1795
1880
|
// failing, not the caller — rethrown so the shared boundary maps it (still a 503
|
|
@@ -1808,7 +1893,7 @@ export function createControlPlaneApi(options) {
|
|
|
1808
1893
|
const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId, includeRevoked: true });
|
|
1809
1894
|
const row = rows.find((r) => r.id === id);
|
|
1810
1895
|
if (!row)
|
|
1811
|
-
return c
|
|
1896
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1812
1897
|
if (row.status !== 'revoked')
|
|
1813
1898
|
await c.var.admin.revokeConnection(c.get('actor'), row.id);
|
|
1814
1899
|
return c.body(null, 204);
|
|
@@ -1846,7 +1931,7 @@ export function createControlPlaneApi(options) {
|
|
|
1846
1931
|
scopeId,
|
|
1847
1932
|
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1848
1933
|
});
|
|
1849
|
-
return link ? c.json(link) : c
|
|
1934
|
+
return link ? c.json(link) : refuse(c, 'not_found', 'unknown connect link');
|
|
1850
1935
|
});
|
|
1851
1936
|
// The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
|
|
1852
1937
|
// "already used" is the answer to the question asked, and the callback renders each one.
|
|
@@ -1885,7 +1970,7 @@ export function createControlPlaneApi(options) {
|
|
|
1885
1970
|
scopeId,
|
|
1886
1971
|
id: connectLinkId.parse(c.req.param('linkId')),
|
|
1887
1972
|
});
|
|
1888
|
-
return link ? c.body(null, 204) : c
|
|
1973
|
+
return link ? c.body(null, 204) : refuse(c, 'not_found', 'unknown connect link');
|
|
1889
1974
|
});
|
|
1890
1975
|
/**
|
|
1891
1976
|
* The tenant-scoped connection lookup the two inspection routes share. Same K-3
|
|
@@ -1907,6 +1992,79 @@ export function createControlPlaneApi(options) {
|
|
|
1907
1992
|
const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId });
|
|
1908
1993
|
return rows.find((r) => r.id === id);
|
|
1909
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
|
+
});
|
|
1910
2068
|
/**
|
|
1911
2069
|
* **Verify a credential against the provider** (#605) — the read that turns "stored"
|
|
1912
2070
|
* into "works, and for this account".
|
|
@@ -1924,10 +2082,10 @@ export function createControlPlaneApi(options) {
|
|
|
1924
2082
|
app.post('/tenants/:tenantId/connections/:id/verify', async (c) => {
|
|
1925
2083
|
const row = await inspectableConnection(c);
|
|
1926
2084
|
if (!row)
|
|
1927
|
-
return c
|
|
2085
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1928
2086
|
const probe = options.connectionInspectors?.[row.provider]?.probe;
|
|
1929
2087
|
if (!probe) {
|
|
1930
|
-
return c
|
|
2088
|
+
return refuseStatus(c, 501, `no probe registered for provider '${row.provider}'`);
|
|
1931
2089
|
}
|
|
1932
2090
|
return c.json(connectionProbe.parse(await probe(c.var.host, row)));
|
|
1933
2091
|
});
|
|
@@ -1946,10 +2104,10 @@ export function createControlPlaneApi(options) {
|
|
|
1946
2104
|
app.get('/tenants/:tenantId/connections/:id/activity', async (c) => {
|
|
1947
2105
|
const row = await inspectableConnection(c);
|
|
1948
2106
|
if (!row)
|
|
1949
|
-
return c
|
|
2107
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1950
2108
|
const activity = options.connectionInspectors?.[row.provider]?.activity;
|
|
1951
2109
|
if (!activity) {
|
|
1952
|
-
return c
|
|
2110
|
+
return refuseStatus(c, 501, `no activity view registered for provider '${row.provider}'`);
|
|
1953
2111
|
}
|
|
1954
2112
|
const source = connectionActivitySource.catch('ledger').parse(c.req.query('source'));
|
|
1955
2113
|
const live = c.req.query('live') === '1';
|
|
@@ -1967,10 +2125,10 @@ export function createControlPlaneApi(options) {
|
|
|
1967
2125
|
app.get('/tenants/:tenantId/connections/:id/credential', async (c) => {
|
|
1968
2126
|
const row = await inspectableConnection(c);
|
|
1969
2127
|
if (!row)
|
|
1970
|
-
return c
|
|
2128
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1971
2129
|
const credential = options.connectionInspectors?.[row.provider]?.credential;
|
|
1972
2130
|
if (!credential) {
|
|
1973
|
-
return c
|
|
2131
|
+
return refuseStatus(c, 501, `no credential view registered for provider '${row.provider}'`);
|
|
1974
2132
|
}
|
|
1975
2133
|
return c.json(connectionCredential.parse(await credential(c.var.host, row)));
|
|
1976
2134
|
});
|
|
@@ -1982,10 +2140,10 @@ export function createControlPlaneApi(options) {
|
|
|
1982
2140
|
app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
|
|
1983
2141
|
const row = await inspectableConnection(c);
|
|
1984
2142
|
if (!row)
|
|
1985
|
-
return c
|
|
2143
|
+
return refuse(c, 'not_found', 'unknown connection');
|
|
1986
2144
|
const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
|
|
1987
2145
|
if (!certificate)
|
|
1988
|
-
return c
|
|
2146
|
+
return refuse(c, 'not_found', 'this connection signs in with no certificate');
|
|
1989
2147
|
return c.json(connectionCertificate.parse(certificate));
|
|
1990
2148
|
});
|
|
1991
2149
|
// -- the scope directory (§3.2/§4.2) ---------------------------------------
|
|
@@ -2043,7 +2201,7 @@ export function createControlPlaneApi(options) {
|
|
|
2043
2201
|
// A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
|
|
2044
2202
|
// never distinguished from "no such scope".
|
|
2045
2203
|
if (outsideTenant(p, tenantId)) {
|
|
2046
|
-
return c
|
|
2204
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})`);
|
|
2047
2205
|
}
|
|
2048
2206
|
// The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
|
|
2049
2207
|
// platform-intent drain's `provision-sibling` handler (platform-intents.md B2).
|
|
@@ -2057,12 +2215,12 @@ export function createControlPlaneApi(options) {
|
|
|
2057
2215
|
owner: input.owner,
|
|
2058
2216
|
});
|
|
2059
2217
|
if (!result.ok)
|
|
2060
|
-
return c
|
|
2218
|
+
return refuseStatus(c, result.status, result.error);
|
|
2061
2219
|
return c.json(await c.var.admin.getScopeRecord(actor, tenantId, input.scopeId), 201);
|
|
2062
2220
|
}
|
|
2063
2221
|
catch (e) {
|
|
2064
2222
|
if (e instanceof ControlPlaneError)
|
|
2065
|
-
return
|
|
2223
|
+
return relayRefusal(c, e);
|
|
2066
2224
|
throw e;
|
|
2067
2225
|
}
|
|
2068
2226
|
});
|
|
@@ -2072,12 +2230,12 @@ export function createControlPlaneApi(options) {
|
|
|
2072
2230
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
2073
2231
|
const p = c.get('principal');
|
|
2074
2232
|
if (outsideTenant(p, tenantId)) {
|
|
2075
|
-
return c
|
|
2233
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2076
2234
|
}
|
|
2077
2235
|
const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
2078
2236
|
// Absent, or present under another tenant — indistinguishable on purpose (K-3).
|
|
2079
2237
|
if (!record)
|
|
2080
|
-
return c
|
|
2238
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2081
2239
|
return c.json(record);
|
|
2082
2240
|
});
|
|
2083
2241
|
// Read-only introspection of a scope's own database (kernel-design §5.4 admin-query
|
|
@@ -3023,7 +3181,7 @@ export function createControlPlaneApi(options) {
|
|
|
3023
3181
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3024
3182
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3025
3183
|
if (!scope)
|
|
3026
|
-
return c
|
|
3184
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3027
3185
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
|
|
3028
3186
|
viaVertical: (v) => v.listScopeTables(scopeId),
|
|
3029
3187
|
colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
|
|
@@ -3053,11 +3211,11 @@ export function createControlPlaneApi(options) {
|
|
|
3053
3211
|
// A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
|
|
3054
3212
|
const principal = c.get('principal');
|
|
3055
3213
|
if (outsideTenant(principal, tenantId)) {
|
|
3056
|
-
return c
|
|
3214
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3057
3215
|
}
|
|
3058
3216
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3059
3217
|
if (!scope)
|
|
3060
|
-
return c
|
|
3218
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3061
3219
|
// Health reads the scope the same way the Data view does, so it leaves the same
|
|
3062
3220
|
// row: an auditor should not be able to tell which route reached the tables.
|
|
3063
3221
|
const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
|
|
@@ -3105,7 +3263,7 @@ export function createControlPlaneApi(options) {
|
|
|
3105
3263
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3106
3264
|
const principal = c.get('principal');
|
|
3107
3265
|
if (outsideTenant(principal, tenantId)) {
|
|
3108
|
-
return c
|
|
3266
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3109
3267
|
}
|
|
3110
3268
|
const filter = platformRequestFilter.parse({
|
|
3111
3269
|
kind: c.req.query('kind'),
|
|
@@ -3114,7 +3272,7 @@ export function createControlPlaneApi(options) {
|
|
|
3114
3272
|
});
|
|
3115
3273
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3116
3274
|
if (!scope)
|
|
3117
|
-
return c
|
|
3275
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3118
3276
|
// Same delegation ladder as the table reads: the intents live in the DO of the scope's BOUND
|
|
3119
3277
|
// version, so a hosted scope is asked through its vertical and a co-located one locally.
|
|
3120
3278
|
const vertical = await verticalForScope(c, scope);
|
|
@@ -3135,17 +3293,17 @@ export function createControlPlaneApi(options) {
|
|
|
3135
3293
|
const actor = c.get('actor');
|
|
3136
3294
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3137
3295
|
if (!scope)
|
|
3138
|
-
return c
|
|
3296
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3139
3297
|
const p = c.get('principal');
|
|
3140
3298
|
const scopePin = confinedTenant(p);
|
|
3141
3299
|
if (scopePin) {
|
|
3142
3300
|
const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
|
|
3143
3301
|
if (!v || v.ownerTenant !== scopePin)
|
|
3144
|
-
return c
|
|
3302
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
3145
3303
|
}
|
|
3146
3304
|
const reached = await deploymentForScope(c, scope);
|
|
3147
3305
|
if (!reached)
|
|
3148
|
-
return c
|
|
3306
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3149
3307
|
const vertical = reached.client;
|
|
3150
3308
|
const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
|
|
3151
3309
|
// #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
|
|
@@ -3299,7 +3457,7 @@ export function createControlPlaneApi(options) {
|
|
|
3299
3457
|
// unbound store is exactly what makes a vertical refuse its own reconcile), so
|
|
3300
3458
|
// it must not be the half that gets dropped.
|
|
3301
3459
|
const detail = storeErrors.length ? `${e.message} (stores: ${storeErrors.join('; ')})` : e.message;
|
|
3302
|
-
return c
|
|
3460
|
+
return refuseStatus(c, failedWith(c, e), detail);
|
|
3303
3461
|
}
|
|
3304
3462
|
throw e;
|
|
3305
3463
|
}
|
|
@@ -3321,7 +3479,7 @@ export function createControlPlaneApi(options) {
|
|
|
3321
3479
|
});
|
|
3322
3480
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3323
3481
|
if (!scope)
|
|
3324
|
-
return c
|
|
3482
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3325
3483
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
|
|
3326
3484
|
// #1762: only the vertical's answer needs the guard. The co-located read runs
|
|
3327
3485
|
// this platform's own kernel, which withholds by construction.
|
|
@@ -3390,7 +3548,7 @@ export function createControlPlaneApi(options) {
|
|
|
3390
3548
|
});
|
|
3391
3549
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3392
3550
|
if (!scope)
|
|
3393
|
-
return c
|
|
3551
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3394
3552
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
|
|
3395
3553
|
viaVertical: (v) => v.eventCause(scopeId, input),
|
|
3396
3554
|
colocated: () => c.var.admin.eventCause(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3406,7 +3564,7 @@ export function createControlPlaneApi(options) {
|
|
|
3406
3564
|
});
|
|
3407
3565
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3408
3566
|
if (!scope)
|
|
3409
|
-
return c
|
|
3567
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3410
3568
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
|
|
3411
3569
|
viaVertical: (v) => v.eventEffects(scopeId, input),
|
|
3412
3570
|
colocated: () => c.var.admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3423,7 +3581,7 @@ export function createControlPlaneApi(options) {
|
|
|
3423
3581
|
});
|
|
3424
3582
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3425
3583
|
if (!scope)
|
|
3426
|
-
return c
|
|
3584
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3427
3585
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'invocationEvents', input, {
|
|
3428
3586
|
viaVertical: (v) => v.invocationEvents(scopeId, input),
|
|
3429
3587
|
colocated: () => c.var.admin.invocationEvents(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3440,7 +3598,7 @@ export function createControlPlaneApi(options) {
|
|
|
3440
3598
|
});
|
|
3441
3599
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3442
3600
|
if (!scope)
|
|
3443
|
-
return c
|
|
3601
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3444
3602
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'deadLetters', input, {
|
|
3445
3603
|
viaVertical: (v) => v.deadLetters(scopeId, input),
|
|
3446
3604
|
colocated: () => c.var.admin.deadLetters(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3455,7 +3613,7 @@ export function createControlPlaneApi(options) {
|
|
|
3455
3613
|
const input = lifecycleFlowInput.parse(await c.req.json());
|
|
3456
3614
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3457
3615
|
if (!scope)
|
|
3458
|
-
return c
|
|
3616
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3459
3617
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'lifecycleFlow', input, {
|
|
3460
3618
|
viaVertical: (v) => v.lifecycleFlow(scopeId, input),
|
|
3461
3619
|
colocated: () => c.var.admin.lifecycleFlow(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3470,7 +3628,7 @@ export function createControlPlaneApi(options) {
|
|
|
3470
3628
|
const input = operationSeriesInput.parse(await c.req.json());
|
|
3471
3629
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3472
3630
|
if (!scope)
|
|
3473
|
-
return c
|
|
3631
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3474
3632
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'operationSeries', input, {
|
|
3475
3633
|
viaVertical: (v) => v.operationSeries(scopeId, input),
|
|
3476
3634
|
colocated: () => c.var.admin.operationSeries(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3490,7 +3648,7 @@ export function createControlPlaneApi(options) {
|
|
|
3490
3648
|
});
|
|
3491
3649
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3492
3650
|
if (!scope)
|
|
3493
|
-
return c
|
|
3651
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3494
3652
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
|
|
3495
3653
|
viaVertical: (v) => v.entityHistory(scopeId, input),
|
|
3496
3654
|
colocated: () => c.var.admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3506,7 +3664,7 @@ export function createControlPlaneApi(options) {
|
|
|
3506
3664
|
});
|
|
3507
3665
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3508
3666
|
if (!scope)
|
|
3509
|
-
return c
|
|
3667
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3510
3668
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
|
|
3511
3669
|
viaVertical: (v) => v.readScopeTable(scopeId, input),
|
|
3512
3670
|
colocated: () => c.var.admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
|
|
@@ -3523,7 +3681,7 @@ export function createControlPlaneApi(options) {
|
|
|
3523
3681
|
const input = queryScopeInput.parse(await c.req.json());
|
|
3524
3682
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3525
3683
|
if (!scope)
|
|
3526
|
-
return c
|
|
3684
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3527
3685
|
return c.json(await delegatedRead(
|
|
3528
3686
|
// The SQL is the argument the co-located branch logs, so it is the argument
|
|
3529
3687
|
// here — an audit row for the read-only console that did not carry the
|
|
@@ -3549,12 +3707,12 @@ export function createControlPlaneApi(options) {
|
|
|
3549
3707
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3550
3708
|
const principal = c.get('principal');
|
|
3551
3709
|
if (outsideTenant(principal, tenantId)) {
|
|
3552
|
-
return c
|
|
3710
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3553
3711
|
}
|
|
3554
3712
|
const filter = denialLogQuery.parse(c.req.query());
|
|
3555
3713
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3556
3714
|
if (!scope)
|
|
3557
|
-
return c
|
|
3715
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3558
3716
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
|
|
3559
3717
|
viaVertical: (v) => v.listDenials(scopeId, filter),
|
|
3560
3718
|
colocated: () => c.var.admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3565,12 +3723,12 @@ export function createControlPlaneApi(options) {
|
|
|
3565
3723
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3566
3724
|
const principal = c.get('principal');
|
|
3567
3725
|
if (outsideTenant(principal, tenantId)) {
|
|
3568
|
-
return c
|
|
3726
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3569
3727
|
}
|
|
3570
3728
|
const filter = denialLogQuery.parse(c.req.query());
|
|
3571
3729
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3572
3730
|
if (!scope)
|
|
3573
|
-
return c
|
|
3731
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3574
3732
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
|
|
3575
3733
|
viaVertical: (v) => v.summarizeDenials(scopeId, filter),
|
|
3576
3734
|
colocated: () => c.var.admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3583,14 +3741,14 @@ export function createControlPlaneApi(options) {
|
|
|
3583
3741
|
// does not rest on which list the route is absent from. Delegated like the denial log.
|
|
3584
3742
|
app.get('/tenants/:tenantId/scopes/:scopeId/capabilities', async (c) => {
|
|
3585
3743
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3586
|
-
return c
|
|
3744
|
+
return refuse(c, 'forbidden', 'forbidden: the capability directory is staff-only');
|
|
3587
3745
|
}
|
|
3588
3746
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3589
3747
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3590
3748
|
const filter = capabilityFilterQuery.parse(c.req.query());
|
|
3591
3749
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3592
3750
|
if (!scope)
|
|
3593
|
-
return c
|
|
3751
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3594
3752
|
return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listCapabilities', filter, {
|
|
3595
3753
|
viaVertical: (v) => v.listCapabilities(scopeId, filter),
|
|
3596
3754
|
colocated: () => c.var.admin.listCapabilities(c.get('actor'), tenantId, scopeId, filter),
|
|
@@ -3604,7 +3762,7 @@ export function createControlPlaneApi(options) {
|
|
|
3604
3762
|
// the scope does not hold is 404; a deployment built before the far end answers 501 "redeploy".
|
|
3605
3763
|
app.post('/tenants/:tenantId/scopes/:scopeId/capabilities/:capabilityId/revoke', async (c) => {
|
|
3606
3764
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3607
|
-
return c
|
|
3765
|
+
return refuse(c, 'forbidden', 'forbidden: revoking a capability is staff-only');
|
|
3608
3766
|
}
|
|
3609
3767
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3610
3768
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3625,20 +3783,20 @@ export function createControlPlaneApi(options) {
|
|
|
3625
3783
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3626
3784
|
const principal = c.get('principal');
|
|
3627
3785
|
if (outsideTenant(principal, tenantId)) {
|
|
3628
|
-
return c
|
|
3786
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3629
3787
|
}
|
|
3630
3788
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3631
3789
|
if (!scope)
|
|
3632
|
-
return c
|
|
3790
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3633
3791
|
const vertical = await verticalForScope(c, scope);
|
|
3634
3792
|
if (!vertical)
|
|
3635
|
-
return c
|
|
3793
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3636
3794
|
try {
|
|
3637
3795
|
return c.json(await vertical.ownerSeat(tenantId, scopeId));
|
|
3638
3796
|
}
|
|
3639
3797
|
catch (e) {
|
|
3640
3798
|
if (e instanceof ControlPlaneError)
|
|
3641
|
-
return
|
|
3799
|
+
return relayRefusal(c, e);
|
|
3642
3800
|
throw e;
|
|
3643
3801
|
}
|
|
3644
3802
|
});
|
|
@@ -3660,25 +3818,25 @@ export function createControlPlaneApi(options) {
|
|
|
3660
3818
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
3661
3819
|
const principal = c.get('principal');
|
|
3662
3820
|
if (outsideTenant(principal, tenantId)) {
|
|
3663
|
-
return c
|
|
3821
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3664
3822
|
}
|
|
3665
3823
|
const actor = c.get('actor');
|
|
3666
3824
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3667
3825
|
if (!scope)
|
|
3668
|
-
return c
|
|
3826
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3669
3827
|
const vertical = await verticalForScope(c, scope);
|
|
3670
3828
|
if (!vertical)
|
|
3671
|
-
return c
|
|
3829
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
3672
3830
|
const origin = await instanceOrigin(c.var.admin, actor, scopeId);
|
|
3673
3831
|
if (!origin) {
|
|
3674
|
-
return c
|
|
3832
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
|
|
3675
3833
|
}
|
|
3676
3834
|
try {
|
|
3677
3835
|
return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
|
|
3678
3836
|
}
|
|
3679
3837
|
catch (e) {
|
|
3680
3838
|
if (e instanceof ControlPlaneError)
|
|
3681
|
-
return
|
|
3839
|
+
return relayRefusal(c, e);
|
|
3682
3840
|
throw e;
|
|
3683
3841
|
}
|
|
3684
3842
|
});
|
|
@@ -3706,10 +3864,10 @@ export function createControlPlaneApi(options) {
|
|
|
3706
3864
|
// K-3: another tenant's pair, or no such scope, reads the same — absent.
|
|
3707
3865
|
const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3708
3866
|
if (!scope)
|
|
3709
|
-
return { refusal: c
|
|
3867
|
+
return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
|
|
3710
3868
|
const vertical = await verticalForScope(c, scope);
|
|
3711
3869
|
if (!vertical)
|
|
3712
|
-
return { refusal: c
|
|
3870
|
+
return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
|
|
3713
3871
|
return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
|
|
3714
3872
|
};
|
|
3715
3873
|
/**
|
|
@@ -3724,7 +3882,7 @@ export function createControlPlaneApi(options) {
|
|
|
3724
3882
|
const { operationId } = done;
|
|
3725
3883
|
if ('error' in done) {
|
|
3726
3884
|
if (done.error instanceof ControlPlaneError) {
|
|
3727
|
-
return c
|
|
3885
|
+
return relayRefusal(c, done.error, { operationId });
|
|
3728
3886
|
}
|
|
3729
3887
|
throw done.error;
|
|
3730
3888
|
}
|
|
@@ -3763,7 +3921,7 @@ export function createControlPlaneApi(options) {
|
|
|
3763
3921
|
}
|
|
3764
3922
|
catch (e) {
|
|
3765
3923
|
if (e instanceof ControlPlaneError)
|
|
3766
|
-
return
|
|
3924
|
+
return relayRefusal(c, e);
|
|
3767
3925
|
throw e;
|
|
3768
3926
|
}
|
|
3769
3927
|
});
|
|
@@ -3772,11 +3930,11 @@ export function createControlPlaneApi(options) {
|
|
|
3772
3930
|
if ('refusal' in target)
|
|
3773
3931
|
return target.refusal;
|
|
3774
3932
|
if (!target.person)
|
|
3775
|
-
return c
|
|
3933
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3776
3934
|
const body = memberInviteInput.parse(await c.req.json());
|
|
3777
3935
|
const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
|
|
3778
3936
|
if (!origin) {
|
|
3779
|
-
return c
|
|
3937
|
+
return refuse(c, 'conflict', `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on`);
|
|
3780
3938
|
}
|
|
3781
3939
|
const { tenantId, scopeId, person } = target;
|
|
3782
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 }));
|
|
@@ -3787,7 +3945,7 @@ export function createControlPlaneApi(options) {
|
|
|
3787
3945
|
if ('refusal' in target)
|
|
3788
3946
|
return target.refusal;
|
|
3789
3947
|
if (!target.person)
|
|
3790
|
-
return c
|
|
3948
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3791
3949
|
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3792
3950
|
const body = memberRoleInput.parse(await c.req.json());
|
|
3793
3951
|
const { tenantId, scopeId, person } = target;
|
|
@@ -3799,7 +3957,7 @@ export function createControlPlaneApi(options) {
|
|
|
3799
3957
|
if ('refusal' in target)
|
|
3800
3958
|
return target.refusal;
|
|
3801
3959
|
if (!target.person)
|
|
3802
|
-
return c
|
|
3960
|
+
return refuse(c, 'forbidden', NO_PERSON);
|
|
3803
3961
|
const member = principalIdSchema.parse(c.req.param('principal'));
|
|
3804
3962
|
const { tenantId, scopeId, person } = target;
|
|
3805
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 }));
|
|
@@ -3817,7 +3975,7 @@ export function createControlPlaneApi(options) {
|
|
|
3817
3975
|
// and a team credential cannot show it is that person asking.
|
|
3818
3976
|
app.post('/tenants/:tenantId/scopes/:scopeId/owner-transfer', async (c) => {
|
|
3819
3977
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
3820
|
-
return c
|
|
3978
|
+
return refuse(c, 'forbidden', 'forbidden: an owner hand-over is staff-only');
|
|
3821
3979
|
}
|
|
3822
3980
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
3823
3981
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -3826,10 +3984,10 @@ export function createControlPlaneApi(options) {
|
|
|
3826
3984
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
3827
3985
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
3828
3986
|
if (!scope)
|
|
3829
|
-
return c
|
|
3987
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3830
3988
|
const vertical = await verticalForScope(c, scope);
|
|
3831
3989
|
if (!vertical)
|
|
3832
|
-
return c
|
|
3990
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
3833
3991
|
const operationId = ulid();
|
|
3834
3992
|
// An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
|
|
3835
3993
|
// a hand-over closed without finishing from one that finished.
|
|
@@ -3862,17 +4020,17 @@ export function createControlPlaneApi(options) {
|
|
|
3862
4020
|
const input = configureInstanceBody.parse(await c.req.json());
|
|
3863
4021
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
3864
4022
|
if (!scope)
|
|
3865
|
-
return c
|
|
4023
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3866
4024
|
const vertical = await verticalForScope(c, scope);
|
|
3867
4025
|
if (!vertical) {
|
|
3868
|
-
return c
|
|
4026
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
|
|
3869
4027
|
}
|
|
3870
4028
|
try {
|
|
3871
4029
|
await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
|
|
3872
4030
|
}
|
|
3873
4031
|
catch (e) {
|
|
3874
4032
|
if (e instanceof ControlPlaneError) {
|
|
3875
|
-
return
|
|
4033
|
+
return relayRefusal(c, e);
|
|
3876
4034
|
}
|
|
3877
4035
|
throw e;
|
|
3878
4036
|
}
|
|
@@ -3929,7 +4087,7 @@ export function createControlPlaneApi(options) {
|
|
|
3929
4087
|
message: error,
|
|
3930
4088
|
}, failure.cause);
|
|
3931
4089
|
c.set('failureRecorded', true);
|
|
3932
|
-
return c
|
|
4090
|
+
return refuse(c, 'unavailable', error);
|
|
3933
4091
|
}
|
|
3934
4092
|
}
|
|
3935
4093
|
}
|
|
@@ -4065,7 +4223,7 @@ export function createControlPlaneApi(options) {
|
|
|
4065
4223
|
const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
4066
4224
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4067
4225
|
if (!scope)
|
|
4068
|
-
return c
|
|
4226
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4069
4227
|
try {
|
|
4070
4228
|
const { snapId, replay } = await orchestratedSnapshot(c, tenantId, scope, body);
|
|
4071
4229
|
const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, snapId);
|
|
@@ -4074,7 +4232,7 @@ export function createControlPlaneApi(options) {
|
|
|
4074
4232
|
}
|
|
4075
4233
|
catch (e) {
|
|
4076
4234
|
if (e instanceof ControlPlaneError) {
|
|
4077
|
-
return
|
|
4235
|
+
return relayRefusal(c, e);
|
|
4078
4236
|
}
|
|
4079
4237
|
throw e;
|
|
4080
4238
|
}
|
|
@@ -4120,9 +4278,20 @@ export function createControlPlaneApi(options) {
|
|
|
4120
4278
|
*/
|
|
4121
4279
|
const reapScopeStorage = async (c, scope) => {
|
|
4122
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);
|
|
4123
4292
|
// #2207: the stores first, on every path below: a routed scope's copies are reaped through
|
|
4124
4293
|
// their scripts, and a store is per scope, not per script.
|
|
4125
|
-
await
|
|
4294
|
+
await wipeStores();
|
|
4126
4295
|
// The deployment the scope's vertical resolves to, deleted through; a 501 there is recorded
|
|
4127
4296
|
// as stranded storage, as `reapScopeScriptCopies` records its own.
|
|
4128
4297
|
const viaDeployment = async () => {
|
|
@@ -4132,13 +4301,11 @@ export function createControlPlaneApi(options) {
|
|
|
4132
4301
|
}
|
|
4133
4302
|
return stranded;
|
|
4134
4303
|
};
|
|
4135
|
-
if (!
|
|
4136
|
-
await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
|
|
4304
|
+
if (!dispatch)
|
|
4137
4305
|
return viaDeployment();
|
|
4138
|
-
|
|
4139
|
-
const
|
|
4140
|
-
const
|
|
4141
|
-
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;
|
|
4142
4309
|
return storageStranded || bySlug;
|
|
4143
4310
|
};
|
|
4144
4311
|
app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
|
|
@@ -4147,9 +4314,9 @@ export function createControlPlaneApi(options) {
|
|
|
4147
4314
|
const actor = c.get('actor');
|
|
4148
4315
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4149
4316
|
if (!scope)
|
|
4150
|
-
return c
|
|
4317
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4151
4318
|
if (!scope.forkedFrom) {
|
|
4152
|
-
return c
|
|
4319
|
+
return refuse(c, 'conflict', `scope ${scopeId} is not a fork — only snapshots may be deleted`);
|
|
4153
4320
|
}
|
|
4154
4321
|
try {
|
|
4155
4322
|
// Vertical's storage first, then the in-process delete (refusal re-check,
|
|
@@ -4162,7 +4329,7 @@ export function createControlPlaneApi(options) {
|
|
|
4162
4329
|
}
|
|
4163
4330
|
catch (e) {
|
|
4164
4331
|
if (e instanceof ControlPlaneError) {
|
|
4165
|
-
return
|
|
4332
|
+
return relayRefusal(c, e);
|
|
4166
4333
|
}
|
|
4167
4334
|
throw e;
|
|
4168
4335
|
}
|
|
@@ -4211,12 +4378,12 @@ export function createControlPlaneApi(options) {
|
|
|
4211
4378
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
4212
4379
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4213
4380
|
if (!options.scopeBackups)
|
|
4214
|
-
return c
|
|
4381
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
4215
4382
|
// Reads the directory, not the store, for the tenant cross-check: the store is keyed
|
|
4216
4383
|
// by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
|
|
4217
4384
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4218
4385
|
if (!scope)
|
|
4219
|
-
return c
|
|
4386
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4220
4387
|
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
4221
4388
|
});
|
|
4222
4389
|
// One backup's DUMP — the restore source. Staff-only like the export it came from
|
|
@@ -4227,13 +4394,13 @@ export function createControlPlaneApi(options) {
|
|
|
4227
4394
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4228
4395
|
const capturedAt = c.req.param('capturedAt');
|
|
4229
4396
|
if (!options.scopeBackups)
|
|
4230
|
-
return c
|
|
4397
|
+
return refuseStatus(c, 501, 'no backup target configured');
|
|
4231
4398
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4232
4399
|
if (!scope)
|
|
4233
|
-
return c
|
|
4400
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4234
4401
|
const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
4235
4402
|
if (!dump)
|
|
4236
|
-
return c
|
|
4403
|
+
return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
|
|
4237
4404
|
// Unseal on the way out (#37). The bytes in the store stay sealed — this is the
|
|
4238
4405
|
// authorized read opening them with the keys the platform holds. A subject shredded
|
|
4239
4406
|
// since the copy was taken opens to a null payload, which is exactly the erasure
|
|
@@ -4250,13 +4417,13 @@ export function createControlPlaneApi(options) {
|
|
|
4250
4417
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
4251
4418
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4252
4419
|
if (!scope)
|
|
4253
|
-
return c
|
|
4420
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4254
4421
|
try {
|
|
4255
4422
|
return c.json(await backupScope(c, tenantId, scope), 201);
|
|
4256
4423
|
}
|
|
4257
4424
|
catch (e) {
|
|
4258
4425
|
if (e instanceof ControlPlaneError) {
|
|
4259
|
-
return
|
|
4426
|
+
return relayRefusal(c, e);
|
|
4260
4427
|
}
|
|
4261
4428
|
throw e;
|
|
4262
4429
|
}
|
|
@@ -4284,7 +4451,7 @@ export function createControlPlaneApi(options) {
|
|
|
4284
4451
|
const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
|
|
4285
4452
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
4286
4453
|
if (!scope)
|
|
4287
|
-
return c
|
|
4454
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4288
4455
|
const copies = await listAllScopeScriptCopies(c.var.admin, c.get('actor'), tenantId, scopeId);
|
|
4289
4456
|
// A move in flight can still restore a copy no inventory read now would name. Its entries
|
|
4290
4457
|
// settle when it confirms or fails, or, if it died, when the sweep finds its lease run out.
|
|
@@ -4293,17 +4460,33 @@ export function createControlPlaneApi(options) {
|
|
|
4293
4460
|
}
|
|
4294
4461
|
const erasureEpoch = await c.var.admin.scopeErasureEpoch(c.get('actor'), tenantId, scopeId);
|
|
4295
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);
|
|
4296
4466
|
// #2207: the stores of the version the deployment runs, resolved before anything is erased. A
|
|
4297
4467
|
// version that declares none is not asked. One that cannot be resolved while a kind is
|
|
4298
4468
|
// registered refuses (503): the key must not be destroyed while a store may hold the subject.
|
|
4299
|
-
const reach = await platformStoresOf(c, scope);
|
|
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
|
+
};
|
|
4300
4483
|
// Not asked is `undefined`, not `[]` (#2242): with no platform path the host's own stores
|
|
4301
4484
|
// (the pure adapter's co-located runners) are erased in its shred or its finalize.
|
|
4302
4485
|
const eraseStores = async () => reach ? reach.caller.eraseSubject({ tenantId, scopeId, vertical: reach.vertical, subjectId, stores: reach.stores }) : undefined;
|
|
4303
4486
|
/** The host's own shred, with the platform's store erasure first: a store that errors keeps the key. */
|
|
4304
4487
|
const shredHere = async () => {
|
|
4305
4488
|
const storeErasures = await eraseStores();
|
|
4306
|
-
const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId);
|
|
4489
|
+
const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId, fence).catch(relayHostRefusal);
|
|
4307
4490
|
return { ...receipt, storeErasures: [...receipt.storeErasures, ...(storeErasures ?? [])] };
|
|
4308
4491
|
};
|
|
4309
4492
|
if (!options.resolveVerticalRef) {
|
|
@@ -4332,10 +4515,7 @@ export function createControlPlaneApi(options) {
|
|
|
4332
4515
|
if (bySlug)
|
|
4333
4516
|
redactions.push(await bySlug.redactSubject(scopeId, subjectId));
|
|
4334
4517
|
const storeErasures = await eraseStores();
|
|
4335
|
-
return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions,
|
|
4336
|
-
versionId: scope.verticalVersionId, servingRef: scope.servingRef ?? null, epoch: erasureEpoch,
|
|
4337
|
-
copyCount: copies.length,
|
|
4338
|
-
}, storeErasures).catch(relayHostRefusal));
|
|
4518
|
+
return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, fence, storeErasures).catch(relayHostRefusal));
|
|
4339
4519
|
});
|
|
4340
4520
|
// -- directory backups (#40) -----------------------------------------------
|
|
4341
4521
|
// The platform's own disaster recovery, on the same store posture as the scope
|
|
@@ -4350,7 +4530,7 @@ export function createControlPlaneApi(options) {
|
|
|
4350
4530
|
// question an unrehearsed backup story never has a way to ask.
|
|
4351
4531
|
app.get('/directory/backups', async (c) => {
|
|
4352
4532
|
if (!options.directoryBackups)
|
|
4353
|
-
return c
|
|
4533
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4354
4534
|
return c.json(await options.directoryBackups.list());
|
|
4355
4535
|
});
|
|
4356
4536
|
// Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
|
|
@@ -4358,7 +4538,7 @@ export function createControlPlaneApi(options) {
|
|
|
4358
4538
|
// copy without waiting a day for the cron.
|
|
4359
4539
|
app.post('/directory/backups', async (c) => {
|
|
4360
4540
|
if (!options.directoryBackups)
|
|
4361
|
-
return c
|
|
4541
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4362
4542
|
const result = await backupDirectoryIfDue({
|
|
4363
4543
|
admin: c.var.admin,
|
|
4364
4544
|
store: options.directoryBackups,
|
|
@@ -4373,11 +4553,11 @@ export function createControlPlaneApi(options) {
|
|
|
4373
4553
|
// and audited by `exportDirectory` underneath.
|
|
4374
4554
|
app.get('/directory/backups/:capturedAt', async (c) => {
|
|
4375
4555
|
if (!options.directoryBackups)
|
|
4376
|
-
return c
|
|
4556
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4377
4557
|
const capturedAt = c.req.param('capturedAt');
|
|
4378
4558
|
const dump = await options.directoryBackups.get({ capturedAt });
|
|
4379
4559
|
if (!dump)
|
|
4380
|
-
return c
|
|
4560
|
+
return refuse(c, 'not_found', `no directory backup at ${capturedAt}`);
|
|
4381
4561
|
return c.json(dump);
|
|
4382
4562
|
});
|
|
4383
4563
|
// Break-glass: REPLACE the directory with a stored copy.
|
|
@@ -4391,18 +4571,16 @@ export function createControlPlaneApi(options) {
|
|
|
4391
4571
|
// with nothing in it — needs no such ceremony.
|
|
4392
4572
|
app.post('/directory/restore', async (c) => {
|
|
4393
4573
|
if (!options.directoryBackups)
|
|
4394
|
-
return c
|
|
4574
|
+
return refuseStatus(c, 501, 'no directory backup target configured');
|
|
4395
4575
|
const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
|
|
4396
4576
|
const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
|
|
4397
4577
|
if (!dump)
|
|
4398
|
-
return c
|
|
4578
|
+
return refuse(c, 'not_found', `no directory backup at ${body.capturedAt}`);
|
|
4399
4579
|
const actor = c.get('actor');
|
|
4400
4580
|
const live = await c.var.admin.listTenants(actor, { limit: 1 });
|
|
4401
4581
|
if (live.length > 0 && !body.overwrite) {
|
|
4402
|
-
return c
|
|
4403
|
-
|
|
4404
|
-
'since this copy was taken would be lost; pass overwrite=true to confirm',
|
|
4405
|
-
}, 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');
|
|
4406
4584
|
}
|
|
4407
4585
|
await c.var.admin.restoreDirectory(actor, dump);
|
|
4408
4586
|
return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
|
|
@@ -4454,7 +4632,7 @@ export function createControlPlaneApi(options) {
|
|
|
4454
4632
|
};
|
|
4455
4633
|
app.post('/previews/repair-serving-pins', async (c) => {
|
|
4456
4634
|
if (!options.resolveVerticalVersion || !options.resolveVerticalRef) {
|
|
4457
|
-
return c
|
|
4635
|
+
return refuseStatus(c, 501, 'repairing serving pins needs dispatch resolution for both ends');
|
|
4458
4636
|
}
|
|
4459
4637
|
const { cursor, limit, dryRun } = await readJsonBody(c, repairServingPinsBody);
|
|
4460
4638
|
const actor = c.get('actor');
|
|
@@ -4625,13 +4803,13 @@ export function createControlPlaneApi(options) {
|
|
|
4625
4803
|
const actor = c.get('actor');
|
|
4626
4804
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4627
4805
|
if (!scope)
|
|
4628
|
-
return c
|
|
4806
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4629
4807
|
if (!isPrimaryScope(scope)) {
|
|
4630
|
-
return c
|
|
4808
|
+
return refuse(c, 'conflict', 'clear-copy-mark refused: the directory says this scope is a copy (a preview or a fork)');
|
|
4631
4809
|
}
|
|
4632
4810
|
const vertical = await verticalForScope(c, scope);
|
|
4633
4811
|
if (!vertical)
|
|
4634
|
-
return c
|
|
4812
|
+
return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
|
|
4635
4813
|
const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
|
|
4636
4814
|
await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
|
|
4637
4815
|
return c.json({ cleared });
|
|
@@ -4657,16 +4835,14 @@ export function createControlPlaneApi(options) {
|
|
|
4657
4835
|
const actor = c.get('actor');
|
|
4658
4836
|
const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
|
|
4659
4837
|
if (!parsed.success) {
|
|
4660
|
-
return c
|
|
4838
|
+
return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen');
|
|
4661
4839
|
}
|
|
4662
4840
|
// Refused at the door rather than left to the adapters: an instant in the future
|
|
4663
4841
|
// reopens rows the drain shipped AFTER the rebuild, which is exactly the double-write
|
|
4664
4842
|
// the required instant exists to prevent. Host code, so it may read the real clock.
|
|
4665
4843
|
if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
|
|
4666
|
-
return c.
|
|
4667
|
-
|
|
4668
|
-
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
|
|
4669
|
-
}, 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.');
|
|
4670
4846
|
}
|
|
4671
4847
|
// The input schema also carries `countOnly` (#1545), and this route does not honour it:
|
|
4672
4848
|
// its answer (`redrained`, `more`) is a statement that rows moved. Ignoring the field
|
|
@@ -4675,11 +4851,11 @@ export function createControlPlaneApi(options) {
|
|
|
4675
4851
|
// caller is told where counting lives. Refused whatever its value: on this route the
|
|
4676
4852
|
// field has no meaning at all, and "accepted when false" is a rule nobody can rely on.
|
|
4677
4853
|
if (parsed.data.countOnly !== undefined) {
|
|
4678
|
-
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');
|
|
4679
4855
|
}
|
|
4680
4856
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4681
4857
|
if (!scope)
|
|
4682
|
-
return c
|
|
4858
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4683
4859
|
// The instant alone, never `parsed.data`, so the field cannot reach the adapter from
|
|
4684
4860
|
// this door even if the refusal above is ever relaxed.
|
|
4685
4861
|
const redrained = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
|
|
@@ -4710,26 +4886,72 @@ export function createControlPlaneApi(options) {
|
|
|
4710
4886
|
const actor = c.get('actor');
|
|
4711
4887
|
const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
|
|
4712
4888
|
if (!parsed.success) {
|
|
4713
|
-
return c
|
|
4889
|
+
return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count');
|
|
4714
4890
|
}
|
|
4715
4891
|
// The same future refusal as the reopen. A count of a window that has not closed yet
|
|
4716
4892
|
// is not dangerous, it is meaningless — and a dry run whose instant this door accepts
|
|
4717
4893
|
// and the real run rejects would be the worst kind of rehearsal.
|
|
4718
4894
|
if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
|
|
4719
|
-
return c.
|
|
4720
|
-
|
|
4721
|
-
'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
|
|
4722
|
-
}, 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.');
|
|
4723
4897
|
}
|
|
4724
4898
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4725
4899
|
if (!scope)
|
|
4726
|
-
return c
|
|
4900
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4727
4901
|
const redrainable = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
|
|
4728
4902
|
drainedBefore: parsed.data.drainedBefore,
|
|
4729
4903
|
countOnly: true,
|
|
4730
4904
|
});
|
|
4731
4905
|
return c.json({ redrainable, drainedBefore: parsed.data.drainedBefore });
|
|
4732
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
|
+
});
|
|
4733
4955
|
app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
|
|
4734
4956
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
4735
4957
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -4739,9 +4961,9 @@ export function createControlPlaneApi(options) {
|
|
|
4739
4961
|
const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
|
|
4740
4962
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4741
4963
|
if (!scope)
|
|
4742
|
-
return c
|
|
4964
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4743
4965
|
if (scope.status !== 'archived') {
|
|
4744
|
-
return c
|
|
4966
|
+
return refuse(c, 'conflict', `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped`);
|
|
4745
4967
|
}
|
|
4746
4968
|
// Refuse a scope that still resolves a hostname BEFORE any delegation — the vertical's
|
|
4747
4969
|
// `deleteScope` below wipes the hosted DO's bytes, so this must gate ahead of it, not
|
|
@@ -4750,10 +4972,8 @@ export function createControlPlaneApi(options) {
|
|
|
4750
4972
|
// the same guard because releasing every name is the point there.)
|
|
4751
4973
|
const boundNames = await c.var.admin.listHostnames(actor, { scopeId, limit: 1 });
|
|
4752
4974
|
if (boundNames.length > 0) {
|
|
4753
|
-
return c.
|
|
4754
|
-
|
|
4755
|
-
`unbind it before reaping (reap wipes storage and cannot be undone)`,
|
|
4756
|
-
}, 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)`);
|
|
4757
4977
|
}
|
|
4758
4978
|
// The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
|
|
4759
4979
|
// guarantee: `backupScope` has to have resolved — durably stored — before
|
|
@@ -4772,13 +4992,10 @@ export function createControlPlaneApi(options) {
|
|
|
4772
4992
|
}
|
|
4773
4993
|
catch (e) {
|
|
4774
4994
|
if (e instanceof ControlPlaneError) {
|
|
4775
|
-
return
|
|
4995
|
+
return relayRefusal(c, e);
|
|
4776
4996
|
}
|
|
4777
|
-
return c
|
|
4778
|
-
|
|
4779
|
-
'retry, or reap with backup=false to accept an unrecoverable wipe',
|
|
4780
|
-
detail: e instanceof Error ? e.message : String(e),
|
|
4781
|
-
}, 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) });
|
|
4782
4999
|
}
|
|
4783
5000
|
}
|
|
4784
5001
|
try {
|
|
@@ -4793,7 +5010,7 @@ export function createControlPlaneApi(options) {
|
|
|
4793
5010
|
}
|
|
4794
5011
|
catch (e) {
|
|
4795
5012
|
if (e instanceof ControlPlaneError) {
|
|
4796
|
-
return
|
|
5013
|
+
return relayRefusal(c, e);
|
|
4797
5014
|
}
|
|
4798
5015
|
throw e;
|
|
4799
5016
|
}
|
|
@@ -4810,15 +5027,13 @@ export function createControlPlaneApi(options) {
|
|
|
4810
5027
|
const actor = c.get('actor');
|
|
4811
5028
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4812
5029
|
if (!scope)
|
|
4813
|
-
return c
|
|
5030
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4814
5031
|
// Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
|
|
4815
5032
|
// lands the data on a machine outside the platform's control, so anything
|
|
4816
5033
|
// pinned tighter than `global` is refused until a compliant path exists.
|
|
4817
5034
|
if (scope.jurisdiction !== 'global') {
|
|
4818
|
-
return c.
|
|
4819
|
-
|
|
4820
|
-
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
|
|
4821
|
-
}, 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)`);
|
|
4822
5037
|
}
|
|
4823
5038
|
const full = c.req.query('full') === 'true';
|
|
4824
5039
|
try {
|
|
@@ -4846,7 +5061,7 @@ export function createControlPlaneApi(options) {
|
|
|
4846
5061
|
}
|
|
4847
5062
|
catch (e) {
|
|
4848
5063
|
if (e instanceof ControlPlaneError) {
|
|
4849
|
-
return
|
|
5064
|
+
return relayRefusal(c, e);
|
|
4850
5065
|
}
|
|
4851
5066
|
throw e;
|
|
4852
5067
|
}
|
|
@@ -4873,7 +5088,7 @@ export function createControlPlaneApi(options) {
|
|
|
4873
5088
|
const actor = c.get('actor');
|
|
4874
5089
|
const tenant = await c.var.admin.getTenant(actor, tenantId);
|
|
4875
5090
|
if (!tenant)
|
|
4876
|
-
return c
|
|
5091
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
4877
5092
|
// Every scope, tombstones included: an archived or reaped scope is part of the
|
|
4878
5093
|
// tenant's history, and an export that quietly dropped them would misrepresent what
|
|
4879
5094
|
// the tenant was. Their DATA is a different question, handled below.
|
|
@@ -4885,12 +5100,10 @@ export function createControlPlaneApi(options) {
|
|
|
4885
5100
|
// partial is the failure mode worth avoiding.
|
|
4886
5101
|
const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
|
|
4887
5102
|
if (pinned.length > 0) {
|
|
4888
|
-
return c.
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
`global scopes individually, or wait for a per-jurisdiction path.`,
|
|
4893
|
-
}, 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.`);
|
|
4894
5107
|
}
|
|
4895
5108
|
const full = c.req.query('full') === 'true';
|
|
4896
5109
|
try {
|
|
@@ -4969,7 +5182,7 @@ export function createControlPlaneApi(options) {
|
|
|
4969
5182
|
}
|
|
4970
5183
|
catch (e) {
|
|
4971
5184
|
if (e instanceof ControlPlaneError) {
|
|
4972
|
-
return
|
|
5185
|
+
return relayRefusal(c, e);
|
|
4973
5186
|
}
|
|
4974
5187
|
throw e;
|
|
4975
5188
|
}
|
|
@@ -4991,7 +5204,7 @@ export function createControlPlaneApi(options) {
|
|
|
4991
5204
|
const actor = c.get('actor');
|
|
4992
5205
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
4993
5206
|
if (!scope)
|
|
4994
|
-
return c
|
|
5207
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4995
5208
|
const raw = await c.req.json();
|
|
4996
5209
|
const dump = scopeDump.parse(raw);
|
|
4997
5210
|
// #1869: where the uploaded file says it came from, when the caller had to set the dump's
|
|
@@ -5001,7 +5214,7 @@ export function createControlPlaneApi(options) {
|
|
|
5001
5214
|
// A backup with no tables is not a scope dump — name that plainly rather than letting
|
|
5002
5215
|
// the empty replay reach the checker and surface as a bare `internal error` (#321).
|
|
5003
5216
|
if (dump.tables.length === 0) {
|
|
5004
|
-
return c
|
|
5217
|
+
return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
|
|
5005
5218
|
}
|
|
5006
5219
|
// #2207: the dump's stores against the destination's declaration, before anything is written.
|
|
5007
5220
|
if (options.platformScopeStores) {
|
|
@@ -5010,7 +5223,7 @@ export function createControlPlaneApi(options) {
|
|
|
5010
5223
|
}
|
|
5011
5224
|
catch (e) {
|
|
5012
5225
|
if (e instanceof ControlPlaneError)
|
|
5013
|
-
return
|
|
5226
|
+
return relayRefusal(c, e);
|
|
5014
5227
|
throw e;
|
|
5015
5228
|
}
|
|
5016
5229
|
}
|
|
@@ -5076,7 +5289,7 @@ export function createControlPlaneApi(options) {
|
|
|
5076
5289
|
// reconcile); a clear that fails here must not hide the restore's own error.
|
|
5077
5290
|
await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
|
|
5078
5291
|
if (e instanceof ControlPlaneError) {
|
|
5079
|
-
return
|
|
5292
|
+
return relayRefusal(c, e);
|
|
5080
5293
|
}
|
|
5081
5294
|
// A restore throw is driven by the caller-supplied dump (a shape the target cannot
|
|
5082
5295
|
// load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
|
|
@@ -5084,7 +5297,7 @@ export function createControlPlaneApi(options) {
|
|
|
5084
5297
|
// secondary obs #2). The route is staff/owner-gated and the detail is about the
|
|
5085
5298
|
// dump the caller sent, not another tenant's state.
|
|
5086
5299
|
const detail = e instanceof Error ? e.message : String(e);
|
|
5087
|
-
return c
|
|
5300
|
+
return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
|
|
5088
5301
|
}
|
|
5089
5302
|
});
|
|
5090
5303
|
// #1236: when this scope's migrations actually ran — the schema-change history
|
|
@@ -5097,7 +5310,7 @@ export function createControlPlaneApi(options) {
|
|
|
5097
5310
|
const actor = c.get('actor');
|
|
5098
5311
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5099
5312
|
if (!scope)
|
|
5100
|
-
return c
|
|
5313
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5101
5314
|
const vertical = await verticalForScope(c, scope);
|
|
5102
5315
|
return c.json(vertical
|
|
5103
5316
|
? await vertical.appliedMigrations(scopeId)
|
|
@@ -5113,7 +5326,7 @@ export function createControlPlaneApi(options) {
|
|
|
5113
5326
|
const actor = c.get('actor');
|
|
5114
5327
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5115
5328
|
if (!scope)
|
|
5116
|
-
return c
|
|
5329
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5117
5330
|
const vertical = await verticalForScope(c, scope);
|
|
5118
5331
|
return c.json(vertical
|
|
5119
5332
|
? await vertical.migrationBookmarks(scopeId)
|
|
@@ -5133,7 +5346,7 @@ export function createControlPlaneApi(options) {
|
|
|
5133
5346
|
const actor = c.get('actor');
|
|
5134
5347
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5135
5348
|
if (!scope)
|
|
5136
|
-
return c
|
|
5349
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5137
5350
|
try {
|
|
5138
5351
|
const vertical = await verticalForScope(c, scope);
|
|
5139
5352
|
const result = await c.var.admin.rewindScope(actor, tenantId, scopeId, bookmark, {
|
|
@@ -5156,7 +5369,7 @@ export function createControlPlaneApi(options) {
|
|
|
5156
5369
|
}
|
|
5157
5370
|
catch (e) {
|
|
5158
5371
|
if (e instanceof ControlPlaneError) {
|
|
5159
|
-
return
|
|
5372
|
+
return relayRefusal(c, e);
|
|
5160
5373
|
}
|
|
5161
5374
|
throw e;
|
|
5162
5375
|
}
|
|
@@ -5176,7 +5389,7 @@ export function createControlPlaneApi(options) {
|
|
|
5176
5389
|
}
|
|
5177
5390
|
catch (e) {
|
|
5178
5391
|
if (e instanceof ControlPlaneError) {
|
|
5179
|
-
return c
|
|
5392
|
+
return relayRefusal(c, e, exportBreaksOf(e));
|
|
5180
5393
|
}
|
|
5181
5394
|
throw e;
|
|
5182
5395
|
}
|
|
@@ -5194,13 +5407,13 @@ export function createControlPlaneApi(options) {
|
|
|
5194
5407
|
if (adoptPin) {
|
|
5195
5408
|
const v = await verticalOf(actor, slug);
|
|
5196
5409
|
if (!v || v.ownerTenant !== adoptPin)
|
|
5197
|
-
return c
|
|
5410
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
5198
5411
|
}
|
|
5199
5412
|
const v = await verticalOf(actor, slug);
|
|
5200
5413
|
if (!v)
|
|
5201
|
-
return c
|
|
5414
|
+
return refuse(c, 'not_found', `unknown vertical '${slug}'`);
|
|
5202
5415
|
if (v.ownerTenant === null) {
|
|
5203
|
-
return c
|
|
5416
|
+
return refuse(c, 'conflict', 'adopt-serving is a private-vertical operation');
|
|
5204
5417
|
}
|
|
5205
5418
|
const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
|
|
5206
5419
|
const { acknowledge } = await readJsonBody(c, adoptVerticalServingBody);
|
|
@@ -5214,7 +5427,7 @@ export function createControlPlaneApi(options) {
|
|
|
5214
5427
|
}
|
|
5215
5428
|
catch (e) {
|
|
5216
5429
|
if (e instanceof ControlPlaneError) {
|
|
5217
|
-
return c
|
|
5430
|
+
return relayRefusal(c, e, { ...exportBreaksOf(e), adopted, alreadyAdopted });
|
|
5218
5431
|
}
|
|
5219
5432
|
throw e;
|
|
5220
5433
|
}
|
|
@@ -5234,7 +5447,7 @@ export function createControlPlaneApi(options) {
|
|
|
5234
5447
|
// another tenant's private vertical, which a confined caller must not even learn exists
|
|
5235
5448
|
// (404, as `GET /verticals/:slug` answers it).
|
|
5236
5449
|
if (await notReadable(c.get('principal'), vertical)) {
|
|
5237
|
-
return c
|
|
5450
|
+
return refuse(c, 'not_found', `unknown vertical: ${vertical}`);
|
|
5238
5451
|
}
|
|
5239
5452
|
try {
|
|
5240
5453
|
const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
|
|
@@ -5246,7 +5459,7 @@ export function createControlPlaneApi(options) {
|
|
|
5246
5459
|
}
|
|
5247
5460
|
catch (e) {
|
|
5248
5461
|
if (e instanceof ControlPlaneError) {
|
|
5249
|
-
return c
|
|
5462
|
+
return relayRefusal(c, e, exportBreaksOf(e));
|
|
5250
5463
|
}
|
|
5251
5464
|
throw e;
|
|
5252
5465
|
}
|
|
@@ -5317,7 +5530,7 @@ export function createControlPlaneApi(options) {
|
|
|
5317
5530
|
const { script } = keptScriptQuery.parse(c.req.query());
|
|
5318
5531
|
const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
5319
5532
|
if (!scope)
|
|
5320
|
-
return c
|
|
5533
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5321
5534
|
const kept = await (await keptHolder(script)).keptCopy(scopeId);
|
|
5322
5535
|
return c.json({ scopeId, script, kept, routesTo: await routeOf(c, scope) });
|
|
5323
5536
|
});
|
|
@@ -5336,17 +5549,15 @@ export function createControlPlaneApi(options) {
|
|
|
5336
5549
|
const actor = c.get('actor');
|
|
5337
5550
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5338
5551
|
if (!scope)
|
|
5339
|
-
return c
|
|
5552
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5340
5553
|
if (scope.jurisdiction !== 'global') {
|
|
5341
|
-
return c.
|
|
5342
|
-
|
|
5343
|
-
`move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
|
|
5344
|
-
}, 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)`);
|
|
5345
5556
|
}
|
|
5346
5557
|
const holder = await keptHolder(script);
|
|
5347
5558
|
const kept = await holder.keptCopy(scopeId);
|
|
5348
5559
|
if (!kept)
|
|
5349
|
-
return c
|
|
5560
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${script}'`);
|
|
5350
5561
|
const dump = await c.var.admin.exportScope(actor, tenantId, scopeId);
|
|
5351
5562
|
const tables = await holder.exportScope(scopeId);
|
|
5352
5563
|
if (full === 'true')
|
|
@@ -5361,20 +5572,18 @@ export function createControlPlaneApi(options) {
|
|
|
5361
5572
|
const actor = c.get('actor');
|
|
5362
5573
|
const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
5363
5574
|
if (!scope)
|
|
5364
|
-
return c
|
|
5575
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5365
5576
|
const holder = await keptHolder(body.script);
|
|
5366
5577
|
const kept = await holder.keptCopy(scopeId);
|
|
5367
5578
|
if (!kept)
|
|
5368
|
-
return c
|
|
5579
|
+
return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${body.script}'`);
|
|
5369
5580
|
const route = await routeOf(c, scope);
|
|
5370
5581
|
// `release` is for a kept copy the scope routes to (a rollback bound it after a carry kept
|
|
5371
5582
|
// it); the other two act on a kept copy the scope has left, against the store it runs on.
|
|
5372
5583
|
if (body.action === 'release' ? route !== body.script : !route || route === body.script) {
|
|
5373
|
-
return c.
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
: `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`,
|
|
5377
|
-
}, 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`);
|
|
5378
5587
|
}
|
|
5379
5588
|
const at = new Date().toISOString();
|
|
5380
5589
|
const revisionOf = async (client) => {
|
|
@@ -5405,7 +5614,7 @@ export function createControlPlaneApi(options) {
|
|
|
5405
5614
|
if (body.action === 'release') {
|
|
5406
5615
|
const out = await holder.releaseKeptCopy({ scopeId, revision: revisionBefore, ...markCopy, ...stampFence });
|
|
5407
5616
|
if ('refused' in out) {
|
|
5408
|
-
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`);
|
|
5409
5618
|
}
|
|
5410
5619
|
await record('release', revisionBefore);
|
|
5411
5620
|
return c.json({ scopeId, script: body.script, action: 'release', revisionBefore });
|
|
@@ -5413,7 +5622,7 @@ export function createControlPlaneApi(options) {
|
|
|
5413
5622
|
if (body.action === 'discard') {
|
|
5414
5623
|
const out = await holder.discardKeptCopy({ scopeId, revision: revisionBefore, carriedTo: route, at, ...markCopy, ...stampFence });
|
|
5415
5624
|
if ('refused' in out) {
|
|
5416
|
-
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`);
|
|
5417
5626
|
}
|
|
5418
5627
|
const revisionAfter = await revisionOf(holder);
|
|
5419
5628
|
await record('discard', revisionAfter);
|
|
@@ -5421,10 +5630,8 @@ export function createControlPlaneApi(options) {
|
|
|
5421
5630
|
}
|
|
5422
5631
|
// restore-forward: the acknowledgement names what is replaced, as the marker says it.
|
|
5423
5632
|
if (body.acknowledge.replacesLiveWritesSince !== kept.keptAt) {
|
|
5424
|
-
return c.
|
|
5425
|
-
|
|
5426
|
-
`(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`,
|
|
5427
|
-
}, 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}')`);
|
|
5428
5635
|
}
|
|
5429
5636
|
// Fenced on the binding through the whole resolution (Codex #2008 r8): the kept copy goes only
|
|
5430
5637
|
// once its data is in the store the scope still routes to. Every refusal below leaves it kept.
|
|
@@ -5436,21 +5643,19 @@ export function createControlPlaneApi(options) {
|
|
|
5436
5643
|
(now.verticalVersionId ?? null) === (scope.verticalVersionId ?? null) &&
|
|
5437
5644
|
(now.servingRef ?? null) === (scope.servingRef ?? null));
|
|
5438
5645
|
};
|
|
5439
|
-
const moved = () => c
|
|
5440
|
-
|
|
5441
|
-
`and the kept copy in '${body.script}' stays kept; read it again and resolve it again`,
|
|
5442
|
-
}, 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`);
|
|
5443
5648
|
// The live store as read now, and nothing else, is what gets replaced. The binding read again
|
|
5444
5649
|
// after the marker ties the marker to the live store: a move that landed before this read
|
|
5445
5650
|
// (its wipe minting a tombstone here) changed the binding, and is refused.
|
|
5446
5651
|
const liveMarker = await live.loadMarker(scopeId);
|
|
5447
5652
|
if (liveMarker === 'unfenced') {
|
|
5448
|
-
return c
|
|
5653
|
+
return refuse(c, 'conflict', `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve`);
|
|
5449
5654
|
}
|
|
5450
5655
|
if (!(await bindingHeld()))
|
|
5451
5656
|
return moved();
|
|
5452
5657
|
if ((await isCarriedAway(live, scope)) || (await live.keptCopy(scopeId))) {
|
|
5453
|
-
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`);
|
|
5454
5659
|
}
|
|
5455
5660
|
const dump = await retryTransient(() => holder.exportScope(scopeId));
|
|
5456
5661
|
// #2185: before the load as well as after it, so a throw once the kept copy has landed (the
|
|
@@ -5489,10 +5694,8 @@ export function createControlPlaneApi(options) {
|
|
|
5489
5694
|
...stampFence,
|
|
5490
5695
|
});
|
|
5491
5696
|
if ('refused' in wiped) {
|
|
5492
|
-
return c.
|
|
5493
|
-
|
|
5494
|
-
`stays kept: read it again and resolve it again`,
|
|
5495
|
-
}, 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`);
|
|
5496
5699
|
}
|
|
5497
5700
|
return c.json({ scopeId, script: body.script, action: 'restore-forward', liveScript, revisionBefore, revisionAfter });
|
|
5498
5701
|
});
|
|
@@ -5550,7 +5753,7 @@ export function createControlPlaneApi(options) {
|
|
|
5550
5753
|
};
|
|
5551
5754
|
if (snapshot) {
|
|
5552
5755
|
if (!scope) {
|
|
5553
|
-
return c
|
|
5756
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
5554
5757
|
}
|
|
5555
5758
|
const vertical = await verticalForScope(c, scope);
|
|
5556
5759
|
if (vertical) {
|
|
@@ -5625,13 +5828,13 @@ export function createControlPlaneApi(options) {
|
|
|
5625
5828
|
// uniform whether or not anything is deployed. Existing scopes keep serving.
|
|
5626
5829
|
const registered = (await c.var.admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
|
|
5627
5830
|
if (registered?.installsBlocked) {
|
|
5628
|
-
return c
|
|
5831
|
+
return refuse(c, 'forbidden', `new installs of vertical '${slug}' are blocked`);
|
|
5629
5832
|
}
|
|
5630
5833
|
// Static binding first (milestone-one shape), then the dispatch resolver for a
|
|
5631
5834
|
// pushed vertical — the provisioning mirror of the router's verticalFor.
|
|
5632
5835
|
const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
|
|
5633
5836
|
if (!vertical) {
|
|
5634
|
-
return c
|
|
5837
|
+
return refuseStatus(c, 501, `no deployment is bound for vertical '${slug}'`);
|
|
5635
5838
|
}
|
|
5636
5839
|
const input = provisionInstanceBody.parse(await c.req.json());
|
|
5637
5840
|
// #310: the platform is the authoritative source of the tenant's entitlements — it
|
|
@@ -5750,7 +5953,7 @@ export function createControlPlaneApi(options) {
|
|
|
5750
5953
|
message: e.message,
|
|
5751
5954
|
}, e);
|
|
5752
5955
|
}
|
|
5753
|
-
return
|
|
5956
|
+
return relayRefusal(c, e);
|
|
5754
5957
|
}
|
|
5755
5958
|
throw e;
|
|
5756
5959
|
}
|
|
@@ -5979,7 +6182,7 @@ export function createControlPlaneApi(options) {
|
|
|
5979
6182
|
input.slug = effectiveSlug(p, input.slug);
|
|
5980
6183
|
const owner = await ownerOf(p.actor, input.slug);
|
|
5981
6184
|
if (owner !== undefined && owner !== p.tenantId)
|
|
5982
|
-
return c
|
|
6185
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
5983
6186
|
input.ownerTenant = p.tenantId;
|
|
5984
6187
|
}
|
|
5985
6188
|
await c.var.admin.registerVertical(c.get('actor'), input);
|
|
@@ -5994,7 +6197,7 @@ export function createControlPlaneApi(options) {
|
|
|
5994
6197
|
// A builder reading a vertical it does not own gets 404 — indistinguishable from
|
|
5995
6198
|
// absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
|
|
5996
6199
|
if (await notReadable(p, slug)) {
|
|
5997
|
-
return c
|
|
6200
|
+
return refuse(c, 'not_found', 'not found');
|
|
5998
6201
|
}
|
|
5999
6202
|
const page = pageParams(c);
|
|
6000
6203
|
const entries = await c.var.admin.listVersions(c.get('actor'), slug, page);
|
|
@@ -6006,12 +6209,12 @@ export function createControlPlaneApi(options) {
|
|
|
6006
6209
|
// the same fail-closed cross-check `(tenantId, scopeId)` makes (K-3) — a mismatch is
|
|
6007
6210
|
// a client bug, not a silent publish under the wrong vertical.
|
|
6008
6211
|
if (input.verticalSlug !== c.req.param('slug')) {
|
|
6009
|
-
return c
|
|
6212
|
+
return refuse(c, 'validation_failed', 'verticalSlug does not match the path');
|
|
6010
6213
|
}
|
|
6011
6214
|
const p = c.get('principal');
|
|
6012
6215
|
const slug = await resolveVerticalId(c, input.verticalSlug);
|
|
6013
6216
|
if (await notOwned(p, slug)) {
|
|
6014
|
-
return c
|
|
6217
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6015
6218
|
}
|
|
6016
6219
|
input.verticalSlug = slug;
|
|
6017
6220
|
// `manifestJson` is a string the schema cannot look inside, and every reader parses it
|
|
@@ -6028,19 +6231,19 @@ export function createControlPlaneApi(options) {
|
|
|
6028
6231
|
raw = JSON.parse(input.manifestJson);
|
|
6029
6232
|
}
|
|
6030
6233
|
catch {
|
|
6031
|
-
return c
|
|
6234
|
+
return refuse(c, 'validation_failed', 'manifestJson is not valid JSON');
|
|
6032
6235
|
}
|
|
6033
|
-
const
|
|
6236
|
+
const refuseManifest = (issue, under = []) => refuse(c, 'validation_failed', `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}`);
|
|
6034
6237
|
const checked = storedDeployManifest.safeParse(raw);
|
|
6035
6238
|
if (!checked.success)
|
|
6036
|
-
return
|
|
6239
|
+
return refuseManifest(checked.error.issues[0]);
|
|
6037
6240
|
// #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
|
|
6038
6241
|
// entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
|
|
6039
6242
|
// An absent registry stays accepted, as above.
|
|
6040
6243
|
if (checked.data.registry) {
|
|
6041
6244
|
const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
|
|
6042
6245
|
if (!registry.success)
|
|
6043
|
-
return
|
|
6246
|
+
return refuseManifest(registry.error.issues[0], ['registry']);
|
|
6044
6247
|
}
|
|
6045
6248
|
}
|
|
6046
6249
|
await c.var.admin.publishVersion(c.get('actor'), input);
|
|
@@ -6057,7 +6260,7 @@ export function createControlPlaneApi(options) {
|
|
|
6057
6260
|
const p = c.get('principal');
|
|
6058
6261
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6059
6262
|
if (await notReadable(p, slug)) {
|
|
6060
|
-
return c
|
|
6263
|
+
return refuse(c, 'not_found', 'not found');
|
|
6061
6264
|
}
|
|
6062
6265
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
6063
6266
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
@@ -6075,7 +6278,7 @@ export function createControlPlaneApi(options) {
|
|
|
6075
6278
|
const p = c.get('principal');
|
|
6076
6279
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6077
6280
|
if (await notOwned(p, slug)) {
|
|
6078
|
-
return c
|
|
6281
|
+
return refuse(c, 'not_found', 'not found');
|
|
6079
6282
|
}
|
|
6080
6283
|
// `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
|
|
6081
6284
|
const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
|
|
@@ -6097,7 +6300,7 @@ export function createControlPlaneApi(options) {
|
|
|
6097
6300
|
const p = c.get('principal');
|
|
6098
6301
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6099
6302
|
if (await notReadable(p, slug)) {
|
|
6100
|
-
return c
|
|
6303
|
+
return refuse(c, 'not_found', 'not found');
|
|
6101
6304
|
}
|
|
6102
6305
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
6103
6306
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -6120,7 +6323,7 @@ export function createControlPlaneApi(options) {
|
|
|
6120
6323
|
const p = c.get('principal');
|
|
6121
6324
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6122
6325
|
if (await notReadable(p, slug)) {
|
|
6123
|
-
return c
|
|
6326
|
+
return refuse(c, 'not_found', 'not found');
|
|
6124
6327
|
}
|
|
6125
6328
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
6126
6329
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -6148,7 +6351,7 @@ export function createControlPlaneApi(options) {
|
|
|
6148
6351
|
const p = c.get('principal');
|
|
6149
6352
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6150
6353
|
if (await notReadable(p, slug)) {
|
|
6151
|
-
return c
|
|
6354
|
+
return refuse(c, 'not_found', 'not found');
|
|
6152
6355
|
}
|
|
6153
6356
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
6154
6357
|
const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
|
|
@@ -6167,7 +6370,7 @@ export function createControlPlaneApi(options) {
|
|
|
6167
6370
|
const p = c.get('principal');
|
|
6168
6371
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6169
6372
|
if (await notReadable(p, slug)) {
|
|
6170
|
-
return c
|
|
6373
|
+
return refuse(c, 'not_found', 'not found');
|
|
6171
6374
|
}
|
|
6172
6375
|
const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
6173
6376
|
const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
|
|
@@ -6192,7 +6395,7 @@ export function createControlPlaneApi(options) {
|
|
|
6192
6395
|
const p = c.get('principal');
|
|
6193
6396
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6194
6397
|
if (await notOwned(p, slug)) {
|
|
6195
|
-
return c
|
|
6398
|
+
return refuse(c, 'not_found', 'not found');
|
|
6196
6399
|
}
|
|
6197
6400
|
await c.var.admin.requestPublish(c.get('actor'), slug);
|
|
6198
6401
|
return c.json({ slug, requested: true });
|
|
@@ -6248,7 +6451,7 @@ export function createControlPlaneApi(options) {
|
|
|
6248
6451
|
// 404, not 403: a slug this tenant does not own reads as absent, the same
|
|
6249
6452
|
// existence-hiding reflex the registry's other per-slug reads use (K-3).
|
|
6250
6453
|
if (await notOwned(c.get('principal'), slug))
|
|
6251
|
-
return c
|
|
6454
|
+
return refuse(c, 'not_found', 'not found');
|
|
6252
6455
|
await c.var.admin.deleteVertical(c.get('actor'), slug);
|
|
6253
6456
|
return c.json({ slug, deleted: true });
|
|
6254
6457
|
});
|
|
@@ -6256,7 +6459,7 @@ export function createControlPlaneApi(options) {
|
|
|
6256
6459
|
const p = c.get('principal');
|
|
6257
6460
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6258
6461
|
if (await notReadable(p, slug)) {
|
|
6259
|
-
return c
|
|
6462
|
+
return refuse(c, 'not_found', 'not found');
|
|
6260
6463
|
}
|
|
6261
6464
|
const page = pageParams(c);
|
|
6262
6465
|
const entries = await c.var.admin.listChannels(c.get('actor'), slug, page);
|
|
@@ -6270,16 +6473,74 @@ export function createControlPlaneApi(options) {
|
|
|
6270
6473
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
6271
6474
|
const rawChannel = c.req.param('channel');
|
|
6272
6475
|
if (rawChannel !== 'prod') {
|
|
6273
|
-
return c
|
|
6476
|
+
return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — only 'prod' has history`);
|
|
6274
6477
|
}
|
|
6275
6478
|
const channel = channelName.parse(rawChannel);
|
|
6276
6479
|
if (await notOwned(p, slug)) {
|
|
6277
|
-
return c
|
|
6480
|
+
return refuse(c, 'not_found', 'not found');
|
|
6278
6481
|
}
|
|
6279
6482
|
const page = pageParams(c);
|
|
6280
6483
|
const entries = await c.var.admin.listChannelHistory(c.get('actor'), slug, channel, page);
|
|
6281
6484
|
return c.json(pageOf(entries, page.limit, (e) => e.id));
|
|
6282
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
|
+
};
|
|
6283
6544
|
/**
|
|
6284
6545
|
* Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
|
|
6285
6546
|
* serving script, where every scope's data DO lives — this is the step that makes a
|
|
@@ -6332,6 +6593,8 @@ export function createControlPlaneApi(options) {
|
|
|
6332
6593
|
].map((b) => b.type === 'd1'
|
|
6333
6594
|
? { type: 'd1', name: b.name, id: b.id }
|
|
6334
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);
|
|
6335
6598
|
await options.deployVertical(ref, {
|
|
6336
6599
|
entry: manifest.entry,
|
|
6337
6600
|
compatibilityDate: manifest.compatibilityDate,
|
|
@@ -6487,7 +6750,7 @@ export function createControlPlaneApi(options) {
|
|
|
6487
6750
|
if (pin) {
|
|
6488
6751
|
const v = await verticalOf(p.actor, slug);
|
|
6489
6752
|
if (!v || v.ownerTenant !== pin)
|
|
6490
|
-
return c
|
|
6753
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6491
6754
|
}
|
|
6492
6755
|
const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
|
|
6493
6756
|
const { visible, otherTenants } = narrowToCaller(breaks, pin);
|
|
@@ -6500,10 +6763,8 @@ export function createControlPlaneApi(options) {
|
|
|
6500
6763
|
// with the honest alternative — a non-prod environment is a scope with data, a preview.
|
|
6501
6764
|
const rawChannel = c.req.param('channel');
|
|
6502
6765
|
if (rawChannel !== 'prod') {
|
|
6503
|
-
return c.
|
|
6504
|
-
|
|
6505
|
-
`against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`,
|
|
6506
|
-
}, 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>'.`);
|
|
6507
6768
|
}
|
|
6508
6769
|
const channel = channelName.parse(rawChannel);
|
|
6509
6770
|
const promotePin = confinedTenant(p);
|
|
@@ -6511,7 +6772,7 @@ export function createControlPlaneApi(options) {
|
|
|
6511
6772
|
// A confined principal promotes only verticals its tenant owns.
|
|
6512
6773
|
const v = await verticalOf(p.actor, slug);
|
|
6513
6774
|
if (!v || v.ownerTenant !== promotePin)
|
|
6514
|
-
return c
|
|
6775
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6515
6776
|
// …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
|
|
6516
6777
|
// A private vertical's blast radius is the owning tenant itself, and dev/staging
|
|
6517
6778
|
// already run the same bundle in the same sandbox, so a staff prod gate there
|
|
@@ -6525,7 +6786,7 @@ export function createControlPlaneApi(options) {
|
|
|
6525
6786
|
// a tenant may do inside its own. Taking the gate away from them here would be a
|
|
6526
6787
|
// capability change hidden in a security fix.
|
|
6527
6788
|
if (p.kind === 'builder' && channel === 'prod' && v.listed) {
|
|
6528
|
-
return c
|
|
6789
|
+
return refuse(c, 'forbidden', 'promotion to prod is staff-only for a listed vertical');
|
|
6529
6790
|
}
|
|
6530
6791
|
}
|
|
6531
6792
|
const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
|
|
@@ -6563,7 +6824,7 @@ export function createControlPlaneApi(options) {
|
|
|
6563
6824
|
exportBreaks: null,
|
|
6564
6825
|
exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
|
|
6565
6826
|
}));
|
|
6566
|
-
return c
|
|
6827
|
+
return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
|
|
6567
6828
|
}
|
|
6568
6829
|
// The in-place serve (#286), prod only, AFTER every promote gate has passed —
|
|
6569
6830
|
// uploading first would deploy to live scopes before the acknowledgement check.
|
|
@@ -6589,10 +6850,7 @@ export function createControlPlaneApi(options) {
|
|
|
6589
6850
|
catch (e) {
|
|
6590
6851
|
const detail = e instanceof Error ? e.message : String(e);
|
|
6591
6852
|
console.error('serve.inplace.failed', { slug, versionId, detail });
|
|
6592
|
-
return c
|
|
6593
|
-
error: 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry',
|
|
6594
|
-
detail,
|
|
6595
|
-
}, 502);
|
|
6853
|
+
return refuseStatus(c, 502, 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry', { detail });
|
|
6596
6854
|
}
|
|
6597
6855
|
}
|
|
6598
6856
|
// #1756: served, and every install that could move has moved and been backfilled, but one
|
|
@@ -6601,9 +6859,8 @@ export function createControlPlaneApi(options) {
|
|
|
6601
6859
|
// version. It is left where it is. Promoting again acknowledged moves it.
|
|
6602
6860
|
if (adopted.refused.length > 0 || adopted.refusal) {
|
|
6603
6861
|
const sentence = adopted.refused.length > 0 ? bindExportBreakRefusal(adopted.refused) : adopted.refusal;
|
|
6604
|
-
return c
|
|
6605
|
-
|
|
6606
|
-
`${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`, {
|
|
6607
6864
|
...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
|
|
6608
6865
|
// Narrowed as the success answer's is: a builder never reads another tenant's id here.
|
|
6609
6866
|
...(backfill.minted.length || backfill.error
|
|
@@ -6618,7 +6875,7 @@ export function createControlPlaneApi(options) {
|
|
|
6618
6875
|
};
|
|
6619
6876
|
})()
|
|
6620
6877
|
: {}),
|
|
6621
|
-
}
|
|
6878
|
+
});
|
|
6622
6879
|
}
|
|
6623
6880
|
const promoted = (await c.var.admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
|
|
6624
6881
|
// The store backfill rides the response only when it has something to say (#825), so
|
|
@@ -6656,13 +6913,13 @@ export function createControlPlaneApi(options) {
|
|
|
6656
6913
|
// tenant); for everything else admission still gates serving.
|
|
6657
6914
|
app.post('/verticals/:slug/deploy', async (c) => {
|
|
6658
6915
|
if (!options.deployVertical) {
|
|
6659
|
-
return c
|
|
6916
|
+
return refuseStatus(c, 501, 'deploy is not configured on this control plane');
|
|
6660
6917
|
}
|
|
6661
6918
|
const p = c.get('principal');
|
|
6662
6919
|
const form = await c.req.formData();
|
|
6663
6920
|
const raw = form.get('manifest');
|
|
6664
6921
|
if (typeof raw !== 'string')
|
|
6665
|
-
return c
|
|
6922
|
+
return refuse(c, 'validation_failed', 'missing manifest part');
|
|
6666
6923
|
const manifest = deployManifest.parse(JSON.parse(raw));
|
|
6667
6924
|
// §4 sandbox contract, before anything reaches the namespace.
|
|
6668
6925
|
assertSandboxContract(manifest);
|
|
@@ -6699,20 +6956,20 @@ export function createControlPlaneApi(options) {
|
|
|
6699
6956
|
let ownerTenant;
|
|
6700
6957
|
if (p.kind === 'builder') {
|
|
6701
6958
|
if (pin && pin !== p.tenantSlug && pin !== p.tenantId) {
|
|
6702
|
-
return c
|
|
6959
|
+
return refuse(c, 'forbidden', `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'`);
|
|
6703
6960
|
}
|
|
6704
6961
|
slug = effectiveSlug(p, bare);
|
|
6705
6962
|
// A builder pushes to a slug it owns, or claims an unregistered one (§3).
|
|
6706
6963
|
const existingOwner = await ownerOf(c.get('actor'), slug);
|
|
6707
6964
|
if (existingOwner !== undefined && existingOwner !== p.tenantId) {
|
|
6708
|
-
return c
|
|
6965
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
6709
6966
|
}
|
|
6710
6967
|
ownerTenant = p.tenantId;
|
|
6711
6968
|
}
|
|
6712
6969
|
else if (pin) {
|
|
6713
6970
|
const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
|
|
6714
6971
|
if (!workspace)
|
|
6715
|
-
return c
|
|
6972
|
+
return refuse(c, 'not_found', `unknown workspace '${pin}'`);
|
|
6716
6973
|
// Back-compat: a BARE slug already registered as the pinned tenant's (a staff
|
|
6717
6974
|
// hand-registration predating prefixed claims) stays addressable as itself.
|
|
6718
6975
|
// Otherwise the claim lands under the tenant prefix, exactly like a builder push —
|
|
@@ -6721,7 +6978,7 @@ export function createControlPlaneApi(options) {
|
|
|
6721
6978
|
slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
|
|
6722
6979
|
const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
|
|
6723
6980
|
if (existingOwner !== undefined && existingOwner !== workspace.id) {
|
|
6724
|
-
return c
|
|
6981
|
+
return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
|
|
6725
6982
|
}
|
|
6726
6983
|
ownerTenant = workspace.id;
|
|
6727
6984
|
}
|
|
@@ -6736,7 +6993,7 @@ export function createControlPlaneApi(options) {
|
|
|
6736
6993
|
// registry id that predates the rule, so it can never put a script under a platform name.
|
|
6737
6994
|
const collision = platformScriptCollision(slug);
|
|
6738
6995
|
if (collision)
|
|
6739
|
-
return c
|
|
6996
|
+
return refuseStatus(c, 422, platformScriptRefusal(slug, collision));
|
|
6740
6997
|
// #388: refuse the SILENT lineage fork. A first push of a registry id that does not
|
|
6741
6998
|
// exist yet, whose product name (the slug's tail) matches an existing lineage this
|
|
6742
6999
|
// push could CONFUSE ITSELF WITH, is almost always a mis-identified project — a
|
|
@@ -6771,13 +7028,11 @@ export function createControlPlaneApi(options) {
|
|
|
6771
7028
|
? 'owned by this same workspace'
|
|
6772
7029
|
: `owned by tenant ${v.ownerTenant}`;
|
|
6773
7030
|
const named = siblings.map((v) => `'${v.slug}' (${owner(v)}${v.listed ? ', listed' : ''})`).join(', ');
|
|
6774
|
-
return c.
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
`separate same-named lineage, re-run with --allow-fork.`,
|
|
6780
|
-
}, 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.`);
|
|
6781
7036
|
}
|
|
6782
7037
|
}
|
|
6783
7038
|
// Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
|
|
@@ -6802,7 +7057,7 @@ export function createControlPlaneApi(options) {
|
|
|
6802
7057
|
});
|
|
6803
7058
|
}
|
|
6804
7059
|
if (!modules.some((m) => m.name === manifest.entry)) {
|
|
6805
|
-
return c
|
|
7060
|
+
return refuse(c, 'validation_failed', `entry module '${manifest.entry}' is not among the uploaded files`);
|
|
6806
7061
|
}
|
|
6807
7062
|
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
6808
7063
|
// bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
|
|
@@ -6815,19 +7070,17 @@ export function createControlPlaneApi(options) {
|
|
|
6815
7070
|
for (const file of manifest.assets?.files ?? []) {
|
|
6816
7071
|
const part = assetParts.get(file.path);
|
|
6817
7072
|
if (!part) {
|
|
6818
|
-
return c
|
|
7073
|
+
return refuse(c, 'validation_failed', `asset '${file.path}' is named in the manifest but was not uploaded`);
|
|
6819
7074
|
}
|
|
6820
7075
|
const content = new Uint8Array(await part.arrayBuffer());
|
|
6821
7076
|
if (content.byteLength !== file.size) {
|
|
6822
|
-
return c
|
|
7077
|
+
return refuse(c, 'validation_failed', `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived`);
|
|
6823
7078
|
}
|
|
6824
7079
|
const actual = await assetHash(content, file.path);
|
|
6825
7080
|
if (actual !== file.hash) {
|
|
6826
|
-
return c.
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
`so a mismatch is refused rather than stored`,
|
|
6830
|
-
}, 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`);
|
|
6831
7084
|
}
|
|
6832
7085
|
assets.push({
|
|
6833
7086
|
path: file.path,
|
|
@@ -6842,9 +7095,7 @@ export function createControlPlaneApi(options) {
|
|
|
6842
7095
|
// An unlisted asset part would be uploaded by nothing and served by nothing; saying so
|
|
6843
7096
|
// beats silently dropping it, because the builder's page would 404 with no explanation.
|
|
6844
7097
|
const extra = [...assetParts.keys()].slice(0, 5).join(', ');
|
|
6845
|
-
return c.
|
|
6846
|
-
error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
|
|
6847
|
-
}, 400);
|
|
7098
|
+
return refuse(c, 'validation_failed', `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`);
|
|
6848
7099
|
}
|
|
6849
7100
|
// #1902: whether this version's uploads carry the platform's scope sweeper — decided here,
|
|
6850
7101
|
// ONCE, before anything is uploaded, and recorded with the version below so every later
|
|
@@ -6852,11 +7103,14 @@ export function createControlPlaneApi(options) {
|
|
|
6852
7103
|
// while it is still a push; it can never become a promote or a backout that refuses.
|
|
6853
7104
|
const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
|
|
6854
7105
|
if ('refuse' in sweeper)
|
|
6855
|
-
return c
|
|
7106
|
+
return refuseStatus(c, 422, sweeper.refuse);
|
|
6856
7107
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
6857
7108
|
// on it, so it is CF-valid and unique per version.
|
|
6858
7109
|
const id = ulid();
|
|
6859
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);
|
|
6860
7114
|
try {
|
|
6861
7115
|
await options.deployVertical(deploymentRef, {
|
|
6862
7116
|
entry: manifest.entry,
|
|
@@ -6905,8 +7159,8 @@ export function createControlPlaneApi(options) {
|
|
|
6905
7159
|
}, e);
|
|
6906
7160
|
c.set('failureRecorded', true);
|
|
6907
7161
|
return rejected
|
|
6908
|
-
? c
|
|
6909
|
-
: c
|
|
7162
|
+
? refuseStatus(c, 422, 'deploy rejected', { detail })
|
|
7163
|
+
: refuseStatus(c, 502, 'deploy upload failed', { detail });
|
|
6910
7164
|
}
|
|
6911
7165
|
// Register-then-publish, both idempotent-ish below the seam: a first push of a
|
|
6912
7166
|
// slug registers it; publishVersion lands the version pending with deploymentRef
|
|
@@ -6993,7 +7247,7 @@ export function createControlPlaneApi(options) {
|
|
|
6993
7247
|
// because "no namespaces in that script" and "I cannot look" are different answers.
|
|
6994
7248
|
app.get('/platform/do-namespaces', async (c) => {
|
|
6995
7249
|
if (!options.doNamespaces) {
|
|
6996
|
-
return c
|
|
7250
|
+
return refuseStatus(c, 501, 'durable-object namespace lookup is not configured on this control plane');
|
|
6997
7251
|
}
|
|
6998
7252
|
const { script } = z.object({ script: z.string().min(1).max(200) }).parse({ script: c.req.query('script') });
|
|
6999
7253
|
return c.json(namespacesForScript(await options.doNamespaces.list(), script));
|
|
@@ -7013,7 +7267,7 @@ export function createControlPlaneApi(options) {
|
|
|
7013
7267
|
// on the route where a mistake is least visible.
|
|
7014
7268
|
app.get('/observability/metrics', async (c) => {
|
|
7015
7269
|
if (!options.observability) {
|
|
7016
|
-
return c
|
|
7270
|
+
return refuseStatus(c, 501, 'observability is not configured on this control plane');
|
|
7017
7271
|
}
|
|
7018
7272
|
const { hours } = z
|
|
7019
7273
|
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
@@ -7030,7 +7284,7 @@ export function createControlPlaneApi(options) {
|
|
|
7030
7284
|
// 501 when the reader cannot bucket — an empty series would render as "quiet".
|
|
7031
7285
|
app.get('/observability/metrics-series', async (c) => {
|
|
7032
7286
|
if (!options.observability?.serviceMetricsSeries) {
|
|
7033
|
-
return c
|
|
7287
|
+
return refuseStatus(c, 501, 'bucketed observability is not configured on this control plane');
|
|
7034
7288
|
}
|
|
7035
7289
|
const { hours } = z
|
|
7036
7290
|
.object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
|
|
@@ -7054,7 +7308,7 @@ export function createControlPlaneApi(options) {
|
|
|
7054
7308
|
});
|
|
7055
7309
|
app.get('/observability/logs', async (c) => {
|
|
7056
7310
|
if (!options.observability) {
|
|
7057
|
-
return c
|
|
7311
|
+
return refuseStatus(c, 501, 'observability is not configured on this control plane');
|
|
7058
7312
|
}
|
|
7059
7313
|
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
7060
7314
|
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
@@ -7100,7 +7354,7 @@ export function createControlPlaneApi(options) {
|
|
|
7100
7354
|
// a forgotten parameter is the exact leak being closed here.
|
|
7101
7355
|
app.get('/observability/tenant-metrics', async (c) => {
|
|
7102
7356
|
if (!options.observability?.tenantMetrics) {
|
|
7103
|
-
return c
|
|
7357
|
+
return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
|
|
7104
7358
|
}
|
|
7105
7359
|
const p = c.get('principal');
|
|
7106
7360
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -7145,7 +7399,7 @@ export function createControlPlaneApi(options) {
|
|
|
7145
7399
|
// buckets, and at least one is required because an unnarrowed ask is not on offer.
|
|
7146
7400
|
app.get('/observability/tenant-metrics-series', async (c) => {
|
|
7147
7401
|
if (!options.observability?.tenantMetricsSeries) {
|
|
7148
|
-
return c
|
|
7402
|
+
return refuseStatus(c, 501, 'bucketed tenant-grain observability is not configured on this control plane');
|
|
7149
7403
|
}
|
|
7150
7404
|
const p = c.get('principal');
|
|
7151
7405
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -7176,7 +7430,7 @@ export function createControlPlaneApi(options) {
|
|
|
7176
7430
|
});
|
|
7177
7431
|
app.get('/observability/tenant-logs', async (c) => {
|
|
7178
7432
|
if (!options.observability?.tenantLogs) {
|
|
7179
|
-
return c
|
|
7433
|
+
return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
|
|
7180
7434
|
}
|
|
7181
7435
|
const p = c.get('principal');
|
|
7182
7436
|
const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
|
|
@@ -7234,7 +7488,7 @@ export function createControlPlaneApi(options) {
|
|
|
7234
7488
|
// #1331: one app's sampled field reports, joined to the router's own request lines.
|
|
7235
7489
|
app.get('/observability/tenant-field-coverage', async (c) => {
|
|
7236
7490
|
if (!options.observability?.fieldCoverage) {
|
|
7237
|
-
return c
|
|
7491
|
+
return refuseStatus(c, 501, 'field coverage is not configured on this control plane');
|
|
7238
7492
|
}
|
|
7239
7493
|
const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
|
|
7240
7494
|
if (!tenantId)
|
|
@@ -7257,11 +7511,11 @@ export function createControlPlaneApi(options) {
|
|
|
7257
7511
|
const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
|
|
7258
7512
|
const installed = scopes.find((s) => s.id === input.scopeId);
|
|
7259
7513
|
if (!installed?.vertical)
|
|
7260
|
-
return c
|
|
7514
|
+
return refuse(c, 'not_found', 'app not found');
|
|
7261
7515
|
const services = scriptFamiliesOfScopes(scopes, { scopeId: input.scopeId });
|
|
7262
7516
|
const json = await c.var.admin.versionManifest(c.get('actor'), installed.vertical, input.versionId);
|
|
7263
7517
|
if (!json)
|
|
7264
|
-
return c
|
|
7518
|
+
return refuse(c, 'not_found', 'version not found');
|
|
7265
7519
|
const outputSurface = storedDeployManifest.parse(JSON.parse(json)).outputSurface ?? [];
|
|
7266
7520
|
const declared = Object.fromEntries(outputSurface.map((op) => [op.operationId, op.fields]));
|
|
7267
7521
|
return c.json(await options.observability.fieldCoverage({
|
|
@@ -7297,7 +7551,7 @@ export function createControlPlaneApi(options) {
|
|
|
7297
7551
|
const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await host.admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
|
|
7298
7552
|
app.get('/observability/tenant-request-volume', async (c) => {
|
|
7299
7553
|
if (!options.observability?.tenantRequestVolume) {
|
|
7300
|
-
return c
|
|
7554
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7301
7555
|
}
|
|
7302
7556
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7303
7557
|
// A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
|
|
@@ -7308,7 +7562,7 @@ export function createControlPlaneApi(options) {
|
|
|
7308
7562
|
});
|
|
7309
7563
|
app.get('/observability/tenant-request-facets', async (c) => {
|
|
7310
7564
|
if (!options.observability?.tenantRequestFacets) {
|
|
7311
|
-
return c
|
|
7565
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7312
7566
|
}
|
|
7313
7567
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7314
7568
|
// `facet` repeats; absent means every facet. Each one is a backend query, so a panel
|
|
@@ -7321,7 +7575,7 @@ export function createControlPlaneApi(options) {
|
|
|
7321
7575
|
// #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
|
|
7322
7576
|
app.get('/observability/tenant-log-patterns', async (c) => {
|
|
7323
7577
|
if (!options.observability?.tenantLogPatterns) {
|
|
7324
|
-
return c
|
|
7578
|
+
return refuseStatus(c, 501, 'log patterns are not configured on this control plane');
|
|
7325
7579
|
}
|
|
7326
7580
|
const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7327
7581
|
const narrow = z
|
|
@@ -7349,7 +7603,7 @@ export function createControlPlaneApi(options) {
|
|
|
7349
7603
|
});
|
|
7350
7604
|
app.get('/observability/tenant-requests', async (c) => {
|
|
7351
7605
|
if (!options.observability?.tenantRequests) {
|
|
7352
|
-
return c
|
|
7606
|
+
return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
|
|
7353
7607
|
}
|
|
7354
7608
|
const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
|
|
7355
7609
|
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
|
|
@@ -7384,7 +7638,7 @@ export function createControlPlaneApi(options) {
|
|
|
7384
7638
|
*/
|
|
7385
7639
|
app.get('/verticals/:slug/egress', async (c) => {
|
|
7386
7640
|
if (!options.observability?.observedEgress) {
|
|
7387
|
-
return c
|
|
7641
|
+
return refuseStatus(c, 501, 'observed egress is not available on this control plane');
|
|
7388
7642
|
}
|
|
7389
7643
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
7390
7644
|
const { hours, limit } = z
|
|
@@ -7455,12 +7709,12 @@ export function createControlPlaneApi(options) {
|
|
|
7455
7709
|
// can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
|
|
7456
7710
|
app.post('/push-tokens', async (c) => {
|
|
7457
7711
|
if (!options.pushTokenSecret) {
|
|
7458
|
-
return c
|
|
7712
|
+
return refuseStatus(c, 501, 'push tokens are not configured on this control plane');
|
|
7459
7713
|
}
|
|
7460
7714
|
const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
|
|
7461
7715
|
const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
|
|
7462
7716
|
if (!tenant)
|
|
7463
|
-
return c
|
|
7717
|
+
return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
|
|
7464
7718
|
const token = await mintPushToken(options.pushTokenSecret, {
|
|
7465
7719
|
actor: await pushActorFor(tenantId),
|
|
7466
7720
|
tenantId,
|
|
@@ -7477,7 +7731,7 @@ export function createControlPlaneApi(options) {
|
|
|
7477
7731
|
// ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
|
|
7478
7732
|
app.post('/tenant-tokens', async (c) => {
|
|
7479
7733
|
if (!options.tenantTokenSecret) {
|
|
7480
|
-
return c
|
|
7734
|
+
return refuseStatus(c, 501, 'tenant tokens are not configured on this control plane');
|
|
7481
7735
|
}
|
|
7482
7736
|
const { tenantId, principal, impersonation } = z
|
|
7483
7737
|
.object({
|
|
@@ -7604,7 +7858,7 @@ export function createControlPlaneApi(options) {
|
|
|
7604
7858
|
// The body names the tenant the binding lands under; a confined principal may
|
|
7605
7859
|
// only name its own (the adapter then verifies the scope belongs to it, K-3).
|
|
7606
7860
|
if (input.tenantId !== bindPin)
|
|
7607
|
-
return c
|
|
7861
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
7608
7862
|
}
|
|
7609
7863
|
if (p.kind === 'builder') {
|
|
7610
7864
|
// The region column is an EU-residency claim (K-30) — never builder-suppliable.
|
|
@@ -7613,7 +7867,7 @@ export function createControlPlaneApi(options) {
|
|
|
7613
7867
|
// the binding sits, so widening this to #977's credential would be a behaviour
|
|
7614
7868
|
// change wearing a security fix's clothes.
|
|
7615
7869
|
if (input.region !== null) {
|
|
7616
|
-
return c
|
|
7870
|
+
return refuse(c, 'forbidden', 'region is derived from the scope, not chosen on a binding');
|
|
7617
7871
|
}
|
|
7618
7872
|
}
|
|
7619
7873
|
// Registrable-suffix guard (#305, D-35): a custom domain must be a real registrable
|
|
@@ -7622,7 +7876,7 @@ export function createControlPlaneApi(options) {
|
|
|
7622
7876
|
if (custom(input.hostname)) {
|
|
7623
7877
|
const bad = validateBindableHostname(input.hostname);
|
|
7624
7878
|
if (bad)
|
|
7625
|
-
return c
|
|
7879
|
+
return refuseStatus(c, 422, bad);
|
|
7626
7880
|
}
|
|
7627
7881
|
await c.var.admin.bindHostname(c.get('actor'), input);
|
|
7628
7882
|
const bound = (await c.var.admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
|
|
@@ -7639,11 +7893,11 @@ export function createControlPlaneApi(options) {
|
|
|
7639
7893
|
app.post('/hostnames/:hostname/verify', async (c) => {
|
|
7640
7894
|
const name = c.req.param('hostname');
|
|
7641
7895
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7642
|
-
return c
|
|
7896
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7643
7897
|
}
|
|
7644
7898
|
const row = await hostnameRow(c, name);
|
|
7645
7899
|
if (!row)
|
|
7646
|
-
return c
|
|
7900
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7647
7901
|
await runIssuance(c.var.admin, c.get('actor'), row);
|
|
7648
7902
|
return c.json(await hostnameRow(c, name));
|
|
7649
7903
|
});
|
|
@@ -7653,7 +7907,7 @@ export function createControlPlaneApi(options) {
|
|
|
7653
7907
|
// `setHostnameStatus` normalizes and 404s an unknown one below the seam.
|
|
7654
7908
|
const name = c.req.param('hostname');
|
|
7655
7909
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7656
|
-
return c
|
|
7910
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7657
7911
|
}
|
|
7658
7912
|
await c.var.admin.setHostnameStatus(c.get('actor'), name, status, note);
|
|
7659
7913
|
const row = (await c.var.admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
|
|
@@ -7667,7 +7921,7 @@ export function createControlPlaneApi(options) {
|
|
|
7667
7921
|
app.delete('/hostnames/:hostname', async (c) => {
|
|
7668
7922
|
const name = c.req.param('hostname');
|
|
7669
7923
|
if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
|
|
7670
|
-
return c
|
|
7924
|
+
return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
|
|
7671
7925
|
}
|
|
7672
7926
|
// Release the Cloudflare custom hostname before dropping the row, or the CF object
|
|
7673
7927
|
// leaks (billable, and it would block a future rebind of the same name). Best-effort:
|
|
@@ -7717,27 +7971,35 @@ export function createControlPlaneApi(options) {
|
|
|
7717
7971
|
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
7718
7972
|
/** Answers whether some of the preview's storage was stranded (and recorded as such). */
|
|
7719
7973
|
const reapPreview = async (c, preview) => {
|
|
7720
|
-
|
|
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
|
|
7721
7988
|
// place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
|
|
7722
7989
|
// would name those clients again. A clean room never had one.
|
|
7723
7990
|
await retireClientsOfReapedScope(previewAuthDeps(c), preview);
|
|
7724
|
-
await
|
|
7991
|
+
await wipeStores();
|
|
7725
7992
|
let storageStranded = false;
|
|
7726
|
-
if (
|
|
7727
|
-
({ storageStranded } = await
|
|
7728
|
-
admin: c.var.admin,
|
|
7729
|
-
actor: c.get('actor'),
|
|
7730
|
-
resolveRef: options.resolveVerticalRef,
|
|
7731
|
-
}, preview.tenantId, preview.id));
|
|
7993
|
+
if (dispatch) {
|
|
7994
|
+
({ storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, preview.tenantId, preview.id));
|
|
7732
7995
|
}
|
|
7733
7996
|
else {
|
|
7734
|
-
await assertNoUnreachableScopeCopies(c.var.admin, c.get('actor'), preview.tenantId, preview.id);
|
|
7735
7997
|
const vertical = await verticalForScope(c, preview);
|
|
7736
7998
|
if (vertical)
|
|
7737
7999
|
await vertical.deleteScope({ tenantId: preview.tenantId, scopeId: preview.id });
|
|
7738
8000
|
}
|
|
7739
8001
|
// Co-located hosts have no script ref; their storage is wiped by deleteSnapshot.
|
|
7740
|
-
await c.var.host.deleteSnapshot(
|
|
8002
|
+
await c.var.host.deleteSnapshot(actor, preview.tenantId, preview.id);
|
|
7741
8003
|
return { storageStranded };
|
|
7742
8004
|
};
|
|
7743
8005
|
/** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
|
|
@@ -8171,23 +8433,23 @@ export function createControlPlaneApi(options) {
|
|
|
8171
8433
|
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
8172
8434
|
const v = await verticalOf(c.get('actor'), slug);
|
|
8173
8435
|
if (!v)
|
|
8174
|
-
return { error: `unknown vertical '${slug}'
|
|
8436
|
+
return { refused: 'not_found', error: `unknown vertical '${slug}'` };
|
|
8175
8437
|
if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
|
|
8176
|
-
return {
|
|
8438
|
+
return { refused: 'forbidden', error: 'forbidden' };
|
|
8177
8439
|
}
|
|
8178
8440
|
if (v.ownerTenant === null)
|
|
8179
|
-
return { error: `vertical '${slug}' has no owner tenant
|
|
8441
|
+
return { refused: 'conflict', error: `vertical '${slug}' has no owner tenant` };
|
|
8180
8442
|
return { tenantId: v.ownerTenant, slug };
|
|
8181
8443
|
};
|
|
8182
8444
|
app.post('/verticals/:slug/previews', async (c) => {
|
|
8183
8445
|
const gate = await previewVertical(c);
|
|
8184
8446
|
if ('error' in gate)
|
|
8185
|
-
return c
|
|
8447
|
+
return refuse(c, gate.refused, gate.error);
|
|
8186
8448
|
const { tenantId, slug } = gate;
|
|
8187
8449
|
const body = createPreviewBody.parse(await c.req.json());
|
|
8188
8450
|
const actor = c.get('actor');
|
|
8189
8451
|
if (body.empty && body.sourceScopeId) {
|
|
8190
|
-
return c
|
|
8452
|
+
return refuse(c, 'validation_failed', `pass either --empty (clean-room) or a sourceScopeId, not both`);
|
|
8191
8453
|
}
|
|
8192
8454
|
// Clean-room (#509 (b)): a null source provisions an EMPTY scope — no prod scope needed.
|
|
8193
8455
|
// Everything else forks the tenant's own active, non-fork scope for this vertical.
|
|
@@ -8201,12 +8463,12 @@ export function createControlPlaneApi(options) {
|
|
|
8201
8463
|
: null;
|
|
8202
8464
|
if (!source) {
|
|
8203
8465
|
if (body.sourceScopeId) {
|
|
8204
|
-
return c
|
|
8466
|
+
return refuse(c, 'not_found', `no active prod scope ${body.sourceScopeId} for '${slug}'`);
|
|
8205
8467
|
}
|
|
8206
8468
|
if (owned.length === 0) {
|
|
8207
|
-
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`);
|
|
8208
8470
|
}
|
|
8209
|
-
return c
|
|
8471
|
+
return refuse(c, 'conflict', `'${slug}' has several prod scopes — pass sourceScopeId`, { scopes: owned.map((s) => s.id) });
|
|
8210
8472
|
}
|
|
8211
8473
|
}
|
|
8212
8474
|
try {
|
|
@@ -8215,14 +8477,14 @@ export function createControlPlaneApi(options) {
|
|
|
8215
8477
|
}
|
|
8216
8478
|
catch (e) {
|
|
8217
8479
|
if (e instanceof ControlPlaneError)
|
|
8218
|
-
return
|
|
8480
|
+
return relayRefusal(c, e);
|
|
8219
8481
|
throw e;
|
|
8220
8482
|
}
|
|
8221
8483
|
});
|
|
8222
8484
|
app.get('/verticals/:slug/previews', async (c) => {
|
|
8223
8485
|
const gate = await previewVertical(c);
|
|
8224
8486
|
if ('error' in gate)
|
|
8225
|
-
return c
|
|
8487
|
+
return refuse(c, gate.refused, gate.error);
|
|
8226
8488
|
const { tenantId, slug } = gate;
|
|
8227
8489
|
const actor = c.get('actor');
|
|
8228
8490
|
const previews = (await c.var.admin.listScopes(actor, { tenantId, vertical: slug }))
|
|
@@ -8248,7 +8510,7 @@ export function createControlPlaneApi(options) {
|
|
|
8248
8510
|
app.delete('/verticals/:slug/previews/:tag', async (c) => {
|
|
8249
8511
|
const gate = await previewVertical(c);
|
|
8250
8512
|
if ('error' in gate)
|
|
8251
|
-
return c
|
|
8513
|
+
return refuse(c, gate.refused, gate.error);
|
|
8252
8514
|
const { tenantId, slug } = gate;
|
|
8253
8515
|
const tag = c.req.param('tag');
|
|
8254
8516
|
const actor = c.get('actor');
|
|
@@ -8266,7 +8528,7 @@ export function createControlPlaneApi(options) {
|
|
|
8266
8528
|
}
|
|
8267
8529
|
catch (e) {
|
|
8268
8530
|
if (e instanceof ControlPlaneError)
|
|
8269
|
-
return
|
|
8531
|
+
return relayRefusal(c, e);
|
|
8270
8532
|
throw e;
|
|
8271
8533
|
}
|
|
8272
8534
|
});
|
|
@@ -8353,7 +8615,7 @@ export function createControlPlaneApi(options) {
|
|
|
8353
8615
|
// not be able to switch itself back on, and the dashboard has no flow that pulls it.
|
|
8354
8616
|
const switchScheduleRoute = (to) => async (c) => {
|
|
8355
8617
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8356
|
-
return c
|
|
8618
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8357
8619
|
}
|
|
8358
8620
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8359
8621
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
@@ -8361,7 +8623,7 @@ export function createControlPlaneApi(options) {
|
|
|
8361
8623
|
const actor = c.get('actor');
|
|
8362
8624
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8363
8625
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8364
|
-
return c
|
|
8626
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8365
8627
|
}
|
|
8366
8628
|
const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
|
|
8367
8629
|
return c.json(to === 'off' ? await c.var.admin.revokeFromSystem(actor, input) : await c.var.admin.restoreToSystem(actor, input));
|
|
@@ -8375,14 +8637,14 @@ export function createControlPlaneApi(options) {
|
|
|
8375
8637
|
// `confinedTenant` check, not a kind test.
|
|
8376
8638
|
app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
|
|
8377
8639
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8378
|
-
return c
|
|
8640
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8379
8641
|
}
|
|
8380
8642
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8381
8643
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8382
8644
|
const actor = c.get('actor');
|
|
8383
8645
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8384
8646
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8385
|
-
return c
|
|
8647
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8386
8648
|
}
|
|
8387
8649
|
return c.json(await c.var.admin.systemGrantsStatus(actor, { tenantId, scopeId }));
|
|
8388
8650
|
});
|
|
@@ -8397,7 +8659,7 @@ export function createControlPlaneApi(options) {
|
|
|
8397
8659
|
// it must not start answering if this path is ever allowlisted for a narrowed read.
|
|
8398
8660
|
app.get('/system-switches', async (c) => {
|
|
8399
8661
|
if (confinedTenant(c.get('principal')) !== null) {
|
|
8400
|
-
return c
|
|
8662
|
+
return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
|
|
8401
8663
|
}
|
|
8402
8664
|
const q = systemSwitchesQuery.parse({
|
|
8403
8665
|
position: c.req.query('position'),
|
|
@@ -8435,12 +8697,12 @@ export function createControlPlaneApi(options) {
|
|
|
8435
8697
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8436
8698
|
const pin = confinedTenant(c.get('principal'));
|
|
8437
8699
|
if (pin !== null && pin !== tenantId)
|
|
8438
|
-
return c
|
|
8700
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8439
8701
|
const body = peerSwitchBody.parse(await c.req.json());
|
|
8440
8702
|
const actor = c.get('actor');
|
|
8441
8703
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8442
8704
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8443
|
-
return c
|
|
8705
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8444
8706
|
}
|
|
8445
8707
|
const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
|
|
8446
8708
|
return c.json(to === 'off' ? await c.var.admin.revokeFromPeer(actor, input) : await c.var.admin.restoreToPeer(actor, input));
|
|
@@ -8455,10 +8717,10 @@ export function createControlPlaneApi(options) {
|
|
|
8455
8717
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8456
8718
|
const pin = confinedTenant(c.get('principal'));
|
|
8457
8719
|
if (pin !== null && pin !== tenantId)
|
|
8458
|
-
return c
|
|
8720
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8459
8721
|
const actor = c.get('actor');
|
|
8460
8722
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8461
|
-
return c
|
|
8723
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8462
8724
|
}
|
|
8463
8725
|
return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
|
|
8464
8726
|
});
|
|
@@ -8471,10 +8733,10 @@ export function createControlPlaneApi(options) {
|
|
|
8471
8733
|
const vertical = c.req.query('vertical');
|
|
8472
8734
|
const pin = confinedTenant(c.get('principal'));
|
|
8473
8735
|
if (pin !== null && pin !== tenantId)
|
|
8474
|
-
return c
|
|
8736
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8475
8737
|
const actor = c.get('actor');
|
|
8476
8738
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
8477
|
-
return c
|
|
8739
|
+
return refuse(c, 'not_found', 'app not found');
|
|
8478
8740
|
return c.json(vertical === undefined
|
|
8479
8741
|
? await c.var.admin.peerBindings(actor, tenantId, scopeId)
|
|
8480
8742
|
: await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
|
|
@@ -8486,11 +8748,11 @@ export function createControlPlaneApi(options) {
|
|
|
8486
8748
|
// The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
|
|
8487
8749
|
// choose a tenant's site on its behalf.
|
|
8488
8750
|
if (pin !== tenantId)
|
|
8489
|
-
return c
|
|
8751
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8490
8752
|
const body = peerBindingBody.parse(await c.req.json());
|
|
8491
8753
|
const actor = c.get('actor');
|
|
8492
8754
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
|
|
8493
|
-
return c
|
|
8755
|
+
return refuse(c, 'not_found', 'app not found');
|
|
8494
8756
|
const operationId = ulid();
|
|
8495
8757
|
const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
|
|
8496
8758
|
const done = await auditedChange({
|
|
@@ -8605,16 +8867,16 @@ export function createControlPlaneApi(options) {
|
|
|
8605
8867
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
8606
8868
|
const pin = confinedTenant(c.get('principal'));
|
|
8607
8869
|
if (pin !== null && pin !== tenantId)
|
|
8608
|
-
return c
|
|
8870
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8609
8871
|
const raw = await c.req.json().catch(() => ({}));
|
|
8610
8872
|
const refused = importCursorAcknowledgementMissing(raw);
|
|
8611
8873
|
if (refused)
|
|
8612
|
-
return c
|
|
8874
|
+
return refuse(c, 'validation_failed', refused);
|
|
8613
8875
|
const move = importCursorMove.parse(raw);
|
|
8614
8876
|
const actor = c.get('actor');
|
|
8615
8877
|
// K-3 first, so a scope of another tenant reads as absent before anything is reached.
|
|
8616
8878
|
if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
|
|
8617
|
-
return c
|
|
8879
|
+
return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
8618
8880
|
}
|
|
8619
8881
|
return c.json(await c.var.admin.moveImportCursor(actor, tenantId, scopeId, move));
|
|
8620
8882
|
});
|
|
@@ -8627,7 +8889,7 @@ export function createControlPlaneApi(options) {
|
|
|
8627
8889
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8628
8890
|
const pin = confinedTenant(c.get('principal'));
|
|
8629
8891
|
if (pin !== null && pin !== tenantId)
|
|
8630
|
-
return c
|
|
8892
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8631
8893
|
const focus = c.req.query('scopeId');
|
|
8632
8894
|
return c.json(await crossVerticalHealth(c.var.host, {
|
|
8633
8895
|
actor: c.get('actor'),
|
|
@@ -8659,7 +8921,7 @@ export function createControlPlaneApi(options) {
|
|
|
8659
8921
|
const input = issueStatusUpdate.parse(await c.req.json());
|
|
8660
8922
|
const updated = await c.var.admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
|
|
8661
8923
|
if (!updated)
|
|
8662
|
-
return c
|
|
8924
|
+
return refuse(c, 'not_found', 'unknown issue fingerprint');
|
|
8663
8925
|
return c.json(updated);
|
|
8664
8926
|
});
|
|
8665
8927
|
// -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
|
|
@@ -8682,17 +8944,17 @@ export function createControlPlaneApi(options) {
|
|
|
8682
8944
|
app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
|
|
8683
8945
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8684
8946
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8685
|
-
return c
|
|
8947
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8686
8948
|
const { status } = findingStatusUpdate.parse(await c.req.json());
|
|
8687
8949
|
const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
|
|
8688
8950
|
if (!updated)
|
|
8689
|
-
return c
|
|
8951
|
+
return refuse(c, 'not_found', 'unknown finding');
|
|
8690
8952
|
return c.json(updated);
|
|
8691
8953
|
});
|
|
8692
8954
|
app.get('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8693
8955
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8694
8956
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8695
|
-
return c
|
|
8957
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8696
8958
|
const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
|
|
8697
8959
|
const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
|
|
8698
8960
|
return c.json({ entries });
|
|
@@ -8702,17 +8964,17 @@ export function createControlPlaneApi(options) {
|
|
|
8702
8964
|
app.post('/tenants/:tenantId/finding-rules', async (c) => {
|
|
8703
8965
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8704
8966
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8705
|
-
return c
|
|
8967
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8706
8968
|
const input = findingRuleInput.parse(await c.req.json());
|
|
8707
8969
|
return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
|
|
8708
8970
|
});
|
|
8709
8971
|
app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
|
|
8710
8972
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
8711
8973
|
if (outsideTenant(c.get('principal'), tenantId))
|
|
8712
|
-
return c
|
|
8974
|
+
return refuse(c, 'forbidden', 'forbidden');
|
|
8713
8975
|
const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
|
|
8714
8976
|
if (!revoked)
|
|
8715
|
-
return c
|
|
8977
|
+
return refuse(c, 'not_found', 'unknown finding rule');
|
|
8716
8978
|
return c.json(revoked);
|
|
8717
8979
|
});
|
|
8718
8980
|
// -- model usage (#1054): meter 3, the one D-30 could not compute -------------
|
|
@@ -8748,6 +9010,166 @@ export function createControlPlaneApi(options) {
|
|
|
8748
9010
|
const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
|
|
8749
9011
|
return c.json(summary);
|
|
8750
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
|
+
});
|
|
8751
9134
|
return app;
|
|
8752
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
|
+
}
|
|
8753
9175
|
//# sourceMappingURL=api.js.map
|