Backgrounds

Heritage and data spaces: experiments 2 & 3

This post is automatically translated from Dutch by AI.

In digital heritage, you occasionally hear the term data space. But what does it actually mean? And more importantly: what can it really do? Is it just a new term for policymakers? Or is there concrete technology underneath it? Out of curiosity, I decided to investigate.

You can find the first experiments in the P-322 GitHub data space-experiments repository.

In the first blog I explained what a data space is, which problem it tries to solve, and I gave an overview of the concepts, terms, and roles. That post also contains a working experimental data space transaction.

On the road to testable access conditions

My personal interest lies in designing access policies in an open data environment. It only really makes sense to start testing those once multiple consumers begin approaching a provider with interest in an offer.

That is why, in this experiment, I want to explore how multiple consumers relate to one another. To do so, the code from experiment 1 needs to be reworked. At the moment, it is little more than a procedural script that executes a fixed sequence of steps for a single provider and a single consumer. It needs to become more generic and reusable, so that adding new ideas later becomes easier.

Experiment 2

The goal is to support multiple data space transactions. Different consumers request data from what is, for now, a single provider. Of course, after completing the first transaction, we could configure the steps from experiment 1 again for a second consumer and run them sequentially — but that does not scale. Tomorrow, it might be a third or fourth user.

That is why I split the steps from experiment 1 into two parts. In steps 1 and 2, I initialize the provider’s asset and offer. In steps 3 through 7, the consumer and provider go through the contract negotiation process and the data transfer. Steps 1 and 2 happen only once; steps 3–7 need to be repeated every time.

Consumer – Provider

We therefore shift the code from a process-oriented perspective to a role-oriented one. In the provider role, you should be able to register assets and create offers. As a consumer, you should be able to query the catalog, start a contract negotiation, initiate the transfer, retrieve an EDR, and pull in the data.

That leads to two objects: Provider and Consumer, in which the different transaction steps reside. Steps 1 and 2 belong to the provider as a role, not to an interaction. An asset and an offer exist independently of whoever comes along to inspect them. They are a promise to the world — or at least to the data space. You create them once, and after that they are ready. That makes the provider the logical home for these steps: they describe what the provider is and offers, not what happens at a specific moment in time.

import { Logger } from "../utils/logger.js";import { EdcManagementClient } from "./edcManagementClient.js"; export type PublicationSpec = {  assetId: string;  contractDefId: string;  policyId: string;  sourceUrl: string;}; export class Provider {  readonly mgmt: EdcManagementClient;  readonly log: Logger;   constructor(    readonly pid: string,    mgmtBaseUrl: string,    mgmtApiKey: string,    readonly dspAddressDocker: string,    readonly publicBaseHost: string,    logger: Logger  ) {    this.mgmt = new EdcManagementClient(mgmtBaseUrl, mgmtApiKey);    this.log = logger;  }   async ensureAsset(pub: PublicationSpec): Promise<void> {    this.log.p(`Ensuring asset '${pub.assetId}' exists`);     try {      await this.mgmt.json(        "GET",        `/v3/assets/${encodeURIComponent(pub.assetId)}`      );      this.log.p(`Asset '${pub.assetId}' already exists`);      return;    } catch (e: any) {      if (!String(e.message).includes("404")) throw e;    }     await this.mgmt.json("POST", `/v3/assets`, {      "@context": {        "@vocab": "https://w3id.org/edc/v0.0.1/ns/",      },      "@id": pub.assetId,      "@type": "Asset",       properties: {        name: pub.assetId,        contenttype: "application/json",      },       dataAddress: {        "@type": "HttpData",        "https://w3id.org/edc/v0.0.1/ns/type": "HttpData",        baseUrl: pub.sourceUrl,        proxyPath: "true",        method: "GET",      },    });     this.log.p(`Asset '${pub.assetId}' created`);  }   async ensureContractDefinition(pub: PublicationSpec): Promise<void> {    this.log.p(`Ensuring contract definition '${pub.contractDefId}' exists`);     const byId = `/v3/contractdefinitions/${encodeURIComponent(      pub.contractDefId    )}`;     try {      await this.mgmt.json("GET", byId);      this.log.p(`Contract definition '${pub.contractDefId}' already exists`);      return;    } catch (e: any) {      if (!String(e.message).includes("404")) throw e;    }     await this.mgmt.json("POST", `/v3/contractdefinitions`, {      "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },      "@id": pub.contractDefId,      "@type": "ContractDefinition",       accessPolicyId: pub.policyId,      contractPolicyId: pub.policyId,       assetsSelector: [        {          "@type": "Criterion",          operandLeft: "id",          operator: "=",          operandRight: pub.assetId,        },      ],    });     this.log.p(`Contract definition '${pub.contractDefId}' created`);  }} 
import { Logger } from "../utils/logger.js";import { EdcManagementClient } from "./edcManagementClient.js";import { EdcTransaction } from "./edcTransaction.js";import { Provider } from "./provider.js"; export class Consumer {  readonly mgmt: EdcManagementClient;  readonly log: Logger;   constructor(    readonly pid: string,    mgmtBaseUrl: string,    mgmtApiKey: string,    log: Logger  ) {    this.mgmt = new EdcManagementClient(mgmtBaseUrl, mgmtApiKey);    this.log = log;  }   transaction(provider: Provider) {    const l = this.log.scoped(`[${this.pid}]`);    return new EdcTransaction(this.pid, this.mgmt, provider, l);  }} 

Transaction

Steps 3 through 7 are of a different nature. These are not properties, but events. Together, they form a temporary process between consumer and provider. Negotiation takes place, decisions are made, access is granted or denied, and eventually data is transferred.

That process has a life of its own: it has a beginning, a progression, a possible failure, and an end. It is also repeatable. A consumer might later respond to a different offer from the same provider, or start a transaction with an entirely new party.

If you keep embedding all of this directly into the consumer, you need to track state with extreme care. The risk that a consumer accidentally asks provider A for an offer from provider B is very real — and undesirable.

In addition, we can already see that a consumer may want to run multiple transactions in parallel, or retry a failed one later. That is simply not possible if all steps live inside the consumer.

That is why steps 3 through 7 are placed in an EdcTransaction object. This gives the transaction itself some weight. It becomes something you can follow, log, repeat, or compare. The consumer initiates transactions, but the transactions themselves carry their own story. And that is exactly the story I want to understand, test, and question.

import { firstObj } from "../utils/collections.js";import { Logger } from "../utils/logger.js";import { EdcManagementClient } from "./edcManagementClient.js";import { Provider } from "./provider.js"; /* ------------------------------------------------------------ * Types local to the transaction * ------------------------------------------------------------ */ export type Json =  | null  | boolean  | number  | string  | Json[]  | { [k: string]: Json }; export type JsonObject = { [k: string]: Json };export type OfferPolicy = JsonObject; export type CatalogResult = {  providerPid: string;  assetId: string;  offer: OfferPolicy;}; export type NegotiationResult = {  negotiationId: string;  agreementId: string;}; export type TransferResult = {  transferProcessId: string;}; export type EdrResult = {  endpointDocker: string;  endpointHost: string;  token: string;}; /* ------------------------------------------------------------ * EdcTransaction * ------------------------------------------------------------ */ export class EdcTransaction {  constructor(    readonly consumerPid: string,    readonly mgmt: EdcManagementClient,    readonly provider: Provider,    private readonly log: ReturnType<Logger["scoped"]>  ) {}   /* ------------------------------------------------------------   * Helper   * ------------------------------------------------------------ */   normalizeOfferForContractRequest(input: {    offer: OfferPolicy;    assetId: string;    providerPid: string;    consumerPid: string;  }): JsonObject {    const { offer, assetId, providerPid, consumerPid } = input;     return {      ...offer,      "@type": "odrl:Offer",      "odrl:target": { "@id": assetId },      "odrl:assigner": { "@id": providerPid },      "odrl:assignee": { "@id": consumerPid },    };  }   /* ------------------------------------------------------------   * Orchestration   * ------------------------------------------------------------ */   async run(authHeaderMode: "bearer" | "raw" = "raw") {    this.log.s(`Transaction: Fetch catalog + offer`, { nl: true });    const cat = await this.fetchCatalog();     this.log.s(`Transaction: Contract negotiation`, { nl: true });    const neg = await this.negotiateContract(cat);     this.log.s(`Transaction: Transfer process`, { nl: true });    const tr = await this.startTransfer(cat, neg);     this.log.s(`Transaction: Fetch EDR`, { nl: true });    const edr = await this.fetchEdr(tr);     this.log.s(`Transaction: Data access`, { nl: true });    const data = await this.accessData(edr, authHeaderMode);     return { cat, neg, tr, edr, data };  }   /* ------------------------------------------------------------   * Step 3 — Catalog   * ------------------------------------------------------------ */   async fetchCatalog(): Promise<CatalogResult> {    const l = this.log;     l.c(`Requesting catalog from provider via consumer management API`);    l.c(`Provider DSP address: ${this.provider.dspAddressDocker}`);    l.c(`Protocol: dataspace-protocol-http`);     const catalog = await this.mgmt.json("POST", `/v3/catalog/request`, {      "@type": "https://w3id.org/edc/v0.0.1/ns/CatalogRequest",      "https://w3id.org/edc/v0.0.1/ns/counterPartyAddress":        this.provider.dspAddressDocker,      "https://w3id.org/edc/v0.0.1/ns/protocol": "dataspace-protocol-http",      "https://w3id.org/edc/v0.0.1/ns/querySpec": {        "@type": "https://w3id.org/edc/v0.0.1/ns/QuerySpec",        "https://w3id.org/edc/v0.0.1/ns/offset": 0,        "https://w3id.org/edc/v0.0.1/ns/limit": 50,      },    });     const providerPid = catalog?.["dspace:participantId"] ?? this.provider.pid;     const dataset = firstObj<any>(catalog?.["dcat:dataset"]);    if (!dataset) {      l.c(`No dataset found in catalog`);      throw new Error(        `No dcat:dataset in catalog:\n${JSON.stringify(catalog, null, 2)}`      );    }     const assetId = dataset?.["@id"];    const offer = dataset?.["odrl:hasPolicy"];    if (!assetId || !offer) {      throw new Error(        `Missing @id or odrl:hasPolicy:\n${JSON.stringify(dataset, null, 2)}`      );    }     l.c(`Normalizing offer for ContractRequest`);     const normalizedOffer = this.normalizeOfferForContractRequest({      offer,      assetId,      providerPid,      consumerPid: this.consumerPid,    });     return { providerPid, assetId, offer: normalizedOffer };  }   /* ------------------------------------------------------------   * Step 4 — Negotiation   * ------------------------------------------------------------ */   async negotiateContract(cat: CatalogResult): Promise<NegotiationResult> {    const l = this.log;     l.c(`Negotiating contract for asset '${cat.assetId}'`);     const req = {      "@context": {        "@vocab": "https://w3id.org/edc/v0.0.1/ns/",        edc: "https://w3id.org/edc/v0.0.1/ns/",        odrl: "http://www.w3.org/ns/odrl/2/",      },      "@type": "edc:ContractRequest",      "edc:counterPartyAddress": this.provider.dspAddressDocker,      "edc:counterPartyId": cat.providerPid,      "edc:protocol": "dataspace-protocol-http",      "edc:policy": cat.offer,    };     const created = await this.mgmt.raw(      "POST",      `/v3/contractnegotiations`,      JSON.stringify(req)    );     const negotiationId = created?.["@id"];    if (!negotiationId) {      throw new Error(`No negotiation @id returned`);    }     const finalized = await this.mgmt.waitForState(      `/v3/contractnegotiations/${encodeURIComponent(negotiationId)}`,      (b) => b?.state,      "FINALIZED",      60    );     const agreementId = finalized?.contractAgreementId;    if (!agreementId) {      throw new Error(`No contractAgreementId on finalized negotiation`);    }     l.c(`Contract finalized: agreementId=${agreementId}`);     return { negotiationId, agreementId };  }   /* ------------------------------------------------------------   * Step 5 — Transfer   * ------------------------------------------------------------ */   async startTransfer(    cat: CatalogResult,    neg: NegotiationResult  ): Promise<TransferResult> {    const l = this.log;     l.c(`Starting transfer for agreement '${neg.agreementId}'`);     const created = await this.mgmt.raw(      "POST",      `/v3/transferprocesses`,      JSON.stringify({        "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },        "@type": "TransferRequest",        contractId: neg.agreementId,        protocol: "dataspace-protocol-http",        connectorId: cat.providerPid,        counterPartyAddress: this.provider.dspAddressDocker,        transferType: "HttpData-PULL",      })    );     const transferProcessId = created?.["@id"];    if (!transferProcessId) {      throw new Error(`No transferProcessId returned`);    }     await this.mgmt.waitForState(      `/v3/transferprocesses/${encodeURIComponent(transferProcessId)}`,      (b) => b?.state,      "STARTED",      60    );     return { transferProcessId };  }   /* ------------------------------------------------------------   * Step 6EDR   * ------------------------------------------------------------ */   async fetchEdr(tr: TransferResult): Promise<EdrResult> {    const l = this.log;     const path = `/v3/edrs/${encodeURIComponent(      tr.transferProcessId    )}/dataaddress`;     let last: any = null;     for (let i = 0; i < 60; i++) {      const res = await fetch(`${this.mgmt.baseUrl}${path}`, {        headers: { "X-Api-Key": this.mgmt.apiKey },      });       const text = await res.text();      try {        last = JSON.parse(text);      } catch {        last = text;      }       const obj = firstObj<any>(last);      if (obj?.authorization && obj?.endpoint) {        const endpointHost = obj.endpoint.replace(          "http://edc-provider:11005/api/public",          this.provider.publicBaseHost        );         l.token("consumer", "EDR token", obj.authorization);        return {          endpointDocker: obj.endpoint,          endpointHost,          token: obj.authorization,        };      }       await new Promise((r) => setTimeout(r, 1000));    }     throw new Error(`Timed out waiting for EDR`);  }   /* ------------------------------------------------------------   * Step 7 — Data access   * ------------------------------------------------------------ */   async accessData(    edr: EdrResult,    authHeaderMode: "bearer" | "raw"  ): Promise<any> {    const l = this.log;     l.c(`Accessing provider data via EDR public endpoint`);    l.c(`Endpoint: ${edr.endpointHost}/`);    l.c(`Authorization mode: ${authHeaderMode}`);     const auth =      authHeaderMode === "bearer" ? `Bearer ${edr.token}` : edr.token;     const res = await fetch(`${edr.endpointHost}/`, {      headers: {        Authorization: auth,        Accept: "application/json",      },    });     l.c(`Provider responded with HTTP ${res.status}`);     const text = await res.text();     let parsed: any = text;    let isJson = false;     try {      parsed = JSON.parse(text);      isJson = true;      l.c(`Response body is valid JSON`);    } catch {      l.c(`Response body is not JSON`);    }     if (!res.ok) {      l.err(`Data access failed`);      throw new Error(        `Data access failed: HTTP ${res.status}\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    }     l.c(`Transferred data:`);     if (isJson) {      if (Array.isArray(parsed)) {        l.c(`Data shape: array(len=${parsed.length})`);      } else if (parsed && typeof parsed === "object") {        l.c(`Data shape: keys=[${Object.keys(parsed).join(", ")}]`);      } else {        l.c(`Data type: ${typeof parsed}`);      }       l.c(`Payload:\n${JSON.stringify(parsed, null, 2)}`);    } else {      l.c(`Payload (text):\n${String(parsed)}`);    }     return parsed;  }} 

Note: with EdcTransaction we are already anticipating future needs. In experiment 2, each consumer still has only a single transaction, and I could technically have kept those steps — just like with the provider — inside the consumer.

ManagementClient

In a data space, not everything is a conversation. Some things are simply administration. Registering assets, creating offers, tracking contracts, monitoring transfers — all of that happens via the participants’ management interfaces. These are not public conversations between parties, but internal actions through which an organization controls its own data space role. The EDC Management API is exactly that: the control panel of a participant.

As we saw in experiment 1, there is both a Consumer Management API and a Provider Management API.

The EdcManagementClient object is my first, minimal translation of that control panel into code. No clever logic, no policy decisions — just a reliable hand that handles API requests, reads data, renders errors intelligible, and occasionally waits patiently for everything to catch up.

export class EdcManagementClient {  constructor(readonly baseUrl: string, readonly apiKey: string) {}   async json(method: string, path: string, body?: any): Promise<any> {    const res = await fetch(`${this.baseUrl}${path}`, {      method,      headers: {        "X-Api-Key": this.apiKey,        "Content-Type": "application/json",        Accept: "application/json",      },      body: body ? JSON.stringify(body) : undefined,    });     const text = await res.text();    let parsed: any = text;    try {      parsed = JSON.parse(text);    } catch {}     if (!res.ok) {      throw new Error(        `HTTP ${res.status} ${method} ${this.baseUrl}${path}\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    }    return parsed;  }   async raw(method: string, path: string, rawBody: string): Promise<any> {    const res = await fetch(`${this.baseUrl}${path}`, {      method,      headers: {        "X-Api-Key": this.apiKey,        "Content-Type": "application/json",        Accept: "application/json",      },      body: rawBody,    });     const text = await res.text();    let parsed: any = text;    try {      parsed = JSON.parse(text);    } catch {}     if (!res.ok) {      throw new Error(        `HTTP ${res.status} ${method} ${this.baseUrl}${path}\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    }    return parsed;  }   async waitForState<T = any>(    path: string,    getState: (body: any) => string | undefined,    desired: string,    timeoutSeconds = 60  ): Promise<T> {    const deadline = Date.now() + timeoutSeconds * 1000;    let last: any = null;     while (Date.now() < deadline) {      const body = await this.json("GET", path);      last = body;      if (getState(body) === desired) return body as T;      await new Promise((r) => setTimeout(r, 1000));    }     throw new Error(      `Timeout waiting for state '${desired}' at ${        this.baseUrl      }${path}\nLast=${JSON.stringify(last, null, 2)}`    );  }} 

What matters most is what this client does not do. It does not negotiate, does not choose offers, and does not make decisions. That is the work of consumers, providers, and transactions. The management client merely ensures that those roles can talk to their own EDC infrastructure in a consistent way. In that sense, it is an adapter: small, boring, and therefore essential.

The result

Although we have restructured all the code, experiment 2 does not actually do much more than experiment 1. What it provides is a sturdier foundation for the next steps.

import { Consumer } from "./lib/consumer.js";import { Provider, PublicationSpec } from "./lib/provider.js";import { Logger } from "./utils/logger.js"; type RunConfig = {  apiKey: string;   // Provider (host-facing mgmt) + (docker-facing dsp)  providerPid: string;  providerMgmtBaseUrl: string;  providerDspAddressDocker: string;  providerPublicBaseHost: string;   // Publications (provider)  publications: PublicationSpec[];   // Consumers  consumers: Array<{    pid: string;    mgmtBaseUrl: string;  }>;   // Step 7  authHeaderMode?: "bearer" | "raw";}; function loadRunConfig(): RunConfig {  const apiKey = process.env.API_KEY ?? "SomeOtherApiKey";   return {    apiKey,     providerPid: process.env.PROVIDER_PID ?? "provider",    providerMgmtBaseUrl:      process.env.PROVIDER_MGMT ?? "http://localhost:11012/api/management",    providerDspAddressDocker:      process.env.PROVIDER_DSP_DOCKER ?? "http://edc-provider:11003/api/dsp",    providerPublicBaseHost:      process.env.PROVIDER_PUBLIC_HOST ?? "http://localhost:11015/api/public",     publications: [      {        assetId: process.env.ASSET_ID ?? "asset-hello-1",        contractDefId: process.env.CONTRACT_DEF_ID ?? "cd-hello-1",        policyId: process.env.POLICY_ID ?? "always-true",        // docker-internal URL        sourceUrl: process.env.SOURCE_URL ?? "http://api:7070/hello",      },    ],     consumers: [      {        pid: process.env.CONSUMER_1_PID ?? "consumer-1",        mgmtBaseUrl:          process.env.CONSUMER_1_MGMT ??          "http://localhost:12012/api/management",      },      {        pid: process.env.CONSUMER_2_PID ?? "consumer-2",        mgmtBaseUrl:          process.env.CONSUMER_2_MGMT ??          "http://localhost:22012/api/management",      },    ],     authHeaderMode: (process.env.AUTH_HEADER_MODE as "bearer" | "raw") ?? "raw",  };} export async function run() {  const cfg = loadRunConfig();  const logger = new Logger();   // --- Provider setup ---  const provider = new Provider(    cfg.providerPid,    cfg.providerMgmtBaseUrl,    cfg.apiKey,    cfg.providerDspAddressDocker,    cfg.providerPublicBaseHost,    logger.scoped("setup")  );   logger.s("== Provider setup: assets + contract definitions ==");  for (const pub of cfg.publications) {    logger.s(`== Ensure asset '${pub.assetId}' ==`);    await provider.ensureAsset(pub);     logger.s(`== Ensure contract definition '${pub.contractDefId}' ==`);    await provider.ensureContractDefinition(pub);  }   // --- Consumer transactions ---  let i = 0;  for (const c of cfg.consumers) {    i++;    const consumer = new Consumer(      c.pid,      c.mgmtBaseUrl,      cfg.apiKey,      logger.scoped(`[consumer-${i}]`)    );    const tx = consumer.transaction(provider);    await tx.run("raw");  }   return { ok: true as const };} if (import.meta.url === `file://${process.argv[1]}`) {  run().catch((err) => {    console.error(err);    process.exit(1);  });} 

After initialization — starting all containers — we configure the experiment by creating the provider and the consumers. The provider starts first and creates an asset and an offer. Then we run a loop over all consumers. For each consumer, we create a data space transaction and execute it.

  // --- Consumer transactions ---  let i = 0;  for (const c of cfg.consumers) {    i++;    const consumer = new Consumer(      c.pid,      c.mgmtBaseUrl,      cfg.apiKey,      logger.scoped(`[consumer-${i}]`)    );    const tx = consumer.transaction(provider);    await tx.run("raw");  }

Twice (for consumer-1 and consumer-2), the source data appears:

{  "message": "Hello, Data space",  "ts": "2026-01-07T14:47:50.462Z",  "dataset": [    {      "id": "a1",      "title": "Example record",      "license": "CC0"    }  ]}

In both transactions, however, the agreementId and the EDR token differ. Each consumer has completed its own contract negotiation, received access approval from the provider, and been issued a unique key.

Not very exciting — but the experiment succeeded. Hooray.

Experiment 3

After a restructuring like the one in experiment 2, the most interesting question you can ask is: how do we break this?

Although I am eager to start working on provider-side access conditions, I was curious enough about the behavior of the EDC itself to add a small third experiment. What happens if a malicious consumer tries to use the agreementId or EDR token of another consumer?

// Implements the three “reuse” attempts://// 1) Guaranteed failure: consumer-3 cannot retrieve consumer-2’s EDR from consumer-3 mgmt API// 2) Likely failure: consumer-3 cannot use consumer-2’s agreementId to start a transfer// 3) Not guaranteed: consumer-3 reusing consumer-2’s EDR token for direct HTTP access import { Consumer } from "./lib/consumer.js";import { Provider, PublicationSpec } from "./lib/provider.js";import { Logger } from "./utils/logger.js"; type RunConfig = {  apiKey: string;   // Provider (host-facing mgmt) + (docker-facing dsp)  providerPid: string;  providerMgmtBaseUrl: string;  providerDspAddressDocker: string;  providerPublicBaseHost: string;   // Publications (provider)  publications: PublicationSpec[];   // Consumers (1 and 2 run full tx)  consumers: Array<{    pid: string;    mgmtBaseUrl: string;  }>;   // Consumer-3 (reuse attempts)  consumer3: {    pid: string;    mgmtBaseUrl: string;  };   // Step 7  authHeaderMode?: "bearer" | "raw";}; function loadRunConfig(): RunConfig {  const apiKey = process.env.API_KEY ?? "SomeOtherApiKey";   return {    apiKey,     providerPid: process.env.PROVIDER_PID ?? "provider",    providerMgmtBaseUrl:      process.env.PROVIDER_MGMT ?? "http://localhost:11012/api/management",    providerDspAddressDocker:      process.env.PROVIDER_DSP_DOCKER ?? "http://edc-provider:11003/api/dsp",    providerPublicBaseHost:      process.env.PROVIDER_PUBLIC_HOST ?? "http://localhost:11015/api/public",     publications: [      {        assetId: process.env.ASSET_ID ?? "asset-hello-1",        contractDefId: process.env.CONTRACT_DEF_ID ?? "cd-hello-1",        policyId: process.env.POLICY_ID ?? "always-true",        // docker-internal URL        sourceUrl: process.env.SOURCE_URL ?? "http://api:7070/hello",      },    ],     consumers: [      {        pid: process.env.CONSUMER_1_PID ?? "consumer-1",        mgmtBaseUrl:          process.env.CONSUMER_1_MGMT ??          "http://localhost:12012/api/management",      },      {        pid: process.env.CONSUMER_2_PID ?? "consumer-2",        mgmtBaseUrl:          process.env.CONSUMER_2_MGMT ??          "http://localhost:22012/api/management",      },    ],     consumer3: {      pid: process.env.CONSUMER_3_PID ?? "consumer-3",      mgmtBaseUrl:        process.env.CONSUMER_3_MGMT ?? "http://localhost:32012/api/management",    },     authHeaderMode: (process.env.AUTH_HEADER_MODE as "bearer" | "raw") ?? "raw",  };} export async function run() {  const cfg = loadRunConfig();  const logger = new Logger();   // --- Provider setup ---  const provider = new Provider(    cfg.providerPid,    cfg.providerMgmtBaseUrl,    cfg.apiKey,    cfg.providerDspAddressDocker,    cfg.providerPublicBaseHost,    logger.scoped("setup")  );   logger.s("== Provider setup: assets + contract definitions ==");  for (const pub of cfg.publications) {    logger.s(`== Ensure asset '${pub.assetId}' ==`);    await provider.ensureAsset(pub);     logger.s(`== Ensure contract definition '${pub.contractDefId}' ==`);    await provider.ensureContractDefinition(pub);  }   // --- Consumer transactions (1 and 2) ---  const results: Array<{    consumerPid: string;    r: any; // keep flexible; we mainly need r.cat/r.neg/r.tr/r.edr  }> = [];   for (const c of cfg.consumers) {    const consumer = new Consumer(      c.pid,      c.mgmtBaseUrl,      cfg.apiKey,      logger.scoped(`[${c.pid}]`)    );    const tx = consumer.transaction(provider);    const r = await tx.run(cfg.authHeaderMode ?? "raw");    results.push({ consumerPid: c.pid, r });  }   const r2 = results.at(1)?.r;  if (!r2?.tr?.transferProcessId || !r2?.neg?.agreementId || !r2?.edr?.token) {    throw new Error(      `Missing consumer-2 results. Need r2.tr.transferProcessId, r2.neg.agreementId, r2.edr.token. Got:\n${JSON.stringify(        r2,        null,        2      )}`    );  }   // --- Consumer-3 reuse attempts ---  const consumer3 = new Consumer(    cfg.consumer3.pid,    cfg.consumer3.mgmtBaseUrl,    cfg.apiKey,    logger.scoped(`[${cfg.consumer3.pid}]`)  );   logger.s("== Reuse attempts (consumer-3) ==");   // 1) Guaranteed failure: consumer-3 cannot retrieve consumer-2’s EDR from consumer-3 mgmt API  logger.s(    "== 1) consumer-3 fetches consumer-2 EDR via management API (should fail) =="  );  try {    const tp2 = r2.tr.transferProcessId as string;    await consumer3.mgmt.json(      "GET",      `/v3/edrs/${encodeURIComponent(tp2)}/dataaddress`    );    logger.err(      `Unexpected success: consumer-3 retrieved an EDR for consumer-2 transferProcessId=${tp2}`    );  } catch (e: any) {    logger.w(`Expected failure: ${String(e?.message ?? e)}`);  }   // 2) Likely failure: consumer-3 cannot use consumer-2’s agreementId to start a transfer  logger.s(    "== 2) consumer-3 starts transfer using consumer-2 agreementId (should fail) =="  );  try {    const created = await consumer3.mgmt.raw(      "POST",      `/v3/transferprocesses`,      JSON.stringify({        "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },        "@type": "TransferRequest",        contractId: r2.neg.agreementId,        protocol: "dataspace-protocol-http",        connectorId: r2.cat.providerPid,        counterPartyAddress: provider.dspAddressDocker,        transferType: "HttpData-PULL",      })    );     logger.err(      `Unexpected success: consumer-3 started transfer with consumer-2 agreementId. Response:\n${JSON.stringify(        created,        null,        2      )}`    );  } catch (e: any) {    logger.w(`Expected failure: ${String(e?.message ?? e)}`);  }   // 3) Not guaranteed: consumer-3 reusing consumer-2’s EDR token for direct HTTP access  logger.s(    "== 3) consumer-3 uses consumer-2 EDR token directly (may succeed or fail) =="  );  try {    const authMode = cfg.authHeaderMode ?? "raw";    const auth =      authMode === "bearer" ? `Bearer ${r2.edr.token}` : r2.edr.token;     const res = await fetch(`${r2.edr.endpointHost}/`, {      headers: {        Authorization: auth,        Accept: "application/json",      },    });     const text = await res.text();    let parsed: any = text;    try {      parsed = JSON.parse(text);    } catch {}     if (!res.ok) {      logger.w(        `Token reuse blocked (HTTP ${res.status}). Body:\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    } else {      logger.w(        `Token reuse succeeded (HTTP ${          res.status        }). This means “whoever has the token can use it” in this setup.\nBody:\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    }  } catch (e: any) {    logger.w(`Token reuse attempt errored: ${String(e?.message ?? e)}`);  }   return { ok: true as const };} if (import.meta.url === `file://${process.argv[1]}`) {  run().catch((err) => {    console.error(err);    process.exit(1);  });} 

For this experiment, we introduce consumer-3, who plays the role of the villain. Thanks to the refactored code, this is now pleasantly easy to do.

Can a villain retrieve another consumer’s key from the provider?

The first question is what happens when villain/consumer-3 asks the provider for the EDR token of consumer-2 using that consumer’s agreementId. This is step 6 in these experiments. Ideally, the provider should not respond, because the agreement and token are tied to a different consumer identity.

  // 1) Guaranteed failure: consumer-3 cannot retrieve consumer-2’s EDR from consumer-3 mgmt API  logger.s(    "== 1) consumer-3 fetches consumer-2 EDR via management API (should fail) =="  );  try {    const tp2 = r2.tr.transferProcessId as string;    await consumer3.mgmt.json(      "GET",      `/v3/edrs/${encodeURIComponent(tp2)}/dataaddress`    );    logger.err(      `Unexpected success: consumer-3 retrieved an EDR for consumer-2 transferProcessId=${tp2}`    );  } catch (e: any) {    logger.w(`Expected failure: ${String(e?.message ?? e)}`);  } 

The answer is:

[  {    "message": "Object of type EndpointDataReferenceEntry with ID=019bb7a9-c6cf-7806-ae4b-4bcfbdb3e94c was not found",    "type": "ObjectNotFound",    "path": null,    "invalidValue": null  }]

Good. That does not work. The provider simply reports that no keys were found.

Can a villain convince the provider to create a key using someone else’s agreement?

Next, I want to know whether our villain can intervene earlier and convince the provider to start a transfer using an agreementId, thereby creating an EDR. This is step 5 in the experiments.

  // 2) Likely failure: consumer-3 cannot use consumer-2’s agreementId to start a transfer  logger.s(    "== 2) consumer-3 starts transfer using consumer-2 agreementId (should fail) =="  );  try {    const created = await consumer3.mgmt.raw(      "POST",      `/v3/transferprocesses`,      JSON.stringify({        "@context": { "@vocab": "https://w3id.org/edc/v0.0.1/ns/" },        "@type": "TransferRequest",        contractId: r2.neg.agreementId,        protocol: "dataspace-protocol-http",        connectorId: r2.cat.providerPid,        counterPartyAddress: provider.dspAddressDocker,        transferType: "HttpData-PULL",      })    );     logger.err(      `Unexpected success: consumer-3 started transfer with consumer-2 agreementId. Response:\n${JSON.stringify(        created,        null,        2      )}`    );  } catch (e: any) {    logger.w(`Expected failure: ${String(e?.message ?? e)}`);  } 

The provider responds as follows:

[  {    "message": "Contract agreement with id 019bb7a9-c1f6-7faf-951e-b270df39ec10 not found",    "type": "InvalidRequest",    "path": null,    "invalidValue": null  }]

Such requests are blocked during the negotiation process. For consumer-3, the agreementId does not exist — exactly as intended.

Can a villain retrieve data using another consumer’s key?

The final question may be the most interesting one. Suppose our villain has intercepted the network traffic between the provider and consumer-2 and obtained the EDR token. What happens in step 7 of these experiments, when the data is actually retrieved?

  // 3) Not guaranteed: consumer-3 reusing consumer-2’s EDR token for direct HTTP access  logger.s(    "== 3) consumer-3 uses consumer-2 EDR token directly (may succeed or fail) =="  );  try {    const authMode = cfg.authHeaderMode ?? "raw";    const auth =      authMode === "bearer" ? `Bearer ${r2.edr.token}` : r2.edr.token;     const res = await fetch(`${r2.edr.endpointHost}/`, {      headers: {        Authorization: auth,        Accept: "application/json",      },    });     const text = await res.text();    let parsed: any = text;    try {      parsed = JSON.parse(text);    } catch {}     if (!res.ok) {      logger.w(        `Token reuse blocked (HTTP ${res.status}). Body:\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    } else {      logger.w(        `Token reuse succeeded (HTTP ${          res.status        }). This means “whoever has the token can use it” in this setup.\nBody:\n${          typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2)        }`      );    }  } catch (e: any) {    logger.w(`Token reuse attempt errored: ${String(e?.message ?? e)}`);  } 

In experiment 1, I explained that a transaction between consumer and provider runs across two layers. The Control Plane handles the contract negotiation and issues a key to the consumer. The Data Plane then deals exclusively with transporting the information. If a valid key exists, the Data Plane gets to work and no longer performs checks.

I therefore suspected that the transport would succeed, and that the villain would gain access to the data.

{  "message": "Hello, Data space",  "ts": "2026-01-07T14:47:50.462Z",  "dataset": [    {      "id": "a1",      "title": "Example record",      "license": "CC0"    }  ]}

Indeed, that is what happens. The key is valid, the Data Plane no longer checks, and if consumer-3 has the EDR token, access is granted.

Is that strange? Well… no. Not really. It is the responsibility of consumer-2 to keep the key safe — not the provider’s. This is also why EDR tokens typically have a limited lifetime.

Where are we now?

In experiment 1, I tested the process of a data space transaction. Those steps have now landed in a robust structure and are ready for further experimentation.

In the next experiments, I will finally look at the access conditions that a provider can attach to an offer, and how those conditions might be evaluated automatically. I also have a list of follow-up ideas, but feel free to send me a message if you have suggestions for other data space experiments.

The experiments will not necessarily appear in quick succession, so keep following this blog for updates.