@substrat-run/adapter-cloudflare 0.55.0 → 0.57.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,4 +1,4 @@
1
- import { accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, } from '@substrat-run/contracts';
1
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
3
  import { attachmentBlobKey, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, createSubjectKeys, } from '@substrat-run/kernel';
4
4
  import { tenantStoreDatabaseName } from './d1.js';
@@ -102,6 +102,8 @@ export class CloudflareScopeHost {
102
102
  causedBy = null;
103
103
  withdrawn = new Map(); // operation → module
104
104
  operationEntitlement = new Map();
105
+ /** #574: remote connector write-back for scopes served by another deployment. */
106
+ connectorDelegation;
105
107
  /**
106
108
  * MUST be constructed per request. Never cache an instance across requests.
107
109
  *
@@ -125,6 +127,7 @@ export class CloudflareScopeHost {
125
127
  this.cp = options.controlPlane
126
128
  ? options.controlPlane.get(options.controlPlane.idFromName('control-plane'))
127
129
  : nullControlPlane();
130
+ this.connectorDelegation = options.connectorDelegation;
128
131
  this.admin = this.buildAdmin();
129
132
  }
130
133
  // -- registration mechanics (validation only) -----------------------------
@@ -147,6 +150,7 @@ export class CloudflareScopeHost {
147
150
  handler,
148
151
  retry: resolveRetryPolicy(options),
149
152
  timeoutMs: options?.timeoutMs ?? 30_000,
153
+ provider: options?.provider ?? id,
150
154
  });
151
155
  }
152
156
  /**
@@ -216,6 +220,7 @@ export class CloudflareScopeHost {
216
220
  delivered: 0,
217
221
  retrying: 0,
218
222
  deadLettered: 0,
223
+ routedToPlatform: 0,
219
224
  };
220
225
  if (this.executors.size === 0)
221
226
  return report;
@@ -227,14 +232,28 @@ export class CloudflareScopeHost {
227
232
  report.attempted += 1;
228
233
  this.causedBy = event.id;
229
234
  try {
230
- if (executor.kind === 'connector') {
235
+ if (executor.kind === 'connector' && this.cpLess) {
236
+ // #574 phase 3: this host cannot run a connector — no connection
237
+ // directory, no credentials, no sanctioned egress. Route the delivery
238
+ // onto the platform-requests surface instead: the DO enqueues the
239
+ // `connector:<provider>` intent and journals the delivery as routed in
240
+ // one atomic verb, and the platform's drain executes the handler with
241
+ // the authority this host lacks. The intent row carries the retry
242
+ // state from here on; the handler's own idempotency ledger absorbs
243
+ // the at-least-once residue, as it already must in-process.
244
+ await stub.routeExecutorEventToPlatform(event.id, deliveryId, connectorDispatchKind(executor.provider), JSON.stringify({ executorId: id, event }), JSON.stringify({ system: 'connector-dispatch' }));
245
+ report.routedToPlatform += 1;
246
+ }
247
+ else if (executor.kind === 'connector') {
231
248
  await executor.handler(await this.connectorContext(tenantId, scopeId, executor.timeoutMs), event);
249
+ await stub.recordExecutorAttempt(event.id, deliveryId, null, null);
250
+ report.delivered += 1;
232
251
  }
233
252
  else {
234
253
  await executor.handler(this.admin, event);
254
+ await stub.recordExecutorAttempt(event.id, deliveryId, null, null);
255
+ report.delivered += 1;
235
256
  }
236
- await stub.recordExecutorAttempt(event.id, deliveryId, null, null);
237
- report.delivered += 1;
238
257
  }
239
258
  catch (err) {
240
259
  const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
@@ -263,6 +282,21 @@ export class CloudflareScopeHost {
263
282
  await this.migrateAndRecord(scopeId);
264
283
  return this.drainExecutors(tenantId, scopeId);
265
284
  }
285
+ async dispatchConnector(tenantId, scopeId, handler, event, options) {
286
+ // The platform half of a routed delivery (#574 phase 3). The same lifecycle gate as
287
+ // `drainDue` — a suspended scope's routed intent waits, it does not execute — and the
288
+ // same context build as the in-process path, so the handler cannot tell which host
289
+ // ran it. On a CP-less host `connectorContext` throws from the null control plane:
290
+ // fail closed, exactly the hole routing exists to avoid.
291
+ await this.cp.validateScopeAccess(tenantId, scopeId);
292
+ this.causedBy = event.id;
293
+ try {
294
+ await handler(await this.connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000), event);
295
+ }
296
+ finally {
297
+ this.causedBy = null;
298
+ }
299
+ }
266
300
  async executorDeadLetters(tenantId, scopeId) {
267
301
  await this.cp.validateScopeAccess(tenantId, scopeId);
268
302
  await this.migrateAndRecord(scopeId);
@@ -654,6 +688,24 @@ export class CloudflareScopeHost {
654
688
  `runs '${scope.vertical ?? 'none'}'`);
655
689
  }
656
690
  await this.cp.validateScopeAccess(conn.tenant_id, scopeId);
691
+ // #574: same delegation as getConnectorScope. Only `upload` crosses the seam in
692
+ // phase 1 — it is the one verb the reconcile path needs (landing the sealed PDF);
693
+ // the read/remove verbs fail loudly rather than pretending.
694
+ if (this.connectorDelegation) {
695
+ const delegation = this.connectorDelegation;
696
+ const tenant = conn.tenant_id;
697
+ const vertical = conn.vertical;
698
+ const notDelegated = (verb) => async () => {
699
+ throw new Error(`connector attachment ${verb} is not delegated (#574 phase 1) — only upload ` +
700
+ `crosses the /internal seam to the serving deployment`);
701
+ };
702
+ return {
703
+ upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
704
+ list: notDelegated('list'),
705
+ open: notDelegated('open'),
706
+ remove: notDelegated('remove'),
707
+ };
708
+ }
657
709
  await this.migrateAndRecord(scopeId);
658
710
  const store = await this.resolveAttachmentStore(conn.tenant_id);
659
711
  return this.buildAttachmentSurface({ connectionId }, conn.tenant_id, scopeId, store);
@@ -876,6 +928,26 @@ export class CloudflareScopeHost {
876
928
  `runs '${scope.vertical ?? 'none'}'`);
877
929
  }
878
930
  await this.cp.validateScopeAccess(conn.tenant_id, scopeId);
931
+ // #574: a scope served by ANOTHER deployment (the shared control plane running the
932
+ // connector pass for a dispatch vertical) — the write-back rides the delegation
933
+ // seam; migration is the serving deployment's business, exactly like provision.
934
+ if (this.connectorDelegation) {
935
+ const delegation = this.connectorDelegation;
936
+ const tenant = conn.tenant_id;
937
+ const vertical = conn.vertical;
938
+ return {
939
+ tenantId: tenant,
940
+ scopeId,
941
+ invoke: async (operation, input) => (await delegation.invoke({
942
+ connectionId,
943
+ tenantId: tenant,
944
+ scopeId,
945
+ vertical,
946
+ operation,
947
+ input,
948
+ })),
949
+ };
950
+ }
879
951
  await this.migrateAndRecord(scopeId);
880
952
  return this.buildStub(conn.tenant_id, scopeId, undefined, connectionId);
881
953
  }
@@ -974,11 +1046,15 @@ export class CloudflareScopeHost {
974
1046
  return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
975
1047
  }
976
1048
  const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId);
977
- await this.drainExecutors(tenantId, scopeId);
1049
+ const drained = await this.drainExecutors(tenantId, scopeId);
978
1050
  // #458: the operation committed having enqueued platform intents — tell the
979
1051
  // caller's harness so it can flag the response for the router kick (#381).
980
- if (envelope.platformRequests > 0)
981
- options?.onPlatformRequests?.(envelope.platformRequests);
1052
+ // Routed connector deliveries (#574 phase 3) count too: the inline drain just
1053
+ // turned this operation's event into a `connector:<provider>` intent, and the
1054
+ // kick is what collapses its dispatch latency from sweep-cadence to seconds.
1055
+ const enqueued = envelope.platformRequests + (drained.routedToPlatform ?? 0);
1056
+ if (enqueued > 0)
1057
+ options?.onPlatformRequests?.(enqueued);
982
1058
  return envelope.result;
983
1059
  },
984
1060
  };
@@ -1033,6 +1109,7 @@ export class CloudflareScopeHost {
1033
1109
  deploymentRef: r.deployment_ref,
1034
1110
  admission: r.admission,
1035
1111
  admissionNote: r.admission_note,
1112
+ origin: r.origin_json ? JSON.parse(r.origin_json) : null,
1036
1113
  createdAt: r.created_at,
1037
1114
  });
1038
1115
  const mapOrg = (r) => orgSchema.parse({
@@ -1193,7 +1270,23 @@ export class CloudflareScopeHost {
1193
1270
  `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
1194
1271
  }
1195
1272
  }
1196
- await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
1273
+ // #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
1274
+ // deployment serving it — for the shared control plane that is the vertical's
1275
+ // dispatch script, so the tuple write rides the delegation seam. Tenant-level
1276
+ // grants stay directory-side either way.
1277
+ if (this.connectorDelegation && grant.node.scopeId) {
1278
+ await this.connectorDelegation.grant({
1279
+ connectionId: grant.connectionId,
1280
+ tenantId: grant.node.tenantId,
1281
+ scopeId: grant.node.scopeId,
1282
+ vertical: conn.vertical,
1283
+ permission: grant.permission,
1284
+ expiresAt: grant.expiresAt,
1285
+ });
1286
+ }
1287
+ else {
1288
+ await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
1289
+ }
1197
1290
  await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
1198
1291
  connectionId: grant.connectionId,
1199
1292
  provider: conn.provider,
@@ -1372,16 +1465,18 @@ export class CloudflareScopeHost {
1372
1465
  const selfAdmits = owning.owner_tenant !== null && !owning.listed;
1373
1466
  // The manifest is retained for the serving upload (#286), not audited — a whole
1374
1467
  // manifest per publish would drown the admin log in bundle metadata.
1375
- const { manifestJson, ...audited } = parsed;
1468
+ const { manifestJson, origin, ...audited } = parsed;
1376
1469
  await this.cp.insertVersion({
1377
1470
  ...audited,
1378
1471
  manifestJson: manifestJson ?? null,
1472
+ originJson: origin ? JSON.stringify(origin) : null,
1379
1473
  admission: selfAdmits ? 'admitted' : 'pending',
1380
1474
  admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
1381
1475
  createdAt: new Date().toISOString(),
1382
1476
  });
1383
1477
  await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
1384
1478
  ...audited,
1479
+ ...(origin ? { origin } : {}),
1385
1480
  admission: selfAdmits ? 'admitted' : 'pending',
1386
1481
  });
1387
1482
  },
@@ -2550,5 +2645,35 @@ export class CloudflareScopeHost {
2550
2645
  async grantEntityLocal(scopeId, principal, permission, entity) {
2551
2646
  await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, null);
2552
2647
  }
2648
+ // -- the connector write-back's far end (#574) -----------------------------
2649
+ // A CP-less dispatch vertical cannot run a connector, so the shared control
2650
+ // plane runs the pass FOR it and reaches back through the platform-secret-gated
2651
+ // `/internal/connector-*` surface — these are that surface's host methods. The
2652
+ // directory gates (live connection, tenant/vertical match) ran on the platform
2653
+ // side before the call; what runs HERE is the half only this deployment can
2654
+ // enforce: the scope's own permission check against its delivered
2655
+ // `connection:<id>` tuple, in the scope's own DO. Fail closed — no grant, no
2656
+ // effect — exactly as for any other caller.
2657
+ /** Invoke ONE operation in this deployment as a CONNECTION (#574). */
2658
+ async connectorInvokeLocal(connectionId, tenantId, scopeId, operation, input) {
2659
+ await this.migrateAndRecord(scopeId);
2660
+ return this.buildStub(tenantId, scopeId, undefined, connectionId).invoke(operation, input);
2661
+ }
2662
+ /** Land provider bytes in this deployment as a CONNECTION — the bytes leg (#574). */
2663
+ async connectorAttachmentUploadLocal(connectionId, tenantId, scopeId, upload) {
2664
+ await this.migrateAndRecord(scopeId);
2665
+ const store = await this.resolveAttachmentStore(tenantId);
2666
+ return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store).upload(upload);
2667
+ }
2668
+ /**
2669
+ * The delivery half of `grantToConnection` for a scope served HERE (#574): write
2670
+ * the scope-local `connection:<id>` grant tuple the permission checker reads.
2671
+ * Revocation needs no mirror verb — every delegated call re-passes the platform's
2672
+ * live-connection gate first, so revoking the connection closes the door even
2673
+ * while the tuple remains; `expiresAt` bounds the tuple itself.
2674
+ */
2675
+ async connectorGrantLocal(connectionId, scopeId, permission, expiresAt) {
2676
+ await this.writeScopeTuple(scopeId, subjectRef({ kind: 'connection', id: connectionId }), `granted:${permission}`, `scope:${scopeId}`, expiresAt ?? null);
2677
+ }
2553
2678
  }
2554
2679
  //# sourceMappingURL=host.js.map