@substrat-run/adapter-cloudflare 0.136.0 → 0.137.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/checker.d.ts.map +1 -1
- package/dist/checker.js +3 -1
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +25 -27
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +106 -14
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +109 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +423 -111
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +2 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +271 -47
- package/dist/scope-do.js.map +1 -1
- package/dist/system-door.d.ts +21 -0
- package/dist/system-door.d.ts.map +1 -0
- package/dist/system-door.js +13 -0
- package/dist/system-door.js.map +1 -0
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { env as ambientEnv } from 'cloudflare:workers';
|
|
2
2
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
3
|
-
import {
|
|
3
|
+
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
|
+
import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
4
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
5
|
-
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, } from '@substrat-run/kernel';
|
|
6
7
|
import { attributedHost } from '@substrat-run/kernel';
|
|
7
8
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
8
9
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
@@ -37,6 +38,28 @@ function unwrapCapabilityReply(reply) {
|
|
|
37
38
|
* clock; the skew allows for clocks that disagree, and bounds what a forged answer can move.
|
|
38
39
|
*/
|
|
39
40
|
export const LIFECYCLE_EPOCH_SKEW_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
/**
|
|
42
|
+
* #2016: how many served scopes one heal pass asks for their tenant record, beyond the drift and the
|
|
43
|
+
* holds. Rotated least-recently-asked first, so a deployment that cannot answer yet (a build from
|
|
44
|
+
* before the record) costs at most this many deliveries a pass and starves nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const TENANT_UNRECORDED_PER_PASS = 50;
|
|
47
|
+
/**
|
|
48
|
+
* K-3's refusal of a (tenant, scope) pair the host does not hold — the same answer for a scope that
|
|
49
|
+
* does not exist and for one of another tenant (#1718), in one wording.
|
|
50
|
+
*/
|
|
51
|
+
const unknownScopeForTenant = (tenantId, scopeId) => substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
52
|
+
/**
|
|
53
|
+
* #2016: the line a CP-less host writes each time it admits a pair to a scope with no tenant record
|
|
54
|
+
* and no role rows — nothing to hold the pair against. Ids only.
|
|
55
|
+
*/
|
|
56
|
+
export const tenantUnrecordedLine = (tenantId, scopeId) => ({
|
|
57
|
+
substrat: 'tenant-unrecorded',
|
|
58
|
+
level: 'warn',
|
|
59
|
+
tenantId,
|
|
60
|
+
scopeId,
|
|
61
|
+
message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
|
|
62
|
+
});
|
|
40
63
|
/**
|
|
41
64
|
* Refuse to mark a scope a copy unless the request classifies it non-primary (#2005). The
|
|
42
65
|
* classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
|
|
@@ -51,9 +74,10 @@ function assertCopyLineage(lineage) {
|
|
|
51
74
|
* A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
|
|
52
75
|
* The hot path a served scope actually touches becomes trust-the-upstream:
|
|
53
76
|
* - `scopeAccessRefusal` / `setMigrationState` → no-op: the router gates the scope's
|
|
54
|
-
* lifecycle + tenancy from the shared directory, and the vertical
|
|
55
|
-
*
|
|
56
|
-
*
|
|
77
|
+
* lifecycle + tenancy from the shared directory, and the vertical does not re-read a
|
|
78
|
+
* directory it does not have. Its own half of both gates reads the scope's storage
|
|
79
|
+
* instead (`validateScopeAccess`): the tenant the scope was provisioned for (#2016) and the lifecycle
|
|
80
|
+
* the platform delivered (#1713).
|
|
57
81
|
* - `tenantHoldsEntitlement` → true: the SKU was enforced on the shared control plane
|
|
58
82
|
* at provision (before `provisionInstance`), so a scope that EXISTS here was granted
|
|
59
83
|
* it upstream — a single-vertical deployment holds its own entitlements by construction.
|
|
@@ -71,12 +95,14 @@ function nullControlPlane() {
|
|
|
71
95
|
// #1674: the schedule switch's record is a DIRECTORY store, and a CP-less host has no
|
|
72
96
|
// directory — the shared control plane that delegates here keeps it. Same posture as
|
|
73
97
|
// `recordAdmin`: nothing to write, nothing recorded to read back.
|
|
74
|
-
recordSystemSwitchedOff:
|
|
98
|
+
recordSystemSwitchedOff: async () => null,
|
|
75
99
|
recordSystemSwitchedOn: async () => null,
|
|
76
100
|
restoreSystemSwitchRecord: noop,
|
|
77
101
|
listSystemSwitches: async () => [],
|
|
78
102
|
systemSwitchRecordsOf: async () => [],
|
|
79
103
|
switchedOffModulesOf: async () => [],
|
|
104
|
+
// #1823: no tenant tuples here either — the platform reads them and sends `tenantHeld`.
|
|
105
|
+
tenantHeldSystemModules: async () => [],
|
|
80
106
|
};
|
|
81
107
|
return new Proxy({}, {
|
|
82
108
|
get: (_t, prop) => typeof prop === 'string' && prop in passthrough
|
|
@@ -124,6 +150,20 @@ export const SWITCH_HOLD_EXTRA_WAITS = 3;
|
|
|
124
150
|
* clock, against the releasing host's clock: minutes, not seconds.
|
|
125
151
|
*/
|
|
126
152
|
export const SWITCH_HOLD_PENDING_MAX_MS = 5 * 60_000;
|
|
153
|
+
/**
|
|
154
|
+
* An OFF of a tenant-held module whose far end did not attest `deniesTenantGrants` (#1823): it
|
|
155
|
+
* switched, but its evaluator still authorizes the module's tenant-level grant on the scope.
|
|
156
|
+
* Carries the far end's outcome, so the operator's OFF knows whether there is a move to put back.
|
|
157
|
+
*/
|
|
158
|
+
class UnattestedSwitchOff extends Error {
|
|
159
|
+
outcome;
|
|
160
|
+
constructor(scopeId, moduleId, outcome) {
|
|
161
|
+
super(`the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
|
|
162
|
+
`module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
|
|
163
|
+
`Redeploy the vertical, then retry.`);
|
|
164
|
+
this.outcome = outcome;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
127
167
|
/**
|
|
128
168
|
* The attachment bucket a host resolves when it was handed no resolver (#1995): the
|
|
129
169
|
* platform's per-tenant binding, read off this script's own env, named by the one shared
|
|
@@ -543,7 +583,7 @@ export class CloudflareScopeHost {
|
|
|
543
583
|
return {
|
|
544
584
|
startOrJoin: (key, row) => stub.jobRunStartOrJoin(key.moduleId, key.job, key.instance, row),
|
|
545
585
|
get: (id) => stub.jobRunById(id),
|
|
546
|
-
|
|
586
|
+
dueKeys: (now, max) => stub.jobRunsDueKeys(now, max),
|
|
547
587
|
list: (filter) => stub.jobRunList(filter),
|
|
548
588
|
patch: (id, patch) => stub.jobRunPatch(id, patch),
|
|
549
589
|
commitPass: (id, patch) => stub.jobCommitPass(id, patch),
|
|
@@ -585,7 +625,10 @@ export class CloudflareScopeHost {
|
|
|
585
625
|
store: this.jobStore(scopeId),
|
|
586
626
|
handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
|
|
587
627
|
now: () => new Date().toISOString(),
|
|
588
|
-
|
|
628
|
+
// #1834: the door is opened FOR this pass, so its "not now" is tied to this pass alone.
|
|
629
|
+
openScope: async (run, pass) => this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(run.module_id, tenantId, scopeId, undefined, pass)),
|
|
630
|
+
// #1834: only this host's own door refusals defer a pass, recognised by identity.
|
|
631
|
+
deferral: (err, pass) => this.takeDoorWait(err, pass),
|
|
589
632
|
maxPasses: options?.maxPasses,
|
|
590
633
|
limit: options?.limit,
|
|
591
634
|
});
|
|
@@ -665,7 +708,7 @@ export class CloudflareScopeHost {
|
|
|
665
708
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
666
709
|
// K-3: a scope under another tenant is indistinguishable from one that does not exist.
|
|
667
710
|
if (!rec)
|
|
668
|
-
throw
|
|
711
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
669
712
|
if (rec.status !== 'active' && rec.status !== 'provisioning') {
|
|
670
713
|
throw new Error(`scope not migratable (status: ${rec.status}): ${scopeId}`);
|
|
671
714
|
}
|
|
@@ -737,6 +780,31 @@ export class CloudflareScopeHost {
|
|
|
737
780
|
async listCapabilitiesLocal(scopeId, filter) {
|
|
738
781
|
return this.scopeStub(scopeId).listCapabilities(filter);
|
|
739
782
|
}
|
|
783
|
+
/**
|
|
784
|
+
* The platform's `become` mint on the CP-less path (#1686) — `HostAdmin.mintCapability`'s
|
|
785
|
+
* ScopeDO half, for the deployment that serves the scope. Its one caller is an owner claim
|
|
786
|
+
* link, minted on the platform's instruction behind the vertical's platform-gated
|
|
787
|
+
* `/internal/owner-claim`; the control plane made the K-3 check and keeps the admin log before
|
|
788
|
+
* it called. Not a module verb: a module mints through `ctx.capabilities`, which mints `act`
|
|
789
|
+
* capabilities only. Same lifecycle gate as the exchange, so a held scope mints nothing.
|
|
790
|
+
*/
|
|
791
|
+
async mintCapabilityLocal(tenantId, scopeId, input, actor) {
|
|
792
|
+
// Checked on this side too, as `HostAdmin.mintCapability` does: a typed refusal thrown
|
|
793
|
+
// across the RPC arrives as a bare message.
|
|
794
|
+
checkBecomeInput(input, new Date().toISOString());
|
|
795
|
+
await this.assertLive(tenantId, scopeId);
|
|
796
|
+
await this.migrateAndRecord(scopeId);
|
|
797
|
+
return mintedCapability.parse(await this.scopeStub(scopeId).mintBecomeCapability(input, actor));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The platform's revoke on the CP-less path (#1686) — how a re-minted claim link retires the
|
|
801
|
+
* previous one. True when the scope held the capability (revoked now, or already); false when
|
|
802
|
+
* it holds no such capability. No lifecycle gate: a revoke only narrows, so a held scope still
|
|
803
|
+
* takes one. Idempotent.
|
|
804
|
+
*/
|
|
805
|
+
async revokeCapabilityLocal(scopeId, capabilityId, actor) {
|
|
806
|
+
return (await this.scopeStub(scopeId).revokeCapabilityAsPlatform(capabilityId, actor)) !== null;
|
|
807
|
+
}
|
|
740
808
|
/**
|
|
741
809
|
* Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
|
|
742
810
|
* the data half of an orchestrated snapshot (preview-and-snapshots.md §9). Like the
|
|
@@ -745,12 +813,24 @@ export class CloudflareScopeHost {
|
|
|
745
813
|
* activation, version bind) stays on the control plane's side. Because source and
|
|
746
814
|
* destination sit in the same SCOPE namespace, no scope bytes ever leave the
|
|
747
815
|
* deployment — the §9 property the trust line rests on.
|
|
816
|
+
*
|
|
817
|
+
* #2016: `tenantId`, the tenant the platform snapshots for (absent from an older platform). The
|
|
818
|
+
* source must not be foreign to it — a copy never reads another tenant's scope — and the fork
|
|
819
|
+
* records it as its own receipt, not the source's.
|
|
748
820
|
*/
|
|
749
|
-
async snapshotScopeLocal(sourceScopeId, destScopeId) {
|
|
821
|
+
async snapshotScopeLocal(sourceScopeId, destScopeId, tenantId) {
|
|
822
|
+
if (tenantId !== undefined && (await this.scopeStub(sourceScopeId).admission(tenantId)).verdict === 'foreign') {
|
|
823
|
+
throw unknownScopeForTenant(tenantId, sourceScopeId);
|
|
824
|
+
}
|
|
750
825
|
const tables = await this.scopeStub(sourceScopeId).exportDump();
|
|
751
826
|
// #2009: a snapshot is a fork by construction (the control plane's row names `forkedFrom`, so
|
|
752
827
|
// the directory never calls it primary), and a load classifies nothing by itself: marked here.
|
|
753
|
-
await this.scopeStub(destScopeId).importDump(tables, destScopeId, {
|
|
828
|
+
await this.scopeStub(destScopeId).importDump(tables, destScopeId, {
|
|
829
|
+
sourceScopeId,
|
|
830
|
+
exact: true,
|
|
831
|
+
markCopy: true,
|
|
832
|
+
provisionedFor: tenantId,
|
|
833
|
+
});
|
|
754
834
|
return { tables: tables.length };
|
|
755
835
|
}
|
|
756
836
|
/**
|
|
@@ -767,18 +847,23 @@ export class CloudflareScopeHost {
|
|
|
767
847
|
* #1722: `loadStamp`, the stamp a carry leaves on the copy it lands, for a fenced wipe later,
|
|
768
848
|
* and `expect`, the marker the carry read here: the load is refused if the store moved since.
|
|
769
849
|
* #2005: `markCopy` — the directory's classification of the scope, sent when it is not
|
|
770
|
-
* primary; the restore then marks it a copy. Refused if it classifies a primary.
|
|
850
|
+
* primary; the restore then marks it a copy. Refused if it classifies a primary.
|
|
851
|
+
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
852
|
+
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
771
853
|
opts) {
|
|
772
854
|
if (opts?.markCopy)
|
|
773
855
|
assertCopyLineage(opts.markCopy);
|
|
774
856
|
const load = {
|
|
775
|
-
switchOff: opts?.switchedOff
|
|
857
|
+
switchOff: opts?.switchedOff
|
|
858
|
+
? { moduleIds: opts.switchedOff, at: new Date().toISOString(), tenantHeld: opts.tenantHeld }
|
|
859
|
+
: undefined,
|
|
776
860
|
sourceScopeId: opts?.sourceScopeId,
|
|
777
861
|
exact: opts?.exact,
|
|
778
862
|
loadStamp: opts?.loadStamp,
|
|
779
863
|
markCopy: opts?.markCopy !== undefined,
|
|
864
|
+
provisionedFor: opts?.tenantId,
|
|
780
865
|
};
|
|
781
|
-
//
|
|
866
|
+
// The refusals come back as values (a throw over the RPC carries only its message) and are
|
|
782
867
|
// thrown here, in the vertical's own isolate, so its route answers 409 or 412, not a fault.
|
|
783
868
|
const out = await this.scopeStub(scopeId).importDumpChecked(tables, scopeId, {
|
|
784
869
|
...load,
|
|
@@ -789,6 +874,8 @@ export class CloudflareScopeHost {
|
|
|
789
874
|
}
|
|
790
875
|
if (out.refused === 'kept')
|
|
791
876
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
877
|
+
if (out.refused === 'tenant')
|
|
878
|
+
throw substratError('conflict', out.message);
|
|
792
879
|
// A retry of a load that already committed (its answer was lost on the way back) is
|
|
793
880
|
// refused by the marker that load itself moved. The store holding THIS request's stamp
|
|
794
881
|
// says so: no other load writes it. Answered as applied, without `switchedOff`, so the
|
|
@@ -855,9 +942,16 @@ export class CloudflareScopeHost {
|
|
|
855
942
|
* the vertical's `/internal/lifecycle`. Kept unless the scope already holds a newer one, so a
|
|
856
943
|
* push that arrives late cannot undo a later transition. `applied` is what the platform's heal
|
|
857
944
|
* sweep records as delivered; `changed` says the gate's answer moved.
|
|
945
|
+
*
|
|
946
|
+
* #2016: `tenantId`, the tenant the directory delivered it for (absent from an older platform).
|
|
947
|
+
* A scope provisioned for another tenant refuses the delivery (`conflict`) and stores nothing;
|
|
948
|
+
* a scope provisioned before the tenant receipt existed records it here, its role rows agreeing.
|
|
858
949
|
*/
|
|
859
|
-
async setLifecycleLocal(scopeId, next) {
|
|
860
|
-
|
|
950
|
+
async setLifecycleLocal(scopeId, next, tenantId) {
|
|
951
|
+
const out = await this.scopeStub(scopeId).setLifecycle(scopeLifecycle.parse(next), tenantId);
|
|
952
|
+
if ('refused' in out)
|
|
953
|
+
throw substratError('conflict', out.message);
|
|
954
|
+
return lifecycleDelivery.parse(out);
|
|
861
955
|
}
|
|
862
956
|
/**
|
|
863
957
|
* Mark one scope in THIS deployment a copy (#2005), behind the vertical's `/internal/mark-copy`:
|
|
@@ -1145,8 +1239,15 @@ export class CloudflareScopeHost {
|
|
|
1145
1239
|
// after it then finds them off, and audits what the unit moved.
|
|
1146
1240
|
const ownStore = this.cpLess || record.vertical === null;
|
|
1147
1241
|
const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(input.tenantId, input.scopeId) : [];
|
|
1242
|
+
const seatAt = new Date().toISOString();
|
|
1148
1243
|
const switchedOff = await this.scopeStub(input.scopeId).seatTuples(seats, recordedOff.length
|
|
1149
|
-
? {
|
|
1244
|
+
? {
|
|
1245
|
+
scopeId: input.scopeId,
|
|
1246
|
+
moduleIds: recordedOff,
|
|
1247
|
+
at: seatAt,
|
|
1248
|
+
// #1823: a recorded-off module held only by a tenant grant goes back off too.
|
|
1249
|
+
tenantHeld: await this.cp.tenantHeldSystemModules(input.tenantId, recordedOff, seatAt),
|
|
1250
|
+
}
|
|
1150
1251
|
: undefined);
|
|
1151
1252
|
if (ownStore) {
|
|
1152
1253
|
await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: switchedOff });
|
|
@@ -1292,15 +1393,15 @@ export class CloudflareScopeHost {
|
|
|
1292
1393
|
return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
|
|
1293
1394
|
}
|
|
1294
1395
|
async getSystemAttachments(moduleId, tenantId, scopeId) {
|
|
1295
|
-
// Reuse the system invoke door's module, tenant and lifecycle gate
|
|
1296
|
-
// authorizes each open against the attachment target's read permission as
|
|
1396
|
+
// Reuse the system invoke door's module, tenant and lifecycle gate, and its rewind gate
|
|
1397
|
+
// (#1834). The DO authorizes each open against the attachment target's read permission as
|
|
1297
1398
|
// system:<moduleId>; the worker reads bytes only after that gate succeeds.
|
|
1298
|
-
await this.
|
|
1399
|
+
const door = await this.openSystemDoor(moduleId, tenantId, scopeId);
|
|
1299
1400
|
const store = await this.resolveAttachmentStore(tenantId);
|
|
1300
1401
|
const stub = this.scopeStub(scopeId);
|
|
1301
1402
|
return {
|
|
1302
1403
|
open: async (attachmentId) => {
|
|
1303
|
-
const record = await stub.systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId);
|
|
1404
|
+
const record = await door.through((instance) => stub.systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId, instance));
|
|
1304
1405
|
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
1305
1406
|
},
|
|
1306
1407
|
};
|
|
@@ -1506,8 +1607,16 @@ export class CloudflareScopeHost {
|
|
|
1506
1607
|
// restored in its deployment, which the platform carries the same list to.
|
|
1507
1608
|
const ownStore = this.cpLess || existing.vertical === null;
|
|
1508
1609
|
const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(tenantId, scopeId) : [];
|
|
1610
|
+
const replayAt = new Date().toISOString();
|
|
1509
1611
|
const switchedOff = await this.scopeStub(scopeId).importDump(dump.tables, scopeId, {
|
|
1510
|
-
switchOff: recordedOff.length
|
|
1612
|
+
switchOff: recordedOff.length
|
|
1613
|
+
? {
|
|
1614
|
+
moduleIds: recordedOff,
|
|
1615
|
+
at: replayAt,
|
|
1616
|
+
// #1823: a recorded-off module held only by a tenant grant goes back off too.
|
|
1617
|
+
tenantHeld: await this.cp.tenantHeldSystemModules(tenantId, recordedOff, replayAt),
|
|
1618
|
+
}
|
|
1619
|
+
: undefined,
|
|
1511
1620
|
sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
|
|
1512
1621
|
});
|
|
1513
1622
|
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
@@ -1685,12 +1794,93 @@ export class CloudflareScopeHost {
|
|
|
1685
1794
|
* against `system:<moduleId>` grants inside the stub.
|
|
1686
1795
|
*/
|
|
1687
1796
|
async getSystemScope(moduleId, tenantId, scopeId) {
|
|
1797
|
+
return this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(moduleId, tenantId, scopeId));
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* #1834: the system door — the ONE place every entry acting as `system:<moduleId>` passes
|
|
1801
|
+
* through: a schedule's fire, a job run's `pass.scope()`, a module's attachment open. The
|
|
1802
|
+
* module must be registered here and the scope live, as before. Then the door GATES: the
|
|
1803
|
+
* scope's own state read for the module, and after it the rewind hold (#1819), in the order
|
|
1804
|
+
* `SWITCH_HOLD_SETTLE_MS` relies on. A module the hold keeps off is refused here, as a
|
|
1805
|
+
* schedule pass skips it.
|
|
1806
|
+
*
|
|
1807
|
+
* A gate read is about the instance that answered it, and a door can be held for as long as
|
|
1808
|
+
* a job pass runs. So every call through the door is PINNED to that instance: the DO refuses
|
|
1809
|
+
* one that lands on another (`SYSTEM_DOOR_MOVED`), since a PITR restore always restarts the
|
|
1810
|
+
* object, and the door gates again and retries. A restart within one call's round trip is
|
|
1811
|
+
* rare, so past `SYSTEM_DOOR_REGATES` it fails closed (`unavailable`). No clock is involved:
|
|
1812
|
+
* the gate and the call meet the same storage, or the call does not run.
|
|
1813
|
+
*/
|
|
1814
|
+
async openSystemDoor(moduleId, tenantId, scopeId, gated,
|
|
1815
|
+
/** #1834: the job pass this door is opened for; its "not now" refusals are tied to it. */
|
|
1816
|
+
pass) {
|
|
1688
1817
|
if (!this.moduleIds.has(moduleId)) {
|
|
1689
1818
|
throw new Error(`module not registered on this host: ${moduleId}`);
|
|
1690
1819
|
}
|
|
1691
1820
|
await this.assertLive(tenantId, scopeId);
|
|
1692
1821
|
await this.migrateAndRecord(scopeId);
|
|
1693
|
-
|
|
1822
|
+
// A gate this door reads itself reports a hold it could not read; one handed in was the
|
|
1823
|
+
// caller's to report (`runDueSchedules` puts it on its pass report).
|
|
1824
|
+
const regate = async () => {
|
|
1825
|
+
const fresh = await this.systemDoorGate(scopeId, moduleId);
|
|
1826
|
+
if (fresh.error)
|
|
1827
|
+
console.error(`substrat: system door ${moduleId} on ${scopeId}: ${fresh.error}`);
|
|
1828
|
+
return fresh;
|
|
1829
|
+
};
|
|
1830
|
+
let gate = gated ?? (await regate());
|
|
1831
|
+
return {
|
|
1832
|
+
moduleId,
|
|
1833
|
+
through: async (call) => {
|
|
1834
|
+
for (let regates = 0;; regates += 1) {
|
|
1835
|
+
if (gate.held) {
|
|
1836
|
+
// The hold ends when the switch is applied again: a job run waits it out without
|
|
1837
|
+
// spending its retries, because the driver defers on THIS refusal (`doorWait`).
|
|
1838
|
+
throw this.doorWait(pass, substratError('forbidden', `module '${moduleId}' is held off on this scope: it was rewound to before its schedule ` +
|
|
1839
|
+
'switch was turned off, and it stays off until the switch is applied again', { reason: SYSTEM_DOOR_WAIT }));
|
|
1840
|
+
}
|
|
1841
|
+
const answer = await call(gate.instance);
|
|
1842
|
+
if (!isSystemDoorMoved(answer))
|
|
1843
|
+
return answer;
|
|
1844
|
+
if (regates >= SYSTEM_DOOR_REGATES) {
|
|
1845
|
+
throw this.doorWait(pass, substratError('unavailable', `the scope kept restarting under the system door of module '${moduleId}' ` +
|
|
1846
|
+
`(${regates + 1} attempts); nothing ran. Retry later`, { reason: SYSTEM_DOOR_WAIT }));
|
|
1847
|
+
}
|
|
1848
|
+
gate = await regate();
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* #1834: the refusals this host's system door threw that mean "not now" (a hold, a scope that
|
|
1855
|
+
* kept restarting), each tied by identity to the job pass whose door threw it. The job driver
|
|
1856
|
+
* defers a pass only on one of these, never on an error's shape, which any step or operation
|
|
1857
|
+
* could copy. A mark is ORIGIN and FRESHNESS together: it is consumed when the driver takes it,
|
|
1858
|
+
* and only the pass it names can take it, so a handler that keeps a refusal and throws it again
|
|
1859
|
+
* on a later pass is an ordinary failure there. Held weakly: an error nobody holds goes.
|
|
1860
|
+
*/
|
|
1861
|
+
doorWaits = new WeakMap();
|
|
1862
|
+
/** #1834: mark this door's own refusal as "not now" for the pass it was opened for, and hand it back. */
|
|
1863
|
+
doorWait(pass, err) {
|
|
1864
|
+
if (pass)
|
|
1865
|
+
this.doorWaits.set(err, pass);
|
|
1866
|
+
return err;
|
|
1867
|
+
}
|
|
1868
|
+
/** #1834: is `err` a "not now" this host's door threw on `pass`? Consumes the mark when it is. */
|
|
1869
|
+
takeDoorWait(err, pass) {
|
|
1870
|
+
if (typeof err !== 'object' || err === null || this.doorWaits.get(err) !== pass)
|
|
1871
|
+
return false;
|
|
1872
|
+
this.doorWaits.delete(err);
|
|
1873
|
+
return true;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* #1834: the system door's gate — the scope's state read for the module, the instance that
|
|
1877
|
+
* answered it, and then (only for a module the scope has on) the rewind hold (#1819). The
|
|
1878
|
+
* hold is read AFTER the state, which is the order `switchHeld` needs.
|
|
1879
|
+
*/
|
|
1880
|
+
async systemDoorGate(scopeId, moduleId) {
|
|
1881
|
+
const { state, instance } = await this.scopeStub(scopeId).systemDoorState(moduleId);
|
|
1882
|
+
const hold = state === 'on' ? await this.switchHeld(scopeId, moduleId) : { held: false };
|
|
1883
|
+
return { state, instance, held: hold.held, ...(hold.error ? { error: hold.error } : {}) };
|
|
1694
1884
|
}
|
|
1695
1885
|
/**
|
|
1696
1886
|
* #1705: the consumer half. The same lifecycle gate as every door (K-3), then the ScopeDO
|
|
@@ -1772,7 +1962,7 @@ export class CloudflareScopeHost {
|
|
|
1772
1962
|
// provisioning receipt is K-3 here (#1738), read before anything migrates the DO, and the
|
|
1773
1963
|
// refusal reads as the pure host's does: an unknown scope, not an unknown role.
|
|
1774
1964
|
if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
|
|
1775
|
-
throw
|
|
1965
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
1776
1966
|
}
|
|
1777
1967
|
await this.peerScopeGate(tenantId, scopeId, 'canAssign');
|
|
1778
1968
|
const bound = await this.scopeStub(scopeId).canAssignFor(tenantId, scopeId, principalId.parse(principal), roleKey);
|
|
@@ -1782,7 +1972,7 @@ export class CloudflareScopeHost {
|
|
|
1782
1972
|
}
|
|
1783
1973
|
async assignScopeRoleBounded(tenantId, scopeId, caller, assignee, roleKey) {
|
|
1784
1974
|
if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
|
|
1785
|
-
throw
|
|
1975
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
1786
1976
|
}
|
|
1787
1977
|
await this.peerScopeGate(tenantId, scopeId, 'assignScopeRoleBounded');
|
|
1788
1978
|
const bound = await this.scopeStub(scopeId).assignScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(assignee), roleKey);
|
|
@@ -1807,7 +1997,7 @@ export class CloudflareScopeHost {
|
|
|
1807
1997
|
if (!this.cpLess) {
|
|
1808
1998
|
const record = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1809
1999
|
if (!record) {
|
|
1810
|
-
throw
|
|
2000
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
1811
2001
|
}
|
|
1812
2002
|
this.assertServedHere(record, scopeId, verb);
|
|
1813
2003
|
}
|
|
@@ -1875,7 +2065,7 @@ export class CloudflareScopeHost {
|
|
|
1875
2065
|
async capabilityScopeStub(tenantId, scopeId, verb) {
|
|
1876
2066
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1877
2067
|
if (!rec)
|
|
1878
|
-
throw
|
|
2068
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
1879
2069
|
if (this.servesScopesElsewhereNow && rec.vertical !== null) {
|
|
1880
2070
|
throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
|
|
1881
2071
|
'deployment, and platform capability verbs are not delegated there yet');
|
|
@@ -1921,7 +2111,9 @@ export class CloudflareScopeHost {
|
|
|
1921
2111
|
if (!rec || rec.status !== 'active')
|
|
1922
2112
|
return report;
|
|
1923
2113
|
}
|
|
1924
|
-
else if (await this.
|
|
2114
|
+
else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
|
|
2115
|
+
// #2016: the pair is held to the scope's own record first, so a sweep roster entry whose
|
|
2116
|
+
// tenant disagrees with the scope is refused before the probe reads or writes anything.
|
|
1925
2117
|
// #1713: a held scope emits nothing, so judging it stale would only raise a false alarm.
|
|
1926
2118
|
return report;
|
|
1927
2119
|
}
|
|
@@ -1963,13 +2155,15 @@ export class CloudflareScopeHost {
|
|
|
1963
2155
|
return report;
|
|
1964
2156
|
// Only run on a live scope of this tenant; a scope archived between the sweep's
|
|
1965
2157
|
// enumeration and here simply has nothing due. A CP-less host has no directory
|
|
1966
|
-
// to ask (#461), so it reads the lifecycle the platform delivered to the scope (#1713)
|
|
2158
|
+
// to ask (#461), so it reads the lifecycle the platform delivered to the scope (#1713), in
|
|
2159
|
+
// the same call that holds the pair to the scope's own record (#2016): a roster entry whose
|
|
2160
|
+
// tenant disagrees is refused before a grant, a cadence row or a run is read or written.
|
|
1967
2161
|
if (!this.cpLess) {
|
|
1968
2162
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1969
2163
|
if (!rec || rec.status !== 'active')
|
|
1970
2164
|
return report;
|
|
1971
2165
|
}
|
|
1972
|
-
else if (await this.
|
|
2166
|
+
else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
|
|
1973
2167
|
// #1713: the lifecycle the platform delivered holds the scope. Every schedule is
|
|
1974
2168
|
// `skipped` and no cadence row moves, as under the kill switch, so a schedule that
|
|
1975
2169
|
// came due meanwhile fires once on the first pass after the scope is live again.
|
|
@@ -1986,15 +2180,15 @@ export class CloudflareScopeHost {
|
|
|
1986
2180
|
// never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
|
|
1987
2181
|
// before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
|
|
1988
2182
|
// does not touch its cadence rows — so a restore fires a due schedule on the next pass.
|
|
1989
|
-
|
|
1990
|
-
|
|
2183
|
+
// #1819: a scope rewound to before its switch was pulled answers `on`, and the rewind held
|
|
2184
|
+
// the module outside the scope. Both are read by the system door's own gate (#1834), which
|
|
2185
|
+
// every fire below then goes through, pinned to the instance this read came from.
|
|
2186
|
+
const gate = await this.systemDoorGate(scopeId, moduleId);
|
|
2187
|
+
if (gate.state === 'ungranted')
|
|
1991
2188
|
return report;
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
if (hold.error)
|
|
1996
|
-
report.errors.push({ operation: 'switch-hold', error: hold.error });
|
|
1997
|
-
if (state === 'off' || hold.held) {
|
|
2189
|
+
if (gate.error)
|
|
2190
|
+
report.errors.push({ operation: 'switch-hold', error: gate.error });
|
|
2191
|
+
if (gate.state === 'off' || gate.held) {
|
|
1998
2192
|
for (const schedule of schedules) {
|
|
1999
2193
|
report.skipped += 1;
|
|
2000
2194
|
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
@@ -2004,6 +2198,7 @@ export class CloudflareScopeHost {
|
|
|
2004
2198
|
}
|
|
2005
2199
|
const now = Date.now();
|
|
2006
2200
|
const lines = asyncLinePass();
|
|
2201
|
+
let door;
|
|
2007
2202
|
for (const schedule of schedules) {
|
|
2008
2203
|
const last = await stub.scheduleLastRun(schedule.operation);
|
|
2009
2204
|
const lastRun = last ? Date.parse(last) : null;
|
|
@@ -2023,7 +2218,10 @@ export class CloudflareScopeHost {
|
|
|
2023
2218
|
let emitted;
|
|
2024
2219
|
let failure;
|
|
2025
2220
|
try {
|
|
2026
|
-
|
|
2221
|
+
// The gate above already answered for this pass; a fire that meets a restarted scope
|
|
2222
|
+
// is gated again by the door (#1834).
|
|
2223
|
+
door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
|
|
2224
|
+
const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
|
|
2027
2225
|
await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
2028
2226
|
report.fired += 1;
|
|
2029
2227
|
}
|
|
@@ -2058,7 +2256,12 @@ export class CloudflareScopeHost {
|
|
|
2058
2256
|
return report;
|
|
2059
2257
|
}
|
|
2060
2258
|
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
2061
|
-
buildStub(tenantId, scopeId, principal, connectionId,
|
|
2259
|
+
buildStub(tenantId, scopeId, principal, connectionId,
|
|
2260
|
+
/**
|
|
2261
|
+
* #1834: the system door this stub acts through. The ONLY way to make a stub acting as
|
|
2262
|
+
* `system:<moduleId>`: the module is the door's, and every invoke is gated and pinned by it.
|
|
2263
|
+
*/
|
|
2264
|
+
systemDoor, options,
|
|
2062
2265
|
/**
|
|
2063
2266
|
* K-42: the session this stub acts under. Re-read from the directory on
|
|
2064
2267
|
* EVERY invoke rather than captured here — a stub is a capability and
|
|
@@ -2079,6 +2282,7 @@ export class CloudflareScopeHost {
|
|
|
2079
2282
|
*/
|
|
2080
2283
|
verticalCaller) {
|
|
2081
2284
|
const stub = this.scopeStub(scopeId);
|
|
2285
|
+
const systemModuleId = systemDoor?.moduleId;
|
|
2082
2286
|
const cp = this.cp;
|
|
2083
2287
|
const operationEntitlement = this.operationEntitlement;
|
|
2084
2288
|
// The DO needs SOME principal-shaped value for `ctx.principal`; for a
|
|
@@ -2133,7 +2337,15 @@ export class CloudflareScopeHost {
|
|
|
2133
2337
|
}
|
|
2134
2338
|
// K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
|
|
2135
2339
|
const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
|
|
2136
|
-
const
|
|
2340
|
+
const send = (systemDoorInstance) => stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession, verticalCaller, systemDoorInstance);
|
|
2341
|
+
// #1834: through the system door, pinned to the instance its gate read. The DO's
|
|
2342
|
+
// refusal comes back as a failure envelope, so it is rethrown for the door to re-gate.
|
|
2343
|
+
const envelope = systemDoor
|
|
2344
|
+
? await systemDoor.through(async (instance) => {
|
|
2345
|
+
const sent = await send(instance);
|
|
2346
|
+
return sent.systemDoorMoved === true ? SYSTEM_DOOR_MOVED : sent;
|
|
2347
|
+
})
|
|
2348
|
+
: await send();
|
|
2137
2349
|
// The operation failed and the DO handed the error back as DATA — so it still
|
|
2138
2350
|
// has its code and extensions, which a throw across this boundary would have
|
|
2139
2351
|
// stripped down to a message (#113 §3). Rethrown here, where the caller expects
|
|
@@ -2182,6 +2394,12 @@ export class CloudflareScopeHost {
|
|
|
2182
2394
|
throw substratError('unavailable', `${operation} ran on a scope host that did not understand peer callers. ` +
|
|
2183
2395
|
'Retry once the scope has been migrated');
|
|
2184
2396
|
}
|
|
2397
|
+
// #1834, the same shape: a DO too old to know the system door's pin ran the call
|
|
2398
|
+
// without checking it met the storage the door's gate read.
|
|
2399
|
+
if (systemDoor !== undefined && envelope.systemDoor?.honoured !== true) {
|
|
2400
|
+
throw substratError('unavailable', `${operation} ran on a scope host that did not understand the system door's pin. ` +
|
|
2401
|
+
'Retry once the scope has been migrated');
|
|
2402
|
+
}
|
|
2185
2403
|
if (session !== undefined && envelope.impersonation?.honoured !== true) {
|
|
2186
2404
|
throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
|
|
2187
2405
|
'operation may have run unrecorded and unbounded. Retry once the scope has ' +
|
|
@@ -2414,7 +2632,7 @@ export class CloudflareScopeHost {
|
|
|
2414
2632
|
if (!this.cpLess) {
|
|
2415
2633
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2416
2634
|
if (!rec)
|
|
2417
|
-
throw
|
|
2635
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2418
2636
|
vertical = rec.vertical;
|
|
2419
2637
|
}
|
|
2420
2638
|
const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
|
|
@@ -2484,21 +2702,50 @@ export class CloudflareScopeHost {
|
|
|
2484
2702
|
if (!this.cpLess) {
|
|
2485
2703
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2486
2704
|
if (!rec)
|
|
2487
|
-
throw
|
|
2705
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2488
2706
|
vertical = rec.vertical;
|
|
2489
2707
|
}
|
|
2490
2708
|
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
2491
2709
|
// #1819: a scope whose store is here was rewound here, so `switchInScope` releases its
|
|
2492
2710
|
// hold here too. A delegated move releases in the deployment serving the scope.
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2711
|
+
// #1823: a module whose only authority on the scope is a TENANT-level grant has nothing in
|
|
2712
|
+
// the scope's storage for the switch to find, so the directory says whether it is held.
|
|
2713
|
+
// A caller moving several modules reads them in one call and passes each answer in.
|
|
2714
|
+
// An OFF of a tenant-held module is refused, after the far end answers, unless that answer
|
|
2715
|
+
// attests the evaluator that denies a tenant-level grant (`deniesTenantGrants`). A
|
|
2716
|
+
// deployment built before #1823 drops `tenantHeld`, still answers `held: true` (the module's
|
|
2717
|
+
// scope-level grants were there to switch), and keeps authorizing the tenant-level grant —
|
|
2718
|
+
// so its `held` alone would have the platform record a scope off that is not. Every OFF the
|
|
2719
|
+
// platform drives moves through here: an operator's (`switchSystem`, which then puts the
|
|
2720
|
+
// switch back) and a re-assert's, which every carry ends with.
|
|
2721
|
+
const move = async (moduleId, to, at, tenantHeld) => {
|
|
2722
|
+
const held = tenantHeld ?? (await this.cp.tenantHeldSystemModules(tenantId, [moduleId], at)).length > 0;
|
|
2723
|
+
const outcome = await (delegation
|
|
2724
|
+
? delegation.switch({ tenantId, scopeId, moduleId, to, tenantHeld: held })
|
|
2725
|
+
: this.switchInScope(scopeId, moduleId, to, at, held));
|
|
2726
|
+
if (to === 'off' && held && outcome.held && outcome.deniesTenantGrants !== true) {
|
|
2727
|
+
throw new UnattestedSwitchOff(scopeId, moduleId, outcome);
|
|
2728
|
+
}
|
|
2729
|
+
return outcome;
|
|
2730
|
+
};
|
|
2731
|
+
/**
|
|
2732
|
+
* Is the module switched off on the scope right now? Read from wherever `move` writes — the
|
|
2733
|
+
* deployment's status read or this scope's own — for a move that threw (#1823): the switch
|
|
2734
|
+
* may already have moved before the throw (`switchInScope` moves it, then works the rewind
|
|
2735
|
+
* hold), and then the directory's record must stay.
|
|
2736
|
+
*/
|
|
2737
|
+
const isOff = async (moduleId) => {
|
|
2738
|
+
const rows = delegation
|
|
2739
|
+
? await delegation.status({ tenantId, scopeId })
|
|
2740
|
+
: systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
|
|
2741
|
+
return rows.some((r) => r.moduleId === moduleId && r.schedules === 'off');
|
|
2742
|
+
};
|
|
2743
|
+
return { vertical, move, isOff };
|
|
2497
2744
|
};
|
|
2498
2745
|
const switchSystem = async (actor, raw, to) => {
|
|
2499
2746
|
const input = systemSwitch.parse(raw);
|
|
2500
2747
|
const { tenantId, scopeId } = input.node;
|
|
2501
|
-
const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
|
|
2748
|
+
const { vertical, move, isOff } = await systemSwitchTarget(tenantId, scopeId);
|
|
2502
2749
|
// AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
|
|
2503
2750
|
// outcome row after — every attempt, a repeat included. The scope's store and the
|
|
2504
2751
|
// admin log are separate, so no order makes the pair atomic; this one fails toward
|
|
@@ -2509,37 +2756,37 @@ export class CloudflareScopeHost {
|
|
|
2509
2756
|
const target = { tenantId, scopeId, vertical };
|
|
2510
2757
|
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
2511
2758
|
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
2512
|
-
// The directory's record (#1674)
|
|
2513
|
-
//
|
|
2759
|
+
// The directory's record (#1674), written BEFORE the scope moves, both ways, and undone
|
|
2760
|
+
// if the move throws or holds nothing — see `recordSystemSwitchedOn` and (#1823)
|
|
2761
|
+
// `recordSystemSwitchedOff` for why that order is the safe one.
|
|
2514
2762
|
const at = new Date().toISOString();
|
|
2515
2763
|
const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
|
|
2516
2764
|
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
2517
|
-
let prior
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
}
|
|
2765
|
+
let prior;
|
|
2766
|
+
try {
|
|
2767
|
+
prior = to === 'on' ? await this.cp.recordSystemSwitchedOn(record) : await this.cp.recordSystemSwitchedOff(record);
|
|
2768
|
+
}
|
|
2769
|
+
catch (err) {
|
|
2770
|
+
// Nothing has moved: fail the call here, audited. An ON must not switch a scope on
|
|
2771
|
+
// whose record still says off (the next reconcile would switch it back off), and an
|
|
2772
|
+
// OFF must not leave a scope off that the record does not know of (#1823).
|
|
2773
|
+
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
|
|
2774
|
+
throw err;
|
|
2528
2775
|
}
|
|
2529
2776
|
/**
|
|
2530
|
-
*
|
|
2531
|
-
* is answered rather than swallowed — it lands on the outcome row as
|
|
2532
|
-
* the call
|
|
2533
|
-
* turns into a module running again.
|
|
2777
|
+
* The record's undo, after the scope's move threw or held nothing: retried once, and a
|
|
2778
|
+
* failure of both is answered rather than swallowed — it lands on the outcome row as
|
|
2779
|
+
* `recordError`, and the call says so.
|
|
2534
2780
|
*/
|
|
2535
|
-
const
|
|
2781
|
+
const undoRecord = async () => {
|
|
2782
|
+
const undo = () => this.cp.restoreSystemSwitchRecord(record, prior);
|
|
2536
2783
|
try {
|
|
2537
|
-
await
|
|
2784
|
+
await undo();
|
|
2538
2785
|
return null;
|
|
2539
2786
|
}
|
|
2540
2787
|
catch {
|
|
2541
2788
|
try {
|
|
2542
|
-
await
|
|
2789
|
+
await undo();
|
|
2543
2790
|
return null;
|
|
2544
2791
|
}
|
|
2545
2792
|
catch (err) {
|
|
@@ -2552,25 +2799,54 @@ export class CloudflareScopeHost {
|
|
|
2552
2799
|
outcome = await move(input.moduleId, to, at);
|
|
2553
2800
|
}
|
|
2554
2801
|
catch (err) {
|
|
2555
|
-
|
|
2802
|
+
if (err instanceof UnattestedSwitchOff) {
|
|
2803
|
+
// The far end switched, and cannot hold it against the tenant-level grant. Refused:
|
|
2804
|
+
// ON gives back exactly what this OFF took (nothing, when it changed nothing), and
|
|
2805
|
+
// then the record goes back too — neither the scope nor the directory says off. An ON
|
|
2806
|
+
// that fails here leaves the scope off, and the readback rule below keeps its record.
|
|
2807
|
+
const putBack = err.outcome.changed
|
|
2808
|
+
? await move(input.moduleId, 'on', new Date().toISOString(), true).then(() => true, () => false)
|
|
2809
|
+
: true;
|
|
2810
|
+
if (putBack) {
|
|
2811
|
+
const recordError = await undoRecord();
|
|
2812
|
+
await this.recordAdmin(actor, action, target, null, {
|
|
2813
|
+
...base,
|
|
2814
|
+
phase: 'refused',
|
|
2815
|
+
error: err.message,
|
|
2816
|
+
...(recordError ? { recordError } : {}),
|
|
2817
|
+
}).catch(() => undefined);
|
|
2818
|
+
throw substratError('precondition_failed', `${err.message} Nothing was switched` +
|
|
2819
|
+
(recordError ? `, but its directory record could not be put back (${recordError}).` : '.'));
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
// A throw does not say the scope did not move: the move crosses the scope's DO and then
|
|
2823
|
+
// the rewind hold, and a throw from the second leaves the scope's switch moved. So the
|
|
2824
|
+
// record is undone only when the scope reads back in the OTHER position — an OFF that
|
|
2825
|
+
// reads not off, an ON that still reads off — and kept when it reads back as asked.
|
|
2826
|
+
// Undoing an ON that landed would put the record back to `off` beside a scope that is
|
|
2827
|
+
// on, and the next re-assert would switch the operator's ON back off. A read that fails
|
|
2828
|
+
// too counts as off, both ways, so what is left fails toward off: an OFF keeps its
|
|
2829
|
+
// record (never a scope switched off with no record — a wipe or restore would have
|
|
2830
|
+
// nothing to re-assert, and #1743's refusal nothing to read), and an ON is undone back
|
|
2831
|
+
// to `off`, which the next re-assert completes if the scope did move.
|
|
2832
|
+
const off = await isOff(input.moduleId).catch(() => true);
|
|
2833
|
+
const recordKept = off === (to === 'off');
|
|
2834
|
+
const recordError = recordKept ? null : await undoRecord();
|
|
2556
2835
|
// Best effort: the original error is what the caller must see, and the intent row
|
|
2557
2836
|
// already says an attempt was made.
|
|
2558
2837
|
await this.recordAdmin(actor, action, target, null, {
|
|
2559
2838
|
...base,
|
|
2560
2839
|
phase: 'failed',
|
|
2561
2840
|
error: errorOf(err),
|
|
2841
|
+
...(recordKept ? { recordKept: true } : {}),
|
|
2562
2842
|
...(recordError ? { recordError } : {}),
|
|
2563
2843
|
}).catch(() => undefined);
|
|
2564
2844
|
throw err;
|
|
2565
2845
|
}
|
|
2566
|
-
//
|
|
2567
|
-
//
|
|
2568
|
-
// module running.
|
|
2569
|
-
const recordError =
|
|
2570
|
-
? await recordWrite(() => this.cp.recordSystemSwitchedOff(record))
|
|
2571
|
-
: to === 'on' && !outcome.held && prior
|
|
2572
|
-
? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior))
|
|
2573
|
-
: null;
|
|
2846
|
+
// A call that held nothing moved nothing, so its record write is undone: left `off`, it
|
|
2847
|
+
// would switch the module off the day it is installed; left `on`, the next reconcile of
|
|
2848
|
+
// a wiped scope would leave the module running.
|
|
2849
|
+
const recordError = outcome.held ? null : await undoRecord();
|
|
2574
2850
|
await this.recordAdmin(actor, action, target, null, {
|
|
2575
2851
|
...base,
|
|
2576
2852
|
phase: outcome.held ? 'applied' : 'refused',
|
|
@@ -2583,11 +2859,6 @@ export class CloudflareScopeHost {
|
|
|
2583
2859
|
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
|
|
2584
2860
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
2585
2861
|
}
|
|
2586
|
-
if (recordError) {
|
|
2587
|
-
throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
|
|
2588
|
-
`could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
|
|
2589
|
-
`otherwise lose the switch`);
|
|
2590
|
-
}
|
|
2591
2862
|
return {
|
|
2592
2863
|
operationId,
|
|
2593
2864
|
moduleId: input.moduleId,
|
|
@@ -2651,7 +2922,7 @@ export class CloudflareScopeHost {
|
|
|
2651
2922
|
if (!this.cpLess) {
|
|
2652
2923
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2653
2924
|
if (!rec)
|
|
2654
|
-
throw
|
|
2925
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2655
2926
|
vertical = rec.vertical;
|
|
2656
2927
|
}
|
|
2657
2928
|
// A hosted scope (a vertical bound, on a non-cpLess host) has no local storage worth
|
|
@@ -2738,8 +3009,9 @@ export class CloudflareScopeHost {
|
|
|
2738
3009
|
});
|
|
2739
3010
|
}
|
|
2740
3011
|
const results = [];
|
|
3012
|
+
const tenantHeld = new Set(modules.length ? await this.cp.tenantHeldSystemModules(tenantId, modules, at) : []);
|
|
2741
3013
|
for (const moduleId of modules) {
|
|
2742
|
-
const outcome = await move(moduleId, 'off', at);
|
|
3014
|
+
const outcome = await move(moduleId, 'off', at, tenantHeld.has(moduleId));
|
|
2743
3015
|
if (outcome.changed) {
|
|
2744
3016
|
await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
|
|
2745
3017
|
operationId: ulid(),
|
|
@@ -2769,7 +3041,7 @@ export class CloudflareScopeHost {
|
|
|
2769
3041
|
if (!this.cpLess) {
|
|
2770
3042
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2771
3043
|
if (!rec)
|
|
2772
|
-
throw
|
|
3044
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2773
3045
|
vertical = rec.vertical;
|
|
2774
3046
|
}
|
|
2775
3047
|
if (!this.cpLess && vertical !== null && !this.peerSwitchDelegation) {
|
|
@@ -2968,6 +3240,13 @@ export class CloudflareScopeHost {
|
|
|
2968
3240
|
await this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
2969
3241
|
return rows.map((r) => systemSwitchRecord.parse(r));
|
|
2970
3242
|
},
|
|
3243
|
+
tenantHeldSystemModules: async (actor, tenantId, moduleIds) => {
|
|
3244
|
+
const held = moduleIds.length
|
|
3245
|
+
? await this.cp.tenantHeldSystemModules(tenantId, moduleIds, new Date().toISOString())
|
|
3246
|
+
: [];
|
|
3247
|
+
await this.recordAccess(actor, 'tenantHeldSystemModules', { tenantId }, { moduleIds: [...moduleIds] }, held.length);
|
|
3248
|
+
return held;
|
|
3249
|
+
},
|
|
2971
3250
|
reassertSystemSwitches: reassertSystemSwitchesOf,
|
|
2972
3251
|
// #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
|
|
2973
3252
|
// the failure each leaves is the safe one: a mint whose audit row did not land never
|
|
@@ -4738,14 +5017,35 @@ export class CloudflareScopeHost {
|
|
|
4738
5017
|
* The getScope gate (control-plane.md §4.1/§4.2) — the pair check, then the tenant's and
|
|
4739
5018
|
* the scope's lifecycle — thrown on THIS side of the RPC (#1718). The ControlPlaneDO
|
|
4740
5019
|
* answers its refusal as data, because an error thrown there arrives here flattened, with
|
|
4741
|
-
* no code; a record crosses intact.
|
|
4742
|
-
*
|
|
5020
|
+
* no code; a record crosses intact.
|
|
5021
|
+
*
|
|
5022
|
+
* A CP-less host's null control plane answers nothing: the router made the pair check from
|
|
5023
|
+
* the shared directory. The scope's own storage then makes it again (#2016), against the
|
|
5024
|
+
* tenant it was provisioned for (`ScopeDO.admission`), so a vertical that passes a scope id
|
|
5025
|
+
* under the wrong tenant meets K-3's refusal at the door instead of relying on the permission
|
|
5026
|
+
* gate's grant data to deny it.
|
|
5027
|
+
*
|
|
5028
|
+
* Answers with the lifecycle the CP-less scope holds (#1713), read in the same DO call as the
|
|
5029
|
+
* pair check, so an `assertLive` door still costs one round-trip; a door that reads the scope
|
|
5030
|
+
* without the lifecycle half pays the one DO call the pair check needs. Null with a directory,
|
|
5031
|
+
* whose refusal already covered the lifecycle.
|
|
4743
5032
|
*/
|
|
4744
5033
|
async validateScopeAccess(tenantId, scopeId) {
|
|
4745
5034
|
const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
|
|
4746
|
-
if (
|
|
4747
|
-
|
|
4748
|
-
|
|
5035
|
+
if (refusal)
|
|
5036
|
+
throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
|
|
5037
|
+
if (!this.cpLess)
|
|
5038
|
+
return null;
|
|
5039
|
+
const { verdict, lifecycle } = await this.scopeStub(scopeId).admission(tenantId);
|
|
5040
|
+
if (verdict === 'foreign')
|
|
5041
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
5042
|
+
// A scope with no tenant record and no role rows has nothing to hold the pair against, so it is
|
|
5043
|
+
// let through — a compatibility state, not a steady one: the next reconcile (every push runs
|
|
5044
|
+
// one against the scopes behind it, #1172) or lifecycle delivery records the directory's tenant.
|
|
5045
|
+
// Logged on every admission, so an operator sees a scope that has not converged.
|
|
5046
|
+
if (verdict === 'unknown')
|
|
5047
|
+
console.warn(JSON.stringify(tenantUnrecordedLine(tenantId, scopeId)));
|
|
5048
|
+
return lifecycle;
|
|
4749
5049
|
}
|
|
4750
5050
|
/**
|
|
4751
5051
|
* THE lifecycle gate (#1713) at every door that runs a scope's work: a request's stubs and
|
|
@@ -4763,17 +5063,10 @@ export class CloudflareScopeHost {
|
|
|
4763
5063
|
* grants) keep `validateScopeAccess` alone: an operator still reads a suspended scope.
|
|
4764
5064
|
*/
|
|
4765
5065
|
async assertLive(tenantId, scopeId) {
|
|
4766
|
-
await this.validateScopeAccess(tenantId, scopeId);
|
|
4767
|
-
const refusal = await this.cpLessRefusal(scopeId, { tenantId, scopeId });
|
|
5066
|
+
const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId), { tenantId, scopeId });
|
|
4768
5067
|
if (refusal)
|
|
4769
5068
|
throw new Error(refusal);
|
|
4770
5069
|
}
|
|
4771
|
-
/** The delivered lifecycle's refusal on a CP-less host (#1713); always null with a directory. */
|
|
4772
|
-
async cpLessRefusal(scopeId, ids) {
|
|
4773
|
-
if (!this.cpLess)
|
|
4774
|
-
return null;
|
|
4775
|
-
return lifecycleRefusal(await this.scopeStub(scopeId).lifecycle(), ids);
|
|
4776
|
-
}
|
|
4777
5070
|
/**
|
|
4778
5071
|
* Deliver the directory's lifecycle to the deployments serving the matching scopes (#1713):
|
|
4779
5072
|
* one scope after its transition, a tenant's scopes after the tenant's, or the heal sweep's
|
|
@@ -4792,8 +5085,25 @@ export class CloudflareScopeHost {
|
|
|
4792
5085
|
if (!delegation || this.cpLess)
|
|
4793
5086
|
return report;
|
|
4794
5087
|
let targets;
|
|
5088
|
+
// #2016: the heal also asks the served scopes whose deployment has not answered that it holds a
|
|
5089
|
+
// record of the scope's tenant, a copy as much as a primary (no push reconciles a copy), until
|
|
5090
|
+
// each answers that it does. A bounded slice per pass, after the drift and the holds.
|
|
5091
|
+
const askTenant = new Set();
|
|
4795
5092
|
try {
|
|
4796
5093
|
targets = await this.cp.lifecycleTargets(filter);
|
|
5094
|
+
if (filter.drift) {
|
|
5095
|
+
const unrecorded = await this.cp.lifecycleTargets({ unrecorded: true, limit: TENANT_UNRECORDED_PER_PASS });
|
|
5096
|
+
// Noted before asking, so an ask that fails (or a deployment that never answers) rotates
|
|
5097
|
+
// behind the scopes not asked yet, and the next pass reaches them.
|
|
5098
|
+
if (unrecorded.length > 0)
|
|
5099
|
+
await this.cp.recordTenantAsks(unrecorded.map((u) => u.scope_id), new Date().toISOString());
|
|
5100
|
+
const seen = new Set(targets.map((t) => t.scope_id));
|
|
5101
|
+
for (const u of unrecorded) {
|
|
5102
|
+
askTenant.add(u.scope_id);
|
|
5103
|
+
if (!seen.has(u.scope_id))
|
|
5104
|
+
targets.push(u);
|
|
5105
|
+
}
|
|
5106
|
+
}
|
|
4797
5107
|
}
|
|
4798
5108
|
catch (err) {
|
|
4799
5109
|
console.error('substrat: could not read the scopes to deliver a lifecycle to (#1713)', err);
|
|
@@ -4815,8 +5125,10 @@ export class CloudflareScopeHost {
|
|
|
4815
5125
|
// no lifecycle runs live. That keeps an activation, and every transition before a
|
|
4816
5126
|
// deployment carries the route, from posting a delivery that changes nothing. After a
|
|
4817
5127
|
// restore (epoch > 0) no receipt says nothing about what the deployment holds, so it is
|
|
4818
|
-
// delivered. A HOLD is always delivered.
|
|
4819
|
-
if (
|
|
5128
|
+
// delivered. A HOLD is always delivered, and so is a scope the heal asks for its tenant record.
|
|
5129
|
+
if (!askTenant.has(scopeId) &&
|
|
5130
|
+
lifecycleRefusal(lifecycle) === null &&
|
|
5131
|
+
(t.delivered === null ? t.epoch === 0 : t.delivered === lifecycleReceipt(lifecycle))) {
|
|
4820
5132
|
continue;
|
|
4821
5133
|
}
|
|
4822
5134
|
report.attempted += 1;
|
|
@@ -4874,7 +5186,7 @@ export class CloudflareScopeHost {
|
|
|
4874
5186
|
}
|
|
4875
5187
|
}
|
|
4876
5188
|
// The receipt is what the scope HOLDS: a newer delivery it kept instead is the truth.
|
|
4877
|
-
await this.cp.recordLifecycleReceipt(scopeId, lifecycleReceipt(answer.lifecycle), new Date().toISOString());
|
|
5189
|
+
await this.cp.recordLifecycleReceipt(scopeId, lifecycleReceipt(answer.lifecycle), new Date().toISOString(), answer.tenantRecorded === true);
|
|
4878
5190
|
report.delivered += 1;
|
|
4879
5191
|
}
|
|
4880
5192
|
catch (err) {
|
|
@@ -4923,7 +5235,7 @@ export class CloudflareScopeHost {
|
|
|
4923
5235
|
* points ask the directory themselves.
|
|
4924
5236
|
*/
|
|
4925
5237
|
async lifecycleHeld(scopeId) {
|
|
4926
|
-
return (await this.
|
|
5238
|
+
return this.cpLess && lifecycleRefusal(await this.scopeStub(scopeId).lifecycle()) !== null;
|
|
4927
5239
|
}
|
|
4928
5240
|
/**
|
|
4929
5241
|
* K-3's cross-check on its own: the (tenant, scope) pair must exist and agree before a
|
|
@@ -4933,7 +5245,7 @@ export class CloudflareScopeHost {
|
|
|
4933
5245
|
async assertScope(tenantId, scopeId) {
|
|
4934
5246
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4935
5247
|
if (!rec)
|
|
4936
|
-
throw
|
|
5248
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
4937
5249
|
}
|
|
4938
5250
|
/**
|
|
4939
5251
|
* The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
|
|
@@ -4948,7 +5260,7 @@ export class CloudflareScopeHost {
|
|
|
4948
5260
|
async scopeRecordForRead(tenantId, scopeId) {
|
|
4949
5261
|
const row = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4950
5262
|
if (!row)
|
|
4951
|
-
throw
|
|
5263
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
4952
5264
|
if (row.status === 'reaped') {
|
|
4953
5265
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
4954
5266
|
}
|
|
@@ -5229,7 +5541,7 @@ export class CloudflareScopeHost {
|
|
|
5229
5541
|
* its claims before the OFF's join lands. The joined row then holds the module the operator just
|
|
5230
5542
|
* turned ON, until the next surviving move of that switch releases it (another ON does).
|
|
5231
5543
|
*/
|
|
5232
|
-
async switchInScope(scopeId, moduleId, to, at) {
|
|
5544
|
+
async switchInScope(scopeId, moduleId, to, at, tenantHeld = false) {
|
|
5233
5545
|
let scopeClaims;
|
|
5234
5546
|
let readError;
|
|
5235
5547
|
try {
|
|
@@ -5238,7 +5550,7 @@ export class CloudflareScopeHost {
|
|
|
5238
5550
|
catch (err) {
|
|
5239
5551
|
readError = err;
|
|
5240
5552
|
}
|
|
5241
|
-
const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
|
|
5553
|
+
const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld);
|
|
5242
5554
|
if (!scopeClaims)
|
|
5243
5555
|
throw readError;
|
|
5244
5556
|
const now = Date.now();
|
|
@@ -5538,7 +5850,7 @@ export class CloudflareScopeHost {
|
|
|
5538
5850
|
// good. Read first, so a pair the directory does not hold writes nothing.
|
|
5539
5851
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5540
5852
|
if (!scope)
|
|
5541
|
-
throw
|
|
5853
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
5542
5854
|
const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
|
|
5543
5855
|
const connectionKeys = await this.connectionKeyRows(tenantId, scope.vertical);
|
|
5544
5856
|
await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
|
|
@@ -5716,7 +6028,7 @@ export class CloudflareScopeHost {
|
|
|
5716
6028
|
// #1742: the recorded-off modules, switched off after the seat in the same unit, on the
|
|
5717
6029
|
// scope this call provisions — the one the seat's tuples name.
|
|
5718
6030
|
input.switchedOff
|
|
5719
|
-
? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
|
|
6031
|
+
? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString(), tenantHeld: input.tenantHeld }
|
|
5720
6032
|
: undefined, services?.map((id) => `principal:${id}`));
|
|
5721
6033
|
return input.switchedOff ? { switchedOff } : {};
|
|
5722
6034
|
}
|
|
@@ -5865,11 +6177,11 @@ export class CloudflareScopeHost {
|
|
|
5865
6177
|
* admin log and writes the row once this returns. `held: false` is an answer, not a
|
|
5866
6178
|
* throw — see `systemSwitchOutcome`.
|
|
5867
6179
|
*/
|
|
5868
|
-
async systemSwitchLocal(scopeId, moduleId, to) {
|
|
6180
|
+
async systemSwitchLocal(scopeId, moduleId, to, opts) {
|
|
5869
6181
|
// Parsed on the way out: this is the wire answer the platform reads, and the DO's
|
|
5870
6182
|
// plain strings become the published shape here rather than on trust.
|
|
5871
6183
|
// #1819: `switchInScope` releases a rewind's hold on the module once the switch lands.
|
|
5872
|
-
return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString()));
|
|
6184
|
+
return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString(), opts?.tenantHeld ?? false));
|
|
5873
6185
|
}
|
|
5874
6186
|
/**
|
|
5875
6187
|
* The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`
|
|
@@ -5970,7 +6282,7 @@ export class CloudflareScopeHost {
|
|
|
5970
6282
|
}
|
|
5971
6283
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5972
6284
|
if (!rec)
|
|
5973
|
-
throw
|
|
6285
|
+
throw unknownScopeForTenant(tenantId, scopeId);
|
|
5974
6286
|
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
5975
6287
|
// Where the write lands is the peer switch's rule: a scope bound to a vertical is served by
|
|
5976
6288
|
// that vertical's deployment, and its watermark lives there. Without a delegation that
|