@twin.org/dataspace-control-plane-service 0.0.3-next.45 → 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
@@ -332,13 +341,18 @@ export class DataspaceControlPlaneService {
332
341
  * Request a Transfer Process.
333
342
  * Creates a new Transfer Process in REQUESTED state.
334
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.
335
349
  * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
336
350
  * @returns Transfer Process (DSP compliant) with state REQUESTED, or TransferError if the operation fails.
337
351
  *
338
352
  * Role Performed: Provider
339
353
  * Called by: Consumer when it wants to request a new Transfer Process
340
354
  */
341
- async requestTransfer(request, trustPayload) {
355
+ async requestTransfer(request, publicOrigin, options, trustPayload) {
342
356
  const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "requestTransfer");
343
357
  const validationFailures = await DataspaceProtocolHelper.validate(JsonLdHelper.toNodeObject(request));
344
358
  if (Is.arrayValue(validationFailures)) {
@@ -405,6 +419,7 @@ export class DataspaceControlPlaneService {
405
419
  datasetId,
406
420
  consumerIdentity,
407
421
  providerIdentity,
422
+ localRole: TransferProcessRole.Provider,
408
423
  // offerId should reference Catalog Offer (via Agreement)
409
424
  // For now, use agreementId as reference (proper flow: Catalog → Negotiation → Agreement)
410
425
  offerId: request.agreementId,
@@ -429,6 +444,16 @@ export class DataspaceControlPlaneService {
429
444
  agreementId: request.agreementId
430
445
  }
431
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
+ }
432
457
  return {
433
458
  "@context": [DataspaceProtocolContexts.Context],
434
459
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -438,7 +463,7 @@ export class DataspaceControlPlaneService {
438
463
  };
439
464
  }
440
465
  /**
441
- * Start a data transfer as a Consumer.
466
+ * Prepare a data transfer as a Consumer.
442
467
  * Generates a consumerPid, POSTs a TransferRequestMessage to the provider's DSP endpoint,
443
468
  * and (only if the provider accepts) persists a local TransferProcess in REQUESTED state.
444
469
  * @param agreementId The finalized agreement ID from contract negotiation.
@@ -456,7 +481,7 @@ export class DataspaceControlPlaneService {
456
481
  * registration ignores the runtime `endpoint` arg and silently POSTs to its
457
482
  * static endpoint instead.
458
483
  */
459
- async startDataTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
484
+ async prepareTransfer(agreementId, providerEndpoint, publicOrigin, format, trustPayload) {
460
485
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "agreementId", agreementId);
461
486
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerEndpoint", providerEndpoint);
462
487
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
@@ -467,12 +492,12 @@ export class DataspaceControlPlaneService {
467
492
  supported: Object.values(DataspaceTransferFormat)
468
493
  });
469
494
  }
470
- const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startDataTransfer");
495
+ const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "prepareTransfer");
471
496
  await this._loggingComponent?.log({
472
497
  level: "info",
473
498
  source: DataspaceControlPlaneService.CLASS_NAME,
474
499
  ts: Date.now(),
475
- message: "startingDataTransfer",
500
+ message: "preparingTransfer",
476
501
  data: { agreementId, providerEndpoint, format, identity: trustInfo.identity }
477
502
  });
478
503
  const agreement = await this.lookupAgreement(agreementId);
@@ -489,9 +514,18 @@ export class DataspaceControlPlaneService {
489
514
  const providerIdentity = assignerIds[0];
490
515
  const datasetId = this.extractDatasetId(agreement);
491
516
  const consumerPid = `urn:uuid:${RandomHelper.generateUuidV7()}`;
492
- const callbackAddress = StringHelper.trimTrailingSlashes(publicOrigin);
493
- // 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.
494
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
+ }
495
529
  const transferRequestMessage = {
496
530
  "@context": [DataspaceProtocolContexts.Context],
497
531
  "@type": DataspaceProtocolTransferProcessTypes.TransferRequestMessage,
@@ -507,7 +541,7 @@ export class DataspaceControlPlaneService {
507
541
  if (!Is.stringValue(this._dataPlanePath)) {
508
542
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid });
509
543
  }
510
- let inboxEndpoint = `${callbackAddress}/${this._dataPlanePath}/inbox`;
544
+ let inboxEndpoint = `${origin}/${this._dataPlanePath}/inbox`;
511
545
  if (Is.stringValue(organizationIdentity)) {
512
546
  inboxEndpoint = HttpUrlHelper.addQueryStringParam(inboxEndpoint, ContextIdKeys.Organization, organizationIdentity);
513
547
  }
@@ -521,7 +555,7 @@ export class DataspaceControlPlaneService {
521
555
  const outboundToken = await this._trustComponent.generate(organizationIdentity, this._overrideTrustGeneratorType, { subject: { consumerPid, agreementId } });
522
556
  // Create a remote REST client pointed at the provider endpoint and call requestTransfer.
523
557
  const remoteControlPlane = ComponentFactory.create(this._remoteControlPlaneComponentType, { endpoint: providerEndpoint, pathPrefix: "" });
524
- const result = await remoteControlPlane.requestTransfer(transferRequestMessage, outboundToken);
558
+ const result = await remoteControlPlane.requestTransfer(transferRequestMessage, "", undefined, outboundToken);
525
559
  if (getJsonLdType(result) === DataspaceProtocolTransferProcessTypes.TransferError) {
526
560
  const transferError = result;
527
561
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferRequestRejectedByProvider", {
@@ -544,6 +578,7 @@ export class DataspaceControlPlaneService {
544
578
  datasetId,
545
579
  consumerIdentity: trustInfo.identity,
546
580
  providerIdentity,
581
+ localRole: TransferProcessRole.Consumer,
547
582
  offerId: agreementId,
548
583
  policies: [agreement],
549
584
  callbackAddress,
@@ -558,7 +593,7 @@ export class DataspaceControlPlaneService {
558
593
  level: "info",
559
594
  source: DataspaceControlPlaneService.CLASS_NAME,
560
595
  ts: Date.now(),
561
- message: "dataTransferStarted",
596
+ message: "transferPrepared",
562
597
  data: {
563
598
  consumerPid,
564
599
  providerPid: storageEntity.providerPid,
@@ -613,6 +648,28 @@ export class DataspaceControlPlaneService {
613
648
  currentState: entity.state
614
649
  }), message);
615
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.
616
673
  // The previous (pre-transition) state drives the push-subscription branch below
617
674
  // (setup vs resume). Do NOT mutate entity.state here as the persisted transition
618
675
  // to STARTED happens after the dispatch block succeeds, so any validation or
@@ -829,10 +886,9 @@ export class DataspaceControlPlaneService {
829
886
  role
830
887
  }
831
888
  });
832
- if (role === TransferProcessRole.Consumer) {
833
- await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.STARTED);
834
- await this._internalTransferCallback.onStarted(entity.consumerPid, response);
835
- }
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));
836
892
  return response;
837
893
  }
838
894
  catch (error) {
@@ -919,6 +975,8 @@ export class DataspaceControlPlaneService {
919
975
  throw teardownError;
920
976
  }
921
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).
922
980
  if (role === TransferProcessRole.Consumer) {
923
981
  await this._internalTransferCallback.onStateChanged(entity.consumerPid, DataspaceProtocolTransferProcessStateType.COMPLETED);
924
982
  await this._internalTransferCallback.onCompleted(entity.consumerPid);
@@ -1021,6 +1079,10 @@ export class DataspaceControlPlaneService {
1021
1079
  : message.reason;
1022
1080
  await this._internalTransferCallback.onSuspended(entity.consumerPid, suspendReason);
1023
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
+ }
1024
1086
  return {
1025
1087
  "@context": [DataspaceProtocolContexts.Context],
1026
1088
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1113,6 +1175,10 @@ export class DataspaceControlPlaneService {
1113
1175
  : message.reason;
1114
1176
  await this._internalTransferCallback.onTerminated(entity.consumerPid, terminateReason);
1115
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
+ }
1116
1182
  return {
1117
1183
  "@context": [DataspaceProtocolContexts.Context],
1118
1184
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -1676,6 +1742,122 @@ export class DataspaceControlPlaneService {
1676
1742
  });
1677
1743
  }
1678
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
+ }
1679
1861
  /**
1680
1862
  * Convert a storage entity to model.
1681
1863
  * @param storageEntity The entity from storage.
@@ -1693,6 +1875,7 @@ export class DataspaceControlPlaneService {
1693
1875
  offerId: storageEntity.offerId,
1694
1876
  consumerIdentity: storageEntity.consumerIdentity,
1695
1877
  providerIdentity: storageEntity.providerIdentity,
1878
+ localRole: storageEntity.localRole,
1696
1879
  format: storageEntity.format,
1697
1880
  callbackAddress: storageEntity.callbackAddress,
1698
1881
  organizationIdentity: storageEntity.organizationIdentity,
@@ -1719,6 +1902,7 @@ export class DataspaceControlPlaneService {
1719
1902
  offerId: entity.offerId,
1720
1903
  consumerIdentity: entity.consumerIdentity,
1721
1904
  providerIdentity: entity.providerIdentity,
1905
+ localRole: entity.localRole,
1722
1906
  format: entity.format,
1723
1907
  callbackAddress: entity.callbackAddress,
1724
1908
  organizationIdentity: entity.organizationIdentity,
@@ -1847,25 +2031,27 @@ export class DataspaceControlPlaneService {
1847
2031
  */
1848
2032
  async lookupTransferByPid(pid) {
1849
2033
  Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
1850
- // Check if pid is a consumerPid (primary key lookup)
1851
- const storageEntity = await this._transferProcessStorage.get(pid);
1852
- if (storageEntity) {
1853
- return {
1854
- entity: this.storageEntityToModel(storageEntity),
1855
- role: TransferProcessRole.Consumer
1856
- };
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;
1857
2041
  }
1858
- // Check if pid is a providerPid (secondary key lookup)
1859
- const providerPidEntity = await this._transferProcessStorage.get(pid, "providerPid");
1860
- if (providerPidEntity) {
1861
- return {
1862
- entity: this.storageEntityToModel(providerPidEntity),
1863
- role: TransferProcessRole.Provider
1864
- };
2042
+ if (!storageEntity) {
2043
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
2044
+ pid
2045
+ });
1865
2046
  }
1866
- throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
1867
- pid
1868
- });
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
+ };
1869
2055
  }
1870
2056
  /**
1871
2057
  * Get raw policy entries from a catalog dataset.