@substrat-run/adapter-cloudflare 0.116.0 → 0.117.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/checker.d.ts.map +1 -1
- package/dist/checker.js +7 -1
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +31 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +54 -11
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +94 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +302 -48
- 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/scope-do.d.ts +0 -16
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +421 -148
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +6 -0
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +14 -0
- package/dist/sql.js.map +1 -1
- package/package.json +4 -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, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, 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,
|
|
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';
|
|
2
2
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
3
|
-
import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, } 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, } 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,23 +22,6 @@ const toConnection = (r) => connection.parse({
|
|
|
22
22
|
createdAt: r.created_at,
|
|
23
23
|
revokedAt: r.revoked_at,
|
|
24
24
|
});
|
|
25
|
-
/** Map a stored platform-request row to the `PlatformRequest` contract shape (JSON columns parsed). */
|
|
26
|
-
function rowToPlatformRequest(r) {
|
|
27
|
-
return platformRequest.parse({
|
|
28
|
-
id: r.id,
|
|
29
|
-
kind: r.kind,
|
|
30
|
-
payload: JSON.parse(r.payload),
|
|
31
|
-
requestedBy: JSON.parse(r.requested_by),
|
|
32
|
-
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
33
|
-
status: r.status,
|
|
34
|
-
attempts: r.attempts,
|
|
35
|
-
lastError: r.last_error,
|
|
36
|
-
failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
|
|
37
|
-
result: r.result === null ? null : JSON.parse(r.result),
|
|
38
|
-
requestedAt: r.requested_at,
|
|
39
|
-
settledAt: r.settled_at,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
25
|
/**
|
|
43
26
|
* A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
|
|
44
27
|
* The hot path a served scope actually touches becomes trust-the-upstream:
|
|
@@ -121,6 +104,8 @@ export class CloudflareScopeHost {
|
|
|
121
104
|
connectorDelegation;
|
|
122
105
|
/** #1334: the Tier-2 drain's reach into the deployment serving a scope. */
|
|
123
106
|
eventDrainDelegation;
|
|
107
|
+
/** #1666: the schedule kill switch's reach into the deployment serving a scope. */
|
|
108
|
+
systemSwitchDelegation;
|
|
124
109
|
/**
|
|
125
110
|
* MUST be constructed per request. Never cache an instance across requests.
|
|
126
111
|
*
|
|
@@ -146,6 +131,7 @@ export class CloudflareScopeHost {
|
|
|
146
131
|
: nullControlPlane();
|
|
147
132
|
this.connectorDelegation = options.connectorDelegation;
|
|
148
133
|
this.eventDrainDelegation = options.eventDrainDelegation;
|
|
134
|
+
this.systemSwitchDelegation = options.systemSwitchDelegation;
|
|
149
135
|
this.admin = this.buildAdmin();
|
|
150
136
|
}
|
|
151
137
|
// -- registration mechanics (validation only) -----------------------------
|
|
@@ -276,7 +262,16 @@ export class CloudflareScopeHost {
|
|
|
276
262
|
const stub = this.scopeStub(scopeId);
|
|
277
263
|
for (const [id, executor] of this.executors) {
|
|
278
264
|
const deliveryId = `executor:${id}`;
|
|
279
|
-
const events = await stub.
|
|
265
|
+
const { events, undecodable } = await stub.pendingExecutorDeliveries(deliveryId, executor.eventType);
|
|
266
|
+
// #1636: an event the DO could not decode is dead-lettered for this executor at once,
|
|
267
|
+
// and its handler never sees it. Terminal on the FIRST failure, unlike a handler's:
|
|
268
|
+
// the decode is pure, so a retry cannot succeed. The rows behind it are delivered
|
|
269
|
+
// below — the decode used to throw the whole list, on every pass.
|
|
270
|
+
for (const bad of undecodable) {
|
|
271
|
+
report.attempted += 1;
|
|
272
|
+
await stub.recordExecutorAttempt(bad.eventId, deliveryId, bad.error, null, invocationId);
|
|
273
|
+
report.deadLettered += 1;
|
|
274
|
+
}
|
|
280
275
|
for (const event of events) {
|
|
281
276
|
report.attempted += 1;
|
|
282
277
|
this.causedBy = event.id;
|
|
@@ -420,12 +415,13 @@ export class CloudflareScopeHost {
|
|
|
420
415
|
async listPlatformRequests(tenantId, scopeId) {
|
|
421
416
|
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
422
417
|
await this.migrateAndRecord(scopeId);
|
|
423
|
-
|
|
418
|
+
// Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
|
|
419
|
+
return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
|
|
424
420
|
}
|
|
425
421
|
async listPlatformRequestHistory(tenantId, scopeId, filter) {
|
|
426
422
|
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
427
423
|
await this.migrateAndRecord(scopeId);
|
|
428
|
-
return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(
|
|
424
|
+
return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(platformRequestOf);
|
|
429
425
|
}
|
|
430
426
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
431
427
|
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
@@ -604,13 +600,17 @@ export class CloudflareScopeHost {
|
|
|
604
600
|
async appliedMigrationsLocal(scopeId) {
|
|
605
601
|
return this.scopeStub(scopeId).appliedMigrations();
|
|
606
602
|
}
|
|
603
|
+
/** This host's own scope's database size in bytes (#1524), the vertical-host read. */
|
|
604
|
+
async databaseSizeLocal(scopeId) {
|
|
605
|
+
return this.scopeStub(scopeId).databaseSize();
|
|
606
|
+
}
|
|
607
607
|
/**
|
|
608
608
|
* The oldest not-yet-drained events of this host's own scope (#1334) — the far end of
|
|
609
609
|
* the control plane's `EventDrainDelegation`. Bounded the same way the audited verb is,
|
|
610
610
|
* so a caller cannot ask this side for more than the other would.
|
|
611
611
|
*/
|
|
612
612
|
async undrainedEventsLocal(scopeId, limit) {
|
|
613
|
-
return this.scopeStub(scopeId).
|
|
613
|
+
return undrainedEventsOf(await this.scopeStub(scopeId).undrainedEventsRead(Math.min(Math.max(limit, 1), 1000)));
|
|
614
614
|
}
|
|
615
615
|
/**
|
|
616
616
|
* Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
|
|
@@ -737,14 +737,18 @@ export class CloudflareScopeHost {
|
|
|
737
737
|
// holds exactly the permissions its schedules declared, on this scope, so
|
|
738
738
|
// `ctx.check` resolves for scheduled work (the gate stays the check). Written to
|
|
739
739
|
// the scope's own tuples, where the checker reads them — the same place the owner
|
|
740
|
-
// grant and connection grants land. Idempotent, so a re-provision re-asserts them
|
|
740
|
+
// grant and connection grants land. Idempotent, so a re-provision re-asserts them —
|
|
741
|
+
// SEATED (#1659): a missing grant is recreated, a revoked one stays revoked. The
|
|
742
|
+
// per-scope schedule kill switch is `revokeFromSystem` (#1666), and its OFF marker is
|
|
743
|
+
// not a grant, so no reconcile can seat it away; `restoreToSystem` is the only way back.
|
|
744
|
+
// (`grantToSystem` still clears a tuple's tombstone, but it does not move the switch.)
|
|
741
745
|
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
742
746
|
const perms = new Set();
|
|
743
747
|
for (const s of schedules)
|
|
744
748
|
for (const p of s.permissions)
|
|
745
749
|
perms.add(p);
|
|
746
750
|
for (const perm of perms) {
|
|
747
|
-
await this.scopeStub(input.scopeId).
|
|
751
|
+
await this.scopeStub(input.scopeId).seatTuple(`system:${moduleId}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
|
|
748
752
|
}
|
|
749
753
|
}
|
|
750
754
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
@@ -1223,6 +1227,59 @@ export class CloudflareScopeHost {
|
|
|
1223
1227
|
await this.migrateAndRecord(scopeId);
|
|
1224
1228
|
return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
|
|
1225
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* The exchange (#1672) — the same fail-closed (tenant, scope) and lifecycle gate as
|
|
1232
|
+
* `getScope`, then the kernel's `exchangeCapability` inside the ScopeDO, where the
|
|
1233
|
+
* capability row lives. The answer is re-parsed on this side of the RPC.
|
|
1234
|
+
*/
|
|
1235
|
+
async exchangeCapability(tenantId, scopeId, secret, options) {
|
|
1236
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1237
|
+
await this.migrateAndRecord(scopeId);
|
|
1238
|
+
const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode);
|
|
1239
|
+
return capabilityExchange.nullable().parse(outcome);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* The capability door (#1672) — mirror of the connection, system and impersonation doors.
|
|
1243
|
+
* The token is shape-checked and hashed HERE; only its hash reaches the ScopeDO, which
|
|
1244
|
+
* re-resolves it to its capability on every invoke and acknowledges that it did.
|
|
1245
|
+
*/
|
|
1246
|
+
async getCapabilityScope(sessionToken, tenantId, scopeId, options) {
|
|
1247
|
+
if (!plausibleSessionToken(sessionToken)) {
|
|
1248
|
+
throw substratError('unauthenticated', 'not a capability session token');
|
|
1249
|
+
}
|
|
1250
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1251
|
+
await this.migrateAndRecord(scopeId);
|
|
1252
|
+
const hash = await capabilityTokenHash(sessionToken);
|
|
1253
|
+
return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Is this the SHARED control plane's host — the one whose own `SCOPE` namespace holds no
|
|
1257
|
+
* hosted scope's storage, and which routes scope writes to the serving deployment? The
|
|
1258
|
+
* delegations are set on that host and no other (their own docs say so), so any one of
|
|
1259
|
+
* them being present is the signal.
|
|
1260
|
+
*/
|
|
1261
|
+
get servesScopesElsewhere() {
|
|
1262
|
+
return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.eventDrainDelegation);
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
|
|
1266
|
+
* loudly, on the shared control plane for a scope bound to a vertical, whose storage lives
|
|
1267
|
+
* in that vertical's deployment. Writing through `this.scopeStub` there would mint into
|
|
1268
|
+
* an empty DO of the wrong namespace, a capability nobody could ever exchange. Delegating
|
|
1269
|
+
* these verbs to the serving deployment (as the schedule switch is) is a follow-up; the
|
|
1270
|
+
* claim-link migration is the first caller that needs it.
|
|
1271
|
+
*/
|
|
1272
|
+
async capabilityScopeStub(tenantId, scopeId, verb) {
|
|
1273
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1274
|
+
if (!rec)
|
|
1275
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1276
|
+
if (this.servesScopesElsewhere && rec.vertical !== null) {
|
|
1277
|
+
throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
|
|
1278
|
+
'deployment, and platform capability verbs are not delegated there yet');
|
|
1279
|
+
}
|
|
1280
|
+
await this.migrateAndRecord(scopeId);
|
|
1281
|
+
return { stub: this.scopeStub(scopeId), vertical: rec.vertical };
|
|
1282
|
+
}
|
|
1226
1283
|
registeredSchedules() {
|
|
1227
1284
|
const out = [];
|
|
1228
1285
|
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
@@ -1304,11 +1361,22 @@ export class CloudflareScopeHost {
|
|
|
1304
1361
|
return report;
|
|
1305
1362
|
}
|
|
1306
1363
|
const stub = this.scopeStub(scopeId);
|
|
1307
|
-
// The grant IS the switch (#383)
|
|
1308
|
-
// `
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1364
|
+
// The grant IS the switch (#383), and the kill switch is its lever (#1666): the
|
|
1365
|
+
// kernel's `systemScheduleState`, the one predicate both adapters run. A scope that
|
|
1366
|
+
// never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
|
|
1367
|
+
// before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
|
|
1368
|
+
// does not touch its cadence rows — so a restore fires a due schedule on the next pass.
|
|
1369
|
+
const state = await stub.systemScheduleState(moduleId);
|
|
1370
|
+
if (state === 'ungranted')
|
|
1371
|
+
return report;
|
|
1372
|
+
if (state === 'off') {
|
|
1373
|
+
for (const schedule of schedules) {
|
|
1374
|
+
report.skipped += 1;
|
|
1375
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
1376
|
+
}
|
|
1377
|
+
report.switchedOff = true;
|
|
1311
1378
|
return report;
|
|
1379
|
+
}
|
|
1312
1380
|
const now = Date.now();
|
|
1313
1381
|
for (const schedule of schedules) {
|
|
1314
1382
|
const last = await stub.scheduleLastRun(schedule.operation);
|
|
@@ -1349,16 +1417,25 @@ export class CloudflareScopeHost {
|
|
|
1349
1417
|
* time box that never runs out for the one caller holding it, and
|
|
1350
1418
|
* `endImpersonation` would stop nothing.
|
|
1351
1419
|
*/
|
|
1352
|
-
sessionId
|
|
1420
|
+
sessionId,
|
|
1421
|
+
/**
|
|
1422
|
+
* #1672: the HASH of a capability session token — the capability door hashed it and
|
|
1423
|
+
* the plaintext goes no further. The ScopeDO resolves it on every invoke, inside its
|
|
1424
|
+
* queue, and acknowledges it; see the refusal below for why the acknowledgement matters.
|
|
1425
|
+
*/
|
|
1426
|
+
capabilitySession) {
|
|
1353
1427
|
const stub = this.scopeStub(scopeId);
|
|
1354
1428
|
const cp = this.cp;
|
|
1355
1429
|
const operationEntitlement = this.operationEntitlement;
|
|
1356
1430
|
// The DO needs SOME principal-shaped value for `ctx.principal`; for a
|
|
1357
1431
|
// connection it is the connection id, for a schedule the module id, and the
|
|
1358
|
-
// honest attribution rides on the event actor instead.
|
|
1432
|
+
// honest attribution rides on the event actor instead. For a capability it is a
|
|
1433
|
+
// FRESH id nobody holds anything under (#1672): the DO replaces it with the resolved
|
|
1434
|
+
// capability, and a DO too old to know that would act as nobody rather than as someone.
|
|
1359
1435
|
const asPrincipalId = (principal ??
|
|
1360
1436
|
connectionId ??
|
|
1361
|
-
systemModuleId
|
|
1437
|
+
systemModuleId ??
|
|
1438
|
+
(capabilitySession !== undefined ? principalId.parse(ulid()) : undefined));
|
|
1362
1439
|
return {
|
|
1363
1440
|
tenantId,
|
|
1364
1441
|
scopeId,
|
|
@@ -1380,7 +1457,7 @@ export class CloudflareScopeHost {
|
|
|
1380
1457
|
}
|
|
1381
1458
|
// K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
|
|
1382
1459
|
const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
|
|
1383
|
-
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session);
|
|
1460
|
+
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession);
|
|
1384
1461
|
// The operation failed and the DO handed the error back as DATA — so it still
|
|
1385
1462
|
// has its code and extensions, which a throw across this boundary would have
|
|
1386
1463
|
// stripped down to a message (#113 §3). Rethrown here, where the caller expects
|
|
@@ -1414,6 +1491,14 @@ export class CloudflareScopeHost {
|
|
|
1414
1491
|
// principal with nothing stamped and no read-only bound. The write has
|
|
1415
1492
|
// committed; what this refuses is the SUCCESS, because a support session
|
|
1416
1493
|
// believed to be recorded and bounded is worse than no support session.
|
|
1494
|
+
// #1672, the same shape: a DO too old to know about capability sessions ignored the
|
|
1495
|
+
// hash and ran the call as the fresh placeholder principal, who holds nothing — so
|
|
1496
|
+
// the likely outcome is a refusal, but a SUCCESS here would have been decided
|
|
1497
|
+
// without the capability's grant, its allowlist or its liveness, and is refused.
|
|
1498
|
+
if (capabilitySession !== undefined && envelope.capability?.honoured !== true) {
|
|
1499
|
+
throw substratError('unavailable', `${operation} ran on a scope host that did not understand capability sessions. ` +
|
|
1500
|
+
'Retry once the scope has been migrated');
|
|
1501
|
+
}
|
|
1417
1502
|
if (session !== undefined && envelope.impersonation?.honoured !== true) {
|
|
1418
1503
|
throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
|
|
1419
1504
|
'operation may have run unrecorded and unbounded. Retry once the scope has ' +
|
|
@@ -1570,6 +1655,74 @@ export class CloudflareScopeHost {
|
|
|
1570
1655
|
await this.cp.writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt ?? null);
|
|
1571
1656
|
}
|
|
1572
1657
|
};
|
|
1658
|
+
/**
|
|
1659
|
+
* #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`.
|
|
1660
|
+
*
|
|
1661
|
+
* Where the write lands is the whole point of the delegation branch. The shared control
|
|
1662
|
+
* plane's own `SCOPE` namespace is the module-less placeholder, so a hosted scope's
|
|
1663
|
+
* switch has to be moved in the deployment serving it; writing it here would report a
|
|
1664
|
+
* switch pulled while every schedule kept firing. The audit row is written HERE either
|
|
1665
|
+
* way — the deployment's host is CP-less, and its `recordAdmin` is a no-op.
|
|
1666
|
+
*/
|
|
1667
|
+
const switchSystem = async (actor, raw, to) => {
|
|
1668
|
+
const input = systemSwitch.parse(raw);
|
|
1669
|
+
const { tenantId, scopeId } = input.node;
|
|
1670
|
+
let vertical = null;
|
|
1671
|
+
if (!this.cpLess) {
|
|
1672
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
1673
|
+
if (!rec)
|
|
1674
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1675
|
+
vertical = rec.vertical;
|
|
1676
|
+
}
|
|
1677
|
+
// AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
|
|
1678
|
+
// outcome row after — every attempt, a repeat included. The scope's store and the
|
|
1679
|
+
// admin log are separate, so no order makes the pair atomic; this one fails toward
|
|
1680
|
+
// "an intent with no recorded outcome" and never toward "a switch that moved with no
|
|
1681
|
+
// audit row". A retry after a crash re-audits even though it answers `changed: false`.
|
|
1682
|
+
const operationId = ulid();
|
|
1683
|
+
const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
|
|
1684
|
+
const target = { tenantId, scopeId, vertical };
|
|
1685
|
+
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
1686
|
+
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
1687
|
+
// A scope bound to no vertical (#1666 review) has no deployment to delegate to: its
|
|
1688
|
+
// store is the DO here, so the switch moves (or answers `held: false`) here too.
|
|
1689
|
+
// Delegating it would throw "no deployment serving scope" instead. A scope WITH a
|
|
1690
|
+
// vertical still delegates, and still fails loudly when none serves it.
|
|
1691
|
+
const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
|
|
1692
|
+
let outcome;
|
|
1693
|
+
try {
|
|
1694
|
+
outcome = delegation
|
|
1695
|
+
? await delegation.switch({ tenantId, scopeId, moduleId: input.moduleId, to })
|
|
1696
|
+
: await this.scopeStub(scopeId).switchSystemSchedules(input.moduleId, scopeId, to, new Date().toISOString());
|
|
1697
|
+
}
|
|
1698
|
+
catch (err) {
|
|
1699
|
+
// Best effort: the original error is what the caller must see, and the intent row
|
|
1700
|
+
// already says an attempt was made.
|
|
1701
|
+
await this.recordAdmin(actor, action, target, null, {
|
|
1702
|
+
...base,
|
|
1703
|
+
phase: 'failed',
|
|
1704
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1705
|
+
}).catch(() => undefined);
|
|
1706
|
+
throw err;
|
|
1707
|
+
}
|
|
1708
|
+
await this.recordAdmin(actor, action, target, null, {
|
|
1709
|
+
...base,
|
|
1710
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
1711
|
+
changed: outcome.changed,
|
|
1712
|
+
permissions: outcome.permissions,
|
|
1713
|
+
});
|
|
1714
|
+
if (!outcome.held) {
|
|
1715
|
+
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
1716
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
|
|
1717
|
+
}
|
|
1718
|
+
return {
|
|
1719
|
+
operationId,
|
|
1720
|
+
moduleId: input.moduleId,
|
|
1721
|
+
schedules: to,
|
|
1722
|
+
changed: outcome.changed,
|
|
1723
|
+
permissions: outcome.permissions,
|
|
1724
|
+
};
|
|
1725
|
+
};
|
|
1573
1726
|
return {
|
|
1574
1727
|
// #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
|
|
1575
1728
|
// store a credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -1708,11 +1861,59 @@ export class CloudflareScopeHost {
|
|
|
1708
1861
|
// The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
|
|
1709
1862
|
// module, one permission; tombstones on revoke; shows in the permission diff.
|
|
1710
1863
|
const grant = systemGrant.parse(raw);
|
|
1711
|
-
|
|
1864
|
+
if (grant.node.scopeId) {
|
|
1865
|
+
// #1666: refused while the module is switched off on this scope — checked and
|
|
1866
|
+
// written in one DO unit. Restore is the lever; a grant is not.
|
|
1867
|
+
const written = await this.scopeStub(grant.node.scopeId).writeSystemGrant(grant.moduleId, `granted:${grant.permission}`, `scope:${grant.node.scopeId}`, grant.expiresAt ?? null);
|
|
1868
|
+
if (!written) {
|
|
1869
|
+
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
else {
|
|
1873
|
+
await writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
1874
|
+
}
|
|
1712
1875
|
await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
1713
1876
|
if (!grant.node.scopeId)
|
|
1714
1877
|
await this.fanOut(grant.node.tenantId);
|
|
1715
1878
|
},
|
|
1879
|
+
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
1880
|
+
// whole rule, shared with the pure adapter; this is the directory check, the reach
|
|
1881
|
+
// into the scope's storage, and the audit row around it.
|
|
1882
|
+
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
1883
|
+
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
1884
|
+
// #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
|
|
1885
|
+
// the failure each leaves is the safe one: a mint whose audit row did not land never
|
|
1886
|
+
// returned its secret, so nobody can ever exchange it; a revoke whose row did not land
|
|
1887
|
+
// has still revoked. Neither the secret nor its hash is ever in before/after.
|
|
1888
|
+
mintCapability: async (actor, tenantId, scopeId, input) => {
|
|
1889
|
+
// Checked HERE as well as in the DO: a typed refusal thrown across the RPC arrives as
|
|
1890
|
+
// a bare message, and the caller would lose `validation_failed`. One function, so the
|
|
1891
|
+
// two sides cannot disagree about what a valid mint is.
|
|
1892
|
+
checkBecomeInput(input, new Date().toISOString());
|
|
1893
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
1894
|
+
const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'mintCapability');
|
|
1895
|
+
const minted = await stub.mintBecomeCapability(input, actor);
|
|
1896
|
+
await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical }, null, {
|
|
1897
|
+
capabilityId: minted.id,
|
|
1898
|
+
mode: 'become',
|
|
1899
|
+
principal: input.principal,
|
|
1900
|
+
expiresAt: input.expiresAt,
|
|
1901
|
+
maxUses: input.maxUses,
|
|
1902
|
+
label: input.label ?? null,
|
|
1903
|
+
});
|
|
1904
|
+
return minted;
|
|
1905
|
+
},
|
|
1906
|
+
revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
|
|
1907
|
+
const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'revokeCapability');
|
|
1908
|
+
const before = await stub.revokeCapabilityAsPlatform(capabilityId, actor);
|
|
1909
|
+
if (!before) {
|
|
1910
|
+
throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
|
|
1911
|
+
}
|
|
1912
|
+
await this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId, vertical }, before, {
|
|
1913
|
+
capabilityId,
|
|
1914
|
+
revoked: true,
|
|
1915
|
+
});
|
|
1916
|
+
},
|
|
1716
1917
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
1717
1918
|
// The org must exist in the node's tenant. A grant to a phantom org looks
|
|
1718
1919
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
@@ -2223,6 +2424,13 @@ export class CloudflareScopeHost {
|
|
|
2223
2424
|
await this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, bookmarks.length);
|
|
2224
2425
|
return bookmarks;
|
|
2225
2426
|
},
|
|
2427
|
+
scopeDatabaseSize: async (actor, tenantId, scopeId) => {
|
|
2428
|
+
// Reaped is refused, not read: addressing the deleted DO would recreate it.
|
|
2429
|
+
await this.scopeRecordForRead(tenantId, scopeId);
|
|
2430
|
+
const bytes = await this.scopeStub(scopeId).databaseSize();
|
|
2431
|
+
await this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
2432
|
+
return bytes;
|
|
2433
|
+
},
|
|
2226
2434
|
rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
|
|
2227
2435
|
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
2228
2436
|
if (!scope)
|
|
@@ -2396,7 +2604,7 @@ export class CloudflareScopeHost {
|
|
|
2396
2604
|
vertical: record.vertical,
|
|
2397
2605
|
limit: bounded,
|
|
2398
2606
|
})
|
|
2399
|
-
: await this.scopeStub(scopeId).
|
|
2607
|
+
: undrainedEventsOf(await this.scopeStub(scopeId).undrainedEventsRead(bounded));
|
|
2400
2608
|
await this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, events.length);
|
|
2401
2609
|
return events;
|
|
2402
2610
|
},
|
|
@@ -2694,12 +2902,29 @@ export class CloudflareScopeHost {
|
|
|
2694
2902
|
// half-done state harms the person: dying after the redaction leaves ciphertext in
|
|
2695
2903
|
// a backup that no key opens; destroying the key first would leave their PII in the
|
|
2696
2904
|
// live database while the audit log already claims they were erased.
|
|
2697
|
-
|
|
2905
|
+
// Both spine copies (#1600): the outbox row AND any platform intent this event was
|
|
2906
|
+
// routed into. One RPC, so a crash cannot land half of it.
|
|
2907
|
+
const redacted = await this.scopeStub(scopeId).redactSubject(subjectId);
|
|
2908
|
+
// An OLD ScopeDO answers with a bare number — it redacted the outbox and never
|
|
2909
|
+
// looked at the intent journal. Refused here, BEFORE the key is destroyed, and
|
|
2910
|
+
// that order is the whole point: the key is the irreversible half, so proceeding
|
|
2911
|
+
// would leave the subject's platform-retained copies permanently unreadable, their
|
|
2912
|
+
// name still sitting in `_substrat_platform_requests`, and no admin-log row at all
|
|
2913
|
+
// (the log is written after this). Refusing leaves an erasure that can simply be
|
|
2914
|
+
// re-run once the scope is redeployed. Loud rather than partial, the way this
|
|
2915
|
+
// interface's reverse skew is left loud on `recordScheduleRun`.
|
|
2916
|
+
if (typeof redacted === 'number') {
|
|
2917
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #1600, whose redaction does not reach ` +
|
|
2918
|
+
`_substrat_platform_requests — erasing now would destroy the subject key while ` +
|
|
2919
|
+
`leaving their payloads in the intent journal. Redeploy the vertical and re-run.`);
|
|
2920
|
+
}
|
|
2921
|
+
const { events: eventsRedacted, intents: intentsRedacted } = redacted;
|
|
2698
2922
|
const at = new Date().toISOString();
|
|
2699
2923
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
2700
2924
|
const receipt = subjectShredReceipt.parse({
|
|
2701
2925
|
subjectId,
|
|
2702
2926
|
eventsRedacted,
|
|
2927
|
+
intentsRedacted,
|
|
2703
2928
|
keyDestroyed: existed,
|
|
2704
2929
|
tombstoned: true,
|
|
2705
2930
|
});
|
|
@@ -2707,7 +2932,9 @@ export class CloudflareScopeHost {
|
|
|
2707
2932
|
// because it destroys evidence. An erasure is the one action where "who asked for
|
|
2708
2933
|
// this to disappear" is itself part of the record.
|
|
2709
2934
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
2710
|
-
|
|
2935
|
+
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
2936
|
+
// an intent payload is a whole event's worth of it.
|
|
2937
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted);
|
|
2711
2938
|
return receipt;
|
|
2712
2939
|
},
|
|
2713
2940
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -3689,15 +3916,22 @@ export class CloudflareScopeHost {
|
|
|
3689
3916
|
// used to leave the scope "roles projected, source=local, zero tuples" — enforcing nothing
|
|
3690
3917
|
// but denials, with no builder-facing lever to fix it. Atomic now: grant and flip land
|
|
3691
3918
|
// together, and the empty-tuple guard in `applyProjection` refuses the flip if they don't.
|
|
3919
|
+
//
|
|
3920
|
+
// #1659: every tuple here is SEATED — created if missing, left alone if revoked — so a
|
|
3921
|
+
// reconcile no longer undoes an operator's revoke. The owner's seat alone carries
|
|
3922
|
+
// `lockout_reseat`: it comes back over a revoke only when the scope would otherwise hold
|
|
3923
|
+
// no effective role grant — none whose role the vertical still defines — which is the
|
|
3924
|
+
// #332 lockout a reconcile exists to repair.
|
|
3692
3925
|
[
|
|
3693
3926
|
{
|
|
3694
3927
|
subject: `principal:${input.owner}`,
|
|
3695
3928
|
relation: `role:${input.ownerRoleKey}`,
|
|
3696
3929
|
object: `scope:${input.scopeId}`,
|
|
3697
3930
|
expires_at: null,
|
|
3931
|
+
lockout_reseat: true,
|
|
3698
3932
|
},
|
|
3699
3933
|
// #461: each registered module's SCHEDULE grants (#383) ride the same unit —
|
|
3700
|
-
// the CP-less mirror of `provisionScope`'s
|
|
3934
|
+
// the CP-less mirror of `provisionScope`'s seatTuple loop. Without them the
|
|
3701
3935
|
// grant-is-the-switch check makes every schedule a silent no-op (`fired: 0`,
|
|
3702
3936
|
// no error — the #49 unfalsifiable zero).
|
|
3703
3937
|
...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
|
|
@@ -3784,16 +4018,23 @@ export class CloudflareScopeHost {
|
|
|
3784
4018
|
* seat is the one that knows what to announce, and it emits from its own operation.
|
|
3785
4019
|
* Guarded like `assignScopeRole`: at the harness route, not at this seam.
|
|
3786
4020
|
*
|
|
3787
|
-
*
|
|
3788
|
-
*
|
|
3789
|
-
* -
|
|
3790
|
-
*
|
|
3791
|
-
*
|
|
3792
|
-
*
|
|
3793
|
-
*
|
|
3794
|
-
*
|
|
3795
|
-
*
|
|
3796
|
-
*
|
|
4021
|
+
* What survives a reconcile (#1659), because the tombstone is only as durable as the next
|
|
4022
|
+
* write that is allowed to replace it:
|
|
4023
|
+
* - Provisioning does not clear it. `provisionScopeLocal` SEATS its tuples (the owner's
|
|
4024
|
+
* role, the `system:` grants, the connection grants): it recreates a missing one and
|
|
4025
|
+
* leaves a revoked one revoked, so a reconcile — every listed promote runs one — keeps
|
|
4026
|
+
* your revoke.
|
|
4027
|
+
* - With ONE exception: the owner-of-record's seat is re-seated over a revoke when the
|
|
4028
|
+
* scope would otherwise hold no effective role grant at all — a holder of a role the
|
|
4029
|
+
* vertical no longer defines passes no check, so it does not count. A scope nobody can
|
|
4030
|
+
* act in is the #332 lockout the reconcile exists to repair, so revoking the LAST holder
|
|
4031
|
+
* is undone at the next reconcile. Seat the successor (in a role the vertical defines)
|
|
4032
|
+
* before unseating the owner, and the revoke stands. The owner re-seated is the one `owner_of_record` names, which is first-write-
|
|
4033
|
+
* wins — if a successor is later revoked too, the ORIGINAL owner comes back. To lock a
|
|
4034
|
+
* compromised owner out, suspend the scope; a seat revoke is not that lever.
|
|
4035
|
+
* - An explicit grant does clear it: `assignScopeRole` is `INSERT OR REPLACE`, and so is
|
|
4036
|
+
* a vertical's `onProvision` hook that re-issues it — which re-seats whatever it names
|
|
4037
|
+
* on every reconcile. Revoke the seats your own flow granted and does not re-grant.
|
|
3797
4038
|
* On a CP-less host this records no admin-log row (there is no control plane to hold
|
|
3798
4039
|
* one), so the row's `revoked_at` is the only evidence, and a re-assign replaces it.
|
|
3799
4040
|
*/
|
|
@@ -3857,6 +4098,19 @@ export class CloudflareScopeHost {
|
|
|
3857
4098
|
async connectorGrantLocal(connectionId, scopeId, permission, expiresAt) {
|
|
3858
4099
|
await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
|
|
3859
4100
|
}
|
|
4101
|
+
/**
|
|
4102
|
+
* The far end of the schedule kill switch for a scope served HERE (#1666):
|
|
4103
|
+
* `/internal/system-switch` lands on this, from the shared control plane's
|
|
4104
|
+
* `revokeFromSystem` / `restoreToSystem`. It moves the switch in the scope's own DO and
|
|
4105
|
+
* answers what it did; it audits nothing, because the control plane that asked holds the
|
|
4106
|
+
* admin log and writes the row once this returns. `held: false` is an answer, not a
|
|
4107
|
+
* throw — see `systemSwitchOutcome`.
|
|
4108
|
+
*/
|
|
4109
|
+
async systemSwitchLocal(scopeId, moduleId, to) {
|
|
4110
|
+
// Parsed on the way out: this is the wire answer the platform reads, and the DO's
|
|
4111
|
+
// plain strings become the published shape here rather than on trust.
|
|
4112
|
+
return systemSwitchOutcome.parse(await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, new Date().toISOString()));
|
|
4113
|
+
}
|
|
3860
4114
|
}
|
|
3861
4115
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
3862
4116
|
function modelUsageEntryOf(r) {
|