vet-data-utils-ts 0.5.25 → 0.5.26

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/README.md CHANGED
@@ -215,6 +215,15 @@ authorization are separate checks performed by their owning adapters.
215
215
  `projectHealthDcatResourceToJsonLd()` is the explicit standards serialization
216
216
  boundary used by host/dataspace adapters.
217
217
 
218
+ Research publication is study-scoped. A derived cohort Dataset links to its
219
+ generating `ResearchStudy` through PROV-O and a separate `CatalogRecord`
220
+ controls whether external catalogue searches may return it. Archiving that
221
+ record withdraws discovery without deleting the study, eligibility/team
222
+ Groups, ResearchSubjects or Dataset, and without changing `dct:accessRights`.
223
+ No individual ResearchSubject identifier is projected into HealthDCAT.
224
+ `Distribution` describes the HDAB-controlled representation; `DataService`
225
+ describes the host API serving Dataset metadata.
226
+
218
227
  `vet-data-utils-ts/organization-application` is the product-neutral application
219
228
  value used by UHC UNID, VetChain and SOSChain adapters. The host supplies the
220
229
  allowed sector list and translated labels. The value contains one official
@@ -19,6 +19,7 @@ export declare enum ClaimsHealthDcatDataset {
19
19
  distribution = "dcat.distribution",
20
20
  source = "dct.source",
21
21
  provenance = "dct.provenance",
22
+ wasGeneratedBy = "prov.wasGeneratedBy",
22
23
  personalData = "dpv.hasPersonalData",
23
24
  healthCategory = "healthdcatap.healthCategory",
24
25
  healthDataAccessBody = "healthdcatap.hdab",
@@ -38,13 +39,47 @@ export declare enum ClaimsHealthDcatCatalog {
38
39
  dataset = "dcat.dataset",
39
40
  userSelected = "Catalog.user-selected"
40
41
  }
42
+ /** DCAT Catalogue Record editorial claims; status is distinct from data access rights. */
43
+ export declare enum ClaimsHealthDcatCatalogRecord {
44
+ identifier = "dct.identifier",
45
+ primaryTopic = "foaf.primaryTopic",
46
+ status = "adms.status",
47
+ modified = "dct.modified",
48
+ conformsTo = "dct.conformsTo",
49
+ userSelected = "CatalogRecord.user-selected"
50
+ }
51
+ /** DCAT Distribution claims used for downloadable or controlled-access representations. */
52
+ export declare enum ClaimsHealthDcatDistribution {
53
+ identifier = "dct.identifier",
54
+ title = "dct.title",
55
+ accessUrl = "dcat.accessURL",
56
+ format = "dct.format",
57
+ mediaType = "dcat.mediaType",
58
+ accessRights = "dct.accessRights",
59
+ policy = "odrl.hasPolicy",
60
+ userSelected = "Distribution.user-selected"
61
+ }
62
+ /** DCAT DataService claims used for host APIs serving one or more Datasets. */
63
+ export declare enum ClaimsHealthDcatDataService {
64
+ identifier = "dct.identifier",
65
+ title = "dct.title",
66
+ publisher = "dct.publisher",
67
+ endpointUrl = "dcat.endpointURL",
68
+ servesDataset = "dcat.servesDataset",
69
+ accessRights = "dct.accessRights",
70
+ userSelected = "DataService.user-selected"
71
+ }
72
+ export type HealthDcatCatalogRecordStatus = 'draft' | 'published' | 'archived';
41
73
  /** Complete allowlist for product-persisted HealthDCAT flat claims. */
42
74
  export declare const HealthDcatFlatClaimCatalog: Readonly<{
43
75
  readonly Dataset: readonly ClaimsHealthDcatDataset[];
44
76
  readonly Catalog: readonly ClaimsHealthDcatCatalog[];
77
+ readonly CatalogRecord: readonly ClaimsHealthDcatCatalogRecord[];
78
+ readonly Distribution: readonly ClaimsHealthDcatDistribution[];
79
+ readonly DataService: readonly ClaimsHealthDcatDataService[];
45
80
  }>;
46
81
  export type HealthDcatResource = Readonly<{
47
- resourceType: 'Dataset' | 'Catalog';
82
+ resourceType: 'Dataset' | 'Catalog' | 'CatalogRecord' | 'Distribution' | 'DataService';
48
83
  id: string;
49
84
  meta: Readonly<{
50
85
  claims: Readonly<Record<string, ClaimValue>>;
@@ -87,6 +122,48 @@ export declare function buildHealthDcatCatalog(input: ControllerAuthority & Read
87
122
  datasetIdentifiers: readonly string[];
88
123
  applicableLegislation: readonly string[];
89
124
  }>): HealthDcatResource;
125
+ /** Builds a governed Dataset Distribution without exposing the source records. */
126
+ export declare function buildHealthDcatDistribution(input: ControllerAuthority & Readonly<{
127
+ id: string;
128
+ title: string;
129
+ accessUrl: string;
130
+ format: string;
131
+ mediaType?: string;
132
+ accessRights: HealthDcatAccessRights;
133
+ healthDataAccessBodyIdentifier: string;
134
+ policyIdentifier?: string;
135
+ }>): HealthDcatResource;
136
+ /** Builds metadata for a host API that serves published Dataset descriptions. */
137
+ export declare function buildHealthDcatDataService(input: ControllerAuthority & Readonly<{
138
+ id: string;
139
+ title: string;
140
+ endpointUrl: string;
141
+ servesDatasetIdentifiers: readonly string[];
142
+ accessRights: HealthDcatAccessRights;
143
+ }>): HealthDcatResource;
144
+ /**
145
+ * Links one cohort Dataset to the ResearchStudy that generated it and creates
146
+ * the separate editorial CatalogRecord controlling external discoverability.
147
+ * ResearchSubject identifiers are deliberately absent from this projection.
148
+ */
149
+ export declare function buildResearchStudyDatasetPublication(input: ControllerAuthority & Readonly<{
150
+ dataset: unknown;
151
+ researchStudyIdentifier: string;
152
+ catalogRecordIdentifier: string;
153
+ status: HealthDcatCatalogRecordStatus;
154
+ modifiedAt: string;
155
+ }>): Readonly<{
156
+ dataset: HealthDcatResource;
157
+ record: HealthDcatResource;
158
+ }>;
159
+ /** Controller-only editorial transition; archiving removes external discovery only. */
160
+ export declare function updateHealthDcatCatalogRecordStatus(input: ControllerAuthority & Readonly<{
161
+ record: unknown;
162
+ status: HealthDcatCatalogRecordStatus;
163
+ modifiedAt: string;
164
+ }>): HealthDcatResource;
165
+ /** True only for records eligible for an external marketplace search result. */
166
+ export declare function isHealthDcatCatalogRecordExternallyDiscoverable(record: unknown): boolean;
90
167
  /** Creates an auditable tenant authorization for host-provided catalogue federation. */
91
168
  export declare function buildHealthDcatFederationRequest(input: ControllerAuthority & Readonly<{
92
169
  catalogIdentifier: string;
@@ -21,6 +21,7 @@ export var ClaimsHealthDcatDataset;
21
21
  ClaimsHealthDcatDataset["distribution"] = "dcat.distribution";
22
22
  ClaimsHealthDcatDataset["source"] = "dct.source";
23
23
  ClaimsHealthDcatDataset["provenance"] = "dct.provenance";
24
+ ClaimsHealthDcatDataset["wasGeneratedBy"] = "prov.wasGeneratedBy";
24
25
  ClaimsHealthDcatDataset["personalData"] = "dpv.hasPersonalData";
25
26
  ClaimsHealthDcatDataset["healthCategory"] = "healthdcatap.healthCategory";
26
27
  ClaimsHealthDcatDataset["healthDataAccessBody"] = "healthdcatap.hdab";
@@ -41,17 +42,53 @@ export var ClaimsHealthDcatCatalog;
41
42
  ClaimsHealthDcatCatalog["dataset"] = "dcat.dataset";
42
43
  ClaimsHealthDcatCatalog["userSelected"] = "Catalog.user-selected";
43
44
  })(ClaimsHealthDcatCatalog || (ClaimsHealthDcatCatalog = {}));
45
+ /** DCAT Catalogue Record editorial claims; status is distinct from data access rights. */
46
+ export var ClaimsHealthDcatCatalogRecord;
47
+ (function (ClaimsHealthDcatCatalogRecord) {
48
+ ClaimsHealthDcatCatalogRecord["identifier"] = "dct.identifier";
49
+ ClaimsHealthDcatCatalogRecord["primaryTopic"] = "foaf.primaryTopic";
50
+ ClaimsHealthDcatCatalogRecord["status"] = "adms.status";
51
+ ClaimsHealthDcatCatalogRecord["modified"] = "dct.modified";
52
+ ClaimsHealthDcatCatalogRecord["conformsTo"] = "dct.conformsTo";
53
+ ClaimsHealthDcatCatalogRecord["userSelected"] = "CatalogRecord.user-selected";
54
+ })(ClaimsHealthDcatCatalogRecord || (ClaimsHealthDcatCatalogRecord = {}));
55
+ /** DCAT Distribution claims used for downloadable or controlled-access representations. */
56
+ export var ClaimsHealthDcatDistribution;
57
+ (function (ClaimsHealthDcatDistribution) {
58
+ ClaimsHealthDcatDistribution["identifier"] = "dct.identifier";
59
+ ClaimsHealthDcatDistribution["title"] = "dct.title";
60
+ ClaimsHealthDcatDistribution["accessUrl"] = "dcat.accessURL";
61
+ ClaimsHealthDcatDistribution["format"] = "dct.format";
62
+ ClaimsHealthDcatDistribution["mediaType"] = "dcat.mediaType";
63
+ ClaimsHealthDcatDistribution["accessRights"] = "dct.accessRights";
64
+ ClaimsHealthDcatDistribution["policy"] = "odrl.hasPolicy";
65
+ ClaimsHealthDcatDistribution["userSelected"] = "Distribution.user-selected";
66
+ })(ClaimsHealthDcatDistribution || (ClaimsHealthDcatDistribution = {}));
67
+ /** DCAT DataService claims used for host APIs serving one or more Datasets. */
68
+ export var ClaimsHealthDcatDataService;
69
+ (function (ClaimsHealthDcatDataService) {
70
+ ClaimsHealthDcatDataService["identifier"] = "dct.identifier";
71
+ ClaimsHealthDcatDataService["title"] = "dct.title";
72
+ ClaimsHealthDcatDataService["publisher"] = "dct.publisher";
73
+ ClaimsHealthDcatDataService["endpointUrl"] = "dcat.endpointURL";
74
+ ClaimsHealthDcatDataService["servesDataset"] = "dcat.servesDataset";
75
+ ClaimsHealthDcatDataService["accessRights"] = "dct.accessRights";
76
+ ClaimsHealthDcatDataService["userSelected"] = "DataService.user-selected";
77
+ })(ClaimsHealthDcatDataService || (ClaimsHealthDcatDataService = {}));
44
78
  /** Complete allowlist for product-persisted HealthDCAT flat claims. */
45
79
  export const HealthDcatFlatClaimCatalog = Object.freeze({
46
80
  Dataset: Object.freeze(Object.values(ClaimsHealthDcatDataset)),
47
81
  Catalog: Object.freeze(Object.values(ClaimsHealthDcatCatalog)),
82
+ CatalogRecord: Object.freeze(Object.values(ClaimsHealthDcatCatalogRecord)),
83
+ Distribution: Object.freeze(Object.values(ClaimsHealthDcatDistribution)),
84
+ DataService: Object.freeze(Object.values(ClaimsHealthDcatDataService)),
48
85
  });
49
86
  /** Validates an indexed HealthDCAT resource and rejects nested or unknown claims. */
50
87
  export function normalizeHealthDcatResource(candidate) {
51
88
  if (!candidate || typeof candidate !== 'object')
52
89
  throw new TypeError('health_dcat_resource_invalid');
53
90
  const value = candidate;
54
- if (value.resourceType !== 'Dataset' && value.resourceType !== 'Catalog')
91
+ if (!['Dataset', 'Catalog', 'CatalogRecord', 'Distribution', 'DataService'].includes(String(value.resourceType)))
55
92
  throw new TypeError('health_dcat_resource_invalid');
56
93
  if (typeof value.id !== 'string' || !value.id.trim())
57
94
  throw new TypeError('health_dcat_resource_invalid');
@@ -60,7 +97,8 @@ export function normalizeHealthDcatResource(candidate) {
60
97
  const claims = value.meta.claims;
61
98
  if (!claims || typeof claims !== 'object' || Array.isArray(claims))
62
99
  throw new TypeError('health_dcat_resource_invalid');
63
- const allowed = HealthDcatFlatClaimCatalog[value.resourceType];
100
+ const resourceType = value.resourceType;
101
+ const allowed = HealthDcatFlatClaimCatalog[resourceType];
64
102
  const normalized = {};
65
103
  for (const [claim, raw] of Object.entries(claims)) {
66
104
  if (!allowed.includes(claim))
@@ -73,7 +111,7 @@ export function normalizeHealthDcatResource(candidate) {
73
111
  throw new TypeError(`health_dcat_claim_value_invalid:${claim}`);
74
112
  }
75
113
  return Object.freeze({
76
- resourceType: value.resourceType,
114
+ resourceType,
77
115
  id: value.id,
78
116
  meta: Object.freeze({ claims: Object.freeze(normalized) }),
79
117
  });
@@ -90,6 +128,7 @@ const JsonLdClaimNames = Object.freeze({
90
128
  [ClaimsHealthDcatDataset.distribution]: 'dcat:distribution',
91
129
  [ClaimsHealthDcatDataset.source]: 'dct:source',
92
130
  [ClaimsHealthDcatDataset.provenance]: 'dct:provenance',
131
+ [ClaimsHealthDcatDataset.wasGeneratedBy]: 'prov:wasGeneratedBy',
93
132
  [ClaimsHealthDcatDataset.personalData]: 'dpv:hasPersonalData',
94
133
  [ClaimsHealthDcatDataset.healthCategory]: 'healthdcatap:healthCategory',
95
134
  [ClaimsHealthDcatDataset.healthDataAccessBody]: 'healthdcatap:hdab',
@@ -97,6 +136,16 @@ const JsonLdClaimNames = Object.freeze({
97
136
  [ClaimsHealthDcatDataset.variables]: 'healthdcatap:hasVariables',
98
137
  [ClaimsHealthDcatDataset.authorizationReference]: 'prov:qualifiedAttribution',
99
138
  [ClaimsHealthDcatCatalog.dataset]: 'dcat:dataset',
139
+ [ClaimsHealthDcatCatalogRecord.primaryTopic]: 'foaf:primaryTopic',
140
+ [ClaimsHealthDcatCatalogRecord.status]: 'adms:status',
141
+ [ClaimsHealthDcatCatalogRecord.modified]: 'dct:modified',
142
+ [ClaimsHealthDcatCatalogRecord.conformsTo]: 'dct:conformsTo',
143
+ [ClaimsHealthDcatDistribution.accessUrl]: 'dcat:accessURL',
144
+ [ClaimsHealthDcatDistribution.format]: 'dct:format',
145
+ [ClaimsHealthDcatDistribution.mediaType]: 'dcat:mediaType',
146
+ [ClaimsHealthDcatDistribution.policy]: 'odrl:hasPolicy',
147
+ [ClaimsHealthDcatDataService.endpointUrl]: 'dcat:endpointURL',
148
+ [ClaimsHealthDcatDataService.servesDataset]: 'dcat:servesDataset',
100
149
  });
101
150
  /** Explicitly projects internal flat claims to DCAT/HealthDCAT JSON-LD. */
102
151
  export function projectHealthDcatResourceToJsonLd(resource) {
@@ -110,9 +159,12 @@ export function projectHealthDcatResourceToJsonLd(resource) {
110
159
  healthdcatap: 'http://healthdataportal.eu/ns/health#',
111
160
  dpv: 'https://w3id.org/dpv#',
112
161
  prov: 'http://www.w3.org/ns/prov#',
162
+ foaf: 'http://xmlns.com/foaf/0.1/',
163
+ adms: 'http://www.w3.org/ns/adms#',
164
+ odrl: 'http://www.w3.org/ns/odrl/2/',
113
165
  }),
114
166
  '@id': normalized.id,
115
- '@type': normalized.resourceType === 'Catalog' ? 'dcat:Catalog' : 'dcat:Dataset',
167
+ '@type': `dcat:${normalized.resourceType}`,
116
168
  };
117
169
  for (const [claim, value] of Object.entries(normalized.meta.claims)) {
118
170
  const jsonLdName = JsonLdClaimNames[claim];
@@ -197,6 +249,111 @@ export function buildHealthDcatCatalog(input) {
197
249
  };
198
250
  return Object.freeze({ resourceType: 'Catalog', id: input.id, meta: Object.freeze({ claims: Object.freeze(claims) }) });
199
251
  }
252
+ /** Builds a governed Dataset Distribution without exposing the source records. */
253
+ export function buildHealthDcatDistribution(input) {
254
+ assertController(input);
255
+ required(input.healthDataAccessBodyIdentifier, 'healthDataAccessBodyIdentifier');
256
+ const accessUrl = required(input.accessUrl, 'accessUrl');
257
+ if (!accessUrl.startsWith('https://'))
258
+ throw new TypeError('accessUrl must use HTTPS');
259
+ const claims = {
260
+ [ClaimsHealthDcatDistribution.identifier]: required(input.id, 'id'),
261
+ [ClaimsHealthDcatDistribution.title]: required(input.title, 'title'),
262
+ [ClaimsHealthDcatDistribution.accessUrl]: accessUrl,
263
+ [ClaimsHealthDcatDistribution.format]: required(input.format, 'format'),
264
+ [ClaimsHealthDcatDistribution.accessRights]: input.accessRights,
265
+ [ClaimsHealthDcatDistribution.userSelected]: true,
266
+ };
267
+ if (input.mediaType)
268
+ claims[ClaimsHealthDcatDistribution.mediaType] = required(input.mediaType, 'mediaType');
269
+ if (input.policyIdentifier)
270
+ claims[ClaimsHealthDcatDistribution.policy] = required(input.policyIdentifier, 'policyIdentifier');
271
+ return normalizeHealthDcatResource({ resourceType: 'Distribution', id: input.id, meta: { claims } });
272
+ }
273
+ /** Builds metadata for a host API that serves published Dataset descriptions. */
274
+ export function buildHealthDcatDataService(input) {
275
+ assertController(input);
276
+ const endpointUrl = required(input.endpointUrl, 'endpointUrl');
277
+ if (!endpointUrl.startsWith('https://'))
278
+ throw new TypeError('endpointUrl must use HTTPS');
279
+ if (!input.servesDatasetIdentifiers.length)
280
+ throw new TypeError('servesDatasetIdentifiers are required');
281
+ return normalizeHealthDcatResource({
282
+ resourceType: 'DataService', id: input.id, meta: { claims: {
283
+ [ClaimsHealthDcatDataService.identifier]: required(input.id, 'id'),
284
+ [ClaimsHealthDcatDataService.title]: required(input.title, 'title'),
285
+ [ClaimsHealthDcatDataService.publisher]: required(input.organizationIdentifier, 'organizationIdentifier'),
286
+ [ClaimsHealthDcatDataService.endpointUrl]: endpointUrl,
287
+ [ClaimsHealthDcatDataService.servesDataset]: Object.freeze([...input.servesDatasetIdentifiers]),
288
+ [ClaimsHealthDcatDataService.accessRights]: input.accessRights,
289
+ [ClaimsHealthDcatDataService.userSelected]: true,
290
+ } },
291
+ });
292
+ }
293
+ /**
294
+ * Links one cohort Dataset to the ResearchStudy that generated it and creates
295
+ * the separate editorial CatalogRecord controlling external discoverability.
296
+ * ResearchSubject identifiers are deliberately absent from this projection.
297
+ */
298
+ export function buildResearchStudyDatasetPublication(input) {
299
+ assertController(input);
300
+ const dataset = normalizeHealthDcatResource(input.dataset);
301
+ if (dataset.resourceType !== 'Dataset')
302
+ throw new TypeError('research_dataset_required');
303
+ const researchStudyIdentifier = required(input.researchStudyIdentifier, 'researchStudyIdentifier');
304
+ if (!researchStudyIdentifier.startsWith('ResearchStudy/'))
305
+ throw new TypeError('research_study_reference_invalid');
306
+ const modifiedAt = new Date(input.modifiedAt);
307
+ if (!Number.isFinite(modifiedAt.getTime()) || modifiedAt.toISOString() !== input.modifiedAt)
308
+ throw new TypeError('modifiedAt must be an ISO instant');
309
+ const linkedDataset = normalizeHealthDcatResource({
310
+ ...dataset,
311
+ meta: { claims: { ...dataset.meta.claims, [ClaimsHealthDcatDataset.wasGeneratedBy]: researchStudyIdentifier } },
312
+ });
313
+ const recordId = required(input.catalogRecordIdentifier, 'catalogRecordIdentifier');
314
+ const record = normalizeHealthDcatResource({
315
+ resourceType: 'CatalogRecord',
316
+ id: recordId,
317
+ meta: { claims: {
318
+ [ClaimsHealthDcatCatalogRecord.identifier]: recordId,
319
+ [ClaimsHealthDcatCatalogRecord.primaryTopic]: `Dataset/${dataset.id}`,
320
+ [ClaimsHealthDcatCatalogRecord.status]: input.status,
321
+ [ClaimsHealthDcatCatalogRecord.modified]: input.modifiedAt,
322
+ [ClaimsHealthDcatCatalogRecord.conformsTo]: 'HealthDCAT-AP Release 7',
323
+ [ClaimsHealthDcatCatalogRecord.userSelected]: true,
324
+ } },
325
+ });
326
+ return Object.freeze({ dataset: linkedDataset, record });
327
+ }
328
+ /** Controller-only editorial transition; archiving removes external discovery only. */
329
+ export function updateHealthDcatCatalogRecordStatus(input) {
330
+ assertController(input);
331
+ const record = normalizeHealthDcatResource(input.record);
332
+ if (record.resourceType !== 'CatalogRecord')
333
+ throw new TypeError('health_dcat_catalog_record_required');
334
+ const timestamp = new Date(input.modifiedAt);
335
+ if (!Number.isFinite(timestamp.getTime()) || timestamp.toISOString() !== input.modifiedAt)
336
+ throw new TypeError('modifiedAt must be an ISO instant');
337
+ return normalizeHealthDcatResource({
338
+ ...record,
339
+ meta: { claims: {
340
+ ...record.meta.claims,
341
+ [ClaimsHealthDcatCatalogRecord.status]: input.status,
342
+ [ClaimsHealthDcatCatalogRecord.modified]: input.modifiedAt,
343
+ } },
344
+ });
345
+ }
346
+ /** True only for records eligible for an external marketplace search result. */
347
+ export function isHealthDcatCatalogRecordExternallyDiscoverable(record) {
348
+ try {
349
+ const normalized = normalizeHealthDcatResource(record);
350
+ return normalized.resourceType === 'CatalogRecord'
351
+ && normalized.meta.claims[ClaimsHealthDcatCatalogRecord.status] === 'published';
352
+ }
353
+ catch {
354
+ return false;
355
+ }
356
+ }
200
357
  /** Creates an auditable tenant authorization for host-provided catalogue federation. */
201
358
  export function buildHealthDcatFederationRequest(input) {
202
359
  assertController(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vet-data-utils-ts",
3
- "version": "0.5.25",
3
+ "version": "0.5.26",
4
4
  "description": "Browser-safe governed VetChain data contracts",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",