@substrat-run/adapter-cloudflare 0.159.0 → 0.161.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 +131 -30
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +358 -94
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +885 -7
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +476 -200
- package/dist/host.js.map +1 -1
- package/dist/platform-sweeper-do.d.ts +3 -2
- package/dist/platform-sweeper-do.d.ts.map +1 -1
- package/dist/platform-sweeper-do.js +3 -2
- package/dist/platform-sweeper-do.js.map +1 -1
- package/dist/r2.d.ts +7 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +23 -2
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +207 -42
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +4 -2
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +7 -1
- package/dist/scope-sweeper-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
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,
|
|
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';
|
|
5
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
6
|
-
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, 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, assertPromoteReviewCurrent, promoteRaceRefusal, 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, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
6
|
+
import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
8
|
-
import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
|
|
8
|
+
import { assertWireAuthFilter, wireAuthAccessTarget, COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
|
|
9
9
|
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
|
|
10
10
|
import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
|
|
11
11
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveRefusalStatus, liveWithinOf, } from './live-reads.js';
|
|
12
12
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
13
|
-
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
13
|
+
import { blobStoreBucketName, r2TenantBlobStore, workerdSha256 } from './r2.js';
|
|
14
14
|
import { unwrapReply } from './do-reply.js';
|
|
15
15
|
/** DO row → contract shape. Never reads the secrets table — that is the split. */
|
|
16
16
|
const toConnection = (r) => connection.parse({
|
|
@@ -207,6 +207,28 @@ async function compensatingRefusal(store, key, call) {
|
|
|
207
207
|
await store.delete(key).catch(() => { });
|
|
208
208
|
return reply;
|
|
209
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Ask the scope's write gate before a streamed body is read (#2516). A scope DO from before the
|
|
212
|
+
* verb exists cannot answer it, and a missing answer is never a pass: the call fails closed with a
|
|
213
|
+
* retryable `unavailable` that says to redeploy, rather than skipping the gate and reading the body.
|
|
214
|
+
*/
|
|
215
|
+
export async function askAttachmentWriteGate(stub, entity, principal, tenantId, scopeId, connectionId) {
|
|
216
|
+
let reply;
|
|
217
|
+
try {
|
|
218
|
+
reply = await stub.attachmentCheckWriteReply(entity, principal, tenantId, scopeId, connectionId);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
// By shape, not wording: the scope answers every refusal as data (`replyOf`), so what is
|
|
222
|
+
// THROWN here is a failure of the call itself, and a verb the receiver does not have is a
|
|
223
|
+
// `TypeError` however workerd phrases it. A dropped connection is a plain `Error`, rethrown.
|
|
224
|
+
if (err instanceof TypeError || err?.name === 'TypeError') {
|
|
225
|
+
throw substratError('unavailable', `the deployment serving scope ${scopeId} predates the upload write check (#2516): a streamed upload ` +
|
|
226
|
+
`is refused rather than read unchecked. Redeploy the vertical, then retry. Nothing was stored.`);
|
|
227
|
+
}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
unwrapReply(reply);
|
|
231
|
+
}
|
|
210
232
|
/** A tenant-held module's OFF answered without `deniesTenantGrants` (#1823). */
|
|
211
233
|
const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
|
|
212
234
|
`module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
|
|
@@ -327,6 +349,12 @@ const DOOR_WORDS = {
|
|
|
327
349
|
};
|
|
328
350
|
/** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
|
|
329
351
|
const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key));
|
|
352
|
+
/** #2541, #2544: a vertical-side read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
|
|
353
|
+
function heldOrRefused(scopeId, answer) {
|
|
354
|
+
if (answer === null)
|
|
355
|
+
throw substratError('conflict', scopeNotHeldRefusal(scopeId));
|
|
356
|
+
return answer;
|
|
357
|
+
}
|
|
330
358
|
export class CloudflareScopeHost {
|
|
331
359
|
admin;
|
|
332
360
|
scopeNs;
|
|
@@ -346,6 +374,8 @@ export class CloudflareScopeHost {
|
|
|
346
374
|
moduleIds = new Set();
|
|
347
375
|
/** #2430: what a registered module declares `holder: 'system'` — `assignRole`'s refusal reads it. */
|
|
348
376
|
reservedKeys = new Set();
|
|
377
|
+
/** #2447: every key a registered or listed module declares, marked or not — a role definition reads it. */
|
|
378
|
+
declaredKeys = new Set();
|
|
349
379
|
/** #2430: the declared permission surface `registerModule` holds each module to, when given. */
|
|
350
380
|
permissions;
|
|
351
381
|
/**
|
|
@@ -370,6 +400,7 @@ export class CloudflareScopeHost {
|
|
|
370
400
|
/** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
|
|
371
401
|
secretBox;
|
|
372
402
|
fetchImpl;
|
|
403
|
+
streamIdleMs;
|
|
373
404
|
connectorCalls;
|
|
374
405
|
/** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
|
|
375
406
|
tenantStores;
|
|
@@ -445,8 +476,10 @@ export class CloudflareScopeHost {
|
|
|
445
476
|
this.servicePrincipals = options.servicePrincipals;
|
|
446
477
|
this.permissions = options.permissions;
|
|
447
478
|
// #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
|
|
448
|
-
for (const m of options.permissions?.modules ?? [])
|
|
479
|
+
for (const m of options.permissions?.modules ?? []) {
|
|
449
480
|
noteReservedKeys(this.reservedKeys, m.manifest);
|
|
481
|
+
noteDeclaredKeys(this.declaredKeys, m.manifest);
|
|
482
|
+
}
|
|
450
483
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
451
484
|
this.tenantStores = options.tenantStores;
|
|
452
485
|
this.blobStores = options.blobStores;
|
|
@@ -455,7 +488,9 @@ export class CloudflareScopeHost {
|
|
|
455
488
|
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
456
489
|
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
457
490
|
this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
|
|
458
|
-
|
|
491
|
+
// #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
|
|
492
|
+
this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
|
|
493
|
+
this.streamIdleMs = options.streamIdleTimeoutMs;
|
|
459
494
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
460
495
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
461
496
|
this.scopeNs = options.scope;
|
|
@@ -511,7 +546,8 @@ export class CloudflareScopeHost {
|
|
|
511
546
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
512
547
|
const vertical = scope?.vertical ?? null;
|
|
513
548
|
const admin = this.admin;
|
|
514
|
-
|
|
549
|
+
// #2518: the call under `timeoutMs`, a streamed body under the idle window instead.
|
|
550
|
+
const fetchImpl = deadlineFetch(this.fetchImpl, timeoutMs, { idleMs: this.streamIdleMs });
|
|
515
551
|
return {
|
|
516
552
|
admin,
|
|
517
553
|
tenantId,
|
|
@@ -542,7 +578,8 @@ export class CloudflareScopeHost {
|
|
|
542
578
|
grants: async () => (await this.connectionGrantsInScope(tenantId, scopeId))
|
|
543
579
|
.filter((g) => g.connectionId === open.id)
|
|
544
580
|
.map((g) => g.permission),
|
|
545
|
-
openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId, { eventId })
|
|
581
|
+
openAttachment: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).open(attachmentId),
|
|
582
|
+
openAttachmentStream: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).openStream(attachmentId),
|
|
546
583
|
// #687: open a cell the scope sealed TO this connection. The keyId-indexed
|
|
547
584
|
// map goes in, never out — a connector receives the plaintext of one cell,
|
|
548
585
|
// not a private key it could mislay. Retired keys are in the map too, so a
|
|
@@ -552,10 +589,7 @@ export class CloudflareScopeHost {
|
|
|
552
589
|
// #1691: timed where the call is made, so the data point carries a duration.
|
|
553
590
|
const started = Date.now();
|
|
554
591
|
try {
|
|
555
|
-
const res = await fetchImpl(input,
|
|
556
|
-
...init,
|
|
557
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
558
|
-
});
|
|
592
|
+
const res = await fetchImpl(input, init);
|
|
559
593
|
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
|
|
560
594
|
return res;
|
|
561
595
|
}
|
|
@@ -972,14 +1006,29 @@ export class CloudflareScopeHost {
|
|
|
972
1006
|
* gate (the caller is the control plane, which did the K-3 check + audit on its side).
|
|
973
1007
|
*/
|
|
974
1008
|
async introspectScopeTables(scopeId) {
|
|
975
|
-
return this.scopeStub(scopeId).
|
|
1009
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTablesIfHeld());
|
|
976
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope the
|
|
1013
|
+
* deployment does not hold, as the reads above are, never answered as an empty store. The carry
|
|
1014
|
+
* and its cleanup read a wiped copy's tombstone, which is not held by design: they use
|
|
1015
|
+
* `readTombstoneTable`.
|
|
1016
|
+
*/
|
|
977
1017
|
async introspectScopeTable(scopeId, input) {
|
|
1018
|
+
const reply = heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTableIfHeld(input.table, input.limit, input.offset));
|
|
1019
|
+
return unwrapReply(reply);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* A table of a store this deployment may no longer HOLD, for the carry and its cleanup, which ask
|
|
1023
|
+
* a wiped copy whether it carries the `carried_away` tombstone (#2563). Not guarded: a wiped copy
|
|
1024
|
+
* answers nothing else. Not for a screen or a console; those use `introspectScopeTable`.
|
|
1025
|
+
*/
|
|
1026
|
+
async readTombstoneTable(scopeId, input) {
|
|
978
1027
|
return unwrapReply(await this.scopeStub(scopeId).introspectTableReply(input.table, input.limit, input.offset));
|
|
979
1028
|
}
|
|
980
|
-
/** The SQL console's CP-less path (#219)
|
|
1029
|
+
/** The SQL console's CP-less path (#219): guarded as the table page is (#2563). */
|
|
981
1030
|
async introspectScopeQuery(scopeId, input) {
|
|
982
|
-
return unwrapReply(await this.scopeStub(scopeId).
|
|
1031
|
+
return unwrapReply(heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectQueryIfHeld(input.sql)));
|
|
983
1032
|
}
|
|
984
1033
|
/**
|
|
985
1034
|
* The K-35 denial log's CP-less path (#867) — same trust line as the reads above.
|
|
@@ -1262,6 +1311,8 @@ export class CloudflareScopeHost {
|
|
|
1262
1311
|
* safe; scope-level tuples (the restored grants) are never touched.
|
|
1263
1312
|
*/
|
|
1264
1313
|
async projectRolesLocal(tenantId, scopeId, roles) {
|
|
1314
|
+
// #2447: the role set is defined here, so it is judged here, before anything is projected.
|
|
1315
|
+
await this.refuseReservedRoleSet(roles, tenantId, scopeId);
|
|
1265
1316
|
unwrapReply(await this.scopeStub(scopeId).applyProjectionReply(tenantId, roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' })), []));
|
|
1266
1317
|
}
|
|
1267
1318
|
/**
|
|
@@ -1298,43 +1349,50 @@ export class CloudflareScopeHost {
|
|
|
1298
1349
|
}
|
|
1299
1350
|
/** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
|
|
1300
1351
|
async facetEventsLocal(scopeId, input) {
|
|
1301
|
-
return this.scopeStub(scopeId).
|
|
1352
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).facetEventsIfHeld(input));
|
|
1302
1353
|
}
|
|
1303
1354
|
/** What one event set off (#1237) on this host's own scope — the vertical-host read. */
|
|
1304
1355
|
async eventEffectsLocal(scopeId, input) {
|
|
1305
|
-
return this.scopeStub(scopeId).
|
|
1356
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventEffectsIfHeld(input));
|
|
1306
1357
|
}
|
|
1307
1358
|
/** Everything one call emitted (#1237) on this host's own scope — the vertical-host read. */
|
|
1308
1359
|
async invocationEventsLocal(scopeId, input) {
|
|
1309
|
-
return this.scopeStub(scopeId).
|
|
1360
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).invocationEventsIfHeld(input));
|
|
1310
1361
|
}
|
|
1311
1362
|
/** Every delivery that gave up (#1525) on this host's own scope — the vertical-host read. */
|
|
1312
1363
|
async deadLettersLocal(scopeId, input) {
|
|
1313
|
-
return this.scopeStub(scopeId).
|
|
1364
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).deadLettersIfHeld(input));
|
|
1314
1365
|
}
|
|
1315
1366
|
/** One entity's lifecycle replayed (#1744) on this host's own scope — the vertical-host read. */
|
|
1316
1367
|
async lifecycleFlowLocal(scopeId, input) {
|
|
1317
|
-
return this.scopeStub(scopeId).
|
|
1368
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).lifecycleFlowIfHeld(input));
|
|
1318
1369
|
}
|
|
1319
1370
|
/** Business volumes per bucket (#1750) on this host's own scope — the vertical-host read. */
|
|
1320
1371
|
async operationSeriesLocal(scopeId, input) {
|
|
1321
|
-
return this.scopeStub(scopeId).
|
|
1372
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).operationSeriesIfHeld(input));
|
|
1322
1373
|
}
|
|
1323
1374
|
/** One event's causal chain (#1237) on this host's own scope — the vertical-host read. */
|
|
1324
1375
|
async eventCauseLocal(scopeId, input) {
|
|
1325
|
-
return this.scopeStub(scopeId).
|
|
1376
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventCauseIfHeld(input));
|
|
1326
1377
|
}
|
|
1327
1378
|
/** One record's event history (#1235) on this host's own scope — the vertical-host read. */
|
|
1328
1379
|
async entityHistoryLocal(scopeId, input) {
|
|
1329
|
-
return this.scopeStub(scopeId).
|
|
1380
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).entityHistoryIfHeld(input));
|
|
1330
1381
|
}
|
|
1331
1382
|
/** When this host's own scope applied each migration (#1236) — the vertical-host read. */
|
|
1332
1383
|
async appliedMigrationsLocal(scopeId) {
|
|
1333
|
-
return this.scopeStub(scopeId).
|
|
1384
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).appliedMigrationsIfHeld());
|
|
1334
1385
|
}
|
|
1335
|
-
/**
|
|
1386
|
+
/**
|
|
1387
|
+
* This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
|
|
1388
|
+
* deployment does not hold is refused (#2541), not read as 0.
|
|
1389
|
+
*/
|
|
1336
1390
|
async databaseSizeLocal(scopeId) {
|
|
1337
|
-
return this.scopeStub(scopeId).
|
|
1391
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).databaseSizeIfHeld());
|
|
1392
|
+
}
|
|
1393
|
+
/** This host's own scope's attachment bytes (#2501), the vertical-host read; refused as the size is. */
|
|
1394
|
+
async attachmentSizeLocal(scopeId) {
|
|
1395
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).attachmentBytesIfHeld());
|
|
1338
1396
|
}
|
|
1339
1397
|
/**
|
|
1340
1398
|
* Each scope store's size in this deployment (#2207). The control plane already
|
|
@@ -1376,7 +1434,8 @@ export class CloudflareScopeHost {
|
|
|
1376
1434
|
* so a caller cannot ask this side for more than the other would.
|
|
1377
1435
|
*/
|
|
1378
1436
|
async undrainedEventsLocal(scopeId, limit) {
|
|
1379
|
-
|
|
1437
|
+
const bounded = Math.min(Math.max(limit, 1), 1000);
|
|
1438
|
+
return undrainedEventsOf(heldOrRefused(scopeId, await this.scopeStub(scopeId).undrainedEventsReadIfHeld(bounded)));
|
|
1380
1439
|
}
|
|
1381
1440
|
/**
|
|
1382
1441
|
* Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
|
|
@@ -1402,7 +1461,7 @@ export class CloudflareScopeHost {
|
|
|
1402
1461
|
* platform's `redrainEvents` is the door, and a count egresses nothing to record.
|
|
1403
1462
|
*/
|
|
1404
1463
|
async redrainCountLocal(scopeId, drainedBefore) {
|
|
1405
|
-
return this.scopeStub(scopeId).
|
|
1464
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
|
|
1406
1465
|
}
|
|
1407
1466
|
/**
|
|
1408
1467
|
* The PITR bookmarks one scope recorded before its migration passes (#286) — the
|
|
@@ -1410,7 +1469,7 @@ export class CloudflareScopeHost {
|
|
|
1410
1469
|
* `/internal/bookmarks`; the control plane is the gate and the auditor.
|
|
1411
1470
|
*/
|
|
1412
1471
|
async migrationBookmarksLocal(scopeId) {
|
|
1413
|
-
return this.scopeStub(scopeId).
|
|
1472
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).migrationBookmarksIfHeld());
|
|
1414
1473
|
}
|
|
1415
1474
|
/**
|
|
1416
1475
|
* Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
|
|
@@ -1583,6 +1642,7 @@ export class CloudflareScopeHost {
|
|
|
1583
1642
|
this.crossVertical.register(manifest, registration.imports);
|
|
1584
1643
|
this.moduleIds.add(manifest.id);
|
|
1585
1644
|
noteReservedKeys(this.reservedKeys, manifest);
|
|
1645
|
+
noteDeclaredKeys(this.declaredKeys, manifest);
|
|
1586
1646
|
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
1587
1647
|
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
1588
1648
|
}
|
|
@@ -1844,22 +1904,11 @@ export class CloudflareScopeHost {
|
|
|
1844
1904
|
const filename = view.filename ?? input.filename;
|
|
1845
1905
|
// Recorded as it is judged: the essence, never a parameter or a stray space.
|
|
1846
1906
|
const contentType = normalizeUploadMediaType(input.contentType);
|
|
1847
|
-
const record = attachmentRecord.parse({
|
|
1848
|
-
id,
|
|
1849
|
-
entity: view.entity,
|
|
1850
|
-
filename,
|
|
1851
|
-
contentType,
|
|
1852
|
-
size: input.body.byteLength,
|
|
1853
|
-
sha256: await attachmentSha256(input.body),
|
|
1854
|
-
visibility: view.visibility,
|
|
1855
|
-
createdBy: mintedBy,
|
|
1856
|
-
createdAt: new Date().toISOString(),
|
|
1857
|
-
});
|
|
1858
1907
|
// Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
|
|
1859
1908
|
// someone else spent first) compensates the object away, and the link is unspent with it.
|
|
1860
1909
|
// A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
|
|
1861
1910
|
// `Idempotency-Key` is answered as a replay (#2315).
|
|
1862
|
-
await store
|
|
1911
|
+
const record = await putAttachmentBytes(store, key, { id, entity: view.entity, filename, contentType, visibility: view.visibility, createdBy: mintedBy, createdAt: new Date().toISOString() }, input.body, workerdSha256);
|
|
1863
1912
|
return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
|
|
1864
1913
|
});
|
|
1865
1914
|
}
|
|
@@ -1908,19 +1957,28 @@ export class CloudflareScopeHost {
|
|
|
1908
1957
|
throw new Error(`connector attachment ${verb} is not delegated (#574) — upload and open are the ` +
|
|
1909
1958
|
`verbs that cross the /internal seam to the serving deployment`);
|
|
1910
1959
|
};
|
|
1960
|
+
const openArgs = (attachmentId) => ({
|
|
1961
|
+
connectionId,
|
|
1962
|
+
tenantId: tenant,
|
|
1963
|
+
scopeId,
|
|
1964
|
+
vertical,
|
|
1965
|
+
attachmentId,
|
|
1966
|
+
// #726 remedy B: the delivery, resolved at the far end against the
|
|
1967
|
+
// deployment's own outbox. Absent outside a dispatch, where the ordinary
|
|
1968
|
+
// permission check still applies.
|
|
1969
|
+
eventId: forEvent?.eventId,
|
|
1970
|
+
});
|
|
1911
1971
|
return {
|
|
1912
1972
|
upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
|
|
1913
|
-
open: (attachmentId) => delegation.openAttachment(
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
eventId: forEvent?.eventId,
|
|
1923
|
-
}),
|
|
1973
|
+
open: (attachmentId) => delegation.openAttachment(openArgs(attachmentId)),
|
|
1974
|
+
// #2514: streamed over the seam when the embedder can. One that cannot reads the bytes whole,
|
|
1975
|
+
// as `open` does, and hands them on as a stream, so the verb still works and holds what it did.
|
|
1976
|
+
openStream: async (attachmentId) => {
|
|
1977
|
+
if (delegation.openAttachmentStream)
|
|
1978
|
+
return delegation.openAttachmentStream(openArgs(attachmentId));
|
|
1979
|
+
const opened = await delegation.openAttachment(openArgs(attachmentId));
|
|
1980
|
+
return opened && { record: opened.record, body: bytesStream(opened.body), contentType: opened.contentType };
|
|
1981
|
+
},
|
|
1924
1982
|
list: notDelegated('list'),
|
|
1925
1983
|
remove: notDelegated('remove'),
|
|
1926
1984
|
// #1575: undelegated for `list`'s reason — a connector picks a document by id.
|
|
@@ -1967,6 +2025,10 @@ export class CloudflareScopeHost {
|
|
|
1967
2025
|
const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
|
|
1968
2026
|
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
1969
2027
|
},
|
|
2028
|
+
openStream: async (attachmentId) => {
|
|
2029
|
+
const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
|
|
2030
|
+
return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
|
|
2031
|
+
},
|
|
1970
2032
|
remove: async (attachmentId) => unwrapReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
1971
2033
|
attachmentId,
|
|
1972
2034
|
})),
|
|
@@ -1986,31 +2048,42 @@ export class CloudflareScopeHost {
|
|
|
1986
2048
|
const asPrincipalId = ('principal' in subject
|
|
1987
2049
|
? subject.principal
|
|
1988
2050
|
: subject.connectionId);
|
|
2051
|
+
// The read gate, asked in the DO before the blob store is touched: a refused read never reaches it.
|
|
2052
|
+
const authorizeRead = async (attachmentId) => unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
|
|
1989
2053
|
return {
|
|
1990
2054
|
upload: async (input) => {
|
|
1991
2055
|
const id = ulid();
|
|
1992
2056
|
const key = attachmentBlobKey(scopeId, id);
|
|
1993
|
-
const
|
|
2057
|
+
const fields = {
|
|
1994
2058
|
id,
|
|
1995
2059
|
entity: input.entity,
|
|
1996
2060
|
filename: input.filename,
|
|
1997
2061
|
contentType: input.contentType,
|
|
1998
|
-
size: input.body.byteLength,
|
|
1999
|
-
sha256: await attachmentSha256(input.body),
|
|
2000
2062
|
visibility: input.visibility,
|
|
2001
2063
|
createdBy,
|
|
2002
2064
|
createdAt: new Date().toISOString(),
|
|
2003
|
-
}
|
|
2065
|
+
};
|
|
2066
|
+
// A streamed body is the case where bytes are the cost, so the write gate is asked before
|
|
2067
|
+
// the first one is read (#2516). The row's own check below stays: this one is only early.
|
|
2068
|
+
if (isSizedByteStream(input.body)) {
|
|
2069
|
+
await askAttachmentWriteGate(stub, input.entity, asPrincipalId, tenantId, scopeId, connectionId);
|
|
2070
|
+
}
|
|
2004
2071
|
// Bytes first, row second — a crash between the two leaves an orphaned object (#2314);
|
|
2005
2072
|
// a refused gate compensates, and only an answered refusal does (`compensatingRefusal`).
|
|
2006
|
-
|
|
2073
|
+
// A streamed body is hashed and counted as R2 reads it (#2498).
|
|
2074
|
+
const record = await putAttachmentBytes(store, key, fields, input.body, workerdSha256);
|
|
2007
2075
|
return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId)));
|
|
2008
2076
|
},
|
|
2009
2077
|
list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
|
|
2010
2078
|
open: async (attachmentId) => {
|
|
2011
|
-
const record =
|
|
2079
|
+
const record = await authorizeRead(attachmentId);
|
|
2012
2080
|
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
2013
2081
|
},
|
|
2082
|
+
// #2514: the same gate, then the bytes as R2 reads them, checked against the record as they pass.
|
|
2083
|
+
openStream: async (attachmentId) => {
|
|
2084
|
+
const record = await authorizeRead(attachmentId);
|
|
2085
|
+
return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
|
|
2086
|
+
},
|
|
2014
2087
|
remove: async (attachmentId) => {
|
|
2015
2088
|
const removed = unwrapReply(await stub.attachmentRemoveReply(attachmentId, asPrincipalId, tenantId, scopeId, connectionId));
|
|
2016
2089
|
if (removed)
|
|
@@ -2044,7 +2117,17 @@ export class CloudflareScopeHost {
|
|
|
2044
2117
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
2045
2118
|
}
|
|
2046
2119
|
async importScope(actor, input, dump) {
|
|
2120
|
+
await this.importScopeFrom(actor, input, dump, true);
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* `importScope`, with its role judgement (#2555) when `judge`, made before the scope is created so a
|
|
2124
|
+
* refused import leaves nothing behind. A snapshot passes false: it is the platform's copy of a live
|
|
2125
|
+
* scope of this host, made at once, so its seats are the ones that scope holds now (K-55).
|
|
2126
|
+
*/
|
|
2127
|
+
async importScopeFrom(actor, input, dump, judge) {
|
|
2047
2128
|
this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
|
|
2129
|
+
if (judge)
|
|
2130
|
+
await this.refuseDumpRoles(input.tenantId, null, dump);
|
|
2048
2131
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
2049
2132
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
2050
2133
|
// the end state is the dump, at the source's frontier. Provenance is stamped from
|
|
@@ -2069,6 +2152,8 @@ export class CloudflareScopeHost {
|
|
|
2069
2152
|
if (!existing)
|
|
2070
2153
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2071
2154
|
this.refuseUnusableScopeStores(dump.stores, existing.vertical);
|
|
2155
|
+
// #2555: judged before the fence, so a refused restore moves nothing.
|
|
2156
|
+
await this.refuseDumpRoles(tenantId, scopeId, dump);
|
|
2072
2157
|
// #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
|
|
2073
2158
|
await this.fenceOrphanSweep(scopeId, tenantId);
|
|
2074
2159
|
// #1742: a dump from before a switch was pulled brings the module's grants back live, so
|
|
@@ -2135,15 +2220,16 @@ export class CloudflareScopeHost {
|
|
|
2135
2220
|
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2136
2221
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
2137
2222
|
const snapshotId = ulid();
|
|
2138
|
-
await this.
|
|
2223
|
+
await this.importScopeFrom(actor, {
|
|
2139
2224
|
tenantId,
|
|
2140
2225
|
scopeId: snapshotId,
|
|
2141
2226
|
kind: opts?.kind ?? 'archive',
|
|
2142
2227
|
vertical: source.vertical,
|
|
2228
|
+
verticalSource: 'copy',
|
|
2143
2229
|
jurisdiction: source.jurisdiction,
|
|
2144
2230
|
expiresAt: opts?.expiresAt,
|
|
2145
2231
|
// forkedFrom/forkedAt are stamped from the dump by importScope.
|
|
2146
|
-
}, dump);
|
|
2232
|
+
}, dump, false);
|
|
2147
2233
|
// Bind the snapshot to the source's current version so it is a runnable copy at the
|
|
2148
2234
|
// same frontier (a fresh bind, so it never re-triggers the snapshot path).
|
|
2149
2235
|
if (source.verticalVersionId) {
|
|
@@ -3126,7 +3212,9 @@ export class CloudflareScopeHost {
|
|
|
3126
3212
|
}
|
|
3127
3213
|
return report;
|
|
3128
3214
|
}
|
|
3129
|
-
async runDueSchedules(moduleId, tenantId, scopeId
|
|
3215
|
+
async runDueSchedules(moduleId, tenantId, scopeId,
|
|
3216
|
+
/** #2517: what the sweep tick's earlier calls did to this scope (`ScopeHost.runDueSchedules`). */
|
|
3217
|
+
tick) {
|
|
3130
3218
|
const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
|
|
3131
3219
|
const schedules = this.moduleSchedules.get(moduleId);
|
|
3132
3220
|
if (!schedules || schedules.length === 0)
|
|
@@ -3181,6 +3269,10 @@ export class CloudflareScopeHost {
|
|
|
3181
3269
|
// scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
|
|
3182
3270
|
let platformRequests = 0;
|
|
3183
3271
|
const counted = { onPlatformRequests: (n) => (platformRequests += n) };
|
|
3272
|
+
// #2517: the part of that the purge runs enqueued, which is what defers a later purge head the
|
|
3273
|
+
// cap refuses (`runPurgePass`): this call's, on top of what the tick's earlier calls report.
|
|
3274
|
+
const earlierPurgeRequests = tick?.earlierPurgeRequests ?? 0;
|
|
3275
|
+
let purgeRequests = 0;
|
|
3184
3276
|
for (const schedule of schedules) {
|
|
3185
3277
|
const last = await stub.scheduleLastRun(schedule.operation);
|
|
3186
3278
|
const lastRun = last ? Date.parse(last) : null;
|
|
@@ -3215,12 +3307,24 @@ export class CloudflareScopeHost {
|
|
|
3215
3307
|
// call and transaction — and the SCOPE decides which are due and runs them, so purge
|
|
3216
3308
|
// authority is never an argument any stub can pass (`runPurgeSweep`).
|
|
3217
3309
|
// #2192: under the run's own id, so the purges and their deliveries join this run's line.
|
|
3218
|
-
// #2431: the
|
|
3219
|
-
// the deliveries their events route to the platform are counted below, as an invoke's are.
|
|
3220
|
-
|
|
3310
|
+
// #2431, #2440: the scope reports the requests its purges enqueued (`pass.platformRequests`),
|
|
3311
|
+
// and the deliveries their events route to the platform are counted below, as an invoke's are.
|
|
3312
|
+
// #2517: with what the purge runs before this one in the tick enqueued, so a head the cap
|
|
3313
|
+
// refuses because they filled the queue is deferred to the next tick rather than failed.
|
|
3314
|
+
const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId, earlierPurgeRequests + purgeRequests));
|
|
3315
|
+
if (pass.deferred) {
|
|
3316
|
+
// Ran nothing: no line, no cadence row, `skipped` like any run that did not happen.
|
|
3317
|
+
report.skipped += 1;
|
|
3318
|
+
report.deferred = (report.deferred ?? 0) + 1;
|
|
3319
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
platformRequests += pass.platformRequests ?? 0;
|
|
3323
|
+
purgeRequests += pass.platformRequests ?? 0;
|
|
3221
3324
|
// The purges' events, delivered the way each invoke's own tail delivers them.
|
|
3222
3325
|
const delivered = await this.drainExecutors(tenantId, scopeId, invocationId);
|
|
3223
3326
|
platformRequests += delivered.routedToPlatform ?? 0;
|
|
3327
|
+
purgeRequests += delivered.routedToPlatform ?? 0;
|
|
3224
3328
|
stillDue = purgeStillDue(pass);
|
|
3225
3329
|
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3226
3330
|
report.errors.push(...outcome.errors);
|
|
@@ -3274,6 +3378,8 @@ export class CloudflareScopeHost {
|
|
|
3274
3378
|
lines.end();
|
|
3275
3379
|
if (platformRequests > 0)
|
|
3276
3380
|
report.platformRequests = platformRequests;
|
|
3381
|
+
if (purgeRequests > 0)
|
|
3382
|
+
report.purgeRequests = purgeRequests;
|
|
3277
3383
|
return report;
|
|
3278
3384
|
}
|
|
3279
3385
|
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
@@ -3594,6 +3700,19 @@ export class CloudflareScopeHost {
|
|
|
3594
3700
|
archivedFromStatus: (r.archived_from_status ?? null),
|
|
3595
3701
|
createdAt: r.created_at,
|
|
3596
3702
|
});
|
|
3703
|
+
// The directory rows `listScopes` and `listScopesReadable` map, filtered and paged alike.
|
|
3704
|
+
const scopeRows = (filter) => this.cp.listScopes({
|
|
3705
|
+
tenantId: filter?.tenantId,
|
|
3706
|
+
status: filter?.status
|
|
3707
|
+
? Array.isArray(filter.status)
|
|
3708
|
+
? filter.status
|
|
3709
|
+
: [filter.status]
|
|
3710
|
+
: undefined,
|
|
3711
|
+
vertical: filter?.vertical,
|
|
3712
|
+
limit: filter?.limit,
|
|
3713
|
+
cursor: filter?.cursor,
|
|
3714
|
+
order: filter?.order,
|
|
3715
|
+
});
|
|
3597
3716
|
// The (version, scope) pair a bind and its impact read both start from, and the refusals
|
|
3598
3717
|
// that come before any export-break question, in the order the bind makes them (#1756).
|
|
3599
3718
|
const bindTarget = async (tenantId, scopeId, versionId) => {
|
|
@@ -4166,7 +4285,19 @@ export class CloudflareScopeHost {
|
|
|
4166
4285
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
4167
4286
|
defineRole: async (actor, tenantId, role) => {
|
|
4168
4287
|
const parsed = roleDefinition.parse(role);
|
|
4169
|
-
|
|
4288
|
+
// #2447: judged in the ControlPlaneDO's unit, against this host's keys and the tenant's other
|
|
4289
|
+
// roles, so a role assigned through a door cannot be widened afterwards. A refusal writes
|
|
4290
|
+
// nothing, so a seat of the role as it was stored keeps working.
|
|
4291
|
+
const context = await this.definitionContext(tenantId, parsed);
|
|
4292
|
+
if (context.unreadable)
|
|
4293
|
+
throw substratError('forbidden', `role '${parsed.key}' cannot be judged: ${context.unreadable}`);
|
|
4294
|
+
// `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
|
|
4295
|
+
// before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
|
|
4296
|
+
// than being misread.
|
|
4297
|
+
const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
|
|
4298
|
+
if ('refused' in defined)
|
|
4299
|
+
throw substratError('forbidden', defined.refused);
|
|
4300
|
+
const before = defined.before;
|
|
4170
4301
|
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
4171
4302
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
4172
4303
|
},
|
|
@@ -4607,8 +4738,7 @@ export class CloudflareScopeHost {
|
|
|
4607
4738
|
}
|
|
4608
4739
|
throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
4609
4740
|
}
|
|
4610
|
-
await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0,
|
|
4611
|
-
await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
|
|
4741
|
+
await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, verticalOwnerNow(), this.adminEntry(actor, 'registerVertical', { tenantId: null }, null, parsed));
|
|
4612
4742
|
},
|
|
4613
4743
|
listVerticals: async (actor, page) => {
|
|
4614
4744
|
const rows = await this.cp.listVerticals(page);
|
|
@@ -4624,7 +4754,9 @@ export class CloudflareScopeHost {
|
|
|
4624
4754
|
// Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
|
|
4625
4755
|
// (tenant-owned, not listed), whose blast radius is its own tenant: there the
|
|
4626
4756
|
// sandbox contract is the gate and the version self-admits, noted so the
|
|
4627
|
-
// publish seam can tell a staff vouch from this shortcut.
|
|
4757
|
+
// publish seam can tell a staff vouch from this shortcut. The DO judges it again on the
|
|
4758
|
+
// vertical as the insert's unit reads it, and that is what the row and the version carry
|
|
4759
|
+
// (#2528); this read answers an older DO, which takes what it is sent.
|
|
4628
4760
|
const selfAdmits = owning.owner_tenant !== null && !owning.listed;
|
|
4629
4761
|
// The manifest is retained for the serving upload (#286), not audited — a whole
|
|
4630
4762
|
// manifest per publish would drown the admin log in bundle metadata.
|
|
@@ -4645,14 +4777,13 @@ export class CloudflareScopeHost {
|
|
|
4645
4777
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
4646
4778
|
admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
|
|
4647
4779
|
createdAt: new Date().toISOString(),
|
|
4648
|
-
}
|
|
4649
|
-
await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
4780
|
+
}, this.adminEntry(actor, 'publishVersion', { tenantId: null }, null, {
|
|
4650
4781
|
...audited,
|
|
4651
4782
|
// The digest the registry holds, which differs from the pushed one only for an unheld version.
|
|
4652
4783
|
migrationDigest,
|
|
4653
4784
|
...(origin ? { origin } : {}),
|
|
4654
4785
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
4655
|
-
});
|
|
4786
|
+
}));
|
|
4656
4787
|
},
|
|
4657
4788
|
verticalScopeStores: async (actor, verticalSlug) => {
|
|
4658
4789
|
const stores = await this.directory('verticalScopeStores', verticalSlug);
|
|
@@ -4671,90 +4802,43 @@ export class CloudflareScopeHost {
|
|
|
4671
4802
|
await this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
|
|
4672
4803
|
return hit ? mapVersion(hit) : undefined;
|
|
4673
4804
|
},
|
|
4805
|
+
// #2528: each vertical write below is one DO call that reads the row's `before`, and runs its
|
|
4806
|
+
// guard, in the unit that writes; it refuses an unknown vertical with not_found.
|
|
4674
4807
|
setVerticalListed: async (actor, slug, listed) => {
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
//
|
|
4679
|
-
|
|
4680
|
-
// prod version has never been read by anyone but its author.
|
|
4681
|
-
if (listed) {
|
|
4682
|
-
const prod = await this.cp.readChannel(slug, 'prod');
|
|
4683
|
-
const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
|
|
4684
|
-
if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
4685
|
-
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
4686
|
-
`a staff admit must vouch for it before listing`);
|
|
4687
|
-
}
|
|
4688
|
-
}
|
|
4689
|
-
await this.cp.updateVerticalListed(slug, listed ? 1 : 0); // also resolves any pending request
|
|
4690
|
-
await this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed });
|
|
4808
|
+
// Listing is the moment other tenants start trusting this code, so the version they
|
|
4809
|
+
// would install must carry a real staff vouch — an auto-admitted prod version has never
|
|
4810
|
+
// been read by anyone but its author. The DO refuses one; it also resolves any pending
|
|
4811
|
+
// publish request.
|
|
4812
|
+
await this.directory('updateVerticalListed', slug, listed ? 1 : 0, this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, null, { listed }));
|
|
4691
4813
|
},
|
|
4692
4814
|
requestPublish: async (actor, slug) => {
|
|
4693
|
-
|
|
4694
|
-
if (!existing)
|
|
4695
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4696
|
-
await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString());
|
|
4697
|
-
await this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
|
|
4815
|
+
await this.directory('updateVerticalPublishRequest', slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
|
|
4698
4816
|
},
|
|
4699
4817
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
4700
|
-
|
|
4701
|
-
if (!existing)
|
|
4702
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4703
|
-
await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0);
|
|
4704
|
-
await this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked });
|
|
4818
|
+
await this.directory('updateVerticalInstallsBlocked', slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, null, { installsBlocked: blocked }));
|
|
4705
4819
|
},
|
|
4706
4820
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
4707
|
-
|
|
4708
|
-
if (!existing)
|
|
4709
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4710
|
-
await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0);
|
|
4711
|
-
await this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted });
|
|
4821
|
+
await this.directory('updateVerticalTenantProvisioner', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, null, { tenantProvisioner: granted }));
|
|
4712
4822
|
},
|
|
4713
4823
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
4714
|
-
|
|
4715
|
-
if (!existing)
|
|
4716
|
-
throw substratError('not_found', `unknown vertical '${slug}'`);
|
|
4717
|
-
await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0);
|
|
4718
|
-
await this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted });
|
|
4824
|
+
await this.directory('updateVerticalEmailSender', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, null, { emailSender: granted }));
|
|
4719
4825
|
},
|
|
4720
4826
|
deleteVertical: async (actor, slug) => {
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
//
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
if (bound.live > 0) {
|
|
4732
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
4733
|
-
}
|
|
4734
|
-
if (bound.archived > 0) {
|
|
4735
|
-
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
4736
|
-
}
|
|
4737
|
-
await this.cp.deleteVertical(slug);
|
|
4738
|
-
await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
|
|
4827
|
+
// Refused while any restorable scope is bound (the DO counts them in the delete's unit).
|
|
4828
|
+
// `reaped` is terminal history and never blocks. Deployed dispatch scripts are NOT reaped
|
|
4829
|
+
// here — they become orphans for the cleanup script (#248). #2501: the period's `until`
|
|
4830
|
+
// is read here, on the clock its `since` was.
|
|
4831
|
+
await this.directory('deleteVertical', slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, null, null), verticalOwnerNow());
|
|
4832
|
+
},
|
|
4833
|
+
listVerticalOwners: async (actor, slug) => {
|
|
4834
|
+
const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
|
|
4835
|
+
await this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
|
|
4836
|
+
return periods;
|
|
4739
4837
|
},
|
|
4740
4838
|
admitVersion: async (actor, versionId) => {
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
if (v.admission === 'admitted') {
|
|
4745
|
-
// Idempotent — except an AUTO-admitted version, which this upgrades to a
|
|
4746
|
-
// manual vouch by clearing the note (what the publish seam requires).
|
|
4747
|
-
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
4748
|
-
return;
|
|
4749
|
-
await this.cp.setAdmission(versionId, 'admitted', null);
|
|
4750
|
-
await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null });
|
|
4751
|
-
return;
|
|
4752
|
-
}
|
|
4753
|
-
if (v.admission === 'rejected') {
|
|
4754
|
-
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
4755
|
-
}
|
|
4756
|
-
await this.cp.setAdmission(versionId, 'admitted', null);
|
|
4757
|
-
await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
|
|
4839
|
+
// Judged in the DO's unit (#2528): idempotent on an admitted version, except an AUTO-admitted
|
|
4840
|
+
// one, which it upgrades to a manual vouch; a rejected one is refused. The DO fills the row.
|
|
4841
|
+
await this.directory('admitVersion', versionId, this.adminEntry(actor, 'admitVersion', { tenantId: null }, null, null));
|
|
4758
4842
|
},
|
|
4759
4843
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
4760
4844
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -4770,16 +4854,8 @@ export class CloudflareScopeHost {
|
|
|
4770
4854
|
return breaks;
|
|
4771
4855
|
},
|
|
4772
4856
|
rejectVersion: async (actor, versionId, note) => {
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
throw substratError('not_found', `unknown version ${versionId}`);
|
|
4776
|
-
if (v.admission === 'admitted') {
|
|
4777
|
-
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
4778
|
-
}
|
|
4779
|
-
if (v.admission === 'rejected')
|
|
4780
|
-
return;
|
|
4781
|
-
await this.cp.setAdmission(versionId, 'rejected', note);
|
|
4782
|
-
await this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note });
|
|
4857
|
+
// Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
|
|
4858
|
+
await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
|
|
4783
4859
|
},
|
|
4784
4860
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4785
4861
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -4853,7 +4929,8 @@ export class CloudflareScopeHost {
|
|
|
4853
4929
|
// in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
|
|
4854
4930
|
// choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
|
|
4855
4931
|
// only if the pointer still names `outgoing`; the promote that lost the race is refused as
|
|
4856
|
-
// stale and writes nothing.
|
|
4932
|
+
// stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
|
|
4933
|
+
// which record their own — and the acknowledgement is recorded, not just enforced.
|
|
4857
4934
|
const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
|
|
4858
4935
|
id: ulid(),
|
|
4859
4936
|
vertical_slug: verticalSlug,
|
|
@@ -4862,7 +4939,7 @@ export class CloudflareScopeHost {
|
|
|
4862
4939
|
from_version_id: outgoing?.id ?? null,
|
|
4863
4940
|
actor,
|
|
4864
4941
|
at: promotedAt,
|
|
4865
|
-
});
|
|
4942
|
+
}, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
4866
4943
|
if (!moved)
|
|
4867
4944
|
throw promoteRaceRefusal(channel);
|
|
4868
4945
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
@@ -4892,7 +4969,6 @@ export class CloudflareScopeHost {
|
|
|
4892
4969
|
}
|
|
4893
4970
|
await this.cp.bindScopeVersion(s.scope_id, versionId, verticalSlug, undefined, undefined, undefined, undefined, undefined, this.adminEntry(actor, 'bindScopeVersion', { tenantId: s.tenant_id, scopeId: s.scope_id }, prev ? { versionId: prev.id, version: prev.version } : null, { versionId, vertical: verticalSlug, version: incoming.version, via: 'promoteVersion' }));
|
|
4894
4971
|
}
|
|
4895
|
-
await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
|
|
4896
4972
|
},
|
|
4897
4973
|
listChannels: async (actor, verticalSlug, page) => {
|
|
4898
4974
|
// `prod` is the only live channel (#509 retired dev/staging). Filter before the parse
|
|
@@ -5056,15 +5132,16 @@ export class CloudflareScopeHost {
|
|
|
5056
5132
|
await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
|
|
5057
5133
|
}
|
|
5058
5134
|
}
|
|
5059
|
-
|
|
5135
|
+
// #2528: a compare-and-set on the serving ref and version the gate above judged, across
|
|
5136
|
+
// awaits another serve can run in. The DO fills the row's `before` from the state it moves.
|
|
5137
|
+
const moved = await this.directory('setVerticalServing', verticalSlug, {
|
|
5060
5138
|
ref: parsed.ref,
|
|
5061
5139
|
versionId: parsed.versionId,
|
|
5062
5140
|
doClassesJson: JSON.stringify(parsed.doClasses),
|
|
5063
5141
|
migrationTag: parsed.migrationTag,
|
|
5064
|
-
});
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
: null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId });
|
|
5142
|
+
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), { ref: r.serving_ref ?? null, versionId: r.serving_version_id ?? null });
|
|
5143
|
+
if (!moved)
|
|
5144
|
+
throw servingRaceRefusal(verticalSlug);
|
|
5068
5145
|
},
|
|
5069
5146
|
versionManifest: async (actor, verticalSlug, versionId) => {
|
|
5070
5147
|
const v = await this.cp.readVersion(versionId);
|
|
@@ -5082,6 +5159,21 @@ export class CloudflareScopeHost {
|
|
|
5082
5159
|
await this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
|
|
5083
5160
|
return v.migrations;
|
|
5084
5161
|
},
|
|
5162
|
+
recordWireAuth: async (actor, input) => {
|
|
5163
|
+
const row = deploymentWireAuth.parse(input);
|
|
5164
|
+
const written = await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
|
|
5165
|
+
// Thrown here, not in the directory's object, so the code survives the RPC.
|
|
5166
|
+
if (!written)
|
|
5167
|
+
throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
|
|
5168
|
+
},
|
|
5169
|
+
listWireAuth: async (actor, input) => {
|
|
5170
|
+
// Judged here, before the call into the directory's object, so a refusal keeps its code; the
|
|
5171
|
+
// judged copy is what crosses the RPC and reaches the log (its own fields, the reach first).
|
|
5172
|
+
const filter = assertWireAuthFilter(input);
|
|
5173
|
+
const rows = await this.cp.listWireAuth(filter);
|
|
5174
|
+
await this.recordAccess(actor, 'listWireAuth', wireAuthAccessTarget(filter), filter, rows.length);
|
|
5175
|
+
return rows;
|
|
5176
|
+
},
|
|
5085
5177
|
setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
|
|
5086
5178
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5087
5179
|
if (!scope)
|
|
@@ -5144,6 +5236,13 @@ export class CloudflareScopeHost {
|
|
|
5144
5236
|
await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
|
|
5145
5237
|
return sizes;
|
|
5146
5238
|
},
|
|
5239
|
+
scopeAttachmentBytes: async (actor, tenantId, scopeId) => {
|
|
5240
|
+
// Reaped is refused, not read: addressing the deleted DO would recreate it.
|
|
5241
|
+
await this.scopeRecordForRead(tenantId, scopeId);
|
|
5242
|
+
const bytes = await this.scopeStub(scopeId).attachmentBytes();
|
|
5243
|
+
await this.recordAccess(actor, 'scopeAttachmentBytes', { tenantId, scopeId }, null, 1);
|
|
5244
|
+
return bytes;
|
|
5245
|
+
},
|
|
5147
5246
|
rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
|
|
5148
5247
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5149
5248
|
if (!scope)
|
|
@@ -5228,31 +5327,26 @@ export class CloudflareScopeHost {
|
|
|
5228
5327
|
},
|
|
5229
5328
|
createTenant: async (actor, input) => {
|
|
5230
5329
|
const parsed = createTenantInput.parse(input);
|
|
5231
|
-
|
|
5232
|
-
//
|
|
5233
|
-
|
|
5234
|
-
return;
|
|
5235
|
-
await this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, created);
|
|
5330
|
+
// Idempotent: re-creating an existing tenant is a no-op, not audited. The DO writes the
|
|
5331
|
+
// row with the tenant it created as its `after`.
|
|
5332
|
+
await this.directory('createTenant', parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null, this.adminEntry(actor, 'createTenant', { tenantId: parsed.id }, null, null));
|
|
5236
5333
|
},
|
|
5237
5334
|
setTenantStatus: async (actor, tenantId, status) => {
|
|
5238
|
-
|
|
5239
|
-
await this.
|
|
5335
|
+
// The DO fills the row's `before` with the status it read in the write's unit.
|
|
5336
|
+
await this.directory('setTenantStatus', tenantId, status, this.adminEntry(actor, 'setTenantStatus', { tenantId }, null, { status }));
|
|
5240
5337
|
// #1713: every hosted scope under the tenant holds or resumes its work by this.
|
|
5241
5338
|
await this.deliverLifecycles(actor, { tenantId });
|
|
5242
5339
|
},
|
|
5243
5340
|
setTenantName: async (actor, tenantId, name) => {
|
|
5244
|
-
|
|
5245
|
-
|
|
5246
|
-
return; // no-op is not audited — nothing changed
|
|
5247
|
-
await this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before }, { name });
|
|
5341
|
+
// A rename to the current name writes nothing and is not audited; the DO fills `before`.
|
|
5342
|
+
await this.directory('setTenantName', tenantId, name, this.adminEntry(actor, 'setTenantName', { tenantId }, null, { name }));
|
|
5248
5343
|
},
|
|
5249
5344
|
reapTenant: async (actor, tenantId) => {
|
|
5250
5345
|
// Directory-side terminal reap (§4.8). The caller reaped every scope's storage
|
|
5251
5346
|
// first (archive-if-needed → reapScope in the vertical deployment); the DO clears
|
|
5252
5347
|
// the tenant's PII/config rows and flips the row to a `reaped` tombstone, keeping
|
|
5253
5348
|
// the row + admin log. Only a `deleting` tenant may be reaped (checked in the DO).
|
|
5254
|
-
|
|
5255
|
-
await this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before }, { status: 'reaped' });
|
|
5349
|
+
await this.directory('reapTenant', tenantId, this.adminEntry(actor, 'reapTenant', { tenantId }, null, { status: 'reaped' }));
|
|
5256
5350
|
},
|
|
5257
5351
|
listTenants: async (actor, page) => {
|
|
5258
5352
|
const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
|
|
@@ -5266,21 +5360,15 @@ export class CloudflareScopeHost {
|
|
|
5266
5360
|
return t ? tenantSchema.parse(t) : undefined;
|
|
5267
5361
|
},
|
|
5268
5362
|
listScopes: async (actor, filter) => {
|
|
5269
|
-
const rows = await
|
|
5270
|
-
tenantId: filter?.tenantId,
|
|
5271
|
-
status: filter?.status
|
|
5272
|
-
? Array.isArray(filter.status)
|
|
5273
|
-
? filter.status
|
|
5274
|
-
: [filter.status]
|
|
5275
|
-
: undefined,
|
|
5276
|
-
vertical: filter?.vertical,
|
|
5277
|
-
limit: filter?.limit,
|
|
5278
|
-
cursor: filter?.cursor,
|
|
5279
|
-
order: filter?.order,
|
|
5280
|
-
});
|
|
5363
|
+
const rows = await scopeRows(filter);
|
|
5281
5364
|
await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
5282
5365
|
return rows.map(mapScope);
|
|
5283
5366
|
},
|
|
5367
|
+
listScopesReadable: async (actor, filter) => {
|
|
5368
|
+
const rows = await scopeRows(filter);
|
|
5369
|
+
await this.recordAccess(actor, 'listScopesReadable', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
5370
|
+
return readableScopes(rows, mapScope);
|
|
5371
|
+
},
|
|
5284
5372
|
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
5285
5373
|
const row = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5286
5374
|
await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
|
|
@@ -5373,6 +5461,9 @@ export class CloudflareScopeHost {
|
|
|
5373
5461
|
recordStoreDriverRebuildRun: async (actor, entry) => {
|
|
5374
5462
|
await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
|
|
5375
5463
|
},
|
|
5464
|
+
recordWireWalk: async (actor, entry) => {
|
|
5465
|
+
await this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry);
|
|
5466
|
+
},
|
|
5376
5467
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
5377
5468
|
// K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
|
|
5378
5469
|
// that does not resolve is unreachable, never another tenant's database.
|
|
@@ -5984,6 +6075,7 @@ export class CloudflareScopeHost {
|
|
|
5984
6075
|
storage: rows.storage,
|
|
5985
6076
|
storageAttempts: rows.storageAttempts,
|
|
5986
6077
|
storeStorage: rows.storeStorage,
|
|
6078
|
+
attachmentStorage: rows.attachmentStorage,
|
|
5987
6079
|
});
|
|
5988
6080
|
// Tenants covered, not totals: "read one tenant's meter" and "metered the whole
|
|
5989
6081
|
// fleet" are different acts, and K-24 exists to tell them apart.
|
|
@@ -6388,7 +6480,10 @@ export class CloudflareScopeHost {
|
|
|
6388
6480
|
return rows.map((r) => adminLogEntry.parse(r));
|
|
6389
6481
|
},
|
|
6390
6482
|
recordOpsFailure: async (entry) => {
|
|
6483
|
+
// #2540: a pair's staff row is minted first, so the tenant's row lists above it.
|
|
6484
|
+
const detail = entry.tenantId && typeof entry.detail === 'string' ? { detail: entry.detail.slice(0, 2000), detail_id: ulid() } : {};
|
|
6391
6485
|
await this.cp.recordOpsFailure({
|
|
6486
|
+
...detail,
|
|
6392
6487
|
id: ulid(),
|
|
6393
6488
|
actor: entry.actor,
|
|
6394
6489
|
operation: entry.operation,
|
|
@@ -6418,6 +6513,7 @@ export class CloudflareScopeHost {
|
|
|
6418
6513
|
code: filter?.code,
|
|
6419
6514
|
fingerprint: filter?.fingerprint,
|
|
6420
6515
|
reference: filter?.reference,
|
|
6516
|
+
referenceInText: filter?.referenceInText,
|
|
6421
6517
|
since: filter?.since,
|
|
6422
6518
|
until: filter?.until,
|
|
6423
6519
|
limit: filter?.limit,
|
|
@@ -6534,6 +6630,30 @@ export class CloudflareScopeHost {
|
|
|
6534
6630
|
return rows;
|
|
6535
6631
|
},
|
|
6536
6632
|
pruneScopeStorage: async (_actor, limit) => this.cp.pruneScopeStorage(assertRowLimit('limit', limit)),
|
|
6633
|
+
recordRequestDay: async (_actor, input) => this.cp.recordRequestDay(input),
|
|
6634
|
+
listRequestDays: async (actor, filter) => {
|
|
6635
|
+
const rows = (await this.cp.listRequestDays(filter)).map((r) => requestDay.parse(r));
|
|
6636
|
+
await this.recordAccess(actor, 'listRequestDays', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
|
|
6637
|
+
return rows;
|
|
6638
|
+
},
|
|
6639
|
+
listRequestRollupDays: async (actor, filter) => {
|
|
6640
|
+
const rows = (await this.cp.listRequestRollupDays(filter)).map((r) => requestRollupDay.parse(r));
|
|
6641
|
+
await this.recordAccess(actor, 'listRequestRollupDays', { tenantId: null }, filter ?? null, rows.length);
|
|
6642
|
+
return rows;
|
|
6643
|
+
},
|
|
6644
|
+
pruneRequestDays: async (_actor, limit, retentionMonths) => this.cp.pruneRequestDays(assertRowLimit('limit', limit), retentionMonths),
|
|
6645
|
+
recordMeterDay: async (_actor, input) => this.cp.recordMeterDay(input),
|
|
6646
|
+
listMeterDays: async (actor, filter) => {
|
|
6647
|
+
const rows = (await this.cp.listMeterDays(filter)).map((r) => meterDay.parse(r));
|
|
6648
|
+
await this.recordAccess(actor, 'listMeterDays', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
6649
|
+
return rows;
|
|
6650
|
+
},
|
|
6651
|
+
listMeterDayTakes: async (actor, filter) => {
|
|
6652
|
+
const rows = (await this.cp.listMeterDayTakes(filter)).map((r) => meterDayTake.parse(r));
|
|
6653
|
+
await this.recordAccess(actor, 'listMeterDayTakes', { tenantId: null }, filter ?? null, rows.length);
|
|
6654
|
+
return rows;
|
|
6655
|
+
},
|
|
6656
|
+
pruneMeterDays: async (_actor, limit, retentionMonths) => this.cp.pruneMeterDays(assertRowLimit('limit', limit), retentionMonths),
|
|
6537
6657
|
recordModelUsage: async (input) => {
|
|
6538
6658
|
const l = input.line;
|
|
6539
6659
|
return this.cp.recordModelUsage({
|
|
@@ -6756,15 +6876,11 @@ export class CloudflareScopeHost {
|
|
|
6756
6876
|
catch (err) {
|
|
6757
6877
|
report.failed += 1;
|
|
6758
6878
|
try {
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
scopeId,
|
|
6765
|
-
message: `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the ` +
|
|
6766
|
-
`deployment serving this scope; the heal sweep will deliver it again: ${err instanceof Error ? err.message : String(err)}`,
|
|
6767
|
-
});
|
|
6879
|
+
// #2554: the throw is the deployment's or its runtime's text, and the deployment serving
|
|
6880
|
+
// a scope is the vertical's code, often another tenant's. The tenant's row says only what
|
|
6881
|
+
// happened; the text is staff's row, under the same reference.
|
|
6882
|
+
const missed = `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the deployment serving this scope; the heal sweep will deliver it again`;
|
|
6883
|
+
await this.admin.recordOpsFailure(withheldOpsFailure({ actor, operation: 'scope.lifecycle', stage: 'deliver', tenantId, scopeId }, `${missed}: ${err instanceof Error ? err.message : String(err)}`, (reference) => `${missed} (reference ${reference})`));
|
|
6768
6884
|
}
|
|
6769
6885
|
catch (recordErr) {
|
|
6770
6886
|
console.error('substrat: could not record a lifecycle delivery failure (#1713)', recordErr);
|
|
@@ -7564,6 +7680,14 @@ export class CloudflareScopeHost {
|
|
|
7564
7680
|
// #2430: refused before anything is written, rather than at the top-up after the projection.
|
|
7565
7681
|
if (input.entityGrants)
|
|
7566
7682
|
await this.refuseReservedShapes(input.entityGrants, input.scopeId);
|
|
7683
|
+
// #2447: the role set this provision defines, and the owner's seat in it — a person's seat, so
|
|
7684
|
+
// the person's door — are judged before anything is written. A refusal fails the provision and
|
|
7685
|
+
// writes nothing, so a seat a reconcile would have re-asserted is left exactly as it is.
|
|
7686
|
+
const reserved = await this.refuseReservedRoleSet(input.roles, input.tenantId, input.scopeId);
|
|
7687
|
+
const ownerRole = input.roles.find((r) => r.key === input.ownerRoleKey);
|
|
7688
|
+
const ownerRefusal = ownerRole ? reservedRoleRefusal(ownerRole, reserved, 'person') : null;
|
|
7689
|
+
if (ownerRefusal)
|
|
7690
|
+
throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
|
|
7567
7691
|
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7568
7692
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7569
7693
|
const stub = this.scopeStub(input.scopeId);
|
|
@@ -7639,6 +7763,136 @@ export class CloudflareScopeHost {
|
|
|
7639
7763
|
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7640
7764
|
};
|
|
7641
7765
|
}
|
|
7766
|
+
/**
|
|
7767
|
+
* #2447: what a tenant's role definition is judged with, beyond the stored roles the ControlPlaneDO
|
|
7768
|
+
* reads in its own unit:
|
|
7769
|
+
* - the reservations of this host's modules, joined with the scope class's. Every scope on this
|
|
7770
|
+
* binding is one class, so one of the tenant's scopes answers for all, and the fan-out after
|
|
7771
|
+
* the write projects into exactly those scopes. A tenant with no scope yet is judged by this
|
|
7772
|
+
* host's modules alone (a stated residual: its first scope's projection is not judged again);
|
|
7773
|
+
* - for a `holder: 'system'` definition of a key with no system-held definition stored (none at
|
|
7774
|
+
* all included: a seat can outlive its role's row), whether anyone but a system principal holds a
|
|
7775
|
+
* live scope-level seat in it in one of those scopes (the ControlPlaneDO reads the tenant-level
|
|
7776
|
+
* seats). No scope is asked for seats otherwise.
|
|
7777
|
+
*
|
|
7778
|
+
* Every scope that is not reaped is read, a scope archived straight from `provisioning` included:
|
|
7779
|
+
* the platform's door seats into a provisioning scope, and unarchive brings the seat back. A DO
|
|
7780
|
+
* cannot be asked anything without waking it, and waking one that never ran would create storage it
|
|
7781
|
+
* never had (#1524), so such a scope is read only when its directory row records a migration
|
|
7782
|
+
* (`schema_version` past `'0'`). That is not a mark of instantiation: it counts the module
|
|
7783
|
+
* migrations applied, and is written only when a pass applied one, so a scope of a class with no
|
|
7784
|
+
* module migrations reads `'0'` though it holds a seat. One that records none is not read, and the
|
|
7785
|
+
* definition fails closed where that scope could decide it: `unreadable` names it, and the caller
|
|
7786
|
+
* refuses a definition its seats, or its class's reservations when no other scope gives them, would
|
|
7787
|
+
* decide. So while such a scope is the tenant's only one, every definition is refused until it is
|
|
7788
|
+
* unarchived or reaped. No product path reaches this today: the dashboard is the one Cloudflare
|
|
7789
|
+
* caller of `defineRole`, and its class has migrations.
|
|
7790
|
+
*
|
|
7791
|
+
* A stated residual: this half is read before the ControlPlaneDO's unit, not in it, so a scope-level
|
|
7792
|
+
* seat made in between is not seen. The tenant-level seats and the stored roles are read in the unit.
|
|
7793
|
+
*/
|
|
7794
|
+
async definitionContext(tenantId, role) {
|
|
7795
|
+
const listed = await this.cp.listScopes({ tenantId, status: [...SEAT_HOLDING_SCOPE_STATUSES] });
|
|
7796
|
+
const neverStored = (s) => (s.status === 'archived' || s.status === 'archiving') && s.archived_from_status === 'provisioning' && (s.schema_version ?? '0') === '0';
|
|
7797
|
+
const scopes = listed.filter((s) => !neverStored(s));
|
|
7798
|
+
const unreadableScope = listed.find(neverStored);
|
|
7799
|
+
const unreadable = unreadableScope
|
|
7800
|
+
? `scope ${unreadableScope.scope_id} was archived straight from provisioning and records no migration, so its seats and its ` +
|
|
7801
|
+
`class's reservations cannot be read without creating storage it may never have had. Unarchive it (or reap it) first`
|
|
7802
|
+
: null;
|
|
7803
|
+
const reservation = { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
|
|
7804
|
+
if (scopes.length > 0) {
|
|
7805
|
+
const here = await this.scopeStub(scopes[0].scope_id).reservationHere();
|
|
7806
|
+
reservation.reserved = [...new Set([...reservation.reserved, ...here.reserved])];
|
|
7807
|
+
reservation.declared = [...new Set([...reservation.declared, ...here.declared])];
|
|
7808
|
+
}
|
|
7809
|
+
let seatedInScopes = false;
|
|
7810
|
+
let seatsDecide = false;
|
|
7811
|
+
if (role.holder === 'system') {
|
|
7812
|
+
const stored = await this.cp.getRole(tenantId, role.key);
|
|
7813
|
+
if (stored?.holder !== 'system') {
|
|
7814
|
+
seatsDecide = true;
|
|
7815
|
+
for (const s of scopes) {
|
|
7816
|
+
if ((await this.scopeStub(s.scope_id).roleStateHere(tenantId)).seated.includes(role.key)) {
|
|
7817
|
+
seatedInScopes = true;
|
|
7818
|
+
break;
|
|
7819
|
+
}
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
7822
|
+
}
|
|
7823
|
+
// Fail closed only where the unread scope could decide: its seats (a system-held definition the
|
|
7824
|
+
// seats judge), or the class's reservations when no other scope gave them.
|
|
7825
|
+
const decides = unreadable !== null && ((seatsDecide && !seatedInScopes) || scopes.length === 0);
|
|
7826
|
+
return { reservation, seatedInScopes, unreadable: decides ? unreadable : null };
|
|
7827
|
+
}
|
|
7828
|
+
/**
|
|
7829
|
+
* #2447: why `roles` may not be defined on `scopeId`, judged as a set against this host's
|
|
7830
|
+
* reservations joined with the scope class's, or null. A restore asks it before it imports a dump,
|
|
7831
|
+
* which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
|
|
7832
|
+
* roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
|
|
7833
|
+
* staged restore has not (its pages are already in the scope), so it is judged as a set alone
|
|
7834
|
+
* here, and `projectRolesLocal` judges again after the import, against what the dump brought.
|
|
7835
|
+
*/
|
|
7836
|
+
async roleSetRefusalLocal(scopeId, roles, dump) {
|
|
7837
|
+
const here = await this.scopeStub(scopeId).reservationHere();
|
|
7838
|
+
return reservedRoleSetRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump ? dumpRoleState(dump.tables, dump.tenantId, new Date().toISOString()) : undefined);
|
|
7839
|
+
}
|
|
7840
|
+
/**
|
|
7841
|
+
* #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles as
|
|
7842
|
+
* the directory stores them now, against the dump's role rows and seats, with this host's
|
|
7843
|
+
* reservations joined with the scope class's. The class is read at `scopeId` when the scope exists
|
|
7844
|
+
* (a restore), else at a live scope of the tenant (an import, whose scope is not created yet: asking
|
|
7845
|
+
* its object would create storage for a scope a refusal leaves unmade).
|
|
7846
|
+
*
|
|
7847
|
+
* Two stated residuals. The directory is read here and the dump is loaded in the scope's own call,
|
|
7848
|
+
* so a role defined between the two is not judged against the dump's seats. And an import for a
|
|
7849
|
+
* tenant with no live scope reads no class, so it is judged with this host's reservations alone: the
|
|
7850
|
+
* seat rule holds without them, and a key only the class reserves is not counted.
|
|
7851
|
+
*/
|
|
7852
|
+
async refuseDumpRoles(tenantId, scopeId, dump) {
|
|
7853
|
+
const classScope = scopeId ?? (await this.cp.listScopes({ tenantId, status: ['active', 'suspended'], limit: 1 }))[0]?.scope_id;
|
|
7854
|
+
const [rows, here] = await Promise.all([
|
|
7855
|
+
this.cp.listRoles({ tenantId }),
|
|
7856
|
+
classScope ? this.scopeStub(classScope).reservationHere() : { reserved: [], declared: [] },
|
|
7857
|
+
]);
|
|
7858
|
+
const roles = rows.map((r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) }));
|
|
7859
|
+
const refusal = dumpRoleRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump, new Date().toISOString());
|
|
7860
|
+
if (refusal)
|
|
7861
|
+
throw substratError('forbidden', refusal);
|
|
7862
|
+
}
|
|
7863
|
+
/**
|
|
7864
|
+
* #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
|
|
7865
|
+
* holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
|
|
7866
|
+
* (`roleStateHere`), read for a promote to judge the incoming version's set with
|
|
7867
|
+
* (`releaseRoleSetRefusal`) before it moves the scope. Read-only, and names no principal.
|
|
7868
|
+
*/
|
|
7869
|
+
async roleStateLocal(tenantId, scopeId) {
|
|
7870
|
+
return this.scopeStub(scopeId).roleStateHere(tenantId);
|
|
7871
|
+
}
|
|
7872
|
+
/**
|
|
7873
|
+
* #2447: refuse a role set a CP-less provision or restore defines on `scopeId` for `tenantId`
|
|
7874
|
+
* (`reservedRoleSetRefusal`), against this host's reservations joined with the scope class's,
|
|
7875
|
+
* and the roles the scope stores for the tenant now with their seats there. Answers the reserved
|
|
7876
|
+
* keys it judged with, for the seats the same call makes.
|
|
7877
|
+
*
|
|
7878
|
+
* A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
|
|
7879
|
+
* (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
|
|
7880
|
+
* wide, and a reconcile runs again every pass.
|
|
7881
|
+
*/
|
|
7882
|
+
async refuseReservedRoleSet(roles, tenantId, scopeId) {
|
|
7883
|
+
const stub = this.scopeStub(scopeId);
|
|
7884
|
+
const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
|
|
7885
|
+
const reserved = new Set([...this.reservedKeys, ...here.reserved]);
|
|
7886
|
+
const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
|
|
7887
|
+
// #2531: with the tombstones the scope keeps, so a system-held role a release dropped is judged
|
|
7888
|
+
// as the stored definition it was. A ScopeDO from before them answers none.
|
|
7889
|
+
{ roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
|
|
7890
|
+
// Named by tenant and scope as well as role: a release push admitted can be refused here at every
|
|
7891
|
+
// scope where the role is seated, and staff find each one by this line (K-55).
|
|
7892
|
+
if (refusal)
|
|
7893
|
+
throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${refusal}`);
|
|
7894
|
+
return reserved;
|
|
7895
|
+
}
|
|
7642
7896
|
/**
|
|
7643
7897
|
* #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
|
|
7644
7898
|
* connection acts for the external provider holding its token, so a grant naming a key reserved
|
|
@@ -7734,15 +7988,26 @@ export class CloudflareScopeHost {
|
|
|
7734
7988
|
* storage. Idempotent (writeTuple is INSERT OR REPLACE). `roleKey` must be one the scope
|
|
7735
7989
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
7736
7990
|
*/
|
|
7737
|
-
async assignScopeRole(scopeId, principal, roleKey
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
//
|
|
7741
|
-
|
|
7991
|
+
async assignScopeRole(scopeId, principal, roleKey,
|
|
7992
|
+
/** #2447: `person` when the caller knows it seats a person (the owner hand-over); the platform's door otherwise. */
|
|
7993
|
+
door = 'platform') {
|
|
7994
|
+
// #2430: a platform door by default, as `HostAdmin.assignRole` is — a role mixing reserved and
|
|
7995
|
+
// other keys is refused, against the scope class's own reservations and the role as the checker
|
|
7996
|
+
// reads it for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens
|
|
7997
|
+
// without one). The person's door also refuses any reserved key and a system-held role (#2447).
|
|
7998
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
|
|
7742
7999
|
if (refusal)
|
|
7743
8000
|
throw substratError('forbidden', refusal);
|
|
7744
8001
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
7745
8002
|
}
|
|
8003
|
+
/**
|
|
8004
|
+
* #2447: why `roleKey` may not be seated at scope level through `door`, or null — the question
|
|
8005
|
+
* `assignScopeRole` asks before it writes, for a caller (the owner hand-over) that must ask it
|
|
8006
|
+
* before anything else moves.
|
|
8007
|
+
*/
|
|
8008
|
+
async scopeRoleRefusal(scopeId, roleKey, door) {
|
|
8009
|
+
return this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
|
|
8010
|
+
}
|
|
7746
8011
|
/**
|
|
7747
8012
|
* Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
|
|
7748
8013
|
* A tombstone, exactly as `HostAdmin.unassignRole` writes for a scope-level assignment:
|
|
@@ -7904,6 +8169,17 @@ export class CloudflareScopeHost {
|
|
|
7904
8169
|
* so a caller falls back rather than failing a dispatch over a missing file.
|
|
7905
8170
|
*/
|
|
7906
8171
|
async connectorAttachmentOpenLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
|
|
8172
|
+
return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).open(attachmentId);
|
|
8173
|
+
}
|
|
8174
|
+
/**
|
|
8175
|
+
* `connectorAttachmentOpenLocal`, with the bytes as a stream read from R2 as the platform reads
|
|
8176
|
+
* the response (#2514). The same gates, in the same order, before the bucket is touched.
|
|
8177
|
+
*/
|
|
8178
|
+
async connectorAttachmentOpenStreamLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
|
|
8179
|
+
return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).openStream(attachmentId);
|
|
8180
|
+
}
|
|
8181
|
+
/** The attachment surface the platform's reads as a connection go through, behind their gates. */
|
|
8182
|
+
async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
|
|
7907
8183
|
// Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
|
|
7908
8184
|
await this.assertLive(tenantId, scopeId);
|
|
7909
8185
|
await this.migrateAndRecord(scopeId);
|
|
@@ -7911,7 +8187,7 @@ export class CloudflareScopeHost {
|
|
|
7911
8187
|
return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
|
|
7912
8188
|
// #726 remedy B: the platform named a delivery; this deployment decides what that
|
|
7913
8189
|
// delivery reaches, from its own spine row. Absent ⇒ the ordinary grant check.
|
|
7914
|
-
eventId === undefined ? undefined : { eventId })
|
|
8190
|
+
eventId === undefined ? undefined : { eventId });
|
|
7915
8191
|
}
|
|
7916
8192
|
/**
|
|
7917
8193
|
* The delivery half of `grantToConnection` for a scope served HERE (#574): write
|