@substrat-run/adapter-cloudflare 0.119.0 → 0.121.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/control-plane-do.d.ts +83 -17
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +141 -44
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +96 -4
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +431 -76
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/kick-coalescer-do.d.ts +57 -0
- package/dist/kick-coalescer-do.d.ts.map +1 -0
- package/dist/kick-coalescer-do.js +106 -0
- package/dist/kick-coalescer-do.js.map +1 -0
- package/dist/route-resolver.d.ts +4 -3
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +5 -4
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +7 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +69 -12
- package/dist/scope-do.js.map +1 -1
- package/package.json +7 -5
package/dist/host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, } from '@substrat-run/contracts';
|
|
1
|
+
import { fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, } from '@substrat-run/contracts';
|
|
2
2
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
3
|
-
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, systemSwitchedOffMessage, CrossVerticalRegistry, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
|
|
3
|
+
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, withRecorded, systemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
|
|
4
4
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
5
5
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
6
6
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
@@ -31,7 +31,7 @@ function unwrapCapabilityReply(reply) {
|
|
|
31
31
|
/**
|
|
32
32
|
* A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
|
|
33
33
|
* The hot path a served scope actually touches becomes trust-the-upstream:
|
|
34
|
-
* - `
|
|
34
|
+
* - `scopeAccessRefusal` / `setMigrationState` → no-op: the router already gated the
|
|
35
35
|
* scope's lifecycle + tenancy from the shared directory, so the vertical trusts the
|
|
36
36
|
* asserted node rather than re-reading a directory it does not have.
|
|
37
37
|
* - `tenantHoldsEntitlement` → true: the SKU was enforced on the shared control plane
|
|
@@ -43,11 +43,20 @@ function unwrapCapabilityReply(reply) {
|
|
|
43
43
|
function nullControlPlane() {
|
|
44
44
|
const noop = async () => undefined;
|
|
45
45
|
const passthrough = {
|
|
46
|
-
|
|
46
|
+
scopeAccessRefusal: noop,
|
|
47
47
|
setMigrationState: noop,
|
|
48
48
|
recordAdmin: noop,
|
|
49
49
|
recordAccess: noop,
|
|
50
50
|
tenantHoldsEntitlement: async () => true,
|
|
51
|
+
// #1674: the schedule switch's record is a DIRECTORY store, and a CP-less host has no
|
|
52
|
+
// directory — the shared control plane that delegates here keeps it. Same posture as
|
|
53
|
+
// `recordAdmin`: nothing to write, nothing recorded to read back.
|
|
54
|
+
recordSystemSwitchedOff: noop,
|
|
55
|
+
recordSystemSwitchedOn: async () => null,
|
|
56
|
+
restoreSystemSwitchRecord: noop,
|
|
57
|
+
listSystemSwitches: async () => [],
|
|
58
|
+
systemSwitchRecordsOf: async () => [],
|
|
59
|
+
switchedOffModulesOf: async () => [],
|
|
51
60
|
};
|
|
52
61
|
return new Proxy({}, {
|
|
53
62
|
get: (_t, prop) => typeof prop === 'string' && prop in passthrough
|
|
@@ -124,6 +133,8 @@ export class CloudflareScopeHost {
|
|
|
124
133
|
systemSwitchDelegation;
|
|
125
134
|
/** #1706: the peer kill switch's reach into the deployment serving a scope. */
|
|
126
135
|
peerSwitchDelegation;
|
|
136
|
+
/** #1705 PR 3: the replay lever's reach into the deployment serving a consumer scope. */
|
|
137
|
+
importCursorDelegation;
|
|
127
138
|
/**
|
|
128
139
|
* MUST be constructed per request. Never cache an instance across requests.
|
|
129
140
|
*
|
|
@@ -152,6 +163,7 @@ export class CloudflareScopeHost {
|
|
|
152
163
|
this.eventDrainDelegation = options.eventDrainDelegation;
|
|
153
164
|
this.systemSwitchDelegation = options.systemSwitchDelegation;
|
|
154
165
|
this.peerSwitchDelegation = options.peerSwitchDelegation;
|
|
166
|
+
this.importCursorDelegation = options.importCursorDelegation;
|
|
155
167
|
this.admin = this.buildAdmin();
|
|
156
168
|
}
|
|
157
169
|
// -- registration mechanics (validation only) -----------------------------
|
|
@@ -341,7 +353,7 @@ export class CloudflareScopeHost {
|
|
|
341
353
|
async drainDue(tenantId, scopeId) {
|
|
342
354
|
// Same lifecycle gate `getScope` applies (K-3): a suspended or archived scope
|
|
343
355
|
// does not get its effects driven either.
|
|
344
|
-
await this.
|
|
356
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
345
357
|
await this.migrateAndRecord(scopeId);
|
|
346
358
|
// #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
|
|
347
359
|
// records no invocation, which is what distinguishes it from the first attempt the
|
|
@@ -380,14 +392,14 @@ export class CloudflareScopeHost {
|
|
|
380
392
|
};
|
|
381
393
|
}
|
|
382
394
|
async startJobRun(tenantId, scopeId, input) {
|
|
383
|
-
await this.
|
|
395
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
384
396
|
await this.migrateAndRecord(scopeId);
|
|
385
397
|
return jobRunOf(await startJobRun(this.jobStore(scopeId), input, ulid, () => new Date().toISOString()));
|
|
386
398
|
}
|
|
387
399
|
async runDueJobs(tenantId, scopeId, options) {
|
|
388
400
|
// Same lifecycle gate as `drainDue`: a suspended scope's runs wait rather than
|
|
389
401
|
// advance, and an archived one's never move again.
|
|
390
|
-
await this.
|
|
402
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
391
403
|
await this.migrateAndRecord(scopeId);
|
|
392
404
|
return runDueJobRuns({
|
|
393
405
|
store: this.jobStore(scopeId),
|
|
@@ -399,7 +411,7 @@ export class CloudflareScopeHost {
|
|
|
399
411
|
});
|
|
400
412
|
}
|
|
401
413
|
async jobRuns(tenantId, scopeId, filter) {
|
|
402
|
-
await this.
|
|
414
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
403
415
|
await this.migrateAndRecord(scopeId);
|
|
404
416
|
return (await this.jobStore(scopeId).list(filter ?? {})).map(jobRunOf);
|
|
405
417
|
}
|
|
@@ -409,7 +421,7 @@ export class CloudflareScopeHost {
|
|
|
409
421
|
// same context build as the in-process path, so the handler cannot tell which host
|
|
410
422
|
// ran it. On a CP-less host `connectorContext` throws from the null control plane:
|
|
411
423
|
// fail closed, exactly the hole routing exists to avoid.
|
|
412
|
-
await this.
|
|
424
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
413
425
|
this.causedBy = event.id;
|
|
414
426
|
try {
|
|
415
427
|
await handler(await this.connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
|
|
@@ -419,7 +431,7 @@ export class CloudflareScopeHost {
|
|
|
419
431
|
}
|
|
420
432
|
}
|
|
421
433
|
async executorDeadLetters(tenantId, scopeId) {
|
|
422
|
-
await this.
|
|
434
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
423
435
|
await this.migrateAndRecord(scopeId);
|
|
424
436
|
return this.scopeStub(scopeId).executorDeadLetters();
|
|
425
437
|
}
|
|
@@ -432,18 +444,18 @@ export class CloudflareScopeHost {
|
|
|
432
444
|
return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
|
|
433
445
|
}
|
|
434
446
|
async listPlatformRequests(tenantId, scopeId) {
|
|
435
|
-
await this.
|
|
447
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
436
448
|
await this.migrateAndRecord(scopeId);
|
|
437
449
|
// Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
|
|
438
450
|
return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
|
|
439
451
|
}
|
|
440
452
|
async listPlatformRequestHistory(tenantId, scopeId, filter) {
|
|
441
|
-
await this.
|
|
453
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
442
454
|
await this.migrateAndRecord(scopeId);
|
|
443
455
|
return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(platformRequestOf);
|
|
444
456
|
}
|
|
445
457
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
446
|
-
await this.
|
|
458
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
447
459
|
await this.migrateAndRecord(scopeId);
|
|
448
460
|
await this.scopeStub(scopeId).settlePlatformRequest(id, outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure));
|
|
449
461
|
}
|
|
@@ -473,7 +485,7 @@ export class CloudflareScopeHost {
|
|
|
473
485
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
474
486
|
// K-3: a scope under another tenant is indistinguishable from one that does not exist.
|
|
475
487
|
if (!rec)
|
|
476
|
-
throw
|
|
488
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
477
489
|
if (rec.status !== 'active' && rec.status !== 'provisioning') {
|
|
478
490
|
throw new Error(`scope not migratable (status: ${rec.status}): ${scopeId}`);
|
|
479
491
|
}
|
|
@@ -783,6 +795,13 @@ export class CloudflareScopeHost {
|
|
|
783
795
|
for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
|
|
784
796
|
await this.scopeStub(input.scopeId).seatTuple(seat.subject, seat.relation, seat.object, null);
|
|
785
797
|
}
|
|
798
|
+
// #1674: re-assert the recorded OFF positions after the seat (`system-switch-record.ts`).
|
|
799
|
+
// Only where this seat landed in the scope's real store — a CP-less host, or a scope
|
|
800
|
+
// bound to no vertical. A scope a vertical's deployment serves is seated there, later,
|
|
801
|
+
// and re-asserted after that deployment's reconcile instead.
|
|
802
|
+
if (this.cpLess || record.vertical === null) {
|
|
803
|
+
await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId });
|
|
804
|
+
}
|
|
786
805
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
787
806
|
if (created) {
|
|
788
807
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -918,7 +937,7 @@ export class CloudflareScopeHost {
|
|
|
918
937
|
// gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
|
|
919
938
|
// in the same transaction); bytes go straight to the per-tenant R2 bucket through the
|
|
920
939
|
// binding the vertical's worker resolved — never through the DO.
|
|
921
|
-
await this.
|
|
940
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
922
941
|
await this.migrateAndRecord(scopeId);
|
|
923
942
|
const store = await this.resolveAttachmentStore(tenantId);
|
|
924
943
|
return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
|
|
@@ -934,12 +953,12 @@ export class CloudflareScopeHost {
|
|
|
934
953
|
throw new Error(`connection ${connectionId} is revoked`);
|
|
935
954
|
const scope = await this.cp.getScopeRecord(conn.tenant_id, scopeId);
|
|
936
955
|
if (!scope)
|
|
937
|
-
throw
|
|
956
|
+
throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
|
|
938
957
|
if (scope.vertical !== conn.vertical) {
|
|
939
958
|
throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
|
|
940
959
|
`runs '${scope.vertical ?? 'none'}'`);
|
|
941
960
|
}
|
|
942
|
-
await this.
|
|
961
|
+
await this.validateScopeAccess(conn.tenant_id, scopeId);
|
|
943
962
|
// #574: same delegation as getConnectorScope. `upload` is the verb the reconcile
|
|
944
963
|
// path needs (landing the sealed PDF) and `open` the one the outbound path needs
|
|
945
964
|
// (sending the vertical's own document, #711). `list` and `remove` still fail
|
|
@@ -1100,14 +1119,14 @@ export class CloudflareScopeHost {
|
|
|
1100
1119
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
1101
1120
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1102
1121
|
if (!existing)
|
|
1103
|
-
throw
|
|
1122
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1104
1123
|
await this.scopeStub(scopeId).importDump(dump.tables, scopeId);
|
|
1105
1124
|
await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1106
1125
|
}
|
|
1107
1126
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
1108
1127
|
const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1109
1128
|
if (!source)
|
|
1110
|
-
throw
|
|
1129
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1111
1130
|
const dump = await this.admin.exportScope(actor, tenantId, scopeId);
|
|
1112
1131
|
const snapshotId = ulid();
|
|
1113
1132
|
await this.importScope(actor, {
|
|
@@ -1132,7 +1151,7 @@ export class CloudflareScopeHost {
|
|
|
1132
1151
|
// #509 ask (b)). A PRIMARY scope keeps the platform's tombstone-only rule (archive it).
|
|
1133
1152
|
const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1134
1153
|
if (!rec)
|
|
1135
|
-
throw
|
|
1154
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1136
1155
|
if (!rec.forkedFrom && rec.kind !== 'preview') {
|
|
1137
1156
|
throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
|
|
1138
1157
|
`archive a primary scope instead`);
|
|
@@ -1188,7 +1207,7 @@ export class CloudflareScopeHost {
|
|
|
1188
1207
|
async getScope(principal, tenantId, scopeId, options) {
|
|
1189
1208
|
// Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
|
|
1190
1209
|
// evaluated durably in the ControlPlaneDO. A throw propagates here.
|
|
1191
|
-
await this.
|
|
1210
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1192
1211
|
await this.migrateAndRecord(scopeId);
|
|
1193
1212
|
return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
|
|
1194
1213
|
}
|
|
@@ -1211,12 +1230,12 @@ export class CloudflareScopeHost {
|
|
|
1211
1230
|
throw new Error(`connection ${connectionId} is revoked`);
|
|
1212
1231
|
const scope = await this.cp.getScopeRecord(conn.tenant_id, scopeId);
|
|
1213
1232
|
if (!scope)
|
|
1214
|
-
throw
|
|
1233
|
+
throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
|
|
1215
1234
|
if (scope.vertical !== conn.vertical) {
|
|
1216
1235
|
throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
|
|
1217
1236
|
`runs '${scope.vertical ?? 'none'}'`);
|
|
1218
1237
|
}
|
|
1219
|
-
await this.
|
|
1238
|
+
await this.validateScopeAccess(conn.tenant_id, scopeId);
|
|
1220
1239
|
// #574: a scope served by ANOTHER deployment (the shared control plane running the
|
|
1221
1240
|
// connector pass for a dispatch vertical) — the write-back rides the delegation
|
|
1222
1241
|
// seam; migration is the serving deployment's business, exactly like provision.
|
|
@@ -1263,7 +1282,7 @@ export class CloudflareScopeHost {
|
|
|
1263
1282
|
const record = await this.resolveImpersonation(session, tenantId, scopeId);
|
|
1264
1283
|
// The same lifecycle gate the principal door applies: a suspended tenant
|
|
1265
1284
|
// refuses a support session exactly as it refuses a user.
|
|
1266
|
-
await this.
|
|
1285
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1267
1286
|
await this.migrateAndRecord(scopeId);
|
|
1268
1287
|
return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
|
|
1269
1288
|
}
|
|
@@ -1277,7 +1296,7 @@ export class CloudflareScopeHost {
|
|
|
1277
1296
|
if (!this.moduleIds.has(moduleId)) {
|
|
1278
1297
|
throw new Error(`module not registered on this host: ${moduleId}`);
|
|
1279
1298
|
}
|
|
1280
|
-
await this.
|
|
1299
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1281
1300
|
await this.migrateAndRecord(scopeId);
|
|
1282
1301
|
return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
|
|
1283
1302
|
}
|
|
@@ -1310,7 +1329,7 @@ export class CloudflareScopeHost {
|
|
|
1310
1329
|
* capability row lives. The answer is re-parsed on this side of the RPC.
|
|
1311
1330
|
*/
|
|
1312
1331
|
async exchangeCapability(tenantId, scopeId, secret, options) {
|
|
1313
|
-
await this.
|
|
1332
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1314
1333
|
await this.migrateAndRecord(scopeId);
|
|
1315
1334
|
const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode);
|
|
1316
1335
|
return capabilityExchange.nullable().parse(outcome);
|
|
@@ -1324,7 +1343,7 @@ export class CloudflareScopeHost {
|
|
|
1324
1343
|
if (!plausibleSessionToken(sessionToken)) {
|
|
1325
1344
|
throw substratError('unauthenticated', 'not a capability session token');
|
|
1326
1345
|
}
|
|
1327
|
-
await this.
|
|
1346
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1328
1347
|
await this.migrateAndRecord(scopeId);
|
|
1329
1348
|
const hash = await capabilityTokenHash(sessionToken);
|
|
1330
1349
|
return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
|
|
@@ -1370,7 +1389,7 @@ export class CloudflareScopeHost {
|
|
|
1370
1389
|
}
|
|
1371
1390
|
this.assertServedHere(record, scopeId, verb);
|
|
1372
1391
|
}
|
|
1373
|
-
await this.
|
|
1392
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1374
1393
|
await this.migrateAndRecord(scopeId);
|
|
1375
1394
|
}
|
|
1376
1395
|
/**
|
|
@@ -1383,7 +1402,7 @@ export class CloudflareScopeHost {
|
|
|
1383
1402
|
if (!plausibleSessionToken(sessionToken)) {
|
|
1384
1403
|
throw substratError('unauthenticated', 'not a capability session token');
|
|
1385
1404
|
}
|
|
1386
|
-
await this.
|
|
1405
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1387
1406
|
await this.migrateAndRecord(scopeId);
|
|
1388
1407
|
const store = await this.resolveAttachmentStore(tenantId);
|
|
1389
1408
|
const capabilitySession = await capabilityTokenHash(sessionToken);
|
|
@@ -1407,13 +1426,21 @@ export class CloudflareScopeHost {
|
|
|
1407
1426
|
* and by the verbs that read a record of their own, so a caller never meets two of them.
|
|
1408
1427
|
*/
|
|
1409
1428
|
assertServedHere(record, scopeId, verb) {
|
|
1410
|
-
if (this.
|
|
1429
|
+
if (this.servesScopesElsewhereNow && record.vertical !== null) {
|
|
1411
1430
|
throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${record.vertical}' deployment, ` +
|
|
1412
1431
|
'which a peer reaches through the platform, not through the shared control plane');
|
|
1413
1432
|
}
|
|
1414
1433
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1434
|
+
/** `ScopeHost.servesScopesElsewhere` (#1705 PR 3): any delegation set means the shared control plane. */
|
|
1435
|
+
servesScopesElsewhere() {
|
|
1436
|
+
return this.servesScopesElsewhereNow;
|
|
1437
|
+
}
|
|
1438
|
+
get servesScopesElsewhereNow() {
|
|
1439
|
+
return Boolean(this.connectorDelegation ||
|
|
1440
|
+
this.systemSwitchDelegation ||
|
|
1441
|
+
this.peerSwitchDelegation ||
|
|
1442
|
+
this.eventDrainDelegation ||
|
|
1443
|
+
this.importCursorDelegation);
|
|
1417
1444
|
}
|
|
1418
1445
|
/**
|
|
1419
1446
|
* Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
|
|
@@ -1427,7 +1454,7 @@ export class CloudflareScopeHost {
|
|
|
1427
1454
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1428
1455
|
if (!rec)
|
|
1429
1456
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1430
|
-
if (this.
|
|
1457
|
+
if (this.servesScopesElsewhereNow && rec.vertical !== null) {
|
|
1431
1458
|
throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
|
|
1432
1459
|
'deployment, and platform capability verbs are not delegated there yet');
|
|
1433
1460
|
}
|
|
@@ -1696,6 +1723,8 @@ export class CloudflareScopeHost {
|
|
|
1696
1723
|
const enqueued = envelope.platformRequests + (drained.routedToPlatform ?? 0);
|
|
1697
1724
|
if (enqueued > 0)
|
|
1698
1725
|
options?.onPlatformRequests?.(enqueued);
|
|
1726
|
+
if ((envelope.exported ?? 0) > 0)
|
|
1727
|
+
options?.onExportedEvents?.(envelope.exported);
|
|
1699
1728
|
if (envelope.concurrency)
|
|
1700
1729
|
invokeOptions?.onEntityVersion?.(envelope.concurrency.version);
|
|
1701
1730
|
if (envelope.idempotency?.replayed)
|
|
@@ -1744,7 +1773,22 @@ export class CloudflareScopeHost {
|
|
|
1744
1773
|
...(r.serving_version_id ? { servingVersionId: r.serving_version_id } : {}),
|
|
1745
1774
|
createdAt: r.created_at,
|
|
1746
1775
|
});
|
|
1747
|
-
|
|
1776
|
+
// A listed version carries its surfaces as lifted JSON text, not its manifest (#1677):
|
|
1777
|
+
// read on exactly `outboundOfManifestJson`'s terms, so the two reads agree.
|
|
1778
|
+
const stringListOfJson = (json) => {
|
|
1779
|
+
if (!json)
|
|
1780
|
+
return null;
|
|
1781
|
+
try {
|
|
1782
|
+
const v = JSON.parse(json);
|
|
1783
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null;
|
|
1784
|
+
}
|
|
1785
|
+
catch {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
const mapVersion = (r) => versionRecord(r, outboundOfManifestJson(r.manifest_json), callsOfManifestJson(r.manifest_json));
|
|
1790
|
+
const mapListedVersion = (r) => versionRecord(r, stringListOfJson(r.outbound_json), stringListOfJson(r.calls_json));
|
|
1791
|
+
const versionRecord = (r, outbound, calls) => verticalVersion.parse({
|
|
1748
1792
|
id: r.id,
|
|
1749
1793
|
verticalSlug: r.vertical_slug,
|
|
1750
1794
|
version: r.version,
|
|
@@ -1755,8 +1799,8 @@ export class CloudflareScopeHost {
|
|
|
1755
1799
|
admission: r.admission,
|
|
1756
1800
|
admissionNote: r.admission_note,
|
|
1757
1801
|
origin: r.origin_json ? JSON.parse(r.origin_json) : null,
|
|
1758
|
-
outbound
|
|
1759
|
-
calls
|
|
1802
|
+
outbound,
|
|
1803
|
+
calls,
|
|
1760
1804
|
createdAt: r.created_at,
|
|
1761
1805
|
});
|
|
1762
1806
|
const mapOrg = (r) => orgSchema.parse({
|
|
@@ -1812,7 +1856,13 @@ export class CloudflareScopeHost {
|
|
|
1812
1856
|
// reap's `backupRef` (#493) is the first. Kept out of `before` deliberately: it
|
|
1813
1857
|
// describes what the transition DID, not the state it left.
|
|
1814
1858
|
afterExtra) => {
|
|
1815
|
-
|
|
1859
|
+
// The DO answers a refusal as data (#1718): a throw from there would arrive here
|
|
1860
|
+
// flattened, its code gone. The pair check stays in the DO, in the same read as the
|
|
1861
|
+
// write, so a row deleted concurrently is still refused `not_found`.
|
|
1862
|
+
const before = await this.cp.transitionScopeOrRefusal(tenantId, scopeId, from, to, action);
|
|
1863
|
+
if (!before.ok) {
|
|
1864
|
+
throw before.code ? substratError(before.code, before.message) : new Error(before.message);
|
|
1865
|
+
}
|
|
1816
1866
|
// The audit target carries the scope's vertical (control-plane.md §4.4:
|
|
1817
1867
|
// "vertical stays null until §4.2 lifecycle actions that name one"). The DO
|
|
1818
1868
|
// returns it with the previous status, so the trail cannot disagree with
|
|
@@ -1869,7 +1919,7 @@ export class CloudflareScopeHost {
|
|
|
1869
1919
|
if (delegation) {
|
|
1870
1920
|
// The gate's lifecycle half still runs (a suspended tenant or scope cannot be
|
|
1871
1921
|
// switched); its `scopeStub` half is what the delegation replaces.
|
|
1872
|
-
await this.
|
|
1922
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
1873
1923
|
}
|
|
1874
1924
|
else {
|
|
1875
1925
|
await this.peerScopeGate(tenantId, scopeId, action);
|
|
@@ -1910,9 +1960,15 @@ export class CloudflareScopeHost {
|
|
|
1910
1960
|
permissions: outcome.permissions,
|
|
1911
1961
|
};
|
|
1912
1962
|
};
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1963
|
+
/**
|
|
1964
|
+
* Where one scope's schedule switch lands — the ONE rule `switchSystem` and the #1674
|
|
1965
|
+
* re-assert share, so a re-assert moves exactly the switch an operator's OFF would have.
|
|
1966
|
+
* A scope bound to no vertical (#1666 review) has no deployment to delegate to: its store
|
|
1967
|
+
* is the DO here, so the switch moves (or answers `held: false`) here too. Delegating it
|
|
1968
|
+
* would throw "no deployment serving scope" instead. A scope WITH a vertical still
|
|
1969
|
+
* delegates, and still fails loudly when none serves it.
|
|
1970
|
+
*/
|
|
1971
|
+
const systemSwitchTarget = async (tenantId, scopeId) => {
|
|
1916
1972
|
let vertical = null;
|
|
1917
1973
|
if (!this.cpLess) {
|
|
1918
1974
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
@@ -1920,6 +1976,16 @@ export class CloudflareScopeHost {
|
|
|
1920
1976
|
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1921
1977
|
vertical = rec.vertical;
|
|
1922
1978
|
}
|
|
1979
|
+
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
1980
|
+
const move = (moduleId, to, at) => delegation
|
|
1981
|
+
? delegation.switch({ tenantId, scopeId, moduleId, to })
|
|
1982
|
+
: this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
|
|
1983
|
+
return { vertical, move };
|
|
1984
|
+
};
|
|
1985
|
+
const switchSystem = async (actor, raw, to) => {
|
|
1986
|
+
const input = systemSwitch.parse(raw);
|
|
1987
|
+
const { tenantId, scopeId } = input.node;
|
|
1988
|
+
const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
|
|
1923
1989
|
// AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
|
|
1924
1990
|
// outcome row after — every attempt, a repeat included. The scope's store and the
|
|
1925
1991
|
// admin log are separate, so no order makes the pair atomic; this one fails toward
|
|
@@ -1930,36 +1996,84 @@ export class CloudflareScopeHost {
|
|
|
1930
1996
|
const target = { tenantId, scopeId, vertical };
|
|
1931
1997
|
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
1932
1998
|
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
const
|
|
1999
|
+
// The directory's record (#1674): ON before the scope moves, OFF after it held — see
|
|
2000
|
+
// `recordSystemSwitchedOn` for why that order is the safe one.
|
|
2001
|
+
const at = new Date().toISOString();
|
|
2002
|
+
const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
|
|
2003
|
+
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
2004
|
+
let prior = null;
|
|
2005
|
+
if (to === 'on') {
|
|
2006
|
+
try {
|
|
2007
|
+
prior = await this.cp.recordSystemSwitchedOn(record);
|
|
2008
|
+
}
|
|
2009
|
+
catch (err) {
|
|
2010
|
+
// Nothing has moved: fail the call here, audited, rather than switch a scope on
|
|
2011
|
+
// whose record still says off (the next reconcile would switch it back off).
|
|
2012
|
+
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
|
|
2013
|
+
throw err;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* A record write AFTER the scope moved (review #5): retried once, and a failure of both
|
|
2018
|
+
* is answered rather than swallowed — it lands on the outcome row as `recordError`, and
|
|
2019
|
+
* the call does not report plain success. A lost OFF record is exactly what a wipe then
|
|
2020
|
+
* turns into a module running again.
|
|
2021
|
+
*/
|
|
2022
|
+
const recordWrite = async (write) => {
|
|
2023
|
+
try {
|
|
2024
|
+
await write();
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
catch {
|
|
2028
|
+
try {
|
|
2029
|
+
await write();
|
|
2030
|
+
return null;
|
|
2031
|
+
}
|
|
2032
|
+
catch (err) {
|
|
2033
|
+
return errorOf(err);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
1938
2037
|
let outcome;
|
|
1939
2038
|
try {
|
|
1940
|
-
outcome =
|
|
1941
|
-
? await delegation.switch({ tenantId, scopeId, moduleId: input.moduleId, to })
|
|
1942
|
-
: await this.scopeStub(scopeId).switchSystemSchedules(input.moduleId, scopeId, to, new Date().toISOString());
|
|
2039
|
+
outcome = await move(input.moduleId, to, at);
|
|
1943
2040
|
}
|
|
1944
2041
|
catch (err) {
|
|
2042
|
+
const recordError = prior ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior)) : null;
|
|
1945
2043
|
// Best effort: the original error is what the caller must see, and the intent row
|
|
1946
2044
|
// already says an attempt was made.
|
|
1947
2045
|
await this.recordAdmin(actor, action, target, null, {
|
|
1948
2046
|
...base,
|
|
1949
2047
|
phase: 'failed',
|
|
1950
|
-
error:
|
|
2048
|
+
error: errorOf(err),
|
|
2049
|
+
...(recordError ? { recordError } : {}),
|
|
1951
2050
|
}).catch(() => undefined);
|
|
1952
2051
|
throw err;
|
|
1953
2052
|
}
|
|
2053
|
+
// OFF is recorded once the scope held it. A refused ON moved nothing, so its record
|
|
2054
|
+
// write is undone: left `on`, the next reconcile of a wiped scope would leave the
|
|
2055
|
+
// module running.
|
|
2056
|
+
const recordError = to === 'off' && outcome.held
|
|
2057
|
+
? await recordWrite(() => this.cp.recordSystemSwitchedOff(record))
|
|
2058
|
+
: to === 'on' && !outcome.held && prior
|
|
2059
|
+
? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior))
|
|
2060
|
+
: null;
|
|
1954
2061
|
await this.recordAdmin(actor, action, target, null, {
|
|
1955
2062
|
...base,
|
|
1956
2063
|
phase: outcome.held ? 'applied' : 'refused',
|
|
1957
2064
|
changed: outcome.changed,
|
|
1958
2065
|
permissions: outcome.permissions,
|
|
2066
|
+
...(recordError ? { recordError } : {}),
|
|
1959
2067
|
});
|
|
1960
2068
|
if (!outcome.held) {
|
|
1961
2069
|
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
1962
|
-
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`
|
|
2070
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
|
|
2071
|
+
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
2072
|
+
}
|
|
2073
|
+
if (recordError) {
|
|
2074
|
+
throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
|
|
2075
|
+
`could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
|
|
2076
|
+
`otherwise lose the switch`);
|
|
1963
2077
|
}
|
|
1964
2078
|
return {
|
|
1965
2079
|
operationId,
|
|
@@ -2036,23 +2150,65 @@ export class CloudflareScopeHost {
|
|
|
2036
2150
|
throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read its schedule switches`);
|
|
2037
2151
|
}
|
|
2038
2152
|
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2153
|
+
// The scope's own position and the directory's record are independent reads.
|
|
2154
|
+
const [states, recordedRows] = await Promise.all([
|
|
2155
|
+
delegation
|
|
2156
|
+
? delegation.status({ tenantId, scopeId })
|
|
2157
|
+
: this.scopeStub(scopeId).systemGrantsStatus().then((rows) => systemScheduleEntry.array().parse(rows)),
|
|
2158
|
+
this.cp.systemSwitchRecordsOf(tenantId, scopeId),
|
|
2159
|
+
]);
|
|
2042
2160
|
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
2043
2161
|
const explanations = offModules.size > 0
|
|
2044
2162
|
? await lastSwitchedOff(tenantId, scopeId, offModules)
|
|
2045
2163
|
: new Map();
|
|
2046
|
-
const result = states.map((s) => ({
|
|
2164
|
+
const result = withRecorded(states, new Map(recordedRows)).map((s) => ({
|
|
2047
2165
|
moduleId: s.moduleId,
|
|
2048
2166
|
schedules: s.schedules,
|
|
2049
2167
|
switchedOff: explanations.get(s.moduleId) ?? null,
|
|
2168
|
+
recorded: s.recorded,
|
|
2050
2169
|
}));
|
|
2051
2170
|
// K-24: reading the switch's position and any live incident reason is itself
|
|
2052
2171
|
// access-logged, the same as every other HostAdmin read.
|
|
2053
2172
|
await this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
|
|
2054
2173
|
return result;
|
|
2055
2174
|
};
|
|
2175
|
+
/**
|
|
2176
|
+
* #1674: put the directory's OFF positions back into one scope — see
|
|
2177
|
+
* `HostAdmin.reassertSystemSwitches`. It moves the switch through `systemSwitchTarget`,
|
|
2178
|
+
* so it lands wherever an operator's OFF would have — for a hosted scope, over the
|
|
2179
|
+
* `/internal/system-switch` route every deployment since #1666 answers.
|
|
2180
|
+
*/
|
|
2181
|
+
const reassertSystemSwitchesOf = async (actor, node) => {
|
|
2182
|
+
const { tenantId, scopeId } = node;
|
|
2183
|
+
const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
|
|
2184
|
+
const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
|
|
2185
|
+
// A hosted scope with no delegation configured (Copilot review): this host's own
|
|
2186
|
+
// namespace is the module-less placeholder, where the switch would answer `held: false`
|
|
2187
|
+
// quietly — a re-assert reported done, and a receipt written, while the deployment
|
|
2188
|
+
// serving the scope keeps its schedules running. Refused loudly instead, as the status
|
|
2189
|
+
// read is, and only when a re-assert is owed: nothing recorded, nothing to refuse.
|
|
2190
|
+
if (modules.length > 0 && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
|
|
2191
|
+
throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
|
|
2192
|
+
`its switched-off schedules in the deployment serving it`);
|
|
2193
|
+
}
|
|
2194
|
+
const at = new Date().toISOString();
|
|
2195
|
+
const results = [];
|
|
2196
|
+
for (const moduleId of modules) {
|
|
2197
|
+
const outcome = await move(moduleId, 'off', at);
|
|
2198
|
+
if (outcome.changed) {
|
|
2199
|
+
await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
|
|
2200
|
+
operationId: ulid(),
|
|
2201
|
+
moduleId,
|
|
2202
|
+
schedules: 'off',
|
|
2203
|
+
phase: 'applied',
|
|
2204
|
+
changed: true,
|
|
2205
|
+
permissions: outcome.permissions,
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
results.push({ moduleId, held: outcome.held, changed: outcome.changed });
|
|
2209
|
+
}
|
|
2210
|
+
return results;
|
|
2211
|
+
};
|
|
2056
2212
|
/**
|
|
2057
2213
|
* The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
|
|
2058
2214
|
* swapped, and delegated for the same reason the write is: a hosted scope's
|
|
@@ -2179,7 +2335,7 @@ export class CloudflareScopeHost {
|
|
|
2179
2335
|
if (grant.node.scopeId) {
|
|
2180
2336
|
const scope = await this.cp.getScopeRecord(grant.node.tenantId, grant.node.scopeId);
|
|
2181
2337
|
if (!scope) {
|
|
2182
|
-
throw
|
|
2338
|
+
throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
2183
2339
|
}
|
|
2184
2340
|
if (scope.vertical !== conn.vertical) {
|
|
2185
2341
|
throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
|
|
@@ -2255,6 +2411,14 @@ export class CloudflareScopeHost {
|
|
|
2255
2411
|
// #1674: the switch's status read — same gate, same delegation, and (unlike the
|
|
2256
2412
|
// deployment it may delegate to) the admin log to explain an `off` entry.
|
|
2257
2413
|
systemGrantsStatus: systemGrantsStatusOf,
|
|
2414
|
+
// #1674: the directory's record of the switch — the fleet read, and the re-assert a
|
|
2415
|
+
// scope that lost its marker gets after a wipe or a restore.
|
|
2416
|
+
listSystemSwitches: async (actor, filter) => {
|
|
2417
|
+
const rows = await this.cp.listSystemSwitches(filter);
|
|
2418
|
+
await this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
2419
|
+
return rows.map((r) => systemSwitchRecord.parse(r));
|
|
2420
|
+
},
|
|
2421
|
+
reassertSystemSwitches: reassertSystemSwitchesOf,
|
|
2258
2422
|
// #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
|
|
2259
2423
|
// the failure each leaves is the safe one: a mint whose audit row did not land never
|
|
2260
2424
|
// returned its secret, so nobody can ever exchange it; a revoke whose row did not land
|
|
@@ -2264,7 +2428,7 @@ export class CloudflareScopeHost {
|
|
|
2264
2428
|
// a bare message, and the caller would lose `validation_failed`. One function, so the
|
|
2265
2429
|
// two sides cannot disagree about what a valid mint is.
|
|
2266
2430
|
checkBecomeInput(input, new Date().toISOString());
|
|
2267
|
-
await this.
|
|
2431
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
2268
2432
|
const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'mintCapability');
|
|
2269
2433
|
const minted = await stub.mintBecomeCapability(input, actor);
|
|
2270
2434
|
await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical }, null, {
|
|
@@ -2303,7 +2467,7 @@ export class CloudflareScopeHost {
|
|
|
2303
2467
|
const parsed = bindHostnameInput.parse(input);
|
|
2304
2468
|
const scope = await this.cp.getScopeRecord(parsed.tenantId, parsed.scopeId);
|
|
2305
2469
|
if (!scope) {
|
|
2306
|
-
throw
|
|
2470
|
+
throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
|
|
2307
2471
|
}
|
|
2308
2472
|
const existing = await this.cp.readHostname(parsed.hostname);
|
|
2309
2473
|
// The holder's own status decides whether the name is reclaimable. Read it from
|
|
@@ -2477,7 +2641,7 @@ export class CloudflareScopeHost {
|
|
|
2477
2641
|
listVersions: async (actor, verticalSlug, page) => {
|
|
2478
2642
|
const rows = await this.cp.listVersions(verticalSlug, page);
|
|
2479
2643
|
await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
|
|
2480
|
-
return rows.map(
|
|
2644
|
+
return rows.map(mapListedVersion);
|
|
2481
2645
|
},
|
|
2482
2646
|
getVersion: async (actor, versionId, verticalSlug) => {
|
|
2483
2647
|
const row = await this.cp.readVersion(versionId);
|
|
@@ -2571,6 +2735,19 @@ export class CloudflareScopeHost {
|
|
|
2571
2735
|
await this.cp.setAdmission(versionId, 'admitted', null);
|
|
2572
2736
|
await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
|
|
2573
2737
|
},
|
|
2738
|
+
promotionImpact: async (actor, verticalSlug, channel, versionId) => {
|
|
2739
|
+
const incoming = await this.cp.readVersion(versionId);
|
|
2740
|
+
if (!incoming || incoming.vertical_slug !== verticalSlug) {
|
|
2741
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
2742
|
+
}
|
|
2743
|
+
const current = await this.cp.readChannel(verticalSlug, channel);
|
|
2744
|
+
const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
|
|
2745
|
+
const breaks = outgoing
|
|
2746
|
+
? await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json)
|
|
2747
|
+
: [];
|
|
2748
|
+
await this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
|
|
2749
|
+
return breaks;
|
|
2750
|
+
},
|
|
2574
2751
|
rejectVersion: async (actor, versionId, note) => {
|
|
2575
2752
|
const v = await this.cp.readVersion(versionId);
|
|
2576
2753
|
if (!v)
|
|
@@ -2607,6 +2784,12 @@ export class CloudflareScopeHost {
|
|
|
2607
2784
|
throw new Error(`promotion changes migrations (${outgoing.migration_digest} → ` +
|
|
2608
2785
|
`${incoming.migration_digest}) — acknowledge it explicitly to promote`);
|
|
2609
2786
|
}
|
|
2787
|
+
// #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
|
|
2788
|
+
if (!ack.exportBreak) {
|
|
2789
|
+
const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json);
|
|
2790
|
+
if (breaks.length > 0)
|
|
2791
|
+
throw substratError('precondition_failed', exportBreakRefusal(breaks));
|
|
2792
|
+
}
|
|
2610
2793
|
}
|
|
2611
2794
|
const promotedAt = new Date().toISOString();
|
|
2612
2795
|
await this.cp.setChannel(verticalSlug, channel, versionId, promotedAt);
|
|
@@ -2626,8 +2809,8 @@ export class CloudflareScopeHost {
|
|
|
2626
2809
|
// promote) is a complete deploy and a rollback promote reaches the running
|
|
2627
2810
|
// app. D-30's lockstep concern is a SHARED vertical's many tenants, which a
|
|
2628
2811
|
// private vertical cannot have — this fires for no one else. Snapshots and
|
|
2629
|
-
// forks (forked_from set) keep their frontier untouched
|
|
2630
|
-
// crosses a migration digest snapshots first (fork-before-promote, §4).
|
|
2812
|
+
// forks (forked_from set) and all previews keep their frontier untouched. A rebind
|
|
2813
|
+
// that crosses a migration digest snapshots first (fork-before-promote, §4).
|
|
2631
2814
|
//
|
|
2632
2815
|
// EXCEPTION (#321): a DISPATCH-BACKED vertical (its version has a
|
|
2633
2816
|
// `deployment_ref`) serves in place off a stable script. Rebinding a legacy
|
|
@@ -2642,7 +2825,7 @@ export class CloudflareScopeHost {
|
|
|
2642
2825
|
if (channel === 'prod' && !incoming.deployment_ref) {
|
|
2643
2826
|
const owning = await this.cp.readVertical(verticalSlug);
|
|
2644
2827
|
if (owning && owning.owner_tenant !== null && !owning.listed) {
|
|
2645
|
-
const bound = (await this.cp.listScopes({ tenantId: owning.owner_tenant, vertical: verticalSlug, status: ['active'] })).filter((s) => !s.forked_from);
|
|
2828
|
+
const bound = (await this.cp.listScopes({ tenantId: owning.owner_tenant, vertical: verticalSlug, status: ['active'] })).filter((s) => !s.forked_from && s.kind !== 'preview');
|
|
2646
2829
|
for (const s of bound) {
|
|
2647
2830
|
if (s.vertical_version_id === versionId)
|
|
2648
2831
|
continue;
|
|
@@ -2694,7 +2877,7 @@ export class CloudflareScopeHost {
|
|
|
2694
2877
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
2695
2878
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2696
2879
|
if (!scope)
|
|
2697
|
-
throw
|
|
2880
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2698
2881
|
// The refusal the registry exists for — but scoped to a SERVING bind. Admission
|
|
2699
2882
|
// gates code reaching an install; a PREVIEW fork is the builder's own tenant's data
|
|
2700
2883
|
// at a non-canonical URL, serving no install, so it may run pending PR code — the
|
|
@@ -2728,7 +2911,7 @@ export class CloudflareScopeHost {
|
|
|
2728
2911
|
markScopeProvisioned: async (actor, tenantId, scopeId, versionId) => {
|
|
2729
2912
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2730
2913
|
if (!scope)
|
|
2731
|
-
throw
|
|
2914
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2732
2915
|
await this.cp.markScopeProvisioned(scopeId, versionId);
|
|
2733
2916
|
await this.recordAdmin(actor, 'markScopeProvisioned', { tenantId, scopeId }, null, {
|
|
2734
2917
|
versionId,
|
|
@@ -2774,21 +2957,21 @@ export class CloudflareScopeHost {
|
|
|
2774
2957
|
setScopeServingRef: async (actor, tenantId, scopeId, servingRef) => {
|
|
2775
2958
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2776
2959
|
if (!scope)
|
|
2777
|
-
throw
|
|
2960
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2778
2961
|
await this.cp.setScopeServingRef(scopeId, servingRef);
|
|
2779
2962
|
await this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref ?? null }, { servingRef });
|
|
2780
2963
|
},
|
|
2781
2964
|
setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
|
|
2782
2965
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2783
2966
|
if (!scope)
|
|
2784
|
-
throw
|
|
2967
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2785
2968
|
await this.cp.setScopeExpiresAt(scopeId, expiresAt);
|
|
2786
2969
|
await this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at ?? null }, { expiresAt });
|
|
2787
2970
|
},
|
|
2788
2971
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
2789
2972
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2790
2973
|
if (!scope)
|
|
2791
|
-
throw
|
|
2974
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2792
2975
|
const applied = await this.scopeStub(scopeId).appliedMigrations();
|
|
2793
2976
|
await this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, applied.length);
|
|
2794
2977
|
return applied;
|
|
@@ -2796,7 +2979,7 @@ export class CloudflareScopeHost {
|
|
|
2796
2979
|
scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
|
|
2797
2980
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2798
2981
|
if (!scope)
|
|
2799
|
-
throw
|
|
2982
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2800
2983
|
const bookmarks = await this.scopeStub(scopeId).migrationBookmarks();
|
|
2801
2984
|
await this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, bookmarks.length);
|
|
2802
2985
|
return bookmarks;
|
|
@@ -2811,7 +2994,7 @@ export class CloudflareScopeHost {
|
|
|
2811
2994
|
rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
|
|
2812
2995
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2813
2996
|
if (!scope)
|
|
2814
|
-
throw
|
|
2997
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
2815
2998
|
// Audit FIRST: a destructive rewind that fails halfway must still be on the
|
|
2816
2999
|
// record — the entry names the intent; the DO's refusals name the outcome.
|
|
2817
3000
|
await this.recordAdmin(actor, 'rewindScope', { tenantId, scopeId }, null, {
|
|
@@ -2995,6 +3178,7 @@ export class CloudflareScopeHost {
|
|
|
2995
3178
|
await this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, state.cursors.length);
|
|
2996
3179
|
return state;
|
|
2997
3180
|
},
|
|
3181
|
+
moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
|
|
2998
3182
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
2999
3183
|
const record = await this.scopeRecordForRead(tenantId, scopeId);
|
|
3000
3184
|
const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
|
|
@@ -3264,7 +3448,7 @@ export class CloudflareScopeHost {
|
|
|
3264
3448
|
// no-op when the bytes lived remotely.
|
|
3265
3449
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
3266
3450
|
if (!rec)
|
|
3267
|
-
throw
|
|
3451
|
+
throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
3268
3452
|
if (rec.status !== 'archived') {
|
|
3269
3453
|
throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
|
|
3270
3454
|
}
|
|
@@ -3906,9 +4090,18 @@ export class CloudflareScopeHost {
|
|
|
3906
4090
|
}
|
|
3907
4091
|
// -- helpers --------------------------------------------------------------
|
|
3908
4092
|
/**
|
|
3909
|
-
*
|
|
3910
|
-
*
|
|
4093
|
+
* The getScope gate (control-plane.md §4.1/§4.2) — the pair check, then the tenant's and
|
|
4094
|
+
* the scope's lifecycle — thrown on THIS side of the RPC (#1718). The ControlPlaneDO
|
|
4095
|
+
* answers its refusal as data, because an error thrown there arrives here flattened, with
|
|
4096
|
+
* no code; a record crosses intact. A CP-less host's null control plane answers nothing,
|
|
4097
|
+
* so the gate passes: the router already made it from the shared directory.
|
|
3911
4098
|
*/
|
|
4099
|
+
async validateScopeAccess(tenantId, scopeId) {
|
|
4100
|
+
const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
|
|
4101
|
+
if (!refusal)
|
|
4102
|
+
return;
|
|
4103
|
+
throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
|
|
4104
|
+
}
|
|
3912
4105
|
/**
|
|
3913
4106
|
* K-3's cross-check on its own: the (tenant, scope) pair must exist and agree before a
|
|
3914
4107
|
* subject-key operation touches anything. Without it a caller could reach another
|
|
@@ -3917,7 +4110,7 @@ export class CloudflareScopeHost {
|
|
|
3917
4110
|
async assertScope(tenantId, scopeId) {
|
|
3918
4111
|
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
3919
4112
|
if (!rec)
|
|
3920
|
-
throw
|
|
4113
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3921
4114
|
}
|
|
3922
4115
|
/**
|
|
3923
4116
|
* The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
|
|
@@ -3932,7 +4125,7 @@ export class CloudflareScopeHost {
|
|
|
3932
4125
|
async scopeRecordForRead(tenantId, scopeId) {
|
|
3933
4126
|
const row = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
3934
4127
|
if (!row)
|
|
3935
|
-
throw
|
|
4128
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3936
4129
|
if (row.status === 'reaped') {
|
|
3937
4130
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
3938
4131
|
}
|
|
@@ -3950,6 +4143,10 @@ export class CloudflareScopeHost {
|
|
|
3950
4143
|
tombstone: (subjectId, at) => this.cp.tombstoneSubjectKey({ scopeId, subjectId, tenantId, at }),
|
|
3951
4144
|
});
|
|
3952
4145
|
}
|
|
4146
|
+
/**
|
|
4147
|
+
* Record a staff read (K-24). `params` is a bounded summary, capped so one query
|
|
4148
|
+
* cannot write an unbounded row.
|
|
4149
|
+
*/
|
|
3953
4150
|
async recordAccess(actor, method, target, params, resultCount) {
|
|
3954
4151
|
await this.cp.recordAccess({
|
|
3955
4152
|
id: ulid(),
|
|
@@ -3985,7 +4182,7 @@ export class CloudflareScopeHost {
|
|
|
3985
4182
|
* directory's view stays on `HostAdmin.listConnectionGrants`.
|
|
3986
4183
|
*/
|
|
3987
4184
|
async connectionGrantsInScope(tenantId, scopeId) {
|
|
3988
|
-
await this.
|
|
4185
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
3989
4186
|
await this.migrateAndRecord(scopeId);
|
|
3990
4187
|
const now = new Date().toISOString();
|
|
3991
4188
|
// Both stores again, but the CF split is not the pure adapter's. The DO holds the
|
|
@@ -4161,7 +4358,7 @@ export class CloudflareScopeHost {
|
|
|
4161
4358
|
* count to the directory — so a scope whose first contact after a deploy is a
|
|
4162
4359
|
* subscription does not go dark in the migration fleet view.
|
|
4163
4360
|
*/
|
|
4164
|
-
await this.
|
|
4361
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
4165
4362
|
await this.migrateAndRecord(scopeId);
|
|
4166
4363
|
// Asserted, not carried through from the client: the principal is the
|
|
4167
4364
|
// vertical's own resolution of its session, and the tenant and scope are the
|
|
@@ -4576,6 +4773,164 @@ export class CloudflareScopeHost {
|
|
|
4576
4773
|
async peerSwitchLocal(scopeId, vertical, to) {
|
|
4577
4774
|
return peerSwitchOutcome.parse(await this.scopeStub(scopeId).switchPeer(verticalSlug.parse(vertical), scopeId, to, new Date().toISOString()));
|
|
4578
4775
|
}
|
|
4776
|
+
// -- the cross-vertical far ends (#1705 PR 2) --------------------------------
|
|
4777
|
+
// The shared control plane runs the cross-vertical phase for every hosted edge and reaches the
|
|
4778
|
+
// scopes over `/internal/exported-events`, `/internal/import-state` and `/internal/import-events`,
|
|
4779
|
+
// which land here. The platform resolved the pair and the tenant; what runs HERE is what only
|
|
4780
|
+
// this deployment can decide: what its own code exports and to whom, and what its own code
|
|
4781
|
+
// imports and runs. Each verb first proves the scope is one this deployment serves
|
|
4782
|
+
// (`assertServesLocally`), because a CP-less host has no directory and an unprovisioned DO
|
|
4783
|
+
// answers every read with a plausible empty result.
|
|
4784
|
+
/** The producer's release after a watermark (#1705), for a scope served HERE. */
|
|
4785
|
+
async exportedEventsLocal(tenantId, scopeId, raw) {
|
|
4786
|
+
const input = exportReadInput.parse(raw);
|
|
4787
|
+
await this.assertServesLocally(tenantId, scopeId, 'readExportedEvents');
|
|
4788
|
+
return exportedBatch.parse(await this.scopeStub(scopeId).exportedEventsRead(input, tenantId, scopeId));
|
|
4789
|
+
}
|
|
4790
|
+
/**
|
|
4791
|
+
* The consumer's imports and watermarks (#1705), for a scope served HERE. The served-here
|
|
4792
|
+
* check comes FIRST, before the "this deployment imports nothing" answer. That answer is a
|
|
4793
|
+
* fact about this code, but given for a scope this deployment does not serve, it would tell the
|
|
4794
|
+
* platform the scope imports nothing, when the truth is that the platform asked the wrong
|
|
4795
|
+
* deployment. The platform reads the former as a disagreement with its registry, and must
|
|
4796
|
+
* hear the latter as a refusal.
|
|
4797
|
+
*/
|
|
4798
|
+
async importStateLocal(tenantId, scopeId) {
|
|
4799
|
+
await this.assertServesLocally(tenantId, scopeId, 'importState');
|
|
4800
|
+
if (this.crossVertical.consumes().length === 0)
|
|
4801
|
+
return { consumes: [], cursors: [] };
|
|
4802
|
+
return importState.parse(await this.scopeStub(scopeId).importStateRead());
|
|
4803
|
+
}
|
|
4804
|
+
/** Apply a producer's batch (#1705) to a scope served HERE, through the peer door's gate. */
|
|
4805
|
+
async importEventsLocal(tenantId, scopeId, batch) {
|
|
4806
|
+
await this.assertServesLocally(tenantId, scopeId, 'deliverToPeer');
|
|
4807
|
+
return this.deliverToPeer(tenantId, scopeId, batch);
|
|
4808
|
+
}
|
|
4809
|
+
/**
|
|
4810
|
+
* The replay lever's far end (#1705 PR 3), for a consumer scope served HERE. The platform
|
|
4811
|
+
* resolved the producer and wrote the intent row. What runs here is the move itself, in this
|
|
4812
|
+
* deployment's own store, under the platform's `replayId`. Nothing is audited here, as for the
|
|
4813
|
+
* switches: the admin rows are the platform's.
|
|
4814
|
+
*/
|
|
4815
|
+
async importCursorLocal(tenantId, scopeId, raw) {
|
|
4816
|
+
const input = importCursorMoveAt.parse(raw);
|
|
4817
|
+
await this.assertServesLocally(tenantId, scopeId, 'moveImportCursor');
|
|
4818
|
+
return this.moveInScope(scopeId, input);
|
|
4819
|
+
}
|
|
4820
|
+
/** The move in this host's own scope DO, on the real clock (host code may read it). */
|
|
4821
|
+
async moveInScope(scopeId, at) {
|
|
4822
|
+
return importCursorMoved.parse(await this.scopeStub(scopeId).importCursorMove({ ...at, now: Date.now() }));
|
|
4823
|
+
}
|
|
4824
|
+
/**
|
|
4825
|
+
* `HostAdmin.moveImportCursor` (#1705 PR 3): the peer switch's shape. Resolve, audit the
|
|
4826
|
+
* intent, move where the scope's storage is (the delegation for a hosted scope, this host's
|
|
4827
|
+
* own DO otherwise), and audit the outcome. Every attempt leaves a row, and a failed one
|
|
4828
|
+
* says why.
|
|
4829
|
+
*/
|
|
4830
|
+
async moveImportCursorAt(actor, tenantId, scopeId, raw) {
|
|
4831
|
+
const move = importCursorMove.parse(raw);
|
|
4832
|
+
if (this.cpLess) {
|
|
4833
|
+
throw substratError('unavailable', 'moveImportCursor needs the directory to resolve the producer — the platform moves a watermark, ' +
|
|
4834
|
+
'and reaches this deployment through /internal/import-cursor');
|
|
4835
|
+
}
|
|
4836
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4837
|
+
if (!rec)
|
|
4838
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4839
|
+
const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
|
|
4840
|
+
// Where the write lands is the peer switch's rule: a scope bound to a vertical is served by
|
|
4841
|
+
// that vertical's deployment, and its watermark lives there. Without a delegation that
|
|
4842
|
+
// refusal stands (`assertServedHere`) rather than a move in the placeholder namespace.
|
|
4843
|
+
const delegation = rec.vertical !== null ? this.importCursorDelegation : undefined;
|
|
4844
|
+
if (!delegation)
|
|
4845
|
+
this.assertServedHere(rec, scopeId, 'moveImportCursor');
|
|
4846
|
+
await this.validateScopeAccess(tenantId, scopeId);
|
|
4847
|
+
const replayId = ulid();
|
|
4848
|
+
const target = { tenantId, scopeId, vertical: rec.vertical };
|
|
4849
|
+
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
4850
|
+
await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
4851
|
+
const at = { move, source: source, replayId };
|
|
4852
|
+
let moved;
|
|
4853
|
+
try {
|
|
4854
|
+
moved = delegation
|
|
4855
|
+
? importCursorMoved.parse(await delegation.move({ tenantId, scopeId, at }))
|
|
4856
|
+
: await this.moveInScope(scopeId, at);
|
|
4857
|
+
}
|
|
4858
|
+
catch (err) {
|
|
4859
|
+
await this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
4860
|
+
...base,
|
|
4861
|
+
phase: 'failed',
|
|
4862
|
+
error: err instanceof Error ? err.message : String(err),
|
|
4863
|
+
}).catch(() => undefined);
|
|
4864
|
+
throw err;
|
|
4865
|
+
}
|
|
4866
|
+
await this.recordAdmin(actor, 'moveImportCursor', target, null, {
|
|
4867
|
+
...base,
|
|
4868
|
+
phase: 'applied',
|
|
4869
|
+
previous: moved.previous,
|
|
4870
|
+
cursor: moved.cursor,
|
|
4871
|
+
archived: moved.archived,
|
|
4872
|
+
});
|
|
4873
|
+
return moved;
|
|
4874
|
+
}
|
|
4875
|
+
/** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
|
|
4876
|
+
exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {
|
|
4877
|
+
return exportBreaksOf({
|
|
4878
|
+
admin: this.admin,
|
|
4879
|
+
actor,
|
|
4880
|
+
producer,
|
|
4881
|
+
outgoing: exportsOfManifestJson(outgoingManifest),
|
|
4882
|
+
incoming: exportsOfManifestJson(incomingManifest),
|
|
4883
|
+
readImports: (slug, versionId) => this.versionImports(slug, versionId),
|
|
4884
|
+
});
|
|
4885
|
+
}
|
|
4886
|
+
/**
|
|
4887
|
+
* The served-here gate (#1705 PR 2), from whichever source of truth this host has.
|
|
4888
|
+
*
|
|
4889
|
+
* With a directory, the directory decides: the record must exist for this (tenant, scope), and
|
|
4890
|
+
* the shared control plane refuses a scope bound to a vertical (`assertServedHere`). Role rows
|
|
4891
|
+
* are not consulted, because a directory-backed host provisions without projecting them.
|
|
4892
|
+
*
|
|
4893
|
+
* CP-less, which is every pushed vertical, there is no directory. The scope must have been
|
|
4894
|
+
* provisioned in THIS deployment's namespace, for THIS tenant (`ScopeDO.servesTenant`).
|
|
4895
|
+
*
|
|
4896
|
+
* A scope this host does not hold is refused `conflict`, not `not_found`: over `/internal` a
|
|
4897
|
+
* 404 means "this deployment predates the route", and the platform reads it as exactly that.
|
|
4898
|
+
* One exception: the SHARED control plane (a directory host with delegations) refuses a scope
|
|
4899
|
+
* bound to a vertical `unavailable`, through `assertServedHere`, the same answer its peer door
|
|
4900
|
+
* gives. It never mounts these routes, so no `/internal` caller meets that answer.
|
|
4901
|
+
*/
|
|
4902
|
+
async assertServesLocally(tenantId, scopeId, verb) {
|
|
4903
|
+
if (!this.cpLess) {
|
|
4904
|
+
const record = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
4905
|
+
if (!record) {
|
|
4906
|
+
throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: the directory has no such scope for tenant ${tenantId}`);
|
|
4907
|
+
}
|
|
4908
|
+
this.assertServedHere(record, scopeId, verb);
|
|
4909
|
+
return;
|
|
4910
|
+
}
|
|
4911
|
+
if (!(await this.scopeStub(scopeId).servesTenant(tenantId))) {
|
|
4912
|
+
throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: this deployment holds no scope provisioned for tenant ` +
|
|
4913
|
+
`${tenantId} under that id — the platform resolved the scope to a deployment that does not serve it`);
|
|
4914
|
+
}
|
|
4915
|
+
}
|
|
4916
|
+
/**
|
|
4917
|
+
* What one pushed version imports from other verticals (#1705 PR 2), from its stored manifest:
|
|
4918
|
+
* the control plane's cross-vertical narrowing reads this to decide which scopes it calls.
|
|
4919
|
+
*
|
|
4920
|
+
* Deliberately NOT through `admin.versionManifest`, which writes an access-log row per read.
|
|
4921
|
+
* The narrowing asks once per distinct running version on every pass and every kick. Audited,
|
|
4922
|
+
* the log would grow with fleet × tick rate + request rate, for a read of the platform's own
|
|
4923
|
+
* code metadata (a version's declared edges, which a push put there), not of a tenant's data.
|
|
4924
|
+
* Throws `not_found` for a version the registry does not know under that vertical, which the
|
|
4925
|
+
* narrowing reports and treats as "cannot say" rather than "imports nothing".
|
|
4926
|
+
*/
|
|
4927
|
+
async versionImports(verticalSlug, versionId) {
|
|
4928
|
+
const v = await this.cp.readVersion(versionId);
|
|
4929
|
+
if (!v || v.vertical_slug !== verticalSlug) {
|
|
4930
|
+
throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
|
|
4931
|
+
}
|
|
4932
|
+
return importsOfManifestJson(v.manifest_json);
|
|
4933
|
+
}
|
|
4579
4934
|
}
|
|
4580
4935
|
/** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
|
|
4581
4936
|
async function sha256Hex(body) {
|