@substrat-run/adapter-cloudflare 0.159.0 → 0.160.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 +91 -24
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +223 -87
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +818 -6
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +336 -103
- 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 +20 -2
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +130 -40
- 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, isSizedByteStream, 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, 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, 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: a vertical-side size read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
|
|
353
|
+
function heldOrRefused(scopeId, size) {
|
|
354
|
+
if (size === null)
|
|
355
|
+
throw substratError('conflict', scopeNotHeldRefusal(scopeId));
|
|
356
|
+
return size;
|
|
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,
|
|
@@ -552,10 +588,7 @@ export class CloudflareScopeHost {
|
|
|
552
588
|
// #1691: timed where the call is made, so the data point carries a duration.
|
|
553
589
|
const started = Date.now();
|
|
554
590
|
try {
|
|
555
|
-
const res = await fetchImpl(input,
|
|
556
|
-
...init,
|
|
557
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
558
|
-
});
|
|
591
|
+
const res = await fetchImpl(input, init);
|
|
559
592
|
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
|
|
560
593
|
return res;
|
|
561
594
|
}
|
|
@@ -1262,6 +1295,8 @@ export class CloudflareScopeHost {
|
|
|
1262
1295
|
* safe; scope-level tuples (the restored grants) are never touched.
|
|
1263
1296
|
*/
|
|
1264
1297
|
async projectRolesLocal(tenantId, scopeId, roles) {
|
|
1298
|
+
// #2447: the role set is defined here, so it is judged here, before anything is projected.
|
|
1299
|
+
await this.refuseReservedRoleSet(roles, tenantId, scopeId);
|
|
1265
1300
|
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
1301
|
}
|
|
1267
1302
|
/**
|
|
@@ -1332,9 +1367,16 @@ export class CloudflareScopeHost {
|
|
|
1332
1367
|
async appliedMigrationsLocal(scopeId) {
|
|
1333
1368
|
return this.scopeStub(scopeId).appliedMigrations();
|
|
1334
1369
|
}
|
|
1335
|
-
/**
|
|
1370
|
+
/**
|
|
1371
|
+
* This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
|
|
1372
|
+
* deployment does not hold is refused (#2541), not read as 0.
|
|
1373
|
+
*/
|
|
1336
1374
|
async databaseSizeLocal(scopeId) {
|
|
1337
|
-
return this.scopeStub(scopeId).
|
|
1375
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).databaseSizeIfHeld());
|
|
1376
|
+
}
|
|
1377
|
+
/** This host's own scope's attachment bytes (#2501), the vertical-host read; refused as the size is. */
|
|
1378
|
+
async attachmentSizeLocal(scopeId) {
|
|
1379
|
+
return heldOrRefused(scopeId, await this.scopeStub(scopeId).attachmentBytesIfHeld());
|
|
1338
1380
|
}
|
|
1339
1381
|
/**
|
|
1340
1382
|
* Each scope store's size in this deployment (#2207). The control plane already
|
|
@@ -1583,6 +1625,7 @@ export class CloudflareScopeHost {
|
|
|
1583
1625
|
this.crossVertical.register(manifest, registration.imports);
|
|
1584
1626
|
this.moduleIds.add(manifest.id);
|
|
1585
1627
|
noteReservedKeys(this.reservedKeys, manifest);
|
|
1628
|
+
noteDeclaredKeys(this.declaredKeys, manifest);
|
|
1586
1629
|
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
1587
1630
|
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
1588
1631
|
}
|
|
@@ -1844,22 +1887,11 @@ export class CloudflareScopeHost {
|
|
|
1844
1887
|
const filename = view.filename ?? input.filename;
|
|
1845
1888
|
// Recorded as it is judged: the essence, never a parameter or a stray space.
|
|
1846
1889
|
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
1890
|
// Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
|
|
1859
1891
|
// someone else spent first) compensates the object away, and the link is unspent with it.
|
|
1860
1892
|
// A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
|
|
1861
1893
|
// `Idempotency-Key` is answered as a replay (#2315).
|
|
1862
|
-
await store
|
|
1894
|
+
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
1895
|
return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
|
|
1864
1896
|
});
|
|
1865
1897
|
}
|
|
@@ -1990,20 +2022,24 @@ export class CloudflareScopeHost {
|
|
|
1990
2022
|
upload: async (input) => {
|
|
1991
2023
|
const id = ulid();
|
|
1992
2024
|
const key = attachmentBlobKey(scopeId, id);
|
|
1993
|
-
const
|
|
2025
|
+
const fields = {
|
|
1994
2026
|
id,
|
|
1995
2027
|
entity: input.entity,
|
|
1996
2028
|
filename: input.filename,
|
|
1997
2029
|
contentType: input.contentType,
|
|
1998
|
-
size: input.body.byteLength,
|
|
1999
|
-
sha256: await attachmentSha256(input.body),
|
|
2000
2030
|
visibility: input.visibility,
|
|
2001
2031
|
createdBy,
|
|
2002
2032
|
createdAt: new Date().toISOString(),
|
|
2003
|
-
}
|
|
2033
|
+
};
|
|
2034
|
+
// A streamed body is the case where bytes are the cost, so the write gate is asked before
|
|
2035
|
+
// the first one is read (#2516). The row's own check below stays: this one is only early.
|
|
2036
|
+
if (isSizedByteStream(input.body)) {
|
|
2037
|
+
await askAttachmentWriteGate(stub, input.entity, asPrincipalId, tenantId, scopeId, connectionId);
|
|
2038
|
+
}
|
|
2004
2039
|
// Bytes first, row second — a crash between the two leaves an orphaned object (#2314);
|
|
2005
2040
|
// a refused gate compensates, and only an answered refusal does (`compensatingRefusal`).
|
|
2006
|
-
|
|
2041
|
+
// A streamed body is hashed and counted as R2 reads it (#2498).
|
|
2042
|
+
const record = await putAttachmentBytes(store, key, fields, input.body, workerdSha256);
|
|
2007
2043
|
return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId)));
|
|
2008
2044
|
},
|
|
2009
2045
|
list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
|
|
@@ -2140,6 +2176,7 @@ export class CloudflareScopeHost {
|
|
|
2140
2176
|
scopeId: snapshotId,
|
|
2141
2177
|
kind: opts?.kind ?? 'archive',
|
|
2142
2178
|
vertical: source.vertical,
|
|
2179
|
+
verticalSource: 'copy',
|
|
2143
2180
|
jurisdiction: source.jurisdiction,
|
|
2144
2181
|
expiresAt: opts?.expiresAt,
|
|
2145
2182
|
// forkedFrom/forkedAt are stamped from the dump by importScope.
|
|
@@ -3126,7 +3163,9 @@ export class CloudflareScopeHost {
|
|
|
3126
3163
|
}
|
|
3127
3164
|
return report;
|
|
3128
3165
|
}
|
|
3129
|
-
async runDueSchedules(moduleId, tenantId, scopeId
|
|
3166
|
+
async runDueSchedules(moduleId, tenantId, scopeId,
|
|
3167
|
+
/** #2517: what the sweep tick's earlier calls did to this scope (`ScopeHost.runDueSchedules`). */
|
|
3168
|
+
tick) {
|
|
3130
3169
|
const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
|
|
3131
3170
|
const schedules = this.moduleSchedules.get(moduleId);
|
|
3132
3171
|
if (!schedules || schedules.length === 0)
|
|
@@ -3181,6 +3220,10 @@ export class CloudflareScopeHost {
|
|
|
3181
3220
|
// scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
|
|
3182
3221
|
let platformRequests = 0;
|
|
3183
3222
|
const counted = { onPlatformRequests: (n) => (platformRequests += n) };
|
|
3223
|
+
// #2517: the part of that the purge runs enqueued, which is what defers a later purge head the
|
|
3224
|
+
// cap refuses (`runPurgePass`): this call's, on top of what the tick's earlier calls report.
|
|
3225
|
+
const earlierPurgeRequests = tick?.earlierPurgeRequests ?? 0;
|
|
3226
|
+
let purgeRequests = 0;
|
|
3184
3227
|
for (const schedule of schedules) {
|
|
3185
3228
|
const last = await stub.scheduleLastRun(schedule.operation);
|
|
3186
3229
|
const lastRun = last ? Date.parse(last) : null;
|
|
@@ -3215,12 +3258,24 @@ export class CloudflareScopeHost {
|
|
|
3215
3258
|
// call and transaction — and the SCOPE decides which are due and runs them, so purge
|
|
3216
3259
|
// authority is never an argument any stub can pass (`runPurgeSweep`).
|
|
3217
3260
|
// #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
|
-
|
|
3261
|
+
// #2431, #2440: the scope reports the requests its purges enqueued (`pass.platformRequests`),
|
|
3262
|
+
// and the deliveries their events route to the platform are counted below, as an invoke's are.
|
|
3263
|
+
// #2517: with what the purge runs before this one in the tick enqueued, so a head the cap
|
|
3264
|
+
// refuses because they filled the queue is deferred to the next tick rather than failed.
|
|
3265
|
+
const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId, earlierPurgeRequests + purgeRequests));
|
|
3266
|
+
if (pass.deferred) {
|
|
3267
|
+
// Ran nothing: no line, no cadence row, `skipped` like any run that did not happen.
|
|
3268
|
+
report.skipped += 1;
|
|
3269
|
+
report.deferred = (report.deferred ?? 0) + 1;
|
|
3270
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
platformRequests += pass.platformRequests ?? 0;
|
|
3274
|
+
purgeRequests += pass.platformRequests ?? 0;
|
|
3221
3275
|
// The purges' events, delivered the way each invoke's own tail delivers them.
|
|
3222
3276
|
const delivered = await this.drainExecutors(tenantId, scopeId, invocationId);
|
|
3223
3277
|
platformRequests += delivered.routedToPlatform ?? 0;
|
|
3278
|
+
purgeRequests += delivered.routedToPlatform ?? 0;
|
|
3224
3279
|
stillDue = purgeStillDue(pass);
|
|
3225
3280
|
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3226
3281
|
report.errors.push(...outcome.errors);
|
|
@@ -3274,6 +3329,8 @@ export class CloudflareScopeHost {
|
|
|
3274
3329
|
lines.end();
|
|
3275
3330
|
if (platformRequests > 0)
|
|
3276
3331
|
report.platformRequests = platformRequests;
|
|
3332
|
+
if (purgeRequests > 0)
|
|
3333
|
+
report.purgeRequests = purgeRequests;
|
|
3277
3334
|
return report;
|
|
3278
3335
|
}
|
|
3279
3336
|
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
@@ -3594,6 +3651,19 @@ export class CloudflareScopeHost {
|
|
|
3594
3651
|
archivedFromStatus: (r.archived_from_status ?? null),
|
|
3595
3652
|
createdAt: r.created_at,
|
|
3596
3653
|
});
|
|
3654
|
+
// The directory rows `listScopes` and `listScopesReadable` map, filtered and paged alike.
|
|
3655
|
+
const scopeRows = (filter) => this.cp.listScopes({
|
|
3656
|
+
tenantId: filter?.tenantId,
|
|
3657
|
+
status: filter?.status
|
|
3658
|
+
? Array.isArray(filter.status)
|
|
3659
|
+
? filter.status
|
|
3660
|
+
: [filter.status]
|
|
3661
|
+
: undefined,
|
|
3662
|
+
vertical: filter?.vertical,
|
|
3663
|
+
limit: filter?.limit,
|
|
3664
|
+
cursor: filter?.cursor,
|
|
3665
|
+
order: filter?.order,
|
|
3666
|
+
});
|
|
3597
3667
|
// The (version, scope) pair a bind and its impact read both start from, and the refusals
|
|
3598
3668
|
// that come before any export-break question, in the order the bind makes them (#1756).
|
|
3599
3669
|
const bindTarget = async (tenantId, scopeId, versionId) => {
|
|
@@ -4166,7 +4236,19 @@ export class CloudflareScopeHost {
|
|
|
4166
4236
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
4167
4237
|
defineRole: async (actor, tenantId, role) => {
|
|
4168
4238
|
const parsed = roleDefinition.parse(role);
|
|
4169
|
-
|
|
4239
|
+
// #2447: judged in the ControlPlaneDO's unit, against this host's keys and the tenant's other
|
|
4240
|
+
// roles, so a role assigned through a door cannot be widened afterwards. A refusal writes
|
|
4241
|
+
// nothing, so a seat of the role as it was stored keeps working.
|
|
4242
|
+
const context = await this.definitionContext(tenantId, parsed);
|
|
4243
|
+
if (context.unreadable)
|
|
4244
|
+
throw substratError('forbidden', `role '${parsed.key}' cannot be judged: ${context.unreadable}`);
|
|
4245
|
+
// `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
|
|
4246
|
+
// before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
|
|
4247
|
+
// than being misread.
|
|
4248
|
+
const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
|
|
4249
|
+
if ('refused' in defined)
|
|
4250
|
+
throw substratError('forbidden', defined.refused);
|
|
4251
|
+
const before = defined.before;
|
|
4170
4252
|
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
4171
4253
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
4172
4254
|
},
|
|
@@ -4607,8 +4689,7 @@ export class CloudflareScopeHost {
|
|
|
4607
4689
|
}
|
|
4608
4690
|
throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
4609
4691
|
}
|
|
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);
|
|
4692
|
+
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
4693
|
},
|
|
4613
4694
|
listVerticals: async (actor, page) => {
|
|
4614
4695
|
const rows = await this.cp.listVerticals(page);
|
|
@@ -4645,14 +4726,13 @@ export class CloudflareScopeHost {
|
|
|
4645
4726
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
4646
4727
|
admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
|
|
4647
4728
|
createdAt: new Date().toISOString(),
|
|
4648
|
-
}
|
|
4649
|
-
await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
|
|
4729
|
+
}, this.adminEntry(actor, 'publishVersion', { tenantId: null }, null, {
|
|
4650
4730
|
...audited,
|
|
4651
4731
|
// The digest the registry holds, which differs from the pushed one only for an unheld version.
|
|
4652
4732
|
migrationDigest,
|
|
4653
4733
|
...(origin ? { origin } : {}),
|
|
4654
4734
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
4655
|
-
});
|
|
4735
|
+
}));
|
|
4656
4736
|
},
|
|
4657
4737
|
verticalScopeStores: async (actor, verticalSlug) => {
|
|
4658
4738
|
const stores = await this.directory('verticalScopeStores', verticalSlug);
|
|
@@ -4686,36 +4766,32 @@ export class CloudflareScopeHost {
|
|
|
4686
4766
|
`a staff admit must vouch for it before listing`);
|
|
4687
4767
|
}
|
|
4688
4768
|
}
|
|
4689
|
-
await this.cp.updateVerticalListed(slug, listed ? 1 : 0
|
|
4690
|
-
|
|
4769
|
+
await this.cp.updateVerticalListed(slug, listed ? 1 : 0, // also resolves any pending request
|
|
4770
|
+
this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed }));
|
|
4691
4771
|
},
|
|
4692
4772
|
requestPublish: async (actor, slug) => {
|
|
4693
4773
|
const existing = await this.cp.readVertical(slug);
|
|
4694
4774
|
if (!existing)
|
|
4695
4775
|
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 });
|
|
4776
|
+
await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
|
|
4698
4777
|
},
|
|
4699
4778
|
setVerticalInstallsBlocked: async (actor, slug, blocked) => {
|
|
4700
4779
|
const existing = await this.cp.readVertical(slug);
|
|
4701
4780
|
if (!existing)
|
|
4702
4781
|
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 });
|
|
4782
|
+
await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked }));
|
|
4705
4783
|
},
|
|
4706
4784
|
setVerticalTenantProvisioner: async (actor, slug, granted) => {
|
|
4707
4785
|
const existing = await this.cp.readVertical(slug);
|
|
4708
4786
|
if (!existing)
|
|
4709
4787
|
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 });
|
|
4788
|
+
await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted }));
|
|
4712
4789
|
},
|
|
4713
4790
|
setVerticalEmailSender: async (actor, slug, granted) => {
|
|
4714
4791
|
const existing = await this.cp.readVertical(slug);
|
|
4715
4792
|
if (!existing)
|
|
4716
4793
|
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 });
|
|
4794
|
+
await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted }));
|
|
4719
4795
|
},
|
|
4720
4796
|
deleteVertical: async (actor, slug) => {
|
|
4721
4797
|
const existing = await this.cp.readVertical(slug);
|
|
@@ -4734,8 +4810,13 @@ export class CloudflareScopeHost {
|
|
|
4734
4810
|
if (bound.archived > 0) {
|
|
4735
4811
|
throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
4736
4812
|
}
|
|
4737
|
-
|
|
4738
|
-
await this.
|
|
4813
|
+
// #2501: the period's `until` is read here, on the clock its `since` was.
|
|
4814
|
+
await this.cp.deleteVertical(slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null), verticalOwnerNow());
|
|
4815
|
+
},
|
|
4816
|
+
listVerticalOwners: async (actor, slug) => {
|
|
4817
|
+
const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
|
|
4818
|
+
await this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
|
|
4819
|
+
return periods;
|
|
4739
4820
|
},
|
|
4740
4821
|
admitVersion: async (actor, versionId) => {
|
|
4741
4822
|
const v = await this.cp.readVersion(versionId);
|
|
@@ -4746,15 +4827,13 @@ export class CloudflareScopeHost {
|
|
|
4746
4827
|
// manual vouch by clearing the note (what the publish seam requires).
|
|
4747
4828
|
if (v.admission_note !== AUTO_ADMISSION_NOTE)
|
|
4748
4829
|
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 });
|
|
4830
|
+
await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null }));
|
|
4751
4831
|
return;
|
|
4752
4832
|
}
|
|
4753
4833
|
if (v.admission === 'rejected') {
|
|
4754
4834
|
throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
|
|
4755
4835
|
}
|
|
4756
|
-
await this.cp.setAdmission(versionId, 'admitted', null);
|
|
4757
|
-
await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
|
|
4836
|
+
await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' }));
|
|
4758
4837
|
},
|
|
4759
4838
|
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
4760
4839
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -4778,8 +4857,7 @@ export class CloudflareScopeHost {
|
|
|
4778
4857
|
}
|
|
4779
4858
|
if (v.admission === 'rejected')
|
|
4780
4859
|
return;
|
|
4781
|
-
await this.cp.setAdmission(versionId, 'rejected', note);
|
|
4782
|
-
await this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note });
|
|
4860
|
+
await this.cp.setAdmission(versionId, 'rejected', note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note }));
|
|
4783
4861
|
},
|
|
4784
4862
|
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
4785
4863
|
const incoming = await this.cp.readVersion(versionId);
|
|
@@ -4853,7 +4931,8 @@ export class CloudflareScopeHost {
|
|
|
4853
4931
|
// in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
|
|
4854
4932
|
// choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
|
|
4855
4933
|
// only if the pointer still names `outgoing`; the promote that lost the race is refused as
|
|
4856
|
-
// stale and writes nothing.
|
|
4934
|
+
// stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
|
|
4935
|
+
// which record their own — and the acknowledgement is recorded, not just enforced.
|
|
4857
4936
|
const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
|
|
4858
4937
|
id: ulid(),
|
|
4859
4938
|
vertical_slug: verticalSlug,
|
|
@@ -4862,7 +4941,7 @@ export class CloudflareScopeHost {
|
|
|
4862
4941
|
from_version_id: outgoing?.id ?? null,
|
|
4863
4942
|
actor,
|
|
4864
4943
|
at: promotedAt,
|
|
4865
|
-
});
|
|
4944
|
+
}, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
|
|
4866
4945
|
if (!moved)
|
|
4867
4946
|
throw promoteRaceRefusal(channel);
|
|
4868
4947
|
// For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
|
|
@@ -4892,7 +4971,6 @@ export class CloudflareScopeHost {
|
|
|
4892
4971
|
}
|
|
4893
4972
|
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
4973
|
}
|
|
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
4974
|
},
|
|
4897
4975
|
listChannels: async (actor, verticalSlug, page) => {
|
|
4898
4976
|
// `prod` is the only live channel (#509 retired dev/staging). Filter before the parse
|
|
@@ -5061,10 +5139,9 @@ export class CloudflareScopeHost {
|
|
|
5061
5139
|
versionId: parsed.versionId,
|
|
5062
5140
|
doClassesJson: JSON.stringify(parsed.doClasses),
|
|
5063
5141
|
migrationTag: parsed.migrationTag,
|
|
5064
|
-
}
|
|
5065
|
-
await this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
|
|
5142
|
+
}, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
|
|
5066
5143
|
? { ref: r.serving_ref, versionId: r.serving_version_id }
|
|
5067
|
-
: null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId });
|
|
5144
|
+
: null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
|
|
5068
5145
|
},
|
|
5069
5146
|
versionManifest: async (actor, verticalSlug, versionId) => {
|
|
5070
5147
|
const v = await this.cp.readVersion(versionId);
|
|
@@ -5082,6 +5159,18 @@ 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
|
+
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
|
+
},
|
|
5166
|
+
listWireAuth: async (actor, input) => {
|
|
5167
|
+
// Judged here, before the call into the directory's object, so a refusal keeps its code; the
|
|
5168
|
+
// judged copy is what crosses the RPC and reaches the log (its own fields, the reach first).
|
|
5169
|
+
const filter = assertWireAuthFilter(input);
|
|
5170
|
+
const rows = await this.cp.listWireAuth(filter);
|
|
5171
|
+
await this.recordAccess(actor, 'listWireAuth', wireAuthAccessTarget(filter), filter, rows.length);
|
|
5172
|
+
return rows;
|
|
5173
|
+
},
|
|
5085
5174
|
setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
|
|
5086
5175
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5087
5176
|
if (!scope)
|
|
@@ -5144,6 +5233,13 @@ export class CloudflareScopeHost {
|
|
|
5144
5233
|
await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
|
|
5145
5234
|
return sizes;
|
|
5146
5235
|
},
|
|
5236
|
+
scopeAttachmentBytes: async (actor, tenantId, scopeId) => {
|
|
5237
|
+
// Reaped is refused, not read: addressing the deleted DO would recreate it.
|
|
5238
|
+
await this.scopeRecordForRead(tenantId, scopeId);
|
|
5239
|
+
const bytes = await this.scopeStub(scopeId).attachmentBytes();
|
|
5240
|
+
await this.recordAccess(actor, 'scopeAttachmentBytes', { tenantId, scopeId }, null, 1);
|
|
5241
|
+
return bytes;
|
|
5242
|
+
},
|
|
5147
5243
|
rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
|
|
5148
5244
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5149
5245
|
if (!scope)
|
|
@@ -5228,31 +5324,26 @@ export class CloudflareScopeHost {
|
|
|
5228
5324
|
},
|
|
5229
5325
|
createTenant: async (actor, input) => {
|
|
5230
5326
|
const parsed = createTenantInput.parse(input);
|
|
5231
|
-
|
|
5232
|
-
//
|
|
5233
|
-
|
|
5234
|
-
return;
|
|
5235
|
-
await this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, created);
|
|
5327
|
+
// Idempotent: re-creating an existing tenant is a no-op, not audited. The DO writes the
|
|
5328
|
+
// row with the tenant it created as its `after`.
|
|
5329
|
+
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
5330
|
},
|
|
5237
5331
|
setTenantStatus: async (actor, tenantId, status) => {
|
|
5238
|
-
|
|
5239
|
-
await this.
|
|
5332
|
+
// The DO fills the row's `before` with the status it read in the write's unit.
|
|
5333
|
+
await this.directory('setTenantStatus', tenantId, status, this.adminEntry(actor, 'setTenantStatus', { tenantId }, null, { status }));
|
|
5240
5334
|
// #1713: every hosted scope under the tenant holds or resumes its work by this.
|
|
5241
5335
|
await this.deliverLifecycles(actor, { tenantId });
|
|
5242
5336
|
},
|
|
5243
5337
|
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 });
|
|
5338
|
+
// A rename to the current name writes nothing and is not audited; the DO fills `before`.
|
|
5339
|
+
await this.directory('setTenantName', tenantId, name, this.adminEntry(actor, 'setTenantName', { tenantId }, null, { name }));
|
|
5248
5340
|
},
|
|
5249
5341
|
reapTenant: async (actor, tenantId) => {
|
|
5250
5342
|
// Directory-side terminal reap (§4.8). The caller reaped every scope's storage
|
|
5251
5343
|
// first (archive-if-needed → reapScope in the vertical deployment); the DO clears
|
|
5252
5344
|
// the tenant's PII/config rows and flips the row to a `reaped` tombstone, keeping
|
|
5253
5345
|
// 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' });
|
|
5346
|
+
await this.directory('reapTenant', tenantId, this.adminEntry(actor, 'reapTenant', { tenantId }, null, { status: 'reaped' }));
|
|
5256
5347
|
},
|
|
5257
5348
|
listTenants: async (actor, page) => {
|
|
5258
5349
|
const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
|
|
@@ -5266,21 +5357,15 @@ export class CloudflareScopeHost {
|
|
|
5266
5357
|
return t ? tenantSchema.parse(t) : undefined;
|
|
5267
5358
|
},
|
|
5268
5359
|
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
|
-
});
|
|
5360
|
+
const rows = await scopeRows(filter);
|
|
5281
5361
|
await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
5282
5362
|
return rows.map(mapScope);
|
|
5283
5363
|
},
|
|
5364
|
+
listScopesReadable: async (actor, filter) => {
|
|
5365
|
+
const rows = await scopeRows(filter);
|
|
5366
|
+
await this.recordAccess(actor, 'listScopesReadable', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
5367
|
+
return readableScopes(rows, mapScope);
|
|
5368
|
+
},
|
|
5284
5369
|
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
5285
5370
|
const row = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
5286
5371
|
await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
|
|
@@ -5984,6 +6069,7 @@ export class CloudflareScopeHost {
|
|
|
5984
6069
|
storage: rows.storage,
|
|
5985
6070
|
storageAttempts: rows.storageAttempts,
|
|
5986
6071
|
storeStorage: rows.storeStorage,
|
|
6072
|
+
attachmentStorage: rows.attachmentStorage,
|
|
5987
6073
|
});
|
|
5988
6074
|
// Tenants covered, not totals: "read one tenant's meter" and "metered the whole
|
|
5989
6075
|
// fleet" are different acts, and K-24 exists to tell them apart.
|
|
@@ -6388,7 +6474,10 @@ export class CloudflareScopeHost {
|
|
|
6388
6474
|
return rows.map((r) => adminLogEntry.parse(r));
|
|
6389
6475
|
},
|
|
6390
6476
|
recordOpsFailure: async (entry) => {
|
|
6477
|
+
// #2540: a pair's staff row is minted first, so the tenant's row lists above it.
|
|
6478
|
+
const detail = entry.tenantId && typeof entry.detail === 'string' ? { detail: entry.detail.slice(0, 2000), detail_id: ulid() } : {};
|
|
6391
6479
|
await this.cp.recordOpsFailure({
|
|
6480
|
+
...detail,
|
|
6392
6481
|
id: ulid(),
|
|
6393
6482
|
actor: entry.actor,
|
|
6394
6483
|
operation: entry.operation,
|
|
@@ -6418,6 +6507,7 @@ export class CloudflareScopeHost {
|
|
|
6418
6507
|
code: filter?.code,
|
|
6419
6508
|
fingerprint: filter?.fingerprint,
|
|
6420
6509
|
reference: filter?.reference,
|
|
6510
|
+
referenceInText: filter?.referenceInText,
|
|
6421
6511
|
since: filter?.since,
|
|
6422
6512
|
until: filter?.until,
|
|
6423
6513
|
limit: filter?.limit,
|
|
@@ -6534,6 +6624,30 @@ export class CloudflareScopeHost {
|
|
|
6534
6624
|
return rows;
|
|
6535
6625
|
},
|
|
6536
6626
|
pruneScopeStorage: async (_actor, limit) => this.cp.pruneScopeStorage(assertRowLimit('limit', limit)),
|
|
6627
|
+
recordRequestDay: async (_actor, input) => this.cp.recordRequestDay(input),
|
|
6628
|
+
listRequestDays: async (actor, filter) => {
|
|
6629
|
+
const rows = (await this.cp.listRequestDays(filter)).map((r) => requestDay.parse(r));
|
|
6630
|
+
await this.recordAccess(actor, 'listRequestDays', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
|
|
6631
|
+
return rows;
|
|
6632
|
+
},
|
|
6633
|
+
listRequestRollupDays: async (actor, filter) => {
|
|
6634
|
+
const rows = (await this.cp.listRequestRollupDays(filter)).map((r) => requestRollupDay.parse(r));
|
|
6635
|
+
await this.recordAccess(actor, 'listRequestRollupDays', { tenantId: null }, filter ?? null, rows.length);
|
|
6636
|
+
return rows;
|
|
6637
|
+
},
|
|
6638
|
+
pruneRequestDays: async (_actor, limit, retentionMonths) => this.cp.pruneRequestDays(assertRowLimit('limit', limit), retentionMonths),
|
|
6639
|
+
recordMeterDay: async (_actor, input) => this.cp.recordMeterDay(input),
|
|
6640
|
+
listMeterDays: async (actor, filter) => {
|
|
6641
|
+
const rows = (await this.cp.listMeterDays(filter)).map((r) => meterDay.parse(r));
|
|
6642
|
+
await this.recordAccess(actor, 'listMeterDays', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
6643
|
+
return rows;
|
|
6644
|
+
},
|
|
6645
|
+
listMeterDayTakes: async (actor, filter) => {
|
|
6646
|
+
const rows = (await this.cp.listMeterDayTakes(filter)).map((r) => meterDayTake.parse(r));
|
|
6647
|
+
await this.recordAccess(actor, 'listMeterDayTakes', { tenantId: null }, filter ?? null, rows.length);
|
|
6648
|
+
return rows;
|
|
6649
|
+
},
|
|
6650
|
+
pruneMeterDays: async (_actor, limit, retentionMonths) => this.cp.pruneMeterDays(assertRowLimit('limit', limit), retentionMonths),
|
|
6537
6651
|
recordModelUsage: async (input) => {
|
|
6538
6652
|
const l = input.line;
|
|
6539
6653
|
return this.cp.recordModelUsage({
|
|
@@ -6756,15 +6870,11 @@ export class CloudflareScopeHost {
|
|
|
6756
6870
|
catch (err) {
|
|
6757
6871
|
report.failed += 1;
|
|
6758
6872
|
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
|
-
});
|
|
6873
|
+
// #2554: the throw is the deployment's or its runtime's text, and the deployment serving
|
|
6874
|
+
// a scope is the vertical's code, often another tenant's. The tenant's row says only what
|
|
6875
|
+
// happened; the text is staff's row, under the same reference.
|
|
6876
|
+
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`;
|
|
6877
|
+
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
6878
|
}
|
|
6769
6879
|
catch (recordErr) {
|
|
6770
6880
|
console.error('substrat: could not record a lifecycle delivery failure (#1713)', recordErr);
|
|
@@ -7564,6 +7674,14 @@ export class CloudflareScopeHost {
|
|
|
7564
7674
|
// #2430: refused before anything is written, rather than at the top-up after the projection.
|
|
7565
7675
|
if (input.entityGrants)
|
|
7566
7676
|
await this.refuseReservedShapes(input.entityGrants, input.scopeId);
|
|
7677
|
+
// #2447: the role set this provision defines, and the owner's seat in it — a person's seat, so
|
|
7678
|
+
// the person's door — are judged before anything is written. A refusal fails the provision and
|
|
7679
|
+
// writes nothing, so a seat a reconcile would have re-asserted is left exactly as it is.
|
|
7680
|
+
const reserved = await this.refuseReservedRoleSet(input.roles, input.tenantId, input.scopeId);
|
|
7681
|
+
const ownerRole = input.roles.find((r) => r.key === input.ownerRoleKey);
|
|
7682
|
+
const ownerRefusal = ownerRole ? reservedRoleRefusal(ownerRole, reserved, 'person') : null;
|
|
7683
|
+
if (ownerRefusal)
|
|
7684
|
+
throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
|
|
7567
7685
|
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7568
7686
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7569
7687
|
const stub = this.scopeStub(input.scopeId);
|
|
@@ -7639,6 +7757,110 @@ export class CloudflareScopeHost {
|
|
|
7639
7757
|
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7640
7758
|
};
|
|
7641
7759
|
}
|
|
7760
|
+
/**
|
|
7761
|
+
* #2447: what a tenant's role definition is judged with, beyond the stored roles the ControlPlaneDO
|
|
7762
|
+
* reads in its own unit:
|
|
7763
|
+
* - the reservations of this host's modules, joined with the scope class's. Every scope on this
|
|
7764
|
+
* binding is one class, so one of the tenant's scopes answers for all, and the fan-out after
|
|
7765
|
+
* the write projects into exactly those scopes. A tenant with no scope yet is judged by this
|
|
7766
|
+
* host's modules alone (a stated residual: its first scope's projection is not judged again);
|
|
7767
|
+
* - for a `holder: 'system'` definition of a key with no system-held definition stored (none at
|
|
7768
|
+
* all included: a seat can outlive its role's row), whether anyone but a system principal holds a
|
|
7769
|
+
* live scope-level seat in it in one of those scopes (the ControlPlaneDO reads the tenant-level
|
|
7770
|
+
* seats). No scope is asked for seats otherwise.
|
|
7771
|
+
*
|
|
7772
|
+
* Every scope that is not reaped is read, a scope archived straight from `provisioning` included:
|
|
7773
|
+
* the platform's door seats into a provisioning scope, and unarchive brings the seat back. A DO
|
|
7774
|
+
* cannot be asked anything without waking it, and waking one that never ran would create storage it
|
|
7775
|
+
* never had (#1524), so such a scope is read only when its directory row records a migration
|
|
7776
|
+
* (`schema_version` past `'0'`). That is not a mark of instantiation: it counts the module
|
|
7777
|
+
* migrations applied, and is written only when a pass applied one, so a scope of a class with no
|
|
7778
|
+
* module migrations reads `'0'` though it holds a seat. One that records none is not read, and the
|
|
7779
|
+
* definition fails closed where that scope could decide it: `unreadable` names it, and the caller
|
|
7780
|
+
* refuses a definition its seats, or its class's reservations when no other scope gives them, would
|
|
7781
|
+
* decide. So while such a scope is the tenant's only one, every definition is refused until it is
|
|
7782
|
+
* unarchived or reaped. No product path reaches this today: the dashboard is the one Cloudflare
|
|
7783
|
+
* caller of `defineRole`, and its class has migrations.
|
|
7784
|
+
*
|
|
7785
|
+
* A stated residual: this half is read before the ControlPlaneDO's unit, not in it, so a scope-level
|
|
7786
|
+
* seat made in between is not seen. The tenant-level seats and the stored roles are read in the unit.
|
|
7787
|
+
*/
|
|
7788
|
+
async definitionContext(tenantId, role) {
|
|
7789
|
+
const listed = await this.cp.listScopes({ tenantId, status: [...SEAT_HOLDING_SCOPE_STATUSES] });
|
|
7790
|
+
const neverStored = (s) => (s.status === 'archived' || s.status === 'archiving') && s.archived_from_status === 'provisioning' && (s.schema_version ?? '0') === '0';
|
|
7791
|
+
const scopes = listed.filter((s) => !neverStored(s));
|
|
7792
|
+
const unreadableScope = listed.find(neverStored);
|
|
7793
|
+
const unreadable = unreadableScope
|
|
7794
|
+
? `scope ${unreadableScope.scope_id} was archived straight from provisioning and records no migration, so its seats and its ` +
|
|
7795
|
+
`class's reservations cannot be read without creating storage it may never have had. Unarchive it (or reap it) first`
|
|
7796
|
+
: null;
|
|
7797
|
+
const reservation = { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
|
|
7798
|
+
if (scopes.length > 0) {
|
|
7799
|
+
const here = await this.scopeStub(scopes[0].scope_id).reservationHere();
|
|
7800
|
+
reservation.reserved = [...new Set([...reservation.reserved, ...here.reserved])];
|
|
7801
|
+
reservation.declared = [...new Set([...reservation.declared, ...here.declared])];
|
|
7802
|
+
}
|
|
7803
|
+
let seatedInScopes = false;
|
|
7804
|
+
let seatsDecide = false;
|
|
7805
|
+
if (role.holder === 'system') {
|
|
7806
|
+
const stored = await this.cp.getRole(tenantId, role.key);
|
|
7807
|
+
if (stored?.holder !== 'system') {
|
|
7808
|
+
seatsDecide = true;
|
|
7809
|
+
for (const s of scopes) {
|
|
7810
|
+
if ((await this.scopeStub(s.scope_id).roleStateHere(tenantId)).seated.includes(role.key)) {
|
|
7811
|
+
seatedInScopes = true;
|
|
7812
|
+
break;
|
|
7813
|
+
}
|
|
7814
|
+
}
|
|
7815
|
+
}
|
|
7816
|
+
}
|
|
7817
|
+
// Fail closed only where the unread scope could decide: its seats (a system-held definition the
|
|
7818
|
+
// seats judge), or the class's reservations when no other scope gave them.
|
|
7819
|
+
const decides = unreadable !== null && ((seatsDecide && !seatedInScopes) || scopes.length === 0);
|
|
7820
|
+
return { reservation, seatedInScopes, unreadable: decides ? unreadable : null };
|
|
7821
|
+
}
|
|
7822
|
+
/**
|
|
7823
|
+
* #2447: why `roles` may not be defined on `scopeId`, judged as a set against this host's
|
|
7824
|
+
* reservations joined with the scope class's, or null. A restore asks it before it imports a dump,
|
|
7825
|
+
* which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
|
|
7826
|
+
* roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
|
|
7827
|
+
* staged restore has not (its pages are already in the scope), so it is judged as a set alone
|
|
7828
|
+
* here, and `projectRolesLocal` judges again after the import, against what the dump brought.
|
|
7829
|
+
*/
|
|
7830
|
+
async roleSetRefusalLocal(scopeId, roles, dump) {
|
|
7831
|
+
const here = await this.scopeStub(scopeId).reservationHere();
|
|
7832
|
+
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);
|
|
7833
|
+
}
|
|
7834
|
+
/**
|
|
7835
|
+
* #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
|
|
7836
|
+
* holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
|
|
7837
|
+
* (`roleStateHere`), read for a promote to judge the incoming version's set with
|
|
7838
|
+
* (`releaseRoleSetRefusal`) before it moves the scope. Read-only, and names no principal.
|
|
7839
|
+
*/
|
|
7840
|
+
async roleStateLocal(tenantId, scopeId) {
|
|
7841
|
+
return this.scopeStub(scopeId).roleStateHere(tenantId);
|
|
7842
|
+
}
|
|
7843
|
+
/**
|
|
7844
|
+
* #2447: refuse a role set a CP-less provision or restore defines on `scopeId` for `tenantId`
|
|
7845
|
+
* (`reservedRoleSetRefusal`), against this host's reservations joined with the scope class's,
|
|
7846
|
+
* and the roles the scope stores for the tenant now with their seats there. Answers the reserved
|
|
7847
|
+
* keys it judged with, for the seats the same call makes.
|
|
7848
|
+
*
|
|
7849
|
+
* A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
|
|
7850
|
+
* (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
|
|
7851
|
+
* wide, and a reconcile runs again every pass.
|
|
7852
|
+
*/
|
|
7853
|
+
async refuseReservedRoleSet(roles, tenantId, scopeId) {
|
|
7854
|
+
const stub = this.scopeStub(scopeId);
|
|
7855
|
+
const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
|
|
7856
|
+
const reserved = new Set([...this.reservedKeys, ...here.reserved]);
|
|
7857
|
+
const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) }, { roles: state.roles, seated: new Set(state.seated) });
|
|
7858
|
+
// Named by tenant and scope as well as role: a release push admitted can be refused here at every
|
|
7859
|
+
// scope where the role is seated, and staff find each one by this line (K-55).
|
|
7860
|
+
if (refusal)
|
|
7861
|
+
throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${refusal}`);
|
|
7862
|
+
return reserved;
|
|
7863
|
+
}
|
|
7642
7864
|
/**
|
|
7643
7865
|
* #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
|
|
7644
7866
|
* connection acts for the external provider holding its token, so a grant naming a key reserved
|
|
@@ -7734,15 +7956,26 @@ export class CloudflareScopeHost {
|
|
|
7734
7956
|
* storage. Idempotent (writeTuple is INSERT OR REPLACE). `roleKey` must be one the scope
|
|
7735
7957
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
7736
7958
|
*/
|
|
7737
|
-
async assignScopeRole(scopeId, principal, roleKey
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
//
|
|
7741
|
-
|
|
7959
|
+
async assignScopeRole(scopeId, principal, roleKey,
|
|
7960
|
+
/** #2447: `person` when the caller knows it seats a person (the owner hand-over); the platform's door otherwise. */
|
|
7961
|
+
door = 'platform') {
|
|
7962
|
+
// #2430: a platform door by default, as `HostAdmin.assignRole` is — a role mixing reserved and
|
|
7963
|
+
// other keys is refused, against the scope class's own reservations and the role as the checker
|
|
7964
|
+
// reads it for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens
|
|
7965
|
+
// without one). The person's door also refuses any reserved key and a system-held role (#2447).
|
|
7966
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
|
|
7742
7967
|
if (refusal)
|
|
7743
7968
|
throw substratError('forbidden', refusal);
|
|
7744
7969
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
7745
7970
|
}
|
|
7971
|
+
/**
|
|
7972
|
+
* #2447: why `roleKey` may not be seated at scope level through `door`, or null — the question
|
|
7973
|
+
* `assignScopeRole` asks before it writes, for a caller (the owner hand-over) that must ask it
|
|
7974
|
+
* before anything else moves.
|
|
7975
|
+
*/
|
|
7976
|
+
async scopeRoleRefusal(scopeId, roleKey, door) {
|
|
7977
|
+
return this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
|
|
7978
|
+
}
|
|
7746
7979
|
/**
|
|
7747
7980
|
* Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
|
|
7748
7981
|
* A tombstone, exactly as `HostAdmin.unassignRole` writes for a scope-level assignment:
|