@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.
- package/LICENSE +201 -0
- package/README.md +119 -0
- package/dist/es/dataspaceControlPlanePolicyRequester.js +228 -0
- package/dist/es/dataspaceControlPlanePolicyRequester.js.map +1 -0
- package/dist/es/dataspaceControlPlaneRoutes.js +202 -0
- package/dist/es/dataspaceControlPlaneRoutes.js.map +1 -0
- package/dist/es/dataspaceControlPlaneService.js +1420 -0
- package/dist/es/dataspaceControlPlaneService.js.map +1 -0
- package/dist/es/index.js +11 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/IDataspaceControlPlaneServiceConfig.js +4 -0
- package/dist/es/models/IDataspaceControlPlaneServiceConfig.js.map +1 -0
- package/dist/es/models/IDataspaceControlPlaneServiceConstructorOptions.js +2 -0
- package/dist/es/models/IDataspaceControlPlaneServiceConstructorOptions.js.map +1 -0
- package/dist/es/models/INegotiationState.js +4 -0
- package/dist/es/models/INegotiationState.js.map +1 -0
- package/dist/es/restEntryPoints.js +15 -0
- package/dist/es/restEntryPoints.js.map +1 -0
- package/dist/es/schema.js +11 -0
- package/dist/es/schema.js.map +1 -0
- package/dist/es/utils/dataHelpers.js +22 -0
- package/dist/es/utils/dataHelpers.js.map +1 -0
- package/dist/es/utils/transferErrorUtils.js +78 -0
- package/dist/es/utils/transferErrorUtils.js.map +1 -0
- package/dist/types/dataspaceControlPlanePolicyRequester.d.ts +80 -0
- package/dist/types/dataspaceControlPlaneRoutes.d.ts +17 -0
- package/dist/types/dataspaceControlPlaneService.d.ts +153 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/models/IDataspaceControlPlaneServiceConfig.d.ts +20 -0
- package/dist/types/models/IDataspaceControlPlaneServiceConstructorOptions.d.ts +69 -0
- package/dist/types/models/INegotiationState.d.ts +27 -0
- package/dist/types/restEntryPoints.d.ts +7 -0
- package/dist/types/schema.d.ts +4 -0
- package/dist/types/utils/dataHelpers.d.ts +1 -0
- package/dist/types/utils/transferErrorUtils.d.ts +39 -0
- package/docs/API.md +341 -0
- package/docs/changelog.md +31 -0
- package/docs/examples.md +1 -0
- package/docs/reference/classes/DataspaceControlPlanePolicyRequester.md +244 -0
- package/docs/reference/classes/DataspaceControlPlaneService.md +549 -0
- package/docs/reference/functions/generateRestRoutesDataspaceControlPlane.md +29 -0
- package/docs/reference/functions/initSchema.md +9 -0
- package/docs/reference/index.md +22 -0
- package/docs/reference/interfaces/IDataspaceControlPlaneServiceConfig.md +26 -0
- package/docs/reference/interfaces/IDataspaceControlPlaneServiceConstructorOptions.md +160 -0
- package/docs/reference/interfaces/INegotiationState.md +43 -0
- package/docs/reference/variables/restEntryPoints.md +7 -0
- package/docs/reference/variables/tagsDataspaceControlPlane.md +5 -0
- package/locales/en.json +93 -0
- 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
|