@substrat-run/adapter-cloudflare 0.161.0 → 0.163.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 +119 -15
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +292 -41
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +103 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +329 -163
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +63 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +307 -124
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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, 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, 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
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';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
@@ -75,6 +75,24 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
|
|
|
75
75
|
* classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
|
|
76
76
|
* it, so a primary can be marked only by a request that misstates its lineage, never by default.
|
|
77
77
|
*/
|
|
78
|
+
/** A vertical's role definitions as a CP-less projection writes them (`applyProjection`'s rows). */
|
|
79
|
+
function projectedRoleRows(roles) {
|
|
80
|
+
return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
|
|
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
|
+
}
|
|
78
96
|
function assertCopyLineage(lineage) {
|
|
79
97
|
if (isPrimaryScope(lineage)) {
|
|
80
98
|
throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
|
|
@@ -1124,7 +1142,7 @@ export class CloudflareScopeHost {
|
|
|
1124
1142
|
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
1125
1143
|
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
1126
1144
|
opts) {
|
|
1127
|
-
const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1145
|
+
const out = await this.loadLocal(scopeId, opts, (load, repair) => repair ? this.scopeStub(scopeId).importDumpJudged(tables, scopeId, load, repair) : this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1128
1146
|
return { ...out, tables: tables.length };
|
|
1129
1147
|
}
|
|
1130
1148
|
/**
|
|
@@ -1146,7 +1164,9 @@ export class CloudflareScopeHost {
|
|
|
1146
1164
|
}
|
|
1147
1165
|
/** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
|
|
1148
1166
|
async restoreStagedScopeLocal(scopeId, stageId, opts) {
|
|
1149
|
-
return this.loadLocal(scopeId, opts, (load) =>
|
|
1167
|
+
return this.loadLocal(scopeId, opts, (load, repair) => repair
|
|
1168
|
+
? this.scopeStub(scopeId).commitStagedDumpJudged(stageId, scopeId, load, repair)
|
|
1169
|
+
: this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
|
|
1150
1170
|
}
|
|
1151
1171
|
/** Drop the dump staged under `stageId` (#2450), if it is still there. */
|
|
1152
1172
|
async discardStagedScopeLocal(scopeId, stageId) {
|
|
@@ -1156,6 +1176,11 @@ export class CloudflareScopeHost {
|
|
|
1156
1176
|
async loadLocal(scopeId, opts, run) {
|
|
1157
1177
|
if (opts?.markCopy)
|
|
1158
1178
|
assertCopyLineage(opts.markCopy);
|
|
1179
|
+
if (opts?.roles && !opts.tenantId) {
|
|
1180
|
+
throw substratError('validation_failed', 'restore refused: a role repair needs the tenant it is projected for; nothing was loaded');
|
|
1181
|
+
}
|
|
1182
|
+
// #2531: the repair the load judges and projects in its own transaction.
|
|
1183
|
+
const repair = opts?.roles && opts.tenantId ? { tenantId: opts.tenantId, roles: projectedRoleRows(opts.roles), judge: this.roleJudge() } : undefined;
|
|
1159
1184
|
// #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
|
|
1160
1185
|
await this.fenceOrphanSweep(scopeId, opts?.tenantId);
|
|
1161
1186
|
const carry = opts ? recordedOffFromWire(opts) : undefined;
|
|
@@ -1173,14 +1198,22 @@ export class CloudflareScopeHost {
|
|
|
1173
1198
|
...load,
|
|
1174
1199
|
...(opts?.expect ? { expect: opts.expect } : {}),
|
|
1175
1200
|
...(opts?.fence ? { fence: opts.fence } : {}),
|
|
1176
|
-
});
|
|
1201
|
+
}, repair);
|
|
1177
1202
|
if (out.refused === false) {
|
|
1178
|
-
return {
|
|
1203
|
+
return {
|
|
1204
|
+
tables: out.tables ?? 0,
|
|
1205
|
+
...(carry ? { switchedOff: out.switchedOff } : {}),
|
|
1206
|
+
// Only a load that ran the judged method can say the repair landed in it.
|
|
1207
|
+
...(repair && out.rolesProjected === true ? { rolesProjected: true } : {}),
|
|
1208
|
+
};
|
|
1179
1209
|
}
|
|
1180
1210
|
if (out.refused === 'kept')
|
|
1181
1211
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
1182
1212
|
if (out.refused === 'tenant')
|
|
1183
1213
|
throw substratError('conflict', out.message);
|
|
1214
|
+
// #2531: the repair's role set, refused on the seats the load wrote; the whole load rolled back.
|
|
1215
|
+
if (out.refused === 'roles')
|
|
1216
|
+
throw substratError('conflict', `the vertical's roles cannot be defined here: ${out.message}`);
|
|
1184
1217
|
if (out.refused === 'lapsed')
|
|
1185
1218
|
throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
|
|
1186
1219
|
// A retry of a load that already committed (its answer was lost on the way back) is
|
|
@@ -1311,9 +1344,33 @@ export class CloudflareScopeHost {
|
|
|
1311
1344
|
* safe; scope-level tuples (the restored grants) are never touched.
|
|
1312
1345
|
*/
|
|
1313
1346
|
async projectRolesLocal(tenantId, scopeId, roles) {
|
|
1314
|
-
// #2447: the role set is defined here, so it is judged here, before anything is projected
|
|
1347
|
+
// #2447: the role set is defined here, so it is judged here, before anything is projected, and
|
|
1348
|
+
// (#2531) again inside the projection's unit, on the seats the scope holds as it writes.
|
|
1315
1349
|
await this.refuseReservedRoleSet(roles, tenantId, scopeId);
|
|
1316
|
-
|
|
1350
|
+
await this.projectJudged(tenantId, scopeId, projectedRoleRows(roles), []);
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* #2531: what this host's own modules reserve and declare, for a unit that judges a role set in the
|
|
1354
|
+
* scope (`RoleJudge`); the scope class's are joined there.
|
|
1355
|
+
*/
|
|
1356
|
+
roleJudge() {
|
|
1357
|
+
return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* #2531: `applyProjectionReply` with the role set judged in its unit (`applyJudgedProjectionReply`).
|
|
1361
|
+
* A refusal there answers with the staff-facing prefix `refuseReservedRoleSet` gives a pre-check's.
|
|
1362
|
+
* A ScopeDO from before the judged method refuses the call itself, with nothing written.
|
|
1363
|
+
*/
|
|
1364
|
+
async projectJudged(tenantId, scopeId, ...rest) {
|
|
1365
|
+
try {
|
|
1366
|
+
return unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, ...rest));
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
if (errorCodeOf(e) === 'forbidden') {
|
|
1370
|
+
throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${e.message}`);
|
|
1371
|
+
}
|
|
1372
|
+
throw e;
|
|
1373
|
+
}
|
|
1317
1374
|
}
|
|
1318
1375
|
/**
|
|
1319
1376
|
* Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
|
|
@@ -1463,6 +1520,18 @@ export class CloudflareScopeHost {
|
|
|
1463
1520
|
async redrainCountLocal(scopeId, drainedBefore) {
|
|
1464
1521
|
return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
|
|
1465
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* RFC wire-signing §4: spend a signed call's nonce in this scope's own object, the one the
|
|
1525
|
+
* call's handler reaches through the same stub. The platform's entry calls it, through the host
|
|
1526
|
+
* the vertical registered, only after every other check of the envelope passed, and only for
|
|
1527
|
+
* the scope the verified envelope names. `true` once per nonce, `false` for a nonce already spent
|
|
1528
|
+
* or a claim after `until`, and a throw when the store is at its cap. The tenant is the one the
|
|
1529
|
+
* envelope names beside the scope; the object is keyed by the scope alone, so it is not read here.
|
|
1530
|
+
* No provisioned check: the call that provisions a scope spends its nonce there too.
|
|
1531
|
+
*/
|
|
1532
|
+
async claimWireNonceLocal(_tenantId, scopeId, nonce, until) {
|
|
1533
|
+
return this.scopeStub(scopeId).claimWireNonce(nonce, until);
|
|
1534
|
+
}
|
|
1466
1535
|
/**
|
|
1467
1536
|
* The PITR bookmarks one scope recorded before its migration passes (#286) — the
|
|
1468
1537
|
* rewind points a backout UI offers. Behind the vertical's platform-gated
|
|
@@ -1692,6 +1761,10 @@ export class CloudflareScopeHost {
|
|
|
1692
1761
|
}
|
|
1693
1762
|
// -- scope lifecycle ------------------------------------------------------
|
|
1694
1763
|
async provisionScope(actor, input) {
|
|
1764
|
+
await this.provisionScopeCreating(actor, input);
|
|
1765
|
+
}
|
|
1766
|
+
/** `provisionScope`, answering whether this call created the directory row (#2555: an import's teardown). */
|
|
1767
|
+
async provisionScopeCreating(actor, input) {
|
|
1695
1768
|
// Shared with the pure adapter so the defaults cannot drift between them.
|
|
1696
1769
|
const record = resolveScopeRecord(input);
|
|
1697
1770
|
// Fail-closed tenant gate throws out of the awaited cp call BEFORE migrate
|
|
@@ -1747,6 +1820,7 @@ export class CloudflareScopeHost {
|
|
|
1747
1820
|
if (created) {
|
|
1748
1821
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
1749
1822
|
}
|
|
1823
|
+
return created;
|
|
1750
1824
|
}
|
|
1751
1825
|
// Per-tenant relational stores (#301, PR-2 — the live D1 path). The coordinator holds
|
|
1752
1826
|
// the platform's D1 credential (the same split secretBox uses: the DO records, it never
|
|
@@ -2132,19 +2206,53 @@ export class CloudflareScopeHost {
|
|
|
2132
2206
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
2133
2207
|
// the end state is the dump, at the source's frontier. Provenance is stamped from
|
|
2134
2208
|
// the dump unless the caller set it (§3: a fork always records its origin).
|
|
2135
|
-
await this.
|
|
2209
|
+
const created = await this.provisionScopeCreating(actor, {
|
|
2136
2210
|
...input,
|
|
2137
2211
|
forkedFrom: input.forkedFrom ?? dump.scopeId,
|
|
2138
2212
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
2139
2213
|
});
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2214
|
+
// A fork: its callers (snapshotScope) hand it a dump the platform exported.
|
|
2215
|
+
const load = { sourceScopeId: dump.scopeId, exact: true };
|
|
2216
|
+
// #2555: an import lands with the directory's current permission state, as a restore does. A
|
|
2217
|
+
// snapshot is left as it was: a same-instant copy of a live scope the fan-out keeps current.
|
|
2218
|
+
const projection = judge ? await this.loadProjectionFor(input.tenantId, input.vertical) : undefined;
|
|
2219
|
+
let reprojected;
|
|
2220
|
+
if (projection === undefined)
|
|
2221
|
+
await this.scopeStub(input.scopeId).importDump(dump.tables, input.scopeId, load);
|
|
2222
|
+
else {
|
|
2223
|
+
try {
|
|
2224
|
+
await this.loadProjected(input.scopeId, dump.tables, load, projection, 'import');
|
|
2225
|
+
}
|
|
2226
|
+
catch (e) {
|
|
2227
|
+
if (loadRoleRefusals.has(e))
|
|
2228
|
+
await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
|
|
2229
|
+
throw e;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
// The second read comes before the stores, so a scope taken down here has none of the dump's
|
|
2233
|
+
// store rows either.
|
|
2234
|
+
if (projection) {
|
|
2235
|
+
const converged = await this.convergeLoadedProjection(input.tenantId, input.scopeId, input.vertical, projection, 'import');
|
|
2236
|
+
reprojected = converged.reprojected;
|
|
2237
|
+
if ('error' in converged) {
|
|
2238
|
+
// A scope this call created is not activated, and goes as a refused import does. One that
|
|
2239
|
+
// existed has had its rows replaced by the committed load, so its stores follow the load
|
|
2240
|
+
// (as a restore's do) and the import is recorded first.
|
|
2241
|
+
if (created)
|
|
2242
|
+
await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
|
|
2243
|
+
else {
|
|
2244
|
+
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2245
|
+
await this.recordImport(actor, input, dump, reprojected);
|
|
2246
|
+
}
|
|
2247
|
+
throw converged.error;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2145
2250
|
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
2146
2251
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
2147
|
-
await this.
|
|
2252
|
+
await this.recordImport(actor, input, dump, reprojected);
|
|
2253
|
+
}
|
|
2254
|
+
async recordImport(actor, input, dump, reprojected) {
|
|
2255
|
+
await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt, ...(reprojected ? { reprojected } : {}) });
|
|
2148
2256
|
}
|
|
2149
2257
|
async restoreScope(actor, tenantId, scopeId, dump, opts) {
|
|
2150
2258
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
@@ -2154,6 +2262,8 @@ export class CloudflareScopeHost {
|
|
|
2154
2262
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
2155
2263
|
// #2555: judged before the fence, so a refused restore moves nothing.
|
|
2156
2264
|
await this.refuseDumpRoles(tenantId, scopeId, dump);
|
|
2265
|
+
// #2555: the directory's state the load projects, judged again in its unit against the seats it loads.
|
|
2266
|
+
const projection = await this.loadProjectionFor(tenantId, existing.vertical);
|
|
2157
2267
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2158
2268
|
await this.fenceOrphanSweep(scopeId, tenantId);
|
|
2159
2269
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2163,21 +2273,25 @@ export class CloudflareScopeHost {
|
|
|
2163
2273
|
const ownStore = this.cpLess || existing.vertical === null;
|
|
2164
2274
|
const replayAt = new Date().toISOString();
|
|
2165
2275
|
const carry = ownStore ? await this.recordedOffCarry(tenantId, scopeId, replayAt) : undefined;
|
|
2166
|
-
const switchedOff = await this.
|
|
2167
|
-
switchOff: carry ? { at: replayAt, ...carry } : undefined,
|
|
2168
|
-
sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
|
|
2169
|
-
});
|
|
2276
|
+
const switchedOff = await this.loadProjected(scopeId, dump.tables, { switchOff: carry ? { at: replayAt, ...carry } : undefined, sourceScopeId: opts?.sourceScopeId ?? dump.scopeId }, projection, 'restore');
|
|
2170
2277
|
await this.importScopeStores(tenantId, scopeId, dump.stores);
|
|
2171
2278
|
const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
|
|
2279
|
+
// The load has committed, so a re-projection refused or failed is recorded with the restore and
|
|
2280
|
+
// the switches still go back off below; it is thrown last, loud.
|
|
2281
|
+
const converged = projection ? await this.convergeLoadedProjection(tenantId, scopeId, existing.vertical, projection, 'restore') : {};
|
|
2282
|
+
const reprojected = converged.reprojected;
|
|
2172
2283
|
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
|
|
2173
2284
|
sourceScopeId: dump.scopeId,
|
|
2174
2285
|
tables: dump.tables.length,
|
|
2175
2286
|
capturedAt: dump.capturedAt,
|
|
2176
2287
|
...replay,
|
|
2288
|
+
...(reprojected ? { reprojected } : {}),
|
|
2177
2289
|
});
|
|
2178
2290
|
if (carry) {
|
|
2179
2291
|
await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
|
|
2180
2292
|
}
|
|
2293
|
+
if ('error' in converged)
|
|
2294
|
+
throw converged.error;
|
|
2181
2295
|
return replay;
|
|
2182
2296
|
}
|
|
2183
2297
|
/**
|
|
@@ -3762,7 +3876,9 @@ export class CloudflareScopeHost {
|
|
|
3762
3876
|
await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
|
|
3763
3877
|
}
|
|
3764
3878
|
else {
|
|
3765
|
-
|
|
3879
|
+
// #2526: a tenant-level grant is a directory tuple, written by the ControlPlaneDO in the
|
|
3880
|
+
// unit that writes its admin row — never through here.
|
|
3881
|
+
throw new Error('writeGrant: a tenant-level grant is written with its row in the ControlPlaneDO');
|
|
3766
3882
|
}
|
|
3767
3883
|
};
|
|
3768
3884
|
/**
|
|
@@ -4294,11 +4410,11 @@ export class CloudflareScopeHost {
|
|
|
4294
4410
|
// `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
|
|
4295
4411
|
// before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
|
|
4296
4412
|
// than being misread.
|
|
4297
|
-
|
|
4413
|
+
// #2526: through `reply`, with the row, which the DO writes in the definition's unit, its
|
|
4414
|
+
// `before` the role it read there. A DO from before #2526 does not list the method, and refuses.
|
|
4415
|
+
const defined = await this.directory('defineRoleOrRefusal', tenantId, parsed, context.reservation, context.seatedInScopes, this.adminEntry(actor, 'defineRole', { tenantId }, null, parsed));
|
|
4298
4416
|
if ('refused' in defined)
|
|
4299
4417
|
throw substratError('forbidden', defined.refused);
|
|
4300
|
-
const before = defined.before;
|
|
4301
|
-
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
4302
4418
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
4303
4419
|
},
|
|
4304
4420
|
listRoles: async (actor, filter) => {
|
|
@@ -4323,29 +4439,22 @@ export class CloudflareScopeHost {
|
|
|
4323
4439
|
assignRole: async (actor, assignment) => {
|
|
4324
4440
|
// #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
|
|
4325
4441
|
// At a scope node, read where the checker reads the role, against the scope class's own
|
|
4326
|
-
// reservations (it answers null when it has none). At the tenant node, against this host's
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
}
|
|
4337
|
-
const subject = `principal:${assignment.principalId}`;
|
|
4338
|
-
if (assignment.node.scopeId) {
|
|
4339
|
-
await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
|
|
4340
|
-
}
|
|
4341
|
-
else {
|
|
4342
|
-
await this.cp.writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`, null);
|
|
4442
|
+
// reservations (it answers null when it has none). At the tenant node, against this host's,
|
|
4443
|
+
// in the ControlPlaneDO's unit that writes the tuple and its row (#2526).
|
|
4444
|
+
const { tenantId, scopeId } = assignment.node;
|
|
4445
|
+
if (!scopeId) {
|
|
4446
|
+
const assigned = await this.directory('assignTenantRole', tenantId, assignment.principalId, assignment.roleKey, [...this.reservedKeys], this.adminEntry(actor, 'assignRole', { tenantId, scopeId }, null, assignment));
|
|
4447
|
+
if (assigned.refused)
|
|
4448
|
+
throw substratError('forbidden', assigned.refused);
|
|
4449
|
+
// A tenant-level assignment changes the projected set and must fan out.
|
|
4450
|
+
await this.fanOut(tenantId);
|
|
4451
|
+
return;
|
|
4343
4452
|
}
|
|
4344
|
-
await this.
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4453
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform');
|
|
4454
|
+
if (refusal)
|
|
4455
|
+
throw substratError('forbidden', refusal);
|
|
4456
|
+
await this.writeScopeTuple(scopeId, `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeId}`, null);
|
|
4457
|
+
await this.recordAdmin(actor, 'assignRole', { tenantId, scopeId }, null, assignment);
|
|
4349
4458
|
},
|
|
4350
4459
|
unassignRole: async (actor, assignment) => {
|
|
4351
4460
|
// Tombstone (K-21) — the checker skips revoked rows. A no-op returns false so
|
|
@@ -4372,12 +4481,18 @@ export class CloudflareScopeHost {
|
|
|
4372
4481
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
4373
4482
|
const grant = capabilityGrant.parse(raw);
|
|
4374
4483
|
await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4484
|
+
const subject = `principal:${grant.principalId}`;
|
|
4485
|
+
const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
|
|
4486
|
+
if (!grant.node.scopeId && !grant.entity) {
|
|
4487
|
+
// #2526: a directory tuple, written with its row in one ControlPlaneDO unit. Tenant-level
|
|
4488
|
+
// → it changes the projected set, so it fans out.
|
|
4489
|
+
await this.directory('writeTenantTuple', grant.node.tenantId, subject, `granted:${grant.permission}`, `tenant:${grant.node.tenantId}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grant', target, null, grant));
|
|
4380
4490
|
await this.fanOut(grant.node.tenantId);
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
// Scope-level and entity grants write scope tuples (already local): no fan-out.
|
|
4494
|
+
await writeGrant(subject, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
4495
|
+
await this.recordAdmin(actor, 'grant', target, null, grant);
|
|
4381
4496
|
},
|
|
4382
4497
|
grantEntityShape: async (actor, grant) => {
|
|
4383
4498
|
await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
|
|
@@ -4427,27 +4542,37 @@ export class CloudflareScopeHost {
|
|
|
4427
4542
|
// fails below is still gathered by the next provision/reconcile — the
|
|
4428
4543
|
// repair channel — instead of vanishing. The tuple stays the only thing
|
|
4429
4544
|
// the permission checker reads; this row is what the platform gathers
|
|
4430
|
-
// from so scopes provisioned AFTER the grant receive it too.
|
|
4431
|
-
|
|
4545
|
+
// from so scopes provisioned AFTER the grant receive it too. #2526: the
|
|
4546
|
+
// record, the checks above made again, the admin row and — at the tenant
|
|
4547
|
+
// node — the tuple are one ControlPlaneDO unit.
|
|
4548
|
+
const { vertical } = await this.directory('grantConnectionInDirectory', {
|
|
4432
4549
|
connectionId: grant.connectionId,
|
|
4433
4550
|
tenantId: grant.node.tenantId,
|
|
4434
|
-
vertical: conn.vertical,
|
|
4435
4551
|
permission: grant.permission,
|
|
4436
4552
|
scopeId: grant.node.scopeId ?? null,
|
|
4437
4553
|
expiresAt: grant.expiresAt ?? null,
|
|
4438
4554
|
grantedBy: grant.grantedBy,
|
|
4439
4555
|
grantedAt: new Date().toISOString(),
|
|
4440
|
-
}
|
|
4556
|
+
}, this.adminEntry(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
4557
|
+
connectionId: grant.connectionId,
|
|
4558
|
+
provider: conn.provider,
|
|
4559
|
+
permission: grant.permission,
|
|
4560
|
+
node: grant.node,
|
|
4561
|
+
}));
|
|
4562
|
+
if (!grant.node.scopeId) {
|
|
4563
|
+
// A tenant tuple changes the projected set, as its siblings' do: it fans out.
|
|
4564
|
+
await this.fanOut(grant.node.tenantId);
|
|
4565
|
+
return;
|
|
4566
|
+
}
|
|
4441
4567
|
// #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
|
|
4442
4568
|
// deployment serving it — for the shared control plane that is the vertical's
|
|
4443
|
-
// dispatch script, so the tuple write rides the delegation seam.
|
|
4444
|
-
|
|
4445
|
-
if (this.connectorDelegation && grant.node.scopeId) {
|
|
4569
|
+
// dispatch script, so the tuple write rides the delegation seam.
|
|
4570
|
+
if (this.connectorDelegation) {
|
|
4446
4571
|
await this.connectorDelegation.grant({
|
|
4447
4572
|
connectionId: grant.connectionId,
|
|
4448
4573
|
tenantId: grant.node.tenantId,
|
|
4449
4574
|
scopeId: grant.node.scopeId,
|
|
4450
|
-
vertical
|
|
4575
|
+
vertical,
|
|
4451
4576
|
permission: grant.permission,
|
|
4452
4577
|
expiresAt: grant.expiresAt,
|
|
4453
4578
|
});
|
|
@@ -4455,17 +4580,13 @@ export class CloudflareScopeHost {
|
|
|
4455
4580
|
else {
|
|
4456
4581
|
await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4457
4582
|
}
|
|
4458
|
-
await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
4459
|
-
connectionId: grant.connectionId,
|
|
4460
|
-
provider: conn.provider,
|
|
4461
|
-
permission: grant.permission,
|
|
4462
|
-
node: grant.node,
|
|
4463
|
-
});
|
|
4464
4583
|
},
|
|
4465
4584
|
grantToSystem: async (actor, raw) => {
|
|
4466
4585
|
// The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
|
|
4467
4586
|
// module, one permission; tombstones on revoke; shows in the permission diff.
|
|
4468
4587
|
const grant = systemGrant.parse(raw);
|
|
4588
|
+
const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
|
|
4589
|
+
const after = { moduleId: grant.moduleId, permission: grant.permission, node: grant.node };
|
|
4469
4590
|
if (grant.node.scopeId) {
|
|
4470
4591
|
// #1666: refused while the module is switched off on this scope — checked and
|
|
4471
4592
|
// written in one DO unit. Restore is the lever; a grant is not.
|
|
@@ -4473,18 +4594,17 @@ export class CloudflareScopeHost {
|
|
|
4473
4594
|
if (!written) {
|
|
4474
4595
|
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
|
|
4475
4596
|
}
|
|
4597
|
+
await this.recordAdmin(actor, 'grantToSystem', target, null, after);
|
|
4598
|
+
return;
|
|
4476
4599
|
}
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
}
|
|
4600
|
+
// #1743: refused while the record holds the module off on any scope — checked and
|
|
4601
|
+
// written in one control-plane call, where the record and the tenant tuple both live,
|
|
4602
|
+
// and (#2526) the admin row with them.
|
|
4603
|
+
const off = await this.directory('writeTenantSystemGrant', grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grantToSystem', target, null, after));
|
|
4604
|
+
if (off.length > 0) {
|
|
4605
|
+
throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
|
|
4484
4606
|
}
|
|
4485
|
-
await this.
|
|
4486
|
-
if (!grant.node.scopeId)
|
|
4487
|
-
await this.fanOut(grant.node.tenantId);
|
|
4607
|
+
await this.fanOut(grant.node.tenantId);
|
|
4488
4608
|
},
|
|
4489
4609
|
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4490
4610
|
// whole rule, shared with the pure adapter; this is the directory check, the reach
|
|
@@ -4572,88 +4692,54 @@ export class CloudflareScopeHost {
|
|
|
4572
4692
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
4573
4693
|
await requireOrg(node.tenantId, orgId);
|
|
4574
4694
|
await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
if (!node.scopeId)
|
|
4695
|
+
const target = { tenantId: node.tenantId, scopeId: node.scopeId };
|
|
4696
|
+
const after = { orgId, permission, node, entity };
|
|
4697
|
+
if (!node.scopeId && !entity) {
|
|
4698
|
+
// #2526: the org checked again, the tuple and its row, in one ControlPlaneDO unit.
|
|
4699
|
+
await this.directory('writeOrgTenantTuple', node.tenantId, orgId, `org:${orgId}`, `granted:${permission}`, `tenant:${node.tenantId}`, null, this.adminEntry(actor, 'grantToOrg', target, null, after));
|
|
4578
4700
|
await this.fanOut(node.tenantId);
|
|
4701
|
+
return;
|
|
4702
|
+
}
|
|
4703
|
+
await writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4704
|
+
await this.recordAdmin(actor, 'grantToOrg', target, null, after);
|
|
4579
4705
|
},
|
|
4580
4706
|
// -- vertical + version registry (#31) ---------------------------------
|
|
4581
4707
|
// -- the hostname map (K-26) -------------------------------------------
|
|
4582
4708
|
bindHostname: async (actor, input) => {
|
|
4583
4709
|
const parsed = bindHostnameInput.parse(input);
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
const existing = await this.cp.readHostname(parsed.hostname);
|
|
4589
|
-
// The holder's own status decides whether the name is reclaimable. Read it from
|
|
4590
|
-
// the scope record rather than joining it onto the hostname read: the router
|
|
4591
|
-
// shares that read's shape, and a scope status it can see is an invitation to
|
|
4592
|
-
// re-check suspension there (route-resolver's `readRoute` deliberately has none).
|
|
4593
|
-
const holder = existing && existing.scope_id !== parsed.scopeId
|
|
4594
|
-
? await this.cp.getScopeRecord(existing.tenant_id, existing.scope_id)
|
|
4595
|
-
: undefined;
|
|
4596
|
-
const holderReleased = holder?.status === 'archived' || holder?.status === 'reaped';
|
|
4597
|
-
if (existing && existing.scope_id !== parsed.scopeId && !holderReleased) {
|
|
4598
|
-
// A hostname routes to exactly one place; silently rebinding would move
|
|
4599
|
-
// another tenant's traffic. Exception: the holder is ARCHIVED or REAPED (a
|
|
4600
|
-
// deleted app, storage since wiped) — it has released the name, so the rebind
|
|
4601
|
-
// reclaims it.
|
|
4602
|
-
throw substratError('conflict', `hostname '${parsed.hostname}' is already bound to another scope`);
|
|
4603
|
-
}
|
|
4604
|
-
// Exactly one canonical per (scope, surface).
|
|
4605
|
-
if (parsed.canonical)
|
|
4606
|
-
await this.cp.demoteCanonical(parsed.scopeId, parsed.surface);
|
|
4607
|
-
await this.cp.upsertHostname({
|
|
4710
|
+
// #2526: the scope, the holder whose status decides whether the name is reclaimable
|
|
4711
|
+
// (archived or reaped released it), the canonical's demotion, the bind and its row are one
|
|
4712
|
+
// ControlPlaneDO unit; the row's vertical is the scope's as that unit read it.
|
|
4713
|
+
await this.directory('bindHostnameInDirectory', {
|
|
4608
4714
|
hostname: parsed.hostname,
|
|
4609
4715
|
tenantId: parsed.tenantId,
|
|
4610
4716
|
scopeId: parsed.scopeId,
|
|
4611
|
-
verticalSlug: scope.vertical,
|
|
4612
4717
|
surface: parsed.surface,
|
|
4613
4718
|
region: parsed.region,
|
|
4614
4719
|
canonical: parsed.canonical,
|
|
4615
4720
|
createdAt: new Date().toISOString(),
|
|
4616
|
-
});
|
|
4617
|
-
await this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
|
|
4721
|
+
}, this.adminEntry(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, parsed));
|
|
4618
4722
|
},
|
|
4723
|
+
// #2526: the three below read their row in the unit that writes it (refusing an unbound name
|
|
4724
|
+
// `not_found`, judging the no-op, filling the row's target and `before`), so the template
|
|
4725
|
+
// carries no tenant or scope of its own.
|
|
4619
4726
|
setHostnameStatus: async (actor, raw, status, note) => {
|
|
4620
4727
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4621
|
-
|
|
4622
|
-
if (!row)
|
|
4623
|
-
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
4624
|
-
if (row.status === status)
|
|
4625
|
-
return; // idempotent, unaudited
|
|
4626
|
-
await this.cp.setHostnameStatus(hostname, status, note ?? null);
|
|
4627
|
-
await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
|
|
4728
|
+
await this.directory('setHostnameStatus', hostname, status, note ?? null, this.adminEntry(actor, 'setHostnameStatus', { tenantId: null }, null, { status, note: note ?? null }));
|
|
4628
4729
|
},
|
|
4629
4730
|
setHostnameIssuance: async (actor, raw, fields) => {
|
|
4630
4731
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4631
|
-
|
|
4632
|
-
if (!row)
|
|
4633
|
-
throw substratError('not_found', `unknown hostname '${hostname}'`);
|
|
4634
|
-
// A poll that finds nothing changed (same status, same records, id already set)
|
|
4635
|
-
// is not an event — skip the write and the audit entry, so the reconcile sweep
|
|
4636
|
-
// does not flood the admin log with no-op rows every interval.
|
|
4637
|
-
const recordsJson = JSON.stringify(fields.validationRecords);
|
|
4638
|
-
const idUnchanged = fields.customHostnameId === undefined || fields.customHostnameId === row.custom_hostname_id;
|
|
4639
|
-
if (row.status === fields.status && (row.validation_records ?? '[]') === recordsJson && idUnchanged) {
|
|
4640
|
-
return;
|
|
4641
|
-
}
|
|
4642
|
-
await this.cp.setHostnameIssuance(hostname, {
|
|
4732
|
+
await this.directory('setHostnameIssuance', hostname, {
|
|
4643
4733
|
status: fields.status,
|
|
4644
4734
|
note: fields.note ?? null,
|
|
4645
4735
|
customHostnameId: fields.customHostnameId,
|
|
4646
|
-
validationRecords: fields.validationRecords.length ?
|
|
4647
|
-
});
|
|
4648
|
-
await this.recordAdmin(actor, 'setHostnameIssuance', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status: fields.status, note: fields.note ?? null });
|
|
4736
|
+
validationRecords: fields.validationRecords.length ? JSON.stringify(fields.validationRecords) : null,
|
|
4737
|
+
}, this.adminEntry(actor, 'setHostnameIssuance', { tenantId: null }, null, { status: fields.status, note: fields.note ?? null }));
|
|
4649
4738
|
},
|
|
4650
4739
|
unbindHostname: async (actor, raw, owner) => {
|
|
4651
4740
|
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
4652
4741
|
// The row the delete removed, so an owner is judged by the delete itself (#2285).
|
|
4653
|
-
|
|
4654
|
-
if (!row)
|
|
4655
|
-
return; // idempotent, and a no-op is not audited
|
|
4656
|
-
await this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
|
|
4742
|
+
await this.directory('deleteHostname', hostname, owner, this.adminEntry(actor, 'unbindHostname', { tenantId: null }, null, null));
|
|
4657
4743
|
},
|
|
4658
4744
|
listHostnames: async (actor, filter) => {
|
|
4659
4745
|
const rows = await this.cp.listHostnames({
|
|
@@ -4857,7 +4943,7 @@ export class CloudflareScopeHost {
|
|
|
4857
4943
|
// Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
|
|
4858
4944
|
await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
|
|
4859
4945
|
},
|
|
4860
|
-
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4946
|
+
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge, options) => {
|
|
4861
4947
|
const incoming = await this.cp.readVersion(versionId);
|
|
4862
4948
|
if (!incoming)
|
|
4863
4949
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
@@ -4931,7 +5017,8 @@ export class CloudflareScopeHost {
|
|
|
4931
5017
|
// only if the pointer still names `outgoing`; the promote that lost the race is refused as
|
|
4932
5018
|
// stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
|
|
4933
5019
|
// which record their own — and the acknowledgement is recorded, not just enforced.
|
|
4934
|
-
|
|
5020
|
+
// #2565: the listing's rule and the `unlistedOnly` door are judged in that unit too.
|
|
5021
|
+
const moved = await this.directory('promoteChannel', outgoing?.id ?? null, {
|
|
4935
5022
|
id: ulid(),
|
|
4936
5023
|
vertical_slug: verticalSlug,
|
|
4937
5024
|
channel,
|
|
@@ -4939,7 +5026,7 @@ export class CloudflareScopeHost {
|
|
|
4939
5026
|
from_version_id: outgoing?.id ?? null,
|
|
4940
5027
|
actor,
|
|
4941
5028
|
at: promotedAt,
|
|
4942
|
-
}, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
5029
|
+
}, 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 }));
|
|
4943
5030
|
if (!moved)
|
|
4944
5031
|
throw promoteRaceRefusal(channel);
|
|
4945
5032
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
@@ -5265,10 +5352,8 @@ export class CloudflareScopeHost {
|
|
|
5265
5352
|
},
|
|
5266
5353
|
createOrg: async (actor, input) => {
|
|
5267
5354
|
const parsed = createOrgInput.parse(input);
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
return; // idempotent, and a no-op is not audited
|
|
5271
|
-
await this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
|
|
5355
|
+
// #2526: the org and its row in one unit; an org that exists already writes neither.
|
|
5356
|
+
await this.directory('insertOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString(), this.adminEntry(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed));
|
|
5272
5357
|
},
|
|
5273
5358
|
listOrgs: async (actor, tenantId) => {
|
|
5274
5359
|
const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
|
|
@@ -5281,10 +5366,9 @@ export class CloudflareScopeHost {
|
|
|
5281
5366
|
return r ? mapOrg(r) : undefined;
|
|
5282
5367
|
},
|
|
5283
5368
|
addMember: async (actor, tenantId, principal, orgId, opts) => {
|
|
5284
|
-
await requireOrg(tenantId, orgId);
|
|
5285
5369
|
const expiresAt = opts?.expiresAt ?? null;
|
|
5286
|
-
|
|
5287
|
-
await this.
|
|
5370
|
+
// #2526: the org's check, the tuple and its row, in one ControlPlaneDO unit.
|
|
5371
|
+
await this.directory('writeOrgTenantTuple', tenantId, orgId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt, this.adminEntry(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt)));
|
|
5288
5372
|
await this.fanOut(tenantId); // membership is a tenant-level tuple
|
|
5289
5373
|
},
|
|
5290
5374
|
applyMembership: async (actor, change) => {
|
|
@@ -6008,32 +6092,22 @@ export class CloudflareScopeHost {
|
|
|
6008
6092
|
},
|
|
6009
6093
|
grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
|
|
6010
6094
|
const input = entitlementGrantInput.parse(plan ?? {});
|
|
6011
|
-
|
|
6095
|
+
// #2526: the held grant (the row's `before`, and the no-op judgement), the upsert and the
|
|
6096
|
+
// row are one ControlPlaneDO unit, which fills `before` and `after`.
|
|
6097
|
+
const result = await this.directory('grantEntitlement', tenantId, entitlementKey, input, actor, this.adminEntry(actor, 'grantEntitlement', { tenantId }, null, null));
|
|
6012
6098
|
if (!result.changed)
|
|
6013
6099
|
return; // idempotent — an unchanged grant is not audited
|
|
6014
|
-
await this.recordAdmin(actor, 'grantEntitlement', { tenantId }, result.before
|
|
6015
|
-
? {
|
|
6016
|
-
entitlementKey,
|
|
6017
|
-
expiresAt: result.before.expires_at,
|
|
6018
|
-
quota: result.before.quota,
|
|
6019
|
-
plan: result.before.plan,
|
|
6020
|
-
}
|
|
6021
|
-
: null, {
|
|
6022
|
-
entitlementKey,
|
|
6023
|
-
expiresAt: result.after.expires_at,
|
|
6024
|
-
quota: result.after.quota,
|
|
6025
|
-
plan: result.after.plan,
|
|
6026
|
-
});
|
|
6027
6100
|
// #304: an entitlement change is a tenant-level write, so it fans out into the
|
|
6028
6101
|
// tenant's projected scopes — the invalidation half of the OQ5 answer. A no-op
|
|
6029
|
-
// unless scope-local projection is on; the reconcile sweep repairs any drop.
|
|
6102
|
+
// unless scope-local projection is on; the reconcile sweep repairs any drop. It runs
|
|
6103
|
+
// after the grant and its row have committed.
|
|
6030
6104
|
await this.fanOut(tenantId);
|
|
6031
6105
|
},
|
|
6032
6106
|
revokeEntitlement: async (actor, tenantId, entitlementKey) => {
|
|
6033
|
-
|
|
6107
|
+
// #2526: the delete and its row, `before` the grant it removed, are one unit.
|
|
6108
|
+
const removed = await this.directory('revokeEntitlement', tenantId, entitlementKey, this.adminEntry(actor, 'revokeEntitlement', { tenantId }, null, null));
|
|
6034
6109
|
if (!removed)
|
|
6035
6110
|
return; // nothing held, nothing changed
|
|
6036
|
-
await this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey, expiresAt: removed.expires_at, quota: removed.quota, plan: removed.plan }, null);
|
|
6037
6111
|
// The revoke must reach the projected scopes so a running vertical stops honouring
|
|
6038
6112
|
// the entitlement — a dropped fan-out here would leave it enforcing a stale grant.
|
|
6039
6113
|
await this.fanOut(tenantId);
|
|
@@ -7690,9 +7764,11 @@ export class CloudflareScopeHost {
|
|
|
7690
7764
|
throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
|
|
7691
7765
|
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7692
7766
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7693
|
-
const stub = this.scopeStub(input.scopeId);
|
|
7694
7767
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
7695
|
-
|
|
7768
|
+
// #2531: judged again in the projection's own unit, on the roles and seats the scope holds as it
|
|
7769
|
+
// writes, so a seat made since the pre-check above is seen. The module migrations above have
|
|
7770
|
+
// already run when that refuses; they are this host's own setup, and the projection writes nothing.
|
|
7771
|
+
const switchedOff = await this.projectJudged(input.tenantId, input.scopeId, projectedRoleRows(input.roles), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
|
|
7696
7772
|
// Only PROJECT (and thereby switch on strict enforcement) when the platform actually
|
|
7697
7773
|
// supplies entitlements. Omitting them leaves the scope un-projected and trusting-
|
|
7698
7774
|
// upstream, so a vertical whose provision path predates #304 is never denied — it
|
|
@@ -7752,7 +7828,7 @@ export class CloudflareScopeHost {
|
|
|
7752
7828
|
: undefined,
|
|
7753
7829
|
// #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
|
|
7754
7830
|
// same unit, on the scope this call provisions — the one the seat's tuples name.
|
|
7755
|
-
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`))
|
|
7831
|
+
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`));
|
|
7756
7832
|
const shapes = input.entityGrants
|
|
7757
7833
|
? await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants)
|
|
7758
7834
|
: null;
|
|
@@ -7831,7 +7907,8 @@ export class CloudflareScopeHost {
|
|
|
7831
7907
|
* which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
|
|
7832
7908
|
* roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
|
|
7833
7909
|
* staged restore has not (its pages are already in the scope), so it is judged as a set alone
|
|
7834
|
-
* here
|
|
7910
|
+
* here. The load given the vertical's `roles` judges again in its own transaction, against what
|
|
7911
|
+
* the dump brought (#2531), and `projectRolesLocal` does in its unit where the load did not.
|
|
7835
7912
|
*/
|
|
7836
7913
|
async roleSetRefusalLocal(scopeId, roles, dump) {
|
|
7837
7914
|
const here = await this.scopeStub(scopeId).reservationHere();
|
|
@@ -7860,6 +7937,95 @@ export class CloudflareScopeHost {
|
|
|
7860
7937
|
if (refusal)
|
|
7861
7938
|
throw substratError('forbidden', refusal);
|
|
7862
7939
|
}
|
|
7940
|
+
/**
|
|
7941
|
+
* #2555: what a control-plane-full restore or import projects into a scope of `vertical` in the
|
|
7942
|
+
* load's own transaction: the directory's current state for `tenantId`, exactly what a fan-out
|
|
7943
|
+
* sends (`tenantProjection` and the vertical's connection keys), with this host's reservations to
|
|
7944
|
+
* judge its roles by. Null on a host that does not project permissions into its scopes: there the
|
|
7945
|
+
* load only keeps the destination's permission flags.
|
|
7946
|
+
*/
|
|
7947
|
+
async loadProjectionFor(tenantId, vertical) {
|
|
7948
|
+
if (!this.scopeLocalPermissions)
|
|
7949
|
+
return null;
|
|
7950
|
+
const [state, connectionKeys] = await Promise.all([this.tenantProjection(tenantId), this.connectionKeyRows(tenantId, vertical)]);
|
|
7951
|
+
return { tenantId, ...state, connectionKeys, judge: this.roleJudge() };
|
|
7952
|
+
}
|
|
7953
|
+
/**
|
|
7954
|
+
* #2555: `importDumpProjected`, a failure rethrown with its code. The role set refused in the
|
|
7955
|
+
* load's unit is `forbidden`, worded for the verb (`restore refused: `, `import refused: `), and
|
|
7956
|
+
* told apart by identity (`loadRoleRefusals`); the scope keeps its rows.
|
|
7957
|
+
*/
|
|
7958
|
+
async loadProjected(scopeId, tables, opts, projection, verb) {
|
|
7959
|
+
const loaded = unwrapReply(await this.scopeStub(scopeId).importDumpProjected(tables, scopeId, opts, projection));
|
|
7960
|
+
if ('switchedOff' in loaded)
|
|
7961
|
+
return loaded.switchedOff;
|
|
7962
|
+
const refused = substratError('forbidden', `${verb} refused: ${loaded.refusedRoles}`);
|
|
7963
|
+
loadRoleRefusals.add(refused);
|
|
7964
|
+
throw refused;
|
|
7965
|
+
}
|
|
7966
|
+
/**
|
|
7967
|
+
* #2555: an import that cannot finish (its load refused its role set, or its re-projection
|
|
7968
|
+
* refused or failed) takes down the scope it created for itself: only one this call created
|
|
7969
|
+
* (`created`) and still `provisioning`, never activated, so an import pointed at an existing
|
|
7970
|
+
* scope never touches it. Its directory row goes, as the pure host takes such a scope down, so a
|
|
7971
|
+
* retry with the same id imports. The storage object is left for that retry's load to replace;
|
|
7972
|
+
* until then it holds what the provision projected into it (the tenant's roles, tuples,
|
|
7973
|
+
* entitlements, identity links and connection keys) and, after a load, the dump, and no
|
|
7974
|
+
* directory row leads a later tenant deletion to it. Best effort: the refusal is the answer
|
|
7975
|
+
* whatever this does. A teardown that fails is a staff-only ops row (no tenant on it), with the
|
|
7976
|
+
* scope left in `provisioning` for staff to reap. No admin row: the import never happened, and
|
|
7977
|
+
* its `provisionScope` row stays as the record of the attempt.
|
|
7978
|
+
*/
|
|
7979
|
+
async dropRefusedImport(actor, tenantId, scopeId, created) {
|
|
7980
|
+
if (!created)
|
|
7981
|
+
return;
|
|
7982
|
+
try {
|
|
7983
|
+
// Judged again in the directory's own unit, so a concurrent call that activated it keeps it; the
|
|
7984
|
+
// read before it is what an older directory, which ignores the condition, is held to.
|
|
7985
|
+
if ((await this.cp.getScopeRecord(tenantId, scopeId))?.status !== 'provisioning')
|
|
7986
|
+
return;
|
|
7987
|
+
await this.cp.deleteScopeDirectory(scopeId, 'provisioning');
|
|
7988
|
+
}
|
|
7989
|
+
catch (teardown) {
|
|
7990
|
+
await this.admin
|
|
7991
|
+
.recordOpsFailure({
|
|
7992
|
+
actor,
|
|
7993
|
+
operation: 'importScope',
|
|
7994
|
+
stage: 'refused-import-teardown',
|
|
7995
|
+
scopeId,
|
|
7996
|
+
message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
|
|
7997
|
+
})
|
|
7998
|
+
.catch(() => {
|
|
7999
|
+
// Nothing more to do here: the refusal is still the answer.
|
|
8000
|
+
});
|
|
8001
|
+
}
|
|
8002
|
+
}
|
|
8003
|
+
/**
|
|
8004
|
+
* #2555: the directory read again once the load has committed. A tenant-level write made between
|
|
8005
|
+
* the first read and the load fanned out before the load replaced it, so when the directory has
|
|
8006
|
+
* moved, the scope is projected once more, judged in its own unit against the seats the load
|
|
8007
|
+
* brought (`applyJudgedProjectionReply`): a role the directory redefined in the window, which its
|
|
8008
|
+
* own door judged without those seats, is judged with them here. Once at most: a write after this
|
|
8009
|
+
* read fans out to the scope itself. Answers `reprojected` (`true` when it re-projected), and the
|
|
8010
|
+
* error with `'refused'` or `'failed'` when the re-projection could not land, which leaves the
|
|
8011
|
+
* scope on the set the load judged until the next fan-out, which is not judged (K-55); the
|
|
8012
|
+
* refusal says what staff do about it. Ordering projections by a per-tenant revision (#2595)
|
|
8013
|
+
* would make the second read unnecessary.
|
|
8014
|
+
*/
|
|
8015
|
+
async convergeLoadedProjection(tenantId, scopeId, vertical, loaded, verb) {
|
|
8016
|
+
try {
|
|
8017
|
+
const now = await this.loadProjectionFor(tenantId, vertical);
|
|
8018
|
+
if (!now || projectionDigest(now) === projectionDigest(loaded))
|
|
8019
|
+
return {};
|
|
8020
|
+
unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, now.roles, now.tuples, now.entitlements, undefined, now.identities, now.connectionKeys));
|
|
8021
|
+
return { reprojected: true };
|
|
8022
|
+
}
|
|
8023
|
+
catch (error) {
|
|
8024
|
+
if (errorCodeOf(error) !== 'forbidden')
|
|
8025
|
+
return { reprojected: 'failed', error };
|
|
8026
|
+
return { reprojected: 'refused', error: substratError('forbidden', `${verb} refused: ${error.message}. ${SECOND_READ_REMEDY}`) };
|
|
8027
|
+
}
|
|
8028
|
+
}
|
|
7863
8029
|
/**
|
|
7864
8030
|
* #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
|
|
7865
8031
|
* holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
|
|
@@ -7875,9 +8041,9 @@ export class CloudflareScopeHost {
|
|
|
7875
8041
|
* and the roles the scope stores for the tenant now with their seats there. Answers the reserved
|
|
7876
8042
|
* keys it judged with, for the seats the same call makes.
|
|
7877
8043
|
*
|
|
7878
|
-
*
|
|
7879
|
-
*
|
|
7880
|
-
*
|
|
8044
|
+
* The scope state is read in one RPC (`roleStateHere`) and written in another, so this is the
|
|
8045
|
+
* first check, for its detailed wording; the write judges the set again inside its own unit
|
|
8046
|
+
* (`projectJudged`, #2531), where a seat made between the two is seen.
|
|
7881
8047
|
*/
|
|
7882
8048
|
async refuseReservedRoleSet(roles, tenantId, scopeId) {
|
|
7883
8049
|
const stub = this.scopeStub(scopeId);
|