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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. 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
- return Array.from(this.sourceMetaByUrl.values());
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 (const cs of changes.added || []) {
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
- if (entry?.meta && !OCLSourceCodeSystemFactory.hasFactory(cs.url, cs.version || null)) {
155
- const factory = OCLSourceCodeSystemFactory.createForDiscoveredSource(this.httpClient, entry.meta);
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 newly discovered source: ${cs.url}`);
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
- // Fallback for OCL instances that expose global listing but not org listing.
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
- if (!source || typeof source !== 'object') {
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
- return allSources;
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
- const crypto = require('crypto');
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
- const base = `${normalizedSystem}|${this.version() || ''}`;
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
+ };