@substrat-run/adapter-cloudflare 0.133.0 → 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 +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 +17 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +73 -17
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +70 -18
- 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';
|
|
@@ -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
|
|
@@ -208,6 +210,8 @@ export class CloudflareScopeHost {
|
|
|
208
210
|
this.tenantStores = options.tenantStores;
|
|
209
211
|
this.blobStores = options.blobStores;
|
|
210
212
|
this.attachmentBuckets = options.attachmentBuckets;
|
|
213
|
+
assertAttachmentExtractors(options.attachmentExtractors ?? []);
|
|
214
|
+
this.attachmentExtractors = options.attachmentExtractors ?? [];
|
|
211
215
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
212
216
|
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
213
217
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
@@ -418,6 +422,8 @@ export class CloudflareScopeHost {
|
|
|
418
422
|
return this.drainExecutors(tenantId, scopeId, null);
|
|
419
423
|
}
|
|
420
424
|
registerJob(moduleId, name, handler, retry) {
|
|
425
|
+
// #1575: the kernel's own jobs are dispatched before this registry is read.
|
|
426
|
+
assertJobRegistrable(moduleId, name);
|
|
421
427
|
const key = `${moduleId}/${name}`;
|
|
422
428
|
if (this.jobs.has(key))
|
|
423
429
|
throw new Error(`job '${key}' is already registered`);
|
|
@@ -448,6 +454,23 @@ export class CloudflareScopeHost {
|
|
|
448
454
|
recordStep: (runId, name, result, attempts, lastError, at) => stub.jobStepRecord(runId, name, result, attempts, lastError, at),
|
|
449
455
|
};
|
|
450
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
|
+
}
|
|
451
474
|
async startJobRun(tenantId, scopeId, input) {
|
|
452
475
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
453
476
|
await this.migrateAndRecord(scopeId);
|
|
@@ -458,9 +481,12 @@ export class CloudflareScopeHost {
|
|
|
458
481
|
// advance, and an archived one's never move again.
|
|
459
482
|
await this.validateScopeAccess(tenantId, scopeId);
|
|
460
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) };
|
|
461
487
|
return runDueJobRuns({
|
|
462
488
|
store: this.jobStore(scopeId),
|
|
463
|
-
handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
|
|
489
|
+
handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
|
|
464
490
|
now: () => new Date().toISOString(),
|
|
465
491
|
openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
|
|
466
492
|
maxPasses: options?.maxPasses,
|
|
@@ -1088,6 +1114,8 @@ export class CloudflareScopeHost {
|
|
|
1088
1114
|
}),
|
|
1089
1115
|
list: notDelegated('list'),
|
|
1090
1116
|
remove: notDelegated('remove'),
|
|
1117
|
+
// #1575: undelegated for `list`'s reason — a connector picks a document by id.
|
|
1118
|
+
search: notDelegated('search'),
|
|
1091
1119
|
};
|
|
1092
1120
|
}
|
|
1093
1121
|
await this.migrateAndRecord(scopeId);
|
|
@@ -1136,6 +1164,12 @@ export class CloudflareScopeHost {
|
|
|
1136
1164
|
remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
|
|
1137
1165
|
attachmentId,
|
|
1138
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
|
+
},
|
|
1139
1173
|
};
|
|
1140
1174
|
}
|
|
1141
1175
|
const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
|
|
@@ -1156,7 +1190,7 @@ export class CloudflareScopeHost {
|
|
|
1156
1190
|
filename: input.filename,
|
|
1157
1191
|
contentType: input.contentType,
|
|
1158
1192
|
size: input.body.byteLength,
|
|
1159
|
-
sha256: await
|
|
1193
|
+
sha256: await attachmentSha256(input.body),
|
|
1160
1194
|
visibility: input.visibility,
|
|
1161
1195
|
createdBy,
|
|
1162
1196
|
createdAt: new Date().toISOString(),
|
|
@@ -1183,6 +1217,14 @@ export class CloudflareScopeHost {
|
|
|
1183
1217
|
await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
|
|
1184
1218
|
return removed;
|
|
1185
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
|
+
},
|
|
1186
1228
|
};
|
|
1187
1229
|
}
|
|
1188
1230
|
/**
|
|
@@ -1196,7 +1238,7 @@ export class CloudflareScopeHost {
|
|
|
1196
1238
|
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
1197
1239
|
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
1198
1240
|
}
|
|
1199
|
-
if ((await
|
|
1241
|
+
if ((await attachmentSha256(obj.body)) !== record.sha256) {
|
|
1200
1242
|
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
1201
1243
|
}
|
|
1202
1244
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
@@ -2040,7 +2082,7 @@ export class CloudflareScopeHost {
|
|
|
2040
2082
|
// lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
|
|
2041
2083
|
// §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
|
|
2042
2084
|
if (v.admission !== 'admitted' && scope.kind !== 'preview') {
|
|
2043
|
-
throw
|
|
2085
|
+
throw substratError('conflict', `version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
2044
2086
|
}
|
|
2045
2087
|
return { v, scope };
|
|
2046
2088
|
};
|
|
@@ -2831,7 +2873,7 @@ export class CloudflareScopeHost {
|
|
|
2831
2873
|
return;
|
|
2832
2874
|
}
|
|
2833
2875
|
if (existing.owner_tenant !== parsed.ownerTenant) {
|
|
2834
|
-
throw
|
|
2876
|
+
throw substratError('conflict', `vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
|
|
2835
2877
|
}
|
|
2836
2878
|
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
2837
2879
|
}
|
|
@@ -2898,7 +2940,7 @@ export class CloudflareScopeHost {
|
|
|
2898
2940
|
const prod = await this.cp.readChannel(slug, 'prod');
|
|
2899
2941
|
const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
|
|
2900
2942
|
if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
|
|
2901
|
-
throw
|
|
2943
|
+
throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
|
|
2902
2944
|
`a staff admit must vouch for it before listing`);
|
|
2903
2945
|
}
|
|
2904
2946
|
}
|
|
@@ -3005,7 +3047,7 @@ export class CloudflareScopeHost {
|
|
|
3005
3047
|
throw new Error(`version ${versionId} belongs to '${incoming.vertical_slug}'`);
|
|
3006
3048
|
}
|
|
3007
3049
|
if (incoming.admission !== 'admitted') {
|
|
3008
|
-
throw
|
|
3050
|
+
throw substratError('conflict', `version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
3009
3051
|
}
|
|
3010
3052
|
const current = await this.cp.readChannel(verticalSlug, channel);
|
|
3011
3053
|
const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
|
|
@@ -3441,7 +3483,7 @@ export class CloudflareScopeHost {
|
|
|
3441
3483
|
moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
|
|
3442
3484
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
3443
3485
|
const record = await this.scopeRecordForRead(tenantId, scopeId);
|
|
3444
|
-
const bounded = Math.min(
|
|
3486
|
+
const bounded = Math.min(assertRowLimit('limit', limit ?? 200), 1000);
|
|
3445
3487
|
// #1334: on the shared control plane the scope's outbox is in its vertical's
|
|
3446
3488
|
// deployment, and this host's own namespace is the module-less placeholder —
|
|
3447
3489
|
// reading it would construct an empty DO and answer "nothing to ship". The
|
|
@@ -3602,6 +3644,9 @@ export class CloudflareScopeHost {
|
|
|
3602
3644
|
},
|
|
3603
3645
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
3604
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);
|
|
3605
3650
|
const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
|
|
3606
3651
|
await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
|
|
3607
3652
|
return page;
|
|
@@ -3783,7 +3828,20 @@ export class CloudflareScopeHost {
|
|
|
3783
3828
|
`_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
|
|
3784
3829
|
`key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
|
|
3785
3830
|
}
|
|
3786
|
-
|
|
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 });
|
|
3787
3845
|
const at = new Date().toISOString();
|
|
3788
3846
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
3789
3847
|
const receipt = subjectShredReceipt.parse({
|
|
@@ -3800,7 +3858,7 @@ export class CloudflareScopeHost {
|
|
|
3800
3858
|
await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
3801
3859
|
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
3802
3860
|
// an intent payload is a whole event's worth of it.
|
|
3803
|
-
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
|
|
3861
|
+
await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
|
|
3804
3862
|
return receipt;
|
|
3805
3863
|
},
|
|
3806
3864
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -4195,7 +4253,8 @@ export class CloudflareScopeHost {
|
|
|
4195
4253
|
return drained;
|
|
4196
4254
|
},
|
|
4197
4255
|
pruneAccessLog: async (actor, limit) => {
|
|
4198
|
-
|
|
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));
|
|
4199
4258
|
if (pruned > 0) {
|
|
4200
4259
|
// The payload is the APPLIED state, so it belongs in `after` (contracts'
|
|
4201
4260
|
// adminLogEntry: before = prior state, after = the applied payload) — the
|
|
@@ -4308,6 +4367,8 @@ export class CloudflareScopeHost {
|
|
|
4308
4367
|
await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
4309
4368
|
return rows.map((r) => sweepRunEntry.parse(r));
|
|
4310
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)),
|
|
4311
4372
|
listIssues: async (actor, filter) => {
|
|
4312
4373
|
const rows = await this.cp.listIssues({
|
|
4313
4374
|
status: filter?.status,
|
|
@@ -5534,11 +5595,6 @@ export class CloudflareScopeHost {
|
|
|
5534
5595
|
return importsOfManifestJson(v.manifest_json);
|
|
5535
5596
|
}
|
|
5536
5597
|
}
|
|
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
5598
|
/** A ledger row -> the wire entry, the attribution re-nested (#1054). */
|
|
5543
5599
|
function modelUsageEntryOf(r) {
|
|
5544
5600
|
return modelUsageEntry.parse({
|