@twin.org/dataspace-control-plane-service 0.0.3-next.33 → 0.0.3-next.35

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.
@@ -120,6 +120,22 @@ export class DataspaceControlPlaneService {
120
120
  * @internal
121
121
  */
122
122
  _negotiationCallbacks;
123
+ /**
124
+ * Registered transfer callbacks from upstream callers, keyed by registration key.
125
+ * @internal
126
+ */
127
+ _transferCallbacks;
128
+ /**
129
+ * Internal transfer callback that fans out to all registered transfer callbacks.
130
+ * @internal
131
+ */
132
+ _internalTransferCallback;
133
+ /**
134
+ * Factory key used to create remote control plane REST client instances for outbound
135
+ * DSP transfer requests. Resolved via ComponentFactory.create() at call time.
136
+ * @internal
137
+ */
138
+ _remoteControlPlaneComponentType;
123
139
  /**
124
140
  * The component type name for the hosting component.
125
141
  * @internal
@@ -159,6 +175,10 @@ export class DataspaceControlPlaneService {
159
175
  options?.dataPlaneComponentType ?? "dataspace-data-plane-service";
160
176
  this._urlTransformerComponent = ComponentFactory.get(options?.urlTransformerComponentType ?? "url-transformer");
161
177
  this._negotiationCallbacks = new Map();
178
+ this._transferCallbacks = new Map();
179
+ this._internalTransferCallback = this.createInternalTransferCallback();
180
+ this._remoteControlPlaneComponentType =
181
+ options?.remoteControlPlaneComponentType ?? "dataspace-control-plane-rest-client";
162
182
  const internalCallback = this.createInternalCallback();
163
183
  this._policyRequester = new DataspaceControlPlanePolicyRequester(options?.loggingComponentType ?? "logging", internalCallback);
164
184
  PolicyRequesterFactory.register(DataspaceControlPlaneService._REQUESTER_TYPE, () => this._policyRequester);
@@ -188,6 +208,24 @@ export class DataspaceControlPlaneService {
188
208
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "key", key);
189
209
  this._negotiationCallbacks.delete(key);
190
210
  }
211
+ /**
212
+ * Register a callback to receive transfer process state change notifications.
213
+ * @param key A unique key identifying this callback registration.
214
+ * @param callback The callback interface to register.
215
+ */
216
+ registerTransferCallback(key, callback) {
217
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "key", key);
218
+ Guards.object(DataspaceControlPlaneService.CLASS_NAME, "callback", callback);
219
+ this._transferCallbacks.set(key, callback);
220
+ }
221
+ /**
222
+ * Unregister a previously registered transfer callback.
223
+ * @param key The key used when registering the callback.
224
+ */
225
+ unregisterTransferCallback(key) {
226
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "key", key);
227
+ this._transferCallbacks.delete(key);
228
+ }
191
229
  /**
192
230
  * The service needs to be started when the application is initialized.
193
231
  * Populates the Federated Catalogue with datasets from registered apps
@@ -406,6 +444,142 @@ export class DataspaceControlPlaneService {
406
444
  state: entity.state
407
445
  };
408
446
  }
447
+ /**
448
+ * Start a data transfer as a Consumer.
449
+ * Generates a consumerPid, POSTs a TransferRequestMessage to the provider's DSP endpoint,
450
+ * and (only if the provider accepts) persists a local TransferProcess in REQUESTED state.
451
+ * @param agreementId The finalized agreement ID from contract negotiation.
452
+ * @param providerEndpoint The provider's DSP control plane base URL.
453
+ * @param publicOrigin The public origin URL of this control plane (used as callbackAddress).
454
+ * @param format The transfer format (e.g. "HttpData-PULL", "HttpData-PUSH").
455
+ * @param trustPayload Trust payload for authenticating this call.
456
+ * @returns The consumerPid of the newly created TransferProcess.
457
+ *
458
+ * **Engine configuration requirement:** The outbound call to the provider uses
459
+ * `ComponentFactory.create(remoteControlPlaneComponentType, { endpoint, pathPrefix })`.
460
+ * For the runtime `providerEndpoint` to be forwarded correctly, the engine **must** register
461
+ * the component type (default: `dataspace-control-plane-rest-client`) as a
462
+ * **multi-instance** component (`isMultiInstance: true` in engine config). A singleton
463
+ * registration ignores the runtime `endpoint` arg and silently POSTs to its
464
+ * static endpoint instead.
465
+ */
466
+ async startDataTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
467
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "agreementId", agreementId);
468
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerEndpoint", providerEndpoint);
469
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
470
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "format", format);
471
+ if (!Object.values(DataspaceTransferFormat).includes(format)) {
472
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "unsupportedTransferFormat", {
473
+ format,
474
+ supported: Object.values(DataspaceTransferFormat)
475
+ });
476
+ }
477
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startDataTransfer");
478
+ await this._loggingComponent?.log({
479
+ level: "info",
480
+ source: DataspaceControlPlaneService.CLASS_NAME,
481
+ ts: Date.now(),
482
+ message: "startingDataTransfer",
483
+ data: { agreementId, providerEndpoint, format, identity: trustInfo.identity }
484
+ });
485
+ const agreement = await this.lookupAgreement(agreementId);
486
+ const assigneeIdentity = OdrlPolicyHelper.extractAssigneeIdentity(agreement);
487
+ const assigneeIds = ArrayHelper.fromObjectOrArray(assigneeIdentity);
488
+ const callerComposite = this.buildCallerComposite(trustInfo);
489
+ if (!assigneeIds.includes(callerComposite)) {
490
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "callerNotAuthorizedForAgreement");
491
+ }
492
+ const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
493
+ const assignerIds = ArrayHelper.fromObjectOrArray(assignerIdentity);
494
+ if (assignerIds.length > 1) {
495
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "multipleAssignersNotSupported");
496
+ }
497
+ const providerIdentity = assignerIds[0];
498
+ const datasetId = this.extractDatasetId(agreement);
499
+ const consumerPid = `urn:uuid:${RandomHelper.generateUuidV7()}`;
500
+ const callbackAddress = StringHelper.trimTrailingSlashes(publicOrigin);
501
+ // Fetch context once and reuse across the PUSH dataAddress, trust token, and storage entity.
502
+ const contextIds = await ContextIdStore.getContextIds();
503
+ const nodeIdentity = contextIds?.[ContextIdKeys.Node];
504
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "nodeIdentity", nodeIdentity);
505
+ const tenantId = contextIds?.[ContextIdKeys.Tenant];
506
+ const organizationIdentity = contextIds?.[ContextIdKeys.Organization];
507
+ const transferRequestMessage = {
508
+ "@context": [DataspaceProtocolContexts.Context],
509
+ "@type": DataspaceProtocolTransferProcessTypes.TransferRequestMessage,
510
+ consumerPid,
511
+ agreementId,
512
+ callbackAddress,
513
+ format
514
+ };
515
+ // For consumer-initiated PUSH transfers the consumer must supply its /inbox endpoint as
516
+ // dataAddress so the provider knows where to push ActivityStreams objects. Without it the
517
+ // provider silently falls through to PULL mode on startTransfer.
518
+ if (format === DataspaceTransferFormat.HttpDataPush) {
519
+ if (!Is.stringValue(this._dataPlanePath)) {
520
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid });
521
+ }
522
+ let inboxEndpoint = `${callbackAddress}/${this._dataPlanePath}/inbox`;
523
+ if (Is.stringValue(tenantId)) {
524
+ inboxEndpoint = await this._urlTransformerComponent.addEncryptedQueryParamToUrl(inboxEndpoint, "tenant", tenantId);
525
+ }
526
+ transferRequestMessage.dataAddress = {
527
+ "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
528
+ endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
529
+ endpoint: inboxEndpoint
530
+ };
531
+ }
532
+ // Generate outbound trust token to authenticate this node to the provider.
533
+ const outboundToken = await this._trustComponent.generate(nodeIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } }, Is.stringValue(tenantId) ? TrustHelper.hashTenantId(tenantId) : undefined, organizationIdentity);
534
+ // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
535
+ const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: providerEndpoint, pathPrefix: "" });
536
+ const result = await remoteControlPlane.requestTransfer(transferRequestMessage, outboundToken);
537
+ if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
538
+ const transferError = result;
539
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferRequestRejectedByProvider", {
540
+ agreementId,
541
+ providerEndpoint,
542
+ code: transferError.code
543
+ });
544
+ }
545
+ const transferProcess = result;
546
+ if (!Is.stringValue(transferProcess.providerPid)) {
547
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerPidMissingInResponse", { consumerPid });
548
+ }
549
+ const now = new Date();
550
+ const storageEntity = {
551
+ id: Converter.bytesToHex(RandomHelper.generate(32)),
552
+ consumerPid,
553
+ providerPid: transferProcess.providerPid,
554
+ state: DataspaceProtocolTransferProcessStateType.REQUESTED,
555
+ agreementId,
556
+ datasetId,
557
+ consumerIdentity: callerComposite,
558
+ providerIdentity,
559
+ offerId: agreementId,
560
+ policies: [agreement],
561
+ callbackAddress,
562
+ format,
563
+ dataAddress: transferRequestMessage.dataAddress,
564
+ tenantId: Is.stringValue(tenantId) ? tenantId : undefined,
565
+ dateCreated: now.toISOString(),
566
+ dateModified: now.toISOString()
567
+ };
568
+ await this._transferProcessStorage.set(storageEntity);
569
+ await this._loggingComponent?.log({
570
+ level: "info",
571
+ source: DataspaceControlPlaneService.CLASS_NAME,
572
+ ts: Date.now(),
573
+ message: "dataTransferStarted",
574
+ data: {
575
+ consumerPid,
576
+ providerPid: storageEntity.providerPid,
577
+ agreementId,
578
+ format
579
+ }
580
+ });
581
+ return { consumerPid };
582
+ }
409
583
  // ----------------------------------------------------------------------------
410
584
  // PROVIDER SIDE OPERATIONS
411
585
  // ----------------------------------------------------------------------------
@@ -492,10 +666,10 @@ export class DataspaceControlPlaneService {
492
666
  //
493
667
  // See: https://eclipse-dataspace-protocol-base.github.io/DataspaceProtocol/2025-1-err1/#transfer-start-message
494
668
  // ============================================================================
495
- if (Is.empty(entity.dataAddress) && entity.format === DataspaceTransferFormat.HttpProxyPost) {
669
+ if (Is.empty(entity.dataAddress) && entity.format === DataspaceTransferFormat.HttpDataPost) {
496
670
  // PROVIDER-INITIATED PUSH: Consumer requested push but did not supply an /inbox.
497
671
  // Provider returns its own /inbox URL + a signed JWT so the consumer can verify
498
- // the incoming activities (HttpProxy-POST / DataspaceTransferFormat.HttpProxyPost).
672
+ // the incoming activities (HttpData-POST / DataspaceTransferFormat.HttpDataPost).
499
673
  if (!Is.stringValue(this._dataPlanePath)) {
500
674
  return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid: entity.consumerPid }), message);
501
675
  }
@@ -549,7 +723,7 @@ export class DataspaceControlPlaneService {
549
723
  consumerPid: entity.consumerPid,
550
724
  providerPid: entity.providerPid,
551
725
  endpoint: fullEndpoint,
552
- transferMode: DataspaceTransferFormat.HttpProxyPost
726
+ transferMode: DataspaceTransferFormat.HttpDataPost
553
727
  }
554
728
  });
555
729
  }
@@ -651,7 +825,7 @@ export class DataspaceControlPlaneService {
651
825
  consumerPid: entity.consumerPid,
652
826
  providerPid: entity.providerPid,
653
827
  endpoint: fullEndpoint,
654
- transferMode: DataspaceTransferFormat.HttpProxyPush
828
+ transferMode: DataspaceTransferFormat.HttpDataPush
655
829
  }
656
830
  });
657
831
  try {
@@ -670,6 +844,10 @@ export class DataspaceControlPlaneService {
670
844
  throw setupError;
671
845
  }
672
846
  }
847
+ if (role === TransferProcessRole.Consumer) {
848
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
849
+ await this._internalTransferCallback.onStarted(entity.consumerPid, response);
850
+ }
673
851
  return response;
674
852
  }
675
853
  catch (error) {
@@ -757,6 +935,10 @@ export class DataspaceControlPlaneService {
757
935
  throw teardownError;
758
936
  }
759
937
  }
938
+ if (role === TransferProcessRole.Consumer) {
939
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.COMPLETED);
940
+ await this._internalTransferCallback.onCompleted(entity.consumerPid);
941
+ }
760
942
  return {
761
943
  "@context": [DataspaceProtocolContexts.Context],
762
944
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -846,6 +1028,15 @@ export class DataspaceControlPlaneService {
846
1028
  throw suspendError;
847
1029
  }
848
1030
  }
1031
+ if (role === TransferProcessRole.Consumer) {
1032
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.SUSPENDED);
1033
+ // DSP reason is typed any[] — forward only the first entry since the callback
1034
+ // contract is reason?: string. Additional entries are intentionally dropped.
1035
+ const suspendReason = Array.isArray(message.reason)
1036
+ ? message.reason[0]
1037
+ : message.reason;
1038
+ await this._internalTransferCallback.onSuspended(entity.consumerPid, suspendReason);
1039
+ }
849
1040
  return {
850
1041
  "@context": [DataspaceProtocolContexts.Context],
851
1042
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -929,6 +1120,15 @@ export class DataspaceControlPlaneService {
929
1120
  throw teardownError;
930
1121
  }
931
1122
  }
1123
+ if (role === TransferProcessRole.Consumer) {
1124
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.TERMINATED);
1125
+ // DSP reason is typed any[] — forward only the first entry since the callback
1126
+ // contract is reason?: string. Additional entries are intentionally dropped.
1127
+ const terminateReason = Array.isArray(message.reason)
1128
+ ? message.reason[0]
1129
+ : message.reason;
1130
+ await this._internalTransferCallback.onTerminated(entity.consumerPid, terminateReason);
1131
+ }
932
1132
  return {
933
1133
  "@context": [DataspaceProtocolContexts.Context],
934
1134
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1632,8 +1832,8 @@ export class DataspaceControlPlaneService {
1632
1832
  * @internal
1633
1833
  */
1634
1834
  isPushFormat(format) {
1635
- return (format === DataspaceTransferFormat.HttpProxyPush ||
1636
- format === DataspaceTransferFormat.HttpProxyPost);
1835
+ return (format === DataspaceTransferFormat.HttpDataPush ||
1836
+ format === DataspaceTransferFormat.HttpDataPost);
1637
1837
  }
1638
1838
  /**
1639
1839
  * Extract the dataset ID from an ODRL agreement's target.
@@ -1996,6 +2196,101 @@ export class DataspaceControlPlaneService {
1996
2196
  }
1997
2197
  };
1998
2198
  }
2199
+ /**
2200
+ * Creates the internal ITransferCallback that fans out to all registered transfer callbacks.
2201
+ * Errors thrown by individual callbacks are logged and swallowed so they cannot affect
2202
+ * the DSP protocol state machine or other registrants.
2203
+ * @returns The internal transfer callback.
2204
+ */
2205
+ createInternalTransferCallback() {
2206
+ return {
2207
+ onStateChanged: async (consumerPid, state) => {
2208
+ for (const [key, cb] of this._transferCallbacks.entries()) {
2209
+ try {
2210
+ await cb.onStateChanged(consumerPid, state);
2211
+ }
2212
+ catch (error) {
2213
+ await this._loggingComponent?.log({
2214
+ level: "error",
2215
+ source: DataspaceControlPlaneService.CLASS_NAME,
2216
+ ts: Date.now(),
2217
+ message: "transferCallbackError",
2218
+ error: BaseError.fromError(error),
2219
+ data: { key, consumerPid, state, method: "onStateChanged" }
2220
+ });
2221
+ }
2222
+ }
2223
+ },
2224
+ onStarted: async (consumerPid, message) => {
2225
+ for (const [key, cb] of this._transferCallbacks.entries()) {
2226
+ try {
2227
+ await cb.onStarted(consumerPid, message);
2228
+ }
2229
+ catch (error) {
2230
+ await this._loggingComponent?.log({
2231
+ level: "error",
2232
+ source: DataspaceControlPlaneService.CLASS_NAME,
2233
+ ts: Date.now(),
2234
+ message: "transferCallbackError",
2235
+ error: BaseError.fromError(error),
2236
+ data: { key, consumerPid, method: "onStarted" }
2237
+ });
2238
+ }
2239
+ }
2240
+ },
2241
+ onCompleted: async (consumerPid) => {
2242
+ for (const [key, cb] of this._transferCallbacks.entries()) {
2243
+ try {
2244
+ await cb.onCompleted(consumerPid);
2245
+ }
2246
+ catch (error) {
2247
+ await this._loggingComponent?.log({
2248
+ level: "error",
2249
+ source: DataspaceControlPlaneService.CLASS_NAME,
2250
+ ts: Date.now(),
2251
+ message: "transferCallbackError",
2252
+ error: BaseError.fromError(error),
2253
+ data: { key, consumerPid, method: "onCompleted" }
2254
+ });
2255
+ }
2256
+ }
2257
+ },
2258
+ onSuspended: async (consumerPid, reason) => {
2259
+ for (const [key, cb] of this._transferCallbacks.entries()) {
2260
+ try {
2261
+ await cb.onSuspended(consumerPid, reason);
2262
+ }
2263
+ catch (error) {
2264
+ await this._loggingComponent?.log({
2265
+ level: "error",
2266
+ source: DataspaceControlPlaneService.CLASS_NAME,
2267
+ ts: Date.now(),
2268
+ message: "transferCallbackError",
2269
+ error: BaseError.fromError(error),
2270
+ data: { key, consumerPid, method: "onSuspended" }
2271
+ });
2272
+ }
2273
+ }
2274
+ },
2275
+ onTerminated: async (consumerPid, reason) => {
2276
+ for (const [key, cb] of this._transferCallbacks.entries()) {
2277
+ try {
2278
+ await cb.onTerminated(consumerPid, reason);
2279
+ }
2280
+ catch (error) {
2281
+ await this._loggingComponent?.log({
2282
+ level: "error",
2283
+ source: DataspaceControlPlaneService.CLASS_NAME,
2284
+ ts: Date.now(),
2285
+ message: "transferCallbackError",
2286
+ error: BaseError.fromError(error),
2287
+ data: { key, consumerPid, method: "onTerminated" }
2288
+ });
2289
+ }
2290
+ }
2291
+ }
2292
+ };
2293
+ }
1999
2294
  /**
2000
2295
  * Re-stamp `@id` onto a stored payload blob using the entity primary key.
2001
2296
  * @param payload The stored payload blob (without `@id`).