@substrat-run/adapter-cloudflare 0.152.0 → 0.154.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
@@ -3,7 +3,7 @@ import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
4
  import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, 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) {
@@ -3116,6 +3176,11 @@ export class CloudflareScopeHost {
3116
3176
  const now = Date.now();
3117
3177
  const lines = asyncLinePass();
3118
3178
  let door;
3179
+ // #2431: what the fires below report through `onPlatformRequests` — the callback an HTTP
3180
+ // harness uses to flag the router kick (#458) — so the sweep that ran this pass can drain the
3181
+ // scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
3182
+ let platformRequests = 0;
3183
+ const counted = { onPlatformRequests: (n) => (platformRequests += n) };
3119
3184
  for (const schedule of schedules) {
3120
3185
  const last = await stub.scheduleLastRun(schedule.operation);
3121
3186
  const lastRun = last ? Date.parse(last) : null;
@@ -3144,12 +3209,13 @@ export class CloudflareScopeHost {
3144
3209
  if (door)
3145
3210
  await this.assertLive(tenantId, scopeId);
3146
3211
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
3147
- const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
3212
+ const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door, counted);
3148
3213
  if (schedule.purge) {
3149
3214
  // #119: a purge horizon's schedule runs its operation once per due entity, each its own
3150
3215
  // call and transaction — and the SCOPE decides which are due and runs them, so purge
3151
3216
  // authority is never an argument any stub can pass (`runPurgeSweep`).
3152
3217
  // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3218
+ // #2431: what the purges enqueue is not counted (`ScheduleRunReport.platformRequests`).
3153
3219
  const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
3154
3220
  // The purges' events, delivered the way each invoke's own tail delivers them.
3155
3221
  await this.drainExecutors(tenantId, scopeId, invocationId);
@@ -3204,6 +3270,8 @@ export class CloudflareScopeHost {
3204
3270
  await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
3205
3271
  }
3206
3272
  lines.end();
3273
+ if (platformRequests > 0)
3274
+ report.platformRequests = platformRequests;
3207
3275
  return report;
3208
3276
  }
3209
3277
  /** The stub body, shared by the principal, connection, system and impersonation doors. */
@@ -4119,6 +4187,19 @@ export class CloudflareScopeHost {
4119
4187
  }));
4120
4188
  },
4121
4189
  assignRole: async (actor, assignment) => {
4190
+ // #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
4191
+ // At a scope node, read where the checker reads the role, against the scope class's own
4192
+ // reservations (it answers null when it has none). At the tenant node, against this host's.
4193
+ {
4194
+ const { tenantId, scopeId } = assignment.node;
4195
+ const refusal = scopeId
4196
+ ? await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform')
4197
+ : this.reservedKeys.size > 0
4198
+ ? await this.cp.getRole(tenantId, assignment.roleKey).then((role) => role && reservedRoleRefusal(role, this.reservedKeys, 'platform'))
4199
+ : null;
4200
+ if (refusal)
4201
+ throw substratError('forbidden', refusal);
4202
+ }
4122
4203
  const subject = `principal:${assignment.principalId}`;
4123
4204
  if (assignment.node.scopeId) {
4124
4205
  await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
@@ -4156,6 +4237,7 @@ export class CloudflareScopeHost {
4156
4237
  // trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
4157
4238
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
4158
4239
  const grant = capabilityGrant.parse(raw);
4240
+ await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
4159
4241
  await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
4160
4242
  await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
4161
4243
  // Tenant-level grant → changes the projected set. Scope-level + entity
@@ -4201,6 +4283,12 @@ export class CloudflareScopeHost {
4201
4283
  `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
4202
4284
  }
4203
4285
  }
4286
+ // #2430: a connection acts for the external provider holding its token, so it is never
4287
+ // given a key reserved to a system principal — refused before the directory record, so a
4288
+ // refused grant is gathered by no later provision. Against this host's reservations, and
4289
+ // the scope class's when the scope is served here; a delegated scope's deployment judges
4290
+ // its own at delivery (`connectorGrantLocal`). A grant recorded before stays.
4291
+ await this.refuseReservedGrant([grant.permission], this.connectorDelegation ? null : (grant.node.scopeId ?? null));
4204
4292
  // #592: the directory-side record FIRST, so a grant whose tuple delivery
4205
4293
  // fails below is still gathered by the next provision/reconcile — the
4206
4294
  // repair channel — instead of vanishing. The tuple stays the only thing
@@ -4349,6 +4437,7 @@ export class CloudflareScopeHost {
4349
4437
  // The org must exist in the node's tenant. A grant to a phantom org looks
4350
4438
  // applied, resolves for nobody, and still shows up in the permission diff.
4351
4439
  await requireOrg(node.tenantId, orgId);
4440
+ await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
4352
4441
  await writeGrant(`org:${orgId}`, permission, node, entity);
4353
4442
  await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
4354
4443
  if (!node.scopeId)
@@ -5107,7 +5196,9 @@ export class CloudflareScopeHost {
5107
5196
  : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
5108
5197
  // #2047: the org's own row, when the change joins or leaves one. The unit fills in its
5109
5198
  // `before`/`after` and writes it only for what it does — a join's carries the expiry.
5110
- orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined);
5199
+ orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined,
5200
+ // #2430: what this host's modules reserve, so the unit refuses an add of a role carrying it.
5201
+ [...this.reservedKeys]);
5111
5202
  // The tenant-level tuple, or its tombstone, reaches the projections.
5112
5203
  if (result.applied && (op === 'add' || result.changed))
5113
5204
  await this.fanOut(tenantId);
@@ -7467,6 +7558,10 @@ export class CloudflareScopeHost {
7467
7558
  */
7468
7559
  async provisionScopeLocal(input) {
7469
7560
  const carry = recordedOffFromWire(input);
7561
+ // #2430: refused before anything is written, rather than at the top-up after the projection.
7562
+ if (input.entityGrants)
7563
+ await this.refuseReservedShapes(input.entityGrants, input.scopeId);
7564
+ const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
7470
7565
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7471
7566
  const stub = this.scopeStub(input.scopeId);
7472
7567
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
@@ -7503,7 +7598,7 @@ export class CloudflareScopeHost {
7503
7598
  expires_at: null,
7504
7599
  lockout_reseat: true,
7505
7600
  },
7506
- ...this.declaredSeatsLocal(input.scopeId, input.connectionGrants),
7601
+ ...this.declaredSeatsLocal(input.scopeId, connections.seat),
7507
7602
  ],
7508
7603
  // #406: identity links, delivered by the platform with provisioning exactly as
7509
7604
  // entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
@@ -7538,8 +7633,31 @@ export class CloudflareScopeHost {
7538
7633
  ...(carry ? { switchedOff } : {}),
7539
7634
  ...(shapes ? { retirementsLeftOpen: shapes.retirementsLeftOpen ?? 0 } : {}),
7540
7635
  ...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
7636
+ ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
7541
7637
  };
7542
7638
  }
7639
+ /**
7640
+ * #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
7641
+ * connection acts for the external provider holding its token, so a grant naming a key reserved
7642
+ * to a system principal here (this host's reservations, or the scope class's) is never seated.
7643
+ * The directory may have recorded it before these checks existed, so it is skipped and reported
7644
+ * (in the answer and as a warn line), never a failed provision: one such grant must not stop a
7645
+ * promote or a reconcile seating everything else.
7646
+ */
7647
+ async seatableConnectionGrants(tenantId, scopeId, grants) {
7648
+ if (!grants?.length)
7649
+ return { seat: grants, skipped: [] };
7650
+ const permissions = [...new Set(grants.map((g) => g.permission))];
7651
+ const reserved = new Set([
7652
+ ...permissions.filter((p) => this.reservedKeys.has(p)),
7653
+ ...(await this.scopeStub(scopeId).reservedAmong(permissions)),
7654
+ ]);
7655
+ if (reserved.size === 0)
7656
+ return { seat: grants, skipped: [] };
7657
+ const skipped = grants.filter((g) => reserved.has(g.permission)).map((g) => ({ connectionId: g.connectionId, permission: g.permission }));
7658
+ console.warn(JSON.stringify(connectionGrantsSkippedLine(tenantId, scopeId, skipped)));
7659
+ return { seat: grants.filter((g) => !reserved.has(g.permission)), skipped };
7660
+ }
7543
7661
  /**
7544
7662
  * The tuples this deployment's code declares for one scope, seated by every provision and
7545
7663
  * reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
@@ -7583,9 +7701,13 @@ export class CloudflareScopeHost {
7583
7701
  if (!(await stub.servesTenant(input.tenantId)))
7584
7702
  throw unknownScopeForTenant(input.tenantId, input.scopeId);
7585
7703
  const carry = recordedOffFromWire(input);
7704
+ const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
7586
7705
  await this.migrateAndRecord(input.scopeId);
7587
- const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, input.connectionGrants), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
7588
- return carry ? { switchedOff } : {};
7706
+ const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
7707
+ return {
7708
+ ...(carry ? { switchedOff } : {}),
7709
+ ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
7710
+ };
7589
7711
  }
7590
7712
  /**
7591
7713
  * Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
@@ -7610,6 +7732,12 @@ export class CloudflareScopeHost {
7610
7732
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
7611
7733
  */
7612
7734
  async assignScopeRole(scopeId, principal, roleKey) {
7735
+ // #2430: a platform door, as `HostAdmin.assignRole` is — a role mixing reserved and other keys
7736
+ // is refused, against the scope class's own reservations and the role as the checker reads it
7737
+ // for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens without one).
7738
+ const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, 'platform');
7739
+ if (refusal)
7740
+ throw substratError('forbidden', refusal);
7613
7741
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
7614
7742
  }
7615
7743
  /**
@@ -7669,6 +7797,7 @@ export class CloudflareScopeHost {
7669
7797
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
7670
7798
  */
7671
7799
  async grantEntityLocal(scopeId, principal, permission, entity) {
7800
+ await this.refuseReservedGrant([permission], scopeId); // #2430
7672
7801
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
7673
7802
  null));
7674
7803
  }
@@ -7681,8 +7810,29 @@ export class CloudflareScopeHost {
7681
7810
  * like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
7682
7811
  */
7683
7812
  async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
7813
+ await this.refuseReservedGrant(permissions, scopeId); // #2430
7684
7814
  await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
7685
7815
  }
7816
+ /**
7817
+ * #2430: refuse a raw grant of a reserved key (`reservedGrantRefusal`): against this host's
7818
+ * reservations, and at a scope node against the scope class's own, which may name more.
7819
+ */
7820
+ async refuseReservedGrant(permissions, scopeId) {
7821
+ const refusal = reservedGrantRefusal(permissions, this.reservedKeys) ??
7822
+ (scopeId ? await this.scopeStub(scopeId).reservedGrantRefusalFor([...permissions]) : null);
7823
+ if (refusal)
7824
+ throw substratError('forbidden', refusal);
7825
+ }
7826
+ /**
7827
+ * #2430: refuse a reconcile of declared shapes when one holds a reserved key in `permissions`
7828
+ * (`reservedShapeRefusal`): against this host's reservations, then the scope class's own.
7829
+ */
7830
+ async refuseReservedShapes(shapes, scopeId) {
7831
+ const refusal = reservedShapeRefusal(shapes, this.reservedKeys) ??
7832
+ (await this.scopeStub(scopeId).reservedShapeRefusalFor(shapes.map((s) => ({ entityType: s.entityType, permissions: [...s.permissions] }))));
7833
+ if (refusal)
7834
+ throw substratError('forbidden', refusal);
7835
+ }
7686
7836
  /**
7687
7837
  * Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
7688
7838
  * passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
@@ -7694,6 +7844,7 @@ export class CloudflareScopeHost {
7694
7844
  */
7695
7845
  async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
7696
7846
  const limit = shapeTopUpBatch(batch);
7847
+ await this.refuseReservedShapes(shapes, scopeId); // #2430
7697
7848
  let toppedUp = 0;
7698
7849
  let retired = 0;
7699
7850
  let retirementsLeftOpen = 0;
@@ -7767,6 +7918,8 @@ export class CloudflareScopeHost {
7767
7918
  * while the tuple remains; `expiresAt` bounds the tuple itself.
7768
7919
  */
7769
7920
  async connectorGrantLocal(connectionId, scopeId, permission, expiresAt) {
7921
+ // #2430: against the reservations of the scope served here, as `grantToConnection` asks.
7922
+ await this.refuseReservedGrant([permission], scopeId);
7770
7923
  await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
7771
7924
  }
7772
7925
  /** #2045 (Codex r3): every switch mover here honours the fence — what `/internal/switch-fence` reports. */