@substrat-run/adapter-cloudflare 0.54.0 → 0.56.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 +3 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +6 -2
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +66 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +134 -9
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +24 -0
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
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
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|