@substrat-run/adapter-cloudflare 0.179.0 → 0.183.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, connectorsOfManifestJson, 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, connectionRevoke, 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, MODULE_OBJECTS_NOT_CARRIED, errorCodeOf, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, storeDriverDumpPageBytes, } from '@substrat-run/contracts';
4
+ import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, connectorsOfManifestJson, 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, boundedBindLinkMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, principalBindLinkInput, capabilityGrant, principalId, connectionGrant, connectionRevoke, 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, MODULE_OBJECTS_NOT_CARRIED, errorCodeOf, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, storeDriverDumpPageBytes, } 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, outboxSubscribersOf, pruneInputOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditedScopePermissionWrite, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, migratesFrom, servedFromRow, 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';
@@ -50,15 +50,18 @@ export const TENANT_UNRECORDED_PER_PASS = 50;
50
50
  */
51
51
  const unknownScopeForTenant = (tenantId, scopeId) => substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
52
52
  /**
53
- * #2016: the line a CP-less host writes each time it admits a pair to a scope with no tenant record
54
- * and no role rows — nothing to hold the pair against. Ids only.
53
+ * #2035: the line a CP-less host writes when a door refuses a scope whose tenant is not recorded —
54
+ * no receipt and no role rows. The caller gets the foreign pair's refusal unchanged, so this line is
55
+ * the only place an operator tells a scope that has not converged from a wrong-tenant request.
56
+ * Server-side only, and ids and the door's name only.
55
57
  */
56
- export const tenantUnrecordedLine = (tenantId, scopeId) => ({
57
- substrat: 'tenant-unrecorded',
58
+ const tenantUnrecordedRefusedLine = (tenantId, scopeId, door) => ({
59
+ substrat: 'tenant-unrecorded-refused',
58
60
  level: 'warn',
59
61
  tenantId,
60
62
  scopeId,
61
- message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
63
+ door,
64
+ message: 'refused a scope whose tenant is not recorded; a reconcile, provision or load naming its tenant records it',
62
65
  });
63
66
  /**
64
67
  * #2430: the line a host writes when a provision or reconcile skips delivered connection grants that
@@ -828,7 +831,7 @@ export class CloudflareScopeHost {
828
831
  async drainDue(tenantId, scopeId) {
829
832
  // Same lifecycle gate `getScope` applies (K-3): a suspended or archived scope
830
833
  // does not get its effects driven either.
831
- await this.assertLive(tenantId, scopeId);
834
+ await this.assertLive(tenantId, scopeId, 'drainDue');
832
835
  await this.migrateAndRecord(scopeId);
833
836
  // #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
834
837
  // records no invocation, which is what distinguishes it from the first attempt the
@@ -889,14 +892,14 @@ export class CloudflareScopeHost {
889
892
  this.attachmentExtractors, this.attachmentTextBounds);
890
893
  }
891
894
  async startJobRun(tenantId, scopeId, input) {
892
- await this.assertLive(tenantId, scopeId);
895
+ await this.assertLive(tenantId, scopeId, 'startJobRun');
893
896
  await this.migrateAndRecord(scopeId);
894
897
  return jobRunOf(await startJobRun(this.jobStore(scopeId), input, ulid, () => new Date().toISOString()));
895
898
  }
896
899
  async runDueJobs(tenantId, scopeId, options) {
897
900
  // Same lifecycle gate as `drainDue`: a suspended scope's runs wait rather than
898
901
  // advance, and an archived one's never move again.
899
- await this.assertLive(tenantId, scopeId);
902
+ await this.assertLive(tenantId, scopeId, 'runDueJobs');
900
903
  await this.migrateAndRecord(scopeId);
901
904
  // #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
902
905
  // deployment registers them, and none can shadow them. The backfill starts here, on
@@ -920,7 +923,7 @@ export class CloudflareScopeHost {
920
923
  });
921
924
  }
922
925
  async jobRuns(tenantId, scopeId, filter) {
923
- await this.validateScopeAccess(tenantId, scopeId);
926
+ await this.validateScopeAccess(tenantId, scopeId, 'jobRuns');
924
927
  await this.migrateAndRecord(scopeId);
925
928
  return (await this.jobStore(scopeId).list(filter ?? {})).map(jobRunOf);
926
929
  }
@@ -930,13 +933,13 @@ export class CloudflareScopeHost {
930
933
  // same context build as the in-process path, so the handler cannot tell which host
931
934
  // ran it. On a CP-less host `connectorContext` throws from the null control plane:
932
935
  // fail closed, exactly the hole routing exists to avoid.
933
- await this.assertLive(tenantId, scopeId);
936
+ await this.assertLive(tenantId, scopeId, 'dispatchConnector');
934
937
  // Built on a view bound to the event, so its admin rows carry it and nothing else the
935
938
  // host serves does (#2055).
936
939
  await handler(await this.causedByView(event.id).connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
937
940
  }
938
941
  async executorDeadLetters(tenantId, scopeId) {
939
- await this.validateScopeAccess(tenantId, scopeId);
942
+ await this.validateScopeAccess(tenantId, scopeId, 'executorDeadLetters');
940
943
  await this.migrateAndRecord(scopeId);
941
944
  return this.scopeStub(scopeId).executorDeadLetters();
942
945
  }
@@ -949,18 +952,18 @@ export class CloudflareScopeHost {
949
952
  return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
950
953
  }
951
954
  async listPlatformRequests(tenantId, scopeId) {
952
- await this.validateScopeAccess(tenantId, scopeId);
955
+ await this.validateScopeAccess(tenantId, scopeId, 'listPlatformRequests');
953
956
  await this.migrateAndRecord(scopeId);
954
957
  // Tolerant (#1588, #1637): an undecodable row comes back naming why, never throws for the list.
955
958
  return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
956
959
  }
957
960
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
958
- await this.validateScopeAccess(tenantId, scopeId);
961
+ await this.validateScopeAccess(tenantId, scopeId, 'listPlatformRequestHistory');
959
962
  await this.migrateAndRecord(scopeId);
960
963
  return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(platformRequestOf);
961
964
  }
962
965
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
963
- await this.validateScopeAccess(tenantId, scopeId);
966
+ await this.validateScopeAccess(tenantId, scopeId, 'settlePlatformRequest');
964
967
  await this.migrateAndRecord(scopeId);
965
968
  // #2102: an outcome event travels by the verb that writes it; every other settle keeps the
966
969
  // positional one, so it still reaches a DO class that predates the event.
@@ -1095,7 +1098,7 @@ export class CloudflareScopeHost {
1095
1098
  // Checked on this side too, as `HostAdmin.mintCapability` does: a typed refusal thrown
1096
1099
  // across the RPC arrives as a bare message.
1097
1100
  checkBecomeInput(input, new Date().toISOString());
1098
- await this.assertLive(tenantId, scopeId);
1101
+ await this.assertLive(tenantId, scopeId, 'mintCapabilityLocal');
1099
1102
  await this.migrateAndRecord(scopeId);
1100
1103
  return mintedCapability.parse(await this.scopeStub(scopeId).mintBecomeCapability(input, actor));
1101
1104
  }
@@ -1674,7 +1677,7 @@ export class CloudflareScopeHost {
1674
1677
  after = { scriptRef: last.scriptRef, moveId: last.moveId };
1675
1678
  }
1676
1679
  // The tenant's status too, as every door into a scope asks.
1677
- await this.assertLive(tenantId, scopeId);
1680
+ await this.assertLive(tenantId, scopeId, 'sweepOrphanAttachments');
1678
1681
  vouched = true;
1679
1682
  }
1680
1683
  else if (request.copiesSettled !== true) {
@@ -1993,18 +1996,18 @@ export class CloudflareScopeHost {
1993
1996
  // gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
1994
1997
  // in the same transaction); bytes go straight to the per-tenant R2 bucket through the
1995
1998
  // binding the vertical's worker resolved — never through the DO.
1996
- await this.assertLive(tenantId, scopeId);
1999
+ await this.assertLive(tenantId, scopeId, 'attachments');
1997
2000
  await this.migrateAndRecord(scopeId);
1998
2001
  const store = await this.resolveAttachmentStore(tenantId);
1999
2002
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
2000
2003
  }
2001
2004
  async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
2002
- await this.assertLive(tenantId, scopeId);
2005
+ await this.assertLive(tenantId, scopeId, 'inspectUploadLink');
2003
2006
  await this.migrateAndRecord(scopeId);
2004
2007
  return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId, target, idempotencyKey)).view;
2005
2008
  }
2006
2009
  async redeemUploadLink(tenantId, scopeId, secret, input) {
2007
- await this.assertLive(tenantId, scopeId);
2010
+ await this.assertLive(tenantId, scopeId, 'redeemUploadLink');
2008
2011
  await this.migrateAndRecord(scopeId);
2009
2012
  const stub = this.scopeStub(scopeId);
2010
2013
  // The target first (the kernel judges it before the state), so a wrong path never reads as
@@ -2057,7 +2060,7 @@ export class CloudflareScopeHost {
2057
2060
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
2058
2061
  `runs '${scope.vertical ?? 'none'}'`);
2059
2062
  }
2060
- await this.validateScopeAccess(conn.tenant_id, scopeId);
2063
+ await this.validateScopeAccess(conn.tenant_id, scopeId, 'getConnectorAttachments');
2061
2064
  // #574: same delegation as getConnectorScope. `upload` is the verb the reconcile
2062
2065
  // path needs (landing the sealed PDF) and `open` the one the outbound path needs
2063
2066
  // (sending the vertical's own document, #711). `list` and `remove` still fail
@@ -2458,7 +2461,7 @@ export class CloudflareScopeHost {
2458
2461
  async getScope(principal, tenantId, scopeId, options) {
2459
2462
  // Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
2460
2463
  // evaluated durably in the ControlPlaneDO. A throw propagates here.
2461
- await this.assertLive(tenantId, scopeId);
2464
+ await this.assertLive(tenantId, scopeId, 'getScope');
2462
2465
  await this.migrateAndRecord(scopeId);
2463
2466
  return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
2464
2467
  }
@@ -2495,7 +2498,7 @@ export class CloudflareScopeHost {
2495
2498
  throw substratError('not_found', `no scope store is bound for '${binding}'`);
2496
2499
  }
2497
2500
  const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2498
- await this.assertLive(tenantId, scopeId);
2501
+ await this.assertLive(tenantId, scopeId, 'getScopeStore');
2499
2502
  if (runner) {
2500
2503
  await this.scopeStoreLifecycle.start();
2501
2504
  return {
@@ -2587,7 +2590,7 @@ export class CloudflareScopeHost {
2587
2590
  await this.scopeRecordForRead(tenantId, scopeId);
2588
2591
  const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2589
2592
  if (this.cpLess)
2590
- await this.assertLive(tenantId, scopeId);
2593
+ await this.assertLive(tenantId, scopeId, 'readScopeStoreSizes');
2591
2594
  const sizes = [];
2592
2595
  for (const binding of bindings) {
2593
2596
  const { bytes } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.size');
@@ -2792,7 +2795,7 @@ export class CloudflareScopeHost {
2792
2795
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
2793
2796
  `runs '${scope.vertical ?? 'none'}'`);
2794
2797
  }
2795
- await this.validateScopeAccess(conn.tenant_id, scopeId);
2798
+ await this.validateScopeAccess(conn.tenant_id, scopeId, 'getConnectorScope');
2796
2799
  // #574: a scope served by ANOTHER deployment (the shared control plane running the
2797
2800
  // connector pass for a dispatch vertical) — the write-back rides the delegation
2798
2801
  // seam; migration is the serving deployment's business, exactly like provision.
@@ -2839,7 +2842,7 @@ export class CloudflareScopeHost {
2839
2842
  const record = await this.resolveImpersonation(session, tenantId, scopeId);
2840
2843
  // The same lifecycle gate the principal door applies: a suspended tenant
2841
2844
  // refuses a support session exactly as it refuses a user.
2842
- await this.assertLive(tenantId, scopeId);
2845
+ await this.assertLive(tenantId, scopeId, 'getImpersonatedScope');
2843
2846
  await this.migrateAndRecord(scopeId);
2844
2847
  return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
2845
2848
  }
@@ -2873,7 +2876,7 @@ export class CloudflareScopeHost {
2873
2876
  if (!this.moduleIds.has(moduleId)) {
2874
2877
  throw substratError('not_found', `module not registered on this host: ${moduleId}`);
2875
2878
  }
2876
- await this.assertLive(tenantId, scopeId);
2879
+ await this.assertLive(tenantId, scopeId, 'openSystemDoor');
2877
2880
  await this.migrateAndRecord(scopeId);
2878
2881
  return this.openDoor('system', moduleId, scopeId, gated, pass);
2879
2882
  }
@@ -3025,9 +3028,9 @@ export class CloudflareScopeHost {
3025
3028
  * capability row lives. The answer is re-parsed on this side of the RPC.
3026
3029
  */
3027
3030
  async exchangeCapability(tenantId, scopeId, secret, options) {
3028
- await this.assertLive(tenantId, scopeId);
3031
+ await this.assertLive(tenantId, scopeId, 'exchangeCapability');
3029
3032
  await this.migrateAndRecord(scopeId);
3030
- const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode);
3033
+ const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode, options?.mode === 'bind' ? principalId.parse(options.presenter) : undefined);
3031
3034
  return capabilityExchange.nullable().parse(outcome);
3032
3035
  }
3033
3036
  /**
@@ -3039,7 +3042,7 @@ export class CloudflareScopeHost {
3039
3042
  if (!plausibleSessionToken(sessionToken)) {
3040
3043
  throw substratError('unauthenticated', 'not a capability session token');
3041
3044
  }
3042
- await this.assertLive(tenantId, scopeId);
3045
+ await this.assertLive(tenantId, scopeId, 'getCapabilityScope');
3043
3046
  await this.migrateAndRecord(scopeId);
3044
3047
  const hash = await capabilityTokenHash(sessionToken);
3045
3048
  return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
@@ -3145,6 +3148,15 @@ export class CloudflareScopeHost {
3145
3148
  await this.scopeRoleGate(tenantId, scopeId, 'mintBecomeCapabilityBounded');
3146
3149
  return boundedBecomeMint.parse(await this.scopeStub(scopeId).mintBecomeCapabilityBoundedFor(tenantId, scopeId, principalId.parse(caller), principalBecomeCapabilityInput.parse(input)));
3147
3150
  }
3151
+ /**
3152
+ * A principal's `bind` link mint (#2817) — binding an existing member to a record, minted by
3153
+ * someone holding the binding's key on it. The bound (`bindLinkMintCheck`) and the write are one
3154
+ * ScopeDO task; a refusal writes nothing. Same gate as the scope-role verbs.
3155
+ */
3156
+ async mintBindLinkBounded(tenantId, scopeId, caller, input) {
3157
+ await this.scopeRoleGate(tenantId, scopeId, 'mintBindLinkBounded');
3158
+ return boundedBindLinkMint.parse(await this.scopeStub(scopeId).mintBindLinkBoundedFor(tenantId, scopeId, principalId.parse(caller), principalBindLinkInput.parse(input)));
3159
+ }
3148
3160
  /**
3149
3161
  * Revoke a `become` capability a principal minted (#1686) — what withdrawing a member invite
3150
3162
  * does to its link. `revoked` is false when it was already revoked or is not such a capability
@@ -3205,7 +3217,7 @@ export class CloudflareScopeHost {
3205
3217
  }
3206
3218
  this.assertServedHere(record, scopeId, verb);
3207
3219
  }
3208
- await this.assertLive(tenantId, scopeId);
3220
+ await this.assertLive(tenantId, scopeId, 'peerScopeGate');
3209
3221
  await this.migrateAndRecord(scopeId);
3210
3222
  return record;
3211
3223
  }
@@ -3235,7 +3247,7 @@ export class CloudflareScopeHost {
3235
3247
  if (!plausibleSessionToken(sessionToken)) {
3236
3248
  throw substratError('unauthenticated', 'not a capability session token');
3237
3249
  }
3238
- await this.assertLive(tenantId, scopeId);
3250
+ await this.assertLive(tenantId, scopeId, 'getCapabilityAttachments');
3239
3251
  await this.migrateAndRecord(scopeId);
3240
3252
  const store = await this.resolveAttachmentStore(tenantId);
3241
3253
  const capabilitySession = await capabilityTokenHash(sessionToken);
@@ -3342,7 +3354,7 @@ export class CloudflareScopeHost {
3342
3354
  if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
3343
3355
  return report;
3344
3356
  }
3345
- else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
3357
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, 'checkFreshness')) !== null) {
3346
3358
  // #2016: the pair is held to the scope's own record first, so a sweep roster entry whose
3347
3359
  // tenant disagrees with the scope is refused before the probe reads or writes anything.
3348
3360
  // #1713: a held scope emits nothing, so judging it stale would only raise a false alarm.
@@ -3395,7 +3407,7 @@ export class CloudflareScopeHost {
3395
3407
  if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
3396
3408
  return report;
3397
3409
  }
3398
- else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
3410
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, 'runDueSchedules')) !== null) {
3399
3411
  // #1713: the lifecycle the platform delivered holds the scope. Every schedule is
3400
3412
  // `skipped` and no cadence row moves, as under the kill switch, so a schedule that
3401
3413
  // came due meanwhile fires once on the first pass after the scope is live again.
@@ -3466,7 +3478,7 @@ export class CloudflareScopeHost {
3466
3478
  // read again before every later fire: the scope does not hold a directory lifecycle,
3467
3479
  // so a suspension after an earlier fire would otherwise not stop this one (#1713).
3468
3480
  if (door)
3469
- await this.assertLive(tenantId, scopeId);
3481
+ await this.assertLive(tenantId, scopeId, 'runDueSchedules');
3470
3482
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
3471
3483
  const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door, counted);
3472
3484
  if (schedule.purge) {
@@ -4031,7 +4043,7 @@ export class CloudflareScopeHost {
4031
4043
  // Without one, a hosted scope is refused `unavailable` rather than switched in the
4032
4044
  // placeholder namespace, where it would tombstone nothing a peer call reads.
4033
4045
  if (delegated)
4034
- await this.validateScopeAccess(tenantId, scopeId);
4046
+ await this.validateScopeAccess(tenantId, scopeId, 'switchSubject');
4035
4047
  else
4036
4048
  await this.peerScopeGate(tenantId, scopeId, action);
4037
4049
  }
@@ -4821,7 +4833,7 @@ export class CloudflareScopeHost {
4821
4833
  // a bare message, and the caller would lose `validation_failed`. One function, so the
4822
4834
  // two sides cannot disagree about what a valid mint is.
4823
4835
  checkBecomeInput(input, new Date().toISOString());
4824
- await this.validateScopeAccess(tenantId, scopeId);
4836
+ await this.validateScopeAccess(tenantId, scopeId, 'mintCapability');
4825
4837
  const rec = await this.capabilityScopeRecord(tenantId, scopeId);
4826
4838
  // A hosted refusal precedes intent; the local migrate happens inside the audited write.
4827
4839
  this.assertCapabilityScopeReach(rec, scopeId, 'mintCapability');
@@ -7053,7 +7065,9 @@ export class CloudflareScopeHost {
7053
7065
  * without the lifecycle half pays the one DO call the pair check needs. Null with a directory,
7054
7066
  * whose refusal already covered the lifecycle.
7055
7067
  */
7056
- async validateScopeAccess(tenantId, scopeId) {
7068
+ async validateScopeAccess(tenantId, scopeId,
7069
+ /** The host method the call came through, named in `tenantUnrecordedRefusedLine` only. */
7070
+ door) {
7057
7071
  const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
7058
7072
  if (refusal) {
7059
7073
  throw refusal.code
@@ -7063,14 +7077,15 @@ export class CloudflareScopeHost {
7063
7077
  if (!this.cpLess)
7064
7078
  return null;
7065
7079
  const { verdict, lifecycle } = await this.scopeStub(scopeId).admission(tenantId);
7066
- if (verdict === 'foreign')
7067
- throw unknownScopeForTenant(tenantId, scopeId);
7068
- // A scope with no tenant record and no role rows has nothing to hold the pair against, so it is
7069
- // let through — a compatibility state, not a steady one: the next reconcile (every push runs
7070
- // one against the scopes behind it, #1172) or lifecycle delivery records the directory's tenant.
7071
- // Logged on every admission, so an operator sees a scope that has not converged.
7080
+ // #2035: a scope with no tenant record and no role rows has nothing to hold the pair against, so
7081
+ // it is refused like a foreign one. The platform's paths record the tenant without passing this
7082
+ // door — a provision or reconcile (every push runs one, #1172), a restore or snapshot, a
7083
+ // lifecycle delivery to a scope holding data — so a scope converges rather than staying shut.
7084
+ // The caller meets the foreign pair's refusal, word for word; only the log tells them apart.
7072
7085
  if (verdict === 'unknown')
7073
- console.warn(JSON.stringify(tenantUnrecordedLine(tenantId, scopeId)));
7086
+ console.warn(JSON.stringify(tenantUnrecordedRefusedLine(tenantId, scopeId, door)));
7087
+ if (verdict === 'foreign' || verdict === 'unknown')
7088
+ throw unknownScopeForTenant(tenantId, scopeId);
7074
7089
  return lifecycle;
7075
7090
  }
7076
7091
  /**
@@ -7088,8 +7103,8 @@ export class CloudflareScopeHost {
7088
7103
  * The admin and diagnostic reads (dead letters, job runs, platform requests, delivered
7089
7104
  * grants) keep `validateScopeAccess` alone: an operator still reads a suspended scope.
7090
7105
  */
7091
- async assertLive(tenantId, scopeId) {
7092
- const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId), { tenantId, scopeId });
7106
+ async assertLive(tenantId, scopeId, door) {
7107
+ const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, door), { tenantId, scopeId });
7093
7108
  if (refusal)
7094
7109
  throw substratError('conflict', refusal, { reason: SCOPE_GATE_REASONS.notActive });
7095
7110
  }
@@ -7355,7 +7370,7 @@ export class CloudflareScopeHost {
7355
7370
  * directory's view stays on `HostAdmin.listConnectionGrants`.
7356
7371
  */
7357
7372
  async connectionGrantsInScope(tenantId, scopeId) {
7358
- await this.validateScopeAccess(tenantId, scopeId);
7373
+ await this.validateScopeAccess(tenantId, scopeId, 'connectionGrantsInScope');
7359
7374
  if (this.connectorDelegation) {
7360
7375
  if (!this.connectorDelegation.grants)
7361
7376
  throw new Error('connector grant inspection is not configured for the serving deployment');
@@ -7368,10 +7383,11 @@ export class CloudflareScopeHost {
7368
7383
  // CP-less shape); a scope with a live control plane has its tenant-level grants in
7369
7384
  // the directory instead, where the checker reads them from (`cp.tenantTuples`). Read
7370
7385
  // only the DO and a tenant-wide grant would be reported absent on exactly the
7371
- // deployments that have a control plane — the fleet — while being enforced.
7386
+ // deployments that have a control plane — the fleet — while being enforced. A CP-less
7387
+ // host has no directory to ask, and its DO already holds the projection (#2892).
7372
7388
  const rows = [
7373
7389
  ...(await this.scopeStub(scopeId).listConnectionGrants(now)),
7374
- ...(await this.cp.dumpTenantTuples(tenantId))
7390
+ ...(this.cpLess ? [] : await this.cp.dumpTenantTuples(tenantId))
7375
7391
  .filter((r) => r.subject.startsWith('connection:') &&
7376
7392
  r.relation.startsWith('granted:') &&
7377
7393
  r.revoked_at === null &&
@@ -7849,7 +7865,7 @@ export class CloudflareScopeHost {
7849
7865
  * count to the directory — so a scope whose first contact after a deploy is a
7850
7866
  * subscription does not go dark in the migration fleet view.
7851
7867
  */
7852
- await this.assertLive(tenantId, scopeId);
7868
+ await this.assertLive(tenantId, scopeId, 'subscribe');
7853
7869
  await this.migrateAndRecord(scopeId);
7854
7870
  // Asserted, not carried through from the client: the principal is the
7855
7871
  // vertical's own resolution of its session, and the tenant and scope are the
@@ -8394,17 +8410,25 @@ export class CloudflareScopeHost {
8394
8410
  * and a switched-off subject gets nothing. It can therefore only add what the running code
8395
8411
  * declares, and never takes anything away but a recorded OFF.
8396
8412
  *
8397
- * Only a scope already provisioned for `tenantId` is seated (`servesTenant`, K-3): this never
8398
- * provisions a scope, and never writes into another tenant's.
8413
+ * Only a scope that serves `tenantId` is seated (K-3): this never provisions a scope, and never
8414
+ * writes into another tenant's. Like a full reconcile, it records the platform's tenant on a scope
8415
+ * that holds no receipt yet (`seatsTenant`, #2035), so a push converges an ownerless vertical's
8416
+ * scopes too — every door refuses a scope whose tenant nothing recorded. The receipt lands in the
8417
+ * seat's own unit, after the recorded-off positions: until then every door still refuses the
8418
+ * scope, and a seat that fails records nothing.
8399
8419
  */
8400
8420
  async seatScopeLocal(input) {
8401
8421
  const stub = this.scopeStub(input.scopeId);
8402
- if (!(await stub.servesTenant(input.tenantId)))
8422
+ if (!(await stub.seatsTenant(input.tenantId)))
8403
8423
  throw unknownScopeForTenant(input.tenantId, input.scopeId);
8404
8424
  const carry = recordedOffFromWire(input);
8405
8425
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
8406
8426
  await this.migrateAndRecord(input.scopeId);
8407
- const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
8427
+ const seated = await stub.seatTuplesFor(input.tenantId, this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
8428
+ // Reaped or recorded for another tenant since the check: K-3's refusal, naming no other tenant.
8429
+ if ('refused' in seated)
8430
+ throw unknownScopeForTenant(input.tenantId, input.scopeId);
8431
+ const { switchedOff } = seated;
8408
8432
  return {
8409
8433
  ...(carry ? { switchedOff } : {}),
8410
8434
  ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
@@ -8592,7 +8616,7 @@ export class CloudflareScopeHost {
8592
8616
  // scope (#1713): a held scope's invoke and bytes doors refuse, as every other door does.
8593
8617
  /** Invoke ONE operation in this deployment as a CONNECTION (#574). */
8594
8618
  async connectorInvokeLocal(connectionId, tenantId, scopeId, operation, input) {
8595
- await this.assertLive(tenantId, scopeId); // #1713: a held scope runs no connector work either
8619
+ await this.assertLive(tenantId, scopeId, 'connectorInvokeLocal'); // #1713: a held scope runs no connector work either
8596
8620
  await this.migrateAndRecord(scopeId);
8597
8621
  return this.buildStub(tenantId, scopeId, undefined, connectionId).invoke(operation, input);
8598
8622
  }
@@ -8600,7 +8624,7 @@ export class CloudflareScopeHost {
8600
8624
  async connectorAttachmentUploadLocal(connectionId, tenantId, scopeId, upload) {
8601
8625
  // The lifecycle gate every attachments door takes (#1713), ahead of the bucket: a held
8602
8626
  // scope's provider bytes wait for the delivery's retry rather than land (#1995).
8603
- await this.assertLive(tenantId, scopeId);
8627
+ await this.assertLive(tenantId, scopeId, 'connectorAttachmentUploadLocal');
8604
8628
  await this.migrateAndRecord(scopeId);
8605
8629
  const store = await this.resolveAttachmentStore(tenantId);
8606
8630
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store).upload(upload);
@@ -8625,7 +8649,7 @@ export class CloudflareScopeHost {
8625
8649
  /** The attachment surface the platform's reads as a connection go through, behind their gates. */
8626
8650
  async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
8627
8651
  // Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
8628
- await this.assertLive(tenantId, scopeId);
8652
+ await this.assertLive(tenantId, scopeId, 'connectorReadSurface');
8629
8653
  await this.migrateAndRecord(scopeId);
8630
8654
  const store = await this.resolveAttachmentStore(tenantId);
8631
8655
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
@@ -8799,7 +8823,7 @@ export class CloudflareScopeHost {
8799
8823
  const delegation = rec.vertical !== null ? this.importCursorDelegation : undefined;
8800
8824
  if (!delegation)
8801
8825
  this.assertServedHere(rec, scopeId, 'moveImportCursor');
8802
- await this.validateScopeAccess(tenantId, scopeId);
8826
+ await this.validateScopeAccess(tenantId, scopeId, 'moveImportCursorAt');
8803
8827
  // #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
8804
8828
  // closes the lever's rows exactly as it does the kill switches'.
8805
8829
  const replayId = ulid();