@substrat-run/adapter-cloudflare 0.132.1 → 0.134.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 +10 -2
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +60 -15
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +24 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +82 -19
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +85 -29
- 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
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';
|
|
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, 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';
|
|
@@ -110,6 +110,7 @@ export class CloudflareScopeHost {
|
|
|
110
110
|
admin;
|
|
111
111
|
scopeNs;
|
|
112
112
|
cp;
|
|
113
|
+
servicePrincipals;
|
|
113
114
|
/** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
|
|
114
115
|
cpLess;
|
|
115
116
|
/** Project + evaluate permissions scope-locally (scope-local-permissions.md). */
|
|
@@ -146,6 +147,8 @@ export class CloudflareScopeHost {
|
|
|
146
147
|
blobStores;
|
|
147
148
|
/** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
|
|
148
149
|
attachmentBuckets;
|
|
150
|
+
/** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
|
|
151
|
+
attachmentExtractors;
|
|
149
152
|
executors = new Map();
|
|
150
153
|
/**
|
|
151
154
|
* `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
|
|
@@ -202,10 +205,13 @@ export class CloudflareScopeHost {
|
|
|
202
205
|
* request after that returned 1101 in production.
|
|
203
206
|
*/
|
|
204
207
|
constructor(options) {
|
|
208
|
+
this.servicePrincipals = options.servicePrincipals;
|
|
205
209
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
206
210
|
this.tenantStores = options.tenantStores;
|
|
207
211
|
this.blobStores = options.blobStores;
|
|
208
212
|
this.attachmentBuckets = options.attachmentBuckets;
|
|
213
|
+
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
214
|
+
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
209
215
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
210
216
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
211
217
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
@@ -416,6 +422,8 @@ export class CloudflareScopeHost {
|
|
|
416
422
|
return this.drainExecutors(tenantId, scopeId, null);
|
|
417
423
|
}
|
|
418
424
|
registerJob(moduleId, name, handler, retry) {
|
|
425
|
+
// #1575: the kernel's own jobs are dispatched before this registry is read.
|
|
426
|
+
assertJobRegistrable(moduleId, name);
|
|
419
427
|
const key = `${moduleId}/${name}`;
|
|
420
428
|
if (this.jobs.has(key))
|
|
421
429
|
throw new Error(`job '${key}' is already registered`);
|
|
@@ -446,6 +454,23 @@ export class CloudflareScopeHost {
|
|
|
446
454
|
recordStep: (runId, name, result, attempts, lastError, at) => stub.jobStepRecord(runId, name, result, attempts, lastError, at),
|
|
447
455
|
};
|
|
448
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* The extraction job's adapter half (#1575). The record and the outcome are RPCs to the
|
|
459
|
+
* scope DO; the bytes are read here, where the per-tenant bucket is bound — the DO never
|
|
460
|
+
* holds them. No permission gate on any of it: extraction is the kernel's own derivation,
|
|
461
|
+
* and the person-facing gate is at search.
|
|
462
|
+
*/
|
|
463
|
+
attachmentTextHandler(tenantId, scopeId) {
|
|
464
|
+
const stub = this.scopeStub(scopeId);
|
|
465
|
+
return attachmentTextJob({
|
|
466
|
+
record: (attachmentId) => stub.attachmentTextSource(attachmentId),
|
|
467
|
+
bytes: async (record) => (await (await this.resolveAttachmentStore(tenantId)).get(attachmentBlobKey(scopeId, record.id)))?.body ??
|
|
468
|
+
null,
|
|
469
|
+
write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
|
|
470
|
+
},
|
|
471
|
+
// K-43: the host's parsers, handed in — this adapter imports none.
|
|
472
|
+
this.attachmentExtractors);
|
|
473
|
+
}
|
|
449
474
|
async startJobRun(tenantId, scopeId, input) {
|
|
450
475
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
451
476
|
await this.migrateAndRecord(scopeId);
|
|
@@ -456,9 +481,12 @@ export class CloudflareScopeHost {
|
|
|
456
481
|
// advance, and an archived one's never move again.
|
|
457
482
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
458
483
|
await this.migrateAndRecord(scopeId);
|
|
484
|
+
// #1575: the kernel's extraction job is this host's own, bound to this scope — no
|
|
485
|
+
// deployment registers it, and none can shadow it.
|
|
486
|
+
const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
|
|
459
487
|
return runDueJobRuns({
|
|
460
488
|
store: this.jobStore(scopeId),
|
|
461
|
-
handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
|
|
489
|
+
handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
|
|
462
490
|
now: () => new Date().toISOString(),
|
|
463
491
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
464
492
|
maxPasses: options?.maxPasses,
|
|
@@ -1086,6 +1114,8 @@ export class CloudflareScopeHost {
|
|
|
1086
1114
|
}),
|
|
1087
1115
|
list: notDelegated('list'),
|
|
1088
1116
|
remove: notDelegated('remove'),
|
|
1117
|
+
// #1575: undelegated for `list`'s reason — a connector picks a document by id.
|
|
1118
|
+
search: notDelegated('search'),
|
|
1089
1119
|
};
|
|
1090
1120
|
}
|
|
1091
1121
|
await this.migrateAndRecord(scopeId);
|
|
@@ -1134,6 +1164,12 @@ export class CloudflareScopeHost {
|
|
|
1134
1164
|
remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
1135
1165
|
attachmentId,
|
|
1136
1166
|
})),
|
|
1167
|
+
search: async (term, options) => {
|
|
1168
|
+
// Judged here too, so a short term reaches the caller as `SearchTermTooShort`
|
|
1169
|
+
// rather than as a message that crossed the RPC boundary. The DO judges again.
|
|
1170
|
+
searchMatchExpression(term, 'prefix');
|
|
1171
|
+
return unwrapCapabilityReply(await stub.capabilityAttachmentSearch(term, searchLimit(options?.limit), hash, tenantId, scopeId));
|
|
1172
|
+
},
|
|
1137
1173
|
};
|
|
1138
1174
|
}
|
|
1139
1175
|
const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
|
|
@@ -1154,7 +1190,7 @@ export class CloudflareScopeHost {
|
|
|
1154
1190
|
filename: input.filename,
|
|
1155
1191
|
contentType: input.contentType,
|
|
1156
1192
|
size: input.body.byteLength,
|
|
1157
|
-
sha256: await
|
|
1193
|
+
sha256: await attachmentSha256(input.body),
|
|
1158
1194
|
visibility: input.visibility,
|
|
1159
1195
|
createdBy,
|
|
1160
1196
|
createdAt: new Date().toISOString(),
|
|
@@ -1181,6 +1217,14 @@ export class CloudflareScopeHost {
|
|
|
1181
1217
|
await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
|
|
1182
1218
|
return removed;
|
|
1183
1219
|
},
|
|
1220
|
+
search: async (term, options) => {
|
|
1221
|
+
if (forEvent) {
|
|
1222
|
+
// The delivery-admitted surface reads the ONE attachment its delivery names.
|
|
1223
|
+
throw new Error('attachments.search is not available on a delivery-scoped surface');
|
|
1224
|
+
}
|
|
1225
|
+
searchMatchExpression(term, 'prefix');
|
|
1226
|
+
return unwrapCapabilityReply(await stub.attachmentSearch(term, searchLimit(options?.limit), asPrincipalId, tenantId, scopeId, connectionId));
|
|
1227
|
+
},
|
|
1184
1228
|
};
|
|
1185
1229
|
}
|
|
1186
1230
|
/**
|
|
@@ -1194,7 +1238,7 @@ export class CloudflareScopeHost {
|
|
|
1194
1238
|
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
1195
1239
|
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
1196
1240
|
}
|
|
1197
|
-
if ((await
|
|
1241
|
+
if ((await attachmentSha256(obj.body)) !== record.sha256) {
|
|
1198
1242
|
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
1199
1243
|
}
|
|
1200
1244
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
@@ -2038,7 +2082,7 @@ export class CloudflareScopeHost {
|
|
|
2038
2082
|
// lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
|
|
2039
2083
|
// §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
|
|
2040
2084
|
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
2041
|
-
throw
|
|
2085
|
+
throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
2042
2086
|
}
|
|
2043
2087
|
return { v, scope };
|
|
2044
2088
|
};
|
|
@@ -2829,7 +2873,7 @@ export class CloudflareScopeHost {
|
|
|
2829
2873
|
return;
|
|
2830
2874
|
}
|
|
2831
2875
|
if (existing.owner_tenant !== parsed.ownerTenant) {
|
|
2832
|
-
throw
|
|
2876
|
+
throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
|
|
2833
2877
|
}
|
|
2834
2878
|
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
2835
2879
|
}
|
|
@@ -2896,7 +2940,7 @@ export class CloudflareScopeHost {
|
|
|
2896
2940
|
const prod = await this.cp.readChannel(slug, 'prod');
|
|
2897
2941
|
const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
|
|
2898
2942
|
if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
2899
|
-
throw
|
|
2943
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
2900
2944
|
`a staff admit must vouch for it before listing`);
|
|
2901
2945
|
}
|
|
2902
2946
|
}
|
|
@@ -3003,7 +3047,7 @@ export class CloudflareScopeHost {
|
|
|
3003
3047
|
throw new Error(`version ${versionId} belongs to '${incoming.vertical_slug}'`);
|
|
3004
3048
|
}
|
|
3005
3049
|
if (incoming.admission !== 'admitted') {
|
|
3006
|
-
throw
|
|
3050
|
+
throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
3007
3051
|
}
|
|
3008
3052
|
const current = await this.cp.readChannel(verticalSlug, channel);
|
|
3009
3053
|
const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
|
|
@@ -3114,6 +3158,9 @@ export class CloudflareScopeHost {
|
|
|
3114
3158
|
},
|
|
3115
3159
|
bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
|
|
3116
3160
|
const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
|
|
3161
|
+
if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
|
|
3162
|
+
throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
|
|
3163
|
+
}
|
|
3117
3164
|
const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
|
|
3118
3165
|
// #1756: an export an app in this tenant imports, dropped or re-versioned by what this
|
|
3119
3166
|
// scope would run. Before the snapshot, so a refused bind leaves nothing behind.
|
|
@@ -3130,9 +3177,10 @@ export class CloudflareScopeHost {
|
|
|
3130
3177
|
await this.snapshotScope(actor, tenantId, scopeId);
|
|
3131
3178
|
}
|
|
3132
3179
|
}
|
|
3133
|
-
await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
|
|
3180
|
+
await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId);
|
|
3134
3181
|
await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
|
|
3135
3182
|
versionId, vertical: v.vertical_slug, version: v.version,
|
|
3183
|
+
...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
|
|
3136
3184
|
...(ack.exportBreak ? { acknowledged: ack } : {}),
|
|
3137
3185
|
});
|
|
3138
3186
|
},
|
|
@@ -3435,7 +3483,7 @@ export class CloudflareScopeHost {
|
|
|
3435
3483
|
moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
|
|
3436
3484
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
3437
3485
|
const record = await this.scopeRecordForRead(tenantId, scopeId);
|
|
3438
|
-
const bounded = Math.min(
|
|
3486
|
+
const bounded = Math.min(assertRowLimit('limit', limit ?? 200), 1000);
|
|
3439
3487
|
// #1334: on the shared control plane the scope's outbox is in its vertical's
|
|
3440
3488
|
// deployment, and this host's own namespace is the module-less placeholder —
|
|
3441
3489
|
// reading it would construct an empty DO and answer "nothing to ship". The
|
|
@@ -3596,6 +3644,9 @@ export class CloudflareScopeHost {
|
|
|
3596
3644
|
},
|
|
3597
3645
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
3598
3646
|
await this.scopeRecordForRead(tenantId, scopeId);
|
|
3647
|
+
// Checked here as well as in the ScopeDO, so the refusal keeps its code across the hop.
|
|
3648
|
+
assertRowLimit('limit', input.limit);
|
|
3649
|
+
assertRowOffset('offset', input.offset);
|
|
3599
3650
|
const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
|
|
3600
3651
|
await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
3601
3652
|
return page;
|
|
@@ -3777,7 +3828,20 @@ export class CloudflareScopeHost {
|
|
|
3777
3828
|
`_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
|
|
3778
3829
|
`key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
|
|
3779
3830
|
}
|
|
3780
|
-
|
|
3831
|
+
// A DO from before the free-text half (#1632): it never looked at the idempotency
|
|
3832
|
+
// ledger or a queued sweep record, and cannot name the intents the directory's drain
|
|
3833
|
+
// failures quote. Refused before the key, for the reason above.
|
|
3834
|
+
if (!('idempotencyResults' in redacted) ||
|
|
3835
|
+
typeof redacted.idempotencyResults !== 'number' ||
|
|
3836
|
+
!Array.isArray(redacted.intentIds)) {
|
|
3837
|
+
throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO whose redaction does not reach _substrat_idempotency ` +
|
|
3838
|
+
`or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
|
|
3839
|
+
`leaving their data in those rows. Redeploy the vertical and re-run.`);
|
|
3840
|
+
}
|
|
3841
|
+
const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
|
|
3842
|
+
// The directory's failure text (#1632) — a drain failure quoting one of those intents,
|
|
3843
|
+
// an issue's exemplar, a sweep record's error. Still before the key.
|
|
3844
|
+
await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
|
|
3781
3845
|
const at = new Date().toISOString();
|
|
3782
3846
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
3783
3847
|
const receipt = subjectShredReceipt.parse({
|
|
@@ -3794,7 +3858,7 @@ export class CloudflareScopeHost {
|
|
|
3794
3858
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
3795
3859
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
3796
3860
|
// an intent payload is a whole event's worth of it.
|
|
3797
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
|
|
3861
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
3798
3862
|
return receipt;
|
|
3799
3863
|
},
|
|
3800
3864
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -4189,7 +4253,8 @@ export class CloudflareScopeHost {
|
|
|
4189
4253
|
return drained;
|
|
4190
4254
|
},
|
|
4191
4255
|
pruneAccessLog: async (actor, limit) => {
|
|
4192
|
-
|
|
4256
|
+
// Checked here as well as in the directory, so the refusal keeps its code across the hop.
|
|
4257
|
+
const pruned = await this.cp.pruneAccessLog(assertRowLimit('limit', limit));
|
|
4193
4258
|
if (pruned > 0) {
|
|
4194
4259
|
// The payload is the APPLIED state, so it belongs in `after` (contracts'
|
|
4195
4260
|
// adminLogEntry: before = prior state, after = the applied payload) — the
|
|
@@ -4302,6 +4367,8 @@ export class CloudflareScopeHost {
|
|
|
4302
4367
|
await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
4303
4368
|
return rows.map((r) => sweepRunEntry.parse(r));
|
|
4304
4369
|
},
|
|
4370
|
+
// Checked here as well as in the directory, so the refusal keeps its code across the hop.
|
|
4371
|
+
pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
|
|
4305
4372
|
listIssues: async (actor, filter) => {
|
|
4306
4373
|
const rows = await this.cp.listIssues({
|
|
4307
4374
|
status: filter?.status,
|
|
@@ -5067,6 +5134,7 @@ export class CloudflareScopeHost {
|
|
|
5067
5134
|
* from its own storage. No tenant-level tuples, no control plane.
|
|
5068
5135
|
*/
|
|
5069
5136
|
async provisionScopeLocal(input) {
|
|
5137
|
+
const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
|
|
5070
5138
|
const stub = this.scopeStub(input.scopeId);
|
|
5071
5139
|
await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
|
|
5072
5140
|
const switchedOff = await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
|
|
@@ -5163,7 +5231,7 @@ export class CloudflareScopeHost {
|
|
|
5163
5231
|
// scope this call provisions — the one the seat's tuples name.
|
|
5164
5232
|
input.switchedOff
|
|
5165
5233
|
? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
|
|
5166
|
-
: undefined);
|
|
5234
|
+
: undefined, services?.map((id) => `principal:${id}`));
|
|
5167
5235
|
return input.switchedOff ? { switchedOff } : {};
|
|
5168
5236
|
}
|
|
5169
5237
|
/**
|
|
@@ -5527,11 +5595,6 @@ export class CloudflareScopeHost {
|
|
|
5527
5595
|
return importsOfManifestJson(v.manifest_json);
|
|
5528
5596
|
}
|
|
5529
5597
|
}
|
|
5530
|
-
/** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
|
|
5531
|
-
async function sha256Hex(body) {
|
|
5532
|
-
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
5533
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
5534
|
-
}
|
|
5535
5598
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
5536
5599
|
function modelUsageEntryOf(r) {
|
|
5537
5600
|
return modelUsageEntry.parse({
|