fhirsmith 0.16.1 → 0.18.0
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/CHANGELOG.md +130 -0
- package/README.md +105 -30
- package/config-template.json +130 -4
- package/extension-tracker/extension-tracker.js +111 -3
- package/extension-tracker/readme.md +3 -0
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/html.js +9 -0
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +222 -0
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +8 -2
- package/packages/package-crawler.js +189 -82
- package/packages/packages.js +36 -63
- package/packages/utilities/dedupe-versions.js +22 -8
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +48 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +247 -54
- package/registry/uptime.js +154 -0
- package/release.sh +80 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/readme.md +1 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cm/cm-database.js +62 -0
- package/tx/cm/cm-package.js +20 -3
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +142 -19
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +79 -39
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +107 -6
- package/tx/ocl/README.md +62 -6
- package/tx/ocl/cm-ocl.cjs +166 -44
- package/tx/ocl/cs-ocl.cjs +163 -13
- package/tx/ocl/resolve/public-access.js +143 -0
- package/tx/ocl/resolve/reference-resolver.js +458 -0
- package/tx/ocl/vs-ocl.cjs +167 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/problems.js +7 -5
- package/tx/provider.js +21 -5
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +37 -8
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +148 -5
- package/tx/vs/vs-database.js +26 -0
- package/tx/vs/vs-package.js +42 -10
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +34 -7
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/metadata.js +6 -1
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/utilities/submit-test-report.js +1 -1
- package/xig/xig.js +120 -12
- package/gr.sh +0 -3
- package/registry/readme.md +0 -124
package/tx/library.js
CHANGED
|
@@ -5,6 +5,7 @@ const yaml = require('yaml'); // npm install yaml
|
|
|
5
5
|
const { PackageManager, PackageContentLoader } = require('../library/package-manager');
|
|
6
6
|
const { FolderContentLoader, FileContentLoader } = require('../library/folder-content-loader');
|
|
7
7
|
const { CodeSystem } = require("./library/codesystem");
|
|
8
|
+
const { assertNoActiveContent } = require('../library/narrative-check');
|
|
8
9
|
const {CountryCodeFactoryProvider} = require("./cs/cs-country");
|
|
9
10
|
const {Iso4217FactoryProvider} = require("./cs/cs-currency");
|
|
10
11
|
const {AreaCodeFactoryProvider} = require("./cs/cs-areacode");
|
|
@@ -92,6 +93,12 @@ class Library {
|
|
|
92
93
|
totalDownloaded = 0;
|
|
93
94
|
vsacCfg = undefined;
|
|
94
95
|
|
|
96
|
+
/**
|
|
97
|
+
* {SyndicationStore|null} where the content this library loads is recorded for the
|
|
98
|
+
* syndication feed, when the server has one (see tx/syndication)
|
|
99
|
+
*/
|
|
100
|
+
syndication = null;
|
|
101
|
+
|
|
95
102
|
registerProvider(source, factory, isDefault = false) {
|
|
96
103
|
this.#logSystem(factory.system(), factory.version(), source);
|
|
97
104
|
if (isDefault || !this.codeSystemFactories.has(factory.system())) {
|
|
@@ -606,6 +613,7 @@ class Library {
|
|
|
606
613
|
const loinc = new LoincServicesFactory(this.i18n, loincFN);
|
|
607
614
|
await loinc.load();
|
|
608
615
|
this.registerProvider(loincFN, loinc, isDefault);
|
|
616
|
+
this.#syndicateBinary('loinc', loincFN, loinc);
|
|
609
617
|
}
|
|
610
618
|
|
|
611
619
|
async loadRxnorm(details, isDefault, mode) {
|
|
@@ -616,6 +624,7 @@ class Library {
|
|
|
616
624
|
const rxn = new RxNormServicesFactory(this.i18n, rxNormFN);
|
|
617
625
|
await rxn.load();
|
|
618
626
|
this.registerProvider(rxNormFN, rxn, isDefault);
|
|
627
|
+
this.#syndicateBinary('rxnorm', rxNormFN, rxn);
|
|
619
628
|
}
|
|
620
629
|
|
|
621
630
|
async loadNdc(details, isDefault, mode) {
|
|
@@ -626,6 +635,7 @@ class Library {
|
|
|
626
635
|
const ndc = new NdcServicesFactory(this.i18n, ndcFN);
|
|
627
636
|
await ndc.load();
|
|
628
637
|
this.registerProvider(ndcFN, ndc, isDefault);
|
|
638
|
+
this.#syndicateBinary('ndc', ndcFN, ndc);
|
|
629
639
|
}
|
|
630
640
|
|
|
631
641
|
async loadUnii(details, isDefault, mode) {
|
|
@@ -636,6 +646,7 @@ class Library {
|
|
|
636
646
|
const unii = new UniiServicesFactory(this.i18n, uniFN);
|
|
637
647
|
await unii.load();
|
|
638
648
|
this.registerProvider(uniFN, unii, isDefault);
|
|
649
|
+
this.#syndicateBinary('unii', uniFN, unii);
|
|
639
650
|
}
|
|
640
651
|
|
|
641
652
|
async loadSnomed(details, isDefault, mode) {
|
|
@@ -646,6 +657,7 @@ class Library {
|
|
|
646
657
|
const sct = new SnomedServicesFactory(this.i18n, sctFN);
|
|
647
658
|
await sct.load();
|
|
648
659
|
this.registerProvider(sctFN, sct, isDefault);
|
|
660
|
+
this.#syndicateBinary('snomed', sctFN, sct);
|
|
649
661
|
}
|
|
650
662
|
|
|
651
663
|
async loadCpt(details, isDefault, mode) {
|
|
@@ -656,6 +668,8 @@ class Library {
|
|
|
656
668
|
const cpt = new CPTServicesFactory(this.i18n, cptFN);
|
|
657
669
|
await cpt.load();
|
|
658
670
|
this.registerProvider(cptFN, cpt, isDefault);
|
|
671
|
+
// the store syndicates the public fragment only, never the full (licensed) database
|
|
672
|
+
this.#syndicateBinary('cpt', cptFN, cpt);
|
|
659
673
|
}
|
|
660
674
|
|
|
661
675
|
async loadOmop(details, isDefault, mode) {
|
|
@@ -666,6 +680,7 @@ class Library {
|
|
|
666
680
|
const omop = new OMOPServicesFactory(this.i18n, omopFN);
|
|
667
681
|
await omop.load();
|
|
668
682
|
this.registerProvider(omopFN, omop, isDefault);
|
|
683
|
+
this.#syndicateBinary('omop', omopFN, omop);
|
|
669
684
|
}
|
|
670
685
|
|
|
671
686
|
/**
|
|
@@ -678,13 +693,69 @@ class Library {
|
|
|
678
693
|
if (mode === "fetch" || mode === "npm") {
|
|
679
694
|
return;
|
|
680
695
|
}
|
|
696
|
+
// one file, so one syndication entry - identified by the MMS, if the file has it
|
|
697
|
+
let main = null;
|
|
681
698
|
for (const sys of await ICD11ServicesFactory.listSystems(icdFN)) {
|
|
682
699
|
const factory = new ICD11ServicesFactory(this.i18n, icdFN, sys.code);
|
|
683
700
|
await factory.load();
|
|
684
701
|
this.registerProvider(icdFN, factory, isDefault);
|
|
702
|
+
if (!main || sys.code === 'mms') {
|
|
703
|
+
main = factory;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
if (main) {
|
|
707
|
+
this.#syndicateBinary('icd11', icdFN, main);
|
|
685
708
|
}
|
|
686
709
|
}
|
|
687
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Records a binary source file (SNOMED cache, LOINC database etc) in the syndication store,
|
|
713
|
+
* if there is one. The store works in the background, and decides itself whether the file
|
|
714
|
+
* is one it syndicates; nothing it does can stop the library loading.
|
|
715
|
+
* @private
|
|
716
|
+
*/
|
|
717
|
+
#syndicateBinary(sourceType, filePath, factory) {
|
|
718
|
+
if (!this.syndication) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
let name = null;
|
|
723
|
+
try {
|
|
724
|
+
name = factory.name();
|
|
725
|
+
} catch (e) {
|
|
726
|
+
// not every factory has a name
|
|
727
|
+
}
|
|
728
|
+
void this.syndication.inductBinary({
|
|
729
|
+
sourceType, filePath, baseUrl: this.baseUrl, system: factory.system(), version: factory.version(), name
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.log.warn(`Syndication: unable to record ${filePath}: ${error.message}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Records a package in the syndication store, if there is one.
|
|
738
|
+
* @param {PackageContentLoader} contentLoader - the loaded package
|
|
739
|
+
* @param {Object} source - {requestedVersion} for an npm: source, {url} for a url: source,
|
|
740
|
+
* {fhirVersion} (major.minor) for an endpoint's core package
|
|
741
|
+
*/
|
|
742
|
+
syndicatePackage(contentLoader, source = {}) {
|
|
743
|
+
if (!this.syndication || !contentLoader || !contentLoader.package) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
const pkg = contentLoader.package;
|
|
747
|
+
void this.syndication.inductPackage({
|
|
748
|
+
id: contentLoader.id(),
|
|
749
|
+
version: contentLoader.version(),
|
|
750
|
+
canonical: pkg.canonical,
|
|
751
|
+
title: pkg.title,
|
|
752
|
+
description: pkg.description,
|
|
753
|
+
url: source.url,
|
|
754
|
+
fhirVersion: source.fhirVersion,
|
|
755
|
+
requestedVersion: source.requestedVersion
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
688
759
|
/**
|
|
689
760
|
* Returns true if the given url/version should be excluded from npm/url package loading.
|
|
690
761
|
* Matches against the ignored list using either plain url or url#version.
|
|
@@ -706,12 +777,12 @@ class Library {
|
|
|
706
777
|
version = parts[1];
|
|
707
778
|
}
|
|
708
779
|
const packagePath = await packageManager.fetch(packageId, version);
|
|
709
|
-
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
780
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly, { requestedVersion: version });
|
|
710
781
|
}
|
|
711
782
|
|
|
712
783
|
async loadUrl(packageManager, url, isDefault, mode, csOnly) {
|
|
713
784
|
const packagePath = await packageManager.fetchUrl(url);
|
|
714
|
-
await this.#loadPackageFromPath(packagePath, mode, csOnly);
|
|
785
|
+
await this.#loadPackageFromPath(packagePath, mode, csOnly, { url });
|
|
715
786
|
}
|
|
716
787
|
|
|
717
788
|
/**
|
|
@@ -722,13 +793,14 @@ class Library {
|
|
|
722
793
|
* array-backed codeSystems list, breaking url sources).
|
|
723
794
|
* @private
|
|
724
795
|
*/
|
|
725
|
-
async #loadPackageFromPath(packagePath, mode, csOnly) {
|
|
796
|
+
async #loadPackageFromPath(packagePath, mode, csOnly, source) {
|
|
726
797
|
if (mode === "fetch" || mode === "cs") {
|
|
727
798
|
return;
|
|
728
799
|
}
|
|
729
800
|
const fullPackagePath = path.join(this.cacheFolder, packagePath);
|
|
730
801
|
const contentLoader = new PackageContentLoader(fullPackagePath);
|
|
731
802
|
await contentLoader.initialize();
|
|
803
|
+
this.syndicatePackage(contentLoader, source);
|
|
732
804
|
|
|
733
805
|
this.packageSources.push(contentLoader.id()+"#"+contentLoader.version());
|
|
734
806
|
|
|
@@ -738,7 +810,10 @@ class Library {
|
|
|
738
810
|
for (const resource of resources) {
|
|
739
811
|
let cs;
|
|
740
812
|
try {
|
|
741
|
-
|
|
813
|
+
const json = await contentLoader.loadFile(resource, contentLoader.fhirVersion());
|
|
814
|
+
// narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
|
|
815
|
+
assertNoActiveContent(json);
|
|
816
|
+
cs = new CodeSystem(json);
|
|
742
817
|
} catch (e) {
|
|
743
818
|
// one unacceptable resource (e.g. contained resources the server doesn't support)
|
|
744
819
|
// must not stop the rest of the package loading
|
|
@@ -848,6 +923,27 @@ class Library {
|
|
|
848
923
|
*/
|
|
849
924
|
async #loadLocalSource(contentLoader) {
|
|
850
925
|
await contentLoader.initialize();
|
|
926
|
+
if (this.syndication) {
|
|
927
|
+
// each resource is an entry in its own right (those without a version are skipped)
|
|
928
|
+
for (const entry of contentLoader.index.files) {
|
|
929
|
+
if (this.#isIgnored(entry.url, entry.version)) {
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
let title = null;
|
|
933
|
+
let description = null;
|
|
934
|
+
try {
|
|
935
|
+
const json = JSON.parse(await fs.readFile(path.join(contentLoader.sourceFolder, entry.filename), 'utf8'));
|
|
936
|
+
title = json.title || json.name || null;
|
|
937
|
+
description = typeof json.description === 'string' ? json.description : null;
|
|
938
|
+
} catch (e) {
|
|
939
|
+
// initialize() already read it; if it can't now, the store will say so
|
|
940
|
+
}
|
|
941
|
+
void this.syndication.inductResource({
|
|
942
|
+
filePath: path.join(contentLoader.sourceFolder, entry.filename),
|
|
943
|
+
resourceType: entry.resourceType, url: entry.url, version: entry.version, title, description
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
851
947
|
|
|
852
948
|
this.packageSources.push(contentLoader.id() + "#" + contentLoader.version());
|
|
853
949
|
|
|
@@ -857,7 +953,10 @@ class Library {
|
|
|
857
953
|
for (const entry of csEntries) {
|
|
858
954
|
let cs;
|
|
859
955
|
try {
|
|
860
|
-
|
|
956
|
+
const json = await contentLoader.loadFile(entry, contentLoader.fhirVersion());
|
|
957
|
+
// narrative is shown as-is in a page, so it must not carry script (see narrative-check.js)
|
|
958
|
+
assertNoActiveContent(json);
|
|
959
|
+
cs = new CodeSystem(json);
|
|
861
960
|
} catch (e) {
|
|
862
961
|
// one unacceptable resource must not stop the rest of the package loading
|
|
863
962
|
this.log.warn(`Package ${contentLoader.pid()}: skipping CodeSystem ${entry.id || entry.filename || ''}: ${e.message}`);
|
|
@@ -1043,7 +1142,9 @@ class Library {
|
|
|
1043
1142
|
|
|
1044
1143
|
// Load FHIR packages - these will be added to valueSetProviders first
|
|
1045
1144
|
for (const packageId of fhirPackages) {
|
|
1046
|
-
await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
|
|
1145
|
+
const contentLoader = await provider.loadNpm(this.packageManager, this.cacheFolder, packageId, false, "npm", false);
|
|
1146
|
+
// the core packages are the only version specific content in the syndication store
|
|
1147
|
+
this.syndicatePackage(contentLoader, { fhirVersion: VersionUtilities.getMajMin(String(fhirVersion)) });
|
|
1047
1148
|
}
|
|
1048
1149
|
|
|
1049
1150
|
|
package/tx/ocl/README.md
CHANGED
|
@@ -39,14 +39,70 @@ In FHIRsmith terms, these providers are loaded by `tx/library.js` when a source
|
|
|
39
39
|
## Runtime flow
|
|
40
40
|
### Metadata discovery
|
|
41
41
|
CodeSystems (`cs-ocl.js`):
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- fallback
|
|
42
|
+
- prefer one paginated crawl of the global `/sources/` listing (filtered to
|
|
43
|
+
organization-owned entries)
|
|
44
|
+
- fallback: discover orgs via `/orgs/`, then `/orgs/{org}/sources/` per org
|
|
45
45
|
|
|
46
46
|
ValueSets (`vs-ocl.js`):
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
- fallback
|
|
47
|
+
- prefer one paginated crawl of the global `/collections/` listing (filtered to
|
|
48
|
+
organization-owned entries)
|
|
49
|
+
- fallback: discover orgs via `/orgs/`, then `/orgs/{org}/collections/` per org
|
|
50
|
+
|
|
51
|
+
### Visibility policy (org-only + public-only)
|
|
52
|
+
An artifact is expected to live in an **organization** to be visible through the
|
|
53
|
+
terminology service. User-owned repos (`/users/{user}/...`) are experimental by
|
|
54
|
+
convention and are excluded from both discovery and `$resolveReference` results
|
|
55
|
+
(a canonical resolving to a user-owned repo is treated as unresolved).
|
|
56
|
+
|
|
57
|
+
In addition, a repo is only served when it is **publicly viewable**
|
|
58
|
+
(`public_access` is `View`/`Edit`; `None`/unknown is treated as not servable,
|
|
59
|
+
fail-closed). Because the configured token can see every repo its OCL account has
|
|
60
|
+
access to — and both `$resolveReference` and discovery answer anonymous terminology
|
|
61
|
+
requests — without this gate a *private* repo in the token's org could be surfaced
|
|
62
|
+
publicly. This gate is applied in **both** paths:
|
|
63
|
+
|
|
64
|
+
- **`$resolveReference`**: a resolved repo that is not public is treated as unresolved.
|
|
65
|
+
- **Discovery**: the global (`/sources/`, `/collections/`) and per-org listing crawls
|
|
66
|
+
drop non-public (and non-org-owned) repos before they are registered.
|
|
67
|
+
|
|
68
|
+
`public_access` is not returned by the listing endpoints or by `$resolveReference`
|
|
69
|
+
today, so a shared `PublicAccessChecker` fetches each repo once to read it, caching
|
|
70
|
+
the verdict by `repoUrl` (bounded LRU) and running batch checks with a concurrency
|
|
71
|
+
limit. A check that *errors* is not cached, so a transient blip is retried rather
|
|
72
|
+
than pinning a repo as private. The resolver also rejects any `repoUrl` that is not
|
|
73
|
+
a safe same-host relative path, so an unexpected response can never redirect an
|
|
74
|
+
authenticated request off-host.
|
|
75
|
+
|
|
76
|
+
> **Operational guidance (defense-in-depth):** configure the `token=` account with
|
|
77
|
+
> the **least privilege** needed — ideally a service account that can see only
|
|
78
|
+
> public repos. The `public_access` gate enforces public-only serving in code
|
|
79
|
+
> regardless, but a least-privilege token means a misconfiguration can never
|
|
80
|
+
> expose private content.
|
|
81
|
+
|
|
82
|
+
### Canonical resolution via `$resolveReference`
|
|
83
|
+
With a `token=` configured on the `ocl:` source line, the providers resolve
|
|
84
|
+
"which repo holds this canonical URL?" through OCL's
|
|
85
|
+
[`$resolveReference`](https://docs.openconceptlab.org/en/latest/oclapi/apireference/resolveReference.html)
|
|
86
|
+
(global namespace) instead of iterating listings and matching `canonical_url`
|
|
87
|
+
client-side. Used for ConceptMap source-system search, ValueSet lookup by
|
|
88
|
+
canonical, and batched resolution of a collection's compose source canonicals.
|
|
89
|
+
|
|
90
|
+
Without a token nothing changes: `$resolveReference` is authenticated on every
|
|
91
|
+
instance probed (while the listing endpoints are public), so the resolver is
|
|
92
|
+
constructed disabled and every caller keeps its previous search path. A `404`
|
|
93
|
+
(endpoint not implemented) disables it for the process; a transient `401`/`403`
|
|
94
|
+
backs it off for a cooldown and then retries. The resolution cache is a bounded
|
|
95
|
+
LRU and over-long references are never sent to OCL.
|
|
96
|
+
|
|
97
|
+
### CodeSystem default versions (release vs HEAD)
|
|
98
|
+
OCL's own resolution treats a source's **latest release** as its default version
|
|
99
|
+
(HEAD only when nothing is released), but discovery listings only ever report
|
|
100
|
+
HEAD. With a token, discovery batch-resolves every canonical through
|
|
101
|
+
`$resolveReference` and registers **both** versions: the release becomes what a
|
|
102
|
+
versionless request gets (matching OCL), and HEAD stays reachable via an
|
|
103
|
+
explicit `version=HEAD`. Only canonicals that are new or changed are re-resolved
|
|
104
|
+
on refresh, so a steady-state cycle costs no extra requests. Without a token,
|
|
105
|
+
discovery stays HEAD-only as before.
|
|
50
106
|
|
|
51
107
|
ConceptMaps (`cm-ocl.js`):
|
|
52
108
|
- fetch by id via `/mappings/{id}/`
|
package/tx/ocl/cm-ocl.cjs
CHANGED
|
@@ -4,9 +4,17 @@ const { PAGE_SIZE } = require('./shared/constants');
|
|
|
4
4
|
const { createOclHttpClient } = require('./http/client');
|
|
5
5
|
const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
|
|
6
6
|
const { stripIdPrefix } = require('../library/resource-ids');
|
|
7
|
+
const { OclReferenceResolver, isOclRepoPath } = require('./resolve/reference-resolver');
|
|
8
|
+
const Logger = require('../../library/logger');
|
|
9
|
+
const oclCmLog = Logger.getInstance().child({ module: 'ocl-cm' });
|
|
7
10
|
|
|
8
11
|
const DEFAULT_MAX_SEARCH_PAGES = 10;
|
|
9
12
|
|
|
13
|
+
// OCL map types that relate concepts without mapping one to the other, so they must
|
|
14
|
+
// not surface as $translate matches: CIEL uses Q-AND-A (question -> answer) and
|
|
15
|
+
// CONCEPT-SET (set -> member). Unknown types still map to related-to.
|
|
16
|
+
const NON_TRANSLATION_MAP_TYPES = new Set(['Q-AND-A', 'CONCEPT-SET']);
|
|
17
|
+
|
|
10
18
|
class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
11
19
|
constructor(config = {}) {
|
|
12
20
|
super();
|
|
@@ -23,6 +31,15 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
23
31
|
this._sourceCandidatesCache = new Map();
|
|
24
32
|
this._sourceUrlsByCanonical = new Map();
|
|
25
33
|
this._canonicalBySourceUrl = new Map();
|
|
34
|
+
|
|
35
|
+
// Asks OCL which repo holds a canonical instead of guessing via text search.
|
|
36
|
+
// Disabled unless a token is configured, in which case #candidateSourceUrls
|
|
37
|
+
// keeps using its existing search.
|
|
38
|
+
this.referenceResolver = new OclReferenceResolver({
|
|
39
|
+
httpClient: this.httpClient,
|
|
40
|
+
token: options.token || null,
|
|
41
|
+
logger: oclCmLog
|
|
42
|
+
});
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
prefixIds() {
|
|
@@ -114,8 +131,12 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
114
131
|
async searchConceptMaps(searchParams, _elements) {
|
|
115
132
|
this._validateSearchParams(searchParams);
|
|
116
133
|
|
|
134
|
+
// Keep the canonical exactly as supplied. Lower-casing it was harmless while
|
|
135
|
+
// every consumer compared through #norm() (which lower-cases anyway), but
|
|
136
|
+
// $resolveReference matches the canonical exactly, and OCL has no
|
|
137
|
+
// .../fhir/codesystem/x -- only .../fhir/CodeSystem/X.
|
|
117
138
|
const params = Object.fromEntries(
|
|
118
|
-
searchParams.map(({ name, value }) => [name, String(value)
|
|
139
|
+
searchParams.map(({ name, value }) => [name, String(value)])
|
|
119
140
|
);
|
|
120
141
|
const sourceSystem = params['source-system'] || params.source || null;
|
|
121
142
|
const targetSystem = params['target-system'] || params.target || null;
|
|
@@ -138,7 +159,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
138
159
|
async #collectMappingsForSearch(sourceSystem, targetSystem) {
|
|
139
160
|
const systemUrl = sourceSystem || targetSystem;
|
|
140
161
|
const candidates = await this.#candidateSourceUrls(systemUrl);
|
|
141
|
-
const sourcePaths = candidates.filter(
|
|
162
|
+
const sourcePaths = candidates.filter(isOclRepoPath);
|
|
142
163
|
|
|
143
164
|
if (sourcePaths.length === 0) {
|
|
144
165
|
return [];
|
|
@@ -147,30 +168,20 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
147
168
|
const allMappings = [];
|
|
148
169
|
for (const sourcePath of sourcePaths) {
|
|
149
170
|
const normalizedPath = this.#normalizeSourcePath(sourcePath);
|
|
150
|
-
let concepts;
|
|
151
171
|
try {
|
|
152
|
-
concepts
|
|
153
|
-
|
|
172
|
+
// Ask the source for its mappings directly. Walking concepts and asking
|
|
173
|
+
// each one costs a request per concept and, worse, only ever sees the
|
|
174
|
+
// first maxSearchPages of them -- for LOINC that is 1000 of 184683
|
|
175
|
+
// concepts (0.5%), so any mapping past that point was silently invisible.
|
|
176
|
+
// Verified live: both paths return the identical mapping set.
|
|
177
|
+
const mappings = await this.#fetchAllPages(
|
|
178
|
+
`${normalizedPath}mappings/`, { limit: PAGE_SIZE }, this.maxSearchPages
|
|
154
179
|
);
|
|
180
|
+
allMappings.push(...mappings);
|
|
155
181
|
} catch (_err) {
|
|
182
|
+
// source exposes no mappings endpoint or is inaccessible — skip
|
|
156
183
|
continue;
|
|
157
184
|
}
|
|
158
|
-
|
|
159
|
-
for (const concept of concepts) {
|
|
160
|
-
const code = concept.id || concept.mnemonic;
|
|
161
|
-
if (!code) {
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
const mappings = await this.#fetchAllPages(
|
|
166
|
-
`${normalizedPath}concepts/${encodeURIComponent(code)}/mappings/`,
|
|
167
|
-
{ limit: PAGE_SIZE }, 2
|
|
168
|
-
);
|
|
169
|
-
allMappings.push(...mappings);
|
|
170
|
-
} catch (_err) {
|
|
171
|
-
// concept has no mappings or endpoint inaccessible — skip
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
const sourceUrlsToResolve = new Set();
|
|
@@ -194,7 +205,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
194
205
|
const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
|
|
195
206
|
const targetCode = mapping.to_concept_code || mapping.toConceptCode;
|
|
196
207
|
|
|
197
|
-
if (!fromSource || !toSource || !sourceCode || !targetCode) {
|
|
208
|
+
if (!fromSource || !toSource || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
|
|
198
209
|
continue;
|
|
199
210
|
}
|
|
200
211
|
|
|
@@ -252,8 +263,8 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
252
263
|
name: id,
|
|
253
264
|
title: `${sourceId} to ${targetId}`,
|
|
254
265
|
status: 'active',
|
|
255
|
-
|
|
256
|
-
|
|
266
|
+
// No sourceScope/targetScope: a scope is a ValueSet, and $translate resolves it and
|
|
267
|
+
// checks the code against it. The systems are carried by the group instead.
|
|
257
268
|
group: [{
|
|
258
269
|
source: group.sourceCanonical,
|
|
259
270
|
target: group.targetCanonical,
|
|
@@ -290,7 +301,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
290
301
|
const targetCandidates = await this.#candidateSourceUrls(targetSystem);
|
|
291
302
|
|
|
292
303
|
const mappings = [];
|
|
293
|
-
const sourcePaths = sourceCandidates.filter(
|
|
304
|
+
const sourcePaths = sourceCandidates.filter(isOclRepoPath);
|
|
294
305
|
|
|
295
306
|
if (sourceCode && sourcePaths.length > 0) {
|
|
296
307
|
for (const sourcePath of sourcePaths) {
|
|
@@ -410,7 +421,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
410
421
|
const sourceCode = mapping.from_concept_code || mapping.fromConceptCode;
|
|
411
422
|
const targetCode = mapping.to_concept_code || mapping.toConceptCode;
|
|
412
423
|
|
|
413
|
-
if (!source || !target || !sourceCode || !targetCode) {
|
|
424
|
+
if (!source || !target || !sourceCode || !targetCode || !this.#isTranslation(mapping)) {
|
|
414
425
|
return null;
|
|
415
426
|
}
|
|
416
427
|
|
|
@@ -430,8 +441,10 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
430
441
|
name: `mapping-${id}`,
|
|
431
442
|
title: mapping.name || `Mapping ${id}`,
|
|
432
443
|
status: 'active',
|
|
433
|
-
|
|
434
|
-
|
|
444
|
+
// No sourceScope/targetScope: a scope is a ValueSet canonical, which $translate resolves
|
|
445
|
+
// and checks the code against. OCL only offers repo paths here (from_source_url or
|
|
446
|
+
// from_collection_url), which resolve to no ValueSet and made every translation fail
|
|
447
|
+
// with not-found. The systems are carried by the group instead.
|
|
435
448
|
group: [
|
|
436
449
|
{
|
|
437
450
|
source: sourceCanonical,
|
|
@@ -461,21 +474,32 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
461
474
|
return new ConceptMap(json, 'R5');
|
|
462
475
|
}
|
|
463
476
|
|
|
477
|
+
// OCL map_type expresses "from concept <relation> to concept", which lines up
|
|
478
|
+
// with the R5 ConceptMapRelationship codes (source <relation> target).
|
|
464
479
|
#toRelationship(mapType) {
|
|
465
|
-
switch ((mapType
|
|
480
|
+
switch (this.#normalizeMapType(mapType)) {
|
|
466
481
|
case 'SAME-AS':
|
|
467
482
|
return 'equivalent';
|
|
468
483
|
case 'NARROWER-THAN':
|
|
469
|
-
return 'narrower-than';
|
|
484
|
+
return 'source-is-narrower-than-target';
|
|
470
485
|
case 'BROADER-THAN':
|
|
471
|
-
return 'broader-than';
|
|
486
|
+
return 'source-is-broader-than-target';
|
|
472
487
|
case 'NOT-EQUIVALENT':
|
|
488
|
+
case 'NOT-SAME-AS':
|
|
473
489
|
return 'not-related-to';
|
|
474
490
|
default:
|
|
475
491
|
return 'related-to';
|
|
476
492
|
}
|
|
477
493
|
}
|
|
478
494
|
|
|
495
|
+
#isTranslation(mapping) {
|
|
496
|
+
return !NON_TRANSLATION_MAP_TYPES.has(this.#normalizeMapType(mapping.map_type || mapping.mapType));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#normalizeMapType(mapType) {
|
|
500
|
+
return String(mapType || '').trim().toUpperCase().replace(/[\s_]+/g, '-');
|
|
501
|
+
}
|
|
502
|
+
|
|
479
503
|
async #searchMappings(params = {}, maxPages = this.maxSearchPages) {
|
|
480
504
|
const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/mappings/` : '/mappings/';
|
|
481
505
|
return await this.#fetchAllPages(endpoint, params, maxPages);
|
|
@@ -523,9 +547,16 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
523
547
|
}
|
|
524
548
|
}
|
|
525
549
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
550
|
+
// Ask OCL which repo holds this canonical. An authoritative answer makes the
|
|
551
|
+
// heuristic text search below unnecessary; without one we fall back to it.
|
|
552
|
+
const resolved = await this.#resolveViaReferenceResolver(systemUrl);
|
|
553
|
+
if (resolved) {
|
|
554
|
+
result.add(resolved);
|
|
555
|
+
} else {
|
|
556
|
+
const discovered = await this.#resolveSourceCandidatesFromOcl(systemUrl);
|
|
557
|
+
for (const item of discovered) {
|
|
558
|
+
result.add(item);
|
|
559
|
+
}
|
|
529
560
|
}
|
|
530
561
|
|
|
531
562
|
const out = Array.from(result);
|
|
@@ -533,6 +564,56 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
533
564
|
return out;
|
|
534
565
|
}
|
|
535
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Resolve a canonical to its OCL repo via $resolveReference.
|
|
569
|
+
* Returns null when the resolver is disabled (no token), the endpoint is
|
|
570
|
+
* unavailable, or OCL cannot resolve the canonical -- in every case the caller
|
|
571
|
+
* falls back to the existing search.
|
|
572
|
+
*/
|
|
573
|
+
async #resolveViaReferenceResolver(systemUrl) {
|
|
574
|
+
// Say why once, rather than per lookup: for a tokenless deployment this is the
|
|
575
|
+
// expected steady state, not an error.
|
|
576
|
+
if (!this.referenceResolver.isEnabled()) {
|
|
577
|
+
if (!this._resolverDisabledLogged) {
|
|
578
|
+
this._resolverDisabledLogged = true;
|
|
579
|
+
oclCmLog.info(
|
|
580
|
+
`$resolveReference not in use (${this.referenceResolver.disabledReason}); using source search`
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
let resolved;
|
|
587
|
+
try {
|
|
588
|
+
resolved = await this.referenceResolver.resolve(systemUrl);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
oclCmLog.warn(`$resolveReference lookup failed for ${systemUrl}: ${error.message}`);
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!resolved?.resolved || !resolved.repoUrl) {
|
|
595
|
+
oclCmLog.info(`$resolveReference did not resolve ${systemUrl}; falling back to source search`);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Logged on success too: resolver and search return the same thing, so without
|
|
600
|
+
// this there is no way -- from outside or from the logs -- to tell which path ran.
|
|
601
|
+
oclCmLog.info(`$resolveReference resolved ${systemUrl} -> ${resolved.repoUrl}`);
|
|
602
|
+
|
|
603
|
+
// Keep the canonical<->repo caches coherent with the search path's bookkeeping.
|
|
604
|
+
// Index under what was asked, but record OCL's own canonical_url as the repo's
|
|
605
|
+
// canonical: echoing the request back would store whatever spelling the caller
|
|
606
|
+
// used rather than the repo's actual identity.
|
|
607
|
+
const canonicalKey = this.#norm(systemUrl);
|
|
608
|
+
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
609
|
+
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
610
|
+
}
|
|
611
|
+
this._sourceUrlsByCanonical.get(canonicalKey).add(resolved.repoUrl);
|
|
612
|
+
this._canonicalBySourceUrl.set(this.#norm(resolved.repoUrl), resolved.canonical || systemUrl);
|
|
613
|
+
|
|
614
|
+
return resolved.repoUrl;
|
|
615
|
+
}
|
|
616
|
+
|
|
536
617
|
async #resolveSourceCandidatesFromOcl(systemUrl) {
|
|
537
618
|
const endpoint = this.org ? `/orgs/${encodeURIComponent(this.org)}/sources/` : '/sources/';
|
|
538
619
|
const query = this.#queryTokenFromSystem(systemUrl);
|
|
@@ -573,17 +654,52 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
573
654
|
}
|
|
574
655
|
|
|
575
656
|
async #ensureCanonicalForSourceUrls(sourceUrls) {
|
|
657
|
+
// Deduplicate the repo paths that still need a canonical.
|
|
658
|
+
const pending = [];
|
|
659
|
+
const seen = new Set();
|
|
576
660
|
for (const sourceUrl of sourceUrls || []) {
|
|
577
661
|
const sourceKey = this.#norm(sourceUrl);
|
|
578
|
-
if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey)) {
|
|
662
|
+
if (!sourceKey || this._canonicalBySourceUrl.has(sourceKey) || seen.has(sourceKey)) {
|
|
579
663
|
continue;
|
|
580
664
|
}
|
|
581
|
-
|
|
582
665
|
const sourcePath = String(sourceUrl || '').trim();
|
|
583
|
-
if (!sourcePath
|
|
666
|
+
if (!isOclRepoPath(sourcePath)) {
|
|
584
667
|
continue;
|
|
585
668
|
}
|
|
669
|
+
seen.add(sourceKey);
|
|
670
|
+
pending.push(sourcePath);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (pending.length === 0) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// One $resolveReference batch instead of one GET per source: the result
|
|
678
|
+
// carries the repo's canonical_url. Anything the batch cannot resolve falls
|
|
679
|
+
// through to the per-source GETs below, so behaviour without a token (or on
|
|
680
|
+
// failure) is unchanged.
|
|
681
|
+
if (this.referenceResolver.isEnabled()) {
|
|
682
|
+
try {
|
|
683
|
+
const results = await this.referenceResolver.resolveReferences(pending);
|
|
684
|
+
if (Array.isArray(results)) {
|
|
685
|
+
for (let i = 0; i < pending.length; i++) {
|
|
686
|
+
const r = results[i];
|
|
687
|
+
if (!r?.resolved || !r.canonical) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const resolvedSourceUrl = r.repoUrl || pending[i];
|
|
691
|
+
this.#recordCanonicalForSource(pending[i], resolvedSourceUrl, r.canonical);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
} catch (error) {
|
|
695
|
+
oclCmLog.warn(`$resolveReference batch for source canonicals failed: ${error.message}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
586
698
|
|
|
699
|
+
for (const sourcePath of pending) {
|
|
700
|
+
if (this._canonicalBySourceUrl.has(this.#norm(sourcePath))) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
587
703
|
try {
|
|
588
704
|
const response = await this.httpClient.get(sourcePath);
|
|
589
705
|
const source = response.data || {};
|
|
@@ -592,13 +708,7 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
592
708
|
if (!canonical) {
|
|
593
709
|
continue;
|
|
594
710
|
}
|
|
595
|
-
|
|
596
|
-
const canonicalKey = this.#norm(canonical);
|
|
597
|
-
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
598
|
-
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
599
|
-
}
|
|
600
|
-
this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
|
|
601
|
-
this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
|
|
711
|
+
this.#recordCanonicalForSource(sourcePath, resolvedSourceUrl, canonical);
|
|
602
712
|
} catch (e) {
|
|
603
713
|
// Ignore source lookup failures and continue resolving remaining sources.
|
|
604
714
|
continue;
|
|
@@ -606,6 +716,18 @@ class OCLConceptMapProvider extends AbstractConceptMapProvider {
|
|
|
606
716
|
}
|
|
607
717
|
}
|
|
608
718
|
|
|
719
|
+
#recordCanonicalForSource(requestedPath, resolvedSourceUrl, canonical) {
|
|
720
|
+
const canonicalKey = this.#norm(canonical);
|
|
721
|
+
if (!this._sourceUrlsByCanonical.has(canonicalKey)) {
|
|
722
|
+
this._sourceUrlsByCanonical.set(canonicalKey, new Set());
|
|
723
|
+
}
|
|
724
|
+
this._sourceUrlsByCanonical.get(canonicalKey).add(resolvedSourceUrl);
|
|
725
|
+
this._canonicalBySourceUrl.set(this.#norm(resolvedSourceUrl), canonical);
|
|
726
|
+
// Also index under the exact path we were asked about, so the "already
|
|
727
|
+
// resolved" checks hit even when OCL's repo url differs in shape.
|
|
728
|
+
this._canonicalBySourceUrl.set(this.#norm(requestedPath), canonical);
|
|
729
|
+
}
|
|
730
|
+
|
|
609
731
|
#queryTokenFromSystem(systemUrl) {
|
|
610
732
|
const raw = String(systemUrl || '').trim().replace(/\/+$/, '');
|
|
611
733
|
if (!raw) {
|