@twin.org/dataspace-control-plane-service 0.0.3-next.15

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 (50) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +119 -0
  3. package/dist/es/dataspaceControlPlanePolicyRequester.js +228 -0
  4. package/dist/es/dataspaceControlPlanePolicyRequester.js.map +1 -0
  5. package/dist/es/dataspaceControlPlaneRoutes.js +202 -0
  6. package/dist/es/dataspaceControlPlaneRoutes.js.map +1 -0
  7. package/dist/es/dataspaceControlPlaneService.js +1420 -0
  8. package/dist/es/dataspaceControlPlaneService.js.map +1 -0
  9. package/dist/es/index.js +11 -0
  10. package/dist/es/index.js.map +1 -0
  11. package/dist/es/models/IDataspaceControlPlaneServiceConfig.js +4 -0
  12. package/dist/es/models/IDataspaceControlPlaneServiceConfig.js.map +1 -0
  13. package/dist/es/models/IDataspaceControlPlaneServiceConstructorOptions.js +2 -0
  14. package/dist/es/models/IDataspaceControlPlaneServiceConstructorOptions.js.map +1 -0
  15. package/dist/es/models/INegotiationState.js +4 -0
  16. package/dist/es/models/INegotiationState.js.map +1 -0
  17. package/dist/es/restEntryPoints.js +15 -0
  18. package/dist/es/restEntryPoints.js.map +1 -0
  19. package/dist/es/schema.js +11 -0
  20. package/dist/es/schema.js.map +1 -0
  21. package/dist/es/utils/dataHelpers.js +22 -0
  22. package/dist/es/utils/dataHelpers.js.map +1 -0
  23. package/dist/es/utils/transferErrorUtils.js +78 -0
  24. package/dist/es/utils/transferErrorUtils.js.map +1 -0
  25. package/dist/types/dataspaceControlPlanePolicyRequester.d.ts +80 -0
  26. package/dist/types/dataspaceControlPlaneRoutes.d.ts +17 -0
  27. package/dist/types/dataspaceControlPlaneService.d.ts +153 -0
  28. package/dist/types/index.d.ts +8 -0
  29. package/dist/types/models/IDataspaceControlPlaneServiceConfig.d.ts +20 -0
  30. package/dist/types/models/IDataspaceControlPlaneServiceConstructorOptions.d.ts +69 -0
  31. package/dist/types/models/INegotiationState.d.ts +27 -0
  32. package/dist/types/restEntryPoints.d.ts +7 -0
  33. package/dist/types/schema.d.ts +4 -0
  34. package/dist/types/utils/dataHelpers.d.ts +1 -0
  35. package/dist/types/utils/transferErrorUtils.d.ts +39 -0
  36. package/docs/API.md +341 -0
  37. package/docs/changelog.md +31 -0
  38. package/docs/examples.md +1 -0
  39. package/docs/reference/classes/DataspaceControlPlanePolicyRequester.md +244 -0
  40. package/docs/reference/classes/DataspaceControlPlaneService.md +549 -0
  41. package/docs/reference/functions/generateRestRoutesDataspaceControlPlane.md +29 -0
  42. package/docs/reference/functions/initSchema.md +9 -0
  43. package/docs/reference/index.md +22 -0
  44. package/docs/reference/interfaces/IDataspaceControlPlaneServiceConfig.md +26 -0
  45. package/docs/reference/interfaces/IDataspaceControlPlaneServiceConstructorOptions.md +160 -0
  46. package/docs/reference/interfaces/INegotiationState.md +43 -0
  47. package/docs/reference/variables/restEntryPoints.md +7 -0
  48. package/docs/reference/variables/tagsDataspaceControlPlane.md +5 -0
  49. package/locales/en.json +93 -0
  50. package/package.json +70 -0
@@ -0,0 +1,1420 @@
1
+ import { ContextIdKeys, ContextIdStore } from "@twin.org/context";
2
+ import { ArrayHelper, BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, NotFoundError, ObjectHelper, RandomHelper, StringHelper, UnauthorizedError } from "@twin.org/core";
3
+ import { JsonLdHelper } from "@twin.org/data-json-ld";
4
+ import { DataspaceAppFactory, TransferProcessRole } from "@twin.org/dataspace-models";
5
+ import { EngineCoreFactory } from "@twin.org/engine-models";
6
+ import { EntityStorageConnectorFactory } from "@twin.org/entity-storage-models";
7
+ import { OdrlPolicyHelper, PolicyRequesterFactory } from "@twin.org/rights-management-models";
8
+ import { DataspaceProtocolContexts, DataspaceProtocolContractNegotiationTypes, DataspaceProtocolEndpointType, DataspaceProtocolHelper, DataspaceProtocolTransferProcessStateType, DataspaceProtocolTransferProcessTypes } from "@twin.org/standards-dataspace-protocol";
9
+ import { TrustHelper } from "@twin.org/trust-models";
10
+ import { DataspaceControlPlanePolicyRequester } from "./dataspaceControlPlanePolicyRequester.js";
11
+ import { getJsonLdId, getJsonLdType } from "./utils/dataHelpers.js";
12
+ import { isCatalogError, isCatalogErrorName, transformToTransferError } from "./utils/transferErrorUtils.js";
13
+ /**
14
+ * Stalled negotiation threshold in milliseconds (30 minutes).
15
+ * Negotiations that haven't received a state update within this time are considered stalled.
16
+ */
17
+ const STALLED_NEGOTIATION_THRESHOLD_MS = 30 * 60 * 1000;
18
+ /**
19
+ * Dataspace Control Plane Service implementation.
20
+ *
21
+ * Handles contract negotiation (via PNP callbacks) and transfer process management.
22
+ * Negotiation is fully callback-driven: negotiateAgreement() returns immediately with
23
+ * a negotiationId, and the caller is notified via INegotiationCallback when complete.
24
+ */
25
+ export class DataspaceControlPlaneService {
26
+ /**
27
+ * Runtime name for the class.
28
+ */
29
+ static CLASS_NAME = "DataspaceControlPlaneService";
30
+ /**
31
+ * Hardcoded requester type for PNP registration.
32
+ * PNP uses this to route callbacks to our PolicyRequester.
33
+ * @internal
34
+ */
35
+ static _REQUESTER_TYPE = "dataspace-control-plane-requester";
36
+ /**
37
+ * The logging component.
38
+ * @internal
39
+ */
40
+ _loggingComponent;
41
+ /**
42
+ * Policy Administration Point component for Agreement lookup.
43
+ * @internal
44
+ */
45
+ _policyAdministrationPointComponent;
46
+ /**
47
+ * Policy Negotiation Point component for contract negotiation.
48
+ * Used to negotiate agreements with providers before creating transfer processes.
49
+
50
+ * @internal
51
+ */
52
+ _policyNegotiationPointComponent;
53
+ /**
54
+ * Policy Negotiation Admin Point component for negotiation history.
55
+ * Optional - if not configured, history queries will fail gracefully.
56
+ * @internal
57
+ */
58
+ _policyNegotiationAdminPointComponent;
59
+ /**
60
+ * Federated Catalogue component for dataset validation.
61
+ * Used to validate that Agreements reference valid catalog datasets.
62
+ * @internal
63
+ */
64
+ _federatedCatalogueComponent;
65
+ /**
66
+ * Entity storage for Transfer Process entities.
67
+ * Shared between Control Plane and Data Plane.
68
+ * @internal
69
+ */
70
+ _transferProcessStorage;
71
+ /**
72
+ * The trust component for token verification and generation.
73
+ * @internal
74
+ */
75
+ _trustComponent;
76
+ /**
77
+ * Override trust generator type for token generation.
78
+ * @internal
79
+ */
80
+ _overrideTrustGeneratorType;
81
+ /**
82
+ * Data plane endpoint path (path only, not full URL).
83
+ * Will be combined with public origin from hosting component.
84
+ * If not configured, PULL transfers are not supported.
85
+ * @internal
86
+ */
87
+ _dataPlanePath;
88
+ /**
89
+ * Policy requester instance for handling negotiation callbacks.
90
+ * @internal
91
+ */
92
+ _policyRequester;
93
+ /**
94
+ * Task scheduler for periodic stalled negotiation cleanup.
95
+ * @internal
96
+ */
97
+ _taskScheduler;
98
+ /**
99
+ * Registered negotiation callbacks from upstream callers, keyed by registration key.
100
+ * @internal
101
+ */
102
+ _negotiationCallbacks;
103
+ /**
104
+ * Create a new instance of DataspaceControlPlaneService.
105
+ * @param options The options for the service.
106
+ */
107
+ constructor(options) {
108
+ this._loggingComponent = ComponentFactory.getIfExists(options?.loggingComponentType ?? "logging");
109
+ // Retrieve PAP component with default
110
+ this._policyAdministrationPointComponent =
111
+ ComponentFactory.get(options?.policyAdministrationPointComponentType ?? "policy-administration-point");
112
+ // Retrieve PNP component with default
113
+ this._policyNegotiationPointComponent = ComponentFactory.get(options?.policyNegotiationPointComponentType ?? "policy-negotiation-point");
114
+ // Retrieve PNAP component (optional) for negotiation history
115
+ this._policyNegotiationAdminPointComponent =
116
+ ComponentFactory.getIfExists(options?.policyNegotiationAdminPointComponentType ?? "policy-negotiation-admin-point");
117
+ // Retrieve Federated Catalogue component with default
118
+ this._federatedCatalogueComponent = ComponentFactory.get(options?.federatedCatalogueComponentType ?? "federated-catalogue");
119
+ this._transferProcessStorage = EntityStorageConnectorFactory.get(options?.transferProcessEntityStorageType ?? "transfer-process");
120
+ this._trustComponent = ComponentFactory.get(options?.trustComponentType ?? "trust");
121
+ this._overrideTrustGeneratorType = options?.config?.overrideTrustGeneratorType;
122
+ this._dataPlanePath = Is.stringValue(options?.config?.dataPlanePath)
123
+ ? StringHelper.trimLeadingSlashes(options.config.dataPlanePath)
124
+ : undefined;
125
+ this._taskScheduler = ComponentFactory.getIfExists(options?.taskSchedulerComponentType ?? "task-scheduler");
126
+ this._negotiationCallbacks = new Map();
127
+ const internalCallback = {
128
+ onStateChanged: async (negotiationId, state, data) => {
129
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
130
+ try {
131
+ await cb.onStateChanged(negotiationId, state, data);
132
+ }
133
+ catch (error) {
134
+ await this._loggingComponent?.log({
135
+ level: "error",
136
+ source: DataspaceControlPlaneService.CLASS_NAME,
137
+ ts: Date.now(),
138
+ message: "negotiationCallbackError",
139
+ data: { key, negotiationId, method: "onStateChanged", error }
140
+ });
141
+ }
142
+ }
143
+ },
144
+ onCompleted: async (negotiationId, agreementId) => {
145
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
146
+ try {
147
+ await cb.onCompleted(negotiationId, agreementId);
148
+ }
149
+ catch (error) {
150
+ await this._loggingComponent?.log({
151
+ level: "error",
152
+ source: DataspaceControlPlaneService.CLASS_NAME,
153
+ ts: Date.now(),
154
+ message: "negotiationCallbackError",
155
+ data: { key, negotiationId, method: "onCompleted", error }
156
+ });
157
+ }
158
+ }
159
+ },
160
+ onFailed: async (negotiationId, reason) => {
161
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
162
+ try {
163
+ await cb.onFailed(negotiationId, reason);
164
+ }
165
+ catch (error) {
166
+ await this._loggingComponent?.log({
167
+ level: "error",
168
+ source: DataspaceControlPlaneService.CLASS_NAME,
169
+ ts: Date.now(),
170
+ message: "negotiationCallbackError",
171
+ data: { key, negotiationId, method: "onFailed", error }
172
+ });
173
+ }
174
+ }
175
+ }
176
+ };
177
+ this._policyRequester = new DataspaceControlPlanePolicyRequester(options?.loggingComponentType ?? "logging", internalCallback);
178
+ PolicyRequesterFactory.register(DataspaceControlPlaneService._REQUESTER_TYPE, () => this._policyRequester);
179
+ }
180
+ /**
181
+ * Returns the class name of the component.
182
+ * @returns The class name of the component.
183
+ */
184
+ className() {
185
+ return DataspaceControlPlaneService.CLASS_NAME;
186
+ }
187
+ /**
188
+ * Register a callback to receive negotiation state change notifications.
189
+ * Upstream modules (e.g. supply-chain) register their callback here.
190
+ * @param key A unique key identifying this callback registration.
191
+ * @param callback The callback interface to register.
192
+ */
193
+ registerNegotiationCallback(key, callback) {
194
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "key", key);
195
+ this._negotiationCallbacks.set(key, callback);
196
+ }
197
+ /**
198
+ * Unregister a previously registered negotiation callback.
199
+ * @param key The key used when registering the callback.
200
+ */
201
+ unregisterNegotiationCallback(key) {
202
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "key", key);
203
+ this._negotiationCallbacks.delete(key);
204
+ }
205
+ /**
206
+ * The service needs to be started when the application is initialized.
207
+ * Populates the Federated Catalogue with datasets from registered apps
208
+ * and starts the stalled negotiation cleanup task.
209
+ * @param nodeLoggingComponentType The node logging component type.
210
+ */
211
+ async start(nodeLoggingComponentType) {
212
+ const engine = EngineCoreFactory.getIfExists("engine");
213
+ // Skip if no engine exists OR if this is a clone instance
214
+ if (Is.empty(engine) || engine.isClone()) {
215
+ await this._loggingComponent?.log({
216
+ level: "debug",
217
+ ts: Date.now(),
218
+ source: DataspaceControlPlaneService.CLASS_NAME,
219
+ message: "engineCloneStart"
220
+ });
221
+ return;
222
+ }
223
+ await this._loggingComponent?.log({
224
+ level: "info",
225
+ ts: Date.now(),
226
+ source: DataspaceControlPlaneService.CLASS_NAME,
227
+ message: "populatingFederatedCatalogue"
228
+ });
229
+ // Get all registered apps
230
+ const appNames = DataspaceAppFactory.names();
231
+ await this._loggingComponent?.log({
232
+ level: "debug",
233
+ ts: Date.now(),
234
+ source: DataspaceControlPlaneService.CLASS_NAME,
235
+ message: "discoveredApps",
236
+ data: { count: appNames.length, apps: appNames }
237
+ });
238
+ let registeredCount = 0;
239
+ let errorCount = 0;
240
+ // Collect and register datasets from each app
241
+ for (const appName of appNames) {
242
+ try {
243
+ const app = DataspaceAppFactory.get(appName);
244
+ const datasets = await app.datasetsHandled();
245
+ for (const dataset of datasets) {
246
+ try {
247
+ // Ensure publisher is set (required for Federated Catalogue)
248
+ const datasetWithPublisher = await this.ensurePublisher(dataset);
249
+ // Register with Federated Catalogue
250
+ await this._federatedCatalogueComponent.set(datasetWithPublisher);
251
+ registeredCount++;
252
+ await this._loggingComponent?.log({
253
+ level: "debug",
254
+ ts: Date.now(),
255
+ source: DataspaceControlPlaneService.CLASS_NAME,
256
+ message: "datasetRegistered",
257
+ data: {
258
+ datasetId: getJsonLdId(datasetWithPublisher) ?? "",
259
+ appName
260
+ }
261
+ });
262
+ }
263
+ catch (error) {
264
+ errorCount++;
265
+ await this._loggingComponent?.log({
266
+ level: "error",
267
+ ts: Date.now(),
268
+ source: DataspaceControlPlaneService.CLASS_NAME,
269
+ message: "datasetRegistrationFailed",
270
+ error: BaseError.fromError(error),
271
+ data: {
272
+ datasetId: getJsonLdId(dataset) ?? "",
273
+ appName
274
+ }
275
+ });
276
+ }
277
+ }
278
+ }
279
+ catch (error) {
280
+ await this._loggingComponent?.log({
281
+ level: "error",
282
+ ts: Date.now(),
283
+ source: DataspaceControlPlaneService.CLASS_NAME,
284
+ message: "appDatasetsRetrievalFailed",
285
+ error: BaseError.fromError(error),
286
+ data: { appName }
287
+ });
288
+ }
289
+ }
290
+ await this._loggingComponent?.log({
291
+ level: "info",
292
+ ts: Date.now(),
293
+ source: DataspaceControlPlaneService.CLASS_NAME,
294
+ message: "federatedCataloguePopulated",
295
+ data: {
296
+ registeredCount,
297
+ errorCount,
298
+ totalApps: appNames.length
299
+ }
300
+ });
301
+ if (this._taskScheduler) {
302
+ await this._taskScheduler.addTask("control-plane-negotiation-cleanup", [
303
+ {
304
+ nextTriggerTime: Date.now(),
305
+ intervalMinutes: 5
306
+ }
307
+ ], async () => {
308
+ await this.cleanupStalledNegotiations();
309
+ });
310
+ }
311
+ }
312
+ /**
313
+ * Stop the service.
314
+ * Removes the stalled negotiation cleanup task.
315
+ * @param nodeLoggingComponentType The node logging component type.
316
+ */
317
+ async stop(nodeLoggingComponentType) {
318
+ if (this._taskScheduler) {
319
+ await this._taskScheduler.removeTask("control-plane-negotiation-cleanup");
320
+ }
321
+ }
322
+ // ----------------------------------------------------------------------------
323
+ // CONSUMER SIDE OPERATIONS
324
+ // ----------------------------------------------------------------------------
325
+ /**
326
+ * Request a Transfer Process.
327
+ * Creates a new Transfer Process in REQUESTED state.
328
+ * @param request Transfer request message (DSP compliant).
329
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
330
+ * @returns Transfer Process (DSP compliant) with state REQUESTED, or TransferError if the operation fails.
331
+ *
332
+ * Role Performed: Provider
333
+ * Called by: Consumer when it wants to request a new Transfer Process
334
+ */
335
+ async requestTransfer(request, trustPayload) {
336
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "requestTransfer");
337
+ const validationFailures = [];
338
+ const isConformant = await DataspaceProtocolHelper.checkConformance(JsonLdHelper.toNodeObject(request), validationFailures);
339
+ if (!isConformant) {
340
+ await this._loggingComponent?.log({
341
+ level: "error",
342
+ source: DataspaceControlPlaneService.CLASS_NAME,
343
+ ts: Date.now(),
344
+ message: "invalidTransferRequest",
345
+ data: { validationFailures }
346
+ });
347
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidTransferRequest", {
348
+ validationFailures
349
+ });
350
+ }
351
+ const providerPid = `urn:uuid:${RandomHelper.generateUuidV7()}`;
352
+ let datasetId;
353
+ let consumerIdentity;
354
+ let providerIdentity;
355
+ let policies = [];
356
+ try {
357
+ const agreement = await this.lookupAgreement(request.agreementId);
358
+ if (!agreement) {
359
+ await this._loggingComponent?.log({
360
+ level: "error",
361
+ source: DataspaceControlPlaneService.CLASS_NAME,
362
+ ts: Date.now(),
363
+ message: "agreementNotFound",
364
+ data: {
365
+ agreementId: request.agreementId,
366
+ hint: "Agreement must exist before transfer. Use contract negotiation to create agreement first."
367
+ }
368
+ });
369
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "agreementNotFound", request.agreementId, {
370
+ agreementId: request.agreementId,
371
+ hint: "Perform contract negotiation first to create an agreement"
372
+ });
373
+ }
374
+ const assigneeIdentity = OdrlPolicyHelper.extractAssigneeIdentity(agreement);
375
+ consumerIdentity = Is.array(assigneeIdentity) ? assigneeIdentity[0] : assigneeIdentity;
376
+ const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
377
+ providerIdentity = Is.array(assignerIdentity) ? assignerIdentity[0] : assignerIdentity;
378
+ datasetId = this.extractDatasetId(agreement);
379
+ await this.validateCatalogDataset(datasetId, agreement);
380
+ policies = [agreement];
381
+ }
382
+ catch (error) {
383
+ return transformToTransferError(error, { consumerPid: request.consumerPid, providerPid });
384
+ }
385
+ const now = new Date();
386
+ const storageEntity = {
387
+ id: Converter.bytesToHex(RandomHelper.generate(32)),
388
+ consumerPid: request.consumerPid,
389
+ providerPid,
390
+ state: DataspaceProtocolTransferProcessStateType.REQUESTED,
391
+ agreementId: request.agreementId,
392
+ datasetId,
393
+ consumerIdentity: ArrayHelper.fromObjectOrArray(consumerIdentity)[0],
394
+ providerIdentity: ArrayHelper.fromObjectOrArray(providerIdentity)[0],
395
+ // offerId should reference Catalog Offer (via Agreement)
396
+ // For now, use agreementId as reference (proper flow: Catalog → Negotiation → Agreement)
397
+ offerId: request.agreementId,
398
+ policies,
399
+ callbackAddress: request.callbackAddress,
400
+ format: request.format,
401
+ dataAddress: request.dataAddress,
402
+ dateCreated: now.toISOString(),
403
+ dateModified: now.toISOString()
404
+ };
405
+ await this._transferProcessStorage.set(storageEntity);
406
+ const entity = this.storageEntityToModel(storageEntity);
407
+ await this._loggingComponent?.log({
408
+ level: "info",
409
+ source: DataspaceControlPlaneService.CLASS_NAME,
410
+ ts: Date.now(),
411
+ message: "transferProcessInitiated",
412
+ data: {
413
+ consumerPid: request.consumerPid,
414
+ providerPid,
415
+ agreementId: request.agreementId
416
+ }
417
+ });
418
+ return {
419
+ "@context": [DataspaceProtocolContexts.Context],
420
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
421
+ consumerPid: entity.consumerPid,
422
+ providerPid: entity.providerPid,
423
+ state: entity.state
424
+ };
425
+ }
426
+ // ----------------------------------------------------------------------------
427
+ // PROVIDER SIDE OPERATIONS
428
+ // ----------------------------------------------------------------------------
429
+ /**
430
+ * Start a Transfer Process.
431
+ * Transitions Transfer Process from REQUESTED to STARTED state or resumes from SUSPENDED state.
432
+ * @param message Transfer start message (DSP compliant).
433
+ * @param publicOrigin The public origin URL of this service.
434
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
435
+ * @returns Transfer Start Message (DSP compliant) with dataAddress for PULL transfers, or TransferError if the operation fails.
436
+ *
437
+ * Role Performed: Provider / Consumer
438
+ */
439
+ async startTransfer(message, publicOrigin, trustPayload) {
440
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startTransfer");
441
+ const validationFailures = [];
442
+ const isConformant = await DataspaceProtocolHelper.checkConformance(JsonLdHelper.toNodeObject(message), validationFailures);
443
+ if (!isConformant) {
444
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidTransferStartMessage", {
445
+ validationFailures
446
+ }), message);
447
+ }
448
+ try {
449
+ const { entity, role } = await this.lookupTransferByMessage(message);
450
+ if (entity.state !== DataspaceProtocolTransferProcessStateType.REQUESTED &&
451
+ entity.state !== DataspaceProtocolTransferProcessStateType.SUSPENDED) {
452
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForStart", {
453
+ consumerPid: entity.consumerPid,
454
+ providerPid: entity.providerPid,
455
+ currentState: entity.state
456
+ }), message);
457
+ }
458
+ entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
459
+ entity.dateModified = new Date();
460
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
461
+ await this._loggingComponent?.log({
462
+ level: "info",
463
+ source: DataspaceControlPlaneService.CLASS_NAME,
464
+ ts: Date.now(),
465
+ message: "transferProcessStarted",
466
+ data: {
467
+ consumerPid: entity.consumerPid,
468
+ providerPid: entity.providerPid,
469
+ role
470
+ }
471
+ });
472
+ const response = {
473
+ "@context": [DataspaceProtocolContexts.Context],
474
+ "@type": DataspaceProtocolTransferProcessTypes.TransferStartMessage,
475
+ consumerPid: entity.consumerPid,
476
+ providerPid: entity.providerPid
477
+ };
478
+ // ============================================================================
479
+ // PULL vs PUSH Transfer Mode Detection (DSP Protocol)
480
+ // ============================================================================
481
+ // The transfer mode is determined by whether the consumer provided a dataAddress
482
+ // in the original TransferRequestMessage:
483
+ //
484
+ // PULL Mode (dataAddress NOT provided by consumer):
485
+ // - Consumer requests data but doesn't specify where to receive it
486
+ // - Provider generates access token and returns dataAddress in TransferStartMessage
487
+ // - Consumer uses the returned endpoint + token to PULL data from provider
488
+ // - Flow: Consumer → GET /entities?consumerPid=X (with Bearer token) → Provider
489
+ //
490
+ // PUSH Mode (dataAddress PROVIDED by consumer):
491
+ // - Consumer specifies endpoint where they want data sent (e.g., webhook URL)
492
+ // - Provider will PUSH data to the consumer's specified endpoint
493
+ // - Flow: Provider → POST to consumer's dataAddress endpoint → Consumer
494
+ //
495
+ // See: https://eclipse-dataspace-protocol-base.github.io/DataspaceProtocol/2025-1-err1/#transfer-start-message
496
+ // ============================================================================
497
+ if (Is.empty(entity.dataAddress)) {
498
+ if (!Is.stringValue(this._dataPlanePath)) {
499
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pullTransfersNotSupported", {
500
+ consumerPid: entity.consumerPid,
501
+ providerPid: entity.providerPid
502
+ }), message);
503
+ }
504
+ const accessToken = await this._trustComponent.generate(entity.consumerIdentity ?? entity.consumerPid, this._overrideTrustGeneratorType, {
505
+ consumerPid: entity.consumerPid,
506
+ providerPid: entity.providerPid,
507
+ agreementId: entity.agreementId,
508
+ datasetId: entity.datasetId
509
+ });
510
+ const tokenString = Converter.bytesToBase64(ObjectHelper.toBytes(accessToken));
511
+ const fullEndpoint = `${publicOrigin}/${this._dataPlanePath}`;
512
+ response.dataAddress = {
513
+ "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
514
+ endpointType: DataspaceProtocolEndpointType.HttpsQueryEndpoint,
515
+ endpoint: fullEndpoint,
516
+ endpointProperties: [
517
+ {
518
+ "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
519
+ name: "authorization",
520
+ value: tokenString
521
+ },
522
+ {
523
+ "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
524
+ name: "authType",
525
+ value: "bearer"
526
+ }
527
+ ]
528
+ };
529
+ await this._loggingComponent?.log({
530
+ level: "info",
531
+ source: DataspaceControlPlaneService.CLASS_NAME,
532
+ ts: Date.now(),
533
+ message: "dataAccessTokenGenerated",
534
+ data: {
535
+ consumerPid: entity.consumerPid,
536
+ providerPid: entity.providerPid,
537
+ endpoint: fullEndpoint,
538
+ transferMode: "PULL"
539
+ }
540
+ });
541
+ }
542
+ else {
543
+ // PUSH MODE: Provider will push data to consumer's specified endpoint
544
+ // The consumer provided dataAddress in TransferRequestMessage, indicating
545
+ // where they want data sent. Provider acknowledges and will push data there.
546
+ //
547
+ // TODO: PUSH transfer implementation (RFC-007 future work)
548
+ // Implementation would need to:
549
+ // 1. Generate provider's own trust token to authenticate when pushing to consumer
550
+ // 2. Validate consumer's dataAddress endpoint is reachable
551
+ // 3. Extract any consumer-provided authorization from endpointProperties
552
+ // 4. Schedule background task to push data to consumer's endpoint
553
+ // 5. POST data to entity.dataAddress.endpoint with provider's trust token
554
+ // 6. Implement retry logic with exponential backoff
555
+ // 7. Send TransferCompletionMessage when push completes successfully
556
+ //
557
+ // Trust flow for PUSH (mirror of PULL):
558
+ // - PULL: Provider generates token → Consumer uses to authenticate when pulling
559
+ // - PUSH: Provider generates token → Provider uses to authenticate when pushing
560
+ await this._loggingComponent?.log({
561
+ level: "warn",
562
+ source: DataspaceControlPlaneService.CLASS_NAME,
563
+ ts: Date.now(),
564
+ message: "pushTransferModeNotImplemented",
565
+ data: {
566
+ consumerPid: entity.consumerPid,
567
+ providerPid: entity.providerPid,
568
+ consumerEndpoint: entity.dataAddress.endpoint,
569
+ transferMode: "PUSH"
570
+ }
571
+ });
572
+ }
573
+ return response;
574
+ }
575
+ catch (error) {
576
+ return transformToTransferError(error, message);
577
+ }
578
+ }
579
+ // ----------------------------------------------------------------------------
580
+ // SHARED STATE MANAGEMENT OPERATIONS (Either Side)
581
+ // ----------------------------------------------------------------------------
582
+ /**
583
+ * Complete a Transfer Process.
584
+ * @param message Transfer completion message (DSP compliant).
585
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
586
+ * @returns Transfer Process (DSP compliant) with state COMPLETED, or TransferError if the operation fails.
587
+ */
588
+ async completeTransfer(message, trustPayload) {
589
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "completeTransfer");
590
+ const validationFailures = [];
591
+ const isConformant = await DataspaceProtocolHelper.checkConformance(JsonLdHelper.toNodeObject(message), validationFailures);
592
+ if (!isConformant) {
593
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidTransferCompletionMessage", { validationFailures }), message);
594
+ }
595
+ try {
596
+ const { entity, role } = await this.lookupTransferByMessage(message);
597
+ if (entity.state !== DataspaceProtocolTransferProcessStateType.STARTED) {
598
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForComplete", {
599
+ consumerPid: entity.consumerPid,
600
+ providerPid: entity.providerPid,
601
+ currentState: entity.state
602
+ }), message);
603
+ }
604
+ entity.state = DataspaceProtocolTransferProcessStateType.COMPLETED;
605
+ entity.dateModified = new Date();
606
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
607
+ await this._loggingComponent?.log({
608
+ level: "info",
609
+ source: DataspaceControlPlaneService.CLASS_NAME,
610
+ ts: Date.now(),
611
+ message: "transferProcessCompleted",
612
+ data: {
613
+ consumerPid: entity.consumerPid,
614
+ providerPid: entity.providerPid,
615
+ role
616
+ }
617
+ });
618
+ return {
619
+ "@context": [DataspaceProtocolContexts.Context],
620
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
621
+ consumerPid: entity.consumerPid,
622
+ providerPid: entity.providerPid,
623
+ state: entity.state
624
+ };
625
+ }
626
+ catch (error) {
627
+ return transformToTransferError(error, message);
628
+ }
629
+ }
630
+ /**
631
+ * Suspend a Transfer Process.
632
+ * @param message Transfer suspension message (DSP compliant).
633
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
634
+ * @returns Transfer Process (DSP compliant) with state SUSPENDED, or TransferError if the operation fails.
635
+ */
636
+ async suspendTransfer(message, trustPayload) {
637
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "suspendTransfer");
638
+ const validationFailures = [];
639
+ const isConformant = await DataspaceProtocolHelper.checkConformance(JsonLdHelper.toNodeObject(message), validationFailures);
640
+ if (!isConformant) {
641
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidTransferSuspensionMessage", { validationFailures }), message);
642
+ }
643
+ try {
644
+ const { entity, role } = await this.lookupTransferByMessage(message);
645
+ if (entity.state !== DataspaceProtocolTransferProcessStateType.STARTED) {
646
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForSuspend", {
647
+ consumerPid: entity.consumerPid,
648
+ providerPid: entity.providerPid,
649
+ currentState: entity.state
650
+ }), message);
651
+ }
652
+ entity.state = DataspaceProtocolTransferProcessStateType.SUSPENDED;
653
+ entity.dateModified = new Date();
654
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
655
+ await this._loggingComponent?.log({
656
+ level: "info",
657
+ source: DataspaceControlPlaneService.CLASS_NAME,
658
+ ts: Date.now(),
659
+ message: "transferProcessSuspended",
660
+ data: {
661
+ consumerPid: entity.consumerPid,
662
+ providerPid: entity.providerPid,
663
+ role,
664
+ reason: message.reason
665
+ }
666
+ });
667
+ return {
668
+ "@context": [DataspaceProtocolContexts.Context],
669
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
670
+ consumerPid: entity.consumerPid,
671
+ providerPid: entity.providerPid,
672
+ state: entity.state
673
+ };
674
+ }
675
+ catch (error) {
676
+ return transformToTransferError(error, message);
677
+ }
678
+ }
679
+ /**
680
+ * Terminate a Transfer Process.
681
+ * @param message Transfer termination message (DSP compliant).
682
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
683
+ * @returns Transfer Process (DSP compliant) with state TERMINATED, or TransferError if the operation fails.
684
+ */
685
+ async terminateTransfer(message, trustPayload) {
686
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "terminateTransfer");
687
+ const validationFailures = [];
688
+ const isConformant = await DataspaceProtocolHelper.checkConformance(JsonLdHelper.toNodeObject(message), validationFailures);
689
+ if (!isConformant) {
690
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidTransferTerminationMessage", { validationFailures }), message);
691
+ }
692
+ try {
693
+ const { entity, role } = await this.lookupTransferByMessage(message);
694
+ entity.state = DataspaceProtocolTransferProcessStateType.TERMINATED;
695
+ entity.dateModified = new Date();
696
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
697
+ await this._loggingComponent?.log({
698
+ level: "info",
699
+ source: DataspaceControlPlaneService.CLASS_NAME,
700
+ ts: Date.now(),
701
+ message: "transferProcessTerminated",
702
+ data: {
703
+ consumerPid: entity.consumerPid,
704
+ providerPid: entity.providerPid,
705
+ role,
706
+ reason: message.reason
707
+ }
708
+ });
709
+ return {
710
+ "@context": [DataspaceProtocolContexts.Context],
711
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
712
+ consumerPid: entity.consumerPid,
713
+ providerPid: entity.providerPid,
714
+ state: entity.state
715
+ };
716
+ }
717
+ catch (error) {
718
+ return transformToTransferError(error, message);
719
+ }
720
+ }
721
+ /**
722
+ * Get Transfer Process state.
723
+ * @param pid Process ID (consumerPid or providerPid).
724
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
725
+ * @returns Transfer Process (DSP compliant) with current state, or TransferError if the operation fails.
726
+ */
727
+ async getTransferProcess(pid, trustPayload) {
728
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "getTransferProcess");
729
+ try {
730
+ const { entity, role } = await this.lookupTransferByPid(pid);
731
+ await this._loggingComponent?.log({
732
+ level: "info",
733
+ source: DataspaceControlPlaneService.CLASS_NAME,
734
+ ts: Date.now(),
735
+ message: "transferProcessQueried",
736
+ data: {
737
+ pid,
738
+ role,
739
+ state: entity.state
740
+ }
741
+ });
742
+ return {
743
+ "@context": [DataspaceProtocolContexts.Context],
744
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
745
+ consumerPid: entity.consumerPid,
746
+ providerPid: entity.providerPid,
747
+ state: entity.state
748
+ };
749
+ }
750
+ catch (error) {
751
+ return transformToTransferError(error, { consumerPid: pid, providerPid: pid });
752
+ }
753
+ }
754
+ // ============================================================================
755
+ // CONTRACT NEGOTIATION
756
+ // ============================================================================
757
+ /**
758
+ * Negotiate a contract agreement with a provider.
759
+ * Returns immediately with a negotiationId. The caller is notified
760
+ * via the registered INegotiationCallback when the negotiation completes.
761
+ *
762
+ * @param offerId The offer ID from the provider's catalog.
763
+ * @param providerEndpoint The provider's contract negotiation endpoint URL.
764
+ * @param publicOrigin The public origin URL of this control plane (for callbacks).
765
+ * @param trustPayload The trust payload for authentication.
766
+ * @returns The negotiation ID. Use the registered callback for completion notification.
767
+ */
768
+ async negotiateAgreement(offerId, providerEndpoint, publicOrigin, trustPayload) {
769
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "offerId", offerId);
770
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerEndpoint", providerEndpoint);
771
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "publicOrigin", publicOrigin);
772
+ await this._loggingComponent?.log({
773
+ level: "info",
774
+ source: DataspaceControlPlaneService.CLASS_NAME,
775
+ ts: Date.now(),
776
+ message: "startingContractNegotiation",
777
+ data: { offerId, providerEndpoint, publicOrigin }
778
+ });
779
+ const catalogResult = await this._federatedCatalogueComponent.get(offerId);
780
+ if (isCatalogError(catalogResult)) {
781
+ if (isCatalogErrorName(catalogResult, NotFoundError.CLASS_NAME)) {
782
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "datasetNotFoundInCatalog", offerId, {
783
+ offerId,
784
+ providerEndpoint
785
+ });
786
+ }
787
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "catalogLookupFailedForNegotiation", {
788
+ offerId,
789
+ providerEndpoint,
790
+ errorCode: catalogResult.code
791
+ });
792
+ }
793
+ const rawOffers = this.getCatalogDatasetPolicies(catalogResult);
794
+ if (!Is.arrayValue(rawOffers)) {
795
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "datasetHasNoOffers", {
796
+ offerId,
797
+ datasetId: getJsonLdId(catalogResult) ?? ""
798
+ });
799
+ }
800
+ const catalogOffers = rawOffers.filter(offer => Is.object(offer));
801
+ if (!Is.arrayValue(catalogOffers)) {
802
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "datasetHasNoValidOffers", {
803
+ offerId,
804
+ datasetId: getJsonLdId(catalogResult) ?? ""
805
+ });
806
+ }
807
+ const matchingOffer = catalogOffers.find((offer) => {
808
+ const offerUid = OdrlPolicyHelper.getUid(offer);
809
+ return offerUid === offerId;
810
+ });
811
+ if (!matchingOffer) {
812
+ const availableOffers = catalogOffers
813
+ .map((o) => OdrlPolicyHelper.getUid(o) ?? "unknown")
814
+ .join(", ");
815
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "offerNotFoundInDataset", offerId, {
816
+ offerId,
817
+ datasetId: getJsonLdId(catalogResult) ?? "",
818
+ availableOffers
819
+ });
820
+ }
821
+ await this._loggingComponent?.log({
822
+ level: "info",
823
+ source: DataspaceControlPlaneService.CLASS_NAME,
824
+ ts: Date.now(),
825
+ message: "offerFoundInCatalog",
826
+ data: {
827
+ offerId,
828
+ datasetId: getJsonLdId(catalogResult) ?? "",
829
+ offerType: getJsonLdType(matchingOffer)
830
+ }
831
+ });
832
+ const negotiationId = await this._policyNegotiationPointComponent.sendRequestToProvider(providerEndpoint, DataspaceControlPlaneService._REQUESTER_TYPE, offerId, publicOrigin);
833
+ if (!negotiationId) {
834
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "negotiationInitiationFailed", {
835
+ offerId,
836
+ providerEndpoint
837
+ });
838
+ }
839
+ this._policyRequester.trackNegotiation(negotiationId);
840
+ await this._loggingComponent?.log({
841
+ level: "info",
842
+ source: DataspaceControlPlaneService.CLASS_NAME,
843
+ ts: Date.now(),
844
+ message: "negotiationInitiated",
845
+ data: { negotiationId, offerId }
846
+ });
847
+ return { negotiationId };
848
+ }
849
+ /**
850
+ * Get the current state of a contract negotiation.
851
+ * @param negotiationId The unique identifier of the negotiation.
852
+ * @param trustPayload The trust payload for authentication.
853
+ * @returns Current state of the negotiation.
854
+ */
855
+ async getNegotiation(negotiationId, trustPayload) {
856
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "negotiationId", negotiationId);
857
+ await this._loggingComponent?.log({
858
+ level: "info",
859
+ source: DataspaceControlPlaneService.CLASS_NAME,
860
+ ts: Date.now(),
861
+ message: "getNegotiation",
862
+ data: { negotiationId }
863
+ });
864
+ const result = await this._policyNegotiationPointComponent.getNegotiation(negotiationId, trustPayload);
865
+ await this._loggingComponent?.log({
866
+ level: "info",
867
+ source: DataspaceControlPlaneService.CLASS_NAME,
868
+ ts: Date.now(),
869
+ message: "negotiationStateRetrieved",
870
+ data: {
871
+ negotiationId,
872
+ type: getJsonLdType(result),
873
+ state: result.state
874
+ }
875
+ });
876
+ return result;
877
+ }
878
+ /**
879
+ * Get negotiation history.
880
+ * @param state Optional filter by negotiation state.
881
+ * @param cursor Optional pagination cursor.
882
+ * @param trustPayload Trust payload for authentication.
883
+ * @returns List of negotiation history entries with pagination.
884
+ */
885
+ async getNegotiationHistory(state, cursor, trustPayload) {
886
+ await this._loggingComponent?.log({
887
+ level: "info",
888
+ source: DataspaceControlPlaneService.CLASS_NAME,
889
+ ts: Date.now(),
890
+ message: "getNegotiationHistory",
891
+ data: { state, cursor }
892
+ });
893
+ if (!this._policyNegotiationAdminPointComponent) {
894
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pnapNotConfigured", {
895
+ message: "Policy Negotiation Admin Point not configured. " +
896
+ "Set policyNegotiationAdminPointComponentType in constructor options."
897
+ });
898
+ }
899
+ const { items: pnapNegotiations, cursor: nextCursor } = await this._policyNegotiationAdminPointComponent.query(state, cursor);
900
+ const negotiations = pnapNegotiations.map(pnapNeg => {
901
+ const dspNegotiation = {
902
+ "@context": [DataspaceProtocolContexts.Context],
903
+ "@type": DataspaceProtocolContractNegotiationTypes.ContractNegotiation,
904
+ consumerPid: pnapNeg.id,
905
+ providerPid: pnapNeg.correlationId,
906
+ state: pnapNeg.state
907
+ };
908
+ return {
909
+ negotiation: dspNegotiation,
910
+ createdAt: pnapNeg.dateCreated,
911
+ offerId: OdrlPolicyHelper.getUid(pnapNeg.offer),
912
+ agreementId: OdrlPolicyHelper.getUid(pnapNeg.agreement)
913
+ };
914
+ });
915
+ await this._loggingComponent?.log({
916
+ level: "info",
917
+ source: DataspaceControlPlaneService.CLASS_NAME,
918
+ ts: Date.now(),
919
+ message: "negotiationHistoryRetrieved",
920
+ data: {
921
+ count: negotiations.length,
922
+ state,
923
+ hasCursor: Boolean(nextCursor)
924
+ }
925
+ });
926
+ return {
927
+ negotiations,
928
+ cursor: nextCursor,
929
+ count: negotiations.length
930
+ };
931
+ }
932
+ // ============================================================================
933
+ // RESOLVER METHODS - IDataspaceControlPlaneResolverComponent
934
+ // ============================================================================
935
+ /**
936
+ * Resolve consumerPid to Transfer Context.
937
+ * @param consumerPid Consumer Process ID.
938
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
939
+ * @returns Transfer Context with Agreement, datasetId, and Transfer Process metadata.
940
+ */
941
+ async resolveConsumerPid(consumerPid, trustPayload) {
942
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "consumerPid", consumerPid);
943
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "resolveConsumerPid");
944
+ const storageEntity = await this._transferProcessStorage.get(consumerPid);
945
+ if (!storageEntity) {
946
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", undefined, { consumerPid });
947
+ }
948
+ const entity = this.storageEntityToModel(storageEntity);
949
+ if (entity.state === DataspaceProtocolTransferProcessStateType.TERMINATED) {
950
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessTerminated", {
951
+ consumerPid
952
+ });
953
+ }
954
+ const agreement = await this.lookupAgreement(entity.agreementId);
955
+ const contextIds = await ContextIdStore.getContextIds();
956
+ const currentOrgId = contextIds?.[ContextIdKeys.Organization];
957
+ if (!currentOrgId) {
958
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "organizationContextMissing", {
959
+ consumerPid
960
+ });
961
+ }
962
+ const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
963
+ const assignerId = Is.array(assignerIdentity) ? assignerIdentity[0] : assignerIdentity;
964
+ if (assignerId !== currentOrgId) {
965
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "agreementAssignerMismatch", {
966
+ consumerPid,
967
+ agreementId: OdrlPolicyHelper.getUid(agreement),
968
+ expectedOrgId: currentOrgId,
969
+ actualAssigner: assignerId
970
+ });
971
+ }
972
+ await this._loggingComponent?.log({
973
+ level: "info",
974
+ source: DataspaceControlPlaneService.CLASS_NAME,
975
+ ts: Date.now(),
976
+ message: "resolvedConsumerPid",
977
+ data: {
978
+ consumerPid,
979
+ datasetId: entity.datasetId,
980
+ state: entity.state,
981
+ consumerIdentity: entity.consumerIdentity,
982
+ agreementId: OdrlPolicyHelper.getUid(agreement),
983
+ organizationId: currentOrgId
984
+ }
985
+ });
986
+ return {
987
+ consumerPid: entity.consumerPid,
988
+ providerPid: entity.providerPid,
989
+ agreement,
990
+ datasetId: entity.datasetId,
991
+ offerId: entity.offerId,
992
+ state: entity.state,
993
+ consumerIdentity: entity.consumerIdentity,
994
+ providerIdentity: entity.providerIdentity,
995
+ dataAddress: entity.dataAddress
996
+ };
997
+ }
998
+ /**
999
+ * Resolve providerPid to Transfer Context.
1000
+ * @param providerPid Provider Process ID.
1001
+ * @param trustPayload Trust payload containing authorization information (Base64-encoded token).
1002
+ * @returns Transfer Context with Agreement, datasetId, and Transfer Process metadata.
1003
+ */
1004
+ async resolveProviderPid(providerPid, trustPayload) {
1005
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "providerPid", providerPid);
1006
+ await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "resolveProviderPid");
1007
+ const { entity } = await this.lookupTransferByPid(providerPid);
1008
+ if (entity.state === DataspaceProtocolTransferProcessStateType.TERMINATED) {
1009
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessTerminatedProvider", {
1010
+ providerPid
1011
+ });
1012
+ }
1013
+ const agreement = await this.lookupAgreement(entity.agreementId);
1014
+ const contextIds = await ContextIdStore.getContextIds();
1015
+ const currentOrgId = contextIds?.[ContextIdKeys.Organization];
1016
+ if (!currentOrgId) {
1017
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "organizationContextMissingProvider", {
1018
+ providerPid
1019
+ });
1020
+ }
1021
+ // Extract assigner UID (can be string, array, or IOdrlParty object)
1022
+ const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
1023
+ const assignerId = Is.array(assignerIdentity) ? assignerIdentity[0] : assignerIdentity;
1024
+ if (assignerId !== currentOrgId) {
1025
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "agreementAssignerMismatchProvider", {
1026
+ providerPid,
1027
+ agreementId: OdrlPolicyHelper.getUid(agreement),
1028
+ expectedOrgId: currentOrgId,
1029
+ actualAssigner: assignerId
1030
+ });
1031
+ }
1032
+ // Validate that Agreement assignee matches expected consumer identity
1033
+ // This ensures we're pushing to the correct consumer
1034
+ // If assignee is undefined but consumerIdentity has a value, this is also a mismatch
1035
+ const assigneeIdentity = OdrlPolicyHelper.extractAssigneeIdentity(agreement);
1036
+ const assigneeId = Is.array(assigneeIdentity) ? assigneeIdentity[0] : assigneeIdentity;
1037
+ if (assigneeId !== entity.consumerIdentity) {
1038
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "agreementAssigneeMismatch", {
1039
+ providerPid,
1040
+ agreementId: OdrlPolicyHelper.getUid(agreement),
1041
+ expectedConsumerIdentity: entity.consumerIdentity,
1042
+ actualAssignee: assigneeId
1043
+ });
1044
+ }
1045
+ await this._loggingComponent?.log({
1046
+ level: "info",
1047
+ source: DataspaceControlPlaneService.CLASS_NAME,
1048
+ ts: Date.now(),
1049
+ message: "resolvedProviderPid",
1050
+ data: {
1051
+ providerPid,
1052
+ datasetId: entity.datasetId,
1053
+ state: entity.state,
1054
+ consumerIdentity: entity.consumerIdentity,
1055
+ providerIdentity: entity.providerIdentity,
1056
+ agreementId: OdrlPolicyHelper.getUid(agreement),
1057
+ organizationId: currentOrgId
1058
+ }
1059
+ });
1060
+ return {
1061
+ consumerPid: entity.consumerPid,
1062
+ providerPid: entity.providerPid,
1063
+ agreement,
1064
+ datasetId: entity.datasetId,
1065
+ offerId: entity.offerId,
1066
+ state: entity.state,
1067
+ consumerIdentity: entity.consumerIdentity,
1068
+ providerIdentity: entity.providerIdentity,
1069
+ dataAddress: entity.dataAddress
1070
+ };
1071
+ }
1072
+ // ============================================================================
1073
+ // PRIVATE HELPER METHODS
1074
+ // ============================================================================
1075
+ /**
1076
+ * Cleanup stalled negotiations.
1077
+ * Called periodically by the task scheduler.
1078
+ * @internal
1079
+ */
1080
+ async cleanupStalledNegotiations() {
1081
+ const now = Date.now();
1082
+ const stalled = [];
1083
+ for (const [negotiationId, state] of this._policyRequester.getActiveNegotiations()) {
1084
+ if (now - state.updatedAt > STALLED_NEGOTIATION_THRESHOLD_MS) {
1085
+ stalled.push(negotiationId);
1086
+ }
1087
+ }
1088
+ for (const negotiationId of stalled) {
1089
+ this._policyRequester.removeNegotiation(negotiationId);
1090
+ await this._loggingComponent?.log({
1091
+ level: "warn",
1092
+ source: DataspaceControlPlaneService.CLASS_NAME,
1093
+ ts: Date.now(),
1094
+ message: "stalledNegotiationCleanedUp",
1095
+ data: { negotiationId }
1096
+ });
1097
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
1098
+ try {
1099
+ await cb.onFailed(negotiationId, "negotiationStalled");
1100
+ }
1101
+ catch (error) {
1102
+ await this._loggingComponent?.log({
1103
+ level: "error",
1104
+ source: DataspaceControlPlaneService.CLASS_NAME,
1105
+ ts: Date.now(),
1106
+ message: "negotiationCallbackError",
1107
+ data: { key, negotiationId, method: "onFailed", error }
1108
+ });
1109
+ }
1110
+ }
1111
+ }
1112
+ if (stalled.length > 0) {
1113
+ await this._loggingComponent?.log({
1114
+ level: "info",
1115
+ source: DataspaceControlPlaneService.CLASS_NAME,
1116
+ ts: Date.now(),
1117
+ message: "stalledNegotiationsCleanupComplete",
1118
+ data: { cleanedUp: stalled.length }
1119
+ });
1120
+ }
1121
+ }
1122
+ /**
1123
+ * Convert a storage entity to model.
1124
+ * @param storageEntity The entity from storage.
1125
+ * @returns The model representation.
1126
+ * @internal
1127
+ */
1128
+ storageEntityToModel(storageEntity) {
1129
+ return {
1130
+ id: storageEntity.id,
1131
+ consumerPid: storageEntity.consumerPid,
1132
+ providerPid: storageEntity.providerPid,
1133
+ state: storageEntity.state,
1134
+ agreementId: storageEntity.agreementId,
1135
+ datasetId: storageEntity.datasetId,
1136
+ offerId: storageEntity.offerId,
1137
+ consumerIdentity: storageEntity.consumerIdentity,
1138
+ providerIdentity: storageEntity.providerIdentity,
1139
+ format: storageEntity.format,
1140
+ callbackAddress: storageEntity.callbackAddress,
1141
+ dateCreated: new Date(storageEntity.dateCreated),
1142
+ dateModified: new Date(storageEntity.dateModified),
1143
+ policies: storageEntity.policies,
1144
+ dataAddress: storageEntity.dataAddress
1145
+ };
1146
+ }
1147
+ /**
1148
+ * Convert a model to storage entity.
1149
+ * @param entity The model representation.
1150
+ * @returns The entity for storage.
1151
+ * @internal
1152
+ */
1153
+ modelToStorageEntity(entity) {
1154
+ return {
1155
+ id: entity.id,
1156
+ consumerPid: entity.consumerPid,
1157
+ providerPid: entity.providerPid,
1158
+ state: entity.state,
1159
+ agreementId: entity.agreementId,
1160
+ datasetId: entity.datasetId,
1161
+ offerId: entity.offerId,
1162
+ consumerIdentity: entity.consumerIdentity,
1163
+ providerIdentity: entity.providerIdentity,
1164
+ format: entity.format,
1165
+ callbackAddress: entity.callbackAddress,
1166
+ dateCreated: entity.dateCreated.toISOString(),
1167
+ dateModified: entity.dateModified.toISOString(),
1168
+ policies: entity.policies,
1169
+ dataAddress: entity.dataAddress
1170
+ };
1171
+ }
1172
+ /**
1173
+ * Look up Agreement from PAP.
1174
+ * @param agreementId Agreement ID.
1175
+ * @returns Agreement.
1176
+ * @internal
1177
+ */
1178
+ async lookupAgreement(agreementId) {
1179
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "agreementId", agreementId);
1180
+ let agreement;
1181
+ try {
1182
+ agreement = await this._policyAdministrationPointComponent.getAgreement(agreementId);
1183
+ }
1184
+ catch (error) {
1185
+ if (BaseError.isErrorName(error, NotFoundError.CLASS_NAME)) {
1186
+ throw error;
1187
+ }
1188
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementLookupFailed", { agreementId }, error);
1189
+ }
1190
+ return agreement;
1191
+ }
1192
+ /**
1193
+ * Extract dataset ID from Agreement target.
1194
+ * @param agreement Agreement.
1195
+ * @returns Dataset ID.
1196
+ * @internal
1197
+ */
1198
+ extractDatasetId(agreement) {
1199
+ if (Is.empty(agreement.target)) {
1200
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementMissingTarget", {
1201
+ agreementId: OdrlPolicyHelper.getUid(agreement)
1202
+ });
1203
+ }
1204
+ const targetIds = OdrlPolicyHelper.getTargets(agreement);
1205
+ if (targetIds.length === 0) {
1206
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementTargetMissingUid", {
1207
+ agreementId: OdrlPolicyHelper.getUid(agreement)
1208
+ });
1209
+ }
1210
+ // Validate single target, multiple targets are not currently supported
1211
+ if (targetIds.length > 1) {
1212
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementMultipleTargetsNotSupported", {
1213
+ agreementId: OdrlPolicyHelper.getUid(agreement),
1214
+ targetCount: targetIds.length
1215
+ });
1216
+ }
1217
+ return targetIds[0];
1218
+ }
1219
+ /**
1220
+ * Validate that the dataset exists in the Federated Catalogue.
1221
+ * @param datasetId Dataset identifier extracted from Agreement.
1222
+ * @param agreement The Agreement being validated.
1223
+ * @internal
1224
+ */
1225
+ async validateCatalogDataset(datasetId, agreement) {
1226
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "datasetId", datasetId);
1227
+ Guards.object(DataspaceControlPlaneService.CLASS_NAME, "agreement", agreement);
1228
+ // Lookup dataset in catalog
1229
+ const catalogResult = await this._federatedCatalogueComponent.get(datasetId);
1230
+ if (isCatalogError(catalogResult)) {
1231
+ if (isCatalogErrorName(catalogResult, NotFoundError.CLASS_NAME)) {
1232
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "datasetNotInCatalog", datasetId, {
1233
+ datasetId,
1234
+ agreementId: OdrlPolicyHelper.getUid(agreement)
1235
+ });
1236
+ }
1237
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "catalogLookupFailed", {
1238
+ datasetId,
1239
+ agreementId: agreement.uid,
1240
+ errorCode: catalogResult.code
1241
+ });
1242
+ }
1243
+ await this._loggingComponent?.log({
1244
+ level: "info",
1245
+ source: DataspaceControlPlaneService.CLASS_NAME,
1246
+ ts: Date.now(),
1247
+ message: "catalogDatasetFound",
1248
+ data: {
1249
+ datasetId,
1250
+ agreementId: agreement.uid,
1251
+ datasetTitle: catalogResult["dcterms:title"]
1252
+ }
1253
+ });
1254
+ await this.validateAgreementMatchesOffer(agreement, catalogResult);
1255
+ }
1256
+ /**
1257
+ * Extract PID from DSP message and lookup Transfer Process with role detection.
1258
+ * @param message DSP protocol message with consumerPid and/or providerPid fields.
1259
+ * @returns Transfer Process entity and our role in this transfer.
1260
+ * @internal
1261
+ */
1262
+ async lookupTransferByMessage(message) {
1263
+ const pid = message.consumerPid ?? message.providerPid;
1264
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
1265
+ return this.lookupTransferByPid(pid);
1266
+ }
1267
+ /**
1268
+ * Lookup Transfer Process by PID and determine our role.
1269
+ * @param pid Either consumerPid or providerPid.
1270
+ * @returns Transfer Process entity and our role in this transfer.
1271
+ * @internal
1272
+ */
1273
+ async lookupTransferByPid(pid) {
1274
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "pid", pid);
1275
+ // Check if pid is a consumerPid (primary key lookup)
1276
+ const storageEntity = await this._transferProcessStorage.get(pid);
1277
+ if (storageEntity) {
1278
+ return {
1279
+ entity: this.storageEntityToModel(storageEntity),
1280
+ role: TransferProcessRole.Consumer
1281
+ };
1282
+ }
1283
+ // Check if pid is a providerPid (secondary key lookup)
1284
+ const providerPidEntity = await this._transferProcessStorage.get(pid, "providerPid");
1285
+ if (providerPidEntity) {
1286
+ return {
1287
+ entity: this.storageEntityToModel(providerPidEntity),
1288
+ role: TransferProcessRole.Provider
1289
+ };
1290
+ }
1291
+ throw new NotFoundError(DataspaceControlPlaneService.CLASS_NAME, "transferProcessNotFound", pid, {
1292
+ pid
1293
+ });
1294
+ }
1295
+ /**
1296
+ * Get raw policy entries from a catalog dataset.
1297
+ * @param catalogDataset Catalog dataset.
1298
+ * @returns Raw policy entries.
1299
+ * @internal
1300
+ */
1301
+ getCatalogDatasetPolicies(catalogDataset) {
1302
+ // Support both "odrl:hasPolicy" and "hasPolicy" to accommodate different catalog implementations
1303
+ if (Is.object(catalogDataset) && !Is.empty(catalogDataset["odrl:hasPolicy"])) {
1304
+ return (ArrayHelper.fromObjectOrArray(catalogDataset["odrl:hasPolicy"]) ?? []);
1305
+ }
1306
+ if (Is.object(catalogDataset) &&
1307
+ !Is.empty(catalogDataset.hasPolicy)) {
1308
+ return (ArrayHelper.fromObjectOrArray(catalogDataset.hasPolicy) ?? []);
1309
+ }
1310
+ return [];
1311
+ }
1312
+ /**
1313
+ * Validate that the Agreement policies match at least one Catalog Offer.
1314
+ * @param agreement Agreement to validate.
1315
+ * @param catalogDataset Catalog dataset containing Offers.
1316
+ * @internal
1317
+ */
1318
+ async validateAgreementMatchesOffer(agreement, catalogDataset) {
1319
+ Guards.object(DataspaceControlPlaneService.CLASS_NAME, "agreement", agreement);
1320
+ Guards.object(DataspaceControlPlaneService.CLASS_NAME, "catalogDataset", catalogDataset);
1321
+ const rawOffers = this.getCatalogDatasetPolicies(catalogDataset);
1322
+ if (!Is.arrayValue(rawOffers)) {
1323
+ await this._loggingComponent?.log({
1324
+ level: "warn",
1325
+ source: DataspaceControlPlaneService.CLASS_NAME,
1326
+ ts: Date.now(),
1327
+ message: "catalogDatasetHasNoOffers",
1328
+ data: {
1329
+ datasetId: getJsonLdId(catalogDataset) ?? "",
1330
+ agreementId: agreement.uid
1331
+ }
1332
+ });
1333
+ return;
1334
+ }
1335
+ const catalogOffers = rawOffers.filter(offer => Is.object(offer));
1336
+ if (!Is.arrayValue(catalogOffers)) {
1337
+ await this._loggingComponent?.log({
1338
+ level: "warn",
1339
+ source: DataspaceControlPlaneService.CLASS_NAME,
1340
+ ts: Date.now(),
1341
+ message: "catalogDatasetHasNoOffers",
1342
+ data: {
1343
+ datasetId: getJsonLdId(catalogDataset) ?? "",
1344
+ agreementId: agreement.uid
1345
+ }
1346
+ });
1347
+ return;
1348
+ }
1349
+ const matchingOffer = catalogOffers.find((offer) => OdrlPolicyHelper.getUid(offer) === agreement.uid ||
1350
+ this.isPolicyDerivedFrom(agreement, offer));
1351
+ if (!matchingOffer) {
1352
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementNotMatchingOffer", {
1353
+ agreementId: agreement.uid,
1354
+ datasetId: getJsonLdId(catalogDataset) ?? "",
1355
+ availableOffers: catalogOffers
1356
+ .map((o) => OdrlPolicyHelper.getUid(o) ?? "unknown")
1357
+ .join(", ")
1358
+ });
1359
+ }
1360
+ await this._loggingComponent?.log({
1361
+ level: "info",
1362
+ source: DataspaceControlPlaneService.CLASS_NAME,
1363
+ ts: Date.now(),
1364
+ message: "agreementMatchedOffer",
1365
+ data: {
1366
+ agreementId: agreement.uid,
1367
+ offerId: OdrlPolicyHelper.getUid(matchingOffer) ?? "",
1368
+ datasetId: getJsonLdId(catalogDataset) ?? ""
1369
+ }
1370
+ });
1371
+ }
1372
+ /**
1373
+ * Check if an Agreement is derived from an Offer.
1374
+ * @param agreement Agreement to check.
1375
+ * @param offer Offer to compare against.
1376
+ * @returns True if Agreement appears derived from Offer.
1377
+ * @internal
1378
+ */
1379
+ isPolicyDerivedFrom(agreement, offer) {
1380
+ const agreementTargets = OdrlPolicyHelper.getTargets(agreement);
1381
+ const offerTargets = OdrlPolicyHelper.getTargets(offer);
1382
+ if (agreementTargets.length === 0 ||
1383
+ offerTargets.length === 0 ||
1384
+ !agreementTargets.some((agreementTarget) => offerTargets.includes(agreementTarget))) {
1385
+ return false;
1386
+ }
1387
+ const agreementAssignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
1388
+ const offerAssignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(offer);
1389
+ const agreementAssigner = ArrayHelper.fromObjectOrArray(agreementAssignerIdentity);
1390
+ const offerAssigner = ArrayHelper.fromObjectOrArray(offerAssignerIdentity);
1391
+ if (!agreementAssigner.some(assigner => offerAssigner.includes(assigner))) {
1392
+ return false;
1393
+ }
1394
+ if (!Is.array(agreement.permission) || !Is.array(offer.permission)) {
1395
+ return false;
1396
+ }
1397
+ return true;
1398
+ }
1399
+ /**
1400
+ * Ensure the dataset has a publisher set.
1401
+ * @param dataset The dataset.
1402
+ * @returns The dataset with publisher set.
1403
+ * @internal
1404
+ */
1405
+ async ensurePublisher(dataset) {
1406
+ if (dataset["dcterms:publisher"]) {
1407
+ return dataset;
1408
+ }
1409
+ const contextIds = await ContextIdStore.getContextIds();
1410
+ const orgId = contextIds?.[ContextIdKeys.Organization];
1411
+ if (orgId) {
1412
+ return {
1413
+ ...dataset,
1414
+ "dcterms:publisher": orgId
1415
+ };
1416
+ }
1417
+ return dataset;
1418
+ }
1419
+ }
1420
+ //# sourceMappingURL=dataspaceControlPlaneService.js.map