@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/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 sha256Hex(input.body),
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 sha256Hex(obj.body)) !== record.sha256) {
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 new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
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 new Error(`vertical '${parsed.slug}' is owned by ${existing.owner_tenant ?? 'the platform'}, not ${parsed.ownerTenant ?? 'the platform'}`);
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 new Error(`vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
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 new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
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(Math.max(limit ?? 200, 1), 1000);
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
- const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted } = redacted;
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
- const pruned = await this.cp.pruneAccessLog(limit);
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({