@substrat-run/adapter-cloudflare 0.159.0 → 0.160.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,16 +1,16 @@
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, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
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, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
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';
7
7
  import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
8
- import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
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';
10
10
  import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
11
11
  import { isOrangeToOrange, isUpgradeRequest, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveRefusalStatus, liveWithinOf, } from './live-reads.js';
12
12
  import { tenantStoreDatabaseName } from './d1.js';
13
- import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
13
+ import { blobStoreBucketName, r2TenantBlobStore, workerdSha256 } from './r2.js';
14
14
  import { unwrapReply } from './do-reply.js';
15
15
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
16
16
  const toConnection = (r) => connection.parse({
@@ -207,6 +207,28 @@ async function compensatingRefusal(store, key, call) {
207
207
  await store.delete(key).catch(() => { });
208
208
  return reply;
209
209
  }
210
+ /**
211
+ * Ask the scope's write gate before a streamed body is read (#2516). A scope DO from before the
212
+ * verb exists cannot answer it, and a missing answer is never a pass: the call fails closed with a
213
+ * retryable `unavailable` that says to redeploy, rather than skipping the gate and reading the body.
214
+ */
215
+ export async function askAttachmentWriteGate(stub, entity, principal, tenantId, scopeId, connectionId) {
216
+ let reply;
217
+ try {
218
+ reply = await stub.attachmentCheckWriteReply(entity, principal, tenantId, scopeId, connectionId);
219
+ }
220
+ catch (err) {
221
+ // By shape, not wording: the scope answers every refusal as data (`replyOf`), so what is
222
+ // THROWN here is a failure of the call itself, and a verb the receiver does not have is a
223
+ // `TypeError` however workerd phrases it. A dropped connection is a plain `Error`, rethrown.
224
+ if (err instanceof TypeError || err?.name === 'TypeError') {
225
+ throw substratError('unavailable', `the deployment serving scope ${scopeId} predates the upload write check (#2516): a streamed upload ` +
226
+ `is refused rather than read unchecked. Redeploy the vertical, then retry. Nothing was stored.`);
227
+ }
228
+ throw err;
229
+ }
230
+ unwrapReply(reply);
231
+ }
210
232
  /** A tenant-held module's OFF answered without `deniesTenantGrants` (#1823). */
211
233
  const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
212
234
  `module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
@@ -327,6 +349,12 @@ const DOOR_WORDS = {
327
349
  };
328
350
  /** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
329
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)
355
+ throw substratError('conflict', scopeNotHeldRefusal(scopeId));
356
+ return size;
357
+ }
330
358
  export class CloudflareScopeHost {
331
359
  admin;
332
360
  scopeNs;
@@ -346,6 +374,8 @@ export class CloudflareScopeHost {
346
374
  moduleIds = new Set();
347
375
  /** #2430: what a registered module declares `holder: 'system'` — `assignRole`'s refusal reads it. */
348
376
  reservedKeys = new Set();
377
+ /** #2447: every key a registered or listed module declares, marked or not — a role definition reads it. */
378
+ declaredKeys = new Set();
349
379
  /** #2430: the declared permission surface `registerModule` holds each module to, when given. */
350
380
  permissions;
351
381
  /**
@@ -370,6 +400,7 @@ export class CloudflareScopeHost {
370
400
  /** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
371
401
  secretBox;
372
402
  fetchImpl;
403
+ streamIdleMs;
373
404
  connectorCalls;
374
405
  /** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
375
406
  tenantStores;
@@ -445,8 +476,10 @@ export class CloudflareScopeHost {
445
476
  this.servicePrincipals = options.servicePrincipals;
446
477
  this.permissions = options.permissions;
447
478
  // #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
448
- for (const m of options.permissions?.modules ?? [])
479
+ for (const m of options.permissions?.modules ?? []) {
449
480
  noteReservedKeys(this.reservedKeys, m.manifest);
481
+ noteDeclaredKeys(this.declaredKeys, m.manifest);
482
+ }
450
483
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
451
484
  this.tenantStores = options.tenantStores;
452
485
  this.blobStores = options.blobStores;
@@ -455,7 +488,9 @@ export class CloudflareScopeHost {
455
488
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
456
489
  this.attachmentExtractors = options.attachmentExtractors ?? [];
457
490
  this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
458
- this.fetchImpl = options.fetch ?? globalFetch;
491
+ // #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
492
+ this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
493
+ this.streamIdleMs = options.streamIdleTimeoutMs;
459
494
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
460
495
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
461
496
  this.scopeNs = options.scope;
@@ -511,7 +546,8 @@ export class CloudflareScopeHost {
511
546
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
512
547
  const vertical = scope?.vertical ?? null;
513
548
  const admin = this.admin;
514
- const fetchImpl = this.fetchImpl;
549
+ // #2518: the call under `timeoutMs`, a streamed body under the idle window instead.
550
+ const fetchImpl = deadlineFetch(this.fetchImpl, timeoutMs, { idleMs: this.streamIdleMs });
515
551
  return {
516
552
  admin,
517
553
  tenantId,
@@ -552,10 +588,7 @@ export class CloudflareScopeHost {
552
588
  // #1691: timed where the call is made, so the data point carries a duration.
553
589
  const started = Date.now();
554
590
  try {
555
- const res = await fetchImpl(input, {
556
- ...init,
557
- signal: AbortSignal.timeout(timeoutMs),
558
- });
591
+ const res = await fetchImpl(input, init);
559
592
  await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
560
593
  return res;
561
594
  }
@@ -1262,6 +1295,8 @@ export class CloudflareScopeHost {
1262
1295
  * safe; scope-level tuples (the restored grants) are never touched.
1263
1296
  */
1264
1297
  async projectRolesLocal(tenantId, scopeId, roles) {
1298
+ // #2447: the role set is defined here, so it is judged here, before anything is projected.
1299
+ await this.refuseReservedRoleSet(roles, tenantId, scopeId);
1265
1300
  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' })), []));
1266
1301
  }
1267
1302
  /**
@@ -1332,9 +1367,16 @@ export class CloudflareScopeHost {
1332
1367
  async appliedMigrationsLocal(scopeId) {
1333
1368
  return this.scopeStub(scopeId).appliedMigrations();
1334
1369
  }
1335
- /** This host's own scope's database size in bytes (#1524), the vertical-host read. */
1370
+ /**
1371
+ * This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
1372
+ * deployment does not hold is refused (#2541), not read as 0.
1373
+ */
1336
1374
  async databaseSizeLocal(scopeId) {
1337
- return this.scopeStub(scopeId).databaseSize();
1375
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).databaseSizeIfHeld());
1376
+ }
1377
+ /** This host's own scope's attachment bytes (#2501), the vertical-host read; refused as the size is. */
1378
+ async attachmentSizeLocal(scopeId) {
1379
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).attachmentBytesIfHeld());
1338
1380
  }
1339
1381
  /**
1340
1382
  * Each scope store's size in this deployment (#2207). The control plane already
@@ -1583,6 +1625,7 @@ export class CloudflareScopeHost {
1583
1625
  this.crossVertical.register(manifest, registration.imports);
1584
1626
  this.moduleIds.add(manifest.id);
1585
1627
  noteReservedKeys(this.reservedKeys, manifest);
1628
+ noteDeclaredKeys(this.declaredKeys, manifest);
1586
1629
  if (manifest.schedules && manifest.schedules.length > 0) {
1587
1630
  this.moduleSchedules.set(manifest.id, manifest.schedules);
1588
1631
  }
@@ -1844,22 +1887,11 @@ export class CloudflareScopeHost {
1844
1887
  const filename = view.filename ?? input.filename;
1845
1888
  // Recorded as it is judged: the essence, never a parameter or a stray space.
1846
1889
  const contentType = normalizeUploadMediaType(input.contentType);
1847
- const record = attachmentRecord.parse({
1848
- id,
1849
- entity: view.entity,
1850
- filename,
1851
- contentType,
1852
- size: input.body.byteLength,
1853
- sha256: await attachmentSha256(input.body),
1854
- visibility: view.visibility,
1855
- createdBy: mintedBy,
1856
- createdAt: new Date().toISOString(),
1857
- });
1858
1890
  // Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
1859
1891
  // someone else spent first) compensates the object away, and the link is unspent with it.
1860
1892
  // A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
1861
1893
  // `Idempotency-Key` is answered as a replay (#2315).
1862
- await store.put(key, input.body, { contentType });
1894
+ const record = await putAttachmentBytes(store, key, { id, entity: view.entity, filename, contentType, visibility: view.visibility, createdBy: mintedBy, createdAt: new Date().toISOString() }, input.body, workerdSha256);
1863
1895
  return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
1864
1896
  });
1865
1897
  }
@@ -1990,20 +2022,24 @@ export class CloudflareScopeHost {
1990
2022
  upload: async (input) => {
1991
2023
  const id = ulid();
1992
2024
  const key = attachmentBlobKey(scopeId, id);
1993
- const record = attachmentRecord.parse({
2025
+ const fields = {
1994
2026
  id,
1995
2027
  entity: input.entity,
1996
2028
  filename: input.filename,
1997
2029
  contentType: input.contentType,
1998
- size: input.body.byteLength,
1999
- sha256: await attachmentSha256(input.body),
2000
2030
  visibility: input.visibility,
2001
2031
  createdBy,
2002
2032
  createdAt: new Date().toISOString(),
2003
- });
2033
+ };
2034
+ // A streamed body is the case where bytes are the cost, so the write gate is asked before
2035
+ // the first one is read (#2516). The row's own check below stays: this one is only early.
2036
+ if (isSizedByteStream(input.body)) {
2037
+ await askAttachmentWriteGate(stub, input.entity, asPrincipalId, tenantId, scopeId, connectionId);
2038
+ }
2004
2039
  // Bytes first, row second — a crash between the two leaves an orphaned object (#2314);
2005
2040
  // a refused gate compensates, and only an answered refusal does (`compensatingRefusal`).
2006
- await store.put(key, input.body, { contentType: input.contentType });
2041
+ // A streamed body is hashed and counted as R2 reads it (#2498).
2042
+ const record = await putAttachmentBytes(store, key, fields, input.body, workerdSha256);
2007
2043
  return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId)));
2008
2044
  },
2009
2045
  list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
@@ -2140,6 +2176,7 @@ export class CloudflareScopeHost {
2140
2176
  scopeId: snapshotId,
2141
2177
  kind: opts?.kind ?? 'archive',
2142
2178
  vertical: source.vertical,
2179
+ verticalSource: 'copy',
2143
2180
  jurisdiction: source.jurisdiction,
2144
2181
  expiresAt: opts?.expiresAt,
2145
2182
  // forkedFrom/forkedAt are stamped from the dump by importScope.
@@ -3126,7 +3163,9 @@ export class CloudflareScopeHost {
3126
3163
  }
3127
3164
  return report;
3128
3165
  }
3129
- async runDueSchedules(moduleId, tenantId, scopeId) {
3166
+ async runDueSchedules(moduleId, tenantId, scopeId,
3167
+ /** #2517: what the sweep tick's earlier calls did to this scope (`ScopeHost.runDueSchedules`). */
3168
+ tick) {
3130
3169
  const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
3131
3170
  const schedules = this.moduleSchedules.get(moduleId);
3132
3171
  if (!schedules || schedules.length === 0)
@@ -3181,6 +3220,10 @@ export class CloudflareScopeHost {
3181
3220
  // scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
3182
3221
  let platformRequests = 0;
3183
3222
  const counted = { onPlatformRequests: (n) => (platformRequests += n) };
3223
+ // #2517: the part of that the purge runs enqueued, which is what defers a later purge head the
3224
+ // cap refuses (`runPurgePass`): this call's, on top of what the tick's earlier calls report.
3225
+ const earlierPurgeRequests = tick?.earlierPurgeRequests ?? 0;
3226
+ let purgeRequests = 0;
3184
3227
  for (const schedule of schedules) {
3185
3228
  const last = await stub.scheduleLastRun(schedule.operation);
3186
3229
  const lastRun = last ? Date.parse(last) : null;
@@ -3215,12 +3258,24 @@ export class CloudflareScopeHost {
3215
3258
  // call and transaction — and the SCOPE decides which are due and runs them, so purge
3216
3259
  // authority is never an argument any stub can pass (`runPurgeSweep`).
3217
3260
  // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3218
- // #2431: the purges' own requests run inside the scope, which reports no count for them;
3219
- // the deliveries their events route to the platform are counted below, as an invoke's are.
3220
- const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
3261
+ // #2431, #2440: the scope reports the requests its purges enqueued (`pass.platformRequests`),
3262
+ // and the deliveries their events route to the platform are counted below, as an invoke's are.
3263
+ // #2517: with what the purge runs before this one in the tick enqueued, so a head the cap
3264
+ // refuses because they filled the queue is deferred to the next tick rather than failed.
3265
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId, earlierPurgeRequests + purgeRequests));
3266
+ if (pass.deferred) {
3267
+ // Ran nothing: no line, no cadence row, `skipped` like any run that did not happen.
3268
+ report.skipped += 1;
3269
+ report.deferred = (report.deferred ?? 0) + 1;
3270
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
3271
+ continue;
3272
+ }
3273
+ platformRequests += pass.platformRequests ?? 0;
3274
+ purgeRequests += pass.platformRequests ?? 0;
3221
3275
  // The purges' events, delivered the way each invoke's own tail delivers them.
3222
3276
  const delivered = await this.drainExecutors(tenantId, scopeId, invocationId);
3223
3277
  platformRequests += delivered.routedToPlatform ?? 0;
3278
+ purgeRequests += delivered.routedToPlatform ?? 0;
3224
3279
  stillDue = purgeStillDue(pass);
3225
3280
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3226
3281
  report.errors.push(...outcome.errors);
@@ -3274,6 +3329,8 @@ export class CloudflareScopeHost {
3274
3329
  lines.end();
3275
3330
  if (platformRequests > 0)
3276
3331
  report.platformRequests = platformRequests;
3332
+ if (purgeRequests > 0)
3333
+ report.purgeRequests = purgeRequests;
3277
3334
  return report;
3278
3335
  }
3279
3336
  /** The stub body, shared by the principal, connection, system and impersonation doors. */
@@ -3594,6 +3651,19 @@ export class CloudflareScopeHost {
3594
3651
  archivedFromStatus: (r.archived_from_status ?? null),
3595
3652
  createdAt: r.created_at,
3596
3653
  });
3654
+ // The directory rows `listScopes` and `listScopesReadable` map, filtered and paged alike.
3655
+ const scopeRows = (filter) => this.cp.listScopes({
3656
+ tenantId: filter?.tenantId,
3657
+ status: filter?.status
3658
+ ? Array.isArray(filter.status)
3659
+ ? filter.status
3660
+ : [filter.status]
3661
+ : undefined,
3662
+ vertical: filter?.vertical,
3663
+ limit: filter?.limit,
3664
+ cursor: filter?.cursor,
3665
+ order: filter?.order,
3666
+ });
3597
3667
  // The (version, scope) pair a bind and its impact read both start from, and the refusals
3598
3668
  // that come before any export-break question, in the order the bind makes them (#1756).
3599
3669
  const bindTarget = async (tenantId, scopeId, versionId) => {
@@ -4166,7 +4236,19 @@ export class CloudflareScopeHost {
4166
4236
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
4167
4237
  defineRole: async (actor, tenantId, role) => {
4168
4238
  const parsed = roleDefinition.parse(role);
4169
- const before = await this.cp.defineRole(tenantId, parsed);
4239
+ // #2447: judged in the ControlPlaneDO's unit, against this host's keys and the tenant's other
4240
+ // roles, so a role assigned through a door cannot be widened afterwards. A refusal writes
4241
+ // nothing, so a seat of the role as it was stored keeps working.
4242
+ const context = await this.definitionContext(tenantId, parsed);
4243
+ if (context.unreadable)
4244
+ throw substratError('forbidden', `role '${parsed.key}' cannot be judged: ${context.unreadable}`);
4245
+ // `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
4246
+ // before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
4247
+ // than being misread.
4248
+ const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
4249
+ if ('refused' in defined)
4250
+ throw substratError('forbidden', defined.refused);
4251
+ const before = defined.before;
4170
4252
  await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
4171
4253
  await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
4172
4254
  },
@@ -4607,8 +4689,7 @@ export class CloudflareScopeHost {
4607
4689
  }
4608
4690
  throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
4609
4691
  }
4610
- await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
4611
- await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
4692
+ await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, verticalOwnerNow(), this.adminEntry(actor, 'registerVertical', { tenantId: null }, null, parsed));
4612
4693
  },
4613
4694
  listVerticals: async (actor, page) => {
4614
4695
  const rows = await this.cp.listVerticals(page);
@@ -4645,14 +4726,13 @@ export class CloudflareScopeHost {
4645
4726
  admission: selfAdmits ? 'admitted' : 'pending',
4646
4727
  admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
4647
4728
  createdAt: new Date().toISOString(),
4648
- });
4649
- await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
4729
+ }, this.adminEntry(actor, 'publishVersion', { tenantId: null }, null, {
4650
4730
  ...audited,
4651
4731
  // The digest the registry holds, which differs from the pushed one only for an unheld version.
4652
4732
  migrationDigest,
4653
4733
  ...(origin ? { origin } : {}),
4654
4734
  admission: selfAdmits ? 'admitted' : 'pending',
4655
- });
4735
+ }));
4656
4736
  },
4657
4737
  verticalScopeStores: async (actor, verticalSlug) => {
4658
4738
  const stores = await this.directory('verticalScopeStores', verticalSlug);
@@ -4686,36 +4766,32 @@ export class CloudflareScopeHost {
4686
4766
  `a staff admit must vouch for it before listing`);
4687
4767
  }
4688
4768
  }
4689
- await this.cp.updateVerticalListed(slug, listed ? 1 : 0); // also resolves any pending request
4690
- await this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed });
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 }));
4691
4771
  },
4692
4772
  requestPublish: async (actor, slug) => {
4693
4773
  const existing = await this.cp.readVertical(slug);
4694
4774
  if (!existing)
4695
4775
  throw substratError('not_found', `unknown vertical '${slug}'`);
4696
- await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString());
4697
- await this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
4776
+ await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4698
4777
  },
4699
4778
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
4700
4779
  const existing = await this.cp.readVertical(slug);
4701
4780
  if (!existing)
4702
4781
  throw substratError('not_found', `unknown vertical '${slug}'`);
4703
- await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0);
4704
- await this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked });
4782
+ await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked }));
4705
4783
  },
4706
4784
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
4707
4785
  const existing = await this.cp.readVertical(slug);
4708
4786
  if (!existing)
4709
4787
  throw substratError('not_found', `unknown vertical '${slug}'`);
4710
- await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0);
4711
- await this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted });
4788
+ await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted }));
4712
4789
  },
4713
4790
  setVerticalEmailSender: async (actor, slug, granted) => {
4714
4791
  const existing = await this.cp.readVertical(slug);
4715
4792
  if (!existing)
4716
4793
  throw substratError('not_found', `unknown vertical '${slug}'`);
4717
- await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0);
4718
- await this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted });
4794
+ await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted }));
4719
4795
  },
4720
4796
  deleteVertical: async (actor, slug) => {
4721
4797
  const existing = await this.cp.readVertical(slug);
@@ -4734,8 +4810,13 @@ export class CloudflareScopeHost {
4734
4810
  if (bound.archived > 0) {
4735
4811
  throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
4736
4812
  }
4737
- await this.cp.deleteVertical(slug);
4738
- await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
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());
4815
+ },
4816
+ listVerticalOwners: async (actor, slug) => {
4817
+ const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
4818
+ await this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
4819
+ return periods;
4739
4820
  },
4740
4821
  admitVersion: async (actor, versionId) => {
4741
4822
  const v = await this.cp.readVersion(versionId);
@@ -4746,15 +4827,13 @@ export class CloudflareScopeHost {
4746
4827
  // manual vouch by clearing the note (what the publish seam requires).
4747
4828
  if (v.admission_note !== AUTO_ADMISSION_NOTE)
4748
4829
  return;
4749
- await this.cp.setAdmission(versionId, 'admitted', null);
4750
- await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null });
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 }));
4751
4831
  return;
4752
4832
  }
4753
4833
  if (v.admission === 'rejected') {
4754
4834
  throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
4755
4835
  }
4756
- await this.cp.setAdmission(versionId, 'admitted', null);
4757
- await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
4836
+ await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' }));
4758
4837
  },
4759
4838
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
4760
4839
  const incoming = await this.cp.readVersion(versionId);
@@ -4778,8 +4857,7 @@ export class CloudflareScopeHost {
4778
4857
  }
4779
4858
  if (v.admission === 'rejected')
4780
4859
  return;
4781
- await this.cp.setAdmission(versionId, 'rejected', note);
4782
- await this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note });
4860
+ await this.cp.setAdmission(versionId, 'rejected', note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note }));
4783
4861
  },
4784
4862
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4785
4863
  const incoming = await this.cp.readVersion(versionId);
@@ -4853,7 +4931,8 @@ export class CloudflareScopeHost {
4853
4931
  // in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
4854
4932
  // choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
4855
4933
  // only if the pointer still names `outgoing`; the promote that lost the race is refused as
4856
- // stale and writes nothing.
4934
+ // stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
4935
+ // which record their own — and the acknowledgement is recorded, not just enforced.
4857
4936
  const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
4858
4937
  id: ulid(),
4859
4938
  vertical_slug: verticalSlug,
@@ -4862,7 +4941,7 @@ export class CloudflareScopeHost {
4862
4941
  from_version_id: outgoing?.id ?? null,
4863
4942
  actor,
4864
4943
  at: promotedAt,
4865
- });
4944
+ }, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
4866
4945
  if (!moved)
4867
4946
  throw promoteRaceRefusal(channel);
4868
4947
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
@@ -4892,7 +4971,6 @@ export class CloudflareScopeHost {
4892
4971
  }
4893
4972
  await this.cp.bindScopeVersion(s.scope_id, versionId, verticalSlug, undefined, undefined, undefined, undefined, undefined, this.adminEntry(actor, 'bindScopeVersion', { tenantId: s.tenant_id, scopeId: s.scope_id }, prev ? { versionId: prev.id, version: prev.version } : null, { versionId, vertical: verticalSlug, version: incoming.version, via: 'promoteVersion' }));
4894
4973
  }
4895
- await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
4896
4974
  },
4897
4975
  listChannels: async (actor, verticalSlug, page) => {
4898
4976
  // `prod` is the only live channel (#509 retired dev/staging). Filter before the parse
@@ -5061,10 +5139,9 @@ export class CloudflareScopeHost {
5061
5139
  versionId: parsed.versionId,
5062
5140
  doClassesJson: JSON.stringify(parsed.doClasses),
5063
5141
  migrationTag: parsed.migrationTag,
5064
- });
5065
- await this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
5142
+ }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
5066
5143
  ? { ref: r.serving_ref, versionId: r.serving_version_id }
5067
- : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId });
5144
+ : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
5068
5145
  },
5069
5146
  versionManifest: async (actor, verticalSlug, versionId) => {
5070
5147
  const v = await this.cp.readVersion(versionId);
@@ -5082,6 +5159,18 @@ export class CloudflareScopeHost {
5082
5159
  await this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
5083
5160
  return v.migrations;
5084
5161
  },
5162
+ recordWireAuth: async (actor, input) => {
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 }));
5165
+ },
5166
+ listWireAuth: async (actor, input) => {
5167
+ // Judged here, before the call into the directory's object, so a refusal keeps its code; the
5168
+ // judged copy is what crosses the RPC and reaches the log (its own fields, the reach first).
5169
+ const filter = assertWireAuthFilter(input);
5170
+ const rows = await this.cp.listWireAuth(filter);
5171
+ await this.recordAccess(actor, 'listWireAuth', wireAuthAccessTarget(filter), filter, rows.length);
5172
+ return rows;
5173
+ },
5085
5174
  setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
5086
5175
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
5087
5176
  if (!scope)
@@ -5144,6 +5233,13 @@ export class CloudflareScopeHost {
5144
5233
  await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
5145
5234
  return sizes;
5146
5235
  },
5236
+ scopeAttachmentBytes: async (actor, tenantId, scopeId) => {
5237
+ // Reaped is refused, not read: addressing the deleted DO would recreate it.
5238
+ await this.scopeRecordForRead(tenantId, scopeId);
5239
+ const bytes = await this.scopeStub(scopeId).attachmentBytes();
5240
+ await this.recordAccess(actor, 'scopeAttachmentBytes', { tenantId, scopeId }, null, 1);
5241
+ return bytes;
5242
+ },
5147
5243
  rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
5148
5244
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
5149
5245
  if (!scope)
@@ -5228,31 +5324,26 @@ export class CloudflareScopeHost {
5228
5324
  },
5229
5325
  createTenant: async (actor, input) => {
5230
5326
  const parsed = createTenantInput.parse(input);
5231
- const created = await this.directory('createTenant', parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null);
5232
- // Idempotent: re-creating an existing tenant is a no-op, not audited.
5233
- if (!created)
5234
- return;
5235
- await this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, created);
5327
+ // Idempotent: re-creating an existing tenant is a no-op, not audited. The DO writes the
5328
+ // row with the tenant it created as its `after`.
5329
+ await this.directory('createTenant', parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null, this.adminEntry(actor, 'createTenant', { tenantId: parsed.id }, null, null));
5236
5330
  },
5237
5331
  setTenantStatus: async (actor, tenantId, status) => {
5238
- const before = await this.directory('setTenantStatus', tenantId, status);
5239
- await this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before }, { status });
5332
+ // The DO fills the row's `before` with the status it read in the write's unit.
5333
+ await this.directory('setTenantStatus', tenantId, status, this.adminEntry(actor, 'setTenantStatus', { tenantId }, null, { status }));
5240
5334
  // #1713: every hosted scope under the tenant holds or resumes its work by this.
5241
5335
  await this.deliverLifecycles(actor, { tenantId });
5242
5336
  },
5243
5337
  setTenantName: async (actor, tenantId, name) => {
5244
- const before = await this.directory('setTenantName', tenantId, name);
5245
- if (before === name)
5246
- return; // no-op is not audited — nothing changed
5247
- await this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before }, { name });
5338
+ // A rename to the current name writes nothing and is not audited; the DO fills `before`.
5339
+ await this.directory('setTenantName', tenantId, name, this.adminEntry(actor, 'setTenantName', { tenantId }, null, { name }));
5248
5340
  },
5249
5341
  reapTenant: async (actor, tenantId) => {
5250
5342
  // Directory-side terminal reap (§4.8). The caller reaped every scope's storage
5251
5343
  // first (archive-if-needed → reapScope in the vertical deployment); the DO clears
5252
5344
  // the tenant's PII/config rows and flips the row to a `reaped` tombstone, keeping
5253
5345
  // the row + admin log. Only a `deleting` tenant may be reaped (checked in the DO).
5254
- const before = await this.directory('reapTenant', tenantId);
5255
- await this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before }, { status: 'reaped' });
5346
+ await this.directory('reapTenant', tenantId, this.adminEntry(actor, 'reapTenant', { tenantId }, null, { status: 'reaped' }));
5256
5347
  },
5257
5348
  listTenants: async (actor, page) => {
5258
5349
  const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
@@ -5266,21 +5357,15 @@ export class CloudflareScopeHost {
5266
5357
  return t ? tenantSchema.parse(t) : undefined;
5267
5358
  },
5268
5359
  listScopes: async (actor, filter) => {
5269
- const rows = await this.cp.listScopes({
5270
- tenantId: filter?.tenantId,
5271
- status: filter?.status
5272
- ? Array.isArray(filter.status)
5273
- ? filter.status
5274
- : [filter.status]
5275
- : undefined,
5276
- vertical: filter?.vertical,
5277
- limit: filter?.limit,
5278
- cursor: filter?.cursor,
5279
- order: filter?.order,
5280
- });
5360
+ const rows = await scopeRows(filter);
5281
5361
  await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5282
5362
  return rows.map(mapScope);
5283
5363
  },
5364
+ listScopesReadable: async (actor, filter) => {
5365
+ const rows = await scopeRows(filter);
5366
+ await this.recordAccess(actor, 'listScopesReadable', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5367
+ return readableScopes(rows, mapScope);
5368
+ },
5284
5369
  getScopeRecord: async (actor, tenantId, scopeId) => {
5285
5370
  const row = await this.cp.getScopeRecord(tenantId, scopeId);
5286
5371
  await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
@@ -5984,6 +6069,7 @@ export class CloudflareScopeHost {
5984
6069
  storage: rows.storage,
5985
6070
  storageAttempts: rows.storageAttempts,
5986
6071
  storeStorage: rows.storeStorage,
6072
+ attachmentStorage: rows.attachmentStorage,
5987
6073
  });
5988
6074
  // Tenants covered, not totals: "read one tenant's meter" and "metered the whole
5989
6075
  // fleet" are different acts, and K-24 exists to tell them apart.
@@ -6388,7 +6474,10 @@ export class CloudflareScopeHost {
6388
6474
  return rows.map((r) => adminLogEntry.parse(r));
6389
6475
  },
6390
6476
  recordOpsFailure: async (entry) => {
6477
+ // #2540: a pair's staff row is minted first, so the tenant's row lists above it.
6478
+ const detail = entry.tenantId && typeof entry.detail === 'string' ? { detail: entry.detail.slice(0, 2000), detail_id: ulid() } : {};
6391
6479
  await this.cp.recordOpsFailure({
6480
+ ...detail,
6392
6481
  id: ulid(),
6393
6482
  actor: entry.actor,
6394
6483
  operation: entry.operation,
@@ -6418,6 +6507,7 @@ export class CloudflareScopeHost {
6418
6507
  code: filter?.code,
6419
6508
  fingerprint: filter?.fingerprint,
6420
6509
  reference: filter?.reference,
6510
+ referenceInText: filter?.referenceInText,
6421
6511
  since: filter?.since,
6422
6512
  until: filter?.until,
6423
6513
  limit: filter?.limit,
@@ -6534,6 +6624,30 @@ export class CloudflareScopeHost {
6534
6624
  return rows;
6535
6625
  },
6536
6626
  pruneScopeStorage: async (_actor, limit) => this.cp.pruneScopeStorage(assertRowLimit('limit', limit)),
6627
+ recordRequestDay: async (_actor, input) => this.cp.recordRequestDay(input),
6628
+ listRequestDays: async (actor, filter) => {
6629
+ const rows = (await this.cp.listRequestDays(filter)).map((r) => requestDay.parse(r));
6630
+ await this.recordAccess(actor, 'listRequestDays', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
6631
+ return rows;
6632
+ },
6633
+ listRequestRollupDays: async (actor, filter) => {
6634
+ const rows = (await this.cp.listRequestRollupDays(filter)).map((r) => requestRollupDay.parse(r));
6635
+ await this.recordAccess(actor, 'listRequestRollupDays', { tenantId: null }, filter ?? null, rows.length);
6636
+ return rows;
6637
+ },
6638
+ pruneRequestDays: async (_actor, limit, retentionMonths) => this.cp.pruneRequestDays(assertRowLimit('limit', limit), retentionMonths),
6639
+ recordMeterDay: async (_actor, input) => this.cp.recordMeterDay(input),
6640
+ listMeterDays: async (actor, filter) => {
6641
+ const rows = (await this.cp.listMeterDays(filter)).map((r) => meterDay.parse(r));
6642
+ await this.recordAccess(actor, 'listMeterDays', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
6643
+ return rows;
6644
+ },
6645
+ listMeterDayTakes: async (actor, filter) => {
6646
+ const rows = (await this.cp.listMeterDayTakes(filter)).map((r) => meterDayTake.parse(r));
6647
+ await this.recordAccess(actor, 'listMeterDayTakes', { tenantId: null }, filter ?? null, rows.length);
6648
+ return rows;
6649
+ },
6650
+ pruneMeterDays: async (_actor, limit, retentionMonths) => this.cp.pruneMeterDays(assertRowLimit('limit', limit), retentionMonths),
6537
6651
  recordModelUsage: async (input) => {
6538
6652
  const l = input.line;
6539
6653
  return this.cp.recordModelUsage({
@@ -6756,15 +6870,11 @@ export class CloudflareScopeHost {
6756
6870
  catch (err) {
6757
6871
  report.failed += 1;
6758
6872
  try {
6759
- await this.admin.recordOpsFailure({
6760
- actor,
6761
- operation: 'scope.lifecycle',
6762
- stage: 'deliver',
6763
- tenantId,
6764
- scopeId,
6765
- message: `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the ` +
6766
- `deployment serving this scope; the heal sweep will deliver it again: ${err instanceof Error ? err.message : String(err)}`,
6767
- });
6873
+ // #2554: the throw is the deployment's or its runtime's text, and the deployment serving
6874
+ // a scope is the vertical's code, often another tenant's. The tenant's row says only what
6875
+ // happened; the text is staff's row, under the same reference.
6876
+ const missed = `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the deployment serving this scope; the heal sweep will deliver it again`;
6877
+ await this.admin.recordOpsFailure(withheldOpsFailure({ actor, operation: 'scope.lifecycle', stage: 'deliver', tenantId, scopeId }, `${missed}: ${err instanceof Error ? err.message : String(err)}`, (reference) => `${missed} (reference ${reference})`));
6768
6878
  }
6769
6879
  catch (recordErr) {
6770
6880
  console.error('substrat: could not record a lifecycle delivery failure (#1713)', recordErr);
@@ -7564,6 +7674,14 @@ export class CloudflareScopeHost {
7564
7674
  // #2430: refused before anything is written, rather than at the top-up after the projection.
7565
7675
  if (input.entityGrants)
7566
7676
  await this.refuseReservedShapes(input.entityGrants, input.scopeId);
7677
+ // #2447: the role set this provision defines, and the owner's seat in it — a person's seat, so
7678
+ // the person's door — are judged before anything is written. A refusal fails the provision and
7679
+ // writes nothing, so a seat a reconcile would have re-asserted is left exactly as it is.
7680
+ const reserved = await this.refuseReservedRoleSet(input.roles, input.tenantId, input.scopeId);
7681
+ const ownerRole = input.roles.find((r) => r.key === input.ownerRoleKey);
7682
+ const ownerRefusal = ownerRole ? reservedRoleRefusal(ownerRole, reserved, 'person') : null;
7683
+ if (ownerRefusal)
7684
+ throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
7567
7685
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
7568
7686
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7569
7687
  const stub = this.scopeStub(input.scopeId);
@@ -7639,6 +7757,110 @@ export class CloudflareScopeHost {
7639
7757
  ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
7640
7758
  };
7641
7759
  }
7760
+ /**
7761
+ * #2447: what a tenant's role definition is judged with, beyond the stored roles the ControlPlaneDO
7762
+ * reads in its own unit:
7763
+ * - the reservations of this host's modules, joined with the scope class's. Every scope on this
7764
+ * binding is one class, so one of the tenant's scopes answers for all, and the fan-out after
7765
+ * the write projects into exactly those scopes. A tenant with no scope yet is judged by this
7766
+ * host's modules alone (a stated residual: its first scope's projection is not judged again);
7767
+ * - for a `holder: 'system'` definition of a key with no system-held definition stored (none at
7768
+ * all included: a seat can outlive its role's row), whether anyone but a system principal holds a
7769
+ * live scope-level seat in it in one of those scopes (the ControlPlaneDO reads the tenant-level
7770
+ * seats). No scope is asked for seats otherwise.
7771
+ *
7772
+ * Every scope that is not reaped is read, a scope archived straight from `provisioning` included:
7773
+ * the platform's door seats into a provisioning scope, and unarchive brings the seat back. A DO
7774
+ * cannot be asked anything without waking it, and waking one that never ran would create storage it
7775
+ * never had (#1524), so such a scope is read only when its directory row records a migration
7776
+ * (`schema_version` past `'0'`). That is not a mark of instantiation: it counts the module
7777
+ * migrations applied, and is written only when a pass applied one, so a scope of a class with no
7778
+ * module migrations reads `'0'` though it holds a seat. One that records none is not read, and the
7779
+ * definition fails closed where that scope could decide it: `unreadable` names it, and the caller
7780
+ * refuses a definition its seats, or its class's reservations when no other scope gives them, would
7781
+ * decide. So while such a scope is the tenant's only one, every definition is refused until it is
7782
+ * unarchived or reaped. No product path reaches this today: the dashboard is the one Cloudflare
7783
+ * caller of `defineRole`, and its class has migrations.
7784
+ *
7785
+ * A stated residual: this half is read before the ControlPlaneDO's unit, not in it, so a scope-level
7786
+ * seat made in between is not seen. The tenant-level seats and the stored roles are read in the unit.
7787
+ */
7788
+ async definitionContext(tenantId, role) {
7789
+ const listed = await this.cp.listScopes({ tenantId, status: [...SEAT_HOLDING_SCOPE_STATUSES] });
7790
+ const neverStored = (s) => (s.status === 'archived' || s.status === 'archiving') && s.archived_from_status === 'provisioning' && (s.schema_version ?? '0') === '0';
7791
+ const scopes = listed.filter((s) => !neverStored(s));
7792
+ const unreadableScope = listed.find(neverStored);
7793
+ const unreadable = unreadableScope
7794
+ ? `scope ${unreadableScope.scope_id} was archived straight from provisioning and records no migration, so its seats and its ` +
7795
+ `class's reservations cannot be read without creating storage it may never have had. Unarchive it (or reap it) first`
7796
+ : null;
7797
+ const reservation = { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
7798
+ if (scopes.length > 0) {
7799
+ const here = await this.scopeStub(scopes[0].scope_id).reservationHere();
7800
+ reservation.reserved = [...new Set([...reservation.reserved, ...here.reserved])];
7801
+ reservation.declared = [...new Set([...reservation.declared, ...here.declared])];
7802
+ }
7803
+ let seatedInScopes = false;
7804
+ let seatsDecide = false;
7805
+ if (role.holder === 'system') {
7806
+ const stored = await this.cp.getRole(tenantId, role.key);
7807
+ if (stored?.holder !== 'system') {
7808
+ seatsDecide = true;
7809
+ for (const s of scopes) {
7810
+ if ((await this.scopeStub(s.scope_id).roleStateHere(tenantId)).seated.includes(role.key)) {
7811
+ seatedInScopes = true;
7812
+ break;
7813
+ }
7814
+ }
7815
+ }
7816
+ }
7817
+ // Fail closed only where the unread scope could decide: its seats (a system-held definition the
7818
+ // seats judge), or the class's reservations when no other scope gave them.
7819
+ const decides = unreadable !== null && ((seatsDecide && !seatedInScopes) || scopes.length === 0);
7820
+ return { reservation, seatedInScopes, unreadable: decides ? unreadable : null };
7821
+ }
7822
+ /**
7823
+ * #2447: why `roles` may not be defined on `scopeId`, judged as a set against this host's
7824
+ * reservations joined with the scope class's, or null. A restore asks it before it imports a dump,
7825
+ * which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
7826
+ * roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
7827
+ * staged restore has not (its pages are already in the scope), so it is judged as a set alone
7828
+ * here, and `projectRolesLocal` judges again after the import, against what the dump brought.
7829
+ */
7830
+ async roleSetRefusalLocal(scopeId, roles, dump) {
7831
+ const here = await this.scopeStub(scopeId).reservationHere();
7832
+ 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
+ }
7834
+ /**
7835
+ * #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
7836
+ * holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
7837
+ * (`roleStateHere`), read for a promote to judge the incoming version's set with
7838
+ * (`releaseRoleSetRefusal`) before it moves the scope. Read-only, and names no principal.
7839
+ */
7840
+ async roleStateLocal(tenantId, scopeId) {
7841
+ return this.scopeStub(scopeId).roleStateHere(tenantId);
7842
+ }
7843
+ /**
7844
+ * #2447: refuse a role set a CP-less provision or restore defines on `scopeId` for `tenantId`
7845
+ * (`reservedRoleSetRefusal`), against this host's reservations joined with the scope class's,
7846
+ * and the roles the scope stores for the tenant now with their seats there. Answers the reserved
7847
+ * keys it judged with, for the seats the same call makes.
7848
+ *
7849
+ * A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
7850
+ * (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
7851
+ * wide, and a reconcile runs again every pass.
7852
+ */
7853
+ async refuseReservedRoleSet(roles, tenantId, scopeId) {
7854
+ const stub = this.scopeStub(scopeId);
7855
+ const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
7856
+ 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) });
7858
+ // Named by tenant and scope as well as role: a release push admitted can be refused here at every
7859
+ // scope where the role is seated, and staff find each one by this line (K-55).
7860
+ if (refusal)
7861
+ throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${refusal}`);
7862
+ return reserved;
7863
+ }
7642
7864
  /**
7643
7865
  * #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
7644
7866
  * connection acts for the external provider holding its token, so a grant naming a key reserved
@@ -7734,15 +7956,26 @@ export class CloudflareScopeHost {
7734
7956
  * storage. Idempotent (writeTuple is INSERT OR REPLACE). `roleKey` must be one the scope
7735
7957
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
7736
7958
  */
7737
- async assignScopeRole(scopeId, principal, roleKey) {
7738
- // #2430: a platform door, as `HostAdmin.assignRole` is — a role mixing reserved and other keys
7739
- // is refused, against the scope class's own reservations and the role as the checker reads it
7740
- // for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens without one).
7741
- const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, 'platform');
7959
+ async assignScopeRole(scopeId, principal, roleKey,
7960
+ /** #2447: `person` when the caller knows it seats a person (the owner hand-over); the platform's door otherwise. */
7961
+ door = 'platform') {
7962
+ // #2430: a platform door by default, as `HostAdmin.assignRole` is — a role mixing reserved and
7963
+ // other keys is refused, against the scope class's own reservations and the role as the checker
7964
+ // reads it for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens
7965
+ // without one). The person's door also refuses any reserved key and a system-held role (#2447).
7966
+ const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
7742
7967
  if (refusal)
7743
7968
  throw substratError('forbidden', refusal);
7744
7969
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
7745
7970
  }
7971
+ /**
7972
+ * #2447: why `roleKey` may not be seated at scope level through `door`, or null — the question
7973
+ * `assignScopeRole` asks before it writes, for a caller (the owner hand-over) that must ask it
7974
+ * before anything else moves.
7975
+ */
7976
+ async scopeRoleRefusal(scopeId, roleKey, door) {
7977
+ return this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
7978
+ }
7746
7979
  /**
7747
7980
  * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
7748
7981
  * A tombstone, exactly as `HostAdmin.unassignRole` writes for a scope-level assignment: