@substrat-run/adapter-cloudflare 0.161.0 → 0.162.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,7 +1,7 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
4
+ import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, errorCodeOf, 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
6
  import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
7
7
  import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
@@ -75,6 +75,10 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
75
75
  * classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
76
76
  * it, so a primary can be marked only by a request that misstates its lineage, never by default.
77
77
  */
78
+ /** A vertical's role definitions as a CP-less projection writes them (`applyProjection`'s rows). */
79
+ function projectedRoleRows(roles) {
80
+ return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
81
+ }
78
82
  function assertCopyLineage(lineage) {
79
83
  if (isPrimaryScope(lineage)) {
80
84
  throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
@@ -1124,7 +1128,7 @@ export class CloudflareScopeHost {
1124
1128
  * #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
1125
1129
  * the load's own transaction, and a store provisioned for another tenant refuses the load. */
1126
1130
  opts) {
1127
- const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
1131
+ const out = await this.loadLocal(scopeId, opts, (load, repair) => repair ? this.scopeStub(scopeId).importDumpJudged(tables, scopeId, load, repair) : this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
1128
1132
  return { ...out, tables: tables.length };
1129
1133
  }
1130
1134
  /**
@@ -1146,7 +1150,9 @@ export class CloudflareScopeHost {
1146
1150
  }
1147
1151
  /** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
1148
1152
  async restoreStagedScopeLocal(scopeId, stageId, opts) {
1149
- return this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
1153
+ return this.loadLocal(scopeId, opts, (load, repair) => repair
1154
+ ? this.scopeStub(scopeId).commitStagedDumpJudged(stageId, scopeId, load, repair)
1155
+ : this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
1150
1156
  }
1151
1157
  /** Drop the dump staged under `stageId` (#2450), if it is still there. */
1152
1158
  async discardStagedScopeLocal(scopeId, stageId) {
@@ -1156,6 +1162,11 @@ export class CloudflareScopeHost {
1156
1162
  async loadLocal(scopeId, opts, run) {
1157
1163
  if (opts?.markCopy)
1158
1164
  assertCopyLineage(opts.markCopy);
1165
+ if (opts?.roles && !opts.tenantId) {
1166
+ throw substratError('validation_failed', 'restore refused: a role repair needs the tenant it is projected for; nothing was loaded');
1167
+ }
1168
+ // #2531: the repair the load judges and projects in its own transaction.
1169
+ const repair = opts?.roles && opts.tenantId ? { tenantId: opts.tenantId, roles: projectedRoleRows(opts.roles), judge: this.roleJudge() } : undefined;
1159
1170
  // #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
1160
1171
  await this.fenceOrphanSweep(scopeId, opts?.tenantId);
1161
1172
  const carry = opts ? recordedOffFromWire(opts) : undefined;
@@ -1173,14 +1184,22 @@ export class CloudflareScopeHost {
1173
1184
  ...load,
1174
1185
  ...(opts?.expect ? { expect: opts.expect } : {}),
1175
1186
  ...(opts?.fence ? { fence: opts.fence } : {}),
1176
- });
1187
+ }, repair);
1177
1188
  if (out.refused === false) {
1178
- return { tables: out.tables ?? 0, ...(carry ? { switchedOff: out.switchedOff } : {}) };
1189
+ return {
1190
+ tables: out.tables ?? 0,
1191
+ ...(carry ? { switchedOff: out.switchedOff } : {}),
1192
+ // Only a load that ran the judged method can say the repair landed in it.
1193
+ ...(repair && out.rolesProjected === true ? { rolesProjected: true } : {}),
1194
+ };
1179
1195
  }
1180
1196
  if (out.refused === 'kept')
1181
1197
  throw substratError('conflict', KEPT_COPY_REFUSAL);
1182
1198
  if (out.refused === 'tenant')
1183
1199
  throw substratError('conflict', out.message);
1200
+ // #2531: the repair's role set, refused on the seats the load wrote; the whole load rolled back.
1201
+ if (out.refused === 'roles')
1202
+ throw substratError('conflict', `the vertical's roles cannot be defined here: ${out.message}`);
1184
1203
  if (out.refused === 'lapsed')
1185
1204
  throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
1186
1205
  // A retry of a load that already committed (its answer was lost on the way back) is
@@ -1311,9 +1330,33 @@ export class CloudflareScopeHost {
1311
1330
  * safe; scope-level tuples (the restored grants) are never touched.
1312
1331
  */
1313
1332
  async projectRolesLocal(tenantId, scopeId, roles) {
1314
- // #2447: the role set is defined here, so it is judged here, before anything is projected.
1333
+ // #2447: the role set is defined here, so it is judged here, before anything is projected, and
1334
+ // (#2531) again inside the projection's unit, on the seats the scope holds as it writes.
1315
1335
  await this.refuseReservedRoleSet(roles, tenantId, scopeId);
1316
- unwrapReply(await this.scopeStub(scopeId).applyProjectionReply(tenantId, roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' })), []));
1336
+ await this.projectJudged(tenantId, scopeId, projectedRoleRows(roles), []);
1337
+ }
1338
+ /**
1339
+ * #2531: what this host's own modules reserve and declare, for a unit that judges a role set in the
1340
+ * scope (`RoleJudge`); the scope class's are joined there.
1341
+ */
1342
+ roleJudge() {
1343
+ return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
1344
+ }
1345
+ /**
1346
+ * #2531: `applyProjectionReply` with the role set judged in its unit (`applyJudgedProjectionReply`).
1347
+ * A refusal there answers with the staff-facing prefix `refuseReservedRoleSet` gives a pre-check's.
1348
+ * A ScopeDO from before the judged method refuses the call itself, with nothing written.
1349
+ */
1350
+ async projectJudged(tenantId, scopeId, ...rest) {
1351
+ try {
1352
+ return unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, ...rest));
1353
+ }
1354
+ catch (e) {
1355
+ if (errorCodeOf(e) === 'forbidden') {
1356
+ throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${e.message}`);
1357
+ }
1358
+ throw e;
1359
+ }
1317
1360
  }
1318
1361
  /**
1319
1362
  * Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
@@ -3762,7 +3805,9 @@ export class CloudflareScopeHost {
3762
3805
  await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
3763
3806
  }
3764
3807
  else {
3765
- await this.cp.writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt ?? null);
3808
+ // #2526: a tenant-level grant is a directory tuple, written by the ControlPlaneDO in the
3809
+ // unit that writes its admin row — never through here.
3810
+ throw new Error('writeGrant: a tenant-level grant is written with its row in the ControlPlaneDO');
3766
3811
  }
3767
3812
  };
3768
3813
  /**
@@ -4294,11 +4339,11 @@ export class CloudflareScopeHost {
4294
4339
  // `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
4295
4340
  // before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
4296
4341
  // than being misread.
4297
- const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
4342
+ // #2526: through `reply`, with the row, which the DO writes in the definition's unit, its
4343
+ // `before` the role it read there. A DO from before #2526 does not list the method, and refuses.
4344
+ const defined = await this.directory('defineRoleOrRefusal', tenantId, parsed, context.reservation, context.seatedInScopes, this.adminEntry(actor, 'defineRole', { tenantId }, null, parsed));
4298
4345
  if ('refused' in defined)
4299
4346
  throw substratError('forbidden', defined.refused);
4300
- const before = defined.before;
4301
- await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
4302
4347
  await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
4303
4348
  },
4304
4349
  listRoles: async (actor, filter) => {
@@ -4323,29 +4368,22 @@ export class CloudflareScopeHost {
4323
4368
  assignRole: async (actor, assignment) => {
4324
4369
  // #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
4325
4370
  // At a scope node, read where the checker reads the role, against the scope class's own
4326
- // reservations (it answers null when it has none). At the tenant node, against this host's.
4327
- {
4328
- const { tenantId, scopeId } = assignment.node;
4329
- const refusal = scopeId
4330
- ? await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform')
4331
- : this.reservedKeys.size > 0
4332
- ? await this.cp.getRole(tenantId, assignment.roleKey).then((role) => role && reservedRoleRefusal(role, this.reservedKeys, 'platform'))
4333
- : null;
4334
- if (refusal)
4335
- throw substratError('forbidden', refusal);
4336
- }
4337
- const subject = `principal:${assignment.principalId}`;
4338
- if (assignment.node.scopeId) {
4339
- await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
4340
- }
4341
- else {
4342
- await this.cp.writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`, null);
4371
+ // reservations (it answers null when it has none). At the tenant node, against this host's,
4372
+ // in the ControlPlaneDO's unit that writes the tuple and its row (#2526).
4373
+ const { tenantId, scopeId } = assignment.node;
4374
+ if (!scopeId) {
4375
+ const assigned = await this.directory('assignTenantRole', tenantId, assignment.principalId, assignment.roleKey, [...this.reservedKeys], this.adminEntry(actor, 'assignRole', { tenantId, scopeId }, null, assignment));
4376
+ if (assigned.refused)
4377
+ throw substratError('forbidden', assigned.refused);
4378
+ // A tenant-level assignment changes the projected set and must fan out.
4379
+ await this.fanOut(tenantId);
4380
+ return;
4343
4381
  }
4344
- await this.recordAdmin(actor, 'assignRole', { tenantId: assignment.node.tenantId, scopeId: assignment.node.scopeId }, null, assignment);
4345
- // A scope-level assignment writes a scope tuple (already local); only a
4346
- // tenant-level one changes the projected set and must fan out.
4347
- if (!assignment.node.scopeId)
4348
- await this.fanOut(assignment.node.tenantId);
4382
+ const refusal = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform');
4383
+ if (refusal)
4384
+ throw substratError('forbidden', refusal);
4385
+ await this.writeScopeTuple(scopeId, `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeId}`, null);
4386
+ await this.recordAdmin(actor, 'assignRole', { tenantId, scopeId }, null, assignment);
4349
4387
  },
4350
4388
  unassignRole: async (actor, assignment) => {
4351
4389
  // Tombstone (K-21) — the checker skips revoked rows. A no-op returns false so
@@ -4372,12 +4410,18 @@ export class CloudflareScopeHost {
4372
4410
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
4373
4411
  const grant = capabilityGrant.parse(raw);
4374
4412
  await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
4375
- await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
4376
- await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
4377
- // Tenant-level grant → changes the projected set. Scope-level + entity
4378
- // grants write scope tuples (already local), so they need no fan-out.
4379
- if (!grant.node.scopeId)
4413
+ const subject = `principal:${grant.principalId}`;
4414
+ const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
4415
+ if (!grant.node.scopeId && !grant.entity) {
4416
+ // #2526: a directory tuple, written with its row in one ControlPlaneDO unit. Tenant-level
4417
+ // → it changes the projected set, so it fans out.
4418
+ await this.directory('writeTenantTuple', grant.node.tenantId, subject, `granted:${grant.permission}`, `tenant:${grant.node.tenantId}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grant', target, null, grant));
4380
4419
  await this.fanOut(grant.node.tenantId);
4420
+ return;
4421
+ }
4422
+ // Scope-level and entity grants write scope tuples (already local): no fan-out.
4423
+ await writeGrant(subject, grant.permission, grant.node, grant.entity, grant.expiresAt);
4424
+ await this.recordAdmin(actor, 'grant', target, null, grant);
4381
4425
  },
4382
4426
  grantEntityShape: async (actor, grant) => {
4383
4427
  await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
@@ -4427,27 +4471,37 @@ export class CloudflareScopeHost {
4427
4471
  // fails below is still gathered by the next provision/reconcile — the
4428
4472
  // repair channel — instead of vanishing. The tuple stays the only thing
4429
4473
  // the permission checker reads; this row is what the platform gathers
4430
- // from so scopes provisioned AFTER the grant receive it too.
4431
- await this.cp.recordConnectionGrant({
4474
+ // from so scopes provisioned AFTER the grant receive it too. #2526: the
4475
+ // record, the checks above made again, the admin row and — at the tenant
4476
+ // node — the tuple are one ControlPlaneDO unit.
4477
+ const { vertical } = await this.directory('grantConnectionInDirectory', {
4432
4478
  connectionId: grant.connectionId,
4433
4479
  tenantId: grant.node.tenantId,
4434
- vertical: conn.vertical,
4435
4480
  permission: grant.permission,
4436
4481
  scopeId: grant.node.scopeId ?? null,
4437
4482
  expiresAt: grant.expiresAt ?? null,
4438
4483
  grantedBy: grant.grantedBy,
4439
4484
  grantedAt: new Date().toISOString(),
4440
- });
4485
+ }, this.adminEntry(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
4486
+ connectionId: grant.connectionId,
4487
+ provider: conn.provider,
4488
+ permission: grant.permission,
4489
+ node: grant.node,
4490
+ }));
4491
+ if (!grant.node.scopeId) {
4492
+ // A tenant tuple changes the projected set, as its siblings' do: it fans out.
4493
+ await this.fanOut(grant.node.tenantId);
4494
+ return;
4495
+ }
4441
4496
  // #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
4442
4497
  // deployment serving it — for the shared control plane that is the vertical's
4443
- // dispatch script, so the tuple write rides the delegation seam. Tenant-level
4444
- // grants stay directory-side either way.
4445
- if (this.connectorDelegation && grant.node.scopeId) {
4498
+ // dispatch script, so the tuple write rides the delegation seam.
4499
+ if (this.connectorDelegation) {
4446
4500
  await this.connectorDelegation.grant({
4447
4501
  connectionId: grant.connectionId,
4448
4502
  tenantId: grant.node.tenantId,
4449
4503
  scopeId: grant.node.scopeId,
4450
- vertical: conn.vertical,
4504
+ vertical,
4451
4505
  permission: grant.permission,
4452
4506
  expiresAt: grant.expiresAt,
4453
4507
  });
@@ -4455,17 +4509,13 @@ export class CloudflareScopeHost {
4455
4509
  else {
4456
4510
  await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
4457
4511
  }
4458
- await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
4459
- connectionId: grant.connectionId,
4460
- provider: conn.provider,
4461
- permission: grant.permission,
4462
- node: grant.node,
4463
- });
4464
4512
  },
4465
4513
  grantToSystem: async (actor, raw) => {
4466
4514
  // The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
4467
4515
  // module, one permission; tombstones on revoke; shows in the permission diff.
4468
4516
  const grant = systemGrant.parse(raw);
4517
+ const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
4518
+ const after = { moduleId: grant.moduleId, permission: grant.permission, node: grant.node };
4469
4519
  if (grant.node.scopeId) {
4470
4520
  // #1666: refused while the module is switched off on this scope — checked and
4471
4521
  // written in one DO unit. Restore is the lever; a grant is not.
@@ -4473,18 +4523,17 @@ export class CloudflareScopeHost {
4473
4523
  if (!written) {
4474
4524
  throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
4475
4525
  }
4526
+ await this.recordAdmin(actor, 'grantToSystem', target, null, after);
4527
+ return;
4476
4528
  }
4477
- else {
4478
- // #1743: refused while the record holds the module off on any scope — checked and
4479
- // written in one control-plane call, where the record and the tenant tuple both live.
4480
- const off = await this.cp.writeTenantSystemGrant(grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null);
4481
- if (off.length > 0) {
4482
- throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
4483
- }
4529
+ // #1743: refused while the record holds the module off on any scope — checked and
4530
+ // written in one control-plane call, where the record and the tenant tuple both live,
4531
+ // and (#2526) the admin row with them.
4532
+ const off = await this.directory('writeTenantSystemGrant', grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grantToSystem', target, null, after));
4533
+ if (off.length > 0) {
4534
+ throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
4484
4535
  }
4485
- await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
4486
- if (!grant.node.scopeId)
4487
- await this.fanOut(grant.node.tenantId);
4536
+ await this.fanOut(grant.node.tenantId);
4488
4537
  },
4489
4538
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4490
4539
  // whole rule, shared with the pure adapter; this is the directory check, the reach
@@ -4572,10 +4621,16 @@ export class CloudflareScopeHost {
4572
4621
  // applied, resolves for nobody, and still shows up in the permission diff.
4573
4622
  await requireOrg(node.tenantId, orgId);
4574
4623
  await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
4575
- await writeGrant(`org:${orgId}`, permission, node, entity);
4576
- await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
4577
- if (!node.scopeId)
4624
+ const target = { tenantId: node.tenantId, scopeId: node.scopeId };
4625
+ const after = { orgId, permission, node, entity };
4626
+ if (!node.scopeId && !entity) {
4627
+ // #2526: the org checked again, the tuple and its row, in one ControlPlaneDO unit.
4628
+ await this.directory('writeOrgTenantTuple', node.tenantId, orgId, `org:${orgId}`, `granted:${permission}`, `tenant:${node.tenantId}`, null, this.adminEntry(actor, 'grantToOrg', target, null, after));
4578
4629
  await this.fanOut(node.tenantId);
4630
+ return;
4631
+ }
4632
+ await writeGrant(`org:${orgId}`, permission, node, entity);
4633
+ await this.recordAdmin(actor, 'grantToOrg', target, null, after);
4579
4634
  },
4580
4635
  // -- vertical + version registry (#31) ---------------------------------
4581
4636
  // -- the hostname map (K-26) -------------------------------------------
@@ -5265,10 +5320,8 @@ export class CloudflareScopeHost {
5265
5320
  },
5266
5321
  createOrg: async (actor, input) => {
5267
5322
  const parsed = createOrgInput.parse(input);
5268
- const created = await this.directory('createOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
5269
- if (!created)
5270
- return; // idempotent, and a no-op is not audited
5271
- await this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
5323
+ // #2526: the org and its row in one unit; an org that exists already writes neither.
5324
+ await this.directory('insertOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString(), this.adminEntry(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed));
5272
5325
  },
5273
5326
  listOrgs: async (actor, tenantId) => {
5274
5327
  const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
@@ -5281,10 +5334,9 @@ export class CloudflareScopeHost {
5281
5334
  return r ? mapOrg(r) : undefined;
5282
5335
  },
5283
5336
  addMember: async (actor, tenantId, principal, orgId, opts) => {
5284
- await requireOrg(tenantId, orgId);
5285
5337
  const expiresAt = opts?.expiresAt ?? null;
5286
- await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
5287
- await this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
5338
+ // #2526: the org's check, the tuple and its row, in one ControlPlaneDO unit.
5339
+ await this.directory('writeOrgTenantTuple', tenantId, orgId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt, this.adminEntry(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt)));
5288
5340
  await this.fanOut(tenantId); // membership is a tenant-level tuple
5289
5341
  },
5290
5342
  applyMembership: async (actor, change) => {
@@ -7690,9 +7742,11 @@ export class CloudflareScopeHost {
7690
7742
  throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
7691
7743
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
7692
7744
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7693
- const stub = this.scopeStub(input.scopeId);
7694
7745
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
7695
- const switchedOff = unwrapReply(await stub.applyProjectionReply(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
7746
+ // #2531: judged again in the projection's own unit, on the roles and seats the scope holds as it
7747
+ // writes, so a seat made since the pre-check above is seen. The module migrations above have
7748
+ // already run when that refuses; they are this host's own setup, and the projection writes nothing.
7749
+ const switchedOff = await this.projectJudged(input.tenantId, input.scopeId, projectedRoleRows(input.roles), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
7696
7750
  // Only PROJECT (and thereby switch on strict enforcement) when the platform actually
7697
7751
  // supplies entitlements. Omitting them leaves the scope un-projected and trusting-
7698
7752
  // upstream, so a vertical whose provision path predates #304 is never denied — it
@@ -7752,7 +7806,7 @@ export class CloudflareScopeHost {
7752
7806
  : undefined,
7753
7807
  // #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
7754
7808
  // same unit, on the scope this call provisions — the one the seat's tuples name.
7755
- carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
7809
+ carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`));
7756
7810
  const shapes = input.entityGrants
7757
7811
  ? await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants)
7758
7812
  : null;
@@ -7831,7 +7885,8 @@ export class CloudflareScopeHost {
7831
7885
  * which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
7832
7886
  * roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
7833
7887
  * staged restore has not (its pages are already in the scope), so it is judged as a set alone
7834
- * here, and `projectRolesLocal` judges again after the import, against what the dump brought.
7888
+ * here. The load given the vertical's `roles` judges again in its own transaction, against what
7889
+ * the dump brought (#2531), and `projectRolesLocal` does in its unit where the load did not.
7835
7890
  */
7836
7891
  async roleSetRefusalLocal(scopeId, roles, dump) {
7837
7892
  const here = await this.scopeStub(scopeId).reservationHere();
@@ -7875,9 +7930,9 @@ export class CloudflareScopeHost {
7875
7930
  * and the roles the scope stores for the tenant now with their seats there. Answers the reserved
7876
7931
  * keys it judged with, for the seats the same call makes.
7877
7932
  *
7878
- * A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
7879
- * (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
7880
- * wide, and a reconcile runs again every pass.
7933
+ * The scope state is read in one RPC (`roleStateHere`) and written in another, so this is the
7934
+ * first check, for its detailed wording; the write judges the set again inside its own unit
7935
+ * (`projectJudged`, #2531), where a seat made between the two is seen.
7881
7936
  */
7882
7937
  async refuseReservedRoleSet(roles, tenantId, scopeId) {
7883
7938
  const stub = this.scopeStub(scopeId);