@substrat-run/adapter-cloudflare 0.162.0 → 0.164.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/control-plane-do.d.ts +74 -20
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +183 -41
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +90 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +239 -97
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +36 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +101 -20
- package/dist/scope-do.js.map +1 -1
- package/package.json +16 -13
package/dist/host.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { env as ambientEnv } from 'cloudflare:workers';
|
|
2
2
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
|
-
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, errorCodeOf, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
4
|
+
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, connectorsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, errorCodeOf, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
5
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
6
|
-
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, migratesFrom, servedFromRow, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
8
8
|
import { assertWireAuthFilter, wireAuthAccessTarget, COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
|
|
9
9
|
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
|
|
@@ -79,6 +79,20 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
|
|
|
79
79
|
function projectedRoleRows(roles) {
|
|
80
80
|
return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
|
|
81
81
|
}
|
|
82
|
+
/** #2555: a control-plane-full load whose role set was refused in its unit (`loadProjected`), told apart by identity. */
|
|
83
|
+
const loadRoleRefusals = new WeakSet();
|
|
84
|
+
/**
|
|
85
|
+
* #2555: what a refused second read tells staff. The load landed and judged the older set, and the
|
|
86
|
+
* next fan-out, which is not judged (K-55), would write the refused definition over the seats.
|
|
87
|
+
*/
|
|
88
|
+
const SECOND_READ_REMEDY = "The load landed with the roles it judged, and holds only until the next fan-out: take the dump's seats in that role back, or define the role person-held again (K-55)";
|
|
89
|
+
/**
|
|
90
|
+
* #2555: a load projection's directory state as one comparable text; its judge is not state. Each
|
|
91
|
+
* list is compared as a set: the directory reads some of them in no fixed order.
|
|
92
|
+
*/
|
|
93
|
+
function projectionDigest({ judge: _judge, ...state }) {
|
|
94
|
+
return JSON.stringify(Object.entries(state).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => JSON.stringify(x)).sort() : v]));
|
|
95
|
+
}
|
|
82
96
|
function assertCopyLineage(lineage) {
|
|
83
97
|
if (isPrimaryScope(lineage)) {
|
|
84
98
|
throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
|
|
@@ -151,9 +165,11 @@ export const SWITCH_HOLD_SNAPSHOT_MS = 2_000;
|
|
|
151
165
|
* one older than `SWITCH_HOLD_SNAPSHOT_MS`. A state read that met rewound storage therefore
|
|
152
166
|
* finished at least this long after the hold was written, so a snapshot taken before the hold
|
|
153
167
|
* is past its age by then and is re-read. Each side measures a duration on its own clock;
|
|
154
|
-
* nothing compares two clocks.
|
|
168
|
+
* nothing compares two clocks. Derived from the snapshot age, never set on its own, so no host
|
|
169
|
+
* can be given a settle that does not outlast its snapshot (`switchHoldSnapshotMs`).
|
|
155
170
|
*/
|
|
156
|
-
export const
|
|
171
|
+
export const switchHoldSettleMs = (snapshotMs) => (snapshotMs * 3) / 2;
|
|
172
|
+
export const SWITCH_HOLD_SETTLE_MS = switchHoldSettleMs(SWITCH_HOLD_SNAPSHOT_MS);
|
|
157
173
|
/**
|
|
158
174
|
* #1839: how many times a rewind waits again, after its settle, for a row a late OFF added to its
|
|
159
175
|
* claim to be `SWITCH_HOLD_SETTLE_MS` old. The settle argument above holds per ROW, so a row
|
|
@@ -465,6 +481,9 @@ export class CloudflareScopeHost {
|
|
|
465
481
|
* join this one read rather than each sending their own.
|
|
466
482
|
*/
|
|
467
483
|
holdRefresh = null;
|
|
484
|
+
/** #1819: `SWITCH_HOLD_SNAPSHOT_MS` and `SWITCH_HOLD_SETTLE_MS`, unless `switchHoldSnapshotMs` is given. */
|
|
485
|
+
holdSnapshotMs;
|
|
486
|
+
holdSettleMs;
|
|
468
487
|
/**
|
|
469
488
|
* MUST be constructed per request. Never cache an instance across requests.
|
|
470
489
|
*
|
|
@@ -495,6 +514,12 @@ export class CloudflareScopeHost {
|
|
|
495
514
|
// #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
|
|
496
515
|
this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
|
|
497
516
|
this.streamIdleMs = options.streamIdleTimeoutMs;
|
|
517
|
+
this.holdSnapshotMs = options.switchHoldSnapshotMs ?? SWITCH_HOLD_SNAPSHOT_MS;
|
|
518
|
+
// NaN or Infinity never expires a snapshot; zero or less settles for nothing.
|
|
519
|
+
if (!Number.isFinite(this.holdSnapshotMs) || this.holdSnapshotMs <= 0) {
|
|
520
|
+
throw new RangeError(`switchHoldSnapshotMs must be a positive, finite number of milliseconds, got ${this.holdSnapshotMs}`);
|
|
521
|
+
}
|
|
522
|
+
this.holdSettleMs = switchHoldSettleMs(this.holdSnapshotMs);
|
|
498
523
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
499
524
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
500
525
|
this.scopeNs = options.scope;
|
|
@@ -1506,6 +1531,18 @@ export class CloudflareScopeHost {
|
|
|
1506
1531
|
async redrainCountLocal(scopeId, drainedBefore) {
|
|
1507
1532
|
return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
|
|
1508
1533
|
}
|
|
1534
|
+
/**
|
|
1535
|
+
* RFC wire-signing §4: spend a signed call's nonce in this scope's own object, the one the
|
|
1536
|
+
* call's handler reaches through the same stub. The platform's entry calls it, through the host
|
|
1537
|
+
* the vertical registered, only after every other check of the envelope passed, and only for
|
|
1538
|
+
* the scope the verified envelope names. `true` once per nonce, `false` for a nonce already spent
|
|
1539
|
+
* or a claim after `until`, and a throw when the store is at its cap. The tenant is the one the
|
|
1540
|
+
* envelope names beside the scope; the object is keyed by the scope alone, so it is not read here.
|
|
1541
|
+
* No provisioned check: the call that provisions a scope spends its nonce there too.
|
|
1542
|
+
*/
|
|
1543
|
+
async claimWireNonceLocal(_tenantId, scopeId, nonce, until) {
|
|
1544
|
+
return this.scopeStub(scopeId).claimWireNonce(nonce, until);
|
|
1545
|
+
}
|
|
1509
1546
|
/**
|
|
1510
1547
|
* The PITR bookmarks one scope recorded before its migration passes (#286) — the
|
|
1511
1548
|
* rewind points a backout UI offers. Behind the vertical's platform-gated
|
|
@@ -1735,6 +1772,10 @@ export class CloudflareScopeHost {
|
|
|
1735
1772
|
}
|
|
1736
1773
|
// -- scope lifecycle ------------------------------------------------------
|
|
1737
1774
|
async provisionScope(actor, input) {
|
|
1775
|
+
await this.provisionScopeCreating(actor, input);
|
|
1776
|
+
}
|
|
1777
|
+
/** `provisionScope`, answering whether this call created the directory row (#2555: an import's teardown). */
|
|
1778
|
+
async provisionScopeCreating(actor, input) {
|
|
1738
1779
|
// Shared with the pure adapter so the defaults cannot drift between them.
|
|
1739
1780
|
const record = resolveScopeRecord(input);
|
|
1740
1781
|
// Fail-closed tenant gate throws out of the awaited cp call BEFORE migrate
|
|
@@ -1790,6 +1831,7 @@ export class CloudflareScopeHost {
|
|
|
1790
1831
|
if (created) {
|
|
1791
1832
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
1792
1833
|
}
|
|
1834
|
+
return created;
|
|
1793
1835
|
}
|
|
1794
1836
|
// Per-tenant relational stores (#301, PR-2 — the live D1 path). The coordinator holds
|
|
1795
1837
|
// the platform's D1 credential (the same split secretBox uses: the DO records, it never
|
|
@@ -2175,19 +2217,53 @@ export class CloudflareScopeHost {
|
|
|
2175
2217
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
2176
2218
|
// the end state is the dump, at the source's frontier. Provenance is stamped from
|
|
2177
2219
|
// the dump unless the caller set it (§3: a fork always records its origin).
|
|
2178
|
-
await this.
|
|
2220
|
+
const created = await this.provisionScopeCreating(actor, {
|
|
2179
2221
|
...input,
|
|
2180
2222
|
forkedFrom: input.forkedFrom ?? dump.scopeId,
|
|
2181
2223
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
2182
2224
|
});
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2225
|
+
// A fork: its callers (snapshotScope) hand it a dump the platform exported.
|
|
2226
|
+
const load = { sourceScopeId: dump.scopeId, exact: true };
|
|
2227
|
+
// #2555: an import lands with the directory's current permission state, as a restore does. A
|
|
2228
|
+
// snapshot is left as it was: a same-instant copy of a live scope the fan-out keeps current.
|
|
2229
|
+
const projection = judge ? await this.loadProjectionFor(input.tenantId, input.vertical) : undefined;
|
|
2230
|
+
let reprojected;
|
|
2231
|
+
if (projection === undefined)
|
|
2232
|
+
await this.scopeStub(input.scopeId).importDump(dump.tables, input.scopeId, load);
|
|
2233
|
+
else {
|
|
2234
|
+
try {
|
|
2235
|
+
await this.loadProjected(input.scopeId, dump.tables, load, projection, 'import');
|
|
2236
|
+
}
|
|
2237
|
+
catch (e) {
|
|
2238
|
+
if (loadRoleRefusals.has(e))
|
|
2239
|
+
await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
|
|
2240
|
+
throw e;
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
// The second read comes before the stores, so a scope taken down here has none of the dump's
|
|
2244
|
+
// store rows either.
|
|
2245
|
+
if (projection) {
|
|
2246
|
+
const converged = await this.convergeLoadedProjection(input.tenantId, input.scopeId, input.vertical, projection, 'import');
|
|
2247
|
+
reprojected = converged.reprojected;
|
|
2248
|
+
if ('error' in converged) {
|
|
2249
|
+
// A scope this call created is not activated, and goes as a refused import does. One that
|
|
2250
|
+
// existed has had its rows replaced by the committed load, so its stores follow the load
|
|
2251
|
+
// (as a restore's do) and the import is recorded first.
|
|
2252
|
+
if (created)
|
|
2253
|
+
await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
|
|
2254
|
+
else {
|
|
2255
|
+
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2256
|
+
await this.recordImport(actor, input, dump, reprojected);
|
|
2257
|
+
}
|
|
2258
|
+
throw converged.error;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2188
2261
|
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2189
2262
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
2190
|
-
await this.
|
|
2263
|
+
await this.recordImport(actor, input, dump, reprojected);
|
|
2264
|
+
}
|
|
2265
|
+
async recordImport(actor, input, dump, reprojected) {
|
|
2266
|
+
await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt, ...(reprojected ? { reprojected } : {}) });
|
|
2191
2267
|
}
|
|
2192
2268
|
async restoreScope(actor, tenantId, scopeId, dump, opts) {
|
|
2193
2269
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
@@ -2197,6 +2273,8 @@ export class CloudflareScopeHost {
|
|
|
2197
2273
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
2198
2274
|
// #2555: judged before the fence, so a refused restore moves nothing.
|
|
2199
2275
|
await this.refuseDumpRoles(tenantId, scopeId, dump);
|
|
2276
|
+
// #2555: the directory's state the load projects, judged again in its unit against the seats it loads.
|
|
2277
|
+
const projection = await this.loadProjectionFor(tenantId, existing.vertical);
|
|
2200
2278
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2201
2279
|
await this.fenceOrphanSweep(scopeId, tenantId);
|
|
2202
2280
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2206,21 +2284,25 @@ export class CloudflareScopeHost {
|
|
|
2206
2284
|
const ownStore = this.cpLess || existing.vertical === null;
|
|
2207
2285
|
const replayAt = new Date().toISOString();
|
|
2208
2286
|
const carry = ownStore ? await this.recordedOffCarry(tenantId, scopeId, replayAt) : undefined;
|
|
2209
|
-
const switchedOff = await this.
|
|
2210
|
-
switchOff: carry ? { at: replayAt, ...carry } : undefined,
|
|
2211
|
-
sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
|
|
2212
|
-
});
|
|
2287
|
+
const switchedOff = await this.loadProjected(scopeId, dump.tables, { switchOff: carry ? { at: replayAt, ...carry } : undefined, sourceScopeId: opts?.sourceScopeId ?? dump.scopeId }, projection, 'restore');
|
|
2213
2288
|
await this.importScopeStores(tenantId, scopeId, dump.stores);
|
|
2214
2289
|
const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
|
|
2290
|
+
// The load has committed, so a re-projection refused or failed is recorded with the restore and
|
|
2291
|
+
// the switches still go back off below; it is thrown last, loud.
|
|
2292
|
+
const converged = projection ? await this.convergeLoadedProjection(tenantId, scopeId, existing.vertical, projection, 'restore') : {};
|
|
2293
|
+
const reprojected = converged.reprojected;
|
|
2215
2294
|
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
|
|
2216
2295
|
sourceScopeId: dump.scopeId,
|
|
2217
2296
|
tables: dump.tables.length,
|
|
2218
2297
|
capturedAt: dump.capturedAt,
|
|
2219
2298
|
...replay,
|
|
2299
|
+
...(reprojected ? { reprojected } : {}),
|
|
2220
2300
|
});
|
|
2221
2301
|
if (carry) {
|
|
2222
2302
|
await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
|
|
2223
2303
|
}
|
|
2304
|
+
if ('error' in converged)
|
|
2305
|
+
throw converged.error;
|
|
2224
2306
|
return replay;
|
|
2225
2307
|
}
|
|
2226
2308
|
/**
|
|
@@ -4636,79 +4718,39 @@ export class CloudflareScopeHost {
|
|
|
4636
4718
|
// -- the hostname map (K-26) -------------------------------------------
|
|
4637
4719
|
bindHostname: async (actor, input) => {
|
|
4638
4720
|
const parsed = bindHostnameInput.parse(input);
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
const existing = await this.cp.readHostname(parsed.hostname);
|
|
4644
|
-
// The holder's own status decides whether the name is reclaimable. Read it from
|
|
4645
|
-
// the scope record rather than joining it onto the hostname read: the router
|
|
4646
|
-
// shares that read's shape, and a scope status it can see is an invitation to
|
|
4647
|
-
// re-check suspension there (route-resolver's `readRoute` deliberately has none).
|
|
4648
|
-
const holder = existing && existing.scope_id !== parsed.scopeId
|
|
4649
|
-
? await this.cp.getScopeRecord(existing.tenant_id, existing.scope_id)
|
|
4650
|
-
: undefined;
|
|
4651
|
-
const holderReleased = holder?.status === 'archived' || holder?.status === 'reaped';
|
|
4652
|
-
if (existing && existing.scope_id !== parsed.scopeId && !holderReleased) {
|
|
4653
|
-
// A hostname routes to exactly one place; silently rebinding would move
|
|
4654
|
-
// another tenant's traffic. Exception: the holder is ARCHIVED or REAPED (a
|
|
4655
|
-
// deleted app, storage since wiped) — it has released the name, so the rebind
|
|
4656
|
-
// reclaims it.
|
|
4657
|
-
throw substratError('conflict', `hostname '${parsed.hostname}' is already bound to another scope`);
|
|
4658
|
-
}
|
|
4659
|
-
// Exactly one canonical per (scope, surface).
|
|
4660
|
-
if (parsed.canonical)
|
|
4661
|
-
await this.cp.demoteCanonical(parsed.scopeId, parsed.surface);
|
|
4662
|
-
await this.cp.upsertHostname({
|
|
4721
|
+
// #2526: the scope, the holder whose status decides whether the name is reclaimable
|
|
4722
|
+
// (archived or reaped released it), the canonical's demotion, the bind and its row are one
|
|
4723
|
+
// ControlPlaneDO unit; the row's vertical is the scope's as that unit read it.
|
|
4724
|
+
await this.directory('bindHostnameInDirectory', {
|
|
4663
4725
|
hostname: parsed.hostname,
|
|
4664
4726
|
tenantId: parsed.tenantId,
|
|
4665
4727
|
scopeId: parsed.scopeId,
|
|
4666
|
-
verticalSlug: scope.vertical,
|
|
4667
4728
|
surface: parsed.surface,
|
|
4668
4729
|
region: parsed.region,
|
|
4669
4730
|
canonical: parsed.canonical,
|
|
4670
4731
|
createdAt: new Date().toISOString(),
|
|
4671
|
-
});
|
|
4672
|
-
await this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
|
|
4732
|
+
}, this.adminEntry(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, parsed));
|
|
4673
4733
|
},
|
|
4734
|
+
// #2526: the three below read their row in the unit that writes it (refusing an unbound name
|
|
4735
|
+
// `not_found`, judging the no-op, filling the row's target and `before`), so the template
|
|
4736
|
+
// carries no tenant or scope of its own.
|
|
4674
4737
|
setHostnameStatus: async (actor, raw, status, note) => {
|
|
4675
4738
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4676
|
-
|
|
4677
|
-
if (!row)
|
|
4678
|
-
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
4679
|
-
if (row.status === status)
|
|
4680
|
-
return; // idempotent, unaudited
|
|
4681
|
-
await this.cp.setHostnameStatus(hostname, status, note ?? null);
|
|
4682
|
-
await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
|
|
4739
|
+
await this.directory('setHostnameStatus', hostname, status, note ?? null, this.adminEntry(actor, 'setHostnameStatus', { tenantId: null }, null, { status, note: note ?? null }));
|
|
4683
4740
|
},
|
|
4684
4741
|
setHostnameIssuance: async (actor, raw, fields) => {
|
|
4685
4742
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4686
|
-
|
|
4687
|
-
if (!row)
|
|
4688
|
-
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
4689
|
-
// A poll that finds nothing changed (same status, same records, id already set)
|
|
4690
|
-
// is not an event — skip the write and the audit entry, so the reconcile sweep
|
|
4691
|
-
// does not flood the admin log with no-op rows every interval.
|
|
4692
|
-
const recordsJson = JSON.stringify(fields.validationRecords);
|
|
4693
|
-
const idUnchanged = fields.customHostnameId === undefined || fields.customHostnameId === row.custom_hostname_id;
|
|
4694
|
-
if (row.status === fields.status && (row.validation_records ?? '[]') === recordsJson && idUnchanged) {
|
|
4695
|
-
return;
|
|
4696
|
-
}
|
|
4697
|
-
await this.cp.setHostnameIssuance(hostname, {
|
|
4743
|
+
await this.directory('setHostnameIssuance', hostname, {
|
|
4698
4744
|
status: fields.status,
|
|
4699
4745
|
note: fields.note ?? null,
|
|
4700
4746
|
customHostnameId: fields.customHostnameId,
|
|
4701
|
-
validationRecords: fields.validationRecords.length ?
|
|
4702
|
-
});
|
|
4703
|
-
await this.recordAdmin(actor, 'setHostnameIssuance', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status: fields.status, note: fields.note ?? null });
|
|
4747
|
+
validationRecords: fields.validationRecords.length ? JSON.stringify(fields.validationRecords) : null,
|
|
4748
|
+
}, this.adminEntry(actor, 'setHostnameIssuance', { tenantId: null }, null, { status: fields.status, note: fields.note ?? null }));
|
|
4704
4749
|
},
|
|
4705
4750
|
unbindHostname: async (actor, raw, owner) => {
|
|
4706
4751
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4707
4752
|
// The row the delete removed, so an owner is judged by the delete itself (#2285).
|
|
4708
|
-
|
|
4709
|
-
if (!row)
|
|
4710
|
-
return; // idempotent, and a no-op is not audited
|
|
4711
|
-
await this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
|
|
4753
|
+
await this.directory('deleteHostname', hostname, owner, this.adminEntry(actor, 'unbindHostname', { tenantId: null }, null, null));
|
|
4712
4754
|
},
|
|
4713
4755
|
listHostnames: async (actor, filter) => {
|
|
4714
4756
|
const rows = await this.cp.listHostnames({
|
|
@@ -4761,6 +4803,9 @@ export class CloudflareScopeHost {
|
|
|
4761
4803
|
installSpec.provides = parsed.provides;
|
|
4762
4804
|
if (parsed.requires)
|
|
4763
4805
|
installSpec.requires = parsed.requires;
|
|
4806
|
+
// The declared vertical-defined connectors (vertical-connectors.md §7.2), the dashboard's door.
|
|
4807
|
+
if (parsed.connectors)
|
|
4808
|
+
installSpec.connectors = parsed.connectors;
|
|
4764
4809
|
// Declared provisioner intent (#455) — the request half of the tenant-provisioner
|
|
4765
4810
|
// capability; the grant is its own column, never part of this refreshable bag.
|
|
4766
4811
|
if (parsed.provisions)
|
|
@@ -4912,7 +4957,7 @@ export class CloudflareScopeHost {
|
|
|
4912
4957
|
// Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
|
|
4913
4958
|
await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
|
|
4914
4959
|
},
|
|
4915
|
-
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4960
|
+
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge, options) => {
|
|
4916
4961
|
const incoming = await this.cp.readVersion(versionId);
|
|
4917
4962
|
if (!incoming)
|
|
4918
4963
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
@@ -4958,6 +5003,7 @@ export class CloudflareScopeHost {
|
|
|
4958
5003
|
ack,
|
|
4959
5004
|
channel,
|
|
4960
5005
|
baseId: outgoing?.id ?? null,
|
|
5006
|
+
permissionsChange: outgoing !== undefined && outgoing.permission_digest !== incoming.permission_digest,
|
|
4961
5007
|
migrationsChange: outgoing !== undefined && outgoing.migration_digest !== incoming.migration_digest,
|
|
4962
5008
|
read: async () => ({ base: outgoing ? await reviewed(outgoing) : null, incoming: await reviewed(incoming) }),
|
|
4963
5009
|
});
|
|
@@ -4986,7 +5032,8 @@ export class CloudflareScopeHost {
|
|
|
4986
5032
|
// only if the pointer still names `outgoing`; the promote that lost the race is refused as
|
|
4987
5033
|
// stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
|
|
4988
5034
|
// which record their own — and the acknowledgement is recorded, not just enforced.
|
|
4989
|
-
|
|
5035
|
+
// #2565: the listing's rule and the `unlistedOnly` door are judged in that unit too.
|
|
5036
|
+
const moved = await this.directory('promoteChannel', outgoing?.id ?? null, {
|
|
4990
5037
|
id: ulid(),
|
|
4991
5038
|
vertical_slug: verticalSlug,
|
|
4992
5039
|
channel,
|
|
@@ -4994,7 +5041,7 @@ export class CloudflareScopeHost {
|
|
|
4994
5041
|
from_version_id: outgoing?.id ?? null,
|
|
4995
5042
|
actor,
|
|
4996
5043
|
at: promotedAt,
|
|
4997
|
-
}, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
5044
|
+
}, options?.unlistedOnly === true, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
4998
5045
|
if (!moved)
|
|
4999
5046
|
throw promoteRaceRefusal(channel);
|
|
5000
5047
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
@@ -5175,11 +5222,14 @@ export class CloudflareScopeHost {
|
|
|
5175
5222
|
migrationTag: r.serving_migration_tag,
|
|
5176
5223
|
});
|
|
5177
5224
|
},
|
|
5178
|
-
setVerticalServing: async (actor, verticalSlug, state) => {
|
|
5225
|
+
setVerticalServing: async (actor, verticalSlug, state, expected) => {
|
|
5179
5226
|
const parsed = verticalServingState.parse(state);
|
|
5180
5227
|
const r = await this.cp.readVertical(verticalSlug);
|
|
5181
5228
|
if (!r)
|
|
5182
5229
|
throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
|
|
5230
|
+
// #2577: the caller's upload diffed its DO-class migration from `expected`'s class set and tag.
|
|
5231
|
+
if (expected !== undefined && !migratesFrom(r, expected))
|
|
5232
|
+
throw servingRaceRefusal(verticalSlug);
|
|
5183
5233
|
// #2178: every install on the serving script runs the new version from this moment.
|
|
5184
5234
|
if (r.serving_version_id) {
|
|
5185
5235
|
const incoming = await this.cp.readVersion(parsed.versionId);
|
|
@@ -5187,14 +5237,15 @@ export class CloudflareScopeHost {
|
|
|
5187
5237
|
await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
|
|
5188
5238
|
}
|
|
5189
5239
|
}
|
|
5190
|
-
// #2528: a compare-and-set on the serving
|
|
5191
|
-
// awaits another serve can run in. The DO fills the row's `before`
|
|
5240
|
+
// #2528/#2577: a compare-and-set on the serving state the gate above judged, its class set
|
|
5241
|
+
// and tag included, across awaits another serve can run in. The DO fills the row's `before`
|
|
5242
|
+
// from the state it moves.
|
|
5192
5243
|
const moved = await this.directory('setVerticalServing', verticalSlug, {
|
|
5193
5244
|
ref: parsed.ref,
|
|
5194
5245
|
versionId: parsed.versionId,
|
|
5195
5246
|
doClassesJson: JSON.stringify(parsed.doClasses),
|
|
5196
5247
|
migrationTag: parsed.migrationTag,
|
|
5197
|
-
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }),
|
|
5248
|
+
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), servedFromRow(r));
|
|
5198
5249
|
if (!moved)
|
|
5199
5250
|
throw servingRaceRefusal(verticalSlug);
|
|
5200
5251
|
},
|
|
@@ -6060,32 +6111,22 @@ export class CloudflareScopeHost {
|
|
|
6060
6111
|
},
|
|
6061
6112
|
grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
|
|
6062
6113
|
const input = entitlementGrantInput.parse(plan ?? {});
|
|
6063
|
-
|
|
6114
|
+
// #2526: the held grant (the row's `before`, and the no-op judgement), the upsert and the
|
|
6115
|
+
// row are one ControlPlaneDO unit, which fills `before` and `after`.
|
|
6116
|
+
const result = await this.directory('grantEntitlement', tenantId, entitlementKey, input, actor, this.adminEntry(actor, 'grantEntitlement', { tenantId }, null, null));
|
|
6064
6117
|
if (!result.changed)
|
|
6065
6118
|
return; // idempotent — an unchanged grant is not audited
|
|
6066
|
-
await this.recordAdmin(actor, 'grantEntitlement', { tenantId }, result.before
|
|
6067
|
-
? {
|
|
6068
|
-
entitlementKey,
|
|
6069
|
-
expiresAt: result.before.expires_at,
|
|
6070
|
-
quota: result.before.quota,
|
|
6071
|
-
plan: result.before.plan,
|
|
6072
|
-
}
|
|
6073
|
-
: null, {
|
|
6074
|
-
entitlementKey,
|
|
6075
|
-
expiresAt: result.after.expires_at,
|
|
6076
|
-
quota: result.after.quota,
|
|
6077
|
-
plan: result.after.plan,
|
|
6078
|
-
});
|
|
6079
6119
|
// #304: an entitlement change is a tenant-level write, so it fans out into the
|
|
6080
6120
|
// tenant's projected scopes — the invalidation half of the OQ5 answer. A no-op
|
|
6081
|
-
// unless scope-local projection is on; the reconcile sweep repairs any drop.
|
|
6121
|
+
// unless scope-local projection is on; the reconcile sweep repairs any drop. It runs
|
|
6122
|
+
// after the grant and its row have committed.
|
|
6082
6123
|
await this.fanOut(tenantId);
|
|
6083
6124
|
},
|
|
6084
6125
|
revokeEntitlement: async (actor, tenantId, entitlementKey) => {
|
|
6085
|
-
|
|
6126
|
+
// #2526: the delete and its row, `before` the grant it removed, are one unit.
|
|
6127
|
+
const removed = await this.directory('revokeEntitlement', tenantId, entitlementKey, this.adminEntry(actor, 'revokeEntitlement', { tenantId }, null, null));
|
|
6086
6128
|
if (!removed)
|
|
6087
6129
|
return; // nothing held, nothing changed
|
|
6088
|
-
await this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey, expiresAt: removed.expires_at, quota: removed.quota, plan: removed.plan }, null);
|
|
6089
6130
|
// The revoke must reach the projected scopes so a running vertical stops honouring
|
|
6090
6131
|
// the entitlement — a dropped fan-out here would leave it enforcing a stale grant.
|
|
6091
6132
|
await this.fanOut(tenantId);
|
|
@@ -7208,12 +7249,12 @@ export class CloudflareScopeHost {
|
|
|
7208
7249
|
async switchHeld(scopeId, holdKey) {
|
|
7209
7250
|
const at = Date.now();
|
|
7210
7251
|
let error;
|
|
7211
|
-
if (!this.holdSnapshot || at - this.holdSnapshot.at >
|
|
7252
|
+
if (!this.holdSnapshot || at - this.holdSnapshot.at > this.holdSnapshotMs) {
|
|
7212
7253
|
const inflight = this.holdRefresh;
|
|
7213
7254
|
// Join a read that went out within the snapshot age of this consult: its result is then as
|
|
7214
7255
|
// fresh as one this consult would take itself, which is what the settle argument needs.
|
|
7215
7256
|
// Only the consult that sent the read reports its failure.
|
|
7216
|
-
if (inflight && at - inflight.at <=
|
|
7257
|
+
if (inflight && at - inflight.at <= this.holdSnapshotMs) {
|
|
7217
7258
|
await inflight.done;
|
|
7218
7259
|
}
|
|
7219
7260
|
else {
|
|
@@ -7455,7 +7496,7 @@ export class CloudflareScopeHost {
|
|
|
7455
7496
|
* claimed by the re-read.
|
|
7456
7497
|
*/
|
|
7457
7498
|
async settleClaim(holds, scopeId, claimId) {
|
|
7458
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
7499
|
+
await new Promise((resolve) => setTimeout(resolve, this.holdSettleMs));
|
|
7459
7500
|
for (let waits = 0;; waits++) {
|
|
7460
7501
|
const token = await holds.switchHoldToken();
|
|
7461
7502
|
const off = await this.offInStorage(scopeId);
|
|
@@ -7464,14 +7505,14 @@ export class CloudflareScopeHost {
|
|
|
7464
7505
|
this.holdSnapshot = null;
|
|
7465
7506
|
}
|
|
7466
7507
|
const youngest = await holds.switchHoldYoungestMs(scopeId, claimId);
|
|
7467
|
-
if (youngest === null || youngest >=
|
|
7508
|
+
if (youngest === null || youngest >= this.holdSettleMs)
|
|
7468
7509
|
return;
|
|
7469
7510
|
if (waits === SWITCH_HOLD_EXTRA_WAITS) {
|
|
7470
7511
|
throw new Error(`${REWIND_REFUSED}schedule switches kept being pulled off while this rewind waited for them to ` +
|
|
7471
7512
|
`settle (${waits} extra waits); nothing was rewound, and those switches stay off. Retry the rewind`);
|
|
7472
7513
|
}
|
|
7473
7514
|
// Never longer than one settle, even if the hold object's clock moved back under a restart.
|
|
7474
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
7515
|
+
await new Promise((resolve) => setTimeout(resolve, this.holdSettleMs - Math.max(0, youngest)));
|
|
7475
7516
|
}
|
|
7476
7517
|
}
|
|
7477
7518
|
// -- live reads (#938): the door ------------------------------------------
|
|
@@ -7915,6 +7956,95 @@ export class CloudflareScopeHost {
|
|
|
7915
7956
|
if (refusal)
|
|
7916
7957
|
throw substratError('forbidden', refusal);
|
|
7917
7958
|
}
|
|
7959
|
+
/**
|
|
7960
|
+
* #2555: what a control-plane-full restore or import projects into a scope of `vertical` in the
|
|
7961
|
+
* load's own transaction: the directory's current state for `tenantId`, exactly what a fan-out
|
|
7962
|
+
* sends (`tenantProjection` and the vertical's connection keys), with this host's reservations to
|
|
7963
|
+
* judge its roles by. Null on a host that does not project permissions into its scopes: there the
|
|
7964
|
+
* load only keeps the destination's permission flags.
|
|
7965
|
+
*/
|
|
7966
|
+
async loadProjectionFor(tenantId, vertical) {
|
|
7967
|
+
if (!this.scopeLocalPermissions)
|
|
7968
|
+
return null;
|
|
7969
|
+
const [state, connectionKeys] = await Promise.all([this.tenantProjection(tenantId), this.connectionKeyRows(tenantId, vertical)]);
|
|
7970
|
+
return { tenantId, ...state, connectionKeys, judge: this.roleJudge() };
|
|
7971
|
+
}
|
|
7972
|
+
/**
|
|
7973
|
+
* #2555: `importDumpProjected`, a failure rethrown with its code. The role set refused in the
|
|
7974
|
+
* load's unit is `forbidden`, worded for the verb (`restore refused: `, `import refused: `), and
|
|
7975
|
+
* told apart by identity (`loadRoleRefusals`); the scope keeps its rows.
|
|
7976
|
+
*/
|
|
7977
|
+
async loadProjected(scopeId, tables, opts, projection, verb) {
|
|
7978
|
+
const loaded = unwrapReply(await this.scopeStub(scopeId).importDumpProjected(tables, scopeId, opts, projection));
|
|
7979
|
+
if ('switchedOff' in loaded)
|
|
7980
|
+
return loaded.switchedOff;
|
|
7981
|
+
const refused = substratError('forbidden', `${verb} refused: ${loaded.refusedRoles}`);
|
|
7982
|
+
loadRoleRefusals.add(refused);
|
|
7983
|
+
throw refused;
|
|
7984
|
+
}
|
|
7985
|
+
/**
|
|
7986
|
+
* #2555: an import that cannot finish (its load refused its role set, or its re-projection
|
|
7987
|
+
* refused or failed) takes down the scope it created for itself: only one this call created
|
|
7988
|
+
* (`created`) and still `provisioning`, never activated, so an import pointed at an existing
|
|
7989
|
+
* scope never touches it. Its directory row goes, as the pure host takes such a scope down, so a
|
|
7990
|
+
* retry with the same id imports. The storage object is left for that retry's load to replace;
|
|
7991
|
+
* until then it holds what the provision projected into it (the tenant's roles, tuples,
|
|
7992
|
+
* entitlements, identity links and connection keys) and, after a load, the dump, and no
|
|
7993
|
+
* directory row leads a later tenant deletion to it. Best effort: the refusal is the answer
|
|
7994
|
+
* whatever this does. A teardown that fails is a staff-only ops row (no tenant on it), with the
|
|
7995
|
+
* scope left in `provisioning` for staff to reap. No admin row: the import never happened, and
|
|
7996
|
+
* its `provisionScope` row stays as the record of the attempt.
|
|
7997
|
+
*/
|
|
7998
|
+
async dropRefusedImport(actor, tenantId, scopeId, created) {
|
|
7999
|
+
if (!created)
|
|
8000
|
+
return;
|
|
8001
|
+
try {
|
|
8002
|
+
// Judged again in the directory's own unit, so a concurrent call that activated it keeps it; the
|
|
8003
|
+
// read before it is what an older directory, which ignores the condition, is held to.
|
|
8004
|
+
if ((await this.cp.getScopeRecord(tenantId, scopeId))?.status !== 'provisioning')
|
|
8005
|
+
return;
|
|
8006
|
+
await this.cp.deleteScopeDirectory(scopeId, 'provisioning');
|
|
8007
|
+
}
|
|
8008
|
+
catch (teardown) {
|
|
8009
|
+
await this.admin
|
|
8010
|
+
.recordOpsFailure({
|
|
8011
|
+
actor,
|
|
8012
|
+
operation: 'importScope',
|
|
8013
|
+
stage: 'refused-import-teardown',
|
|
8014
|
+
scopeId,
|
|
8015
|
+
message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
|
|
8016
|
+
})
|
|
8017
|
+
.catch(() => {
|
|
8018
|
+
// Nothing more to do here: the refusal is still the answer.
|
|
8019
|
+
});
|
|
8020
|
+
}
|
|
8021
|
+
}
|
|
8022
|
+
/**
|
|
8023
|
+
* #2555: the directory read again once the load has committed. A tenant-level write made between
|
|
8024
|
+
* the first read and the load fanned out before the load replaced it, so when the directory has
|
|
8025
|
+
* moved, the scope is projected once more, judged in its own unit against the seats the load
|
|
8026
|
+
* brought (`applyJudgedProjectionReply`): a role the directory redefined in the window, which its
|
|
8027
|
+
* own door judged without those seats, is judged with them here. Once at most: a write after this
|
|
8028
|
+
* read fans out to the scope itself. Answers `reprojected` (`true` when it re-projected), and the
|
|
8029
|
+
* error with `'refused'` or `'failed'` when the re-projection could not land, which leaves the
|
|
8030
|
+
* scope on the set the load judged until the next fan-out, which is not judged (K-55); the
|
|
8031
|
+
* refusal says what staff do about it. Ordering projections by a per-tenant revision (#2595)
|
|
8032
|
+
* would make the second read unnecessary.
|
|
8033
|
+
*/
|
|
8034
|
+
async convergeLoadedProjection(tenantId, scopeId, vertical, loaded, verb) {
|
|
8035
|
+
try {
|
|
8036
|
+
const now = await this.loadProjectionFor(tenantId, vertical);
|
|
8037
|
+
if (!now || projectionDigest(now) === projectionDigest(loaded))
|
|
8038
|
+
return {};
|
|
8039
|
+
unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, now.roles, now.tuples, now.entitlements, undefined, now.identities, now.connectionKeys));
|
|
8040
|
+
return { reprojected: true };
|
|
8041
|
+
}
|
|
8042
|
+
catch (error) {
|
|
8043
|
+
if (errorCodeOf(error) !== 'forbidden')
|
|
8044
|
+
return { reprojected: 'failed', error };
|
|
8045
|
+
return { reprojected: 'refused', error: substratError('forbidden', `${verb} refused: ${error.message}. ${SECOND_READ_REMEDY}`) };
|
|
8046
|
+
}
|
|
8047
|
+
}
|
|
7918
8048
|
/**
|
|
7919
8049
|
* #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
|
|
7920
8050
|
* holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
|
|
@@ -8562,6 +8692,18 @@ export class CloudflareScopeHost {
|
|
|
8562
8692
|
}
|
|
8563
8693
|
return connectionLandingsOfManifestJson(v.manifest_json);
|
|
8564
8694
|
}
|
|
8695
|
+
/**
|
|
8696
|
+
* What one pushed version declares about vertical-defined connectors (vertical-connectors.md
|
|
8697
|
+
* §6.4), from its stored manifest: the connector proxy reads it for the running version on every
|
|
8698
|
+
* call. Unaudited for the reason {@link versionImports} is.
|
|
8699
|
+
*/
|
|
8700
|
+
async versionConnectors(verticalSlug, versionId) {
|
|
8701
|
+
const v = await this.cp.readVersion(versionId);
|
|
8702
|
+
if (!v || v.vertical_slug !== verticalSlug) {
|
|
8703
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
8704
|
+
}
|
|
8705
|
+
return connectorsOfManifestJson(v.manifest_json);
|
|
8706
|
+
}
|
|
8565
8707
|
}
|
|
8566
8708
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
8567
8709
|
function modelUsageEntryOf(r) {
|