@substrat-run/adapter-cloudflare 0.117.0 → 0.119.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 +60 -2
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +101 -7
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +153 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +501 -31
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/route-resolver.d.ts +59 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +111 -1
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +11 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +362 -30
- package/dist/scope-do.js.map +1 -1
- package/package.json +5 -4
package/dist/host.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { fromWireFailure, 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, 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, outboundOfManifestJson, substratError, redrainEventsInput, } from '@substrat-run/contracts';
|
|
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';
|
|
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, } 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, systemSwitchedOffMessage, CrossVerticalRegistry, 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';
|
|
@@ -22,6 +22,12 @@ const toConnection = (r) => connection.parse({
|
|
|
22
22
|
createdAt: r.created_at,
|
|
23
23
|
revokedAt: r.revoked_at,
|
|
24
24
|
});
|
|
25
|
+
/** Rethrow a capability attachment verb's failure, rebuilt with its code; else its value. */
|
|
26
|
+
function unwrapCapabilityReply(reply) {
|
|
27
|
+
if (reply.failure)
|
|
28
|
+
throw fromWireFailure(reply.failure);
|
|
29
|
+
return reply.value;
|
|
30
|
+
}
|
|
25
31
|
/**
|
|
26
32
|
* A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
|
|
27
33
|
* The hot path a served scope actually touches becomes trust-the-upstream:
|
|
@@ -63,8 +69,17 @@ export class CloudflareScopeHost {
|
|
|
63
69
|
// Registration-mechanics bookkeeping (validation only — the DO executes).
|
|
64
70
|
// Code-time, derived from the bundled modules, NOT durable directory state.
|
|
65
71
|
moduleIds = new Set();
|
|
72
|
+
/**
|
|
73
|
+
* #1705: this deployment's cross-vertical declarations. The DO holds the handlers and makes
|
|
74
|
+
* every decision. The coordinator keeps its own copy for two reasons: registration refuses
|
|
75
|
+
* bad wiring at the same point as the pure host, and `importState` can answer "imports
|
|
76
|
+
* nothing" without waking a DO.
|
|
77
|
+
*/
|
|
78
|
+
crossVertical = new CrossVerticalRegistry();
|
|
66
79
|
/** Module id → its declared recurring schedules (#383), for `registeredSchedules`/`runDueSchedules`. */
|
|
67
80
|
moduleSchedules = new Map();
|
|
81
|
+
/** #1706: every registered module's `peers`, as declared — read as one union per peer. */
|
|
82
|
+
peerSources = [];
|
|
68
83
|
/** #1232: declared freshness per module. Registered UNCONDITIONALLY — the schedule
|
|
69
84
|
* map is only populated for modules WITH schedules, and hanging freshness off it
|
|
70
85
|
* would drop a freshness-only module at registration. */
|
|
@@ -76,6 +91,7 @@ export class CloudflareScopeHost {
|
|
|
76
91
|
/** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
|
|
77
92
|
secretBox;
|
|
78
93
|
fetchImpl;
|
|
94
|
+
connectorCalls;
|
|
79
95
|
/** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
|
|
80
96
|
tenantStores;
|
|
81
97
|
/** The live R2 client for per-tenant blob stores (#473); undefined ⇒ refuse loudly. */
|
|
@@ -106,6 +122,8 @@ export class CloudflareScopeHost {
|
|
|
106
122
|
eventDrainDelegation;
|
|
107
123
|
/** #1666: the schedule kill switch's reach into the deployment serving a scope. */
|
|
108
124
|
systemSwitchDelegation;
|
|
125
|
+
/** #1706: the peer kill switch's reach into the deployment serving a scope. */
|
|
126
|
+
peerSwitchDelegation;
|
|
109
127
|
/**
|
|
110
128
|
* MUST be constructed per request. Never cache an instance across requests.
|
|
111
129
|
*
|
|
@@ -123,6 +141,7 @@ export class CloudflareScopeHost {
|
|
|
123
141
|
this.blobStores = options.blobStores;
|
|
124
142
|
this.attachmentBuckets = options.attachmentBuckets;
|
|
125
143
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
144
|
+
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
126
145
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
127
146
|
this.scopeNs = options.scope;
|
|
128
147
|
this.cpLess = !options.controlPlane;
|
|
@@ -132,6 +151,7 @@ export class CloudflareScopeHost {
|
|
|
132
151
|
this.connectorDelegation = options.connectorDelegation;
|
|
133
152
|
this.eventDrainDelegation = options.eventDrainDelegation;
|
|
134
153
|
this.systemSwitchDelegation = options.systemSwitchDelegation;
|
|
154
|
+
this.peerSwitchDelegation = options.peerSwitchDelegation;
|
|
135
155
|
this.admin = this.buildAdmin();
|
|
136
156
|
}
|
|
137
157
|
// -- registration mechanics (validation only) -----------------------------
|
|
@@ -206,19 +226,18 @@ export class CloudflareScopeHost {
|
|
|
206
226
|
// request pending across a rotation still opens.
|
|
207
227
|
unseal: async (sealed) => openSealed(await this.openSealingKeys(open.id), sealed),
|
|
208
228
|
fetch: async (input, init) => {
|
|
229
|
+
// #1691: timed where the call is made, so the data point carries a duration.
|
|
230
|
+
const started = Date.now();
|
|
209
231
|
try {
|
|
210
232
|
const res = await fetchImpl(input, {
|
|
211
233
|
...init,
|
|
212
234
|
signal: AbortSignal.timeout(timeoutMs),
|
|
213
235
|
});
|
|
214
|
-
await admin.recordConnectionUse(open.id,
|
|
236
|
+
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
|
|
215
237
|
return res;
|
|
216
238
|
}
|
|
217
239
|
catch (err) {
|
|
218
|
-
await admin.recordConnectionUse(open.id, {
|
|
219
|
-
ok: false,
|
|
220
|
-
error: err instanceof Error ? err.message : String(err),
|
|
221
|
-
});
|
|
240
|
+
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { error: err }));
|
|
222
241
|
throw err;
|
|
223
242
|
}
|
|
224
243
|
},
|
|
@@ -667,7 +686,8 @@ export class CloudflareScopeHost {
|
|
|
667
686
|
}
|
|
668
687
|
seen.add(m.version);
|
|
669
688
|
}
|
|
670
|
-
|
|
689
|
+
// IN-SCOPE consumes only (#1705): a `from` entry is an import, handled under `imports`.
|
|
690
|
+
const declaredConsumes = new Set(manifest.events.consumes.filter((c) => c.from === undefined).map((c) => c.type));
|
|
671
691
|
for (const eventType of Object.keys(registration.consumers ?? {})) {
|
|
672
692
|
if (!declaredConsumes.has(eventType)) {
|
|
673
693
|
throw new Error(`${manifest.id} registers a consumer for undeclared event type: ${eventType}`);
|
|
@@ -680,10 +700,15 @@ export class CloudflareScopeHost {
|
|
|
680
700
|
}
|
|
681
701
|
this.predicateNames.set(name, manifest.id);
|
|
682
702
|
}
|
|
703
|
+
// #1705: after the checks above, so a refused module leaves nothing registered here.
|
|
704
|
+
this.crossVertical.register(manifest, registration.imports);
|
|
683
705
|
this.moduleIds.add(manifest.id);
|
|
684
706
|
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
685
707
|
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
686
708
|
}
|
|
709
|
+
if (manifest.peers && manifest.peers.length > 0) {
|
|
710
|
+
this.peerSources.push({ peers: manifest.peers });
|
|
711
|
+
}
|
|
687
712
|
if (manifest.freshness && manifest.freshness.length > 0) {
|
|
688
713
|
this.moduleFreshness.set(manifest.id, manifest.freshness);
|
|
689
714
|
}
|
|
@@ -751,6 +776,13 @@ export class CloudflareScopeHost {
|
|
|
751
776
|
await this.scopeStub(input.scopeId).seatTuple(`system:${moduleId}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
|
|
752
777
|
}
|
|
753
778
|
}
|
|
779
|
+
// #1706: every declared PEER holds its keys on the scope from provisioning on — the one
|
|
780
|
+
// call (`peerSeats`) that makes "declared and installed" mean "live". Same seat as the
|
|
781
|
+
// schedule grants above, so a grant a switch tombstoned stays tombstoned and a
|
|
782
|
+
// switched-off peer gets nothing seated.
|
|
783
|
+
for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
|
|
784
|
+
await this.scopeStub(input.scopeId).seatTuple(seat.subject, seat.relation, seat.object, null);
|
|
785
|
+
}
|
|
754
786
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
755
787
|
if (created) {
|
|
756
788
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -966,6 +998,27 @@ export class CloudflareScopeHost {
|
|
|
966
998
|
buildAttachmentSurface(subject, tenantId, scopeId, store,
|
|
967
999
|
/** The delivery admitting this surface's reads (#726 remedy B), when there is one. */
|
|
968
1000
|
forEvent) {
|
|
1001
|
+
const stub = this.scopeStub(scopeId);
|
|
1002
|
+
if ('capabilitySession' in subject) {
|
|
1003
|
+
// #1686: a capability session. Its own ScopeDO verbs rather than a trailing argument
|
|
1004
|
+
// on the principal ones, so a DO that predates them fails the call (no such RPC
|
|
1005
|
+
// method) instead of ignoring the session and checking as someone else. Writes go to
|
|
1006
|
+
// the DO only to be refused and recorded — never to the blob store.
|
|
1007
|
+
const hash = subject.capabilitySession;
|
|
1008
|
+
return {
|
|
1009
|
+
upload: async (input) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.upload', {
|
|
1010
|
+
entityType: input.entity.entityType,
|
|
1011
|
+
})),
|
|
1012
|
+
list: async (entity) => unwrapCapabilityReply(await stub.capabilityAttachmentList(entity, hash, tenantId, scopeId)),
|
|
1013
|
+
open: async (attachmentId) => {
|
|
1014
|
+
const record = unwrapCapabilityReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
|
|
1015
|
+
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
1016
|
+
},
|
|
1017
|
+
remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
1018
|
+
attachmentId,
|
|
1019
|
+
})),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
969
1022
|
const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
|
|
970
1023
|
const createdBy = 'principal' in subject ? subject.principal : subject.connectionId;
|
|
971
1024
|
// The DO needs SOME principal-shaped value for `ctx.principal`; for a connection it is
|
|
@@ -974,11 +1027,6 @@ export class CloudflareScopeHost {
|
|
|
974
1027
|
const asPrincipalId = ('principal' in subject
|
|
975
1028
|
? subject.principal
|
|
976
1029
|
: subject.connectionId);
|
|
977
|
-
const stub = this.scopeStub(scopeId);
|
|
978
|
-
const sha256Hex = async (body) => {
|
|
979
|
-
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
980
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
981
|
-
};
|
|
982
1030
|
return {
|
|
983
1031
|
upload: async (input) => {
|
|
984
1032
|
const id = ulid();
|
|
@@ -1008,17 +1056,7 @@ export class CloudflareScopeHost {
|
|
|
1008
1056
|
list: (entity) => stub.attachmentList(entity, asPrincipalId, tenantId, scopeId, connectionId),
|
|
1009
1057
|
open: async (attachmentId) => {
|
|
1010
1058
|
const record = await stub.attachmentAuthorize(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId);
|
|
1011
|
-
|
|
1012
|
-
return null;
|
|
1013
|
-
const obj = await store.get(attachmentBlobKey(scopeId, record.id));
|
|
1014
|
-
if (!obj) {
|
|
1015
|
-
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
1016
|
-
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
1017
|
-
}
|
|
1018
|
-
if ((await sha256Hex(obj.body)) !== record.sha256) {
|
|
1019
|
-
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
1020
|
-
}
|
|
1021
|
-
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
1059
|
+
return record ? this.openAttachmentBytes(store, scopeId, record) : null;
|
|
1022
1060
|
},
|
|
1023
1061
|
remove: async (attachmentId) => {
|
|
1024
1062
|
const removed = await stub.attachmentRemove(attachmentId, asPrincipalId, tenantId, scopeId, connectionId);
|
|
@@ -1028,6 +1066,22 @@ export class CloudflareScopeHost {
|
|
|
1028
1066
|
},
|
|
1029
1067
|
};
|
|
1030
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* The bytes of an attachment the ScopeDO has already AUTHORIZED, read from the blob store
|
|
1071
|
+
* and held to the record's sha256 — one path for every subject, so a capability's download
|
|
1072
|
+
* meets the same integrity checks a principal's does.
|
|
1073
|
+
*/
|
|
1074
|
+
async openAttachmentBytes(store, scopeId, record) {
|
|
1075
|
+
const obj = await store.get(attachmentBlobKey(scopeId, record.id));
|
|
1076
|
+
if (!obj) {
|
|
1077
|
+
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
1078
|
+
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
1079
|
+
}
|
|
1080
|
+
if ((await sha256Hex(obj.body)) !== record.sha256) {
|
|
1081
|
+
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
1082
|
+
}
|
|
1083
|
+
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
1084
|
+
}
|
|
1031
1085
|
async importScope(actor, input, dump) {
|
|
1032
1086
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
1033
1087
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
@@ -1227,6 +1281,29 @@ export class CloudflareScopeHost {
|
|
|
1227
1281
|
await this.migrateAndRecord(scopeId);
|
|
1228
1282
|
return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
|
|
1229
1283
|
}
|
|
1284
|
+
/**
|
|
1285
|
+
* #1705: the consumer half. The same lifecycle gate as every door (K-3), then the ScopeDO
|
|
1286
|
+
* applies the batch, and the answer is re-parsed on this side of the RPC. Executors run here
|
|
1287
|
+
* afterwards, as they do after an invoke. A failure to drain them does not un-deliver a batch
|
|
1288
|
+
* that has committed, so it is contained, and the outbox is their backstop.
|
|
1289
|
+
*/
|
|
1290
|
+
async deliverToPeer(tenantId, scopeId, raw) {
|
|
1291
|
+
const batch = importBatch.parse(raw);
|
|
1292
|
+
// The peer door's own gate (#1706): K-3's pair check, the refusal for a scope served
|
|
1293
|
+
// elsewhere, the lifecycle check and the migration. A delivery is a write into the
|
|
1294
|
+
// consumer's scope, so it takes the gate an invoke through that door takes.
|
|
1295
|
+
await this.peerScopeGate(tenantId, scopeId, 'deliverToPeer');
|
|
1296
|
+
const result = importResult.parse(await this.scopeStub(scopeId).importApply(batch, tenantId, scopeId));
|
|
1297
|
+
if (result.delivered > 0) {
|
|
1298
|
+
try {
|
|
1299
|
+
await this.drainExecutors(tenantId, scopeId, null);
|
|
1300
|
+
}
|
|
1301
|
+
catch (err) {
|
|
1302
|
+
console.error('substrat: executor drain after a cross-vertical delivery failed', err);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return result;
|
|
1306
|
+
}
|
|
1230
1307
|
/**
|
|
1231
1308
|
* The exchange (#1672) — the same fail-closed (tenant, scope) and lifecycle gate as
|
|
1232
1309
|
* `getScope`, then the kernel's `exchangeCapability` inside the ScopeDO, where the
|
|
@@ -1252,14 +1329,91 @@ export class CloudflareScopeHost {
|
|
|
1252
1329
|
const hash = await capabilityTokenHash(sessionToken);
|
|
1253
1330
|
return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
|
|
1254
1331
|
}
|
|
1332
|
+
/**
|
|
1333
|
+
* The peer door (#1706) — mirror of the connection, system and capability doors. `caller`
|
|
1334
|
+
* is the platform's word (the router's, on the hosted path); the pair and lifecycle gate run
|
|
1335
|
+
* here, and the ScopeDO admits the peer inside its queue on every invoke and acknowledges it.
|
|
1336
|
+
*/
|
|
1337
|
+
async getVerticalScope(caller, tenantId, scopeId, options) {
|
|
1338
|
+
const parsed = verticalCaller.parse(caller);
|
|
1339
|
+
await this.peerScopeGate(tenantId, scopeId, 'getVerticalScope');
|
|
1340
|
+
return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, undefined, parsed);
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Does peer `vertical` hold each key at this scope's node now (#1706) — the ScopeDO's own
|
|
1344
|
+
* checker, so it answers what an invoke would be told, switch included.
|
|
1345
|
+
*/
|
|
1346
|
+
async peerCovers(tenantId, scopeId, vertical, permissions) {
|
|
1347
|
+
await this.peerScopeGate(tenantId, scopeId, 'peerCovers');
|
|
1348
|
+
return peerCoverage
|
|
1349
|
+
.array()
|
|
1350
|
+
.parse(await this.scopeStub(scopeId).peerCovers(tenantId, scopeId, verticalSlug.parse(vertical), [...permissions]));
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Where a peer verb (#1706) may reach a scope: its own ScopeDO, after the ordinary pair and
|
|
1354
|
+
* lifecycle gate. Refused, loudly, on the SHARED control plane for a scope bound to a
|
|
1355
|
+
* vertical, whose storage lives in that vertical's deployment — reaching through
|
|
1356
|
+
* `this.scopeStub` there would open an empty DO in the wrong namespace. On the hosted path a
|
|
1357
|
+
* peer reaches the target deployment itself, through the platform, never through this host.
|
|
1358
|
+
*/
|
|
1359
|
+
async peerScopeGate(tenantId, scopeId, verb) {
|
|
1360
|
+
// K-3's pair check runs HERE, not only inside the ControlPlaneDO (#1714 review). An error
|
|
1361
|
+
// thrown in a Durable Object arrives at the coordinator FLATTENED — its code and
|
|
1362
|
+
// extensions gone — so a typed refusal thrown there reaches a caller untyped, which this
|
|
1363
|
+
// door's own tenant-confinement test proved. A RECORD crosses intact, so the coordinator
|
|
1364
|
+
// reads the record and types the refusal itself. `validateScopeAccess` still runs below
|
|
1365
|
+
// and still owns the lifecycle half (a suspended tenant or scope).
|
|
1366
|
+
if (!this.cpLess) {
|
|
1367
|
+
const record = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1368
|
+
if (!record) {
|
|
1369
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1370
|
+
}
|
|
1371
|
+
this.assertServedHere(record, scopeId, verb);
|
|
1372
|
+
}
|
|
1373
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1374
|
+
await this.migrateAndRecord(scopeId);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* The capability's attachment door (#1686) — `getCapabilityScope`'s gate, then the
|
|
1378
|
+
* attachment surface with only the session's HASH going on to the ScopeDO. The DO
|
|
1379
|
+
* resolves it inside its queue on every call, checks each read as `{ capability }` and
|
|
1380
|
+
* refuses each write; the bytes stay on this side, as on every attachment path.
|
|
1381
|
+
*/
|
|
1382
|
+
async getCapabilityAttachments(sessionToken, tenantId, scopeId) {
|
|
1383
|
+
if (!plausibleSessionToken(sessionToken)) {
|
|
1384
|
+
throw substratError('unauthenticated', 'not a capability session token');
|
|
1385
|
+
}
|
|
1386
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1387
|
+
await this.migrateAndRecord(scopeId);
|
|
1388
|
+
const store = await this.resolveAttachmentStore(tenantId);
|
|
1389
|
+
const capabilitySession = await capabilityTokenHash(sessionToken);
|
|
1390
|
+
return this.buildAttachmentSurface({ capabilitySession }, tenantId, scopeId, store);
|
|
1391
|
+
}
|
|
1255
1392
|
/**
|
|
1256
1393
|
* Is this the SHARED control plane's host — the one whose own `SCOPE` namespace holds no
|
|
1257
1394
|
* hosted scope's storage, and which routes scope writes to the serving deployment? The
|
|
1258
1395
|
* delegations are set on that host and no other (their own docs say so), so any one of
|
|
1259
1396
|
* them being present is the signal.
|
|
1260
1397
|
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Refuse a scope this host does not serve (#1706's door, and #1705's three cross-vertical
|
|
1400
|
+
* verbs).
|
|
1401
|
+
*
|
|
1402
|
+
* On the shared control plane `this.scopeStub` is the module-less placeholder namespace, so
|
|
1403
|
+
* reaching it for a scope bound to a vertical answers from an EMPTY Durable Object: a read
|
|
1404
|
+
* reports a hosted producer as having nothing to export, and a delivery would journal a
|
|
1405
|
+
* batch into a scope that is not the one it names. Both are wrong ANSWERS rather than
|
|
1406
|
+
* failures, which is the shape that goes unnoticed. One refusal, shared by the door's gate
|
|
1407
|
+
* and by the verbs that read a record of their own, so a caller never meets two of them.
|
|
1408
|
+
*/
|
|
1409
|
+
assertServedHere(record, scopeId, verb) {
|
|
1410
|
+
if (this.servesScopesElsewhere && record.vertical !== null) {
|
|
1411
|
+
throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${record.vertical}' deployment, ` +
|
|
1412
|
+
'which a peer reaches through the platform, not through the shared control plane');
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1261
1415
|
get servesScopesElsewhere() {
|
|
1262
|
-
return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.eventDrainDelegation);
|
|
1416
|
+
return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.peerSwitchDelegation || this.eventDrainDelegation);
|
|
1263
1417
|
}
|
|
1264
1418
|
/**
|
|
1265
1419
|
* Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
|
|
@@ -1280,6 +1434,10 @@ export class CloudflareScopeHost {
|
|
|
1280
1434
|
await this.migrateAndRecord(scopeId);
|
|
1281
1435
|
return { stub: this.scopeStub(scopeId), vertical: rec.vertical };
|
|
1282
1436
|
}
|
|
1437
|
+
/** #1705: what this deployment imports — the sweep's reason to call no scope when it is empty. */
|
|
1438
|
+
registeredImports() {
|
|
1439
|
+
return this.crossVertical.consumes();
|
|
1440
|
+
}
|
|
1283
1441
|
registeredSchedules() {
|
|
1284
1442
|
const out = [];
|
|
1285
1443
|
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
@@ -1423,7 +1581,12 @@ export class CloudflareScopeHost {
|
|
|
1423
1581
|
* the plaintext goes no further. The ScopeDO resolves it on every invoke, inside its
|
|
1424
1582
|
* queue, and acknowledges it; see the refusal below for why the acknowledgement matters.
|
|
1425
1583
|
*/
|
|
1426
|
-
capabilitySession
|
|
1584
|
+
capabilitySession,
|
|
1585
|
+
/**
|
|
1586
|
+
* #1706: the calling PEER vertical, as the platform named it. The ScopeDO admits it on
|
|
1587
|
+
* every invoke, inside its queue, and acknowledges it — the capability session's pattern.
|
|
1588
|
+
*/
|
|
1589
|
+
verticalCaller) {
|
|
1427
1590
|
const stub = this.scopeStub(scopeId);
|
|
1428
1591
|
const cp = this.cp;
|
|
1429
1592
|
const operationEntitlement = this.operationEntitlement;
|
|
@@ -1432,10 +1595,14 @@ export class CloudflareScopeHost {
|
|
|
1432
1595
|
// honest attribution rides on the event actor instead. For a capability it is a
|
|
1433
1596
|
// FRESH id nobody holds anything under (#1672): the DO replaces it with the resolved
|
|
1434
1597
|
// capability, and a DO too old to know that would act as nobody rather than as someone.
|
|
1598
|
+
// A peer vertical (#1706) gets a fresh id too, for the same reason: the DO acts as the
|
|
1599
|
+
// admitted `{ vertical, scope }`, and a DO too old to know that acts as nobody.
|
|
1435
1600
|
const asPrincipalId = (principal ??
|
|
1436
1601
|
connectionId ??
|
|
1437
1602
|
systemModuleId ??
|
|
1438
|
-
(capabilitySession !== undefined
|
|
1603
|
+
(capabilitySession !== undefined || verticalCaller !== undefined
|
|
1604
|
+
? principalId.parse(ulid())
|
|
1605
|
+
: undefined));
|
|
1439
1606
|
return {
|
|
1440
1607
|
tenantId,
|
|
1441
1608
|
scopeId,
|
|
@@ -1457,7 +1624,7 @@ export class CloudflareScopeHost {
|
|
|
1457
1624
|
}
|
|
1458
1625
|
// K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
|
|
1459
1626
|
const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
|
|
1460
|
-
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession);
|
|
1627
|
+
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession, verticalCaller);
|
|
1461
1628
|
// The operation failed and the DO handed the error back as DATA — so it still
|
|
1462
1629
|
// has its code and extensions, which a throw across this boundary would have
|
|
1463
1630
|
// stripped down to a message (#113 §3). Rethrown here, where the caller expects
|
|
@@ -1499,6 +1666,13 @@ export class CloudflareScopeHost {
|
|
|
1499
1666
|
throw substratError('unavailable', `${operation} ran on a scope host that did not understand capability sessions. ` +
|
|
1500
1667
|
'Retry once the scope has been migrated');
|
|
1501
1668
|
}
|
|
1669
|
+
// #1706, the same shape: a DO too old to know about peer callers ran the call as the
|
|
1670
|
+
// fresh placeholder principal — without the peer's admission, its allowlist or its
|
|
1671
|
+
// switch. A success decided that way is refused.
|
|
1672
|
+
if (verticalCaller !== undefined && envelope.vertical?.honoured !== true) {
|
|
1673
|
+
throw substratError('unavailable', `${operation} ran on a scope host that did not understand peer callers. ` +
|
|
1674
|
+
'Retry once the scope has been migrated');
|
|
1675
|
+
}
|
|
1502
1676
|
if (session !== undefined && envelope.impersonation?.honoured !== true) {
|
|
1503
1677
|
throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
|
|
1504
1678
|
'operation may have run unrecorded and unbounded. Retry once the scope has ' +
|
|
@@ -1582,6 +1756,7 @@ export class CloudflareScopeHost {
|
|
|
1582
1756
|
admissionNote: r.admission_note,
|
|
1583
1757
|
origin: r.origin_json ? JSON.parse(r.origin_json) : null,
|
|
1584
1758
|
outbound: outboundOfManifestJson(r.manifest_json),
|
|
1759
|
+
calls: callsOfManifestJson(r.manifest_json),
|
|
1585
1760
|
createdAt: r.created_at,
|
|
1586
1761
|
});
|
|
1587
1762
|
const mapOrg = (r) => orgSchema.parse({
|
|
@@ -1664,6 +1839,77 @@ export class CloudflareScopeHost {
|
|
|
1664
1839
|
* switch pulled while every schedule kept firing. The audit row is written HERE either
|
|
1665
1840
|
* way — the deployment's host is CP-less, and its `recordAdmin` is a no-op.
|
|
1666
1841
|
*/
|
|
1842
|
+
/**
|
|
1843
|
+
* #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
|
|
1844
|
+
* schedule switch's audit discipline (intent first, outcome after, every attempt) and,
|
|
1845
|
+
* since part 3, its delegation: a scope served by a vertical's own deployment is switched
|
|
1846
|
+
* THERE, over `PeerSwitchDelegation`. Without one configured the scope is still refused
|
|
1847
|
+
* `unavailable` (`peerScopeGate`) rather than switched in the placeholder namespace.
|
|
1848
|
+
*/
|
|
1849
|
+
const switchPeerAt = async (actor, raw, to) => {
|
|
1850
|
+
const input = peerSwitch.parse(raw);
|
|
1851
|
+
const { tenantId, scopeId } = input.node;
|
|
1852
|
+
let vertical = null;
|
|
1853
|
+
if (!this.cpLess) {
|
|
1854
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1855
|
+
if (!rec)
|
|
1856
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1857
|
+
vertical = rec.vertical;
|
|
1858
|
+
}
|
|
1859
|
+
const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
|
|
1860
|
+
// Where the write lands is the whole point of this branch (#1706 part 3, mirroring
|
|
1861
|
+
// #1666). A scope bound to a vertical is served by that vertical's own deployment, and
|
|
1862
|
+
// its `vertical:<slug>` grants live there; switching it in the shared control plane's
|
|
1863
|
+
// placeholder namespace would tombstone nothing and report a peer cut off while every
|
|
1864
|
+
// call it makes kept being admitted. A scope bound to NO vertical has its store here,
|
|
1865
|
+
// so it switches here — delegating it would throw "no deployment serving scope".
|
|
1866
|
+
// With no delegation configured the old refusal stands, loudly, rather than a write
|
|
1867
|
+
// into the wrong namespace.
|
|
1868
|
+
const delegation = this.cpLess || vertical !== null ? this.peerSwitchDelegation : undefined;
|
|
1869
|
+
if (delegation) {
|
|
1870
|
+
// The gate's lifecycle half still runs (a suspended tenant or scope cannot be
|
|
1871
|
+
// switched); its `scopeStub` half is what the delegation replaces.
|
|
1872
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1873
|
+
}
|
|
1874
|
+
else {
|
|
1875
|
+
await this.peerScopeGate(tenantId, scopeId, action);
|
|
1876
|
+
}
|
|
1877
|
+
const operationId = ulid();
|
|
1878
|
+
const target = { tenantId, scopeId, vertical };
|
|
1879
|
+
const base = { operationId, vertical: input.vertical, calls: to };
|
|
1880
|
+
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
1881
|
+
let outcome;
|
|
1882
|
+
try {
|
|
1883
|
+
outcome = delegation
|
|
1884
|
+
? await delegation.switch({ tenantId, scopeId, vertical: input.vertical, to })
|
|
1885
|
+
: await this.scopeStub(scopeId).switchPeer(input.vertical, scopeId, to, new Date().toISOString());
|
|
1886
|
+
}
|
|
1887
|
+
catch (err) {
|
|
1888
|
+
await this.recordAdmin(actor, action, target, null, {
|
|
1889
|
+
...base,
|
|
1890
|
+
phase: 'failed',
|
|
1891
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1892
|
+
}).catch(() => undefined);
|
|
1893
|
+
throw err;
|
|
1894
|
+
}
|
|
1895
|
+
await this.recordAdmin(actor, action, target, null, {
|
|
1896
|
+
...base,
|
|
1897
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
1898
|
+
changed: outcome.changed,
|
|
1899
|
+
permissions: outcome.permissions,
|
|
1900
|
+
});
|
|
1901
|
+
if (!outcome.held) {
|
|
1902
|
+
throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
|
|
1903
|
+
`(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
|
|
1904
|
+
}
|
|
1905
|
+
return {
|
|
1906
|
+
operationId,
|
|
1907
|
+
vertical: input.vertical,
|
|
1908
|
+
calls: to,
|
|
1909
|
+
changed: outcome.changed,
|
|
1910
|
+
permissions: outcome.permissions,
|
|
1911
|
+
};
|
|
1912
|
+
};
|
|
1667
1913
|
const switchSystem = async (actor, raw, to) => {
|
|
1668
1914
|
const input = systemSwitch.parse(raw);
|
|
1669
1915
|
const { tenantId, scopeId } = input.node;
|
|
@@ -1723,6 +1969,128 @@ export class CloudflareScopeHost {
|
|
|
1723
1969
|
permissions: outcome.permissions,
|
|
1724
1970
|
};
|
|
1725
1971
|
};
|
|
1972
|
+
/**
|
|
1973
|
+
* The status read's admin-log join (#1674): the `intent` row of the `revokeFromSystem`
|
|
1974
|
+
* still in force for each OFF module — actor, reason, when. Paired to its `applied` row
|
|
1975
|
+
* by `operationId`, so a refused or failed attempt is never read as the explanation
|
|
1976
|
+
* (`switchSystemSchedules`'s `held` check means one can only occur on a module this
|
|
1977
|
+
* scope has never switched off before, so it can never be what a currently-off module
|
|
1978
|
+
* is explained by — this is belt-and-braces, not a case that is expected to fire).
|
|
1979
|
+
*/
|
|
1980
|
+
const lastSwitchedOffBy = async (tenantId, scopeId,
|
|
1981
|
+
/** The revoking action, and the payload field naming its subject (#1706). */
|
|
1982
|
+
action, key, subjects) => {
|
|
1983
|
+
const moduleIds = subjects;
|
|
1984
|
+
// `order: 'desc'` is load-bearing: the loop below takes the FIRST intent it sees per
|
|
1985
|
+
// module as the latest one, and `auditLog`'s own default is 'asc' (oldest first).
|
|
1986
|
+
const rows = await this.cp.auditLog({ tenantId, scopeId, action: [action], order: 'desc' });
|
|
1987
|
+
const appliedOps = new Set();
|
|
1988
|
+
for (const row of rows) {
|
|
1989
|
+
const payload = row.after;
|
|
1990
|
+
if (payload?.phase === 'applied' && payload.operationId)
|
|
1991
|
+
appliedOps.add(payload.operationId);
|
|
1992
|
+
}
|
|
1993
|
+
const result = new Map();
|
|
1994
|
+
for (const row of rows) {
|
|
1995
|
+
if (result.size === moduleIds.size)
|
|
1996
|
+
break;
|
|
1997
|
+
const payload = row.after;
|
|
1998
|
+
const subject = payload?.[key];
|
|
1999
|
+
if (!payload || payload.phase !== 'intent' || !payload.operationId || typeof subject !== 'string')
|
|
2000
|
+
continue;
|
|
2001
|
+
if (!moduleIds.has(subject) || result.has(subject))
|
|
2002
|
+
continue;
|
|
2003
|
+
if (!appliedOps.has(String(payload.operationId)) || typeof payload.reason !== 'string')
|
|
2004
|
+
continue;
|
|
2005
|
+
result.set(subject, { actor: row.actor, reason: payload.reason, at: row.at });
|
|
2006
|
+
}
|
|
2007
|
+
return result;
|
|
2008
|
+
};
|
|
2009
|
+
/** #1674's join, bound to the schedule switch — the shape every caller here used. */
|
|
2010
|
+
const lastSwitchedOff = async (tenantId, scopeId, moduleIds) => lastSwitchedOffBy(tenantId, scopeId, 'revokeFromSystem', 'moduleId', moduleIds);
|
|
2011
|
+
/**
|
|
2012
|
+
* The status read (#1674): every module this scope holds or has held system authority
|
|
2013
|
+
* for, and where each stands — the SAME predicate `switchSystem` gates its writes on
|
|
2014
|
+
* (`systemScheduleState`, walked over every held subject by the kernel's
|
|
2015
|
+
* `systemGrantsStatus`), so the read and the runner cannot disagree. For a hosted
|
|
2016
|
+
* scope, delegated exactly as the write is (see `switchSystem` above): a deployment
|
|
2017
|
+
* built before #1666's route answers the same "redeploy the vertical", never a wrong
|
|
2018
|
+
* `on`. The admin-log join happens HERE only — never on the deployment, which holds
|
|
2019
|
+
* no admin log of its own.
|
|
2020
|
+
*/
|
|
2021
|
+
const systemGrantsStatusOf = async (actor, node) => {
|
|
2022
|
+
const { tenantId, scopeId } = node;
|
|
2023
|
+
let vertical = null;
|
|
2024
|
+
if (!this.cpLess) {
|
|
2025
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2026
|
+
if (!rec)
|
|
2027
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2028
|
+
vertical = rec.vertical;
|
|
2029
|
+
}
|
|
2030
|
+
// A hosted scope (a vertical bound, on a non-cpLess host) has no local storage worth
|
|
2031
|
+
// reading — its grants live in the deployment serving it, reachable only through the
|
|
2032
|
+
// delegation. Without one configured, the ternary below would silently fall to THIS
|
|
2033
|
+
// host's own placeholder DO and answer an unrelated (and likely empty) position as
|
|
2034
|
+
// if it were the scope's own — a fail-open wrong answer, not a fail-closed refusal.
|
|
2035
|
+
if (!this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
|
|
2036
|
+
throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read its schedule switches`);
|
|
2037
|
+
}
|
|
2038
|
+
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
2039
|
+
const states = delegation
|
|
2040
|
+
? await delegation.status({ tenantId, scopeId })
|
|
2041
|
+
: systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
|
|
2042
|
+
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
2043
|
+
const explanations = offModules.size > 0
|
|
2044
|
+
? await lastSwitchedOff(tenantId, scopeId, offModules)
|
|
2045
|
+
: new Map();
|
|
2046
|
+
const result = states.map((s) => ({
|
|
2047
|
+
moduleId: s.moduleId,
|
|
2048
|
+
schedules: s.schedules,
|
|
2049
|
+
switchedOff: explanations.get(s.moduleId) ?? null,
|
|
2050
|
+
}));
|
|
2051
|
+
// K-24: reading the switch's position and any live incident reason is itself
|
|
2052
|
+
// access-logged, the same as every other HostAdmin read.
|
|
2053
|
+
await this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
|
|
2054
|
+
return result;
|
|
2055
|
+
};
|
|
2056
|
+
/**
|
|
2057
|
+
* The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
|
|
2058
|
+
* swapped, and delegated for the same reason the write is: a hosted scope's
|
|
2059
|
+
* `vertical:<slug>` grants live in the deployment serving it, and this host's own
|
|
2060
|
+
* namespace holds a placeholder no peer call ever consults. Reading the placeholder
|
|
2061
|
+
* would be confidently wrong rather than loudly unavailable, which on a kill switch's
|
|
2062
|
+
* status is the worse of the two, so a hosted scope with no delegation configured is
|
|
2063
|
+
* refused. The admin-log join happens HERE only — the deployment holds no admin log.
|
|
2064
|
+
*/
|
|
2065
|
+
const peerGrantsStatusOf = async (actor, node) => {
|
|
2066
|
+
const { tenantId, scopeId } = node;
|
|
2067
|
+
let vertical = null;
|
|
2068
|
+
if (!this.cpLess) {
|
|
2069
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2070
|
+
if (!rec)
|
|
2071
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2072
|
+
vertical = rec.vertical;
|
|
2073
|
+
}
|
|
2074
|
+
if (!this.cpLess && vertical !== null && !this.peerSwitchDelegation) {
|
|
2075
|
+
throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read which ` +
|
|
2076
|
+
`peers may call it`);
|
|
2077
|
+
}
|
|
2078
|
+
const delegation = this.cpLess || vertical !== null ? this.peerSwitchDelegation : undefined;
|
|
2079
|
+
const states = delegation
|
|
2080
|
+
? await delegation.status({ tenantId, scopeId })
|
|
2081
|
+
: peerGrantsEntry.array().parse(await this.scopeStub(scopeId).peerGrantsStatus());
|
|
2082
|
+
const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
|
|
2083
|
+
const explanations = offPeers.size > 0
|
|
2084
|
+
? await lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers)
|
|
2085
|
+
: new Map();
|
|
2086
|
+
const result = states.map((p) => ({
|
|
2087
|
+
vertical: p.vertical,
|
|
2088
|
+
calls: p.calls,
|
|
2089
|
+
switchedOff: explanations.get(p.vertical) ?? null,
|
|
2090
|
+
}));
|
|
2091
|
+
await this.recordAccess(actor, 'peerGrantsStatus', { tenantId, scopeId }, null, result.length);
|
|
2092
|
+
return result;
|
|
2093
|
+
};
|
|
1726
2094
|
return {
|
|
1727
2095
|
// #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
|
|
1728
2096
|
// store a credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -1879,8 +2247,14 @@ export class CloudflareScopeHost {
|
|
|
1879
2247
|
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
1880
2248
|
// whole rule, shared with the pure adapter; this is the directory check, the reach
|
|
1881
2249
|
// into the scope's storage, and the audit row around it.
|
|
2250
|
+
peerGrantsStatus: peerGrantsStatusOf,
|
|
2251
|
+
revokeFromPeer: async (actor, raw) => switchPeerAt(actor, raw, 'off'),
|
|
2252
|
+
restoreToPeer: async (actor, raw) => switchPeerAt(actor, raw, 'on'),
|
|
1882
2253
|
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
1883
2254
|
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
2255
|
+
// #1674: the switch's status read — same gate, same delegation, and (unlike the
|
|
2256
|
+
// deployment it may delegate to) the admin log to explain an `off` entry.
|
|
2257
|
+
systemGrantsStatus: systemGrantsStatusOf,
|
|
1884
2258
|
// #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
|
|
1885
2259
|
// the failure each leaves is the safe one: a mint whose audit row did not land never
|
|
1886
2260
|
// returned its secret, so nobody can ever exchange it; a revoke whose row did not land
|
|
@@ -2014,6 +2388,9 @@ export class CloudflareScopeHost {
|
|
|
2014
2388
|
await this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
2015
2389
|
return rows.map(mapHostname);
|
|
2016
2390
|
},
|
|
2391
|
+
// #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
|
|
2392
|
+
// the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
|
|
2393
|
+
resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
|
|
2017
2394
|
resolveHostname: async (raw) =>
|
|
2018
2395
|
// The router's per-request read. No actor, not logged — the same machine-path
|
|
2019
2396
|
// carve-out resolveIdentity has (K-24). Shares its mapping with the router's
|
|
@@ -2589,6 +2966,35 @@ export class CloudflareScopeHost {
|
|
|
2589
2966
|
await this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
|
|
2590
2967
|
return tables;
|
|
2591
2968
|
},
|
|
2969
|
+
readExportedEvents: async (actor, tenantId, scopeId, raw) => {
|
|
2970
|
+
// #1705: the producer half. The DO decides what leaves, from its own registered
|
|
2971
|
+
// exports. This side gates the pair (K-3), parses both directions, and writes the
|
|
2972
|
+
// access row, because a platform read of domain data leaving a scope is one.
|
|
2973
|
+
const input = exportReadInput.parse(raw);
|
|
2974
|
+
// The read's own K-3 and reap gate, then the same refusal the door gives: on the
|
|
2975
|
+
// shared control plane this scope's outbox lives in its vertical's deployment, and
|
|
2976
|
+
// the placeholder here would report a hosted producer as having nothing to export.
|
|
2977
|
+
// Not `peerScopeGate`: a read must not migrate, and the phase makes this call once
|
|
2978
|
+
// per edge per pass, where an extra round trip is a fleet-wide cost.
|
|
2979
|
+
this.assertServedHere(await this.scopeRecordForRead(tenantId, scopeId), scopeId, 'readExportedEvents');
|
|
2980
|
+
const batch = exportedBatch.parse(await this.scopeStub(scopeId).exportedEventsRead(input, tenantId, scopeId));
|
|
2981
|
+
await this.recordAccess(actor, 'readExportedEvents', { tenantId, scopeId }, { consumer: input.consumer, after: input.after, limit: input.limit }, batch.events.length);
|
|
2982
|
+
return batch;
|
|
2983
|
+
},
|
|
2984
|
+
importState: async (actor, tenantId, scopeId) => {
|
|
2985
|
+
// Answered with no call at all when this deployment imports nothing: not the directory,
|
|
2986
|
+
// not the DO. "Imports nothing" is a fact about this code, and it names no scope, so it
|
|
2987
|
+
// tells a caller nothing about a (tenant, scope) pair it may not address.
|
|
2988
|
+
if (this.crossVertical.consumes().length === 0)
|
|
2989
|
+
return { consumes: [], cursors: [] };
|
|
2990
|
+
// Then the read's own gate, and the same refusal: a hosted scope's watermark lives in
|
|
2991
|
+
// its vertical's deployment, and the placeholder here would answer "never read
|
|
2992
|
+
// anything", which a pass would act on by re-delivering that edge from the start.
|
|
2993
|
+
this.assertServedHere(await this.scopeRecordForRead(tenantId, scopeId), scopeId, 'importState');
|
|
2994
|
+
const state = importState.parse(await this.scopeStub(scopeId).importStateRead());
|
|
2995
|
+
await this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, state.cursors.length);
|
|
2996
|
+
return state;
|
|
2997
|
+
},
|
|
2592
2998
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
2593
2999
|
const record = await this.scopeRecordForRead(tenantId, scopeId);
|
|
2594
3000
|
const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
|
|
@@ -2918,13 +3324,23 @@ export class CloudflareScopeHost {
|
|
|
2918
3324
|
`_substrat_platform_requests — erasing now would destroy the subject key while ` +
|
|
2919
3325
|
`leaving their payloads in the intent journal. Redeploy the vertical and re-run.`);
|
|
2920
3326
|
}
|
|
2921
|
-
|
|
3327
|
+
// A DO from after #1600 and before #1632: it redacted the outbox and the intent
|
|
3328
|
+
// journal and never looked at the job-run tables. Refused BEFORE the key, for the
|
|
3329
|
+
// reason above — its reply read as `jobRuns: 0` would receipt an erasure that left
|
|
3330
|
+
// the person in a step's memo.
|
|
3331
|
+
if (!('jobRuns' in redacted) || typeof redacted.jobRuns !== 'number') {
|
|
3332
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #1632, whose redaction does not reach ` +
|
|
3333
|
+
`_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
|
|
3334
|
+
`key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
|
|
3335
|
+
}
|
|
3336
|
+
const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted } = redacted;
|
|
2922
3337
|
const at = new Date().toISOString();
|
|
2923
3338
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
2924
3339
|
const receipt = subjectShredReceipt.parse({
|
|
2925
3340
|
subjectId,
|
|
2926
3341
|
eventsRedacted,
|
|
2927
3342
|
intentsRedacted,
|
|
3343
|
+
jobRunsRedacted,
|
|
2928
3344
|
keyDestroyed: existed,
|
|
2929
3345
|
tombstoned: true,
|
|
2930
3346
|
});
|
|
@@ -2934,7 +3350,7 @@ export class CloudflareScopeHost {
|
|
|
2934
3350
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
2935
3351
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
2936
3352
|
// an intent payload is a whole event's worth of it.
|
|
2937
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted);
|
|
3353
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
|
|
2938
3354
|
return receipt;
|
|
2939
3355
|
},
|
|
2940
3356
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -3213,7 +3629,11 @@ export class CloudflareScopeHost {
|
|
|
3213
3629
|
};
|
|
3214
3630
|
},
|
|
3215
3631
|
recordConnectionUse: async (id, outcome) => {
|
|
3216
|
-
await this.cp.recordConnectionUse(id, outcome.ok ? null : outcome.error, new Date().toISOString());
|
|
3632
|
+
const row = await this.cp.recordConnectionUse(id, outcome.ok ? null : outcome.error, new Date().toISOString());
|
|
3633
|
+
// #1691: the line is settled — now the data point, off the identity the DO read
|
|
3634
|
+
// from the row (never the caller's input), fire-and-forget.
|
|
3635
|
+
if (row)
|
|
3636
|
+
recordConnectorCall(this.connectorCalls, connectorCallRecord(row, outcome));
|
|
3217
3637
|
},
|
|
3218
3638
|
putConnectorState: async (id, key, value) => {
|
|
3219
3639
|
// JSON on the coordinator; the DO stores an opaque string, the same
|
|
@@ -3946,6 +4366,14 @@ export class CloudflareScopeHost {
|
|
|
3946
4366
|
expires_at: null,
|
|
3947
4367
|
}));
|
|
3948
4368
|
}),
|
|
4369
|
+
// #1706: each declared PEER's grants ride the same unit — the CP-less mirror of
|
|
4370
|
+
// `provisionScope`'s peer seat, through the one function that decides it.
|
|
4371
|
+
...peerSeats(collectPeers(this.peerSources), input.scopeId).map((seat) => ({
|
|
4372
|
+
subject: seat.subject,
|
|
4373
|
+
relation: seat.relation,
|
|
4374
|
+
object: seat.object,
|
|
4375
|
+
expires_at: null,
|
|
4376
|
+
})),
|
|
3949
4377
|
// #592: the tenant's connection grants ride the same unit — the provision-time
|
|
3950
4378
|
// mirror of `connectorGrantLocal`, so the connector return path works on every
|
|
3951
4379
|
// install, not only the one that existed when `grantToConnection` ran.
|
|
@@ -4111,6 +4539,48 @@ export class CloudflareScopeHost {
|
|
|
4111
4539
|
// plain strings become the published shape here rather than on trust.
|
|
4112
4540
|
return systemSwitchOutcome.parse(await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, new Date().toISOString()));
|
|
4113
4541
|
}
|
|
4542
|
+
/**
|
|
4543
|
+
* The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`
|
|
4544
|
+
* lands on this, from the shared control plane's `systemGrantsStatus`. No admin-log join
|
|
4545
|
+
* happens here — this deployment holds none — so it answers the bare per-module position
|
|
4546
|
+
* only, exactly like `systemSwitchLocal` answers a bare outcome; the platform joins its
|
|
4547
|
+
* own admin log onto this by moduleId once it returns.
|
|
4548
|
+
*/
|
|
4549
|
+
async systemGrantsStatusLocal(scopeId) {
|
|
4550
|
+
return systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
|
|
4551
|
+
}
|
|
4552
|
+
/**
|
|
4553
|
+
* The far end of the peer switch's status read (#1706): `/internal/peer-grants` lands on
|
|
4554
|
+
* this, from the shared control plane's `peerGrantsStatus`. The bare per-peer position
|
|
4555
|
+
* only — who switched a peer off and why is the admin log's, and this deployment holds
|
|
4556
|
+
* none — so the platform joins its own log onto this by slug once it returns.
|
|
4557
|
+
*/
|
|
4558
|
+
async peerGrantsStatusLocal(scopeId) {
|
|
4559
|
+
return peerGrantsEntry.array().parse(await this.scopeStub(scopeId).peerGrantsStatus());
|
|
4560
|
+
}
|
|
4561
|
+
// -- the peer door's far end (#1706) ----------------------------------------
|
|
4562
|
+
// The platform identified the calling deployment (the router, at the hop the caller cannot
|
|
4563
|
+
// forge) and resolved THIS scope as the instance of this vertical in the caller's tenant;
|
|
4564
|
+
// it reaches here over the platform-secret-gated `/internal/vertical-invoke`. What runs
|
|
4565
|
+
// HERE is the half only this deployment can enforce: the peer's admission and its grants,
|
|
4566
|
+
// in the scope's own DO, on every call.
|
|
4567
|
+
/** Invoke ONE operation in this deployment as a PEER vertical (#1706). */
|
|
4568
|
+
async verticalInvokeLocal(caller, tenantId, scopeId, operation, input, options) {
|
|
4569
|
+
return (await this.getVerticalScope(caller, tenantId, scopeId)).invoke(operation, input, options);
|
|
4570
|
+
}
|
|
4571
|
+
/**
|
|
4572
|
+
* Move one peer's kill switch on a scope served HERE (#1706) — the far end of
|
|
4573
|
+
* `revokeFromPeer` / `restoreToPeer` for a hosted scope. Nothing is audited here; the
|
|
4574
|
+
* platform writes the admin rows around this call, as for the schedule switch.
|
|
4575
|
+
*/
|
|
4576
|
+
async peerSwitchLocal(scopeId, vertical, to) {
|
|
4577
|
+
return peerSwitchOutcome.parse(await this.scopeStub(scopeId).switchPeer(verticalSlug.parse(vertical), scopeId, to, new Date().toISOString()));
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
/** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
|
|
4581
|
+
async function sha256Hex(body) {
|
|
4582
|
+
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
4583
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
4114
4584
|
}
|
|
4115
4585
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
4116
4586
|
function modelUsageEntryOf(r) {
|