@substrat-run/adapter-cloudflare 0.133.0 → 0.135.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 +9 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +52 -12
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +27 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +120 -22
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +91 -19
- package/dist/scope-do.js.map +1 -1
- package/dist/sql.d.ts +3 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +4 -0
- package/dist/sql.js.map +1 -1
- package/package.json +8 -7
package/dist/host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
|
|
2
|
-
import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
|
|
2
|
+
import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
|
|
3
3
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
4
|
-
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, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, } from '@substrat-run/kernel';
|
|
4
|
+
import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, } from '@substrat-run/kernel';
|
|
5
5
|
import { attributedHost } from '@substrat-run/kernel';
|
|
6
6
|
import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
|
|
7
7
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
@@ -147,6 +147,8 @@ export class CloudflareScopeHost {
|
|
|
147
147
|
blobStores;
|
|
148
148
|
/** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
|
|
149
149
|
attachmentBuckets;
|
|
150
|
+
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
151
|
+
attachmentExtractors;
|
|
150
152
|
executors = new Map();
|
|
151
153
|
/**
|
|
152
154
|
* `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
|
|
@@ -169,6 +171,8 @@ export class CloudflareScopeHost {
|
|
|
169
171
|
*/
|
|
170
172
|
onBehalfOf = null;
|
|
171
173
|
withdrawn = new Map(); // operation → module
|
|
174
|
+
/** Operation → who binds it (module, SKU flag, its declared schedules) — the §4.3 gate's
|
|
175
|
+
* input, resolved per invoke by the kernel's `requiredEntitlementFor` (#1654). */
|
|
172
176
|
operationEntitlement = new Map();
|
|
173
177
|
/** #574: remote connector write-back for scopes served by another deployment. */
|
|
174
178
|
connectorDelegation;
|
|
@@ -208,6 +212,8 @@ export class CloudflareScopeHost {
|
|
|
208
212
|
this.tenantStores = options.tenantStores;
|
|
209
213
|
this.blobStores = options.blobStores;
|
|
210
214
|
this.attachmentBuckets = options.attachmentBuckets;
|
|
215
|
+
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
216
|
+
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
211
217
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
212
218
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
213
219
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
@@ -418,6 +424,8 @@ export class CloudflareScopeHost {
|
|
|
418
424
|
return this.drainExecutors(tenantId, scopeId, null);
|
|
419
425
|
}
|
|
420
426
|
registerJob(moduleId, name, handler, retry) {
|
|
427
|
+
// #1575: the kernel's own jobs are dispatched before this registry is read.
|
|
428
|
+
assertJobRegistrable(moduleId, name);
|
|
421
429
|
const key = `${moduleId}/${name}`;
|
|
422
430
|
if (this.jobs.has(key))
|
|
423
431
|
throw new Error(`job '${key}' is already registered`);
|
|
@@ -448,6 +456,23 @@ export class CloudflareScopeHost {
|
|
|
448
456
|
recordStep: (runId, name, result, attempts, lastError, at) => stub.jobStepRecord(runId, name, result, attempts, lastError, at),
|
|
449
457
|
};
|
|
450
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* The extraction job's adapter half (#1575). The record and the outcome are RPCs to the
|
|
461
|
+
* scope DO; the bytes are read here, where the per-tenant bucket is bound — the DO never
|
|
462
|
+
* holds them. No permission gate on any of it: extraction is the kernel's own derivation,
|
|
463
|
+
* and the person-facing gate is at search.
|
|
464
|
+
*/
|
|
465
|
+
attachmentTextHandler(tenantId, scopeId) {
|
|
466
|
+
const stub = this.scopeStub(scopeId);
|
|
467
|
+
return attachmentTextJob({
|
|
468
|
+
record: (attachmentId) => stub.attachmentTextSource(attachmentId),
|
|
469
|
+
bytes: async (record) => (await (await this.resolveAttachmentStore(tenantId)).get(attachmentBlobKey(scopeId, record.id)))?.body ??
|
|
470
|
+
null,
|
|
471
|
+
write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
|
|
472
|
+
},
|
|
473
|
+
// K-43: the host's parsers, handed in — this adapter imports none.
|
|
474
|
+
this.attachmentExtractors);
|
|
475
|
+
}
|
|
451
476
|
async startJobRun(tenantId, scopeId, input) {
|
|
452
477
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
453
478
|
await this.migrateAndRecord(scopeId);
|
|
@@ -458,9 +483,12 @@ export class CloudflareScopeHost {
|
|
|
458
483
|
// advance, and an archived one's never move again.
|
|
459
484
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
460
485
|
await this.migrateAndRecord(scopeId);
|
|
486
|
+
// #1575: the kernel's extraction job is this host's own, bound to this scope — no
|
|
487
|
+
// deployment registers it, and none can shadow it.
|
|
488
|
+
const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
|
|
461
489
|
return runDueJobRuns({
|
|
462
490
|
store: this.jobStore(scopeId),
|
|
463
|
-
handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
|
|
491
|
+
handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
|
|
464
492
|
now: () => new Date().toISOString(),
|
|
465
493
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
466
494
|
maxPasses: options?.maxPasses,
|
|
@@ -605,6 +633,15 @@ export class CloudflareScopeHost {
|
|
|
605
633
|
async summarizeDenialsLocal(scopeId, filter) {
|
|
606
634
|
return this.scopeStub(scopeId).summarizeDenials(filter);
|
|
607
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* The operator's capability read's CP-less path (#1686) — the denial log's trust line: the
|
|
638
|
+
* directory rows live in the scope's own DO, in the vertical's deployment, reachable only
|
|
639
|
+
* through its platform-gated `/internal/capabilities`; the K-3 check and the K-24 entry are
|
|
640
|
+
* the control plane's, made before it calls. Records only — the DO's query selects no hash.
|
|
641
|
+
*/
|
|
642
|
+
async listCapabilitiesLocal(scopeId, filter) {
|
|
643
|
+
return this.scopeStub(scopeId).listCapabilities(filter);
|
|
644
|
+
}
|
|
608
645
|
/**
|
|
609
646
|
* Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
|
|
610
647
|
* the data half of an orchestrated snapshot (preview-and-snapshots.md §9). Like the
|
|
@@ -819,9 +856,14 @@ export class CloudflareScopeHost {
|
|
|
819
856
|
throw new Error(`${manifest.id} declares operationInputs for unbound operation(s): ` +
|
|
820
857
|
`${unboundInputs.sort().join(', ')} — a schema on nothing reads as a parse that is not there`);
|
|
821
858
|
}
|
|
859
|
+
const binding = {
|
|
860
|
+
moduleId: manifest.id,
|
|
861
|
+
entitlementKey: manifest.entitlementKey,
|
|
862
|
+
scheduledOperations: new Set((manifest.schedules ?? []).map((sch) => sch.operation)),
|
|
863
|
+
};
|
|
822
864
|
for (const name of Object.keys(registration.operations ?? {})) {
|
|
823
865
|
this.bindOperation(name);
|
|
824
|
-
this.operationEntitlement.set(name,
|
|
866
|
+
this.operationEntitlement.set(name, binding);
|
|
825
867
|
}
|
|
826
868
|
}
|
|
827
869
|
defineOperation(name, _handler) {
|
|
@@ -1088,6 +1130,8 @@ export class CloudflareScopeHost {
|
|
|
1088
1130
|
}),
|
|
1089
1131
|
list: notDelegated('list'),
|
|
1090
1132
|
remove: notDelegated('remove'),
|
|
1133
|
+
// #1575: undelegated for `list`'s reason — a connector picks a document by id.
|
|
1134
|
+
search: notDelegated('search'),
|
|
1091
1135
|
};
|
|
1092
1136
|
}
|
|
1093
1137
|
await this.migrateAndRecord(scopeId);
|
|
@@ -1136,6 +1180,12 @@ export class CloudflareScopeHost {
|
|
|
1136
1180
|
remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
1137
1181
|
attachmentId,
|
|
1138
1182
|
})),
|
|
1183
|
+
search: async (term, options) => {
|
|
1184
|
+
// Judged here too, so a short term reaches the caller as `SearchTermTooShort`
|
|
1185
|
+
// rather than as a message that crossed the RPC boundary. The DO judges again.
|
|
1186
|
+
searchMatchExpression(term, 'prefix');
|
|
1187
|
+
return unwrapCapabilityReply(await stub.capabilityAttachmentSearch(term, searchLimit(options?.limit), hash, tenantId, scopeId));
|
|
1188
|
+
},
|
|
1139
1189
|
};
|
|
1140
1190
|
}
|
|
1141
1191
|
const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
|
|
@@ -1156,7 +1206,7 @@ export class CloudflareScopeHost {
|
|
|
1156
1206
|
filename: input.filename,
|
|
1157
1207
|
contentType: input.contentType,
|
|
1158
1208
|
size: input.body.byteLength,
|
|
1159
|
-
sha256: await
|
|
1209
|
+
sha256: await attachmentSha256(input.body),
|
|
1160
1210
|
visibility: input.visibility,
|
|
1161
1211
|
createdBy,
|
|
1162
1212
|
createdAt: new Date().toISOString(),
|
|
@@ -1183,6 +1233,14 @@ export class CloudflareScopeHost {
|
|
|
1183
1233
|
await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
|
|
1184
1234
|
return removed;
|
|
1185
1235
|
},
|
|
1236
|
+
search: async (term, options) => {
|
|
1237
|
+
if (forEvent) {
|
|
1238
|
+
// The delivery-admitted surface reads the ONE attachment its delivery names.
|
|
1239
|
+
throw new Error('attachments.search is not available on a delivery-scoped surface');
|
|
1240
|
+
}
|
|
1241
|
+
searchMatchExpression(term, 'prefix');
|
|
1242
|
+
return unwrapCapabilityReply(await stub.attachmentSearch(term, searchLimit(options?.limit), asPrincipalId, tenantId, scopeId, connectionId));
|
|
1243
|
+
},
|
|
1186
1244
|
};
|
|
1187
1245
|
}
|
|
1188
1246
|
/**
|
|
@@ -1196,7 +1254,7 @@ export class CloudflareScopeHost {
|
|
|
1196
1254
|
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
1197
1255
|
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
1198
1256
|
}
|
|
1199
|
-
if ((await
|
|
1257
|
+
if ((await attachmentSha256(obj.body)) !== record.sha256) {
|
|
1200
1258
|
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
1201
1259
|
}
|
|
1202
1260
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
@@ -1806,7 +1864,13 @@ export class CloudflareScopeHost {
|
|
|
1806
1864
|
// (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
|
|
1807
1865
|
// no-op, so the SAME `requiredKey` is passed to the DO, which fails closed against
|
|
1808
1866
|
// its PROJECTED entitlements (#304). One or the other enforces, never neither.
|
|
1809
|
-
|
|
1867
|
+
//
|
|
1868
|
+
// EXCEPTION (#1654): a module's own declared schedule, through this system door,
|
|
1869
|
+
// demands no SKU — its `system:<moduleId>` grant is the switch, as it already is for
|
|
1870
|
+
// permissions (#383). `requiredEntitlementFor` holds the whole rule and why; it
|
|
1871
|
+
// answers `undefined` there, so neither the coordinator nor the DO asks. Every other
|
|
1872
|
+
// door passes no `systemModuleId` and is gated exactly as before.
|
|
1873
|
+
const requiredKey = requiredEntitlementFor(operation, operationEntitlement.get(operation), systemModuleId);
|
|
1810
1874
|
if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
|
|
1811
1875
|
// Required AND held (#691) — a second CP read, but only on the denial path.
|
|
1812
1876
|
const now = new Date().toISOString();
|
|
@@ -2040,7 +2104,7 @@ export class CloudflareScopeHost {
|
|
|
2040
2104
|
// lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
|
|
2041
2105
|
// §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
|
|
2042
2106
|
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
2043
|
-
throw
|
|
2107
|
+
throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
2044
2108
|
}
|
|
2045
2109
|
return { v, scope };
|
|
2046
2110
|
};
|
|
@@ -2831,7 +2895,7 @@ export class CloudflareScopeHost {
|
|
|
2831
2895
|
return;
|
|
2832
2896
|
}
|
|
2833
2897
|
if (existing.owner_tenant !== parsed.ownerTenant) {
|
|
2834
|
-
throw
|
|
2898
|
+
throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
|
|
2835
2899
|
}
|
|
2836
2900
|
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
2837
2901
|
}
|
|
@@ -2898,7 +2962,7 @@ export class CloudflareScopeHost {
|
|
|
2898
2962
|
const prod = await this.cp.readChannel(slug, 'prod');
|
|
2899
2963
|
const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
|
|
2900
2964
|
if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
2901
|
-
throw
|
|
2965
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
2902
2966
|
`a staff admit must vouch for it before listing`);
|
|
2903
2967
|
}
|
|
2904
2968
|
}
|
|
@@ -2990,7 +3054,7 @@ export class CloudflareScopeHost {
|
|
|
2990
3054
|
if (!v)
|
|
2991
3055
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
2992
3056
|
if (v.admission === 'admitted') {
|
|
2993
|
-
throw
|
|
3057
|
+
throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
|
|
2994
3058
|
}
|
|
2995
3059
|
if (v.admission === 'rejected')
|
|
2996
3060
|
return;
|
|
@@ -3002,10 +3066,10 @@ export class CloudflareScopeHost {
|
|
|
3002
3066
|
if (!incoming)
|
|
3003
3067
|
throw substratError('not_found', `unknown version ${versionId}`);
|
|
3004
3068
|
if (incoming.vertical_slug !== verticalSlug) {
|
|
3005
|
-
throw
|
|
3069
|
+
throw substratError('conflict', `version ${versionId} belongs to '${incoming.vertical_slug}'`);
|
|
3006
3070
|
}
|
|
3007
3071
|
if (incoming.admission !== 'admitted') {
|
|
3008
|
-
throw
|
|
3072
|
+
throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
3009
3073
|
}
|
|
3010
3074
|
const current = await this.cp.readChannel(verticalSlug, channel);
|
|
3011
3075
|
const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
|
|
@@ -3441,7 +3505,7 @@ export class CloudflareScopeHost {
|
|
|
3441
3505
|
moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
|
|
3442
3506
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
3443
3507
|
const record = await this.scopeRecordForRead(tenantId, scopeId);
|
|
3444
|
-
const bounded = Math.min(
|
|
3508
|
+
const bounded = Math.min(assertRowLimit('limit', limit ?? 200), 1000);
|
|
3445
3509
|
// #1334: on the shared control plane the scope's outbox is in its vertical's
|
|
3446
3510
|
// deployment, and this host's own namespace is the module-less placeholder —
|
|
3447
3511
|
// reading it would construct an empty DO and answer "nothing to ship". The
|
|
@@ -3602,6 +3666,9 @@ export class CloudflareScopeHost {
|
|
|
3602
3666
|
},
|
|
3603
3667
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
3604
3668
|
await this.scopeRecordForRead(tenantId, scopeId);
|
|
3669
|
+
// Checked here as well as in the ScopeDO, so the refusal keeps its code across the hop.
|
|
3670
|
+
assertRowLimit('limit', input.limit);
|
|
3671
|
+
assertRowOffset('offset', input.offset);
|
|
3605
3672
|
const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
|
|
3606
3673
|
await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
3607
3674
|
return page;
|
|
@@ -3615,6 +3682,26 @@ export class CloudflareScopeHost {
|
|
|
3615
3682
|
await this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
3616
3683
|
return rows;
|
|
3617
3684
|
},
|
|
3685
|
+
// #1686: the operator's capability read. K-3 on the directory before the DO, like the
|
|
3686
|
+
// denial log. On the shared control plane a scope a vertical's own deployment serves
|
|
3687
|
+
// holds its storage THERE, and this namespace holds a placeholder: the control-plane
|
|
3688
|
+
// API asks the vertical (`listCapabilitiesLocal`), so this branch is the co-located one.
|
|
3689
|
+
listCapabilities: async (actor, tenantId, scopeId, filter) => {
|
|
3690
|
+
// Parsed here too: a typed refusal thrown inside the DO arrives as a bare message.
|
|
3691
|
+
const parsed = capabilityFilter.parse(filter ?? {});
|
|
3692
|
+
const rec = await this.scopeRecordForRead(tenantId, scopeId);
|
|
3693
|
+
this.assertServedHere(rec, scopeId, 'listCapabilities');
|
|
3694
|
+
const page = await this.scopeStub(scopeId).listCapabilities(parsed);
|
|
3695
|
+
await this.recordAccess(actor, 'listCapabilities', { tenantId, scopeId }, filter ?? null, page.entries.length);
|
|
3696
|
+
return page;
|
|
3697
|
+
},
|
|
3698
|
+
listRefusals: async (actor, tenantId, scopeId, filter) => {
|
|
3699
|
+
// #1745: the denial log's discipline — K-3 on the directory before the DO is reached.
|
|
3700
|
+
await this.scopeRecordForRead(tenantId, scopeId);
|
|
3701
|
+
const rows = await this.scopeStub(scopeId).listRefusals(filter);
|
|
3702
|
+
await this.recordAccess(actor, 'listRefusals', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
3703
|
+
return rows;
|
|
3704
|
+
},
|
|
3618
3705
|
summarizeDenials: async (actor, tenantId, scopeId, filter) => {
|
|
3619
3706
|
await this.scopeRecordForRead(tenantId, scopeId);
|
|
3620
3707
|
const summary = await this.scopeStub(scopeId).summarizeDenials(filter);
|
|
@@ -3783,7 +3870,20 @@ export class CloudflareScopeHost {
|
|
|
3783
3870
|
`_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
|
|
3784
3871
|
`key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
|
|
3785
3872
|
}
|
|
3786
|
-
|
|
3873
|
+
// A DO from before the free-text half (#1632): it never looked at the idempotency
|
|
3874
|
+
// ledger or a queued sweep record, and cannot name the intents the directory's drain
|
|
3875
|
+
// failures quote. Refused before the key, for the reason above.
|
|
3876
|
+
if (!('idempotencyResults' in redacted) ||
|
|
3877
|
+
typeof redacted.idempotencyResults !== 'number' ||
|
|
3878
|
+
!Array.isArray(redacted.intentIds)) {
|
|
3879
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO whose redaction does not reach _substrat_idempotency ` +
|
|
3880
|
+
`or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
|
|
3881
|
+
`leaving their data in those rows. Redeploy the vertical and re-run.`);
|
|
3882
|
+
}
|
|
3883
|
+
const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
|
|
3884
|
+
// The directory's failure text (#1632) — a drain failure quoting one of those intents,
|
|
3885
|
+
// an issue's exemplar, a sweep record's error. Still before the key.
|
|
3886
|
+
await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
|
|
3787
3887
|
const at = new Date().toISOString();
|
|
3788
3888
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
3789
3889
|
const receipt = subjectShredReceipt.parse({
|
|
@@ -3800,7 +3900,7 @@ export class CloudflareScopeHost {
|
|
|
3800
3900
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
3801
3901
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
3802
3902
|
// an intent payload is a whole event's worth of it.
|
|
3803
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
|
|
3903
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
3804
3904
|
return receipt;
|
|
3805
3905
|
},
|
|
3806
3906
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -4195,7 +4295,8 @@ export class CloudflareScopeHost {
|
|
|
4195
4295
|
return drained;
|
|
4196
4296
|
},
|
|
4197
4297
|
pruneAccessLog: async (actor, limit) => {
|
|
4198
|
-
|
|
4298
|
+
// Checked here as well as in the directory, so the refusal keeps its code across the hop.
|
|
4299
|
+
const pruned = await this.cp.pruneAccessLog(assertRowLimit('limit', limit));
|
|
4199
4300
|
if (pruned > 0) {
|
|
4200
4301
|
// The payload is the APPLIED state, so it belongs in `after` (contracts'
|
|
4201
4302
|
// adminLogEntry: before = prior state, after = the applied payload) — the
|
|
@@ -4308,6 +4409,8 @@ export class CloudflareScopeHost {
|
|
|
4308
4409
|
await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
4309
4410
|
return rows.map((r) => sweepRunEntry.parse(r));
|
|
4310
4411
|
},
|
|
4412
|
+
// Checked here as well as in the directory, so the refusal keeps its code across the hop.
|
|
4413
|
+
pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
|
|
4311
4414
|
listIssues: async (actor, filter) => {
|
|
4312
4415
|
const rows = await this.cp.listIssues({
|
|
4313
4416
|
status: filter?.status,
|
|
@@ -5534,11 +5637,6 @@ export class CloudflareScopeHost {
|
|
|
5534
5637
|
return importsOfManifestJson(v.manifest_json);
|
|
5535
5638
|
}
|
|
5536
5639
|
}
|
|
5537
|
-
/** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
|
|
5538
|
-
async function sha256Hex(body) {
|
|
5539
|
-
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
5540
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
5541
|
-
}
|
|
5542
5640
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
5543
5641
|
function modelUsageEntryOf(r) {
|
|
5544
5642
|
return modelUsageEntry.parse({
|