@substrat-run/adapter-cloudflare 0.145.0 → 0.147.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 +26 -4
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +63 -28
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +151 -5
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +461 -42
- package/dist/host.js.map +1 -1
- package/dist/route-resolver.d.ts +10 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +26 -1
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +8 -1
- package/dist/scope-do.js.map +1 -1
- package/package.json +10 -10
package/dist/host.js
CHANGED
|
@@ -3,9 +3,10 @@ import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
4
|
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, 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, copyMarkAudit, 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';
|
|
5
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
6
|
-
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, 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, 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, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, 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, 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, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
7
7
|
import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
|
|
8
|
-
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
|
|
8
|
+
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
|
|
9
|
+
import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
|
|
9
10
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
|
|
10
11
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
11
12
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
@@ -187,8 +188,56 @@ const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${s
|
|
|
187
188
|
* platform's per-tenant binding, read off this script's own env, named by the one shared
|
|
188
189
|
* encoding (`blobStoreBindingName`) for the tenant the caller passed in.
|
|
189
190
|
*/
|
|
191
|
+
function ambientRecord() {
|
|
192
|
+
return ambientEnv;
|
|
193
|
+
}
|
|
194
|
+
function ambientBinding(name) {
|
|
195
|
+
return ambientRecord()[name] ?? null;
|
|
196
|
+
}
|
|
190
197
|
function ambientAttachmentBucket(tenantId) {
|
|
191
|
-
return
|
|
198
|
+
return ambientBinding(blobStoreBindingName(ATTACHMENT_BLOB_BINDING, tenantId));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* One store call over an object's fetch, for the vertical's door and the platform's reserved
|
|
202
|
+
* calls alike. A body that is not JSON is the store's failure, answered as a bad envelope.
|
|
203
|
+
*/
|
|
204
|
+
function storeObjectSend(object) {
|
|
205
|
+
return async (request) => {
|
|
206
|
+
const response = await object.fetch('https://scope-store/', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'content-type': 'application/json' },
|
|
209
|
+
body: JSON.stringify(request),
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
return await response.json();
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new StoreError({ code: 'bad_envelope', status: 502 });
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function isScopeStoreNamespace(value) {
|
|
220
|
+
return (typeof value === 'object' &&
|
|
221
|
+
value !== null &&
|
|
222
|
+
typeof Reflect.get(value, 'idFromName') === 'function' &&
|
|
223
|
+
typeof Reflect.get(value, 'get') === 'function');
|
|
224
|
+
}
|
|
225
|
+
const SCOPE_STORE_BINDING_PREFIX = `${PLATFORM_BINDING_PREFIX}STORE_`;
|
|
226
|
+
/** Bindings this script declares by having a `SUBSTRAT_STORE_*` namespace. */
|
|
227
|
+
function ambientScopeStoreBindings() {
|
|
228
|
+
const names = [];
|
|
229
|
+
const env = ambientRecord();
|
|
230
|
+
for (const key of Object.keys(env)) {
|
|
231
|
+
if (!key.startsWith(SCOPE_STORE_BINDING_PREFIX))
|
|
232
|
+
continue;
|
|
233
|
+
const binding = key.slice(SCOPE_STORE_BINDING_PREFIX.length);
|
|
234
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(binding))
|
|
235
|
+
continue;
|
|
236
|
+
if (!isScopeStoreNamespace(Reflect.get(env, key)))
|
|
237
|
+
continue;
|
|
238
|
+
names.push(binding);
|
|
239
|
+
}
|
|
240
|
+
return names;
|
|
192
241
|
}
|
|
193
242
|
/**
|
|
194
243
|
* #2029: scope instances whose door has read the rewind hold and found NO claim on the scope at
|
|
@@ -236,6 +285,11 @@ const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key))
|
|
|
236
285
|
export class CloudflareScopeHost {
|
|
237
286
|
admin;
|
|
238
287
|
scopeNs;
|
|
288
|
+
scopeStoreRunners;
|
|
289
|
+
scopeStoreVertical;
|
|
290
|
+
scopeStoreLifecycle;
|
|
291
|
+
scopeStoreKinds;
|
|
292
|
+
scopeStoreTransport;
|
|
239
293
|
cp;
|
|
240
294
|
servicePrincipals;
|
|
241
295
|
/** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
|
|
@@ -350,6 +404,11 @@ export class CloudflareScopeHost {
|
|
|
350
404
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
351
405
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
352
406
|
this.scopeNs = options.scope;
|
|
407
|
+
this.scopeStoreVertical = options.scopeStores?.vertical;
|
|
408
|
+
this.scopeStoreKinds = options.scopeStores?.kinds ?? {};
|
|
409
|
+
this.scopeStoreTransport = options.scopeStores?.transport ?? 'binding';
|
|
410
|
+
this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
|
|
411
|
+
this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
|
|
353
412
|
this.cpLess = !options.controlPlane;
|
|
354
413
|
this.cp = options.controlPlane
|
|
355
414
|
? options.controlPlane.get(options.controlPlane.idFromName('control-plane'))
|
|
@@ -1124,7 +1183,9 @@ export class CloudflareScopeHost {
|
|
|
1124
1183
|
* control plane, which calls the vertical's `/internal/delete-scope` before deleting
|
|
1125
1184
|
* the row; this end just destroys its own bytes.
|
|
1126
1185
|
*/
|
|
1127
|
-
async deleteScopeLocal(scopeId) {
|
|
1186
|
+
async deleteScopeLocal(scopeId, tenantId) {
|
|
1187
|
+
if (tenantId)
|
|
1188
|
+
await this.wipeScopeStores(tenantId, scopeId);
|
|
1128
1189
|
await this.scopeStub(scopeId).destroyStorage();
|
|
1129
1190
|
// #1819: a deleted scope runs nothing; its claims go with it. Best effort, never a failed reap.
|
|
1130
1191
|
await this.switchHoldsStub()
|
|
@@ -1188,6 +1249,27 @@ export class CloudflareScopeHost {
|
|
|
1188
1249
|
async databaseSizeLocal(scopeId) {
|
|
1189
1250
|
return this.scopeStub(scopeId).databaseSize();
|
|
1190
1251
|
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Each scope store's size in this deployment (#2207). The control plane already
|
|
1254
|
+
* audited the ask; this end does not write the access log again.
|
|
1255
|
+
*/
|
|
1256
|
+
async scopeStoreSizesLocal(tenantId, scopeId) {
|
|
1257
|
+
return this.readScopeStoreSizes(tenantId, scopeId);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Erase one subject from each scope store in this deployment (#2068). The control
|
|
1261
|
+
* plane calls this once on the serving script, then destroys the key itself.
|
|
1262
|
+
*/
|
|
1263
|
+
async scopeStoreExportLocal(tenantId, scopeId) {
|
|
1264
|
+
return this.exportScopeStores(tenantId, scopeId);
|
|
1265
|
+
}
|
|
1266
|
+
async scopeStoreImportLocal(tenantId, scopeId, stores) {
|
|
1267
|
+
this.refuseUnusableScopeStores(stores);
|
|
1268
|
+
await this.importScopeStores(tenantId, scopeId, stores);
|
|
1269
|
+
}
|
|
1270
|
+
async scopeStoreEraseLocal(tenantId, scopeId, subjectId) {
|
|
1271
|
+
return this.eraseScopeStores(tenantId, scopeId, subjectId);
|
|
1272
|
+
}
|
|
1191
1273
|
/**
|
|
1192
1274
|
* The oldest not-yet-drained events of this host's own scope (#1334) — the far end of
|
|
1193
1275
|
* the control plane's `EventDrainDelegation`. Bounded the same way the audited verb is,
|
|
@@ -1706,6 +1788,7 @@ export class CloudflareScopeHost {
|
|
|
1706
1788
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
1707
1789
|
}
|
|
1708
1790
|
async importScope(actor, input, dump) {
|
|
1791
|
+
this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
|
|
1709
1792
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
1710
1793
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
1711
1794
|
// the end state is the dump, at the source's frontier. Provenance is stamped from
|
|
@@ -1720,6 +1803,7 @@ export class CloudflareScopeHost {
|
|
|
1720
1803
|
// A fork: its callers (snapshotScope) hand it a dump the platform exported.
|
|
1721
1804
|
exact: true,
|
|
1722
1805
|
});
|
|
1806
|
+
await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
|
|
1723
1807
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
1724
1808
|
await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1725
1809
|
}
|
|
@@ -1728,6 +1812,7 @@ export class CloudflareScopeHost {
|
|
|
1728
1812
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1729
1813
|
if (!existing)
|
|
1730
1814
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1815
|
+
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
1731
1816
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
1732
1817
|
// the directory's recorded-off modules go back off in the replay's own event. Only where
|
|
1733
1818
|
// this is the scope's real store, as for the provision's seat above; a hosted scope is
|
|
@@ -1739,10 +1824,18 @@ export class CloudflareScopeHost {
|
|
|
1739
1824
|
switchOff: carry ? { at: replayAt, ...carry } : undefined,
|
|
1740
1825
|
sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
|
|
1741
1826
|
});
|
|
1742
|
-
await this.
|
|
1827
|
+
await this.importScopeStores(tenantId, scopeId, dump.stores);
|
|
1828
|
+
const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
|
|
1829
|
+
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
|
|
1830
|
+
sourceScopeId: dump.scopeId,
|
|
1831
|
+
tables: dump.tables.length,
|
|
1832
|
+
capturedAt: dump.capturedAt,
|
|
1833
|
+
...replay,
|
|
1834
|
+
});
|
|
1743
1835
|
if (carry) {
|
|
1744
1836
|
await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
|
|
1745
1837
|
}
|
|
1838
|
+
return replay;
|
|
1746
1839
|
}
|
|
1747
1840
|
/**
|
|
1748
1841
|
* #1742, #2029: what this host's own unit carries of the directory's record — the modules and
|
|
@@ -1814,6 +1907,7 @@ export class CloudflareScopeHost {
|
|
|
1814
1907
|
// Storage BEFORE the directory row: a crash between the two leaves a visible row
|
|
1815
1908
|
// over empty storage — re-running deleteSnapshot converges — never orphaned bytes
|
|
1816
1909
|
// with no record (the §9 hazard). Hostname rows go with the directory delete.
|
|
1910
|
+
await this.wipeScopeStores(tenantId, scopeId);
|
|
1817
1911
|
await this.scopeStub(scopeId).destroyStorage();
|
|
1818
1912
|
await this.cp.deleteScopeDirectory(scopeId);
|
|
1819
1913
|
await this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
|
|
@@ -1866,6 +1960,294 @@ export class CloudflareScopeHost {
|
|
|
1866
1960
|
await this.migrateAndRecord(scopeId);
|
|
1867
1961
|
return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
|
|
1868
1962
|
}
|
|
1963
|
+
async getScopeStore(_principal, tenantId, scopeId, binding, options) {
|
|
1964
|
+
if (this.scopeStoreTransport === 'platform') {
|
|
1965
|
+
return {
|
|
1966
|
+
tenantId,
|
|
1967
|
+
scopeId,
|
|
1968
|
+
subjectKind: 'principal',
|
|
1969
|
+
invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore(async (request) => {
|
|
1970
|
+
const response = await this.fetchImpl(STORE_CALL_URL, {
|
|
1971
|
+
method: 'POST',
|
|
1972
|
+
headers: { 'content-type': 'application/json' },
|
|
1973
|
+
body: JSON.stringify({ scopeId, binding, operation: request.operation, input: request.input }),
|
|
1974
|
+
});
|
|
1975
|
+
try {
|
|
1976
|
+
return await response.json();
|
|
1977
|
+
}
|
|
1978
|
+
catch {
|
|
1979
|
+
throw new StoreError({ code: 'bad_envelope', status: 502 });
|
|
1980
|
+
}
|
|
1981
|
+
}, operation, input, ...extra), options?.onStoreCall),
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
// A missing binding is answered before any directory read. A CP-less host's
|
|
1985
|
+
// directory throws a plain Error, and a vertical with no store must not meet it.
|
|
1986
|
+
const runner = this.scopeStoreRunners.get(binding);
|
|
1987
|
+
const bound = runner ? undefined : ambientBinding(scopeStoreBindingName(binding));
|
|
1988
|
+
if (!runner && (this.scopeStoreRunners.size > 0 || !isScopeStoreNamespace(bound))) {
|
|
1989
|
+
throw substratError('not_found', `no scope store is bound for '${binding}'`);
|
|
1990
|
+
}
|
|
1991
|
+
const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
|
|
1992
|
+
await this.assertLive(tenantId, scopeId);
|
|
1993
|
+
if (runner) {
|
|
1994
|
+
await this.scopeStoreLifecycle.start();
|
|
1995
|
+
return {
|
|
1996
|
+
tenantId,
|
|
1997
|
+
scopeId,
|
|
1998
|
+
subjectKind: 'principal',
|
|
1999
|
+
invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore((request) => runner.invoke({ tenantId, vertical, scopeId, binding }, request), operation, input, ...extra), options?.onStoreCall),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
const namespace = bound;
|
|
2003
|
+
const objectName = scopeStoreObjectName(tenantId, vertical, scopeId, binding);
|
|
2004
|
+
const object = namespace.get(namespace.idFromName(objectName));
|
|
2005
|
+
return {
|
|
2006
|
+
tenantId,
|
|
2007
|
+
scopeId,
|
|
2008
|
+
subjectKind: 'principal',
|
|
2009
|
+
invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore(storeObjectSend(object), operation, input, ...extra), options?.onStoreCall),
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Which vertical names the store object. With a control plane, the scope row.
|
|
2014
|
+
* Without one, {@link CloudflareScopeHostOptions.scopeStores} — the worker
|
|
2015
|
+
* already knows which vertical it is, and the directory is not there to ask.
|
|
2016
|
+
*/
|
|
2017
|
+
async scopeStoreVerticalOf(tenantId, scopeId) {
|
|
2018
|
+
if (this.cpLess) {
|
|
2019
|
+
if (!this.scopeStoreVertical) {
|
|
2020
|
+
throw substratError('unavailable', 'a scope store on a host with no control plane needs scopeStores.vertical');
|
|
2021
|
+
}
|
|
2022
|
+
return this.scopeStoreVertical;
|
|
2023
|
+
}
|
|
2024
|
+
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2025
|
+
const vertical = scope?.vertical;
|
|
2026
|
+
if (!vertical) {
|
|
2027
|
+
throw substratError('not_found', `scope ${scopeId} has no vertical, so it has no scope store`);
|
|
2028
|
+
}
|
|
2029
|
+
if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
|
|
2030
|
+
throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and scope ${scopeId} runs '${vertical}'`);
|
|
2031
|
+
}
|
|
2032
|
+
return vertical;
|
|
2033
|
+
}
|
|
2034
|
+
/** Configured runners, or the ambient namespaces when none were handed in. */
|
|
2035
|
+
scopeStoreBindingNames() {
|
|
2036
|
+
if (this.scopeStoreRunners.size > 0)
|
|
2037
|
+
return [...this.scopeStoreRunners.keys()];
|
|
2038
|
+
return ambientScopeStoreBindings();
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* One reserved call. A configured runner is preferred; otherwise the ambient
|
|
2042
|
+
* namespace. `callReservedStoreOperation` refuses a name that is not reserved.
|
|
2043
|
+
*/
|
|
2044
|
+
async invokeReservedStore(address, operation, input) {
|
|
2045
|
+
const runner = this.scopeStoreRunners.get(address.binding);
|
|
2046
|
+
if (runner) {
|
|
2047
|
+
await this.scopeStoreLifecycle.start();
|
|
2048
|
+
return callReservedStoreOperation((request) => runner.invoke(address, request), operation, input);
|
|
2049
|
+
}
|
|
2050
|
+
if (this.scopeStoreRunners.size > 0) {
|
|
2051
|
+
throw substratError('not_found', `no scope store is bound for '${address.binding}'`);
|
|
2052
|
+
}
|
|
2053
|
+
const bound = ambientBinding(scopeStoreBindingName(address.binding));
|
|
2054
|
+
if (!isScopeStoreNamespace(bound)) {
|
|
2055
|
+
throw substratError('not_found', `no scope store is bound for '${address.binding}'`);
|
|
2056
|
+
}
|
|
2057
|
+
const objectName = scopeStoreObjectName(address.tenantId, address.vertical, address.scopeId, address.binding);
|
|
2058
|
+
const object = bound.get(bound.idFromName(objectName));
|
|
2059
|
+
return callReservedStoreOperation(storeObjectSend(object), operation, input);
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Each declared binding's `substrat.size`. No bindings means an empty list,
|
|
2063
|
+
* and that answer does not read the directory: a host with no store, including
|
|
2064
|
+
* one with no control plane, has nothing to size. Once a binding exists, the
|
|
2065
|
+
* scope is gated before any object is addressed: by the directory with a control
|
|
2066
|
+
* plane, and without one by the door's own gate (`assertLive`: the scope's
|
|
2067
|
+
* admission of the tenant and its delivered lifecycle), as `getScopeStore` is.
|
|
2068
|
+
*/
|
|
2069
|
+
async readScopeStoreSizes(tenantId, scopeId) {
|
|
2070
|
+
const bindings = this.scopeStoreBindingNames();
|
|
2071
|
+
if (bindings.length === 0)
|
|
2072
|
+
return [];
|
|
2073
|
+
if (!this.cpLess)
|
|
2074
|
+
await this.scopeRecordForRead(tenantId, scopeId);
|
|
2075
|
+
const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
|
|
2076
|
+
if (this.cpLess)
|
|
2077
|
+
await this.assertLive(tenantId, scopeId);
|
|
2078
|
+
const sizes = [];
|
|
2079
|
+
for (const binding of bindings) {
|
|
2080
|
+
const { bytes } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.size');
|
|
2081
|
+
sizes.push({ binding, bytes });
|
|
2082
|
+
}
|
|
2083
|
+
return sizes;
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* `substrat.erase` on each declared binding (#2068). A store error throws so the
|
|
2087
|
+
* caller can leave the subject key untouched. Fail closed: an empty answer means
|
|
2088
|
+
* this host KNOWS it holds nothing for the scope, never that it could not tell.
|
|
2089
|
+
*/
|
|
2090
|
+
async eraseScopeStores(tenantId, scopeId, subjectId) {
|
|
2091
|
+
const bindings = this.scopeStoreBindingNames();
|
|
2092
|
+
if (bindings.length === 0)
|
|
2093
|
+
return [];
|
|
2094
|
+
const vertical = await this.erasureStoreVertical(tenantId, scopeId);
|
|
2095
|
+
if (!vertical)
|
|
2096
|
+
return [];
|
|
2097
|
+
const erasures = [];
|
|
2098
|
+
for (const binding of bindings) {
|
|
2099
|
+
const { erased } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.erase', { subjectId });
|
|
2100
|
+
erasures.push({ binding, erased });
|
|
2101
|
+
}
|
|
2102
|
+
return erasures;
|
|
2103
|
+
}
|
|
2104
|
+
/** A runner's kind, or the kind declared for an ambient binding. Never invented. */
|
|
2105
|
+
scopeStoreKind(binding) {
|
|
2106
|
+
const kind = this.scopeStoreRunners.get(binding)?.kind ?? this.scopeStoreKinds[binding];
|
|
2107
|
+
if (!kind) {
|
|
2108
|
+
throw substratError('unavailable', `scope store '${binding}' has no declared kind`);
|
|
2109
|
+
}
|
|
2110
|
+
return kind;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* A dump's stores this host cannot load are refused before any write (#2211, L8): a
|
|
2114
|
+
* binding that is not bound, a kind other than the bound one, or any store at all for
|
|
2115
|
+
* a destination with no vertical (no store object can be named for it, so loading would
|
|
2116
|
+
* drop them), or a destination on a vertical other than the one this host is pinned to.
|
|
2117
|
+
* `vertical` undefined is a caller that cannot read the destination here;
|
|
2118
|
+
* `importScopeStores` then refuses a scope with no vertical itself, still before a store
|
|
2119
|
+
* write. A store with no `kind` is refused: the dump contract requires one, and an
|
|
2120
|
+
* unparsed dump would otherwise skip the kind comparison.
|
|
2121
|
+
*/
|
|
2122
|
+
refuseUnusableScopeStores(stores, vertical) {
|
|
2123
|
+
const listed = stores ?? [];
|
|
2124
|
+
if (listed.length === 0)
|
|
2125
|
+
return;
|
|
2126
|
+
if (vertical === null) {
|
|
2127
|
+
throw substratError('conflict', 'dump carries scope stores, and the destination scope has no vertical to hold them');
|
|
2128
|
+
}
|
|
2129
|
+
if (vertical !== undefined && this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
|
|
2130
|
+
throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and the destination scope runs '${vertical}'`);
|
|
2131
|
+
}
|
|
2132
|
+
const bound = new Set(this.scopeStoreBindingNames());
|
|
2133
|
+
for (const store of listed) {
|
|
2134
|
+
if (!bound.has(store.binding)) {
|
|
2135
|
+
throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
|
|
2136
|
+
}
|
|
2137
|
+
if (!store.kind) {
|
|
2138
|
+
throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
|
|
2139
|
+
}
|
|
2140
|
+
const kind = this.scopeStoreRunners.get(store.binding)?.kind ?? this.scopeStoreKinds[store.binding];
|
|
2141
|
+
if (store.kind !== kind) {
|
|
2142
|
+
throw substratError('conflict', `dump's scope store '${store.binding}' is kind '${store.kind}', and this host binds kind '${kind ?? 'undeclared'}'`);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
async exportScopeStores(tenantId, scopeId) {
|
|
2147
|
+
// No binding is no store, answered before the directory read: a host with no control
|
|
2148
|
+
// plane cannot make that read, and a deployment with no store must not fail an export.
|
|
2149
|
+
const bindings = this.scopeStoreBindingNames();
|
|
2150
|
+
if (bindings.length === 0)
|
|
2151
|
+
return [];
|
|
2152
|
+
const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
|
|
2153
|
+
if (!vertical)
|
|
2154
|
+
return [];
|
|
2155
|
+
const stores = [];
|
|
2156
|
+
for (const binding of bindings) {
|
|
2157
|
+
const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
|
|
2158
|
+
stores.push({
|
|
2159
|
+
binding,
|
|
2160
|
+
kind: this.scopeStoreKind(binding),
|
|
2161
|
+
tables: exported.tables,
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
return stores;
|
|
2165
|
+
}
|
|
2166
|
+
async importScopeStores(tenantId, scopeId, stores) {
|
|
2167
|
+
const listed = stores ?? [];
|
|
2168
|
+
if (listed.length === 0)
|
|
2169
|
+
return;
|
|
2170
|
+
const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
|
|
2171
|
+
if (!vertical) {
|
|
2172
|
+
throw substratError('conflict', `dump carries scope stores, and scope ${scopeId} has no vertical to hold them`);
|
|
2173
|
+
}
|
|
2174
|
+
for (const store of listed) {
|
|
2175
|
+
await this.invokeReservedStore({ tenantId, vertical, scopeId, binding: store.binding }, 'substrat.import', { tables: store.tables });
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* `substrat.erase` again for every tombstoned subject (#2207), on the target's and the
|
|
2180
|
+
* source's marks (`replayStoreErasures`). A target this host names no store for (no
|
|
2181
|
+
* binding, or no vertical a store is kept for) has nothing to replay into: nothing is read.
|
|
2182
|
+
*/
|
|
2183
|
+
async replayShreddedStoreErasures(actor, tenantId, target, source) {
|
|
2184
|
+
if (this.scopeStoreBindingNames().length === 0)
|
|
2185
|
+
return undefined;
|
|
2186
|
+
if (!(await this.erasureStoreVertical(tenantId, target)))
|
|
2187
|
+
return undefined;
|
|
2188
|
+
return replayStoreErasures({
|
|
2189
|
+
admin: this.admin,
|
|
2190
|
+
actor,
|
|
2191
|
+
tenantId,
|
|
2192
|
+
target,
|
|
2193
|
+
source,
|
|
2194
|
+
erase: async (subjectId) => (await this.eraseScopeStores(tenantId, target, subjectId)).length,
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
/** `substrat.wipe` on each declared binding, before the scope's own storage goes. */
|
|
2198
|
+
async wipeScopeStores(tenantId, scopeId) {
|
|
2199
|
+
const bindings = this.scopeStoreBindingNames();
|
|
2200
|
+
if (bindings.length === 0)
|
|
2201
|
+
return;
|
|
2202
|
+
const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
|
|
2203
|
+
if (!vertical)
|
|
2204
|
+
return;
|
|
2205
|
+
for (const binding of bindings) {
|
|
2206
|
+
await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.wipe');
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
/**
|
|
2210
|
+
* The vertical a store export, import or wipe names. With a control plane, the scope row
|
|
2211
|
+
* (`scopeRecordForRead`: unknown and reaped scopes refused, `null` for a scope with no
|
|
2212
|
+
* vertical, a vertical this host is not pinned to refused). Without one, there is no
|
|
2213
|
+
* directory to read: the host's configured vertical (`scopeStoreVerticalOf`).
|
|
2214
|
+
*
|
|
2215
|
+
* Never gated on the scope being active, on either host. These calls are the platform's own
|
|
2216
|
+
* lifecycle work on scopes that often are not: a reap backs up and wipes an archived scope, a
|
|
2217
|
+
* snapshot or a full pull may copy a suspended one, and a fork's stores land before it is
|
|
2218
|
+
* activated. The scope's tables are exported and imported on the same terms.
|
|
2219
|
+
*/
|
|
2220
|
+
async lifecycleStoreVertical(tenantId, scopeId) {
|
|
2221
|
+
if (this.cpLess)
|
|
2222
|
+
return this.scopeStoreVerticalOf(tenantId, scopeId);
|
|
2223
|
+
const scope = await this.scopeRecordForRead(tenantId, scopeId);
|
|
2224
|
+
if (!scope.vertical)
|
|
2225
|
+
return null;
|
|
2226
|
+
if (this.scopeStoreVertical !== undefined && scope.vertical !== this.scopeStoreVertical) {
|
|
2227
|
+
throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and scope ${scopeId} runs '${scope.vertical}'`);
|
|
2228
|
+
}
|
|
2229
|
+
return scope.vertical;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* The vertical an erasure names a store for. With a control plane, the scope row
|
|
2233
|
+
* decides: no vertical, or not the vertical this host is pinned to, means no
|
|
2234
|
+
* store object of this host's is the scope's — an empty answer, which shred
|
|
2235
|
+
* needs for a scope that never had a vertical. With no control plane and no
|
|
2236
|
+
* `scopeStores.vertical`, the host cannot tell which objects are the scope's, so
|
|
2237
|
+
* it refuses `unavailable` as the size read and the door do, and the subject key
|
|
2238
|
+
* survives until the stores can be erased.
|
|
2239
|
+
*/
|
|
2240
|
+
async erasureStoreVertical(tenantId, scopeId) {
|
|
2241
|
+
if (this.cpLess)
|
|
2242
|
+
return this.scopeStoreVerticalOf(tenantId, scopeId);
|
|
2243
|
+
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2244
|
+
const vertical = scope?.vertical ?? undefined;
|
|
2245
|
+
if (!vertical)
|
|
2246
|
+
return undefined;
|
|
2247
|
+
if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical)
|
|
2248
|
+
return undefined;
|
|
2249
|
+
return vertical;
|
|
2250
|
+
}
|
|
1869
2251
|
/**
|
|
1870
2252
|
* A scope stub whose authority is a CONNECTION (#97).
|
|
1871
2253
|
*
|
|
@@ -2148,7 +2530,9 @@ export class CloudflareScopeHost {
|
|
|
2148
2530
|
* here, and the ScopeDO admits the peer inside its queue on every invoke and acknowledges it.
|
|
2149
2531
|
*/
|
|
2150
2532
|
async getVerticalScope(caller, tenantId, scopeId, options) {
|
|
2151
|
-
|
|
2533
|
+
// #2252: the caller as this vertical's `peers` names it, before the door's pin or the DO
|
|
2534
|
+
// reads it — so both key on the declared name, exactly as the seats and the switch do.
|
|
2535
|
+
const parsed = peerCallerAt(collectPeers(this.peerSources), verticalCaller.parse(caller));
|
|
2152
2536
|
await this.peerScopeGate(tenantId, scopeId, 'getVerticalScope');
|
|
2153
2537
|
// #2029: gated against the rewind hold and pinned, as the system door is.
|
|
2154
2538
|
const door = await this.openDoor('peer', parsed.vertical, scopeId);
|
|
@@ -2793,7 +3177,7 @@ export class CloudflareScopeHost {
|
|
|
2793
3177
|
};
|
|
2794
3178
|
}
|
|
2795
3179
|
async close() {
|
|
2796
|
-
|
|
3180
|
+
await this.scopeStoreLifecycle.stop();
|
|
2797
3181
|
}
|
|
2798
3182
|
// -- admin surface --------------------------------------------------------
|
|
2799
3183
|
/** #977: this host, with every admin row it writes naming who the actor acted for. */
|
|
@@ -4399,6 +4783,11 @@ export class CloudflareScopeHost {
|
|
|
4399
4783
|
await this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
4400
4784
|
return bytes;
|
|
4401
4785
|
},
|
|
4786
|
+
scopeStoreSizes: async (actor, tenantId, scopeId) => {
|
|
4787
|
+
const sizes = await this.readScopeStoreSizes(tenantId, scopeId);
|
|
4788
|
+
await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
|
|
4789
|
+
return sizes;
|
|
4790
|
+
},
|
|
4402
4791
|
rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
|
|
4403
4792
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4404
4793
|
if (!scope)
|
|
@@ -4834,7 +5223,8 @@ export class CloudflareScopeHost {
|
|
|
4834
5223
|
await this.scopeRecordForRead(tenantId, scopeId);
|
|
4835
5224
|
const tables = await this.scopeStub(scopeId).exportDump();
|
|
4836
5225
|
await this.recordAccess(actor, 'exportScope', { tenantId, scopeId }, null, tables.length);
|
|
4837
|
-
|
|
5226
|
+
const stores = await this.exportScopeStores(tenantId, scopeId);
|
|
5227
|
+
return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables, stores };
|
|
4838
5228
|
},
|
|
4839
5229
|
exportDirectory: async (actor) => {
|
|
4840
5230
|
// No K-3 cross-check to make: there is one directory, and it is the thing that
|
|
@@ -4928,6 +5318,7 @@ export class CloudflareScopeHost {
|
|
|
4928
5318
|
throw substratError('conflict', `scope ${scopeId} still resolves hostname '${bound[0].hostname}' — ` +
|
|
4929
5319
|
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
4930
5320
|
}
|
|
5321
|
+
await this.wipeScopeStores(tenantId, scopeId);
|
|
4931
5322
|
await this.scopeStub(scopeId).destroyStorage();
|
|
4932
5323
|
// The recoverable copy the caller stored first (#493), named in the audit entry so
|
|
4933
5324
|
// the trail answers "was there a backup" without correlating two timestamps.
|
|
@@ -4948,6 +5339,14 @@ export class CloudflareScopeHost {
|
|
|
4948
5339
|
await this.recordAccess(actor, 'openSubjectPayloads', { tenantId, scopeId }, { subjects: new Set(items.map((i) => i.subjectId)).size }, opened.filter((o) => o !== null).length);
|
|
4949
5340
|
return opened;
|
|
4950
5341
|
},
|
|
5342
|
+
listShreddedSubjects: async (actor, tenantId, scopeId, cursor) => {
|
|
5343
|
+
await this.assertScope(tenantId, scopeId);
|
|
5344
|
+
const rows = await this.cp.listShreddedSubjects(scopeId, tenantId, cursor ?? null);
|
|
5345
|
+
const subjects = rows.slice(0, 100);
|
|
5346
|
+
const nextCursor = rows.length > 100 ? (subjects[99] ?? null) : null;
|
|
5347
|
+
await this.recordAccess(actor, 'listShreddedSubjects', { tenantId, scopeId }, null, subjects.length);
|
|
5348
|
+
return { subjects, nextCursor };
|
|
5349
|
+
},
|
|
4951
5350
|
shredSubject: async (actor, tenantId, scopeId, subjectId) => {
|
|
4952
5351
|
await this.assertScope(tenantId, scopeId);
|
|
4953
5352
|
// #1722: a direct shred claims nothing; it records the epoch it ran under, as an orchestrated
|
|
@@ -5012,6 +5411,8 @@ export class CloudflareScopeHost {
|
|
|
5012
5411
|
// The directory's failure text (#1632) — a drain failure quoting one of those intents,
|
|
5013
5412
|
// an issue's exemplar, a sweep record's error. Still before the key.
|
|
5014
5413
|
await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
|
|
5414
|
+
// Scope stores after the scope-side redaction and before the key (#2068).
|
|
5415
|
+
const storeErasures = await this.eraseScopeStores(tenantId, scopeId, subjectId);
|
|
5015
5416
|
const at = new Date().toISOString();
|
|
5016
5417
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
5017
5418
|
const receipt = subjectShredReceipt.parse({
|
|
@@ -5020,6 +5421,7 @@ export class CloudflareScopeHost {
|
|
|
5020
5421
|
intentsRedacted,
|
|
5021
5422
|
jobRunsRedacted,
|
|
5022
5423
|
...vertical,
|
|
5424
|
+
storeErasures,
|
|
5023
5425
|
keyDestroyed: existed,
|
|
5024
5426
|
tombstoned: true,
|
|
5025
5427
|
});
|
|
@@ -5033,7 +5435,7 @@ export class CloudflareScopeHost {
|
|
|
5033
5435
|
return receipt;
|
|
5034
5436
|
},
|
|
5035
5437
|
scopeErasureEpoch: async (_actor, tenantId, scopeId) => this.cp.scopeErasureEpoch(tenantId, scopeId),
|
|
5036
|
-
finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected) => {
|
|
5438
|
+
finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected, storeErasures = []) => {
|
|
5037
5439
|
await this.assertScope(tenantId, scopeId);
|
|
5038
5440
|
if (redactions.length === 0)
|
|
5039
5441
|
throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
|
|
@@ -5051,6 +5453,7 @@ export class CloudflareScopeHost {
|
|
|
5051
5453
|
verticalRows: redactions.flatMap((r) => r.vertical.verticalRows),
|
|
5052
5454
|
hookRows: redactions.flatMap((r) => r.vertical.hookRows),
|
|
5053
5455
|
unreachedEntities: redactions.flatMap((r) => r.vertical.unreachedEntities),
|
|
5456
|
+
storeErasures,
|
|
5054
5457
|
keyDestroyed: existed,
|
|
5055
5458
|
tombstoned: true,
|
|
5056
5459
|
});
|
|
@@ -5164,6 +5567,7 @@ export class CloudflareScopeHost {
|
|
|
5164
5567
|
})),
|
|
5165
5568
|
storage: rows.storage,
|
|
5166
5569
|
storageAttempts: rows.storageAttempts,
|
|
5570
|
+
storeStorage: rows.storeStorage,
|
|
5167
5571
|
});
|
|
5168
5572
|
// Tenants covered, not totals: "read one tenant's meter" and "metered the whole
|
|
5169
5573
|
// fleet" are different acts, and K-24 exists to tell them apart.
|
|
@@ -6760,39 +7164,7 @@ export class CloudflareScopeHost {
|
|
|
6760
7164
|
expires_at: null,
|
|
6761
7165
|
lockout_reseat: true,
|
|
6762
7166
|
},
|
|
6763
|
-
|
|
6764
|
-
// the CP-less mirror of `provisionScope`'s seatTuple loop. Without them the
|
|
6765
|
-
// grant-is-the-switch check makes every schedule a silent no-op (`fired: 0`,
|
|
6766
|
-
// no error — the #49 unfalsifiable zero).
|
|
6767
|
-
...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
|
|
6768
|
-
const perms = new Set();
|
|
6769
|
-
for (const s of schedules)
|
|
6770
|
-
for (const p of s.permissions)
|
|
6771
|
-
perms.add(p);
|
|
6772
|
-
return [...perms].map((perm) => ({
|
|
6773
|
-
subject: `system:${modId}`,
|
|
6774
|
-
relation: `granted:${perm}`,
|
|
6775
|
-
object: `scope:${input.scopeId}`,
|
|
6776
|
-
expires_at: null,
|
|
6777
|
-
}));
|
|
6778
|
-
}),
|
|
6779
|
-
// #1706: each declared PEER's grants ride the same unit — the CP-less mirror of
|
|
6780
|
-
// `provisionScope`'s peer seat, through the one function that decides it.
|
|
6781
|
-
...peerSeats(collectPeers(this.peerSources), input.scopeId).map((seat) => ({
|
|
6782
|
-
subject: seat.subject,
|
|
6783
|
-
relation: seat.relation,
|
|
6784
|
-
object: seat.object,
|
|
6785
|
-
expires_at: null,
|
|
6786
|
-
})),
|
|
6787
|
-
// #592: the tenant's connection grants ride the same unit — the provision-time
|
|
6788
|
-
// mirror of `connectorGrantLocal`, so the connector return path works on every
|
|
6789
|
-
// install, not only the one that existed when `grantToConnection` ran.
|
|
6790
|
-
...(input.connectionGrants ?? []).map((g) => ({
|
|
6791
|
-
subject: `connection:${g.connectionId}`,
|
|
6792
|
-
relation: `granted:${g.permission}`,
|
|
6793
|
-
object: `scope:${input.scopeId}`,
|
|
6794
|
-
expires_at: g.expiresAt ?? null,
|
|
6795
|
-
})),
|
|
7167
|
+
...this.declaredSeatsLocal(input.scopeId, input.connectionGrants),
|
|
6796
7168
|
],
|
|
6797
7169
|
// #406: identity links, delivered by the platform with provisioning exactly as
|
|
6798
7170
|
// entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
|
|
@@ -6829,6 +7201,53 @@ export class CloudflareScopeHost {
|
|
|
6829
7201
|
...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
|
|
6830
7202
|
};
|
|
6831
7203
|
}
|
|
7204
|
+
/**
|
|
7205
|
+
* The tuples this deployment's code declares for one scope, seated by every provision and
|
|
7206
|
+
* reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
|
|
7207
|
+
* check makes every schedule a silent no-op), each declared PEER's keys (#1706, through the one
|
|
7208
|
+
* function that decides them), and the tenant's connection grants the platform delivered (#592,
|
|
7209
|
+
* the provision-time mirror of `connectorGrantLocal`).
|
|
7210
|
+
*/
|
|
7211
|
+
declaredSeatsLocal(scopeId, connectionGrants) {
|
|
7212
|
+
return [
|
|
7213
|
+
...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
|
|
7214
|
+
const perms = new Set();
|
|
7215
|
+
for (const s of schedules)
|
|
7216
|
+
for (const p of s.permissions)
|
|
7217
|
+
perms.add(p);
|
|
7218
|
+
return [...perms].map((perm) => ({ subject: `system:${modId}`, relation: `granted:${perm}`, object: `scope:${scopeId}`, expires_at: null }));
|
|
7219
|
+
}),
|
|
7220
|
+
...peerSeats(collectPeers(this.peerSources), scopeId).map((seat) => ({ ...seat, expires_at: null })),
|
|
7221
|
+
...(connectionGrants ?? []).map((g) => ({
|
|
7222
|
+
subject: `connection:${g.connectionId}`,
|
|
7223
|
+
relation: `granted:${g.permission}`,
|
|
7224
|
+
object: `scope:${scopeId}`,
|
|
7225
|
+
expires_at: g.expiresAt ?? null,
|
|
7226
|
+
})),
|
|
7227
|
+
];
|
|
7228
|
+
}
|
|
7229
|
+
/**
|
|
7230
|
+
* The ownerless reconcile (#2263): what a reconcile does for a vertical that keeps no owner of
|
|
7231
|
+
* record, so a scope provisioned before a version's declarations still receives them. It SEATS
|
|
7232
|
+
* `declaredSeatsLocal` and re-applies the directory's recorded-off positions in the same unit
|
|
7233
|
+
* (#1742), and does nothing else: no owner seat, no projection (roles, entitlements, identity
|
|
7234
|
+
* links, sealing keys and tenant tuples are left exactly as they are), and no entity-grant
|
|
7235
|
+
* shape top-up or retirement. Every seat is `seatScopeTuple`'s, so a revoked grant stays revoked
|
|
7236
|
+
* and a switched-off subject gets nothing. It can therefore only add what the running code
|
|
7237
|
+
* declares, and never takes anything away but a recorded OFF.
|
|
7238
|
+
*
|
|
7239
|
+
* Only a scope already provisioned for `tenantId` is seated (`servesTenant`, K-3): this never
|
|
7240
|
+
* provisions a scope, and never writes into another tenant's.
|
|
7241
|
+
*/
|
|
7242
|
+
async seatScopeLocal(input) {
|
|
7243
|
+
const stub = this.scopeStub(input.scopeId);
|
|
7244
|
+
if (!(await stub.servesTenant(input.tenantId)))
|
|
7245
|
+
throw unknownScopeForTenant(input.tenantId, input.scopeId);
|
|
7246
|
+
const carry = recordedOffFromWire(input);
|
|
7247
|
+
await this.migrateAndRecord(input.scopeId);
|
|
7248
|
+
const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, input.connectionGrants), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
|
|
7249
|
+
return carry ? { switchedOff } : {};
|
|
7250
|
+
}
|
|
6832
7251
|
/**
|
|
6833
7252
|
* Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
|
|
6834
7253
|
* auth adapter's read path, the local equivalent of `HostAdmin.resolveIdentity` (same
|