@twin.org/dataspace-control-plane-service 0.9.1-next.4 → 0.9.1-next.6

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 (39) hide show
  1. package/dist/es/dataspaceControlPlaneRoutes.js +9 -5
  2. package/dist/es/dataspaceControlPlaneRoutes.js.map +1 -1
  3. package/dist/es/dataspaceControlPlaneService.js +99 -282
  4. package/dist/es/dataspaceControlPlaneService.js.map +1 -1
  5. package/dist/es/factories/transferHandlerFactory.js +11 -0
  6. package/dist/es/factories/transferHandlerFactory.js.map +1 -0
  7. package/dist/es/handlers/httpDataPostTransferHandler.js +112 -0
  8. package/dist/es/handlers/httpDataPostTransferHandler.js.map +1 -0
  9. package/dist/es/handlers/httpDataPullTransferHandler.js +109 -0
  10. package/dist/es/handlers/httpDataPullTransferHandler.js.map +1 -0
  11. package/dist/es/handlers/httpDataPushTransferHandler.js +118 -0
  12. package/dist/es/handlers/httpDataPushTransferHandler.js.map +1 -0
  13. package/dist/es/index.js +7 -0
  14. package/dist/es/index.js.map +1 -1
  15. package/dist/es/models/ITransferHandler.js +2 -0
  16. package/dist/es/models/ITransferHandler.js.map +1 -0
  17. package/dist/es/models/ITransferHandlerPrepareContext.js +4 -0
  18. package/dist/es/models/ITransferHandlerPrepareContext.js.map +1 -0
  19. package/dist/es/models/ITransferHandlerStartContext.js +2 -0
  20. package/dist/es/models/ITransferHandlerStartContext.js.map +1 -0
  21. package/dist/types/factories/transferHandlerFactory.d.ts +8 -0
  22. package/dist/types/handlers/httpDataPostTransferHandler.d.ts +63 -0
  23. package/dist/types/handlers/httpDataPullTransferHandler.d.ts +63 -0
  24. package/dist/types/handlers/httpDataPushTransferHandler.d.ts +68 -0
  25. package/dist/types/index.d.ts +7 -0
  26. package/dist/types/models/ITransferHandler.d.ts +62 -0
  27. package/dist/types/models/ITransferHandlerPrepareContext.d.ts +21 -0
  28. package/dist/types/models/ITransferHandlerStartContext.d.ts +31 -0
  29. package/docs/changelog.md +28 -0
  30. package/docs/reference/classes/HttpDataPostTransferHandler.md +235 -0
  31. package/docs/reference/classes/HttpDataPullTransferHandler.md +235 -0
  32. package/docs/reference/classes/HttpDataPushTransferHandler.md +242 -0
  33. package/docs/reference/index.md +7 -0
  34. package/docs/reference/interfaces/ITransferHandler.md +180 -0
  35. package/docs/reference/interfaces/ITransferHandlerPrepareContext.md +35 -0
  36. package/docs/reference/interfaces/ITransferHandlerStartContext.md +51 -0
  37. package/docs/reference/variables/TransferHandlerFactory.md +7 -0
  38. package/locales/en.json +13 -7
  39. package/package.json +2 -2
@@ -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, DataspaceProtocolVersionBindingType } 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
@@ -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.
@@ -576,23 +574,15 @@ export class DataspaceControlPlaneService {
576
574
  callbackAddress,
577
575
  format
578
576
  };
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
- }
577
+ // Delegate consumer dataAddress construction to the format-specific handler.
578
+ // PUSH supplies its /inbox; PULL and POST return undefined (no consumer address needed).
579
+ const transferHandler = TransferHandlerFactory.get(format);
580
+ transferRequestMessage.dataAddress = transferHandler.buildConsumerDataAddress({
581
+ consumerPid,
582
+ origin,
583
+ dataPlanePath: this._dataPlanePath,
584
+ organizationIdentity
585
+ });
596
586
  // Generate outbound trust token to authenticate this node to the provider.
597
587
  const outboundToken = await this._trustComponent.generate(organizationIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } });
598
588
  // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
@@ -726,200 +716,32 @@ export class DataspaceControlPlaneService {
726
716
  consumerPid: entity.consumerPid,
727
717
  providerPid: entity.providerPid
728
718
  };
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
- });
719
+ // Delegate dataAddress construction and post-start actions to the format-specific handler.
720
+ // Push subscription setup reads the entity from storage and requires state=STARTED,
721
+ // so STARTED is persisted before calling onProviderStart. If subscription setup fails,
722
+ // the state is rolled back so the client can retry.
723
+ const organizationIdentity = await this.resolveContextOrganizationId();
724
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
725
+ response.dataAddress = await transferHandler.buildProviderStartDataAddress({
726
+ entity,
727
+ publicOrigin,
728
+ dataPlanePath: this._dataPlanePath,
729
+ organizationIdentity,
730
+ trustComponent: this._trustComponent,
731
+ overrideTrustGeneratorType: this._overrideTrustGeneratorType
732
+ });
733
+ entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
734
+ entity.dateModified = new Date();
735
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
736
+ try {
737
+ await transferHandler.onProviderStart(this._dataPlaneComponent, entity.consumerPid, previousState);
861
738
  }
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;
739
+ catch (subscriptionError) {
740
+ entity.state = previousState;
903
741
  entity.dateModified = new Date();
904
742
  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
- }
743
+ throw subscriptionError;
919
744
  }
920
- entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
921
- entity.dateModified = new Date();
922
- await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
923
745
  await this._loggingComponent?.log({
924
746
  level: "info",
925
747
  source: DataspaceControlPlaneService.CLASS_NAME,
@@ -1050,19 +872,18 @@ export class DataspaceControlPlaneService {
1050
872
  role
1051
873
  }
1052
874
  });
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
- }
875
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
876
+ try {
877
+ await transferHandler.onComplete(this._dataPlaneComponent, entity.consumerPid);
878
+ }
879
+ catch (teardownError) {
880
+ // Symmetric rollback: data-plane teardown failed, revert the transfer state
881
+ // so the client can retry. Without this the storage row is COMPLETED but
882
+ // the subscription is still flowing, and a retry hits invalidStateForComplete.
883
+ entity.state = previousState;
884
+ entity.dateModified = new Date();
885
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
886
+ throw teardownError;
1066
887
  }
1067
888
  // Completion is consumer-initiated (the auth above accepts only the consumer): a consumer→provider
1068
889
  // notification, so no provider→consumer delivery here — unlike suspend/terminate (either party).
@@ -1147,18 +968,17 @@ export class DataspaceControlPlaneService {
1147
968
  reason: message.reason
1148
969
  }
1149
970
  });
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
- }
971
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
972
+ try {
973
+ await transferHandler.onSuspend(this._dataPlaneComponent, entity.consumerPid);
974
+ }
975
+ catch (suspendError) {
976
+ // Symmetric rollback: data-plane suspend failed, revert transfer state so
977
+ // a retry can repair the subscription instead of failing invalidStateForSuspend.
978
+ entity.state = previousState;
979
+ entity.dateModified = new Date();
980
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
981
+ throw suspendError;
1162
982
  }
1163
983
  if (role === TransferProcessRole.Consumer) {
1164
984
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.SUSPENDED);
@@ -1243,19 +1063,18 @@ export class DataspaceControlPlaneService {
1243
1063
  reason: message.reason
1244
1064
  }
1245
1065
  });
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
- }
1066
+ const transferHandler = TransferHandlerFactory.get(this.inferTransferFormat(entity));
1067
+ try {
1068
+ await transferHandler.onTerminate(this._dataPlaneComponent, entity.consumerPid);
1069
+ }
1070
+ catch (teardownError) {
1071
+ // Symmetric rollback: data-plane teardown failed, revert transfer state so
1072
+ // a retry can repair the subscription. Terminate is reachable from multiple
1073
+ // states (REQUESTED/STARTED/SUSPENDED), so restore the actual previous one.
1074
+ entity.state = previousState;
1075
+ entity.dateModified = new Date();
1076
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
1077
+ throw teardownError;
1259
1078
  }
1260
1079
  if (role === TransferProcessRole.Consumer) {
1261
1080
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.TERMINATED);
@@ -2168,16 +1987,6 @@ export class DataspaceControlPlaneService {
2168
1987
  }
2169
1988
  return agreement;
2170
1989
  }
2171
- /**
2172
- * Check whether a transfer format string represents a push-mode delivery.
2173
- * @param format The transfer format string from the TransferProcess entity.
2174
- * @returns True if the format is a push variant (HttpData-PUSH or HttpData-POST).
2175
- * @internal
2176
- */
2177
- isPushFormat(format) {
2178
- return (format === DataspaceTransferFormat.HttpDataPush ||
2179
- format === DataspaceTransferFormat.HttpDataPost);
2180
- }
2181
1990
  /**
2182
1991
  * Extract the dataset ID from an ODRL agreement's target.
2183
1992
  * @param agreement The ODRL agreement containing the target.
@@ -2433,20 +2242,6 @@ export class DataspaceControlPlaneService {
2433
2242
  }
2434
2243
  return dataset;
2435
2244
  }
2436
- /**
2437
- * Resolve the data plane component or throw if it isn't registered. Push-mode transfers
2438
- * require the data plane; pull-only deployments may run without it.
2439
- * @returns The data plane component.
2440
- * @throws GeneralError if the data plane component is not registered.
2441
- * @internal
2442
- */
2443
- requireDataPlane() {
2444
- const dataPlane = ComponentFactory.getIfExists(this._dataPlaneComponentType);
2445
- if (!dataPlane) {
2446
- throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "dataPlaneNotRegistered");
2447
- }
2448
- return dataPlane;
2449
- }
2450
2245
  /**
2451
2246
  * Creates the internal INegotiationCallback that fans out to all registered callbacks.
2452
2247
  * @returns The internal negotiation callback.
@@ -2658,6 +2453,28 @@ export class DataspaceControlPlaneService {
2658
2453
  const contextIds = await ContextIdStore.getContextIds();
2659
2454
  return contextIds?.[ContextIdKeys.Tenant];
2660
2455
  }
2456
+ /**
2457
+ * Resolve the transfer format for handler dispatch. When the entity already carries a
2458
+ * format string (all transfers created by requestTransfer), that value is used directly.
2459
+ * For entities seeded without a format (e.g. older storage records), the format is inferred
2460
+ * from the presence of a consumer-supplied dataAddress — matching the pre-factory dispatch
2461
+ * logic so existing records continue to work correctly.
2462
+ * @param entity The transfer process entity.
2463
+ * @returns The effective DataspaceTransferFormat for handler lookup.
2464
+ * @internal
2465
+ */
2466
+ inferTransferFormat(entity) {
2467
+ const knownFormats = Object.values(DataspaceTransferFormat);
2468
+ if (Is.stringValue(entity.format) && knownFormats.includes(entity.format)) {
2469
+ return entity.format;
2470
+ }
2471
+ // Backward-compat: entities without a recognized format string (e.g. stored before
2472
+ // the factory was introduced, or using a non-standard format value) fall back to
2473
+ // dataAddress-based dispatch matching the pre-factory logic.
2474
+ return Is.empty(entity.dataAddress)
2475
+ ? DataspaceTransferFormat.HttpDataPull
2476
+ : DataspaceTransferFormat.HttpDataPush;
2477
+ }
2661
2478
  /**
2662
2479
  * Return an existing full-access agreement for the same-organization (implicit trust) case,
2663
2480
  * or create and store one if none exists. Fires onFinalized on all registered callbacks in