@substrat-run/adapter-cloudflare 0.139.0 → 0.140.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/README.md +2 -2
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +4 -0
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +57 -6
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +213 -62
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +30 -7
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +247 -40
- package/dist/host.js.map +1 -1
- package/dist/live-reads.d.ts +30 -7
- package/dist/live-reads.d.ts.map +1 -1
- package/dist/live-reads.js +43 -9
- package/dist/live-reads.js.map +1 -1
- package/dist/route-resolver.d.ts +1 -1
- package/dist/route-resolver.d.ts.map +1 -1
- package/dist/route-resolver.js +7 -0
- package/dist/route-resolver.js.map +1 -1
- package/dist/scope-do.d.ts +29 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +714 -217
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +2 -0
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +23 -1
- package/dist/sql.js.map +1 -1
- package/package.json +9 -9
package/dist/host.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { env as ambientEnv } from 'cloudflare:workers';
|
|
2
2
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
3
3
|
import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
|
|
4
|
-
import { delegatedReadParams, operationSeriesCount, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
4
|
+
import { delegatedReadParams, operationSeriesCount, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
|
|
5
5
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
6
|
-
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, memberAddedAudit, } from '@substrat-run/kernel';
|
|
7
|
-
import { attributedView } from '@substrat-run/kernel';
|
|
8
|
-
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
|
|
6
|
+
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
|
|
7
|
+
import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
|
|
8
|
+
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
|
|
9
9
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
10
10
|
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
11
11
|
import { unwrapReply } from './do-reply.js';
|
|
@@ -777,6 +777,12 @@ export class CloudflareScopeHost {
|
|
|
777
777
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
778
778
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
779
779
|
await this.migrateAndRecord(scopeId);
|
|
780
|
+
// #2102: an outcome event travels by the verb that writes it; every other settle keeps the
|
|
781
|
+
// positional one, so it still reaches a DO class that predates the event.
|
|
782
|
+
if (outcome.event) {
|
|
783
|
+
await this.scopeStub(scopeId).settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
780
786
|
await this.scopeStub(scopeId).settlePlatformRequest(id, outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure));
|
|
781
787
|
}
|
|
782
788
|
migrationFrontier() {
|
|
@@ -1219,6 +1225,9 @@ export class CloudflareScopeHost {
|
|
|
1219
1225
|
if (this.moduleIds.has(manifest.id)) {
|
|
1220
1226
|
throw new Error(`module already registered: ${manifest.id}`);
|
|
1221
1227
|
}
|
|
1228
|
+
// #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
|
|
1229
|
+
// misdeclared erasure fails where it is registered on both hosts.
|
|
1230
|
+
moduleErasurePlan(registration);
|
|
1222
1231
|
const migrations = registration.migrations ?? [];
|
|
1223
1232
|
const seen = new Set();
|
|
1224
1233
|
for (const m of migrations) {
|
|
@@ -1241,6 +1250,9 @@ export class CloudflareScopeHost {
|
|
|
1241
1250
|
}
|
|
1242
1251
|
this.predicateNames.set(name, manifest.id);
|
|
1243
1252
|
}
|
|
1253
|
+
// #119: the trash rules, refused here too so a bad module fails at construction, not at its
|
|
1254
|
+
// scope's first wake. The scope holds the targets; the coordinator only needs the verdict.
|
|
1255
|
+
registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
|
|
1244
1256
|
// #1705: after the checks above, so a refused module leaves nothing registered here.
|
|
1245
1257
|
this.crossVertical.register(manifest, registration.imports);
|
|
1246
1258
|
this.moduleIds.add(manifest.id);
|
|
@@ -2450,22 +2462,41 @@ export class CloudflareScopeHost {
|
|
|
2450
2462
|
const startedAt = Date.now();
|
|
2451
2463
|
let emitted;
|
|
2452
2464
|
let failure;
|
|
2465
|
+
// #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
|
|
2466
|
+
let stillDue = false;
|
|
2453
2467
|
try {
|
|
2454
2468
|
// The gate above already answered for this pass; a fire that meets a restarted scope
|
|
2455
2469
|
// is gated again by the door (#1834).
|
|
2456
2470
|
door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
|
|
2457
2471
|
const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
|
|
2458
|
-
|
|
2472
|
+
if (schedule.purge) {
|
|
2473
|
+
// #119: a purge horizon's schedule runs its operation once per due entity, each its own
|
|
2474
|
+
// call and transaction — and the SCOPE decides which are due and runs them, so purge
|
|
2475
|
+
// authority is never an argument any stub can pass (`runPurgeSweep`).
|
|
2476
|
+
const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
|
|
2477
|
+
// The purges' events, delivered the way each invoke's own tail delivers them.
|
|
2478
|
+
await this.drainExecutors(tenantId, scopeId, null);
|
|
2479
|
+
stillDue = purgeStillDue(pass);
|
|
2480
|
+
const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
|
|
2481
|
+
report.errors.push(...outcome.errors);
|
|
2482
|
+
if (outcome.failure)
|
|
2483
|
+
throw outcome.failure;
|
|
2484
|
+
}
|
|
2485
|
+
else {
|
|
2486
|
+
await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
|
|
2487
|
+
}
|
|
2459
2488
|
report.fired += 1;
|
|
2460
2489
|
}
|
|
2461
2490
|
catch (err) {
|
|
2462
2491
|
status = 'failed';
|
|
2463
2492
|
failure = { error: err };
|
|
2464
2493
|
report.failed += 1;
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2494
|
+
if (!schedule.purge) {
|
|
2495
|
+
report.errors.push({
|
|
2496
|
+
operation: schedule.operation,
|
|
2497
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2469
2500
|
}
|
|
2470
2501
|
lines.write({
|
|
2471
2502
|
kind: 'schedule',
|
|
@@ -2480,10 +2511,12 @@ export class CloudflareScopeHost {
|
|
|
2480
2511
|
latenessMs: lastRun === null ? null : now - dueAt,
|
|
2481
2512
|
...(emitted ? { emitted } : {}),
|
|
2482
2513
|
});
|
|
2514
|
+
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2515
|
+
if (stillDue)
|
|
2516
|
+
continue;
|
|
2483
2517
|
// #1288: 'schedule', whatever this operation happens to be called — including
|
|
2484
2518
|
// `freshness:<something>`, which is exactly the row the evaluator no longer eats.
|
|
2485
2519
|
await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
|
|
2486
|
-
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2487
2520
|
}
|
|
2488
2521
|
lines.end();
|
|
2489
2522
|
return report;
|
|
@@ -2548,6 +2581,9 @@ export class CloudflareScopeHost {
|
|
|
2548
2581
|
? 'vertical'
|
|
2549
2582
|
: undefined,
|
|
2550
2583
|
invoke: async (operation, input, invokeOptions) => {
|
|
2584
|
+
// #119: purge authority is never an invoke option — only the scope's own `runPurgeSweep`
|
|
2585
|
+
// purges — and an options object naming a cutoff is refused outright.
|
|
2586
|
+
assertNoCallerPurge(invokeOptions);
|
|
2551
2587
|
// Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
|
|
2552
2588
|
// SKU flag. The COORDINATOR gates the console-managed path against the shared CP
|
|
2553
2589
|
// (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
|
|
@@ -2950,11 +2986,21 @@ export class CloudflareScopeHost {
|
|
|
2950
2986
|
// admin log are separate, so no order makes the pair atomic; this one fails toward
|
|
2951
2987
|
// "an intent with no recorded outcome" and never toward "a switch that moved with no
|
|
2952
2988
|
// audit row". A retry after a crash re-audits even though it answers `changed: false`.
|
|
2989
|
+
//
|
|
2990
|
+
// #2089: every outcome row goes through the kernel's `recordAuditOutcome`, as the control
|
|
2991
|
+
// plane's audited changes do. One that cannot be written is logged with the operation id,
|
|
2992
|
+
// never swallowed; the call still answers with its own error (or, applied, with success and
|
|
2993
|
+
// `auditWarning`), and the scheduled settle closes the intent as `unknown`.
|
|
2953
2994
|
const operationId = ulid();
|
|
2954
2995
|
const target = { tenantId, scopeId, vertical };
|
|
2955
2996
|
const base = { operationId, ...switchAuditSubject(kind, key, to) };
|
|
2956
2997
|
await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
|
|
2957
2998
|
const errorOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
2999
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
|
|
3000
|
+
flow: `${kind}-switch`,
|
|
3001
|
+
operationId,
|
|
3002
|
+
phase: row.phase,
|
|
3003
|
+
}, (message, fields) => console.error(message, fields));
|
|
2958
3004
|
// #2045 (Codex r3): the deployment's fence is attested BEFORE anything is recorded or moved. An
|
|
2959
3005
|
// ON on a deployment built before it is refused here, with nothing written anywhere but the
|
|
2960
3006
|
// audit. An OFF goes on: the kill switch must work on every deployment (see `UnattestedSwitch`).
|
|
@@ -2964,7 +3010,7 @@ export class CloudflareScopeHost {
|
|
|
2964
3010
|
}
|
|
2965
3011
|
}
|
|
2966
3012
|
catch (err) {
|
|
2967
|
-
await
|
|
3013
|
+
await recordOutcome({ phase: 'refused', error: errorOf(err) });
|
|
2968
3014
|
throw err;
|
|
2969
3015
|
}
|
|
2970
3016
|
// The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
|
|
@@ -2982,13 +3028,13 @@ export class CloudflareScopeHost {
|
|
|
2982
3028
|
// Nothing has moved: fail the call here, audited. An ON must not switch a scope on
|
|
2983
3029
|
// whose record still says off (the next reconcile would switch it back off), and an
|
|
2984
3030
|
// OFF must not leave a scope off that the record does not know of (#1823).
|
|
2985
|
-
await
|
|
3031
|
+
await recordOutcome({ phase: 'failed', error: errorOf(err) });
|
|
2986
3032
|
throw err;
|
|
2987
3033
|
}
|
|
2988
3034
|
// #2045: a newer call on this subject has recorded its position already: this one writes
|
|
2989
3035
|
// nothing, here or in the scope, and says so.
|
|
2990
3036
|
if (recordWriteSuperseded(prior, record)) {
|
|
2991
|
-
await
|
|
3037
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
2992
3038
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
2993
3039
|
}
|
|
2994
3040
|
let outcome;
|
|
@@ -3010,13 +3056,12 @@ export class CloudflareScopeHost {
|
|
|
3010
3056
|
// readback can settle the scope and nothing is put back: the record is this call's position
|
|
3011
3057
|
// under the newest fence, and its write-ahead mark makes the next re-assert move the scope
|
|
3012
3058
|
// to it, under that fence, whichever way it lies now.
|
|
3013
|
-
await
|
|
3014
|
-
...base,
|
|
3059
|
+
await recordOutcome({
|
|
3015
3060
|
phase: err instanceof UnattestedSwitch ? 'refused' : 'failed',
|
|
3016
3061
|
error: errorOf(err),
|
|
3017
3062
|
recordKept: true,
|
|
3018
3063
|
reassertOwed: true,
|
|
3019
|
-
})
|
|
3064
|
+
});
|
|
3020
3065
|
if (err instanceof UnattestedSwitch)
|
|
3021
3066
|
throw substratError('precondition_failed', err.message);
|
|
3022
3067
|
throw err;
|
|
@@ -3025,7 +3070,7 @@ export class CloudflareScopeHost {
|
|
|
3025
3070
|
// record is that newer call's too (its write overwrote this one's, mark included), so nothing
|
|
3026
3071
|
// is undone and no mark is this call's to clear.
|
|
3027
3072
|
if (outcome.superseded) {
|
|
3028
|
-
await
|
|
3073
|
+
await recordOutcome({ phase: 'refused', superseded: true });
|
|
3029
3074
|
throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
|
|
3030
3075
|
}
|
|
3031
3076
|
// A call that held nothing moved nothing, so its record write is undone: left `off`, it
|
|
@@ -3052,8 +3097,7 @@ export class CloudflareScopeHost {
|
|
|
3052
3097
|
else if (!unfenced) {
|
|
3053
3098
|
owedError = await this.cp.clearSwitchOwed(kind, tenantId, scopeId, key, operationId).then(() => null, errorOf);
|
|
3054
3099
|
}
|
|
3055
|
-
|
|
3056
|
-
...base,
|
|
3100
|
+
const unrecorded = await recordOutcome({
|
|
3057
3101
|
phase: outcome.held ? 'applied' : 'refused',
|
|
3058
3102
|
changed: outcome.changed,
|
|
3059
3103
|
permissions: outcome.permissions,
|
|
@@ -3065,30 +3109,32 @@ export class CloudflareScopeHost {
|
|
|
3065
3109
|
throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
|
|
3066
3110
|
(recordError ? `; and its directory record could not be put back (${recordError})` : ''));
|
|
3067
3111
|
}
|
|
3068
|
-
return { operationId, outcome };
|
|
3112
|
+
return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
|
|
3069
3113
|
};
|
|
3070
3114
|
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
3071
3115
|
const switchSystem = async (actor, raw, to) => {
|
|
3072
3116
|
const input = systemSwitch.parse(raw);
|
|
3073
|
-
const { operationId, outcome } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
3117
|
+
const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
|
|
3074
3118
|
return {
|
|
3075
3119
|
operationId,
|
|
3076
3120
|
moduleId: input.moduleId,
|
|
3077
3121
|
schedules: to,
|
|
3078
3122
|
changed: outcome.changed,
|
|
3079
3123
|
permissions: outcome.permissions,
|
|
3124
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
3080
3125
|
};
|
|
3081
3126
|
};
|
|
3082
3127
|
/** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
|
|
3083
3128
|
const switchPeerAt = async (actor, raw, to) => {
|
|
3084
3129
|
const input = peerSwitch.parse(raw);
|
|
3085
|
-
const { operationId, outcome } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
3130
|
+
const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
|
|
3086
3131
|
return {
|
|
3087
3132
|
operationId,
|
|
3088
3133
|
vertical: input.vertical,
|
|
3089
3134
|
calls: to,
|
|
3090
3135
|
changed: outcome.changed,
|
|
3091
3136
|
permissions: outcome.permissions,
|
|
3137
|
+
...(auditWarning ? { auditWarning } : {}),
|
|
3092
3138
|
};
|
|
3093
3139
|
};
|
|
3094
3140
|
/**
|
|
@@ -3355,6 +3401,8 @@ export class CloudflareScopeHost {
|
|
|
3355
3401
|
return result;
|
|
3356
3402
|
};
|
|
3357
3403
|
return {
|
|
3404
|
+
// #2069: a view of the view this admin was built over — the person added, its event kept.
|
|
3405
|
+
attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
|
|
3358
3406
|
// #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
|
|
3359
3407
|
// store a credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
3360
3408
|
canStoreSecrets: isSecretBoxConfigured(this.secretBox),
|
|
@@ -3427,6 +3475,17 @@ export class CloudflareScopeHost {
|
|
|
3427
3475
|
if (!grant.node.scopeId)
|
|
3428
3476
|
await this.fanOut(grant.node.tenantId);
|
|
3429
3477
|
},
|
|
3478
|
+
grantEntityShape: async (actor, grant) => {
|
|
3479
|
+
await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
|
|
3480
|
+
await this.recordAdmin(actor, 'grantEntityShape', grant.node, null, grant);
|
|
3481
|
+
},
|
|
3482
|
+
reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
|
|
3483
|
+
const { toppedUp, retired } = await this.topUpEntityGrantShapesLocal(node.tenantId, node.scopeId, shapes, opts?.batch);
|
|
3484
|
+
if (toppedUp > 0 || retired > 0) {
|
|
3485
|
+
await this.recordAdmin(actor, 'reconcileEntityGrantShapes', node, null, { shapes, toppedUp, retired });
|
|
3486
|
+
}
|
|
3487
|
+
return { toppedUp, retired };
|
|
3488
|
+
},
|
|
3430
3489
|
grantToConnection: async (actor, raw) => {
|
|
3431
3490
|
const grant = connectionGrant.parse(raw);
|
|
3432
3491
|
const conn = await this.cp.readConnection(grant.connectionId);
|
|
@@ -3683,6 +3742,23 @@ export class CloudflareScopeHost {
|
|
|
3683
3742
|
// #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
|
|
3684
3743
|
// the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
|
|
3685
3744
|
resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
|
|
3745
|
+
resolvePeerInstance: async (tenantId, callerScopeId, vertical) => peerInstanceResolution.parse(await this.cp.resolvePeerInstance(tenantId, callerScopeId, verticalSlug.parse(vertical))),
|
|
3746
|
+
peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
|
|
3747
|
+
const row = await this.cp.peerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical));
|
|
3748
|
+
await this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
|
|
3749
|
+
return row ? peerBindingSchema.parse({ tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 }) : undefined;
|
|
3750
|
+
},
|
|
3751
|
+
peerBindings: async (actor, tenantId, callerScopeId) => {
|
|
3752
|
+
const rows = await this.cp.peerBindings(tenantId, callerScopeId);
|
|
3753
|
+
await this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
|
|
3754
|
+
return rows.map((r) => peerBindingSchema.parse({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
|
|
3755
|
+
},
|
|
3756
|
+
setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => {
|
|
3757
|
+
const answer = await this.cp.setPeerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical), targetScopeId);
|
|
3758
|
+
if (!answer.ok)
|
|
3759
|
+
throw substratError(answer.code, answer.message);
|
|
3760
|
+
return { changed: answer.changed, previous: answer.previous };
|
|
3761
|
+
},
|
|
3686
3762
|
resolveHostname: async (raw) =>
|
|
3687
3763
|
// The router's per-request read. No actor, not logged — the same machine-path
|
|
3688
3764
|
// carve-out resolveIdentity has (K-24). Shares its mapping with the router's
|
|
@@ -3708,6 +3784,9 @@ export class CloudflareScopeHost {
|
|
|
3708
3784
|
// Declared email-sender intent (#303) — the request half; the grant is its own column.
|
|
3709
3785
|
if (parsed.sendsEmail)
|
|
3710
3786
|
installSpec.sendsEmail = parsed.sendsEmail;
|
|
3787
|
+
// Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
|
|
3788
|
+
if (parsed.lifecycle)
|
|
3789
|
+
installSpec.lifecycle = parsed.lifecycle;
|
|
3711
3790
|
const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
|
|
3712
3791
|
const existing = await this.cp.readVertical(parsed.slug);
|
|
3713
3792
|
if (existing) {
|
|
@@ -4710,6 +4789,12 @@ export class CloudflareScopeHost {
|
|
|
4710
4789
|
// Both spine copies (#1600): the outbox row AND any platform intent this event was
|
|
4711
4790
|
// routed into. One RPC, so a crash cannot land half of it.
|
|
4712
4791
|
const redacted = await this.scopeStub(scopeId).redactSubject(subjectId);
|
|
4792
|
+
// A refusal answered as data (#2068) — a module's `onSubjectErased` hook threw or reached
|
|
4793
|
+
// past its own tables, and the DO rolled the whole redaction back. Rethrown with its code,
|
|
4794
|
+
// before the key.
|
|
4795
|
+
if (typeof redacted === 'object' && 'failure' in redacted && redacted.failure) {
|
|
4796
|
+
throw fromWireFailure(redacted.failure);
|
|
4797
|
+
}
|
|
4713
4798
|
// An OLD ScopeDO answers with a bare number — it redacted the outbox and never
|
|
4714
4799
|
// looked at the intent journal. Refused here, BEFORE the key is destroyed, and
|
|
4715
4800
|
// that order is the whole point: the key is the irreversible half, so proceeding
|
|
@@ -4742,7 +4827,16 @@ export class CloudflareScopeHost {
|
|
|
4742
4827
|
`or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
|
|
4743
4828
|
`leaving their data in those rows. Redeploy the vertical and re-run.`);
|
|
4744
4829
|
}
|
|
4745
|
-
|
|
4830
|
+
// A DO from before the module half (#2068): it redacted the spine and never ran a
|
|
4831
|
+
// module's declared erasure or its `onSubjectErased` hook. Refused before the key, for
|
|
4832
|
+
// the reason above — its reply read as "no module rows" would receipt an erasure that
|
|
4833
|
+
// left the person in the vertical's own tables, with the key already gone.
|
|
4834
|
+
if (!('vertical' in redacted) || !isModuleErasureCounts(redacted.vertical)) {
|
|
4835
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #2068, whose redaction does not reach a module's ` +
|
|
4836
|
+
`own tables — erasing now would destroy the subject key while leaving their data in the ` +
|
|
4837
|
+
`vertical's rows. Redeploy the vertical and re-run.`);
|
|
4838
|
+
}
|
|
4839
|
+
const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, vertical, } = redacted;
|
|
4746
4840
|
// The directory's failure text (#1632) — a drain failure quoting one of those intents,
|
|
4747
4841
|
// an issue's exemplar, a sweep record's error. Still before the key.
|
|
4748
4842
|
await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
|
|
@@ -4753,6 +4847,7 @@ export class CloudflareScopeHost {
|
|
|
4753
4847
|
eventsRedacted,
|
|
4754
4848
|
intentsRedacted,
|
|
4755
4849
|
jobRunsRedacted,
|
|
4850
|
+
...vertical,
|
|
4756
4851
|
keyDestroyed: existed,
|
|
4757
4852
|
tombstoned: true,
|
|
4758
4853
|
});
|
|
@@ -4762,7 +4857,7 @@ export class CloudflareScopeHost {
|
|
|
4762
4857
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
4763
4858
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
4764
4859
|
// an intent payload is a whole event's worth of it.
|
|
4765
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
4860
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
|
|
4766
4861
|
return receipt;
|
|
4767
4862
|
},
|
|
4768
4863
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -5005,12 +5100,12 @@ export class CloudflareScopeHost {
|
|
|
5005
5100
|
throw new Error(`connection not found: ${id}`);
|
|
5006
5101
|
const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
|
|
5007
5102
|
const now = new Date().toISOString();
|
|
5008
|
-
await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt
|
|
5103
|
+
await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now);
|
|
5009
5104
|
await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
|
|
5010
5105
|
id,
|
|
5011
5106
|
provider: row.provider,
|
|
5012
5107
|
rotatedAt: now,
|
|
5013
|
-
expiresAt: expiresAt
|
|
5108
|
+
expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
|
|
5014
5109
|
// §3.5.1's attribution, rotate-side: the authorizing tenant principal,
|
|
5015
5110
|
// never laundered into the actor column.
|
|
5016
5111
|
...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
|
|
@@ -5026,6 +5121,55 @@ export class CloudflareScopeHost {
|
|
|
5026
5121
|
return; // idempotent, and a no-op is not audited
|
|
5027
5122
|
await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
|
|
5028
5123
|
},
|
|
5124
|
+
// -- a vertical's mailed connect links (connections.md §3.5.4) --------------
|
|
5125
|
+
// Parsed here, run in the ControlPlaneDO as the kernel's statements (shared with
|
|
5126
|
+
// the pure adapter). Each move writes its audit row in the DO's own unit, from the
|
|
5127
|
+
// row minted here (actor, attribution) — so a failed audit write rolls the move back.
|
|
5128
|
+
// The DO has no clock of its own on this path — `now` is the coordinator's, the same
|
|
5129
|
+
// wall clock every other directory write uses.
|
|
5130
|
+
mintConnectLink: async (actor, raw) => {
|
|
5131
|
+
const input = mintConnectLinkInput.parse(raw);
|
|
5132
|
+
return this.cp.insertConnectLink({
|
|
5133
|
+
id: ulid(),
|
|
5134
|
+
tenantId: input.tenantId,
|
|
5135
|
+
scopeId: input.scopeId,
|
|
5136
|
+
vertical: input.vertical,
|
|
5137
|
+
provider: input.provider,
|
|
5138
|
+
createdBy: input.createdBy,
|
|
5139
|
+
subjectRef: input.subjectRef ?? null,
|
|
5140
|
+
returnUrl: input.returnUrl ?? null,
|
|
5141
|
+
createdAt: new Date().toISOString(),
|
|
5142
|
+
expiresAt: input.expiresAt,
|
|
5143
|
+
}, this.adminEntry(actor, 'mintConnectLink', { tenantId: input.tenantId }, null, null));
|
|
5144
|
+
},
|
|
5145
|
+
getConnectLink: async (actor, raw) => {
|
|
5146
|
+
const key = connectLinkKey.parse(raw);
|
|
5147
|
+
const link = await this.cp.readConnectLink(key);
|
|
5148
|
+
await this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
|
|
5149
|
+
return link;
|
|
5150
|
+
},
|
|
5151
|
+
listConnectLinks: async (actor, raw) => {
|
|
5152
|
+
const filter = connectLinkFilter.parse(raw);
|
|
5153
|
+
const links = await this.cp.listConnectLinks(filter, new Date().toISOString());
|
|
5154
|
+
await this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
|
|
5155
|
+
return links;
|
|
5156
|
+
},
|
|
5157
|
+
revokeConnectLink: async (actor, raw) => {
|
|
5158
|
+
const key = connectLinkKey.parse(raw);
|
|
5159
|
+
// Idempotent, and a no-op is not audited (the statement hands back no row for one).
|
|
5160
|
+
const audit = this.adminEntry(actor, 'revokeConnectLink', { tenantId: key.tenantId }, null, null);
|
|
5161
|
+
return (await this.cp.revokeConnectLink(key, audit))?.link;
|
|
5162
|
+
},
|
|
5163
|
+
consumeConnectLink: async (actor, raw) => {
|
|
5164
|
+
const input = consumeConnectLinkInput.parse(raw);
|
|
5165
|
+
const audit = this.adminEntry(actor, 'consumeConnectLink', { tenantId: input.tenantId }, null, null);
|
|
5166
|
+
return this.cp.consumeConnectLink(input, new Date().toISOString(), audit);
|
|
5167
|
+
},
|
|
5168
|
+
restoreConnectLink: async (actor, raw) => {
|
|
5169
|
+
const key = connectLinkKey.parse(raw);
|
|
5170
|
+
const audit = this.adminEntry(actor, 'restoreConnectLink', { tenantId: key.tenantId }, null, null);
|
|
5171
|
+
return (await this.cp.restoreConnectLink(key, new Date().toISOString(), audit)) !== undefined;
|
|
5172
|
+
},
|
|
5029
5173
|
openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
|
|
5030
5174
|
const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
|
|
5031
5175
|
if (!row)
|
|
@@ -5127,6 +5271,18 @@ export class CloudflareScopeHost {
|
|
|
5127
5271
|
const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
|
|
5128
5272
|
await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
|
|
5129
5273
|
},
|
|
5274
|
+
recordPeerBindingChange: async (actor, entry) => {
|
|
5275
|
+
const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
|
|
5276
|
+
await this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
|
|
5277
|
+
},
|
|
5278
|
+
/** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
|
|
5279
|
+
auditedOperations: async (actor, refs) => {
|
|
5280
|
+
const rows = await this.cp.auditedOperations([...refs]);
|
|
5281
|
+
await this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
|
|
5282
|
+
return rows;
|
|
5283
|
+
},
|
|
5284
|
+
/** #2064: settle an intent with no outcome — one unit in the directory DO. */
|
|
5285
|
+
settleUnrecordedOutcome: (actor, input) => this.cp.settleUnrecordedOutcome({ actor, intentId: input.intentId, error: input.error }),
|
|
5130
5286
|
/** #2005: one change to a scope's copy marker, written around the vertical's own change. */
|
|
5131
5287
|
recordCopyMark: async (actor, entry) => {
|
|
5132
5288
|
const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
|
|
@@ -5724,7 +5880,7 @@ export class CloudflareScopeHost {
|
|
|
5724
5880
|
}));
|
|
5725
5881
|
}
|
|
5726
5882
|
async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
|
|
5727
|
-
await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
|
|
5883
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt));
|
|
5728
5884
|
}
|
|
5729
5885
|
/**
|
|
5730
5886
|
* The connection's CURRENT public sealing key, minted on first ask (#687).
|
|
@@ -6115,11 +6271,17 @@ export class CloudflareScopeHost {
|
|
|
6115
6271
|
* arrive as a different class of answer just because the caller asked for a socket.
|
|
6116
6272
|
*/
|
|
6117
6273
|
liveReads = {
|
|
6118
|
-
subscribe: async ({ tenantId, scopeId, principal, request, within }) => {
|
|
6274
|
+
subscribe: async ({ tenantId, scopeId, principal, request, within, expiresAt }) => {
|
|
6119
6275
|
// Decided before anything else, and thrown: a `within` that is neither a plain
|
|
6120
6276
|
// EntityRef nor a value `vouchedWithin` built is a caller bug, and the two ways of
|
|
6121
6277
|
// guessing at it both open a feed wider than was asked for (#1853).
|
|
6122
6278
|
const narrowed = liveWithinOf(within);
|
|
6279
|
+
// The same for an expiry that is not an instant: read as "none", it would keep the
|
|
6280
|
+
// socket open past the session it was meant to end with (#938).
|
|
6281
|
+
const expires = expiresAt === undefined ? undefined : liveInstant(expiresAt);
|
|
6282
|
+
if (expiresAt !== undefined && !expires) {
|
|
6283
|
+
throw substratError('validation_failed', 'live reads: `expiresAt` must be an ISO 8601 instant');
|
|
6284
|
+
}
|
|
6123
6285
|
if (!isUpgradeRequest(request)) {
|
|
6124
6286
|
return new Response('live reads are a WebSocket surface', {
|
|
6125
6287
|
status: 426,
|
|
@@ -6181,6 +6343,10 @@ export class CloudflareScopeHost {
|
|
|
6181
6343
|
headers.set(LIVE_WITHIN_HEADER, encodeLiveWithin(narrowed));
|
|
6182
6344
|
else
|
|
6183
6345
|
headers.delete(LIVE_WITHIN_HEADER);
|
|
6346
|
+
if (expires)
|
|
6347
|
+
headers.set(LIVE_EXPIRES_HEADER, expires);
|
|
6348
|
+
else
|
|
6349
|
+
headers.delete(LIVE_EXPIRES_HEADER);
|
|
6184
6350
|
const forwarded = new Request(new URL(LIVE_SUBSCRIBE_PATH, 'https://scope.substrat.internal'),
|
|
6185
6351
|
// `new Request(url, { …, headers })` rather than `new Request(request, …)`:
|
|
6186
6352
|
// the DO is addressed by its own path, not the client's, and the upgrade
|
|
@@ -6438,6 +6604,8 @@ export class CloudflareScopeHost {
|
|
|
6438
6604
|
// #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
|
|
6439
6605
|
// same unit, on the scope this call provisions — the one the seat's tuples name.
|
|
6440
6606
|
carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
|
|
6607
|
+
if (input.entityGrants)
|
|
6608
|
+
await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants);
|
|
6441
6609
|
return carry ? { switchedOff } : {};
|
|
6442
6610
|
}
|
|
6443
6611
|
/**
|
|
@@ -6463,7 +6631,7 @@ export class CloudflareScopeHost {
|
|
|
6463
6631
|
* already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
|
|
6464
6632
|
*/
|
|
6465
6633
|
async assignScopeRole(scopeId, principal, roleKey) {
|
|
6466
|
-
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null);
|
|
6634
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
|
|
6467
6635
|
}
|
|
6468
6636
|
/**
|
|
6469
6637
|
* Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
|
|
@@ -6522,8 +6690,41 @@ export class CloudflareScopeHost {
|
|
|
6522
6690
|
* (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
|
|
6523
6691
|
*/
|
|
6524
6692
|
async grantEntityLocal(scopeId, principal, permission, entity) {
|
|
6525
|
-
await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
6526
|
-
null);
|
|
6693
|
+
unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
|
|
6694
|
+
null));
|
|
6695
|
+
}
|
|
6696
|
+
/**
|
|
6697
|
+
* Give a person a declared entity-grant SHAPE on one entity in a CP-less vertical (#2071) —
|
|
6698
|
+
* `grantEntityLocal` for every key of the shape, plus the marker that makes them a holder of
|
|
6699
|
+
* it, in one unit. What a vertical calls where it used to loop `grantEntityLocal` over its
|
|
6700
|
+
* `ENTITY_GRANTS` keys: a key the shape gains later then reaches this person at the next
|
|
6701
|
+
* provision or reconcile (`provisionScopeLocal`'s `entityGrants`). Idempotent, and explicit
|
|
6702
|
+
* like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
|
|
6703
|
+
*/
|
|
6704
|
+
async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
|
|
6705
|
+
await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
|
|
6706
|
+
}
|
|
6707
|
+
/**
|
|
6708
|
+
* Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
|
|
6709
|
+
* passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
|
|
6710
|
+
* anything else is `validation_failed`), repeated until a pass finishes. Never re-grants a revoked
|
|
6711
|
+
* key; takes back only a key the shape declares `retired` (#2082). Returns how many
|
|
6712
|
+
* (person, entity) it topped up, and how many it took retired keys from.
|
|
6713
|
+
*/
|
|
6714
|
+
async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
|
|
6715
|
+
const limit = shapeTopUpBatch(batch);
|
|
6716
|
+
let toppedUp = 0;
|
|
6717
|
+
let retired = 0;
|
|
6718
|
+
if (shapes.length === 0)
|
|
6719
|
+
return { toppedUp, retired };
|
|
6720
|
+
const stub = this.scopeStub(scopeId);
|
|
6721
|
+
for (let done = false; !done;) {
|
|
6722
|
+
const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit);
|
|
6723
|
+
toppedUp += pass.toppedUp;
|
|
6724
|
+
retired += pass.retired;
|
|
6725
|
+
done = pass.done;
|
|
6726
|
+
}
|
|
6727
|
+
return { toppedUp, retired };
|
|
6527
6728
|
}
|
|
6528
6729
|
// -- the connector write-back's far end (#574) -----------------------------
|
|
6529
6730
|
// A CP-less dispatch vertical cannot run a connector, so the shared control
|
|
@@ -6722,10 +6923,21 @@ export class CloudflareScopeHost {
|
|
|
6722
6923
|
if (!delegation)
|
|
6723
6924
|
this.assertServedHere(rec, scopeId, 'moveImportCursor');
|
|
6724
6925
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
6926
|
+
// #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
|
|
6927
|
+
// closes the lever's rows exactly as it does the kill switches'.
|
|
6725
6928
|
const replayId = ulid();
|
|
6726
6929
|
const target = { tenantId, scopeId, vertical: rec.vertical };
|
|
6727
|
-
const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6930
|
+
const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
|
|
6728
6931
|
await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
|
|
6932
|
+
// #2114: every outcome row goes through the kernel's `recordAuditOutcome`, as the kill
|
|
6933
|
+
// switches' do (#2089). One that cannot be written is logged with the replay id, never
|
|
6934
|
+
// swallowed; the call still answers with the move's own error (or, applied, with the move and
|
|
6935
|
+
// `auditWarning`), and the scheduled settle closes the intent as `unknown`.
|
|
6936
|
+
const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
|
|
6937
|
+
flow: 'import-cursor',
|
|
6938
|
+
operationId: replayId,
|
|
6939
|
+
phase: row.phase,
|
|
6940
|
+
}, (message, fields) => console.error(message, fields));
|
|
6729
6941
|
const at = { move, source: source, replayId };
|
|
6730
6942
|
let moved;
|
|
6731
6943
|
try {
|
|
@@ -6734,21 +6946,16 @@ export class CloudflareScopeHost {
|
|
|
6734
6946
|
: await this.moveInScope(scopeId, at);
|
|
6735
6947
|
}
|
|
6736
6948
|
catch (err) {
|
|
6737
|
-
await
|
|
6738
|
-
...base,
|
|
6739
|
-
phase: 'failed',
|
|
6740
|
-
error: err instanceof Error ? err.message : String(err),
|
|
6741
|
-
}).catch(() => undefined);
|
|
6949
|
+
await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
|
|
6742
6950
|
throw err;
|
|
6743
6951
|
}
|
|
6744
|
-
|
|
6745
|
-
...base,
|
|
6952
|
+
const unrecorded = await recordOutcome({
|
|
6746
6953
|
phase: 'applied',
|
|
6747
6954
|
previous: moved.previous,
|
|
6748
6955
|
cursor: moved.cursor,
|
|
6749
6956
|
archived: moved.archived,
|
|
6750
6957
|
});
|
|
6751
|
-
return moved;
|
|
6958
|
+
return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
|
|
6752
6959
|
}
|
|
6753
6960
|
/** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
|
|
6754
6961
|
exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {
|