@twin.org/dataspace-control-plane-service 0.0.3-next.44 → 0.0.3-next.46

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.
@@ -93,6 +93,12 @@ export class DataspaceControlPlaneService {
93
93
  * @internal
94
94
  */
95
95
  _dataPlanePath;
96
+ /**
97
+ * Control plane callback mount path (path only, not full URL). Combined with this node's public
98
+ * origin to form the consumer callbackAddress the provider POSTs DSP transfer messages back to.
99
+ * @internal
100
+ */
101
+ _callbackPath;
96
102
  /**
97
103
  * Factory key used to look up the data plane component. Resolved lazily at push-time
98
104
  * (not at construction) so the data plane may register after the control plane is built.
@@ -158,6 +164,9 @@ export class DataspaceControlPlaneService {
158
164
  this._dataPlanePath = Is.stringValue(options?.config?.dataPlanePath)
159
165
  ? StringHelper.trimTrailingSlashes(StringHelper.trimLeadingSlashes(options.config.dataPlanePath))
160
166
  : undefined;
167
+ this._callbackPath = Is.stringValue(options?.config?.callbackPath)
168
+ ? StringHelper.trimLeadingAndTrailingSlashes(options.config.callbackPath)
169
+ : undefined;
161
170
  this._taskScheduler = ComponentFactory.getIfExists(options?.taskSchedulerComponentType ?? "task-scheduler");
162
171
  this._platformComponent = ComponentFactory.getIfExists(options?.platformComponentType ?? "platform");
163
172
  // Data plane component is optional and resolved lazily. The control plane is initialised
@@ -223,6 +232,7 @@ export class DataspaceControlPlaneService {
223
232
  /**
224
233
  * The service needs to be started when the application is initialized.
225
234
  * @param nodeLoggingComponentType The node logging component type.
235
+ * @returns A promise that resolves when the federated catalogue is populated and the cleanup task is scheduled.
226
236
  */
227
237
  async start(nodeLoggingComponentType) {
228
238
  const engine = EngineCoreFactory.getIfExists("engine");
@@ -317,6 +327,7 @@ export class DataspaceControlPlaneService {
317
327
  * Stop the service.
318
328
  * Removes the stalled negotiation cleanup task.
319
329
  * @param nodeLoggingComponentType The node logging component type.
330
+ * @returns A promise that resolves when the cleanup task has been removed.
320
331
  */
321
332
  async stop(nodeLoggingComponentType) {
322
333
  if (this._taskScheduler) {
@@ -330,13 +341,18 @@ export class DataspaceControlPlaneService {
330
341
  * Request a Transfer Process.
331
342
  * Creates a new Transfer Process in REQUESTED state.
332
343
  * @param request Transfer request message (DSP compliant).
344
+ * @param publicOrigin The public origin of this provider node, resolved by the REST route from the
345
+ * hosting component; used to build the data-plane endpoint when auto-starting.
346
+ * @param options Request options.
347
+ * @param options.autoStart When true, the provider immediately starts the requested transfer (scheduled
348
+ * on the next tick); when omitted/false the provider start must be triggered explicitly.
333
349
  * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
334
350
  * @returns Transfer Process (DSP compliant) with state REQUESTED, or TransferError if the operation fails.
335
351
  *
336
352
  * Role Performed: Provider
337
353
  * Called by: Consumer when it wants to request a new Transfer Process
338
354
  */
339
- async requestTransfer(request, trustPayload) {
355
+ async requestTransfer(request, publicOrigin, options, trustPayload) {
340
356
  const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "requestTransfer");
341
357
  const validationFailures = await DataspaceProtocolHelper.validate(JsonLdHelper.toNodeObject(request));
342
358
  if (Is.arrayValue(validationFailures)) {
@@ -403,6 +419,7 @@ export class DataspaceControlPlaneService {
403
419
  datasetId,
404
420
  consumerIdentity,
405
421
  providerIdentity,
422
+ localRole: TransferProcessRole.Provider,
406
423
  // offerId should reference Catalog Offer (via Agreement)
407
424
  // For now, use agreementId as reference (proper flow: Catalog → Negotiation → Agreement)
408
425
  offerId: request.agreementId,
@@ -427,6 +444,16 @@ export class DataspaceControlPlaneService {
427
444
  agreementId: request.agreementId
428
445
  }
429
446
  });
447
+ // When auto-start is requested, schedule the provider start asynchronously (mirrors negotiation's
448
+ // setTimeout follow-up). The timer runs inside the request's ALS context, so the [Node, Tenant] + org
449
+ // partition propagates to the deferred start.
450
+ if (options?.autoStart) {
451
+ const consumerPid = request.consumerPid;
452
+ setTimeout(async () => {
453
+ // runProviderStart catches internally and can't reject; awaiting satisfies no-floating-promises.
454
+ await this.runProviderStart(consumerPid, publicOrigin);
455
+ }, 0);
456
+ }
430
457
  return {
431
458
  "@context": [DataspaceProtocolContexts.Context],
432
459
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -436,7 +463,7 @@ export class DataspaceControlPlaneService {
436
463
  };
437
464
  }
438
465
  /**
439
- * Start a data transfer as a Consumer.
466
+ * Prepare a data transfer as a Consumer.
440
467
  * Generates a consumerPid, POSTs a TransferRequestMessage to the provider's DSP endpoint,
441
468
  * and (only if the provider accepts) persists a local TransferProcess in REQUESTED state.
442
469
  * @param agreementId The finalized agreement ID from contract negotiation.
@@ -454,7 +481,7 @@ export class DataspaceControlPlaneService {
454
481
  * registration ignores the runtime `endpoint` arg and silently POSTs to its
455
482
  * static endpoint instead.
456
483
  */
457
- async startDataTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
484
+ async prepareTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
458
485
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "agreementId", agreementId);
459
486
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerEndpoint", providerEndpoint);
460
487
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
@@ -465,12 +492,12 @@ export class DataspaceControlPlaneService {
465
492
  supported: Object.values(DataspaceTransferFormat)
466
493
  });
467
494
  }
468
- const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startDataTransfer");
495
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "prepareTransfer");
469
496
  await this._loggingComponent?.log({
470
497
  level: "info",
471
498
  source: DataspaceControlPlaneService.CLASS_NAME,
472
499
  ts: Date.now(),
473
- message: "startingDataTransfer",
500
+ message: "preparingTransfer",
474
501
  data: { agreementId, providerEndpoint, format, identity: trustInfo.identity }
475
502
  });
476
503
  const agreement = await this.lookupAgreement(agreementId);
@@ -487,9 +514,18 @@ export class DataspaceControlPlaneService {
487
514
  const providerIdentity = assignerIds[0];
488
515
  const datasetId = this.extractDatasetId(agreement);
489
516
  const consumerPid = `urn:uuid:${RandomHelper.generateUuidV7()}`;
490
- const callbackAddress = StringHelper.trimTrailingSlashes(publicOrigin);
491
- // Fetch context once and reuse across the PUSH dataAddress, trust token, and storage entity.
517
+ const origin = StringHelper.trimTrailingSlashes(publicOrigin);
518
+ // Fetch context once and reuse across the callback, PUSH dataAddress, trust token, and storage.
492
519
  const organizationIdentity = await this.resolveContextOrganizationId();
520
+ // Callback the provider POSTs DSP messages back to: mount path + `?organization=` so the inbound
521
+ // POST routes to the right consumer tenant (mirrors buildCallbackUrl). The bare `origin` is kept for
522
+ // the PUSH-inbox base below (a different mount).
523
+ let callbackAddress = Is.stringValue(this._callbackPath)
524
+ ? `${origin}/${this._callbackPath}`
525
+ : origin;
526
+ if (Is.stringValue(organizationIdentity)) {
527
+ callbackAddress = HttpUrlHelper.addQueryStringParam(callbackAddress, ContextIdKeys.Organization, organizationIdentity);
528
+ }
493
529
  const transferRequestMessage = {
494
530
  "@context": [DataspaceProtocolContexts.Context],
495
531
  "@type": DataspaceProtocolTransferProcessTypes.TransferRequestMessage,
@@ -505,7 +541,7 @@ export class DataspaceControlPlaneService {
505
541
  if (!Is.stringValue(this._dataPlanePath)) {
506
542
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid });
507
543
  }
508
- let inboxEndpoint = `${callbackAddress}/${this._dataPlanePath}/inbox`;
544
+ let inboxEndpoint = `${origin}/${this._dataPlanePath}/inbox`;
509
545
  if (Is.stringValue(organizationIdentity)) {
510
546
  inboxEndpoint = HttpUrlHelper.addQueryStringParam(inboxEndpoint, ContextIdKeys.Organization, organizationIdentity);
511
547
  }
@@ -519,7 +555,7 @@ export class DataspaceControlPlaneService {
519
555
  const outboundToken = await this._trustComponent.generate(organizationIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } });
520
556
  // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
521
557
  const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: providerEndpoint, pathPrefix: "" });
522
- const result = await remoteControlPlane.requestTransfer(transferRequestMessage, outboundToken);
558
+ const result = await remoteControlPlane.requestTransfer(transferRequestMessage, "", undefined, outboundToken);
523
559
  if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
524
560
  const transferError = result;
525
561
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferRequestRejectedByProvider", {
@@ -542,6 +578,7 @@ export class DataspaceControlPlaneService {
542
578
  datasetId,
543
579
  consumerIdentity: trustInfo.identity,
544
580
  providerIdentity,
581
+ localRole: TransferProcessRole.Consumer,
545
582
  offerId: agreementId,
546
583
  policies: [agreement],
547
584
  callbackAddress,
@@ -556,7 +593,7 @@ export class DataspaceControlPlaneService {
556
593
  level: "info",
557
594
  source: DataspaceControlPlaneService.CLASS_NAME,
558
595
  ts: Date.now(),
559
- message: "dataTransferStarted",
596
+ message: "transferPrepared",
560
597
  data: {
561
598
  consumerPid,
562
599
  providerPid: storageEntity.providerPid,
@@ -611,6 +648,28 @@ export class DataspaceControlPlaneService {
611
648
  currentState: entity.state
612
649
  }), message);
613
650
  }
651
+ // CONSUMER-RECEIVE: the provider POSTed a TransferStart to our callback. Use the dataAddress
652
+ // from the message (rebuilding it is provider work), mark our record STARTED, and notify.
653
+ if (role === TransferProcessRole.Consumer) {
654
+ entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
655
+ entity.dateModified = new Date();
656
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
657
+ await this._loggingComponent?.log({
658
+ level: "info",
659
+ source: DataspaceControlPlaneService.CLASS_NAME,
660
+ ts: Date.now(),
661
+ message: "transferProcessStarted",
662
+ data: {
663
+ consumerPid: entity.consumerPid,
664
+ providerPid: entity.providerPid,
665
+ role
666
+ }
667
+ });
668
+ await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
669
+ await this._internalTransferCallback.onStarted(entity.consumerPid, message);
670
+ return message;
671
+ }
672
+ // PROVIDER-ACT (role === Provider): build the dataAddress, transition to STARTED, deliver below.
614
673
  // The previous (pre-transition) state drives the push-subscription branch below
615
674
  // (setup vs resume). Do NOT mutate entity.state here as the persisted transition
616
675
  // to STARTED happens after the dispatch block succeeds, so any validation or
@@ -827,10 +886,9 @@ export class DataspaceControlPlaneService {
827
886
  role
828
887
  }
829
888
  });
830
- if (role === TransferProcessRole.Consumer) {
831
- await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
832
- await this._internalTransferCallback.onStarted(entity.consumerPid, response);
833
- }
889
+ // Notify the consumer by POSTing the TransferStart (with its dataAddress) to the callback.
890
+ // Best-effort: a delivery failure must not roll back the STARTED transition.
891
+ await this.deliverToConsumerCallback(entity, "start", async (remoteControlPlane, token) => remoteControlPlane.startTransfer(response, "", token));
834
892
  return response;
835
893
  }
836
894
  catch (error) {
@@ -917,6 +975,8 @@ export class DataspaceControlPlaneService {
917
975
  throw teardownError;
918
976
  }
919
977
  }
978
+ // Completion is consumer-initiated (the auth above accepts only the consumer): a consumer→provider
979
+ // notification, so no provider→consumer delivery here — unlike suspend/terminate (either party).
920
980
  if (role === TransferProcessRole.Consumer) {
921
981
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.COMPLETED);
922
982
  await this._internalTransferCallback.onCompleted(entity.consumerPid);
@@ -1019,6 +1079,10 @@ export class DataspaceControlPlaneService {
1019
1079
  : message.reason;
1020
1080
  await this._internalTransferCallback.onSuspended(entity.consumerPid, suspendReason);
1021
1081
  }
1082
+ else {
1083
+ // PROVIDER-ACT: forward the suspension (with its reason) to the consumer callback.
1084
+ await this.deliverToConsumerCallback(entity, "suspension", async (remoteControlPlane, token) => remoteControlPlane.suspendTransfer(message, token));
1085
+ }
1022
1086
  return {
1023
1087
  "@context": [DataspaceProtocolContexts.Context],
1024
1088
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1111,6 +1175,10 @@ export class DataspaceControlPlaneService {
1111
1175
  : message.reason;
1112
1176
  await this._internalTransferCallback.onTerminated(entity.consumerPid, terminateReason);
1113
1177
  }
1178
+ else {
1179
+ // PROVIDER-ACT: forward the termination (with its reason) to the consumer callback.
1180
+ await this.deliverToConsumerCallback(entity, "termination", async (remoteControlPlane, token) => remoteControlPlane.terminateTransfer(message, token));
1181
+ }
1114
1182
  return {
1115
1183
  "@context": [DataspaceProtocolContexts.Context],
1116
1184
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1573,6 +1641,7 @@ export class DataspaceControlPlaneService {
1573
1641
  * @param id The stored dataset id.
1574
1642
  * @param appId The dataspace app this dataset belongs to.
1575
1643
  * @param dataset The dataset payload.
1644
+ * @returns A promise that resolves when the dataset has been updated in storage and the catalogue.
1576
1645
  */
1577
1646
  async updateAppDataset(id, appId, dataset) {
1578
1647
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "id", id);
@@ -1599,6 +1668,7 @@ export class DataspaceControlPlaneService {
1599
1668
  /**
1600
1669
  * Delete a dataspace app dataset owned by the calling organization.
1601
1670
  * @param id The stored app dataset id.
1671
+ * @returns A promise that resolves when the dataset has been removed from storage and the catalogue.
1602
1672
  */
1603
1673
  async deleteAppDataset(id) {
1604
1674
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "id", id);
@@ -1627,6 +1697,7 @@ export class DataspaceControlPlaneService {
1627
1697
  /**
1628
1698
  * Cleanup stalled negotiations.
1629
1699
  * Called periodically by the task scheduler.
1700
+ * @returns A promise that resolves when all stalled negotiations have been removed and their callbacks notified.
1630
1701
  * @internal
1631
1702
  */
1632
1703
  async cleanupStalledNegotiations() {
@@ -1671,6 +1742,122 @@ export class DataspaceControlPlaneService {
1671
1742
  });
1672
1743
  }
1673
1744
  }
1745
+ /**
1746
+ * Perform the provider-side start of a just-requested transfer (build dataAddress, transition to
1747
+ * STARTED, deliver to the consumer). Only invoked when the request opted into auto-start, so there is no
1748
+ * approval step. Self-contained: failures are logged, never thrown. Runs inside the request's ALS
1749
+ * context, so the deferred storage access inherits the [Node, Tenant] + org partition.
1750
+ * @param consumerPid The consumerPid of the requested transfer.
1751
+ * @param publicOrigin This provider node's public origin, used to build the data-plane endpoint.
1752
+ * @internal
1753
+ */
1754
+ async runProviderStart(consumerPid, publicOrigin) {
1755
+ try {
1756
+ const { entity } = await this.lookupTransferByPid(consumerPid);
1757
+ if (entity.state !== DataspaceProtocolTransferProcessStateType.REQUESTED) {
1758
+ // Already advanced (e.g. an explicit start raced the auto-start). Nothing to do.
1759
+ return;
1760
+ }
1761
+ if (!Is.stringValue(entity.providerIdentity)) {
1762
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
1763
+ }
1764
+ // Fail closed: without a public origin the data-plane endpoint in the dataAddress would be
1765
+ // broken, so hold the transfer rather than ship a relative/empty endpoint to the consumer.
1766
+ if (!Is.stringValue(publicOrigin)) {
1767
+ await this._loggingComponent?.log({
1768
+ level: "error",
1769
+ source: DataspaceControlPlaneService.CLASS_NAME,
1770
+ ts: Date.now(),
1771
+ message: "autoStartPublicOriginMissing",
1772
+ data: { consumerPid }
1773
+ });
1774
+ return;
1775
+ }
1776
+ // Self-token issued as the provider so startTransfer's provider-auth check passes.
1777
+ const selfToken = await this._trustComponent.generate(entity.providerIdentity, this._overrideTrustGeneratorType, {
1778
+ subject: {
1779
+ consumerPid: entity.consumerPid,
1780
+ providerPid: entity.providerPid,
1781
+ agreementId: entity.agreementId
1782
+ }
1783
+ });
1784
+ const startMessage = {
1785
+ "@context": [DataspaceProtocolContexts.Context],
1786
+ "@type": DataspaceProtocolTransferProcessTypes.TransferStartMessage,
1787
+ consumerPid: entity.consumerPid,
1788
+ providerPid: entity.providerPid
1789
+ };
1790
+ await this.startTransfer(startMessage, publicOrigin, selfToken);
1791
+ }
1792
+ catch (error) {
1793
+ await this._loggingComponent?.log({
1794
+ level: "error",
1795
+ source: DataspaceControlPlaneService.CLASS_NAME,
1796
+ ts: Date.now(),
1797
+ message: "autoStartFailed",
1798
+ error: BaseError.fromError(error),
1799
+ data: { consumerPid }
1800
+ });
1801
+ }
1802
+ }
1803
+ /**
1804
+ * Deliver a transfer state-change DSP message to the consumer's callback (provider → consumer), via a
1805
+ * remote control-plane client and the supplied sender. Mirrors negotiation's sendOfferToConsumer.
1806
+ * Best-effort: failures are logged, not thrown, so delivery problems don't roll back the transition.
1807
+ * @param entity The transfer process (provider side).
1808
+ * @param messageKind Short label for logging (e.g. "start", "suspension", "termination").
1809
+ * @param send Performs the remote POST given the remote client and an outbound trust token.
1810
+ * @internal
1811
+ */
1812
+ async deliverToConsumerCallback(entity, messageKind, send) {
1813
+ if (!Is.stringValue(entity.callbackAddress) || !Is.stringValue(entity.providerIdentity)) {
1814
+ // Spec-allowed: no callback supplied → the consumer polls GET /transfers/:pid instead.
1815
+ return;
1816
+ }
1817
+ try {
1818
+ // Issue the token as the provider party (the agreement assigner) — the identity the consumer's
1819
+ // receive gate checks (`!== providerIdentity`), not the tenant-routing org. Keeps the two
1820
+ // identity spaces separate and works whether or not a node's org == its assigner DID.
1821
+ const outboundToken = await this._trustComponent.generate(entity.providerIdentity, this._overrideTrustGeneratorType, {
1822
+ subject: {
1823
+ consumerPid: entity.consumerPid,
1824
+ providerPid: entity.providerPid,
1825
+ agreementId: entity.agreementId
1826
+ }
1827
+ });
1828
+ const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: entity.callbackAddress, pathPrefix: "" });
1829
+ const result = await send(remoteControlPlane, outboundToken);
1830
+ if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
1831
+ await this._loggingComponent?.log({
1832
+ level: "error",
1833
+ source: DataspaceControlPlaneService.CLASS_NAME,
1834
+ ts: Date.now(),
1835
+ message: "transferCallbackRejected",
1836
+ data: {
1837
+ messageKind,
1838
+ consumerPid: entity.consumerPid,
1839
+ providerPid: entity.providerPid,
1840
+ callbackAddress: entity.callbackAddress
1841
+ }
1842
+ });
1843
+ }
1844
+ }
1845
+ catch (error) {
1846
+ await this._loggingComponent?.log({
1847
+ level: "error",
1848
+ source: DataspaceControlPlaneService.CLASS_NAME,
1849
+ ts: Date.now(),
1850
+ message: "transferCallbackFailed",
1851
+ error: BaseError.fromError(error),
1852
+ data: {
1853
+ messageKind,
1854
+ consumerPid: entity.consumerPid,
1855
+ providerPid: entity.providerPid,
1856
+ callbackAddress: entity.callbackAddress
1857
+ }
1858
+ });
1859
+ }
1860
+ }
1674
1861
  /**
1675
1862
  * Convert a storage entity to model.
1676
1863
  * @param storageEntity The entity from storage.
@@ -1688,6 +1875,7 @@ export class DataspaceControlPlaneService {
1688
1875
  offerId: storageEntity.offerId,
1689
1876
  consumerIdentity: storageEntity.consumerIdentity,
1690
1877
  providerIdentity: storageEntity.providerIdentity,
1878
+ localRole: storageEntity.localRole,
1691
1879
  format: storageEntity.format,
1692
1880
  callbackAddress: storageEntity.callbackAddress,
1693
1881
  organizationIdentity: storageEntity.organizationIdentity,
@@ -1714,6 +1902,7 @@ export class DataspaceControlPlaneService {
1714
1902
  offerId: entity.offerId,
1715
1903
  consumerIdentity: entity.consumerIdentity,
1716
1904
  providerIdentity: entity.providerIdentity,
1905
+ localRole: entity.localRole,
1717
1906
  format: entity.format,
1718
1907
  callbackAddress: entity.callbackAddress,
1719
1908
  organizationIdentity: entity.organizationIdentity,
@@ -1744,9 +1933,9 @@ export class DataspaceControlPlaneService {
1744
1933
  return agreement;
1745
1934
  }
1746
1935
  /**
1747
- * Extract dataset ID from Agreement target.
1748
- * @param format Agreement.
1749
- * @returns Dataset ID.
1936
+ * Check whether a transfer format string represents a push-mode delivery.
1937
+ * @param format The transfer format string from the TransferProcess entity.
1938
+ * @returns True if the format is a push variant (HttpData-PUSH or HttpData-POST).
1750
1939
  * @internal
1751
1940
  */
1752
1941
  isPushFormat(format) {
@@ -1786,6 +1975,7 @@ export class DataspaceControlPlaneService {
1786
1975
  * Validate that the dataset exists in the Federated Catalogue.
1787
1976
  * @param datasetId Dataset identifier extracted from Agreement.
1788
1977
  * @param agreement The Agreement being validated.
1978
+ * @returns A promise that resolves when the dataset has been confirmed in the catalogue and the offer has been validated.
1789
1979
  * @internal
1790
1980
  */
1791
1981
  async validateCatalogDataset(datasetId, agreement) {
@@ -1841,25 +2031,27 @@ export class DataspaceControlPlaneService {
1841
2031
  */
1842
2032
  async lookupTransferByPid(pid) {
1843
2033
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
1844
- // Check if pid is a consumerPid (primary key lookup)
1845
- const storageEntity = await this._transferProcessStorage.get(pid);
1846
- if (storageEntity) {
1847
- return {
1848
- entity: this.storageEntityToModel(storageEntity),
1849
- role: TransferProcessRole.Consumer
1850
- };
2034
+ // consumerPid is the primary key on BOTH nodes, so the matched key alone is not a reliable role
2035
+ // signal; locate the record by primary, then the providerPid secondary index.
2036
+ let storageEntity = await this._transferProcessStorage.get(pid);
2037
+ let matchedByConsumerPid = true;
2038
+ if (!storageEntity) {
2039
+ storageEntity = await this._transferProcessStorage.get(pid, "providerPid");
2040
+ matchedByConsumerPid = false;
1851
2041
  }
1852
- // Check if pid is a providerPid (secondary key lookup)
1853
- const providerPidEntity = await this._transferProcessStorage.get(pid, "providerPid");
1854
- if (providerPidEntity) {
1855
- return {
1856
- entity: this.storageEntityToModel(providerPidEntity),
1857
- role: TransferProcessRole.Provider
1858
- };
2042
+ if (!storageEntity) {
2043
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
2044
+ pid
2045
+ });
1859
2046
  }
1860
- throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
1861
- pid
1862
- });
2047
+ // Prefer the role persisted at write time (set in prepareTransfer / requestTransfer). Fall back
2048
+ // to the matched-key heuristic only for legacy records written before localRole existed.
2049
+ const role = storageEntity.localRole ??
2050
+ (matchedByConsumerPid ? TransferProcessRole.Consumer : TransferProcessRole.Provider);
2051
+ return {
2052
+ entity: this.storageEntityToModel(storageEntity),
2053
+ role
2054
+ };
1863
2055
  }
1864
2056
  /**
1865
2057
  * Get raw policy entries from a catalog dataset.
@@ -1886,6 +2078,7 @@ export class DataspaceControlPlaneService {
1886
2078
  * Validate that the Agreement policies match at least one Catalog Offer.
1887
2079
  * @param agreement Agreement to validate.
1888
2080
  * @param catalogDataset Catalog dataset containing Offers.
2081
+ * @returns A promise that resolves when the agreement has been matched against a catalogue offer.
1889
2082
  * @internal
1890
2083
  */
1891
2084
  async validateAgreementMatchesOffer(agreement, catalogDataset) {