@twin.org/dataspace-control-plane-service 0.0.3-next.28 → 0.0.3-next.30

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.
@@ -1,7 +1,7 @@
1
1
  import { ContextIdKeys, ContextIdStore } from "@twin.org/context";
2
- import { ArrayHelper, BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, NotFoundError, RandomHelper, StringHelper, UnauthorizedError, ValidationError } from "@twin.org/core";
2
+ import { ArrayHelper, BaseError, ComponentFactory, Converter, GeneralError, Guards, Is, NotFoundError, ObjectHelper, RandomHelper, StringHelper, UnauthorizedError, ValidationError } from "@twin.org/core";
3
3
  import { JsonLdHelper } from "@twin.org/data-json-ld";
4
- import { DataspaceAppFactory, TransferProcessRole } from "@twin.org/dataspace-models";
4
+ import { DataspaceAppFactory, DataspaceTransferFormat, TransferProcessRole, getJsonLdId, getJsonLdType } from "@twin.org/dataspace-models";
5
5
  import { EngineCoreFactory } from "@twin.org/engine-models";
6
6
  import { ComparisonOperator } from "@twin.org/entity";
7
7
  import { EntityStorageConnectorFactory } from "@twin.org/entity-storage-models";
@@ -9,7 +9,7 @@ import { OdrlPolicyHelper, PolicyRequesterFactory } from "@twin.org/rights-manag
9
9
  import { DataspaceProtocolContexts, DataspaceProtocolContractNegotiationTypes, DataspaceProtocolEndpointType, DataspaceProtocolHelper, DataspaceProtocolTransferProcessStateType, DataspaceProtocolTransferProcessTypes } from "@twin.org/standards-dataspace-protocol";
10
10
  import { TrustHelper } from "@twin.org/trust-models";
11
11
  import { DataspaceControlPlanePolicyRequester } from "./dataspaceControlPlanePolicyRequester.js";
12
- import { getJsonLdId, getJsonLdType } from "./utils/dataHelpers.js";
12
+ import { EndpointProperties } from "./models/endpointProperties.js";
13
13
  import { isCatalogError, isCatalogErrorName, transformToTransferError } from "./utils/transferErrorUtils.js";
14
14
  /**
15
15
  * Dataspace Control Plane Service implementation.
@@ -35,6 +35,14 @@ export class DataspaceControlPlaneService {
35
35
  * @internal
36
36
  */
37
37
  static _STALLED_NEGOTIATION_THRESHOLD_MS = 30 * 60 * 1000;
38
+ /**
39
+ * Matches the base64url encoding of a BLAKE2b-256 hash (32 bytes → 43 base64url
40
+ * chars, no padding). Used to discriminate the tenant-hash portion of a
41
+ * composite tenant identifier from a bare IOTA DID, whose `<id>` segment is
42
+ * always `0x<64-hex>` (66 chars) and cannot match.
43
+ * @internal
44
+ */
45
+ static _TENANT_HASH_PATTERN = /^[\w-]{43}$/;
38
46
  /**
39
47
  * The logging component.
40
48
  * @internal
@@ -92,6 +100,12 @@ export class DataspaceControlPlaneService {
92
100
  * @internal
93
101
  */
94
102
  _dataPlanePath;
103
+ /**
104
+ * Factory key used to look up the data plane component. Resolved lazily at push-time
105
+ * (not at construction) so the data plane may register after the control plane is built.
106
+ * @internal
107
+ */
108
+ _dataPlaneComponentType;
95
109
  /**
96
110
  * Policy requester instance for handling negotiation callbacks.
97
111
  * @internal
@@ -133,61 +147,20 @@ export class DataspaceControlPlaneService {
133
147
  this._trustComponent = ComponentFactory.get(options?.trustComponentType ?? "trust");
134
148
  this._overrideTrustGeneratorType = options?.config?.overrideTrustGeneratorType;
135
149
  this._dataPlanePath = Is.stringValue(options?.config?.dataPlanePath)
136
- ? StringHelper.trimLeadingSlashes(options.config.dataPlanePath)
150
+ ? StringHelper.trimTrailingSlashes(StringHelper.trimLeadingSlashes(options.config.dataPlanePath))
137
151
  : undefined;
138
152
  this._taskScheduler = ComponentFactory.getIfExists(options?.taskSchedulerComponentType ?? "task-scheduler");
153
+ // Data plane component is optional and resolved lazily. The control plane is initialised
154
+ // BEFORE the data plane in engine startup order, so `getRegisteredInstanceTypeOptional`
155
+ // returns undefined when the engine constructs us — the wiring override can't fire here.
156
+ // The default therefore matches the engine's actual factory key
157
+ // (`nameofKebabCase(DataspaceDataPlaneService)`) so the late-bound `requireDataPlane()`
158
+ // lookup at push time finds it regardless of init order.
159
+ this._dataPlaneComponentType =
160
+ options?.dataPlaneComponentType ?? "dataspace-data-plane-service";
139
161
  this._urlTransformerComponent = ComponentFactory.get(options?.urlTransformerComponentType ?? "url-transformer");
140
162
  this._negotiationCallbacks = new Map();
141
- const internalCallback = {
142
- onStateChanged: async (negotiationId, state, data) => {
143
- for (const [key, cb] of this._negotiationCallbacks.entries()) {
144
- try {
145
- await cb.onStateChanged(negotiationId, state, data);
146
- }
147
- catch (error) {
148
- await this._loggingComponent?.log({
149
- level: "error",
150
- source: DataspaceControlPlaneService.CLASS_NAME,
151
- ts: Date.now(),
152
- message: "negotiationCallbackError",
153
- data: { key, negotiationId, method: "onStateChanged", error }
154
- });
155
- }
156
- }
157
- },
158
- onCompleted: async (negotiationId, agreementId) => {
159
- for (const [key, cb] of this._negotiationCallbacks.entries()) {
160
- try {
161
- await cb.onCompleted(negotiationId, agreementId);
162
- }
163
- catch (error) {
164
- await this._loggingComponent?.log({
165
- level: "error",
166
- source: DataspaceControlPlaneService.CLASS_NAME,
167
- ts: Date.now(),
168
- message: "negotiationCallbackError",
169
- data: { key, negotiationId, method: "onCompleted", error }
170
- });
171
- }
172
- }
173
- },
174
- onFailed: async (negotiationId, reason) => {
175
- for (const [key, cb] of this._negotiationCallbacks.entries()) {
176
- try {
177
- await cb.onFailed(negotiationId, reason);
178
- }
179
- catch (error) {
180
- await this._loggingComponent?.log({
181
- level: "error",
182
- source: DataspaceControlPlaneService.CLASS_NAME,
183
- ts: Date.now(),
184
- message: "negotiationCallbackError",
185
- data: { key, negotiationId, method: "onFailed", error }
186
- });
187
- }
188
- }
189
- }
190
- };
163
+ const internalCallback = this.createInternalCallback();
191
164
  this._policyRequester = new DataspaceControlPlanePolicyRequester(options?.loggingComponentType ?? "logging", internalCallback);
192
165
  PolicyRequesterFactory.register(DataspaceControlPlaneService._REQUESTER_TYPE, () => this._policyRequester);
193
166
  }
@@ -371,10 +344,12 @@ export class DataspaceControlPlaneService {
371
344
  }
372
345
  const assigneeIdentity = OdrlPolicyHelper.extractAssigneeIdentity(agreement);
373
346
  const assigneeIds = ArrayHelper.fromObjectOrArray(assigneeIdentity);
374
- if (!assigneeIds.includes(trustInfo.identity)) {
347
+ // The agreement's assignee is stamped by the provider as the consumer's composite identity (`consumerNodeDid:hash(consumerTenantId)`).
348
+ const callerComposite = this.buildCallerComposite(trustInfo);
349
+ if (!assigneeIds.includes(callerComposite)) {
375
350
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "callerNotAuthorizedForAgreement");
376
351
  }
377
- consumerIdentity = trustInfo.identity;
352
+ consumerIdentity = callerComposite;
378
353
  const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
379
354
  const assignerIds = ArrayHelper.fromObjectOrArray(assignerIdentity);
380
355
  if (assignerIds.length > 1) {
@@ -389,6 +364,8 @@ export class DataspaceControlPlaneService {
389
364
  return transformToTransferError(error, { consumerPid: request.consumerPid, providerPid });
390
365
  }
391
366
  const now = new Date();
367
+ const requestContextIds = await ContextIdStore.getContextIds();
368
+ const requestTenantId = requestContextIds?.[ContextIdKeys.Tenant];
392
369
  const storageEntity = {
393
370
  id: Converter.bytesToHex(RandomHelper.generate(32)),
394
371
  consumerPid: request.consumerPid,
@@ -404,6 +381,7 @@ export class DataspaceControlPlaneService {
404
381
  policies,
405
382
  callbackAddress: request.callbackAddress,
406
383
  format: request.format,
384
+ tenantId: Is.stringValue(requestTenantId) ? requestTenantId : undefined,
407
385
  dataAddress: request.dataAddress,
408
386
  dateCreated: now.toISOString(),
409
387
  dateModified: now.toISOString()
@@ -443,6 +421,7 @@ export class DataspaceControlPlaneService {
443
421
  * Role Performed: Provider / Consumer
444
422
  */
445
423
  async startTransfer(message, publicOrigin, trustPayload) {
424
+ publicOrigin = StringHelper.trimTrailingSlashes(publicOrigin);
446
425
  const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "startTransfer");
447
426
  const validationFailures = await DataspaceProtocolHelper.validate(JsonLdHelper.toNodeObject(message));
448
427
  if (Is.arrayValue(validationFailures)) {
@@ -457,7 +436,15 @@ export class DataspaceControlPlaneService {
457
436
  }
458
437
  try {
459
438
  const { entity, role } = await this.lookupTransferByMessage(message);
460
- this.validateCallerIsProvider(trustInfo.identity, entity);
439
+ this.validateCallerIsProvider(this.buildCallerComposite(trustInfo), entity);
440
+ // Only the tenant that owns this transfer can mutate it.
441
+ // Skipped on single-tenant nodes (entity.tenantId undefined).
442
+ const callingTenantId = await this.resolveCallingTenantId();
443
+ if (Is.stringValue(entity.tenantId) &&
444
+ Is.stringValue(callingTenantId) &&
445
+ entity.tenantId !== callingTenantId) {
446
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "transferWrongTenant");
447
+ }
461
448
  if (entity.state !== DataspaceProtocolTransferProcessStateType.REQUESTED &&
462
449
  entity.state !== DataspaceProtocolTransferProcessStateType.SUSPENDED) {
463
450
  return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForStart", {
@@ -466,6 +453,7 @@ export class DataspaceControlPlaneService {
466
453
  currentState: entity.state
467
454
  }), message);
468
455
  }
456
+ const previousState = entity.state;
469
457
  entity.state = DataspaceProtocolTransferProcessStateType.STARTED;
470
458
  entity.dateModified = new Date();
471
459
  await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
@@ -505,7 +493,68 @@ export class DataspaceControlPlaneService {
505
493
  //
506
494
  // See: https://eclipse-dataspace-protocol-base.github.io/DataspaceProtocol/2025-1-err1/#transfer-start-message
507
495
  // ============================================================================
508
- if (Is.empty(entity.dataAddress)) {
496
+ if (Is.empty(entity.dataAddress) && entity.format === DataspaceTransferFormat.HttpProxyPost) {
497
+ // PROVIDER-INITIATED PUSH: Consumer requested push but did not supply an /inbox.
498
+ // Provider returns its own /inbox URL + a signed JWT so the consumer can verify
499
+ // the incoming activities (HttpProxy-POST / DataspaceTransferFormat.HttpProxyPost).
500
+ if (!Is.stringValue(this._dataPlanePath)) {
501
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid: entity.consumerPid }), message);
502
+ }
503
+ if (!Is.stringValue(entity.providerIdentity)) {
504
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
505
+ }
506
+ // providerIdentity is a composite `nodeDid:hash(tenantId)`
507
+ const pushProviderId = this.parseTenantIdentifier(entity.providerIdentity);
508
+ const accessToken = await this._trustComponent.generate(pushProviderId.nodeDid, this._overrideTrustGeneratorType, {
509
+ subject: {
510
+ consumerPid: entity.consumerPid,
511
+ providerPid: entity.providerPid,
512
+ agreementId: entity.agreementId,
513
+ datasetId: entity.datasetId
514
+ }
515
+ }, pushProviderId.tenantIdHash);
516
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "accessToken", accessToken);
517
+ const tokenString = accessToken;
518
+ let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}/inbox`;
519
+ // Bake the provider's tenant token into the /inbox URL so the consumer's
520
+ // inbound POST routes to the right tenant via TenantProcessor — mirrors the
521
+ // pull-mode endpoint baking below.
522
+ const inboxContextIds1 = await ContextIdStore.getContextIds();
523
+ const inboxTenantId1 = inboxContextIds1?.[ContextIdKeys.Tenant];
524
+ if (Is.stringValue(inboxTenantId1)) {
525
+ fullEndpoint = await this._urlTransformerComponent.addEncryptedQueryParamToUrl(fullEndpoint, "tenant", inboxTenantId1);
526
+ }
527
+ response.dataAddress = {
528
+ "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
529
+ endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
530
+ endpoint: fullEndpoint,
531
+ endpointProperties: [
532
+ {
533
+ "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
534
+ name: EndpointProperties.Authorization,
535
+ value: tokenString
536
+ },
537
+ {
538
+ "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
539
+ name: EndpointProperties.AuthType,
540
+ value: "bearer"
541
+ }
542
+ ]
543
+ };
544
+ await this._loggingComponent?.log({
545
+ level: "info",
546
+ source: DataspaceControlPlaneService.CLASS_NAME,
547
+ ts: Date.now(),
548
+ message: "pushTransferStarted",
549
+ data: {
550
+ consumerPid: entity.consumerPid,
551
+ providerPid: entity.providerPid,
552
+ endpoint: fullEndpoint,
553
+ transferMode: DataspaceTransferFormat.HttpProxyPost
554
+ }
555
+ });
556
+ }
557
+ else if (Is.empty(entity.dataAddress)) {
509
558
  if (!Is.stringValue(this._dataPlanePath)) {
510
559
  return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pullTransfersNotSupported", {
511
560
  consumerPid: entity.consumerPid,
@@ -514,18 +563,22 @@ export class DataspaceControlPlaneService {
514
563
  }
515
564
  // Provider signs the data access token with its own identity.
516
565
  // The subject contains the transfer context claims that the data plane
517
- // will verify when the consumer presents this token.
566
+ // will verify when the consumer presents this token. Parse
567
+ // the composite providerIdentity to extract the signing nodeDid and
568
+ // the tenant-hash to embed as `tid`.
518
569
  if (!Is.stringValue(entity.providerIdentity)) {
519
570
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "providerIdentityMissing");
520
571
  }
521
- const accessToken = await this._trustComponent.generate(entity.providerIdentity, this._overrideTrustGeneratorType, {
572
+ const pullProviderId = this.parseTenantIdentifier(entity.providerIdentity);
573
+ const accessToken = await this._trustComponent.generate(pullProviderId.nodeDid, this._overrideTrustGeneratorType, {
522
574
  subject: {
523
575
  consumerPid: entity.consumerPid,
524
576
  providerPid: entity.providerPid,
525
577
  agreementId: entity.agreementId,
526
578
  datasetId: entity.datasetId
527
579
  }
528
- });
580
+ }, pullProviderId.tenantIdHash);
581
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "accessToken", accessToken);
529
582
  const tokenString = accessToken;
530
583
  let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}`;
531
584
  const contextIds = await ContextIdStore.getContextIds();
@@ -540,12 +593,12 @@ export class DataspaceControlPlaneService {
540
593
  endpointProperties: [
541
594
  {
542
595
  "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
543
- name: "authorization",
596
+ name: EndpointProperties.Authorization,
544
597
  value: tokenString
545
598
  },
546
599
  {
547
600
  "@type": DataspaceProtocolTransferProcessTypes.EndpointProperty,
548
- name: "authType",
601
+ name: EndpointProperties.AuthType,
549
602
  value: "bearer"
550
603
  }
551
604
  ]
@@ -564,35 +617,59 @@ export class DataspaceControlPlaneService {
564
617
  });
565
618
  }
566
619
  else {
567
- // PUSH MODE: Provider will push data to consumer's specified endpoint
568
- // The consumer provided dataAddress in TransferRequestMessage, indicating
569
- // where they want data sent. Provider acknowledges and will push data there.
570
- //
571
- // TODO: PUSH transfer implementation (RFC-007 future work)
572
- // Implementation would need to:
573
- // 1. Generate provider's own trust token to authenticate when pushing to consumer
574
- // 2. Validate consumer's dataAddress endpoint is reachable
575
- // 3. Extract any consumer-provided authorization from endpointProperties
576
- // 4. Schedule background task to push data to consumer's endpoint
577
- // 5. POST data to entity.dataAddress.endpoint with provider's trust token
578
- // 6. Implement retry logic with exponential backoff
579
- // 7. Send TransferCompletionMessage when push completes successfully
580
- //
581
- // Trust flow for PUSH (mirror of PULL):
582
- // - PULL: Provider generates token → Consumer uses to authenticate when pulling
583
- // - PUSH: Provider generates token → Provider uses to authenticate when pushing
620
+ // PUSH MODE (consumer-initiated): Consumer provided their /inbox endpoint in
621
+ // the TransferRequestMessage.dataAddress. Provider responds with its own /inbox
622
+ // URL so the consumer knows where to route data notifications.
623
+ if (!Is.stringValue(entity.dataAddress?.endpoint) ||
624
+ !Is.stringValue(entity.dataAddress?.endpointType)) {
625
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidPushDataAddress", {
626
+ consumerPid: entity.consumerPid
627
+ }), message);
628
+ }
629
+ if (!Is.stringValue(this._dataPlanePath)) {
630
+ return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "pushTransferDataPathNotConfigured", { consumerPid: entity.consumerPid }), message);
631
+ }
632
+ let fullEndpoint = `${publicOrigin}/${this._dataPlanePath}/inbox`;
633
+ // Bake the provider's tenant token into the /inbox URL so the consumer's
634
+ // inbound POST routes to the right tenant via TenantProcessor — mirrors the
635
+ // pull-mode endpoint baking.
636
+ const inboxContextIds2 = await ContextIdStore.getContextIds();
637
+ const inboxTenantId2 = inboxContextIds2?.[ContextIdKeys.Tenant];
638
+ if (Is.stringValue(inboxTenantId2)) {
639
+ fullEndpoint = await this._urlTransformerComponent.addEncryptedQueryParamToUrl(fullEndpoint, "tenant", inboxTenantId2);
640
+ }
641
+ response.dataAddress = {
642
+ "@type": DataspaceProtocolTransferProcessTypes.DataAddress,
643
+ endpointType: DataspaceProtocolEndpointType.HttpsActivityStreamEndpoint,
644
+ endpoint: fullEndpoint
645
+ };
584
646
  await this._loggingComponent?.log({
585
- level: "warn",
647
+ level: "info",
586
648
  source: DataspaceControlPlaneService.CLASS_NAME,
587
649
  ts: Date.now(),
588
- message: "pushTransferModeNotImplemented",
650
+ message: "pushTransferStarted",
589
651
  data: {
590
652
  consumerPid: entity.consumerPid,
591
653
  providerPid: entity.providerPid,
592
- consumerEndpoint: entity.dataAddress.endpoint,
593
- transferMode: "PUSH"
654
+ endpoint: fullEndpoint,
655
+ transferMode: DataspaceTransferFormat.HttpProxyPush
594
656
  }
595
657
  });
658
+ try {
659
+ const dataPlane = this.requireDataPlane();
660
+ if (previousState === DataspaceProtocolTransferProcessStateType.REQUESTED) {
661
+ await dataPlane.setupPushSubscription(entity.consumerPid);
662
+ }
663
+ else if (previousState === DataspaceProtocolTransferProcessStateType.SUSPENDED) {
664
+ await dataPlane.resumePushSubscription(entity.consumerPid);
665
+ }
666
+ }
667
+ catch (setupError) {
668
+ entity.state = previousState;
669
+ entity.dateModified = new Date();
670
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
671
+ throw setupError;
672
+ }
596
673
  }
597
674
  return response;
598
675
  }
@@ -624,7 +701,27 @@ export class DataspaceControlPlaneService {
624
701
  }
625
702
  try {
626
703
  const { entity, role } = await this.lookupTransferByMessage(message);
627
- this.validateCallerIsConsumer(trustInfo.identity, entity);
704
+ this.validateCallerIsConsumer(this.buildCallerComposite(trustInfo), entity);
705
+ // Only the tenant that owns this transfer can mutate it.
706
+ // Skipped on single-tenant nodes (entity.tenantId undefined).
707
+ const callingTenantId = await this.resolveCallingTenantId();
708
+ if (Is.stringValue(entity.tenantId) &&
709
+ Is.stringValue(callingTenantId) &&
710
+ entity.tenantId !== callingTenantId) {
711
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "transferWrongTenant");
712
+ }
713
+ // DSP idempotency: re-receiving TransferCompletionMessage for a transfer already
714
+ // in COMPLETED should return the same success response, not invalidStateForComplete.
715
+ // Data-plane teardown already ran on the first attempt.
716
+ if (entity.state === DataspaceProtocolTransferProcessStateType.COMPLETED) {
717
+ return {
718
+ "@context": [DataspaceProtocolContexts.Context],
719
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
720
+ consumerPid: entity.consumerPid,
721
+ providerPid: entity.providerPid,
722
+ state: entity.state
723
+ };
724
+ }
628
725
  if (entity.state !== DataspaceProtocolTransferProcessStateType.STARTED) {
629
726
  return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForComplete", {
630
727
  consumerPid: entity.consumerPid,
@@ -632,6 +729,7 @@ export class DataspaceControlPlaneService {
632
729
  currentState: entity.state
633
730
  }), message);
634
731
  }
732
+ const previousState = entity.state;
635
733
  entity.state = DataspaceProtocolTransferProcessStateType.COMPLETED;
636
734
  entity.dateModified = new Date();
637
735
  await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
@@ -646,6 +744,20 @@ export class DataspaceControlPlaneService {
646
744
  role
647
745
  }
648
746
  });
747
+ if (this.isPushFormat(entity.format)) {
748
+ try {
749
+ await this.requireDataPlane().teardownPushSubscription(entity.consumerPid);
750
+ }
751
+ catch (teardownError) {
752
+ // Symmetric rollback: data-plane teardown failed, revert the transfer state
753
+ // so the client can retry. Without this the storage row is COMPLETED but
754
+ // the subscription is still flowing, and a retry hits invalidStateForComplete.
755
+ entity.state = previousState;
756
+ entity.dateModified = new Date();
757
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
758
+ throw teardownError;
759
+ }
760
+ }
649
761
  return {
650
762
  "@context": [DataspaceProtocolContexts.Context],
651
763
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -679,7 +791,26 @@ export class DataspaceControlPlaneService {
679
791
  }
680
792
  try {
681
793
  const { entity, role } = await this.lookupTransferByMessage(message);
682
- this.validateCallerIsTransferParty(trustInfo.identity, entity);
794
+ this.validateCallerIsTransferParty(this.buildCallerComposite(trustInfo), entity);
795
+ // S2 belt-and-braces: only the tenant that owns this transfer can mutate it.
796
+ // Skipped on single-tenant nodes (entity.tenantId undefined).
797
+ const callingTenantId = await this.resolveCallingTenantId();
798
+ if (Is.stringValue(entity.tenantId) &&
799
+ Is.stringValue(callingTenantId) &&
800
+ entity.tenantId !== callingTenantId) {
801
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "transferWrongTenant");
802
+ }
803
+ // DSP idempotency: re-receiving TransferSuspensionMessage for a transfer already
804
+ // in SUSPENDED should return success. Data-plane suspend already ran.
805
+ if (entity.state === DataspaceProtocolTransferProcessStateType.SUSPENDED) {
806
+ return {
807
+ "@context": [DataspaceProtocolContexts.Context],
808
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
809
+ consumerPid: entity.consumerPid,
810
+ providerPid: entity.providerPid,
811
+ state: entity.state
812
+ };
813
+ }
683
814
  if (entity.state !== DataspaceProtocolTransferProcessStateType.STARTED) {
684
815
  return transformToTransferError(new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidStateForSuspend", {
685
816
  consumerPid: entity.consumerPid,
@@ -687,6 +818,7 @@ export class DataspaceControlPlaneService {
687
818
  currentState: entity.state
688
819
  }), message);
689
820
  }
821
+ const previousState = entity.state;
690
822
  entity.state = DataspaceProtocolTransferProcessStateType.SUSPENDED;
691
823
  entity.dateModified = new Date();
692
824
  await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
@@ -702,6 +834,19 @@ export class DataspaceControlPlaneService {
702
834
  reason: message.reason
703
835
  }
704
836
  });
837
+ if (this.isPushFormat(entity.format)) {
838
+ try {
839
+ await this.requireDataPlane().suspendPushSubscription(entity.consumerPid);
840
+ }
841
+ catch (suspendError) {
842
+ // Symmetric rollback: data-plane suspend failed, revert transfer state so
843
+ // a retry can repair the subscription instead of failing invalidStateForSuspend.
844
+ entity.state = previousState;
845
+ entity.dateModified = new Date();
846
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
847
+ throw suspendError;
848
+ }
849
+ }
705
850
  return {
706
851
  "@context": [DataspaceProtocolContexts.Context],
707
852
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -735,7 +880,27 @@ export class DataspaceControlPlaneService {
735
880
  }
736
881
  try {
737
882
  const { entity, role } = await this.lookupTransferByMessage(message);
738
- this.validateCallerIsTransferParty(trustInfo.identity, entity);
883
+ this.validateCallerIsTransferParty(this.buildCallerComposite(trustInfo), entity);
884
+ // Only the tenant that owns this transfer can mutate it.
885
+ // Skipped on single-tenant nodes (entity.tenantId undefined).
886
+ const callingTenantId = await this.resolveCallingTenantId();
887
+ if (Is.stringValue(entity.tenantId) &&
888
+ Is.stringValue(callingTenantId) &&
889
+ entity.tenantId !== callingTenantId) {
890
+ throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "transferWrongTenant");
891
+ }
892
+ // DSP idempotency: re-receiving TransferTerminationMessage for a transfer already
893
+ // in TERMINATED should return success. Data-plane teardown already ran.
894
+ if (entity.state === DataspaceProtocolTransferProcessStateType.TERMINATED) {
895
+ return {
896
+ "@context": [DataspaceProtocolContexts.Context],
897
+ "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
898
+ consumerPid: entity.consumerPid,
899
+ providerPid: entity.providerPid,
900
+ state: entity.state
901
+ };
902
+ }
903
+ const previousState = entity.state;
739
904
  entity.state = DataspaceProtocolTransferProcessStateType.TERMINATED;
740
905
  entity.dateModified = new Date();
741
906
  await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
@@ -751,6 +916,20 @@ export class DataspaceControlPlaneService {
751
916
  reason: message.reason
752
917
  }
753
918
  });
919
+ if (this.isPushFormat(entity.format)) {
920
+ try {
921
+ await this.requireDataPlane().teardownPushSubscription(entity.consumerPid);
922
+ }
923
+ catch (teardownError) {
924
+ // Symmetric rollback: data-plane teardown failed, revert transfer state so
925
+ // a retry can repair the subscription. Terminate is reachable from multiple
926
+ // states (REQUESTED/STARTED/SUSPENDED), so restore the actual previous one.
927
+ entity.state = previousState;
928
+ entity.dateModified = new Date();
929
+ await this._transferProcessStorage.set(this.modelToStorageEntity(entity));
930
+ throw teardownError;
931
+ }
932
+ }
754
933
  return {
755
934
  "@context": [DataspaceProtocolContexts.Context],
756
935
  "@type": DataspaceProtocolTransferProcessTypes.TransferProcess,
@@ -773,7 +952,7 @@ export class DataspaceControlPlaneService {
773
952
  const trustInfo = await TrustHelper.verifyTrust(this._trustComponent, trustPayload, "getTransferProcess");
774
953
  try {
775
954
  const { entity, role } = await this.lookupTransferByPid(pid);
776
- this.validateCallerIsTransferParty(trustInfo.identity, entity);
955
+ this.validateCallerIsTransferParty(this.buildCallerComposite(trustInfo), entity);
777
956
  await this._loggingComponent?.log({
778
957
  level: "info",
779
958
  source: DataspaceControlPlaneService.CLASS_NAME,
@@ -1003,19 +1182,22 @@ export class DataspaceControlPlaneService {
1003
1182
  }
1004
1183
  const agreement = await this.lookupAgreement(entity.agreementId);
1005
1184
  const contextIds = await ContextIdStore.getContextIds();
1006
- const currentOrgId = contextIds?.[ContextIdKeys.Organization];
1007
- if (!currentOrgId) {
1185
+ const currentComposite = this.buildTenantIdentifier(contextIds?.[ContextIdKeys.Node], contextIds?.[ContextIdKeys.Tenant]);
1186
+ // Identity is `nodeDid:hash(tenantId)` (or just `nodeDid` in
1187
+ // single-tenant). Without a node DID in context we cannot derive any
1188
+ // caller identity at all.
1189
+ if (!Is.stringValue(currentComposite)) {
1008
1190
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "organizationContextMissing", {
1009
1191
  consumerPid
1010
1192
  });
1011
1193
  }
1012
1194
  const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
1013
1195
  const assignerIds = ArrayHelper.fromObjectOrArray(assignerIdentity);
1014
- if (!assignerIds.includes(currentOrgId)) {
1196
+ if (!assignerIds.includes(currentComposite)) {
1015
1197
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "agreementAssignerMismatch", {
1016
1198
  consumerPid,
1017
1199
  agreementId: OdrlPolicyHelper.getUid(agreement),
1018
- expectedOrgId: currentOrgId,
1200
+ expectedComposite: currentComposite,
1019
1201
  actualAssigner: assignerIds.join(", ")
1020
1202
  });
1021
1203
  }
@@ -1030,7 +1212,7 @@ export class DataspaceControlPlaneService {
1030
1212
  state: entity.state,
1031
1213
  consumerIdentity: entity.consumerIdentity,
1032
1214
  agreementId: OdrlPolicyHelper.getUid(agreement),
1033
- organizationId: currentOrgId
1215
+ organizationId: contextIds?.[ContextIdKeys.Organization]
1034
1216
  }
1035
1217
  });
1036
1218
  return {
@@ -1062,20 +1244,19 @@ export class DataspaceControlPlaneService {
1062
1244
  }
1063
1245
  const agreement = await this.lookupAgreement(entity.agreementId);
1064
1246
  const contextIds = await ContextIdStore.getContextIds();
1065
- const currentOrgId = contextIds?.[ContextIdKeys.Organization];
1066
- if (!currentOrgId) {
1247
+ const currentComposite = this.buildTenantIdentifier(contextIds?.[ContextIdKeys.Node], contextIds?.[ContextIdKeys.Tenant]);
1248
+ if (!Is.stringValue(currentComposite)) {
1067
1249
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "organizationContextMissingProvider", {
1068
1250
  providerPid
1069
1251
  });
1070
1252
  }
1071
- // Extract assigner UID (can be string, array, or IOdrlParty object)
1072
1253
  const assignerIdentity = OdrlPolicyHelper.extractAssignerIdentity(agreement);
1073
1254
  const assignerIds = ArrayHelper.fromObjectOrArray(assignerIdentity);
1074
- if (!assignerIds.includes(currentOrgId)) {
1255
+ if (!assignerIds.includes(currentComposite)) {
1075
1256
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "agreementAssignerMismatchProvider", {
1076
1257
  providerPid,
1077
1258
  agreementId: OdrlPolicyHelper.getUid(agreement),
1078
- expectedOrgId: currentOrgId,
1259
+ expectedComposite: currentComposite,
1079
1260
  actualAssigner: assignerIds.join(", ")
1080
1261
  });
1081
1262
  }
@@ -1105,7 +1286,7 @@ export class DataspaceControlPlaneService {
1105
1286
  consumerIdentity: entity.consumerIdentity,
1106
1287
  providerIdentity: entity.providerIdentity,
1107
1288
  agreementId: OdrlPolicyHelper.getUid(agreement),
1108
- organizationId: currentOrgId
1289
+ organizationId: contextIds?.[ContextIdKeys.Organization]
1109
1290
  }
1110
1291
  });
1111
1292
  return {
@@ -1147,7 +1328,7 @@ export class DataspaceControlPlaneService {
1147
1328
  nodeIdentity,
1148
1329
  tenantId,
1149
1330
  appId,
1150
- dataset: this.stripDatasetId(dataset),
1331
+ dataset: ObjectHelper.omit(dataset, ["@id"]),
1151
1332
  dateCreated: now,
1152
1333
  dateModified: now
1153
1334
  };
@@ -1195,11 +1376,11 @@ export class DataspaceControlPlaneService {
1195
1376
  }
1196
1377
  : undefined, undefined, undefined, cursor, limit);
1197
1378
  const entities = (page.entities ?? []).map(entity => ({
1198
- id: entity.id ?? "",
1199
- appId: entity.appId ?? "",
1379
+ id: entity.id,
1380
+ appId: entity.appId,
1200
1381
  dataset: this.restampDatasetId(entity.dataset ?? {}, entity.id ?? ""),
1201
- dateCreated: entity.dateCreated ?? "",
1202
- dateModified: entity.dateModified ?? ""
1382
+ dateCreated: entity.dateCreated,
1383
+ dateModified: entity.dateModified
1203
1384
  }));
1204
1385
  return {
1205
1386
  entities,
@@ -1227,7 +1408,7 @@ export class DataspaceControlPlaneService {
1227
1408
  const updated = {
1228
1409
  ...existing,
1229
1410
  appId,
1230
- dataset: this.stripDatasetId(dataset),
1411
+ dataset: ObjectHelper.omit(dataset, ["@id"]),
1231
1412
  dateModified: new Date().toISOString()
1232
1413
  };
1233
1414
  // Side effect first, primary storage last
@@ -1327,6 +1508,7 @@ export class DataspaceControlPlaneService {
1327
1508
  providerIdentity: storageEntity.providerIdentity,
1328
1509
  format: storageEntity.format,
1329
1510
  callbackAddress: storageEntity.callbackAddress,
1511
+ tenantId: storageEntity.tenantId,
1330
1512
  dateCreated: new Date(storageEntity.dateCreated),
1331
1513
  dateModified: new Date(storageEntity.dateModified),
1332
1514
  policies: storageEntity.policies,
@@ -1352,6 +1534,7 @@ export class DataspaceControlPlaneService {
1352
1534
  providerIdentity: entity.providerIdentity,
1353
1535
  format: entity.format,
1354
1536
  callbackAddress: entity.callbackAddress,
1537
+ tenantId: entity.tenantId,
1355
1538
  dateCreated: entity.dateCreated.toISOString(),
1356
1539
  dateModified: entity.dateModified.toISOString(),
1357
1540
  policies: entity.policies,
@@ -1378,39 +1561,54 @@ export class DataspaceControlPlaneService {
1378
1561
  }
1379
1562
  return agreement;
1380
1563
  }
1564
+ /**
1565
+ * Build the caller's composite identity (`nodeDid:tenantIdHash`) from a
1566
+ * verified trust payload.
1567
+ * @param trustInfo The verification info from `TrustHelper.verifyTrust`.
1568
+ * @returns The composite identity.
1569
+ * @internal
1570
+ */
1571
+ buildCallerComposite(trustInfo) {
1572
+ Guards.object(DataspaceControlPlaneService.CLASS_NAME, "trustInfo", trustInfo);
1573
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "trustInfo.identity", trustInfo.identity);
1574
+ return Is.stringValue(trustInfo.tenantId)
1575
+ ? `${trustInfo.identity}:${trustInfo.tenantId}`
1576
+ : trustInfo.identity;
1577
+ }
1381
1578
  /**
1382
1579
  * Validate that the caller's verified identity matches the consumer of a transfer process.
1383
- * @param callerIdentity The identity from the verified trust token.
1580
+ * @param callerComposite The caller's composite identity (`nodeDid:tenantIdHash`).
1384
1581
  * @param entity The transfer process entity.
1385
1582
  * @throws UnauthorizedError if the caller is not the consumer.
1386
1583
  * @internal
1387
1584
  */
1388
- validateCallerIsConsumer(callerIdentity, entity) {
1389
- if (callerIdentity !== entity.consumerIdentity) {
1585
+ validateCallerIsConsumer(callerComposite, entity) {
1586
+ if (callerComposite !== entity.consumerIdentity) {
1390
1587
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "callerNotAuthorizedAsConsumer");
1391
1588
  }
1392
1589
  }
1393
1590
  /**
1394
1591
  * Validate that the caller's verified identity matches the provider of a transfer process.
1395
- * @param callerIdentity The identity from the verified trust token.
1592
+ * @param callerComposite The caller's composite identity (`nodeDid:tenantIdHash`).
1396
1593
  * @param entity The transfer process entity.
1397
1594
  * @throws UnauthorizedError if the caller is not the provider.
1398
1595
  * @internal
1399
1596
  */
1400
- validateCallerIsProvider(callerIdentity, entity) {
1401
- if (callerIdentity !== entity.providerIdentity) {
1597
+ validateCallerIsProvider(callerComposite, entity) {
1598
+ if (callerComposite !== entity.providerIdentity) {
1402
1599
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "callerNotAuthorizedAsProvider");
1403
1600
  }
1404
1601
  }
1405
1602
  /**
1406
1603
  * Validate that the caller's verified identity matches either the consumer or provider of a transfer process.
1407
- * @param callerIdentity The identity from the verified trust token.
1604
+ * @param callerComposite The caller's composite identity (`nodeDid:tenantIdHash`).
1408
1605
  * @param entity The transfer process entity.
1409
1606
  * @throws UnauthorizedError if the caller is not a party to the transfer.
1410
1607
  * @internal
1411
1608
  */
1412
- validateCallerIsTransferParty(callerIdentity, entity) {
1413
- if (callerIdentity !== entity.consumerIdentity && callerIdentity !== entity.providerIdentity) {
1609
+ validateCallerIsTransferParty(callerComposite, entity) {
1610
+ if (callerComposite !== entity.consumerIdentity &&
1611
+ callerComposite !== entity.providerIdentity) {
1414
1612
  throw new UnauthorizedError(DataspaceControlPlaneService.CLASS_NAME, "callerNotAuthorizedForTransfer");
1415
1613
  }
1416
1614
  }
@@ -1420,6 +1618,16 @@ export class DataspaceControlPlaneService {
1420
1618
  * @returns Dataset ID.
1421
1619
  * @internal
1422
1620
  */
1621
+ isPushFormat(format) {
1622
+ return (format === DataspaceTransferFormat.HttpProxyPush ||
1623
+ format === DataspaceTransferFormat.HttpProxyPost);
1624
+ }
1625
+ /**
1626
+ * Extract the dataset ID from an ODRL agreement's target.
1627
+ * @param agreement The ODRL agreement containing the target.
1628
+ * @returns The dataset ID extracted from the target URN.
1629
+ * @throws GeneralError if the agreement target is missing, has no UID, or has multiple targets.
1630
+ */
1423
1631
  extractDatasetId(agreement) {
1424
1632
  if (Is.empty(agreement.target)) {
1425
1633
  throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "agreementMissingTarget", {
@@ -1656,22 +1864,124 @@ export class DataspaceControlPlaneService {
1656
1864
  return dataset;
1657
1865
  }
1658
1866
  const contextIds = await ContextIdStore.getContextIds();
1659
- const orgId = contextIds?.[ContextIdKeys.Organization];
1660
- if (orgId) {
1661
- return { ...dataset, "dcterms:publisher": orgId };
1867
+ // The publisher attribution is always the composite identifier
1868
+ const compositePublisher = this.buildTenantIdentifier(contextIds?.[ContextIdKeys.Node], contextIds?.[ContextIdKeys.Tenant]);
1869
+ if (compositePublisher) {
1870
+ return { ...dataset, "dcterms:publisher": compositePublisher };
1662
1871
  }
1663
1872
  return dataset;
1664
1873
  }
1665
1874
  /**
1666
- * Strip `@id` from a dataset payload before storing it.
1667
- * @param dataset The dataset payload.
1668
- * @returns The dataset payload with `@id` removed.
1875
+ * Build the tenant-attributed identifier.
1876
+ * @param nodeId The node identity (from `ContextIdKeys.Node`).
1877
+ * @param tenantId The plaintext tenant id (from `ContextIdKeys.Tenant`), if
1878
+ * any. Hashed before insertion into the composite.
1879
+ * @returns Composite identifier, or undefined if nodeId is missing.
1880
+ * @internal
1881
+ */
1882
+ buildTenantIdentifier(nodeId, tenantId) {
1883
+ if (!Is.stringValue(nodeId)) {
1884
+ return undefined;
1885
+ }
1886
+ return Is.stringValue(tenantId) ? `${nodeId}:${TrustHelper.hashTenantId(tenantId)}` : nodeId;
1887
+ }
1888
+ /**
1889
+ * Parse a composite tenant identifier into its node and tenant-hash portions.
1890
+ * @param composite The composite identifier (`nodeDid` or `nodeDid:hash`).
1891
+ * @returns The parsed parts. `tenantIdHash` is undefined when the composite
1892
+ * has no tenant portion (single-tenant node form).
1893
+ * @throws GuardError if the input is not a non-empty string.
1894
+ * @throws GeneralError if the input does not start with `did:` — the only
1895
+ * valid composite identifier shapes carry a DID as the leading portion.
1669
1896
  * @internal
1670
1897
  */
1671
- stripDatasetId(dataset) {
1672
- const copy = { ...dataset };
1673
- delete copy["@id"];
1674
- return copy;
1898
+ parseTenantIdentifier(composite) {
1899
+ Guards.stringValue(DataspaceControlPlaneService.CLASS_NAME, "composite", composite);
1900
+ if (!composite.startsWith("did:")) {
1901
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "invalidCompositeIdentifier", { composite });
1902
+ }
1903
+ const lastColon = composite.lastIndexOf(":");
1904
+ if (lastColon === -1) {
1905
+ return { nodeDid: composite };
1906
+ }
1907
+ const candidateHash = composite.slice(lastColon + 1);
1908
+ if (DataspaceControlPlaneService._TENANT_HASH_PATTERN.test(candidateHash)) {
1909
+ return {
1910
+ nodeDid: composite.slice(0, lastColon),
1911
+ tenantIdHash: candidateHash
1912
+ };
1913
+ }
1914
+ return { nodeDid: composite };
1915
+ }
1916
+ /**
1917
+ * Resolve the data plane component or throw if it isn't registered. Push-mode transfers
1918
+ * require the data plane; pull-only deployments may run without it.
1919
+ * @returns The data plane component.
1920
+ * @throws GeneralError if the data plane component is not registered.
1921
+ * @internal
1922
+ */
1923
+ requireDataPlane() {
1924
+ const dataPlane = ComponentFactory.getIfExists(this._dataPlaneComponentType);
1925
+ if (!dataPlane) {
1926
+ throw new GeneralError(DataspaceControlPlaneService.CLASS_NAME, "dataPlaneNotRegistered");
1927
+ }
1928
+ return dataPlane;
1929
+ }
1930
+ /**
1931
+ * Creates the internal INegotiationCallback that fans out to all registered callbacks.
1932
+ * @returns The internal negotiation callback.
1933
+ */
1934
+ createInternalCallback() {
1935
+ return {
1936
+ onStateChanged: async (negotiationId, state, data) => {
1937
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
1938
+ try {
1939
+ await cb.onStateChanged(negotiationId, state, data);
1940
+ }
1941
+ catch (error) {
1942
+ await this._loggingComponent?.log({
1943
+ level: "error",
1944
+ source: DataspaceControlPlaneService.CLASS_NAME,
1945
+ ts: Date.now(),
1946
+ message: "negotiationCallbackError",
1947
+ data: { key, negotiationId, method: "onStateChanged", error }
1948
+ });
1949
+ }
1950
+ }
1951
+ },
1952
+ onCompleted: async (negotiationId, agreementId) => {
1953
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
1954
+ try {
1955
+ await cb.onCompleted(negotiationId, agreementId);
1956
+ }
1957
+ catch (error) {
1958
+ await this._loggingComponent?.log({
1959
+ level: "error",
1960
+ source: DataspaceControlPlaneService.CLASS_NAME,
1961
+ ts: Date.now(),
1962
+ message: "negotiationCallbackError",
1963
+ data: { key, negotiationId, method: "onCompleted", error }
1964
+ });
1965
+ }
1966
+ }
1967
+ },
1968
+ onFailed: async (negotiationId, reason) => {
1969
+ for (const [key, cb] of this._negotiationCallbacks.entries()) {
1970
+ try {
1971
+ await cb.onFailed(negotiationId, reason);
1972
+ }
1973
+ catch (error) {
1974
+ await this._loggingComponent?.log({
1975
+ level: "error",
1976
+ source: DataspaceControlPlaneService.CLASS_NAME,
1977
+ ts: Date.now(),
1978
+ message: "negotiationCallbackError",
1979
+ data: { key, negotiationId, method: "onFailed", error }
1980
+ });
1981
+ }
1982
+ }
1983
+ }
1984
+ };
1675
1985
  }
1676
1986
  /**
1677
1987
  * Re-stamp `@id` onto a stored payload blob using the entity primary key.
@@ -1693,8 +2003,9 @@ export class DataspaceControlPlaneService {
1693
2003
  // Override `Tenant` in the context wrap so federated catalogue captures the
1694
2004
  // appDataset's owning tenant. On single-tenant nodes the appDataset has no tenantId,
1695
2005
  // so the assignment writes `Tenant: undefined` — equivalent to no override.
2006
+ const contextIds = (await ContextIdStore.getContextIds()) ?? {};
1696
2007
  const wrappedContextIds = {
1697
- ...((await ContextIdStore.getContextIds()) ?? {}),
2008
+ ...contextIds,
1698
2009
  [ContextIdKeys.Tenant]: appDataset.tenantId
1699
2010
  };
1700
2011
  await ContextIdStore.run(wrappedContextIds, async () => {