@substrat-run/adapter-cloudflare 0.153.0 → 0.155.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 +3 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +7 -2
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +81 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +170 -11
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +10 -2
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +428 -30
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { env as ambientEnv } from 'cloudflare:workers';
|
|
2
2
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
|
-
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
4
|
+
import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, 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, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
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';
|
|
7
7
|
import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
|
|
8
8
|
import { 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';
|
|
@@ -58,6 +58,18 @@ export const tenantUnrecordedLine = (tenantId, scopeId) => ({
|
|
|
58
58
|
scopeId,
|
|
59
59
|
message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
|
|
60
60
|
});
|
|
61
|
+
/**
|
|
62
|
+
* #2430: the line a host writes when a provision or reconcile skips delivered connection grants that
|
|
63
|
+
* name a key reserved to a system principal here. Ids and keys only.
|
|
64
|
+
*/
|
|
65
|
+
const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
|
|
66
|
+
substrat: 'connection-grants-skipped',
|
|
67
|
+
level: 'warn',
|
|
68
|
+
tenantId,
|
|
69
|
+
scopeId,
|
|
70
|
+
skipped,
|
|
71
|
+
message: 'delivered connection grants naming a key reserved to a system principal were not seated',
|
|
72
|
+
});
|
|
61
73
|
/**
|
|
62
74
|
* Refuse to mark a scope a copy unless the request classifies it non-primary (#2005). The
|
|
63
75
|
* classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
|
|
@@ -332,6 +344,10 @@ export class CloudflareScopeHost {
|
|
|
332
344
|
// Registration-mechanics bookkeeping (validation only — the DO executes).
|
|
333
345
|
// Code-time, derived from the bundled modules, NOT durable directory state.
|
|
334
346
|
moduleIds = new Set();
|
|
347
|
+
/** #2430: what a registered module declares `holder: 'system'` — `assignRole`'s refusal reads it. */
|
|
348
|
+
reservedKeys = new Set();
|
|
349
|
+
/** #2430: the declared permission surface `registerModule` holds each module to, when given. */
|
|
350
|
+
permissions;
|
|
335
351
|
/**
|
|
336
352
|
* #1705: this deployment's cross-vertical declarations. The DO holds the handlers and makes
|
|
337
353
|
* every decision. The coordinator keeps its own copy for two reasons: registration refuses
|
|
@@ -427,6 +443,10 @@ export class CloudflareScopeHost {
|
|
|
427
443
|
*/
|
|
428
444
|
constructor(options) {
|
|
429
445
|
this.servicePrincipals = options.servicePrincipals;
|
|
446
|
+
this.permissions = options.permissions;
|
|
447
|
+
// #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
|
|
448
|
+
for (const m of options.permissions?.modules ?? [])
|
|
449
|
+
noteReservedKeys(this.reservedKeys, m.manifest);
|
|
430
450
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
431
451
|
this.tenantStores = options.tenantStores;
|
|
432
452
|
this.blobStores = options.blobStores;
|
|
@@ -1055,6 +1075,36 @@ export class CloudflareScopeHost {
|
|
|
1055
1075
|
* #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
|
|
1056
1076
|
* the load's own transaction, and a store provisioned for another tenant refuses the load. */
|
|
1057
1077
|
opts) {
|
|
1078
|
+
const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
|
|
1079
|
+
return { ...out, tables: tables.length };
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* One page of this scope's dump from THIS deployment (#2450), behind the vertical's
|
|
1083
|
+
* `/internal/export-page`: `exportScopeStampedLocal` a page at a time, for a scope too large to
|
|
1084
|
+
* cross in one call. With `stamp`, the first page (`cursor` null) mints the load stamp.
|
|
1085
|
+
*/
|
|
1086
|
+
async exportScopePageLocal(scopeId, cursor,
|
|
1087
|
+
/** `maxBytes`: a smaller page than `SCOPE_DUMP_PAGE_MAX_BYTES`, for a test that wants many. */
|
|
1088
|
+
opts) {
|
|
1089
|
+
return unwrapReply(await this.scopeStub(scopeId).exportDumpPage(cursor, {
|
|
1090
|
+
stamp: opts?.stamp === true,
|
|
1091
|
+
...(opts?.maxBytes !== undefined ? { maxBytes: opts.maxBytes } : {}),
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
/** Stage one page of a dump into a scope in THIS deployment (#2450), for `restoreStagedScopeLocal`. */
|
|
1095
|
+
async stageScopePageLocal(scopeId, stageId, seq, tables) {
|
|
1096
|
+
return unwrapReply(await this.scopeStub(scopeId).stageDumpPage(stageId, seq, tables));
|
|
1097
|
+
}
|
|
1098
|
+
/** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
|
|
1099
|
+
async restoreStagedScopeLocal(scopeId, stageId, opts) {
|
|
1100
|
+
return this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
|
|
1101
|
+
}
|
|
1102
|
+
/** Drop the dump staged under `stageId` (#2450), if it is still there. */
|
|
1103
|
+
async discardStagedScopeLocal(scopeId, stageId) {
|
|
1104
|
+
return this.scopeStub(scopeId).discardStagedDump(stageId);
|
|
1105
|
+
}
|
|
1106
|
+
/** The restore both `restoreScopeLocal` and `restoreStagedScopeLocal` run around their load. */
|
|
1107
|
+
async loadLocal(scopeId, opts, run) {
|
|
1058
1108
|
if (opts?.markCopy)
|
|
1059
1109
|
assertCopyLineage(opts.markCopy);
|
|
1060
1110
|
// #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
|
|
@@ -1070,13 +1120,13 @@ export class CloudflareScopeHost {
|
|
|
1070
1120
|
};
|
|
1071
1121
|
// The refusals come back as values (a throw over the RPC carries only its message) and are
|
|
1072
1122
|
// thrown here, in the vertical's own isolate, so its route answers 409 or 412, not a fault.
|
|
1073
|
-
const out = await
|
|
1123
|
+
const out = await run({
|
|
1074
1124
|
...load,
|
|
1075
1125
|
...(opts?.expect ? { expect: opts.expect } : {}),
|
|
1076
1126
|
...(opts?.fence ? { fence: opts.fence } : {}),
|
|
1077
1127
|
});
|
|
1078
1128
|
if (out.refused === false) {
|
|
1079
|
-
return { tables: tables
|
|
1129
|
+
return { tables: out.tables ?? 0, ...(carry ? { switchedOff: out.switchedOff } : {}) };
|
|
1080
1130
|
}
|
|
1081
1131
|
if (out.refused === 'kept')
|
|
1082
1132
|
throw substratError('conflict', KEPT_COPY_REFUSAL);
|
|
@@ -1092,7 +1142,7 @@ export class CloudflareScopeHost {
|
|
|
1092
1142
|
if (!opts?.loadStamp || now.loadStamp !== opts.loadStamp) {
|
|
1093
1143
|
throw substratError('precondition_failed', 'scope store changed since the carry read it; nothing was loaded (#1722)');
|
|
1094
1144
|
}
|
|
1095
|
-
return { tables: tables
|
|
1145
|
+
return { tables: out.tables ?? 0 };
|
|
1096
1146
|
}
|
|
1097
1147
|
/** The kept-copy marker of this scope's store in THIS deployment (#1722), or null. */
|
|
1098
1148
|
async keptCopyLocal(scopeId) {
|
|
@@ -1498,6 +1548,7 @@ export class CloudflareScopeHost {
|
|
|
1498
1548
|
if (this.moduleIds.has(manifest.id)) {
|
|
1499
1549
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1500
1550
|
}
|
|
1551
|
+
assertModuleDeclared(this.permissions, manifest.id); // #2430
|
|
1501
1552
|
// #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
|
|
1502
1553
|
// misdeclared erasure fails where it is registered on both hosts.
|
|
1503
1554
|
moduleErasurePlan(registration);
|
|
@@ -1531,6 +1582,7 @@ export class CloudflareScopeHost {
|
|
|
1531
1582
|
// #1705: after the checks above, so a refused module leaves nothing registered here.
|
|
1532
1583
|
this.crossVertical.register(manifest, registration.imports);
|
|
1533
1584
|
this.moduleIds.add(manifest.id);
|
|
1585
|
+
noteReservedKeys(this.reservedKeys, manifest);
|
|
1534
1586
|
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
1535
1587
|
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
1536
1588
|
}
|
|
@@ -2791,6 +2843,10 @@ export class CloudflareScopeHost {
|
|
|
2791
2843
|
throw unknownScopeForTenant(tenantId, scopeId);
|
|
2792
2844
|
}
|
|
2793
2845
|
await this.peerScopeGate(tenantId, scopeId, 'canAssign');
|
|
2846
|
+
// #2430: the answer a person's door would give — a role carrying a reserved key is refused.
|
|
2847
|
+
const refused = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, roleKey, 'person');
|
|
2848
|
+
if (refused)
|
|
2849
|
+
throw substratError('forbidden', refused);
|
|
2794
2850
|
const bound = await this.scopeStub(scopeId).canAssignFor(tenantId, scopeId, principalId.parse(principal), roleKey);
|
|
2795
2851
|
if (!bound)
|
|
2796
2852
|
throw unknownRoleError(roleKey);
|
|
@@ -2804,6 +2860,8 @@ export class CloudflareScopeHost {
|
|
|
2804
2860
|
const bound = await this.scopeStub(scopeId).assignScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(assignee), roleKey);
|
|
2805
2861
|
if (!bound)
|
|
2806
2862
|
throw unknownRoleError(roleKey);
|
|
2863
|
+
if ('refused' in bound)
|
|
2864
|
+
throw substratError('forbidden', bound.refused);
|
|
2807
2865
|
return coverage.parse(bound);
|
|
2808
2866
|
}
|
|
2809
2867
|
async listScopeRoleHolders(tenantId, scopeId, principal) {
|
|
@@ -2818,6 +2876,8 @@ export class CloudflareScopeHost {
|
|
|
2818
2876
|
throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
|
|
2819
2877
|
if (bound === 'unknown-to')
|
|
2820
2878
|
throw unknownRoleError(to);
|
|
2879
|
+
if ('refused' in bound)
|
|
2880
|
+
throw substratError('forbidden', bound.refused);
|
|
2821
2881
|
return coverage.parse(bound);
|
|
2822
2882
|
}
|
|
2823
2883
|
async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
|
|
@@ -3155,10 +3215,12 @@ export class CloudflareScopeHost {
|
|
|
3155
3215
|
// call and transaction — and the SCOPE decides which are due and runs them, so purge
|
|
3156
3216
|
// authority is never an argument any stub can pass (`runPurgeSweep`).
|
|
3157
3217
|
// #2192: under the run's own id, so the purges and their deliveries join this run's line.
|
|
3158
|
-
// #2431:
|
|
3218
|
+
// #2431: the purges' own requests run inside the scope, which reports no count for them;
|
|
3219
|
+
// the deliveries their events route to the platform are counted below, as an invoke's are.
|
|
3159
3220
|
const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
|
|
3160
3221
|
// The purges' events, delivered the way each invoke's own tail delivers them.
|
|
3161
|
-
await this.drainExecutors(tenantId, scopeId, invocationId);
|
|
3222
|
+
const delivered = await this.drainExecutors(tenantId, scopeId, invocationId);
|
|
3223
|
+
platformRequests += delivered.routedToPlatform ?? 0;
|
|
3162
3224
|
stillDue = purgeStillDue(pass);
|
|
3163
3225
|
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
3164
3226
|
report.errors.push(...outcome.errors);
|
|
@@ -4127,6 +4189,19 @@ export class CloudflareScopeHost {
|
|
|
4127
4189
|
}));
|
|
4128
4190
|
},
|
|
4129
4191
|
assignRole: async (actor, assignment) => {
|
|
4192
|
+
// #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
|
|
4193
|
+
// At a scope node, read where the checker reads the role, against the scope class's own
|
|
4194
|
+
// reservations (it answers null when it has none). At the tenant node, against this host's.
|
|
4195
|
+
{
|
|
4196
|
+
const { tenantId, scopeId } = assignment.node;
|
|
4197
|
+
const refusal = scopeId
|
|
4198
|
+
? await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform')
|
|
4199
|
+
: this.reservedKeys.size > 0
|
|
4200
|
+
? await this.cp.getRole(tenantId, assignment.roleKey).then((role) => role && reservedRoleRefusal(role, this.reservedKeys, 'platform'))
|
|
4201
|
+
: null;
|
|
4202
|
+
if (refusal)
|
|
4203
|
+
throw substratError('forbidden', refusal);
|
|
4204
|
+
}
|
|
4130
4205
|
const subject = `principal:${assignment.principalId}`;
|
|
4131
4206
|
if (assignment.node.scopeId) {
|
|
4132
4207
|
await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
|
|
@@ -4164,6 +4239,7 @@ export class CloudflareScopeHost {
|
|
|
4164
4239
|
// trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
|
|
4165
4240
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
4166
4241
|
const grant = capabilityGrant.parse(raw);
|
|
4242
|
+
await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
|
|
4167
4243
|
await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
4168
4244
|
await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
4169
4245
|
// Tenant-level grant → changes the projected set. Scope-level + entity
|
|
@@ -4209,6 +4285,12 @@ export class CloudflareScopeHost {
|
|
|
4209
4285
|
`${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
|
|
4210
4286
|
}
|
|
4211
4287
|
}
|
|
4288
|
+
// #2430: a connection acts for the external provider holding its token, so it is never
|
|
4289
|
+
// given a key reserved to a system principal — refused before the directory record, so a
|
|
4290
|
+
// refused grant is gathered by no later provision. Against this host's reservations, and
|
|
4291
|
+
// the scope class's when the scope is served here; a delegated scope's deployment judges
|
|
4292
|
+
// its own at delivery (`connectorGrantLocal`). A grant recorded before stays.
|
|
4293
|
+
await this.refuseReservedGrant([grant.permission], this.connectorDelegation ? null : (grant.node.scopeId ?? null));
|
|
4212
4294
|
// #592: the directory-side record FIRST, so a grant whose tuple delivery
|
|
4213
4295
|
// fails below is still gathered by the next provision/reconcile — the
|
|
4214
4296
|
// repair channel — instead of vanishing. The tuple stays the only thing
|
|
@@ -4357,6 +4439,7 @@ export class CloudflareScopeHost {
|
|
|
4357
4439
|
// The org must exist in the node's tenant. A grant to a phantom org looks
|
|
4358
4440
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
4359
4441
|
await requireOrg(node.tenantId, orgId);
|
|
4442
|
+
await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
|
|
4360
4443
|
await writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4361
4444
|
await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
4362
4445
|
if (!node.scopeId)
|
|
@@ -5115,7 +5198,9 @@ export class CloudflareScopeHost {
|
|
|
5115
5198
|
: this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
|
|
5116
5199
|
// #2047: the org's own row, when the change joins or leaves one. The unit fills in its
|
|
5117
5200
|
// `before`/`after` and writes it only for what it does — a join's carries the expiry.
|
|
5118
|
-
orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined
|
|
5201
|
+
orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined,
|
|
5202
|
+
// #2430: what this host's modules reserve, so the unit refuses an add of a role carrying it.
|
|
5203
|
+
[...this.reservedKeys]);
|
|
5119
5204
|
// The tenant-level tuple, or its tombstone, reaches the projections.
|
|
5120
5205
|
if (result.applied && (op === 'add' || result.changed))
|
|
5121
5206
|
await this.fanOut(tenantId);
|
|
@@ -7475,6 +7560,10 @@ export class CloudflareScopeHost {
|
|
|
7475
7560
|
*/
|
|
7476
7561
|
async provisionScopeLocal(input) {
|
|
7477
7562
|
const carry = recordedOffFromWire(input);
|
|
7563
|
+
// #2430: refused before anything is written, rather than at the top-up after the projection.
|
|
7564
|
+
if (input.entityGrants)
|
|
7565
|
+
await this.refuseReservedShapes(input.entityGrants, input.scopeId);
|
|
7566
|
+
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
|
|
7478
7567
|
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
7479
7568
|
const stub = this.scopeStub(input.scopeId);
|
|
7480
7569
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
@@ -7511,7 +7600,7 @@ export class CloudflareScopeHost {
|
|
|
7511
7600
|
expires_at: null,
|
|
7512
7601
|
lockout_reseat: true,
|
|
7513
7602
|
},
|
|
7514
|
-
...this.declaredSeatsLocal(input.scopeId,
|
|
7603
|
+
...this.declaredSeatsLocal(input.scopeId, connections.seat),
|
|
7515
7604
|
],
|
|
7516
7605
|
// #406: identity links, delivered by the platform with provisioning exactly as
|
|
7517
7606
|
// entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
|
|
@@ -7546,8 +7635,31 @@ export class CloudflareScopeHost {
|
|
|
7546
7635
|
...(carry ? { switchedOff } : {}),
|
|
7547
7636
|
...(shapes ? { retirementsLeftOpen: shapes.retirementsLeftOpen ?? 0 } : {}),
|
|
7548
7637
|
...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
|
|
7638
|
+
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7549
7639
|
};
|
|
7550
7640
|
}
|
|
7641
|
+
/**
|
|
7642
|
+
* #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
|
|
7643
|
+
* connection acts for the external provider holding its token, so a grant naming a key reserved
|
|
7644
|
+
* to a system principal here (this host's reservations, or the scope class's) is never seated.
|
|
7645
|
+
* The directory may have recorded it before these checks existed, so it is skipped and reported
|
|
7646
|
+
* (in the answer and as a warn line), never a failed provision: one such grant must not stop a
|
|
7647
|
+
* promote or a reconcile seating everything else.
|
|
7648
|
+
*/
|
|
7649
|
+
async seatableConnectionGrants(tenantId, scopeId, grants) {
|
|
7650
|
+
if (!grants?.length)
|
|
7651
|
+
return { seat: grants, skipped: [] };
|
|
7652
|
+
const permissions = [...new Set(grants.map((g) => g.permission))];
|
|
7653
|
+
const reserved = new Set([
|
|
7654
|
+
...permissions.filter((p) => this.reservedKeys.has(p)),
|
|
7655
|
+
...(await this.scopeStub(scopeId).reservedAmong(permissions)),
|
|
7656
|
+
]);
|
|
7657
|
+
if (reserved.size === 0)
|
|
7658
|
+
return { seat: grants, skipped: [] };
|
|
7659
|
+
const skipped = grants.filter((g) => reserved.has(g.permission)).map((g) => ({ connectionId: g.connectionId, permission: g.permission }));
|
|
7660
|
+
console.warn(JSON.stringify(connectionGrantsSkippedLine(tenantId, scopeId, skipped)));
|
|
7661
|
+
return { seat: grants.filter((g) => !reserved.has(g.permission)), skipped };
|
|
7662
|
+
}
|
|
7551
7663
|
/**
|
|
7552
7664
|
* The tuples this deployment's code declares for one scope, seated by every provision and
|
|
7553
7665
|
* reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
|
|
@@ -7591,9 +7703,13 @@ export class CloudflareScopeHost {
|
|
|
7591
7703
|
if (!(await stub.servesTenant(input.tenantId)))
|
|
7592
7704
|
throw unknownScopeForTenant(input.tenantId, input.scopeId);
|
|
7593
7705
|
const carry = recordedOffFromWire(input);
|
|
7706
|
+
const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
|
|
7594
7707
|
await this.migrateAndRecord(input.scopeId);
|
|
7595
|
-
const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId,
|
|
7596
|
-
return
|
|
7708
|
+
const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
|
|
7709
|
+
return {
|
|
7710
|
+
...(carry ? { switchedOff } : {}),
|
|
7711
|
+
...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
|
|
7712
|
+
};
|
|
7597
7713
|
}
|
|
7598
7714
|
/**
|
|
7599
7715
|
* Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
|
|
@@ -7618,6 +7734,12 @@ export class CloudflareScopeHost {
|
|
|
7618
7734
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
7619
7735
|
*/
|
|
7620
7736
|
async assignScopeRole(scopeId, principal, roleKey) {
|
|
7737
|
+
// #2430: a platform door, as `HostAdmin.assignRole` is — a role mixing reserved and other keys
|
|
7738
|
+
// is refused, against the scope class's own reservations and the role as the checker reads it
|
|
7739
|
+
// for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens without one).
|
|
7740
|
+
const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, 'platform');
|
|
7741
|
+
if (refusal)
|
|
7742
|
+
throw substratError('forbidden', refusal);
|
|
7621
7743
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
7622
7744
|
}
|
|
7623
7745
|
/**
|
|
@@ -7677,6 +7799,7 @@ export class CloudflareScopeHost {
|
|
|
7677
7799
|
* (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
|
|
7678
7800
|
*/
|
|
7679
7801
|
async grantEntityLocal(scopeId, principal, permission, entity) {
|
|
7802
|
+
await this.refuseReservedGrant([permission], scopeId); // #2430
|
|
7680
7803
|
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
7681
7804
|
null));
|
|
7682
7805
|
}
|
|
@@ -7689,8 +7812,29 @@ export class CloudflareScopeHost {
|
|
|
7689
7812
|
* like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
|
|
7690
7813
|
*/
|
|
7691
7814
|
async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
|
|
7815
|
+
await this.refuseReservedGrant(permissions, scopeId); // #2430
|
|
7692
7816
|
await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
|
|
7693
7817
|
}
|
|
7818
|
+
/**
|
|
7819
|
+
* #2430: refuse a raw grant of a reserved key (`reservedGrantRefusal`): against this host's
|
|
7820
|
+
* reservations, and at a scope node against the scope class's own, which may name more.
|
|
7821
|
+
*/
|
|
7822
|
+
async refuseReservedGrant(permissions, scopeId) {
|
|
7823
|
+
const refusal = reservedGrantRefusal(permissions, this.reservedKeys) ??
|
|
7824
|
+
(scopeId ? await this.scopeStub(scopeId).reservedGrantRefusalFor([...permissions]) : null);
|
|
7825
|
+
if (refusal)
|
|
7826
|
+
throw substratError('forbidden', refusal);
|
|
7827
|
+
}
|
|
7828
|
+
/**
|
|
7829
|
+
* #2430: refuse a reconcile of declared shapes when one holds a reserved key in `permissions`
|
|
7830
|
+
* (`reservedShapeRefusal`): against this host's reservations, then the scope class's own.
|
|
7831
|
+
*/
|
|
7832
|
+
async refuseReservedShapes(shapes, scopeId) {
|
|
7833
|
+
const refusal = reservedShapeRefusal(shapes, this.reservedKeys) ??
|
|
7834
|
+
(await this.scopeStub(scopeId).reservedShapeRefusalFor(shapes.map((s) => ({ entityType: s.entityType, permissions: [...s.permissions] }))));
|
|
7835
|
+
if (refusal)
|
|
7836
|
+
throw substratError('forbidden', refusal);
|
|
7837
|
+
}
|
|
7694
7838
|
/**
|
|
7695
7839
|
* Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
|
|
7696
7840
|
* passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
|
|
@@ -7702,6 +7846,7 @@ export class CloudflareScopeHost {
|
|
|
7702
7846
|
*/
|
|
7703
7847
|
async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
|
|
7704
7848
|
const limit = shapeTopUpBatch(batch);
|
|
7849
|
+
await this.refuseReservedShapes(shapes, scopeId); // #2430
|
|
7705
7850
|
let toppedUp = 0;
|
|
7706
7851
|
let retired = 0;
|
|
7707
7852
|
let retirementsLeftOpen = 0;
|
|
@@ -7775,6 +7920,8 @@ export class CloudflareScopeHost {
|
|
|
7775
7920
|
* while the tuple remains; `expiresAt` bounds the tuple itself.
|
|
7776
7921
|
*/
|
|
7777
7922
|
async connectorGrantLocal(connectionId, scopeId, permission, expiresAt) {
|
|
7923
|
+
// #2430: against the reservations of the scope served here, as `grantToConnection` asks.
|
|
7924
|
+
await this.refuseReservedGrant([permission], scopeId);
|
|
7778
7925
|
await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
|
|
7779
7926
|
}
|
|
7780
7927
|
/** #2045 (Codex r3): every switch mover here honours the fence — what `/internal/switch-fence` reports. */
|
|
@@ -8071,6 +8218,18 @@ export class CloudflareScopeHost {
|
|
|
8071
8218
|
}
|
|
8072
8219
|
return importsOfManifestJson(v.manifest_json);
|
|
8073
8220
|
}
|
|
8221
|
+
/**
|
|
8222
|
+
* What one pushed version declares about connection landings (#2316), from its stored manifest:
|
|
8223
|
+
* the connector pass reads it for each install's running version. Unaudited for the reason
|
|
8224
|
+
* {@link versionImports} is: it is the platform's own code metadata, read every pass.
|
|
8225
|
+
*/
|
|
8226
|
+
async versionConnectionLandings(verticalSlug, versionId) {
|
|
8227
|
+
const v = await this.cp.readVersion(versionId);
|
|
8228
|
+
if (!v || v.vertical_slug !== verticalSlug) {
|
|
8229
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
8230
|
+
}
|
|
8231
|
+
return connectionLandingsOfManifestJson(v.manifest_json);
|
|
8232
|
+
}
|
|
8074
8233
|
}
|
|
8075
8234
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
8076
8235
|
function modelUsageEntryOf(r) {
|