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/ocl/cs-ocl.cjs
CHANGED
|
@@ -7,6 +7,8 @@ const { SearchFilterText } = require('../library/designations');
|
|
|
7
7
|
const { PAGE_SIZE, CONCEPT_PAGE_SIZE, COLD_CACHE_FRESHNESS_MS, OCL_CODESYSTEM_MARKER_EXTENSION } = require('./shared/constants');
|
|
8
8
|
const { createOclHttpClient } = require('./http/client');
|
|
9
9
|
const { fetchAllPages, extractItemsAndNext } = require('./http/pagination');
|
|
10
|
+
const { OclReferenceResolver, isOrgOwned } = require('./resolve/reference-resolver');
|
|
11
|
+
const { PublicAccessChecker } = require('./resolve/public-access');
|
|
10
12
|
const { CACHE_CS_DIR, CACHE_VS_DIR, getCacheFilePath } = require('./cache/cache-paths');
|
|
11
13
|
const { ensureCacheDirectories, getColdCacheAgeMs, formatCacheAgeMinutes } = require('./cache/cache-utils');
|
|
12
14
|
const { computeCodeSystemFingerprint } = require('./fingerprint/fingerprint');
|
|
@@ -43,6 +45,19 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
43
45
|
this.baseUrl = http.baseUrl;
|
|
44
46
|
this.httpClient = http.client;
|
|
45
47
|
|
|
48
|
+
// Resolves each source's DEFAULT version (latest release, else HEAD) so both
|
|
49
|
+
// get registered. Disabled without a token, in which case discovery stays
|
|
50
|
+
// HEAD-only exactly as before.
|
|
51
|
+
this.publicAccess = new PublicAccessChecker({ httpClient: this.httpClient, logger: oclLog });
|
|
52
|
+
this.referenceResolver = new OclReferenceResolver({
|
|
53
|
+
httpClient: this.httpClient,
|
|
54
|
+
token: options.token || null,
|
|
55
|
+
logger: oclLog,
|
|
56
|
+
publicAccess: this.publicAccess
|
|
57
|
+
});
|
|
58
|
+
this._defaultVersionByCanonical = new Map();
|
|
59
|
+
this._headMetaByCanonical = new Map();
|
|
60
|
+
|
|
46
61
|
this._codeSystemsByCanonical = new Map();
|
|
47
62
|
this._idToCodeSystem = new Map();
|
|
48
63
|
this.sourceMetaByUrl = new Map();
|
|
@@ -72,6 +87,7 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
72
87
|
oclLog.info(`Fetched ${sources.length} sources`);
|
|
73
88
|
|
|
74
89
|
const snapshot = this.#buildSourceSnapshot(sources);
|
|
90
|
+
await this.#augmentSnapshotWithDefaultVersions(snapshot);
|
|
75
91
|
this.#applySnapshot(snapshot);
|
|
76
92
|
|
|
77
93
|
oclLog.info(`Loaded ${this._codeSystemsByCanonical.size} code systems`);
|
|
@@ -135,7 +151,94 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
135
151
|
}
|
|
136
152
|
|
|
137
153
|
getSourceMetas() {
|
|
138
|
-
|
|
154
|
+
// Default metas first: registerProvider's unversioned key is first-wins, so
|
|
155
|
+
// the release (when one exists) becomes what a versionless request gets,
|
|
156
|
+
// matching OCL's own resolution. HEAD variants stay reachable via |HEAD.
|
|
157
|
+
return [
|
|
158
|
+
...this.sourceMetaByUrl.values(),
|
|
159
|
+
...this._headMetaByCanonical.values()
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve each source's DEFAULT version via one chunked $resolveReference
|
|
165
|
+
* batch: OCL answers with the latest release, or HEAD when none is released.
|
|
166
|
+
* For sources whose default is a release, the snapshot entry becomes the
|
|
167
|
+
* release (CodeSystem resource, meta, versioned concepts URL) and the HEAD
|
|
168
|
+
* variant is kept as an extra meta, so BOTH versions get factories.
|
|
169
|
+
*
|
|
170
|
+
* Only canonicals that are new or whose listing checksum changed are
|
|
171
|
+
* re-resolved, so a steady-state refresh costs no extra requests. A no-op
|
|
172
|
+
* without a token: discovery stays HEAD-only exactly as before.
|
|
173
|
+
*/
|
|
174
|
+
async #augmentSnapshotWithDefaultVersions(snapshot) {
|
|
175
|
+
if (!this.referenceResolver.isEnabled()) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const previousSnapshot = this._sourceStateByCanonical;
|
|
180
|
+
const toResolve = [];
|
|
181
|
+
for (const [canonicalUrl, entry] of snapshot.entries()) {
|
|
182
|
+
entry.baseChecksum = entry.checksum;
|
|
183
|
+
const previous = previousSnapshot.get(canonicalUrl);
|
|
184
|
+
const previousBase = previous?.baseChecksum ?? previous?.checksum ?? null;
|
|
185
|
+
if (!this._defaultVersionByCanonical.has(canonicalUrl) || previousBase !== entry.baseChecksum) {
|
|
186
|
+
toResolve.push(canonicalUrl);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (toResolve.length > 0) {
|
|
191
|
+
const results = await this.referenceResolver.resolveReferences(toResolve, { bypassCache: true });
|
|
192
|
+
if (Array.isArray(results)) {
|
|
193
|
+
toResolve.forEach((canonicalUrl, i) => {
|
|
194
|
+
const r = results[i];
|
|
195
|
+
if (r?.resolved && r.result?.version) {
|
|
196
|
+
this._defaultVersionByCanonical.set(canonicalUrl, r.result.version);
|
|
197
|
+
} else {
|
|
198
|
+
this._defaultVersionByCanonical.delete(canonicalUrl);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
// Resolver unavailable: keep whatever defaults we knew; new canonicals
|
|
203
|
+
// stay HEAD-only until a later cycle succeeds.
|
|
204
|
+
oclLog.warn('Default-version resolution unavailable; keeping previous defaults');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let releaseCount = 0;
|
|
209
|
+
for (const [canonicalUrl, entry] of snapshot.entries()) {
|
|
210
|
+
const defaultVersion = this._defaultVersionByCanonical.get(canonicalUrl);
|
|
211
|
+
const headVersion = entry.meta?.version || null;
|
|
212
|
+
if (!defaultVersion || defaultVersion === headVersion) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const conceptsUrl = entry.meta?.conceptsUrl;
|
|
216
|
+
if (!conceptsUrl || !conceptsUrl.endsWith('concepts/')) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const baseRepo = conceptsUrl.slice(0, -'concepts/'.length);
|
|
221
|
+
const releaseJson = structuredClone(entry.cs.jsonObj);
|
|
222
|
+
releaseJson.version = defaultVersion;
|
|
223
|
+
const releaseCs = new CodeSystem(releaseJson, 'R5', true);
|
|
224
|
+
const releaseMeta = {
|
|
225
|
+
...entry.meta,
|
|
226
|
+
version: defaultVersion,
|
|
227
|
+
conceptsUrl: `${baseRepo}${encodeURIComponent(defaultVersion)}/concepts/`,
|
|
228
|
+
codeSystem: releaseCs
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
entry.headMeta = entry.meta;
|
|
232
|
+
entry.meta = releaseMeta;
|
|
233
|
+
entry.cs = releaseCs;
|
|
234
|
+
// A release being published or deleted must surface as "changed".
|
|
235
|
+
entry.checksum = `${entry.baseChecksum}|default=${defaultVersion}`;
|
|
236
|
+
releaseCount++;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (releaseCount > 0) {
|
|
240
|
+
oclLog.info(`${releaseCount} code system(s) defaulting to a released version (HEAD kept as |HEAD)`);
|
|
241
|
+
}
|
|
139
242
|
}
|
|
140
243
|
|
|
141
244
|
#scheduleRefresh() {
|
|
@@ -147,14 +250,24 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
147
250
|
try {
|
|
148
251
|
const sources = await this.#fetchSourcesForDiscovery();
|
|
149
252
|
const nextSnapshot = this.#buildSourceSnapshot(sources);
|
|
253
|
+
await this.#augmentSnapshotWithDefaultVersions(nextSnapshot);
|
|
150
254
|
const changes = this.#diffSnapshots(this._sourceStateByCanonical, nextSnapshot);
|
|
151
255
|
this.#applySnapshot(nextSnapshot);
|
|
152
|
-
for
|
|
256
|
+
// Create factories for versions that appeared: the default (release) meta
|
|
257
|
+
// first so it claims the unversioned keys, then the HEAD variant. Covers
|
|
258
|
+
// both newly discovered sources and a release published on a known one.
|
|
259
|
+
for (const cs of [...(changes.added || []), ...(changes.changed || [])]) {
|
|
153
260
|
const entry = nextSnapshot.get(cs.url);
|
|
154
|
-
|
|
155
|
-
|
|
261
|
+
for (const meta of [entry?.meta, entry?.headMeta]) {
|
|
262
|
+
if (!meta) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (OCLSourceCodeSystemFactory.hasExactFactory(meta.canonicalUrl, meta.version || null)) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource(this.httpClient, meta);
|
|
156
269
|
if (factory) {
|
|
157
|
-
oclLog.info(`Factory created for
|
|
270
|
+
oclLog.info(`Factory created for ${meta.canonicalUrl}|${meta.version || ''}`);
|
|
158
271
|
}
|
|
159
272
|
}
|
|
160
273
|
}
|
|
@@ -189,10 +302,37 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
189
302
|
}
|
|
190
303
|
|
|
191
304
|
async #fetchSourcesForDiscovery() {
|
|
305
|
+
// Prefer the global listing: one paginated crawl instead of one listing per
|
|
306
|
+
// org (N+1 requests). Org-only policy: user-owned sources are experimental by
|
|
307
|
+
// convention and not visible to the terminology service, so filter them out.
|
|
308
|
+
try {
|
|
309
|
+
const all = await this.#fetchAllPages('/sources/');
|
|
310
|
+
if (Array.isArray(all) && all.length > 0) {
|
|
311
|
+
const seen = new Set();
|
|
312
|
+
const orgOwned = [];
|
|
313
|
+
for (const source of all) {
|
|
314
|
+
if (!source || typeof source !== 'object' || !isOrgOwned(source)) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const key = this.#sourceIdentity(source);
|
|
318
|
+
if (seen.has(key)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
seen.add(key);
|
|
322
|
+
orgOwned.push(source);
|
|
323
|
+
}
|
|
324
|
+
if (orgOwned.length > 0) {
|
|
325
|
+
// Public-only: a private source the token can see must not be served.
|
|
326
|
+
return await this.publicAccess.filterPublic(orgOwned);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
oclLog.warn(`Global /sources/ listing failed (${error.message}); falling back to per-org discovery`);
|
|
331
|
+
}
|
|
332
|
+
|
|
192
333
|
const organizations = await this.#fetchOrganizationIds();
|
|
193
334
|
if (organizations.length === 0) {
|
|
194
|
-
|
|
195
|
-
return await this.#fetchAllPages('/sources/');
|
|
335
|
+
return [];
|
|
196
336
|
}
|
|
197
337
|
|
|
198
338
|
const allSources = [];
|
|
@@ -202,7 +342,9 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
202
342
|
const endpoint = `/orgs/${encodeURIComponent(orgId)}/sources/`;
|
|
203
343
|
const sources = await this.#fetchAllPages(endpoint);
|
|
204
344
|
for (const source of sources) {
|
|
205
|
-
|
|
345
|
+
// Org-only policy applies to the per-org fallback too, not just the global
|
|
346
|
+
// listing (an org listing can still surface non-org-owned entries).
|
|
347
|
+
if (!source || typeof source !== 'object' || !isOrgOwned(source)) {
|
|
206
348
|
continue;
|
|
207
349
|
}
|
|
208
350
|
const key = this.#sourceIdentity(source);
|
|
@@ -214,7 +356,8 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
214
356
|
}
|
|
215
357
|
}
|
|
216
358
|
|
|
217
|
-
|
|
359
|
+
// Public-only: a private source the token can see must not be served.
|
|
360
|
+
return await this.publicAccess.filterPublic(allSources);
|
|
218
361
|
}
|
|
219
362
|
|
|
220
363
|
async #fetchOrganizationIds() {
|
|
@@ -284,6 +427,7 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
284
427
|
this._codeSystemsByCanonical.clear();
|
|
285
428
|
this._idToCodeSystem.clear();
|
|
286
429
|
this.sourceMetaByUrl.clear();
|
|
430
|
+
this._headMetaByCanonical.clear();
|
|
287
431
|
this._usedIds.clear();
|
|
288
432
|
|
|
289
433
|
for (const [canonicalUrl, entry] of snapshot.entries()) {
|
|
@@ -308,6 +452,9 @@ class OCLCodeSystemProvider extends AbstractCodeSystemProvider {
|
|
|
308
452
|
this._codeSystemsByCanonical.set(canonicalUrl, cs);
|
|
309
453
|
this._idToCodeSystem.set(cs.id, cs);
|
|
310
454
|
this.sourceMetaByUrl.set(canonicalUrl, meta);
|
|
455
|
+
if (entry.headMeta) {
|
|
456
|
+
this._headMetaByCanonical.set(canonicalUrl, entry.headMeta);
|
|
457
|
+
}
|
|
311
458
|
}
|
|
312
459
|
|
|
313
460
|
this._sourceStateByCanonical = snapshot;
|
|
@@ -1834,11 +1981,14 @@ class OCLSourceCodeSystemFactory extends CodeSystemFactoryProvider {
|
|
|
1834
1981
|
}
|
|
1835
1982
|
|
|
1836
1983
|
#resourceKey() {
|
|
1837
|
-
|
|
1984
|
+
// Plain `system|version`, matching how hasExactFactory/#findFactory build
|
|
1985
|
+
// their lookup keys. This used to be a SHA-256 of the same string, which
|
|
1986
|
+
// meant exact-version lookups could NEVER match a registered factory (plain
|
|
1987
|
+
// string vs hash) — only the unversioned `system|` alias ever worked. The
|
|
1988
|
+
// key is only used for in-memory maps, job keys and logs, so hashing bought
|
|
1989
|
+
// nothing and broke version-aware matching.
|
|
1838
1990
|
const normalizedSystem = OCLSourceCodeSystemFactory.#normalizeSystem(this.system());
|
|
1839
|
-
|
|
1840
|
-
const hash = crypto.createHash('sha256').update(base).digest('hex');
|
|
1841
|
-
return hash;
|
|
1991
|
+
return `${normalizedSystem}|${this.version() || ''}`;
|
|
1842
1992
|
}
|
|
1843
1993
|
|
|
1844
1994
|
currentChecksum() {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Shared public-access gate for OCL repos.
|
|
2
|
+
//
|
|
3
|
+
// The configured OCL token can see every repo its account has access to, and both
|
|
4
|
+
// discovery (listing crawls) and $resolveReference answer anonymous terminology
|
|
5
|
+
// requests — so a PRIVATE repo the token can see must never be surfaced. OCL repo
|
|
6
|
+
// objects carry `public_access` (`View` | `Edit` | `None`; `None` = private), but
|
|
7
|
+
// the listing endpoints and $resolveReference results do NOT include it, so it is
|
|
8
|
+
// read from a per-repo GET. Results are cached by repoUrl (bounded LRU) and shared
|
|
9
|
+
// between the resolver gate and discovery; a GET error is NOT cached (so a transient
|
|
10
|
+
// blip is retried next time rather than pinning a repo as private), and batch checks
|
|
11
|
+
// run with a concurrency limit.
|
|
12
|
+
//
|
|
13
|
+
// Plain .js (not .cjs): jest's collectCoverageFrom globs **/*.js only.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CACHE_LIMIT = 5000;
|
|
16
|
+
const DEFAULT_CONCURRENCY = 8;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether an OCL repo's public_access makes it publicly viewable. Fail-closed:
|
|
20
|
+
* anything that is not an explicit public value (incl. null/unknown) is NOT public.
|
|
21
|
+
*/
|
|
22
|
+
function isPublicAccess(value) {
|
|
23
|
+
const s = String(value == null ? '' : value).trim().toLowerCase();
|
|
24
|
+
return s === 'view' || s === 'edit';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Client input ends up in log lines; strip control characters / newlines and cap
|
|
28
|
+
// the length so a crafted reference cannot forge log entries or flood the log.
|
|
29
|
+
function safeForLog(value) {
|
|
30
|
+
// Matching control characters is the whole point here (we are scrubbing them).
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
const s = String(value == null ? '' : value).replace(/[\u0000-\u001f\u007f]+/g, ' ');
|
|
33
|
+
return s.length > 200 ? `${s.slice(0, 200)}…` : s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Run fn over items with at most `limit` in flight; preserves input order.
|
|
37
|
+
async function mapLimit(items, limit, fn) {
|
|
38
|
+
const results = new Array(items.length);
|
|
39
|
+
let next = 0;
|
|
40
|
+
const size = Math.max(1, Math.min(limit, items.length));
|
|
41
|
+
const workers = Array.from({ length: size }, async () => {
|
|
42
|
+
while (next < items.length) {
|
|
43
|
+
const index = next++;
|
|
44
|
+
results[index] = await fn(items[index], index);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
await Promise.all(workers);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class PublicAccessChecker {
|
|
52
|
+
#httpClient;
|
|
53
|
+
#logger;
|
|
54
|
+
#cache = new Map();
|
|
55
|
+
#cacheLimit;
|
|
56
|
+
#concurrency;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} options
|
|
60
|
+
* @param {object} options.httpClient - axios instance from createOclHttpClient
|
|
61
|
+
* @param {object} [options.logger]
|
|
62
|
+
* @param {number} [options.cacheLimit] - max repoUrls cached (bounded LRU)
|
|
63
|
+
* @param {number} [options.concurrency] - max concurrent repo GETs in a batch
|
|
64
|
+
*/
|
|
65
|
+
constructor({ httpClient, logger = console, cacheLimit = DEFAULT_CACHE_LIMIT, concurrency = DEFAULT_CONCURRENCY } = {}) {
|
|
66
|
+
if (!httpClient) {
|
|
67
|
+
throw new Error('PublicAccessChecker requires an http client');
|
|
68
|
+
}
|
|
69
|
+
this.#httpClient = httpClient;
|
|
70
|
+
this.#logger = logger;
|
|
71
|
+
this.#cacheLimit = cacheLimit > 0 ? cacheLimit : DEFAULT_CACHE_LIMIT;
|
|
72
|
+
this.#concurrency = concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#touch(key) {
|
|
76
|
+
const value = this.#cache.get(key);
|
|
77
|
+
this.#cache.delete(key);
|
|
78
|
+
this.#cache.set(key, value);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#put(key, value) {
|
|
83
|
+
if (this.#cache.has(key)) {
|
|
84
|
+
this.#cache.delete(key);
|
|
85
|
+
}
|
|
86
|
+
this.#cache.set(key, value);
|
|
87
|
+
if (this.#cache.size > this.#cacheLimit) {
|
|
88
|
+
this.#cache.delete(this.#cache.keys().next().value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a repo's public visibility.
|
|
94
|
+
* @param {string} repoUrl - relative OCL repo path
|
|
95
|
+
* @param {string|null} [inlineAccess] - public_access if already known (skips the GET)
|
|
96
|
+
* @returns {Promise<{public: boolean, determined: boolean}>} determined=false when
|
|
97
|
+
* the check errored (not cached, so it is retried later).
|
|
98
|
+
*/
|
|
99
|
+
async check(repoUrl, inlineAccess = null) {
|
|
100
|
+
if (inlineAccess != null) {
|
|
101
|
+
return { public: isPublicAccess(inlineAccess), determined: true };
|
|
102
|
+
}
|
|
103
|
+
if (!repoUrl || typeof repoUrl !== 'string') {
|
|
104
|
+
return { public: false, determined: true };
|
|
105
|
+
}
|
|
106
|
+
if (this.#cache.has(repoUrl)) {
|
|
107
|
+
return { public: this.#touch(repoUrl), determined: true };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const response = await this.#httpClient.get(repoUrl);
|
|
111
|
+
const repo = response?.data && typeof response.data === 'object' ? response.data : null;
|
|
112
|
+
const pub = isPublicAccess(repo ? (repo.public_access ?? repo.publicAccess ?? null) : null);
|
|
113
|
+
this.#put(repoUrl, pub);
|
|
114
|
+
return { public: pub, determined: true };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.#logger.warn(`public_access check failed for ${safeForLog(repoUrl)}: ${safeForLog(error.message)}`);
|
|
117
|
+
return { public: false, determined: false };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async isPublic(repoUrl, inlineAccess = null) {
|
|
122
|
+
return (await this.check(repoUrl, inlineAccess)).public;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Keep only the repo-like items that are publicly viewable, concurrency-limited.
|
|
127
|
+
* @param {Array} items
|
|
128
|
+
* @param {function} [urlOf] - item -> repoUrl
|
|
129
|
+
* @param {function} [accessOf] - item -> inline public_access (null if not present)
|
|
130
|
+
*/
|
|
131
|
+
async filterPublic(items, urlOf = (x) => (x && x.url), accessOf = (x) => (x && (x.public_access ?? x.publicAccess ?? null))) {
|
|
132
|
+
const list = Array.isArray(items) ? items : [];
|
|
133
|
+
const verdicts = await mapLimit(list, this.#concurrency, (item) => this.isPublic(urlOf(item), accessOf(item)));
|
|
134
|
+
return list.filter((_, index) => verdicts[index]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
PublicAccessChecker,
|
|
140
|
+
isPublicAccess,
|
|
141
|
+
safeForLog,
|
|
142
|
+
mapLimit
|
|
143
|
+
};
|