@substrat-run/adapter-cloudflare 0.153.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) {
@@ -4127,6 +4187,19 @@ export class CloudflareScopeHost {
4127
4187
  }));
4128
4188
  },
4129
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
+ }
4130
4203
  const subject = `principal:${assignment.principalId}`;
4131
4204
  if (assignment.node.scopeId) {
4132
4205
  await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
@@ -4164,6 +4237,7 @@ export class CloudflareScopeHost {
4164
4237
  // trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
4165
4238
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
4166
4239
  const grant = capabilityGrant.parse(raw);
4240
+ await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
4167
4241
  await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
4168
4242
  await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
4169
4243
  // Tenant-level grant → changes the projected set. Scope-level + entity
@@ -4209,6 +4283,12 @@ export class CloudflareScopeHost {
4209
4283
  `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
4210
4284
  }
4211
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));
4212
4292
  // #592: the directory-side record FIRST, so a grant whose tuple delivery
4213
4293
  // fails below is still gathered by the next provision/reconcile — the
4214
4294
  // repair channel — instead of vanishing. The tuple stays the only thing
@@ -4357,6 +4437,7 @@ export class CloudflareScopeHost {
4357
4437
  // The org must exist in the node's tenant. A grant to a phantom org looks
4358
4438
  // applied, resolves for nobody, and still shows up in the permission diff.
4359
4439
  await requireOrg(node.tenantId, orgId);
4440
+ await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
4360
4441
  await writeGrant(`org:${orgId}`, permission, node, entity);
4361
4442
  await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
4362
4443
  if (!node.scopeId)
@@ -5115,7 +5196,9 @@ export class CloudflareScopeHost {
5115
5196
  : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
5116
5197
  // #2047: the org's own row, when the change joins or leaves one. The unit fills in its
5117
5198
  // `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);
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]);
5119
5202
  // The tenant-level tuple, or its tombstone, reaches the projections.
5120
5203
  if (result.applied && (op === 'add' || result.changed))
5121
5204
  await this.fanOut(tenantId);
@@ -7475,6 +7558,10 @@ export class CloudflareScopeHost {
7475
7558
  */
7476
7559
  async provisionScopeLocal(input) {
7477
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);
7478
7565
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7479
7566
  const stub = this.scopeStub(input.scopeId);
7480
7567
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
@@ -7511,7 +7598,7 @@ export class CloudflareScopeHost {
7511
7598
  expires_at: null,
7512
7599
  lockout_reseat: true,
7513
7600
  },
7514
- ...this.declaredSeatsLocal(input.scopeId, input.connectionGrants),
7601
+ ...this.declaredSeatsLocal(input.scopeId, connections.seat),
7515
7602
  ],
7516
7603
  // #406: identity links, delivered by the platform with provisioning exactly as
7517
7604
  // entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
@@ -7546,8 +7633,31 @@ export class CloudflareScopeHost {
7546
7633
  ...(carry ? { switchedOff } : {}),
7547
7634
  ...(shapes ? { retirementsLeftOpen: shapes.retirementsLeftOpen ?? 0 } : {}),
7548
7635
  ...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
7636
+ ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
7549
7637
  };
7550
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
+ }
7551
7661
  /**
7552
7662
  * The tuples this deployment's code declares for one scope, seated by every provision and
7553
7663
  * reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
@@ -7591,9 +7701,13 @@ export class CloudflareScopeHost {
7591
7701
  if (!(await stub.servesTenant(input.tenantId)))
7592
7702
  throw unknownScopeForTenant(input.tenantId, input.scopeId);
7593
7703
  const carry = recordedOffFromWire(input);
7704
+ const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
7594
7705
  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 } : {};
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
+ };
7597
7711
  }
7598
7712
  /**
7599
7713
  * Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
@@ -7618,6 +7732,12 @@ export class CloudflareScopeHost {
7618
7732
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
7619
7733
  */
7620
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);
7621
7741
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
7622
7742
  }
7623
7743
  /**
@@ -7677,6 +7797,7 @@ export class CloudflareScopeHost {
7677
7797
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
7678
7798
  */
7679
7799
  async grantEntityLocal(scopeId, principal, permission, entity) {
7800
+ await this.refuseReservedGrant([permission], scopeId); // #2430
7680
7801
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
7681
7802
  null));
7682
7803
  }
@@ -7689,8 +7810,29 @@ export class CloudflareScopeHost {
7689
7810
  * like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
7690
7811
  */
7691
7812
  async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
7813
+ await this.refuseReservedGrant(permissions, scopeId); // #2430
7692
7814
  await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
7693
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
+ }
7694
7836
  /**
7695
7837
  * Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
7696
7838
  * passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
@@ -7702,6 +7844,7 @@ export class CloudflareScopeHost {
7702
7844
  */
7703
7845
  async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
7704
7846
  const limit = shapeTopUpBatch(batch);
7847
+ await this.refuseReservedShapes(shapes, scopeId); // #2430
7705
7848
  let toppedUp = 0;
7706
7849
  let retired = 0;
7707
7850
  let retirementsLeftOpen = 0;
@@ -7775,6 +7918,8 @@ export class CloudflareScopeHost {
7775
7918
  * while the tuple remains; `expiresAt` bounds the tuple itself.
7776
7919
  */
7777
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);
7778
7923
  await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
7779
7924
  }
7780
7925
  /** #2045 (Codex r3): every switch mover here honours the fence — what `/internal/switch-fence` reports. */