@substrat-run/adapter-cloudflare 0.160.0 → 0.161.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, 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';
5
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
6
- import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, isSizedByteStream, 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, 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, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
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';
8
8
  import { assertWireAuthFilter, wireAuthAccessTarget, 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';
@@ -349,11 +349,11 @@ const DOOR_WORDS = {
349
349
  };
350
350
  /** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
351
351
  const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key));
352
- /** #2541: a vertical-side size read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
353
- function heldOrRefused(scopeId, size) {
354
- if (size === null)
352
+ /** #2541, #2544: a vertical-side read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
353
+ function heldOrRefused(scopeId, answer) {
354
+ if (answer === null)
355
355
  throw substratError('conflict', scopeNotHeldRefusal(scopeId));
356
- return size;
356
+ return answer;
357
357
  }
358
358
  export class CloudflareScopeHost {
359
359
  admin;
@@ -578,7 +578,8 @@ export class CloudflareScopeHost {
578
578
  grants: async () => (await this.connectionGrantsInScope(tenantId, scopeId))
579
579
  .filter((g) => g.connectionId === open.id)
580
580
  .map((g) => g.permission),
581
- openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId, { eventId }).then((a) => a.open(attachmentId)),
581
+ openAttachment: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).open(attachmentId),
582
+ openAttachmentStream: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).openStream(attachmentId),
582
583
  // #687: open a cell the scope sealed TO this connection. The keyId-indexed
583
584
  // map goes in, never out — a connector receives the plaintext of one cell,
584
585
  // not a private key it could mislay. Retired keys are in the map too, so a
@@ -1005,14 +1006,29 @@ export class CloudflareScopeHost {
1005
1006
  * gate (the caller is the control plane, which did the K-3 check + audit on its side).
1006
1007
  */
1007
1008
  async introspectScopeTables(scopeId) {
1008
- return this.scopeStub(scopeId).introspectTables();
1009
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTablesIfHeld());
1009
1010
  }
1011
+ /**
1012
+ * The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope the
1013
+ * deployment does not hold, as the reads above are, never answered as an empty store. The carry
1014
+ * and its cleanup read a wiped copy's tombstone, which is not held by design: they use
1015
+ * `readTombstoneTable`.
1016
+ */
1010
1017
  async introspectScopeTable(scopeId, input) {
1018
+ const reply = heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTableIfHeld(input.table, input.limit, input.offset));
1019
+ return unwrapReply(reply);
1020
+ }
1021
+ /**
1022
+ * A table of a store this deployment may no longer HOLD, for the carry and its cleanup, which ask
1023
+ * a wiped copy whether it carries the `carried_away` tombstone (#2563). Not guarded: a wiped copy
1024
+ * answers nothing else. Not for a screen or a console; those use `introspectScopeTable`.
1025
+ */
1026
+ async readTombstoneTable(scopeId, input) {
1011
1027
  return unwrapReply(await this.scopeStub(scopeId).introspectTableReply(input.table, input.limit, input.offset));
1012
1028
  }
1013
- /** The SQL console's CP-less path (#219) — same trust line as the pair above. */
1029
+ /** The SQL console's CP-less path (#219): guarded as the table page is (#2563). */
1014
1030
  async introspectScopeQuery(scopeId, input) {
1015
- return unwrapReply(await this.scopeStub(scopeId).introspectQueryReply(input.sql));
1031
+ return unwrapReply(heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectQueryIfHeld(input.sql)));
1016
1032
  }
1017
1033
  /**
1018
1034
  * The K-35 denial log's CP-less path (#867) — same trust line as the reads above.
@@ -1333,39 +1349,39 @@ export class CloudflareScopeHost {
1333
1349
  }
1334
1350
  /** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
1335
1351
  async facetEventsLocal(scopeId, input) {
1336
- return this.scopeStub(scopeId).facetEvents(input);
1352
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).facetEventsIfHeld(input));
1337
1353
  }
1338
1354
  /** What one event set off (#1237) on this host's own scope — the vertical-host read. */
1339
1355
  async eventEffectsLocal(scopeId, input) {
1340
- return this.scopeStub(scopeId).eventEffects(input);
1356
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventEffectsIfHeld(input));
1341
1357
  }
1342
1358
  /** Everything one call emitted (#1237) on this host's own scope — the vertical-host read. */
1343
1359
  async invocationEventsLocal(scopeId, input) {
1344
- return this.scopeStub(scopeId).invocationEvents(input);
1360
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).invocationEventsIfHeld(input));
1345
1361
  }
1346
1362
  /** Every delivery that gave up (#1525) on this host's own scope — the vertical-host read. */
1347
1363
  async deadLettersLocal(scopeId, input) {
1348
- return this.scopeStub(scopeId).deadLetters(input);
1364
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).deadLettersIfHeld(input));
1349
1365
  }
1350
1366
  /** One entity's lifecycle replayed (#1744) on this host's own scope — the vertical-host read. */
1351
1367
  async lifecycleFlowLocal(scopeId, input) {
1352
- return this.scopeStub(scopeId).lifecycleFlow(input);
1368
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).lifecycleFlowIfHeld(input));
1353
1369
  }
1354
1370
  /** Business volumes per bucket (#1750) on this host's own scope — the vertical-host read. */
1355
1371
  async operationSeriesLocal(scopeId, input) {
1356
- return this.scopeStub(scopeId).operationSeries(input);
1372
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).operationSeriesIfHeld(input));
1357
1373
  }
1358
1374
  /** One event's causal chain (#1237) on this host's own scope — the vertical-host read. */
1359
1375
  async eventCauseLocal(scopeId, input) {
1360
- return this.scopeStub(scopeId).eventCause(input);
1376
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventCauseIfHeld(input));
1361
1377
  }
1362
1378
  /** One record's event history (#1235) on this host's own scope — the vertical-host read. */
1363
1379
  async entityHistoryLocal(scopeId, input) {
1364
- return this.scopeStub(scopeId).entityHistory(input);
1380
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).entityHistoryIfHeld(input));
1365
1381
  }
1366
1382
  /** When this host's own scope applied each migration (#1236) — the vertical-host read. */
1367
1383
  async appliedMigrationsLocal(scopeId) {
1368
- return this.scopeStub(scopeId).appliedMigrations();
1384
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).appliedMigrationsIfHeld());
1369
1385
  }
1370
1386
  /**
1371
1387
  * This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
@@ -1418,7 +1434,8 @@ export class CloudflareScopeHost {
1418
1434
  * so a caller cannot ask this side for more than the other would.
1419
1435
  */
1420
1436
  async undrainedEventsLocal(scopeId, limit) {
1421
- return undrainedEventsOf(await this.scopeStub(scopeId).undrainedEventsRead(Math.min(Math.max(limit, 1), 1000)));
1437
+ const bounded = Math.min(Math.max(limit, 1), 1000);
1438
+ return undrainedEventsOf(heldOrRefused(scopeId, await this.scopeStub(scopeId).undrainedEventsReadIfHeld(bounded)));
1422
1439
  }
1423
1440
  /**
1424
1441
  * Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
@@ -1444,7 +1461,7 @@ export class CloudflareScopeHost {
1444
1461
  * platform's `redrainEvents` is the door, and a count egresses nothing to record.
1445
1462
  */
1446
1463
  async redrainCountLocal(scopeId, drainedBefore) {
1447
- return this.scopeStub(scopeId).redrainCount(drainedBefore);
1464
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
1448
1465
  }
1449
1466
  /**
1450
1467
  * The PITR bookmarks one scope recorded before its migration passes (#286) — the
@@ -1452,7 +1469,7 @@ export class CloudflareScopeHost {
1452
1469
  * `/internal/bookmarks`; the control plane is the gate and the auditor.
1453
1470
  */
1454
1471
  async migrationBookmarksLocal(scopeId) {
1455
- return this.scopeStub(scopeId).migrationBookmarks();
1472
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).migrationBookmarksIfHeld());
1456
1473
  }
1457
1474
  /**
1458
1475
  * Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
@@ -1940,19 +1957,28 @@ export class CloudflareScopeHost {
1940
1957
  throw new Error(`connector attachment ${verb} is not delegated (#574) — upload and open are the ` +
1941
1958
  `verbs that cross the /internal seam to the serving deployment`);
1942
1959
  };
1960
+ const openArgs = (attachmentId) => ({
1961
+ connectionId,
1962
+ tenantId: tenant,
1963
+ scopeId,
1964
+ vertical,
1965
+ attachmentId,
1966
+ // #726 remedy B: the delivery, resolved at the far end against the
1967
+ // deployment's own outbox. Absent outside a dispatch, where the ordinary
1968
+ // permission check still applies.
1969
+ eventId: forEvent?.eventId,
1970
+ });
1943
1971
  return {
1944
1972
  upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
1945
- open: (attachmentId) => delegation.openAttachment({
1946
- connectionId,
1947
- tenantId: tenant,
1948
- scopeId,
1949
- vertical,
1950
- attachmentId,
1951
- // #726 remedy B: the delivery, resolved at the far end against the
1952
- // deployment's own outbox. Absent outside a dispatch, where the ordinary
1953
- // permission check still applies.
1954
- eventId: forEvent?.eventId,
1955
- }),
1973
+ open: (attachmentId) => delegation.openAttachment(openArgs(attachmentId)),
1974
+ // #2514: streamed over the seam when the embedder can. One that cannot reads the bytes whole,
1975
+ // as `open` does, and hands them on as a stream, so the verb still works and holds what it did.
1976
+ openStream: async (attachmentId) => {
1977
+ if (delegation.openAttachmentStream)
1978
+ return delegation.openAttachmentStream(openArgs(attachmentId));
1979
+ const opened = await delegation.openAttachment(openArgs(attachmentId));
1980
+ return opened && { record: opened.record, body: bytesStream(opened.body), contentType: opened.contentType };
1981
+ },
1956
1982
  list: notDelegated('list'),
1957
1983
  remove: notDelegated('remove'),
1958
1984
  // #1575: undelegated for `list`'s reason — a connector picks a document by id.
@@ -1999,6 +2025,10 @@ export class CloudflareScopeHost {
1999
2025
  const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
2000
2026
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
2001
2027
  },
2028
+ openStream: async (attachmentId) => {
2029
+ const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
2030
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2031
+ },
2002
2032
  remove: async (attachmentId) => unwrapReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
2003
2033
  attachmentId,
2004
2034
  })),
@@ -2018,6 +2048,8 @@ export class CloudflareScopeHost {
2018
2048
  const asPrincipalId = ('principal' in subject
2019
2049
  ? subject.principal
2020
2050
  : subject.connectionId);
2051
+ // The read gate, asked in the DO before the blob store is touched: a refused read never reaches it.
2052
+ const authorizeRead = async (attachmentId) => unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
2021
2053
  return {
2022
2054
  upload: async (input) => {
2023
2055
  const id = ulid();
@@ -2044,9 +2076,14 @@ export class CloudflareScopeHost {
2044
2076
  },
2045
2077
  list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
2046
2078
  open: async (attachmentId) => {
2047
- const record = unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
2079
+ const record = await authorizeRead(attachmentId);
2048
2080
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
2049
2081
  },
2082
+ // #2514: the same gate, then the bytes as R2 reads them, checked against the record as they pass.
2083
+ openStream: async (attachmentId) => {
2084
+ const record = await authorizeRead(attachmentId);
2085
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2086
+ },
2050
2087
  remove: async (attachmentId) => {
2051
2088
  const removed = unwrapReply(await stub.attachmentRemoveReply(attachmentId, asPrincipalId, tenantId, scopeId, connectionId));
2052
2089
  if (removed)
@@ -2080,7 +2117,17 @@ export class CloudflareScopeHost {
2080
2117
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
2081
2118
  }
2082
2119
  async importScope(actor, input, dump) {
2120
+ await this.importScopeFrom(actor, input, dump, true);
2121
+ }
2122
+ /**
2123
+ * `importScope`, with its role judgement (#2555) when `judge`, made before the scope is created so a
2124
+ * refused import leaves nothing behind. A snapshot passes false: it is the platform's copy of a live
2125
+ * scope of this host, made at once, so its seats are the ones that scope holds now (K-55).
2126
+ */
2127
+ async importScopeFrom(actor, input, dump, judge) {
2083
2128
  this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
2129
+ if (judge)
2130
+ await this.refuseDumpRoles(input.tenantId, null, dump);
2084
2131
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
2085
2132
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
2086
2133
  // the end state is the dump, at the source's frontier. Provenance is stamped from
@@ -2105,6 +2152,8 @@ export class CloudflareScopeHost {
2105
2152
  if (!existing)
2106
2153
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2107
2154
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2155
+ // #2555: judged before the fence, so a refused restore moves nothing.
2156
+ await this.refuseDumpRoles(tenantId, scopeId, dump);
2108
2157
  // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2109
2158
  await this.fenceOrphanSweep(scopeId, tenantId);
2110
2159
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
@@ -2171,7 +2220,7 @@ export class CloudflareScopeHost {
2171
2220
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2172
2221
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
2173
2222
  const snapshotId = ulid();
2174
- await this.importScope(actor, {
2223
+ await this.importScopeFrom(actor, {
2175
2224
  tenantId,
2176
2225
  scopeId: snapshotId,
2177
2226
  kind: opts?.kind ?? 'archive',
@@ -2180,7 +2229,7 @@ export class CloudflareScopeHost {
2180
2229
  jurisdiction: source.jurisdiction,
2181
2230
  expiresAt: opts?.expiresAt,
2182
2231
  // forkedFrom/forkedAt are stamped from the dump by importScope.
2183
- }, dump);
2232
+ }, dump, false);
2184
2233
  // Bind the snapshot to the source's current version so it is a runnable copy at the
2185
2234
  // same frontier (a fresh bind, so it never re-triggers the snapshot path).
2186
2235
  if (source.verticalVersionId) {
@@ -4705,7 +4754,9 @@ export class CloudflareScopeHost {
4705
4754
  // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
4706
4755
  // (tenant-owned, not listed), whose blast radius is its own tenant: there the
4707
4756
  // sandbox contract is the gate and the version self-admits, noted so the
4708
- // publish seam can tell a staff vouch from this shortcut.
4757
+ // publish seam can tell a staff vouch from this shortcut. The DO judges it again on the
4758
+ // vertical as the insert's unit reads it, and that is what the row and the version carry
4759
+ // (#2528); this read answers an older DO, which takes what it is sent.
4709
4760
  const selfAdmits = owning.owner_tenant !== null && !owning.listed;
4710
4761
  // The manifest is retained for the serving upload (#286), not audited — a whole
4711
4762
  // manifest per publish would drown the admin log in bundle metadata.
@@ -4751,67 +4802,33 @@ export class CloudflareScopeHost {
4751
4802
  await this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
4752
4803
  return hit ? mapVersion(hit) : undefined;
4753
4804
  },
4805
+ // #2528: each vertical write below is one DO call that reads the row's `before`, and runs its
4806
+ // guard, in the unit that writes; it refuses an unknown vertical with not_found.
4754
4807
  setVerticalListed: async (actor, slug, listed) => {
4755
- const existing = await this.cp.readVertical(slug);
4756
- if (!existing)
4757
- throw substratError('not_found', `unknown vertical '${slug}'`);
4758
- // Listing is the moment other tenants start trusting this code, so the
4759
- // version they would install must carry a real staff vouch — an auto-admitted
4760
- // prod version has never been read by anyone but its author.
4761
- if (listed) {
4762
- const prod = await this.cp.readChannel(slug, 'prod');
4763
- const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
4764
- if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
4765
- throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
4766
- `a staff admit must vouch for it before listing`);
4767
- }
4768
- }
4769
- await this.cp.updateVerticalListed(slug, listed ? 1 : 0, // also resolves any pending request
4770
- this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed }));
4808
+ // Listing is the moment other tenants start trusting this code, so the version they
4809
+ // would install must carry a real staff vouch — an auto-admitted prod version has never
4810
+ // been read by anyone but its author. The DO refuses one; it also resolves any pending
4811
+ // publish request.
4812
+ await this.directory('updateVerticalListed', slug, listed ? 1 : 0, this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, null, { listed }));
4771
4813
  },
4772
4814
  requestPublish: async (actor, slug) => {
4773
- const existing = await this.cp.readVertical(slug);
4774
- if (!existing)
4775
- throw substratError('not_found', `unknown vertical '${slug}'`);
4776
- await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4815
+ await this.directory('updateVerticalPublishRequest', slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4777
4816
  },
4778
4817
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
4779
- const existing = await this.cp.readVertical(slug);
4780
- if (!existing)
4781
- throw substratError('not_found', `unknown vertical '${slug}'`);
4782
- await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked }));
4818
+ await this.directory('updateVerticalInstallsBlocked', slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, null, { installsBlocked: blocked }));
4783
4819
  },
4784
4820
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
4785
- const existing = await this.cp.readVertical(slug);
4786
- if (!existing)
4787
- throw substratError('not_found', `unknown vertical '${slug}'`);
4788
- await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted }));
4821
+ await this.directory('updateVerticalTenantProvisioner', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, null, { tenantProvisioner: granted }));
4789
4822
  },
4790
4823
  setVerticalEmailSender: async (actor, slug, granted) => {
4791
- const existing = await this.cp.readVertical(slug);
4792
- if (!existing)
4793
- throw substratError('not_found', `unknown vertical '${slug}'`);
4794
- await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted }));
4824
+ await this.directory('updateVerticalEmailSender', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, null, { emailSender: granted }));
4795
4825
  },
4796
4826
  deleteVertical: async (actor, slug) => {
4797
- const existing = await this.cp.readVertical(slug);
4798
- if (!existing)
4799
- throw substratError('not_found', `unknown vertical '${slug}'`);
4800
- // Refuse while any restorable scope is bound: a deleted registry row would strand
4801
- // those scopes' version pins and routing. An `archived` scope (a deleted app) still
4802
- // blocks — unarchive can bring it back — but the refusal names reap/restore, not
4803
- // "delete", because the app itself is already gone. `reaped` is terminal history and
4804
- // never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
4805
- // for the cleanup script (#248).
4806
- const bound = await this.cp.countScopesForVertical(slug);
4807
- if (bound.live > 0) {
4808
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
4809
- }
4810
- if (bound.archived > 0) {
4811
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
4812
- }
4813
- // #2501: the period's `until` is read here, on the clock its `since` was.
4814
- await this.cp.deleteVertical(slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null), verticalOwnerNow());
4827
+ // Refused while any restorable scope is bound (the DO counts them in the delete's unit).
4828
+ // `reaped` is terminal history and never blocks. Deployed dispatch scripts are NOT reaped
4829
+ // here — they become orphans for the cleanup script (#248). #2501: the period's `until`
4830
+ // is read here, on the clock its `since` was.
4831
+ await this.directory('deleteVertical', slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, null, null), verticalOwnerNow());
4815
4832
  },
4816
4833
  listVerticalOwners: async (actor, slug) => {
4817
4834
  const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
@@ -4819,21 +4836,9 @@ export class CloudflareScopeHost {
4819
4836
  return periods;
4820
4837
  },
4821
4838
  admitVersion: async (actor, versionId) => {
4822
- const v = await this.cp.readVersion(versionId);
4823
- if (!v)
4824
- throw substratError('not_found', `unknown version ${versionId}`);
4825
- if (v.admission === 'admitted') {
4826
- // Idempotent — except an AUTO-admitted version, which this upgrades to a
4827
- // manual vouch by clearing the note (what the publish seam requires).
4828
- if (v.admission_note !== AUTO_ADMISSION_NOTE)
4829
- return;
4830
- await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null }));
4831
- return;
4832
- }
4833
- if (v.admission === 'rejected') {
4834
- throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
4835
- }
4836
- await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' }));
4839
+ // Judged in the DO's unit (#2528): idempotent on an admitted version, except an AUTO-admitted
4840
+ // one, which it upgrades to a manual vouch; a rejected one is refused. The DO fills the row.
4841
+ await this.directory('admitVersion', versionId, this.adminEntry(actor, 'admitVersion', { tenantId: null }, null, null));
4837
4842
  },
4838
4843
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
4839
4844
  const incoming = await this.cp.readVersion(versionId);
@@ -4849,15 +4854,8 @@ export class CloudflareScopeHost {
4849
4854
  return breaks;
4850
4855
  },
4851
4856
  rejectVersion: async (actor, versionId, note) => {
4852
- const v = await this.cp.readVersion(versionId);
4853
- if (!v)
4854
- throw substratError('not_found', `unknown version ${versionId}`);
4855
- if (v.admission === 'admitted') {
4856
- throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
4857
- }
4858
- if (v.admission === 'rejected')
4859
- return;
4860
- await this.cp.setAdmission(versionId, 'rejected', note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note }));
4857
+ // Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
4858
+ await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
4861
4859
  },
4862
4860
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4863
4861
  const incoming = await this.cp.readVersion(versionId);
@@ -5134,14 +5132,16 @@ export class CloudflareScopeHost {
5134
5132
  await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
5135
5133
  }
5136
5134
  }
5137
- await this.cp.setVerticalServing(verticalSlug, {
5135
+ // #2528: a compare-and-set on the serving ref and version the gate above judged, across
5136
+ // awaits another serve can run in. The DO fills the row's `before` from the state it moves.
5137
+ const moved = await this.directory('setVerticalServing', verticalSlug, {
5138
5138
  ref: parsed.ref,
5139
5139
  versionId: parsed.versionId,
5140
5140
  doClassesJson: JSON.stringify(parsed.doClasses),
5141
5141
  migrationTag: parsed.migrationTag,
5142
- }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
5143
- ? { ref: r.serving_ref, versionId: r.serving_version_id }
5144
- : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
5142
+ }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), { ref: r.serving_ref ?? null, versionId: r.serving_version_id ?? null });
5143
+ if (!moved)
5144
+ throw servingRaceRefusal(verticalSlug);
5145
5145
  },
5146
5146
  versionManifest: async (actor, verticalSlug, versionId) => {
5147
5147
  const v = await this.cp.readVersion(versionId);
@@ -5161,7 +5161,10 @@ export class CloudflareScopeHost {
5161
5161
  },
5162
5162
  recordWireAuth: async (actor, input) => {
5163
5163
  const row = deploymentWireAuth.parse(input);
5164
- await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
5164
+ const written = await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
5165
+ // Thrown here, not in the directory's object, so the code survives the RPC.
5166
+ if (!written)
5167
+ throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
5165
5168
  },
5166
5169
  listWireAuth: async (actor, input) => {
5167
5170
  // Judged here, before the call into the directory's object, so a refusal keeps its code; the
@@ -5458,6 +5461,9 @@ export class CloudflareScopeHost {
5458
5461
  recordStoreDriverRebuildRun: async (actor, entry) => {
5459
5462
  await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
5460
5463
  },
5464
+ recordWireWalk: async (actor, entry) => {
5465
+ await this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry);
5466
+ },
5461
5467
  listScopeTables: async (actor, tenantId, scopeId) => {
5462
5468
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
5463
5469
  // that does not resolve is unreachable, never another tenant's database.
@@ -7831,6 +7837,29 @@ export class CloudflareScopeHost {
7831
7837
  const here = await this.scopeStub(scopeId).reservationHere();
7832
7838
  return reservedRoleSetRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump ? dumpRoleState(dump.tables, dump.tenantId, new Date().toISOString()) : undefined);
7833
7839
  }
7840
+ /**
7841
+ * #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles as
7842
+ * the directory stores them now, against the dump's role rows and seats, with this host's
7843
+ * reservations joined with the scope class's. The class is read at `scopeId` when the scope exists
7844
+ * (a restore), else at a live scope of the tenant (an import, whose scope is not created yet: asking
7845
+ * its object would create storage for a scope a refusal leaves unmade).
7846
+ *
7847
+ * Two stated residuals. The directory is read here and the dump is loaded in the scope's own call,
7848
+ * so a role defined between the two is not judged against the dump's seats. And an import for a
7849
+ * tenant with no live scope reads no class, so it is judged with this host's reservations alone: the
7850
+ * seat rule holds without them, and a key only the class reserves is not counted.
7851
+ */
7852
+ async refuseDumpRoles(tenantId, scopeId, dump) {
7853
+ const classScope = scopeId ?? (await this.cp.listScopes({ tenantId, status: ['active', 'suspended'], limit: 1 }))[0]?.scope_id;
7854
+ const [rows, here] = await Promise.all([
7855
+ this.cp.listRoles({ tenantId }),
7856
+ classScope ? this.scopeStub(classScope).reservationHere() : { reserved: [], declared: [] },
7857
+ ]);
7858
+ const roles = rows.map((r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) }));
7859
+ const refusal = dumpRoleRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump, new Date().toISOString());
7860
+ if (refusal)
7861
+ throw substratError('forbidden', refusal);
7862
+ }
7834
7863
  /**
7835
7864
  * #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
7836
7865
  * holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
@@ -7854,7 +7883,10 @@ export class CloudflareScopeHost {
7854
7883
  const stub = this.scopeStub(scopeId);
7855
7884
  const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
7856
7885
  const reserved = new Set([...this.reservedKeys, ...here.reserved]);
7857
- const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) }, { roles: state.roles, seated: new Set(state.seated) });
7886
+ const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
7887
+ // #2531: with the tombstones the scope keeps, so a system-held role a release dropped is judged
7888
+ // as the stored definition it was. A ScopeDO from before them answers none.
7889
+ { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
7858
7890
  // Named by tenant and scope as well as role: a release push admitted can be refused here at every
7859
7891
  // scope where the role is seated, and staff find each one by this line (K-55).
7860
7892
  if (refusal)
@@ -8137,6 +8169,17 @@ export class CloudflareScopeHost {
8137
8169
  * so a caller falls back rather than failing a dispatch over a missing file.
8138
8170
  */
8139
8171
  async connectorAttachmentOpenLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8172
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).open(attachmentId);
8173
+ }
8174
+ /**
8175
+ * `connectorAttachmentOpenLocal`, with the bytes as a stream read from R2 as the platform reads
8176
+ * the response (#2514). The same gates, in the same order, before the bucket is touched.
8177
+ */
8178
+ async connectorAttachmentOpenStreamLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8179
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).openStream(attachmentId);
8180
+ }
8181
+ /** The attachment surface the platform's reads as a connection go through, behind their gates. */
8182
+ async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
8140
8183
  // Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
8141
8184
  await this.assertLive(tenantId, scopeId);
8142
8185
  await this.migrateAndRecord(scopeId);
@@ -8144,7 +8187,7 @@ export class CloudflareScopeHost {
8144
8187
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
8145
8188
  // #726 remedy B: the platform named a delivery; this deployment decides what that
8146
8189
  // delivery reaches, from its own spine row. Absent ⇒ the ordinary grant check.
8147
- eventId === undefined ? undefined : { eventId }).open(attachmentId);
8190
+ eventId === undefined ? undefined : { eventId });
8148
8191
  }
8149
8192
  /**
8150
8193
  * The delivery half of `grantToConnection` for a scope served HERE (#574): write