@twin.org/dataspace-control-plane-service 0.9.0 → 0.9.1-next.10

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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/dist/es/dataspaceControlPlaneRoutes.js +79 -7
  3. package/dist/es/dataspaceControlPlaneRoutes.js.map +1 -1
  4. package/dist/es/dataspaceControlPlaneService.js +121 -356
  5. package/dist/es/dataspaceControlPlaneService.js.map +1 -1
  6. package/dist/es/factories/transferHandlerFactory.js +11 -0
  7. package/dist/es/factories/transferHandlerFactory.js.map +1 -0
  8. package/dist/es/handlers/httpDataPostTransferHandler.js +111 -0
  9. package/dist/es/handlers/httpDataPostTransferHandler.js.map +1 -0
  10. package/dist/es/handlers/httpDataPullTransferHandler.js +108 -0
  11. package/dist/es/handlers/httpDataPullTransferHandler.js.map +1 -0
  12. package/dist/es/handlers/httpDataPushTransferHandler.js +114 -0
  13. package/dist/es/handlers/httpDataPushTransferHandler.js.map +1 -0
  14. package/dist/es/index.js +7 -0
  15. package/dist/es/index.js.map +1 -1
  16. package/dist/es/models/IDataspaceControlPlaneServiceConfig.js.map +1 -1
  17. package/dist/es/models/ITransferHandler.js +2 -0
  18. package/dist/es/models/ITransferHandler.js.map +1 -0
  19. package/dist/es/models/ITransferHandlerPrepareContext.js +4 -0
  20. package/dist/es/models/ITransferHandlerPrepareContext.js.map +1 -0
  21. package/dist/es/models/ITransferHandlerStartContext.js +2 -0
  22. package/dist/es/models/ITransferHandlerStartContext.js.map +1 -0
  23. package/dist/types/dataspaceControlPlaneRoutes.d.ts +1 -1
  24. package/dist/types/dataspaceControlPlaneService.d.ts +6 -1
  25. package/dist/types/factories/transferHandlerFactory.d.ts +8 -0
  26. package/dist/types/handlers/httpDataPostTransferHandler.d.ts +63 -0
  27. package/dist/types/handlers/httpDataPullTransferHandler.d.ts +63 -0
  28. package/dist/types/handlers/httpDataPushTransferHandler.d.ts +68 -0
  29. package/dist/types/index.d.ts +7 -0
  30. package/dist/types/models/IDataspaceControlPlaneServiceConfig.d.ts +10 -5
  31. package/dist/types/models/ITransferHandler.d.ts +62 -0
  32. package/dist/types/models/ITransferHandlerPrepareContext.d.ts +21 -0
  33. package/dist/types/models/ITransferHandlerStartContext.d.ts +31 -0
  34. package/docs/changelog.md +190 -0
  35. package/docs/reference/classes/DataspaceControlPlaneService.md +18 -0
  36. package/docs/reference/classes/HttpDataPostTransferHandler.md +235 -0
  37. package/docs/reference/classes/HttpDataPullTransferHandler.md +235 -0
  38. package/docs/reference/classes/HttpDataPushTransferHandler.md +242 -0
  39. package/docs/reference/index.md +7 -0
  40. package/docs/reference/interfaces/IDataspaceControlPlaneServiceConfig.md +10 -5
  41. package/docs/reference/interfaces/ITransferHandler.md +180 -0
  42. package/docs/reference/interfaces/ITransferHandlerPrepareContext.md +35 -0
  43. package/docs/reference/interfaces/ITransferHandlerStartContext.md +51 -0
  44. package/docs/reference/variables/TransferHandlerFactory.md +7 -0
  45. package/locales/en.json +14 -11
  46. package/package.json +18 -18
@@ -9,12 +9,15 @@ import { EngineCoreFactory } from "@twin.org/engine-models";
9
9
  import { ComparisonOperator } from "@twin.org/entity";
10
10
  import { EntityStorageConnectorFactory } from "@twin.org/entity-storage-models";
11
11
  import { OdrlPolicyHelper, PolicyRequesterFactory } from "@twin.org/rights-management-models";
12
- import { DataspaceProtocolContexts, DataspaceProtocolContractNegotiationTypes, DataspaceProtocolEndpointType, DataspaceProtocolHelper, DataspaceProtocolTransferProcessStateType, DataspaceProtocolTransferProcessTypes } from "@twin.org/standards-dataspace-protocol";
12
+ import { DataspaceProtocolContexts, DataspaceProtocolContractNegotiationTypes, DataspaceProtocolHelper, DataspaceProtocolTransferProcessStateType, DataspaceProtocolTransferProcessTypes, DataspaceProtocolVersionBindingType } from "@twin.org/standards-dataspace-protocol";
13
13
  import { OdrlActionType, OdrlContexts, OdrlPolicyType } from "@twin.org/standards-w3c-odrl";
14
14
  import { MetricHelper } from "@twin.org/telemetry-models";
15
15
  import { TrustHelper } from "@twin.org/trust-models";
16
16
  import { DataspaceControlPlanePolicyRequester } from "./dataspaceControlPlanePolicyRequester.js";
17
- import { EndpointProperties } from "./models/endpointProperties.js";
17
+ import { TransferHandlerFactory } from "./factories/transferHandlerFactory.js";
18
+ import { HttpDataPostTransferHandler } from "./handlers/httpDataPostTransferHandler.js";
19
+ import { HttpDataPullTransferHandler } from "./handlers/httpDataPullTransferHandler.js";
20
+ import { HttpDataPushTransferHandler } from "./handlers/httpDataPushTransferHandler.js";
18
21
  import { isCatalogError, isCatalogErrorName, transformToTransferError } from "./utils/transferErrorUtils.js";
19
22
  /**
20
23
  * Dataspace Control Plane Service implementation.
@@ -108,11 +111,10 @@ export class DataspaceControlPlaneService {
108
111
  */
109
112
  _callbackPath;
110
113
  /**
111
- * Factory key used to look up the data plane component. Resolved lazily at push-time
112
- * (not at construction) so the data plane may register after the control plane is built.
114
+ * Data plane component.
113
115
  * @internal
114
116
  */
115
- _dataPlaneComponentType;
117
+ _dataPlaneComponent;
116
118
  /**
117
119
  * Policy requester instance for handling negotiation callbacks.
118
120
  * @internal
@@ -190,11 +192,11 @@ export class DataspaceControlPlaneService {
190
192
  this._trustComponent = ComponentFactory.get(options?.trustComponentType ?? "trust");
191
193
  this._overrideTrustGeneratorType = options?.config?.overrideTrustGeneratorType;
192
194
  this._dataPlanePath = Is.stringValue(options?.config?.dataPlanePath)
193
- ? StringHelper.trimTrailingSlashes(StringHelper.trimLeadingSlashes(options.config.dataPlanePath))
195
+ ? StringHelper.trimLeadingAndTrailingSlashes(options.config.dataPlanePath)
194
196
  : undefined;
195
197
  this._callbackPath = Is.stringValue(options?.config?.callbackPath)
196
198
  ? StringHelper.trimLeadingAndTrailingSlashes(options.config.callbackPath)
197
- : undefined;
199
+ : "dataspace-control-plane";
198
200
  this._autoStartTransfers = options?.config?.autoStartTransfers ?? false;
199
201
  this._stalledNegotiationTimeoutMs =
200
202
  options?.config?.stalledNegotiationTimeoutMs ??
@@ -205,14 +207,7 @@ export class DataspaceControlPlaneService {
205
207
  this._taskScheduler = ComponentFactory.getIfExists(options?.taskSchedulerComponentType ?? "task-scheduler");
206
208
  this._platformComponent = ComponentFactory.get(options?.platformComponentType ?? "platform");
207
209
  this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
208
- // Data plane component is optional and resolved lazily. The control plane is initialised
209
- // BEFORE the data plane in engine startup order, so `getRegisteredInstanceTypeOptional`
210
- // returns undefined when the engine constructs us — the wiring override can't fire here.
211
- // The default therefore matches the engine's actual factory key
212
- // (`nameofKebabCase(DataspaceDataPlaneService)`) so the late-bound `requireDataPlane()`
213
- // lookup at push time finds it regardless of init order.
214
- this._dataPlaneComponentType =
215
- options?.dataPlaneComponentType ?? "dataspace-data-plane-service";
210
+ this._dataPlaneComponent = ComponentFactory.get(options?.dataPlaneComponentType ?? "dataspace-data-plane-service");
216
211
  this._negotiationCallbacks = new Map();
217
212
  this._transferCallbacks = new Map();
218
213
  this._internalTransferCallback = this.createInternalTransferCallback();
@@ -221,6 +216,9 @@ export class DataspaceControlPlaneService {
221
216
  const internalCallback = this.createInternalCallback();
222
217
  this._policyRequester = new DataspaceControlPlanePolicyRequester(options?.loggingComponentType, internalCallback);
223
218
  PolicyRequesterFactory.register(DataspaceControlPlaneService._REQUESTER_TYPE, () => this._policyRequester);
219
+ TransferHandlerFactory.register(DataspaceTransferFormat.HttpDataPull, () => new HttpDataPullTransferHandler());
220
+ TransferHandlerFactory.register(DataspaceTransferFormat.HttpDataPush, () => new HttpDataPushTransferHandler());
221
+ TransferHandlerFactory.register(DataspaceTransferFormat.HttpDataPost, () => new HttpDataPostTransferHandler());
224
222
  }
225
223
  /**
226
224
  * Returns the class name of the component.
@@ -283,72 +281,6 @@ export class DataspaceControlPlaneService {
283
281
  });
284
282
  return;
285
283
  }
286
- await this._loggingComponent?.log({
287
- level: "info",
288
- ts: Date.now(),
289
- source: DataspaceControlPlaneService.CLASS_NAME,
290
- message: "populatingFederatedCatalogue"
291
- });
292
- let registeredCount = 0;
293
- let errorCount = 0;
294
- let totalDatasets = 0;
295
- // The platform component execute is used so that the dataset publication runs in the
296
- // tenant context, also works in single tenant mode
297
- await this._platformComponent.execute(async () => {
298
- // The tenant context id is set here for each system tenant
299
- let cursor;
300
- do {
301
- const page = await this._dataspaceAppDatasetStorage.query(undefined, undefined, undefined, cursor);
302
- if (Is.arrayValue(page.entities)) {
303
- for (const entity of page.entities) {
304
- const appDataset = entity;
305
- totalDatasets++;
306
- try {
307
- await this.publishAppDataset(appDataset);
308
- registeredCount++;
309
- await this._loggingComponent?.log({
310
- level: "debug",
311
- ts: Date.now(),
312
- source: DataspaceControlPlaneService.CLASS_NAME,
313
- message: "datasetRegistered",
314
- data: {
315
- datasetId: appDataset.id,
316
- appId: appDataset.appId,
317
- tenantId: appDataset.tenantId
318
- }
319
- });
320
- }
321
- catch (error) {
322
- errorCount++;
323
- await this._loggingComponent?.log({
324
- level: "error",
325
- ts: Date.now(),
326
- source: DataspaceControlPlaneService.CLASS_NAME,
327
- message: "datasetPublishFailed",
328
- error: BaseError.fromError(error),
329
- data: {
330
- datasetId: appDataset.id,
331
- appId: appDataset.appId,
332
- tenantId: appDataset.tenantId
333
- }
334
- });
335
- }
336
- }
337
- }
338
- cursor = page.cursor;
339
- } while (Is.stringValue(cursor));
340
- });
341
- await this._loggingComponent?.log({
342
- level: "info",
343
- ts: Date.now(),
344
- source: DataspaceControlPlaneService.CLASS_NAME,
345
- message: "federatedCataloguePopulated",
346
- data: {
347
- registeredCount,
348
- errorCount,
349
- totalDatasets
350
- }
351
- });
352
284
  if (this._taskScheduler) {
353
285
  await this._taskScheduler.addTask("control-plane-negotiation-cleanup", [
354
286
  {
@@ -562,12 +494,7 @@ export class DataspaceControlPlaneService {
562
494
  // Callback the provider POSTs DSP messages back to: mount path + `?organization=` so the inbound
563
495
  // POST routes to the right consumer tenant (mirrors buildCallbackUrl). The bare `origin` is kept for
564
496
  // the PUSH-inbox base below (a different mount).
565
- let callbackAddress = Is.stringValue(this._callbackPath)
566
- ? `${origin}/${this._callbackPath}`
567
- : origin;
568
- if (Is.stringValue(organizationIdentity)) {
569
- callbackAddress = HttpUrlHelper.addQueryStringParam(callbackAddress, ContextIdKeys.Organization, organizationIdentity);
570
- }
497
+ const callbackAddress = HttpUrlHelper.addQueryStringParam(Is.stringValue(this._callbackPath) ? `${origin}/${this._callbackPath}` : origin, ContextIdKeys.Organization, organizationIdentity);
571
498
  const transferRequestMessage = {
572
499
  "@context": [DataspaceProtocolContexts.Context],
573
500
  "@type": DataspaceProtocolTransferProcessTypes.TransferRequestMessage,
@@ -576,23 +503,15 @@ export class DataspaceControlPlaneService {
576
503
  callbackAddress,
577
504
  format
578
505
  };
579
- // For consumer-initiated PUSH transfers the consumer must supply its /inbox endpoint as
580
- // dataAddress so the provider knows where to push ActivityStreams objects. Without it the
581
- // provider silently falls through to PULL mode on startTransfer.
582
- if (format === DataspaceTransferFormat.HttpDataPush) {
583
- if (!Is.stringValue(this._dataPlanePath)) {
584
- throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid });
585
- }
586
- let inboxEndpoint = `${origin}/${this._dataPlanePath}/inbox`;
587
- if (Is.stringValue(organizationIdentity)) {
588
- inboxEndpoint = HttpUrlHelper.addQueryStringParam(inboxEndpoint, ContextIdKeys.Organization, organizationIdentity);
589
- }
590
- transferRequestMessage.dataAddress = {
591
- "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
592
- endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
593
- endpoint: inboxEndpoint
594
- };
595
- }
506
+ // Delegate consumer dataAddress construction to the format-specific handler.
507
+ // PUSH supplies its /inbox; PULL and POST return undefined (no consumer address needed).
508
+ const transferHandler = TransferHandlerFactory.get(format);
509
+ transferRequestMessage.dataAddress = transferHandler.buildConsumerDataAddress({
510
+ consumerPid,
511
+ origin,
512
+ dataPlanePath: this._dataPlanePath,
513
+ organizationIdentity
514
+ });
596
515
  // Generate outbound trust token to authenticate this node to the provider.
597
516
  const outboundToken = await this._trustComponent.generate(organizationIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } });
598
517
  // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
@@ -726,200 +645,32 @@ export class DataspaceControlPlaneService {
726
645
  consumerPid: entity.consumerPid,
727
646
  providerPid: entity.providerPid
728
647
  };
729
- // ============================================================================
730
- // PULL vs PUSH Transfer Mode Detection (DSP Protocol)
731
- // ============================================================================
732
- // The transfer mode is determined by whether the consumer provided a dataAddress
733
- // in the original TransferRequestMessage:
734
- //
735
- // PULL Mode (dataAddress NOT provided by consumer):
736
- // - Consumer requests data but doesn't specify where to receive it
737
- // - Provider generates access token and returns dataAddress in TransferStartMessage
738
- // - Consumer uses the returned endpoint + token to PULL data from provider
739
- // - Flow: Consumer → GET /entities?consumerPid=X (with Bearer token) → Provider
740
- //
741
- // PUSH Mode (dataAddress PROVIDED by consumer):
742
- // - Consumer specifies endpoint where they want data sent (e.g., webhook URL)
743
- // - Provider will PUSH data to the consumer's specified endpoint
744
- // - Flow: Provider → POST to consumer's dataAddress endpoint → Consumer
745
- //
746
- // See: https://eclipse-dataspace-protocol-base.github.io/DataspaceProtocol/2025-1-err1/#transfer-start-message
747
- // ============================================================================
748
- if (Is.empty(entity.dataAddress) && entity.format === DataspaceTransferFormat.HttpDataPost) {
749
- // PROVIDER-INITIATED PUSH: Consumer requested push but did not supply an /inbox.
750
- // Provider returns its own /inbox URL + a signed JWT so the consumer can verify
751
- // the incoming activities (HttpData-POST / DataspaceTransferFormat.HttpDataPost).
752
- if (!Is.stringValue(this._dataPlanePath)) {
753
- return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid: entity.consumerPid }), message);
754
- }
755
- if (!Is.stringValue(entity.providerIdentity)) {
756
- throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
757
- }
758
- const pushProviderId = entity.providerIdentity;
759
- const accessToken = await this._trustComponent.generate(pushProviderId, this._overrideTrustGeneratorType, {
760
- subject: {
761
- consumerPid: entity.consumerPid,
762
- providerPid: entity.providerPid,
763
- agreementId: entity.agreementId,
764
- datasetId: entity.datasetId
765
- }
766
- });
767
- Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "accessToken", accessToken);
768
- const tokenString = accessToken;
769
- let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}/inbox`;
770
- // Bake the provider's organization identity into the /inbox URL so the consumer's
771
- // inbound POST routes to the right organization via TenantProcessor — mirrors the
772
- // pull-mode endpoint baking below.
773
- const organizationIdentity = await this.resolveContextOrganizationId();
774
- fullEndpoint = HttpUrlHelper.addQueryStringParam(fullEndpoint, ContextIdKeys.Organization, organizationIdentity);
775
- response.dataAddress = {
776
- "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
777
- endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
778
- endpoint: fullEndpoint,
779
- endpointProperties: [
780
- {
781
- "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
782
- name: EndpointProperties.Authorization,
783
- value: tokenString
784
- },
785
- {
786
- "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
787
- name: EndpointProperties.AuthType,
788
- value: "bearer"
789
- }
790
- ]
791
- };
792
- await this._loggingComponent?.log({
793
- level: "info",
794
- source: DataspaceControlPlaneService.CLASS_NAME,
795
- ts: Date.now(),
796
- message: "pushTransferStarted",
797
- data: {
798
- consumerPid: entity.consumerPid,
799
- providerPid: entity.providerPid,
800
- endpoint: fullEndpoint,
801
- transferMode: DataspaceTransferFormat.HttpDataPost
802
- }
803
- });
804
- }
805
- else if (Is.empty(entity.dataAddress)) {
806
- if (!Is.stringValue(this._dataPlanePath)) {
807
- return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pullTransfersNotSupported", {
808
- consumerPid: entity.consumerPid,
809
- providerPid: entity.providerPid
810
- }), message);
811
- }
812
- // Provider signs the data access token with its own identity.
813
- // The subject contains the transfer context claims that the data plane
814
- // will verify when the consumer presents this token
815
- if (!Is.stringValue(entity.providerIdentity)) {
816
- throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
817
- }
818
- const pullProviderId = entity.providerIdentity;
819
- const accessToken = await this._trustComponent.generate(pullProviderId, this._overrideTrustGeneratorType, {
820
- subject: {
821
- consumerPid: entity.consumerPid,
822
- providerPid: entity.providerPid,
823
- agreementId: entity.agreementId,
824
- datasetId: entity.datasetId
825
- }
826
- });
827
- Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "accessToken", accessToken);
828
- const tokenString = accessToken;
829
- let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}`;
830
- const organizationIdentity = await this.resolveContextOrganizationId();
831
- fullEndpoint = HttpUrlHelper.addQueryStringParam(fullEndpoint, ContextIdKeys.Organization, organizationIdentity);
832
- response.dataAddress = {
833
- "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
834
- endpointType: DataspaceProtocolEndpointType.HttpsQueryEndpoint,
835
- endpoint: fullEndpoint,
836
- endpointProperties: [
837
- {
838
- "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
839
- name: EndpointProperties.Authorization,
840
- value: tokenString
841
- },
842
- {
843
- "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
844
- name: EndpointProperties.AuthType,
845
- value: "bearer"
846
- }
847
- ]
848
- };
849
- await this._loggingComponent?.log({
850
- level: "info",
851
- source: DataspaceControlPlaneService.CLASS_NAME,
852
- ts: Date.now(),
853
- message: "dataAccessTokenGenerated",
854
- data: {
855
- consumerPid: entity.consumerPid,
856
- providerPid: entity.providerPid,
857
- endpoint: fullEndpoint,
858
- transferMode: "PULL"
859
- }
860
- });
648
+ // Delegate dataAddress construction and post-start actions to the format-specific handler.
649
+ // Push subscription setup reads the entity from storage and requires state=STARTED,
650
+ // so STARTED is persisted before calling onProviderStart. If subscription setup fails,
651
+ // the state is rolled back so the client can retry.
652
+ const organizationIdentity = await this.resolveContextOrganizationId();
653
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
654
+ response.dataAddress = await transferHandler.buildProviderStartDataAddress({
655
+ entity,
656
+ publicOrigin,
657
+ dataPlanePath: this._dataPlanePath,
658
+ organizationIdentity,
659
+ trustComponent: this._trustComponent,
660
+ overrideTrustGeneratorType: this._overrideTrustGeneratorType
661
+ });
662
+ entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
663
+ entity.dateModified = new Date();
664
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
665
+ try {
666
+ await transferHandler.onProviderStart(this._dataPlaneComponent, entity.consumerPid, previousState);
861
667
  }
862
- else {
863
- // PUSH MODE (consumer-initiated): Consumer provided their /inbox endpoint in
864
- // the TransferRequestMessage.dataAddress. Provider responds with its own /inbox
865
- // URL so the consumer knows where to route data notifications.
866
- if (!Is.stringValue(entity.dataAddress?.endpoint) ||
867
- !Is.stringValue(entity.dataAddress?.endpointType)) {
868
- return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidPushDataAddress", {
869
- consumerPid: entity.consumerPid
870
- }), message);
871
- }
872
- if (!Is.stringValue(this._dataPlanePath)) {
873
- return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid: entity.consumerPid }), message);
874
- }
875
- let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}/inbox`;
876
- // Bake the provider's organization identity into the /inbox URL so the consumer's
877
- // inbound POST routes to the right organization via TenantProcessor — mirrors the
878
- // pull-mode endpoint baking.
879
- const organizationIdentity = await this.resolveContextOrganizationId();
880
- fullEndpoint = HttpUrlHelper.addQueryStringParam(fullEndpoint, ContextIdKeys.Organization, organizationIdentity);
881
- response.dataAddress = {
882
- "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
883
- endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
884
- endpoint: fullEndpoint
885
- };
886
- await this._loggingComponent?.log({
887
- level: "info",
888
- source: DataspaceControlPlaneService.CLASS_NAME,
889
- ts: Date.now(),
890
- message: "pushTransferStarted",
891
- data: {
892
- consumerPid: entity.consumerPid,
893
- providerPid: entity.providerPid,
894
- endpoint: fullEndpoint,
895
- transferMode: DataspaceTransferFormat.HttpDataPush
896
- }
897
- });
898
- const dataPlane = this.requireDataPlane();
899
- // Push subscription setup reads the entity from storage and requires state=STARTED.
900
- // Persist STARTED before the data-plane call, and roll back if subscription setup
901
- // fails so the row doesn't leak to STARTED on a setup-time error.
902
- entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
668
+ catch (subscriptionError) {
669
+ entity.state = previousState;
903
670
  entity.dateModified = new Date();
904
671
  await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
905
- try {
906
- if (previousState === DataspaceProtocolTransferProcessStateType.REQUESTED) {
907
- await dataPlane.setupPushSubscription(entity.consumerPid);
908
- }
909
- else if (previousState === DataspaceProtocolTransferProcessStateType.SUSPENDED) {
910
- await dataPlane.resumePushSubscription(entity.consumerPid);
911
- }
912
- }
913
- catch (subscriptionError) {
914
- entity.state = previousState;
915
- entity.dateModified = new Date();
916
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
917
- throw subscriptionError;
918
- }
672
+ throw subscriptionError;
919
673
  }
920
- entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
921
- entity.dateModified = new Date();
922
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
923
674
  await this._loggingComponent?.log({
924
675
  level: "info",
925
676
  source: DataspaceControlPlaneService.CLASS_NAME,
@@ -1050,19 +801,18 @@ export class DataspaceControlPlaneService {
1050
801
  role
1051
802
  }
1052
803
  });
1053
- if (this.isPushFormat(entity.format)) {
1054
- try {
1055
- await this.requireDataPlane().teardownPushSubscription(entity.consumerPid);
1056
- }
1057
- catch (teardownError) {
1058
- // Symmetric rollback: data-plane teardown failed, revert the transfer state
1059
- // so the client can retry. Without this the storage row is COMPLETED but
1060
- // the subscription is still flowing, and a retry hits invalidStateForComplete.
1061
- entity.state = previousState;
1062
- entity.dateModified = new Date();
1063
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
1064
- throw teardownError;
1065
- }
804
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
805
+ try {
806
+ await transferHandler.onComplete(this._dataPlaneComponent, entity.consumerPid);
807
+ }
808
+ catch (teardownError) {
809
+ // Symmetric rollback: data-plane teardown failed, revert the transfer state
810
+ // so the client can retry. Without this the storage row is COMPLETED but
811
+ // the subscription is still flowing, and a retry hits invalidStateForComplete.
812
+ entity.state = previousState;
813
+ entity.dateModified = new Date();
814
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
815
+ throw teardownError;
1066
816
  }
1067
817
  // Completion is consumer-initiated (the auth above accepts only the consumer): a consumer→provider
1068
818
  // notification, so no provider→consumer delivery here — unlike suspend/terminate (either party).
@@ -1147,18 +897,17 @@ export class DataspaceControlPlaneService {
1147
897
  reason: message.reason
1148
898
  }
1149
899
  });
1150
- if (this.isPushFormat(entity.format)) {
1151
- try {
1152
- await this.requireDataPlane().suspendPushSubscription(entity.consumerPid);
1153
- }
1154
- catch (suspendError) {
1155
- // Symmetric rollback: data-plane suspend failed, revert transfer state so
1156
- // a retry can repair the subscription instead of failing invalidStateForSuspend.
1157
- entity.state = previousState;
1158
- entity.dateModified = new Date();
1159
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
1160
- throw suspendError;
1161
- }
900
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
901
+ try {
902
+ await transferHandler.onSuspend(this._dataPlaneComponent, entity.consumerPid);
903
+ }
904
+ catch (suspendError) {
905
+ // Symmetric rollback: data-plane suspend failed, revert transfer state so
906
+ // a retry can repair the subscription instead of failing invalidStateForSuspend.
907
+ entity.state = previousState;
908
+ entity.dateModified = new Date();
909
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
910
+ throw suspendError;
1162
911
  }
1163
912
  if (role === TransferProcessRole.Consumer) {
1164
913
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.SUSPENDED);
@@ -1243,19 +992,18 @@ export class DataspaceControlPlaneService {
1243
992
  reason: message.reason
1244
993
  }
1245
994
  });
1246
- if (this.isPushFormat(entity.format)) {
1247
- try {
1248
- await this.requireDataPlane().teardownPushSubscription(entity.consumerPid);
1249
- }
1250
- catch (teardownError) {
1251
- // Symmetric rollback: data-plane teardown failed, revert transfer state so
1252
- // a retry can repair the subscription. Terminate is reachable from multiple
1253
- // states (REQUESTED/STARTED/SUSPENDED), so restore the actual previous one.
1254
- entity.state = previousState;
1255
- entity.dateModified = new Date();
1256
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
1257
- throw teardownError;
1258
- }
995
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
996
+ try {
997
+ await transferHandler.onTerminate(this._dataPlaneComponent, entity.consumerPid);
998
+ }
999
+ catch (teardownError) {
1000
+ // Symmetric rollback: data-plane teardown failed, revert transfer state so
1001
+ // a retry can repair the subscription. Terminate is reachable from multiple
1002
+ // states (REQUESTED/STARTED/SUSPENDED), so restore the actual previous one.
1003
+ entity.state = previousState;
1004
+ entity.dateModified = new Date();
1005
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
1006
+ throw teardownError;
1259
1007
  }
1260
1008
  if (role === TransferProcessRole.Consumer) {
1261
1009
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.TERMINATED);
@@ -1796,6 +1544,25 @@ export class DataspaceControlPlaneService {
1796
1544
  await MetricHelper.metricIncrement(this._telemetryComponent, DataspaceControlPlaneMetricIds.AppDatasetsDeleted);
1797
1545
  }
1798
1546
  // ============================================================================
1547
+ // DSP VERSION DISCOVERY
1548
+ // ============================================================================
1549
+ /**
1550
+ * Return the Dataspace Protocol versions supported by this connector.
1551
+ * @returns The protocol version response listing all supported DSP versions.
1552
+ */
1553
+ async getProtocolVersions() {
1554
+ return {
1555
+ protocolVersions: [
1556
+ {
1557
+ version: "2025-1",
1558
+ path: `/${this._callbackPath}`,
1559
+ binding: DataspaceProtocolVersionBindingType.HTTPS,
1560
+ serviceId: "twin-connector"
1561
+ }
1562
+ ]
1563
+ };
1564
+ }
1565
+ // ============================================================================
1799
1566
  // PRIVATE HELPER METHODS
1800
1567
  // ============================================================================
1801
1568
  /**
@@ -2149,16 +1916,6 @@ export class DataspaceControlPlaneService {
2149
1916
  }
2150
1917
  return agreement;
2151
1918
  }
2152
- /**
2153
- * Check whether a transfer format string represents a push-mode delivery.
2154
- * @param format The transfer format string from the TransferProcess entity.
2155
- * @returns True if the format is a push variant (HttpData-PUSH or HttpData-POST).
2156
- * @internal
2157
- */
2158
- isPushFormat(format) {
2159
- return (format === DataspaceTransferFormat.HttpDataPush ||
2160
- format === DataspaceTransferFormat.HttpDataPost);
2161
- }
2162
1919
  /**
2163
1920
  * Extract the dataset ID from an ODRL agreement's target.
2164
1921
  * @param agreement The ODRL agreement containing the target.
@@ -2414,20 +2171,6 @@ export class DataspaceControlPlaneService {
2414
2171
  }
2415
2172
  return dataset;
2416
2173
  }
2417
- /**
2418
- * Resolve the data plane component or throw if it isn't registered. Push-mode transfers
2419
- * require the data plane; pull-only deployments may run without it.
2420
- * @returns The data plane component.
2421
- * @throws GeneralError if the data plane component is not registered.
2422
- * @internal
2423
- */
2424
- requireDataPlane() {
2425
- const dataPlane = ComponentFactory.getIfExists(this._dataPlaneComponentType);
2426
- if (!dataPlane) {
2427
- throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "dataPlaneNotRegistered");
2428
- }
2429
- return dataPlane;
2430
- }
2431
2174
  /**
2432
2175
  * Creates the internal INegotiationCallback that fans out to all registered callbacks.
2433
2176
  * @returns The internal negotiation callback.
@@ -2639,6 +2382,28 @@ export class DataspaceControlPlaneService {
2639
2382
  const contextIds = await ContextIdStore.getContextIds();
2640
2383
  return contextIds?.[ContextIdKeys.Tenant];
2641
2384
  }
2385
+ /**
2386
+ * Resolve the transfer format for handler dispatch. When the entity already carries a
2387
+ * format string (all transfers created by requestTransfer), that value is used directly.
2388
+ * For entities seeded without a format (e.g. older storage records), the format is inferred
2389
+ * from the presence of a consumer-supplied dataAddress — matching the pre-factory dispatch
2390
+ * logic so existing records continue to work correctly.
2391
+ * @param entity The transfer process entity.
2392
+ * @returns The effective DataspaceTransferFormat for handler lookup.
2393
+ * @internal
2394
+ */
2395
+ inferTransferFormat(entity) {
2396
+ const knownFormats = Object.values(DataspaceTransferFormat);
2397
+ if (Is.stringValue(entity.format) && knownFormats.includes(entity.format)) {
2398
+ return entity.format;
2399
+ }
2400
+ // Backward-compat: entities without a recognized format string (e.g. stored before
2401
+ // the factory was introduced, or using a non-standard format value) fall back to
2402
+ // dataAddress-based dispatch matching the pre-factory logic.
2403
+ return Is.empty(entity.dataAddress)
2404
+ ? DataspaceTransferFormat.HttpDataPull
2405
+ : DataspaceTransferFormat.HttpDataPush;
2406
+ }
2642
2407
  /**
2643
2408
  * Return an existing full-access agreement for the same-organization (implicit trust) case,
2644
2409
  * or create and store one if none exists. Fires onFinalized on all registered callbacks in