@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/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 this.scopeStub(scopeId).importDumpChecked(tables, scopeId, {
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.length, ...(carry ? { switchedOff: out.switchedOff } : {}) };
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.length };
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: what the purges enqueue is not counted (`ScheduleRunReport.platformRequests`).
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, input.connectionGrants),
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, input.connectionGrants), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
7596
- return carry ? { switchedOff } : {};
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) {