fhirsmith 0.11.2 → 0.13.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 (68) hide show
  1. package/CHANGELOG.md +377 -2
  2. package/README.md +1 -1
  3. package/config-template.json +31 -1
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +208 -30
  10. package/publisher/validation.js +210 -0
  11. package/server.js +24 -15
  12. package/stats-db.js +127 -0
  13. package/stats.js +172 -6
  14. package/translations/Messages.properties +5 -1
  15. package/tx/README.md +21 -0
  16. package/tx/cs/cs-api.js +30 -1
  17. package/tx/cs/cs-api.md +1 -1
  18. package/tx/cs/cs-cs.js +1 -1
  19. package/tx/cs/cs-icd11.js +805 -0
  20. package/tx/cs/cs-lang.js +331 -85
  21. package/tx/cs/cs-loinc.js +45 -4
  22. package/tx/cs/cs-mimetypes.js +492 -21
  23. package/tx/cs/cs-snomed.js +141 -58
  24. package/tx/cs/cs-ucum.js +33 -3
  25. package/tx/cs/icd11-expressions.js +243 -0
  26. package/tx/importers/export-icd11-codesystem.js +823 -0
  27. package/tx/importers/icd11-schema.md +285 -0
  28. package/tx/importers/import-icd11.js +861 -0
  29. package/tx/importers/import-loinc.module.js +14 -7
  30. package/tx/importers/import-sct.module.js +10 -1
  31. package/tx/library/codesystem.js +21 -27
  32. package/tx/library/conceptmap.js +39 -0
  33. package/tx/library/nesting-limit.js +79 -0
  34. package/tx/library/operation-outcome.js +97 -3
  35. package/tx/library/parameter-check.js +139 -0
  36. package/tx/library/valueset.js +16 -20
  37. package/tx/library.js +23 -1
  38. package/tx/ocl/cache/cache-utils.cjs +5 -3
  39. package/tx/ocl/cs-ocl.cjs +73 -17
  40. package/tx/ocl/jobs/background-queue.cjs +6 -3
  41. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  42. package/tx/ocl/shared/patches.cjs +48 -1
  43. package/tx/ocl/vs-ocl.cjs +15 -13
  44. package/tx/operation-context.js +220 -10
  45. package/tx/params.js +226 -175
  46. package/tx/sct/expressions.js +677 -15
  47. package/tx/tx.fhir.org.yml +3 -2
  48. package/tx/tx.js +72 -92
  49. package/tx/workers/batch-validate.js +11 -18
  50. package/tx/workers/batch.js +16 -28
  51. package/tx/workers/cache-control.js +87 -17
  52. package/tx/workers/closure.js +3 -8
  53. package/tx/workers/compare.js +6 -34
  54. package/tx/workers/expand.js +108 -60
  55. package/tx/workers/lookup.js +35 -40
  56. package/tx/workers/read.js +6 -40
  57. package/tx/workers/search.js +2 -8
  58. package/tx/workers/subsumes.js +58 -41
  59. package/tx/workers/translate.js +472 -47
  60. package/tx/workers/validate.js +83 -78
  61. package/tx/workers/worker.js +92 -11
  62. package/tx/xml/xml-base.js +264 -77
  63. package/tx/xversion/xv-conceptmap.js +81 -0
  64. package/tx/xversion/xv-parameters.js +8 -0
  65. package/tx/xversion/xv-valueset.js +39 -1
  66. package/utilities/merge-audit.py +178 -0
  67. package/utilities/why-merge-audit.md +39 -0
  68. package/tx/data/snomed-subset.txt +0 -84
package/CHANGELOG.md CHANGED
@@ -5,7 +5,382 @@ All notable changes to the Health Intersections Node Server will be documented i
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## 0.11.2 - 2026-08-12
8
+ ## [0.13.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - ICD-11 support, in three parts. `tx/importers/import-icd11.js` builds a SQLite database from
13
+ the WHO ICD-API - 110,441 concepts across MMS, ICF and the Foundation for the 2026-01 release,
14
+ in every language the API advertises. The native API is the content source because it is the
15
+ only one that can enumerate the classification: the FHIR endpoint exposes no all-codes value
16
+ set, accepts no client-supplied value sets, and its 28 top-level entities report no parent, so
17
+ nothing there tells a client what the roots are. `tx/importers/export-icd11-codesystem.js`
18
+ writes the content back out as FHIR CodeSystems, language supplements and the 25,354
19
+ postcoordination scale ValueSets, optionally as a FHIR NPM package. `tx/cs/cs-icd11.js` is the
20
+ provider; it is a skeleton at this point - metadata, code location, displays and definitions
21
+ work, while designations, properties, extendLookup, filters, iteration, subsumption and
22
+ postcoordinated expressions are still to come. The `icd-11` suite from the tx-ecosystem IG is
23
+ the specification for the rest of it. Postcoordinated expressions now work:
24
+ `tx/cs/icd11-expressions.js` parses the short-code, entity-uri and ICF dotted forms,
25
+ binds each value to the axis it belongs on, and renders the expression back. The binding
26
+ rule is that an axis not yet carrying a value is preferred over one that is, and required
27
+ axes come before WHO's declaration order - which is what keeps the two values of
28
+ 1D01.0Y/1G41/1G40 on the two axes a coder meant them for instead of coalescing them onto
29
+ the first, and what stops a repeated value being silently dropped. `&` asserts that what
30
+ follows is a value on an axis of the stem, so a value on none of them is an error that
31
+ names the axes; `/` asserts only cluster membership, so a member that fits an unfilled
32
+ axis is taken as a value and one that does not starts a new stem. The provider also now
33
+ answers `concept is-a` / `descendent-of` filters and text search, iterates children and
34
+ roots, tests subsumption, and builds the 25,354 postcoordination scale value sets on
35
+ demand from the database rather than shipping them
36
+
37
+ ### Changed
38
+
39
+ - The tx test runner now names its own output folder (`fhirsmith`) and labels each pass (`r4`,
40
+ `r5`, `r5-cached`), so the three passes stop writing over each other's expected/actual files.
41
+ It also passes its mode set to the validator, which was previously falling back to its own
42
+ default - one that does not include `icd-11`, so every icd-11 test came back "n/a" and was
43
+ counted as a failure. Needs validator 6.10.5 and validator-wrapper 1.4
44
+ - The R5 -> R4 cross-version conversion dropped `ValueSet.compose.property` - the element a
45
+ client uses to say which properties it wants back in an expansion. R4 and R3 have nowhere to
46
+ put it, so it now travels as
47
+ `http://hl7.org/fhir/5.0/StructureDefinition/extension-ValueSet.compose.property` (the same
48
+ extension the Java convertors use) and is read back on the way up, with the extension removed
49
+ so it does not show up twice. Before this, an R4 client that asked for properties got an
50
+ expansion without them
51
+ - Every OperationOutcome the server emits now carries `details.text` and a tx-issue-type
52
+ coding in `details.coding`. `diagnostics` is stripped outright by the test harness, so
53
+ anything a client needs had to stop living *only* there -- it is still sent, and is still
54
+ the right place for server-specific detail: the four (in fact nine) private
55
+ `operationOutcome()` helpers that built `{severity, code, diagnostics}` by hand now all
56
+ delegate to `buildOperationOutcome()` in `tx/library/operation-outcome.js`, and
57
+ `Issue.asIssue()` falls back to a tx-issue-type derived from the FHIR issue type when an
58
+ Issue does not name one, so the guarantee holds without revisiting all ~170 Issue
59
+ construction sites. An informational issue that is not about a code or a value set -- the
60
+ server banner -- gets text but no coding, since it is not a tx issue
61
+
62
+ ### Fixed
63
+
64
+ - The exclude branch of `$expand` called `searchFilter(filter, prep, true)` -- the first two
65
+ arguments the wrong way round, so a provider that implements a text search would have been
66
+ handed the filter where it expected its own filter context. The other three call sites,
67
+ and the declared signature, are `(filterContext, filter, sort)`
68
+ - `$lookup` reported a code that is not in the code system as `not-found`; the code system
69
+ was found, so it is `invalid-code`, which is what the other 62 unknown-code expectations
70
+ in the tx-ecosystem suite assert
71
+ - `$lookup`'s two top-level catch blocks put `Issue.issueCode` -- a tx-issue-type -- into
72
+ `OperationOutcome.issue.code`, which takes a FHIR issue type. Two conventions were in use:
73
+ an `Issue` carries the FHIR issue type in `cause` and the tx-issue-type in `issueCode`,
74
+ while a plain Error tagged by its thrower carries the FHIR issue type in `issueCode` and
75
+ the tx-issue-type in `txIssueType`. Every catch block now goes through
76
+ `outcomeFromError()`, which is the one place that knows about both
77
+
78
+ ## [0.12.0] - 2026-08-27
79
+
80
+ ### Added
81
+
82
+ - `$cache-control?mode=check`: ask the server whether a cache-id is still alive. The check is
83
+ also a keepalive - it resets the cache's idle clock - because a client whose own local cache is
84
+ absorbing the work is invisible to the server, and its cache would otherwise time out mid-job.
85
+ An unknown id answers 200 with `valid` = false (not 404), so a client can tell "the server says
86
+ my cache is gone" from "I could not reach the server"; a live cache also reports `sealed`,
87
+ `resource-count`, `idle` and the server's idle timeout, so a client can work out how often to
88
+ check instead of guessing
89
+ - Cache tombstones: the server now records why a cache-id it issued stopped existing - closed by
90
+ the client, expired after N minutes idle, or cleared with everything else - and says which in
91
+ the error, with the timings. New `CACHE_ID_CLOSED`, `CACHE_ID_EXPIRED` and `CACHE_ID_CLEARED`
92
+ messages; `CACHE_ID_UNKNOWN` now means "never issued here", and says that a cache-id is only
93
+ valid on the server instance and endpoint that issued it (#279)
94
+ - SNOMED CT: MRCM validation of postcoordinated expressions - attribute domain (including the
95
+ lateralizable body structure rule), range (evaluated as ECL, or as a concrete-value range such
96
+ as `dec(>#0..)`), cardinality and grouping. A constraint that cannot be resolved leaves the
97
+ value unchecked rather than rejecting it. The MRCM and lateralizable reference sets are now
98
+ included in the test SNOMED distribution
99
+ - `$translate`: R4 parameter names are accepted as aliases for the R5 ones (`source`/`target` for
100
+ the scopes, `targetsystem`, `targetcode`), and R4's `reverse` parameter now does literally what
101
+ it says - swaps the source and target sides once the parameters are read - while remaining an
102
+ error in R5+, which names the target concept directly instead
103
+ - `$translate`: reverse translation by naming the target concept (`targetCode`, `targetCoding`,
104
+ `targetCodeableConcept`), with the source system saying which system the answers come from
105
+ - `$translate`: `ConceptMap.group.element.comment` is read from R6 resources and from the
106
+ cross-version extension on R5 ones, so a preadopted R5 map and a native R6 map behave the same
107
+ - `$translate`: `originMap` names where a chain of maps started, with every other map consulted
108
+ along the way reported as a `used-conceptmap`; `group.unmapped` is applied per group, so a
109
+ mapping in one group no longer suppresses another group's fallback
110
+ - Persistent usage statistics: request counts per module, endpoint and operation are written to a
111
+ SQLite database every `intervalMinutes`, with both the interval count and the all-time total, so
112
+ they survive restarts and upgrades. New optional `stats` config block (#255)
113
+ - Support for `ValueSet.compose.property` (R6): a value set can name the properties to return in
114
+ its own expansion, rather than leaving it to the request
115
+ - Publisher: input validation for GitHub owner and repository, git branch, package id and version -
116
+ enforced on the server, with matching patterns on the form so the browser objects first
117
+ - Publisher: `large-file-archive` config setting - files the IG Publisher leaves in the web output
118
+ that are too big for GitHub (>100MB) are moved aside for GitHub-hosted websites, instead of
119
+ leaving the push to fail
120
+
121
+ ### Changed
122
+
123
+ - `tx/params.js`: parameter names are now interpreted in exactly one place (`seeParameter`), used
124
+ by both the request `Parameters` resource and the `valueset-expansion-parameter` extension. A
125
+ parameter from the request always wins over one embedded in a ValueSet's expansion parameters;
126
+ accumulating parameters (version rules, designations, properties, supplements) still add
127
+ - An inactive display is now governed by `lenient-display-validation` like every other display
128
+ check - a warning (and `result` = true) when lenient, an error when not - rather than always
129
+ being a warning. The display is a designation of the concept, just not a current one
130
+ - tx.fhir.org now loads `fhir.tx.support` rather than `fhir.tx.support.r4`
131
+
132
+ ### Fixed
133
+
134
+ - The XML parser could be made to loop forever, which on a single-threaded server means the
135
+ process stops answering anything at all - not just the request that carried the input. Several
136
+ scans used `indexOf` without checking for -1 and assigned the result to the cursor, sending it
137
+ backwards; `<ValueSet><x bare></x></ValueSet>` was enough, as was any `<!DOCTYPE` or an
138
+ unterminated comment. The parser has been rewritten to be total: every delimiter search fails
139
+ loudly, every loop asserts that it consumed input, nesting past 1000 elements is an error rather
140
+ than a stack overflow, and a document type declaration is refused outright (FHIR XML has no DTD,
141
+ and accepting one is how a parser ends up expanding entities on someone else's behalf).
142
+ Malformed documents now produce a positioned parse error
143
+ - SNOMED CT: normalising an expression discarded a primitive concept's defining attributes, so
144
+ real subsumption answers were lost. A primitive's definition is necessary but not sufficient, so
145
+ the concept itself must not be replaced by it - but its attributes are still entailed:
146
+ `4846001 |Anicteric viral hepatitis|` is primitive and carries an inferred
147
+ `363698007 |Finding site| = 10200004 |Liver structure|`, so `64572001:{363698007=10200004}`
148
+ really does subsume it, and the server said not-subsumed. The attributes are now added
149
+ alongside the concept, which keeps the other direction sound - the primitive stays in the focus
150
+ as an atom nothing else can match, so its conditions never become sufficient and nothing is
151
+ reported equivalent to it. Read from the concept's relationships at query time rather than from
152
+ the stored normal form, so existing caches need no rebuild
153
+ - IETF language codes (`urn:ietf:bcp:47`) are validated properly. Four things were wrong, all
154
+ visible only through the code system: the registry writes private-use allocations as ranges
155
+ (`QM..QZ`, `Qaaa..Qabx`, `qaa..qtz`, `XA..XZ`) and those were stored as literal keys nobody
156
+ could ever look up, so every code inside them read as unregistered while singly-allocated `AA`
157
+ worked; the 26 grandfathered tags were skipped at load, so registered tags like `i-klingon`
158
+ were rejected; a variant was never checked against the registry at all, so `en-abcdef` parsed
159
+ happily; and the registry's `Prefix` field - its only statement about which subtags may go
160
+ together - was ignored, so `en-cmn` passed even though `cmn` may only follow `zh`. Tags are
161
+ also matched case-insensitively now, per BCP 47 §2.1.1, so `en-us` is accepted as a way of
162
+ writing `en-US`. The stricter checks are options on the parser, applied by the code system;
163
+ Accept-Language parsing is deliberately left as it was. Redundant registry entries such as
164
+ `zh-Hans` still decompose into language plus script rather than being matched whole
165
+ - Language codes gained `=` filters on `language`, `script` and `region`, and three of the
166
+ combinations can now be expanded. The BCP 47 grammar is unbounded, so an expansion is only
167
+ possible where fixing part of a tag leaves a finite list of registry entries to vary: a fixed
168
+ language gives every region plus the bare language (344 codes), a fixed region gives every
169
+ language with it (8787, so too-costly unless paged), and language and region together give
170
+ every script plus the tag with none (275). Anything else - a script on its own, a language with
171
+ a script, an `exists` filter - answers 422 rather than pretending, and `filterMore` throws
172
+ rather than returning false so a mistake cannot surface as an empty expansion that reads like
173
+ "there are none". Every one of these is marked unclosed, since a variant, extension or
174
+ private-use subtag can always be added. A filter value that is not a registered subtag of the
175
+ right kind is refused when the filter is built, rather than silently selecting nothing
176
+ - `$subsumes` works for language codes, where it used to answer `not-subsumed` for everything
177
+ behind a comment reading "No subsumption in language codes". A tag is a set of named
178
+ components rather than an ordered path, so one tag subsumes another when it states a subset of
179
+ what the other states and the languages match: `en` subsumes `en-US`, `zh` subsumes
180
+ `zh-Hans-CN`, `de` subsumes `de-1901`. `en-US` subsumes `en-Latn-US` too, even though the added
181
+ script sits between the two subtags `en-US` names - that is RFC 4647's extended filtering
182
+ rather than basic filtering, and it is what keeps the relation transitive. Comparison is
183
+ case-insensitive, and a grandfathered tag relates only to itself, having no components to
184
+ compare. Suppress-Script is deliberately not consulted, so `en` subsumes `en-Latn` rather than
185
+ being equivalent to it
186
+ - A language code written in the wrong case now comes back with a `normalized-code` and an
187
+ information issue saying so, the way any other case-insensitive code system already behaved -
188
+ `en-us` validates, and the server answers with `en-US`. BCP 47 recommends lower case for the
189
+ language, Titlecase for the script and UPPER CASE for the region; the whole mechanism was
190
+ already in the validator, waiting on the code system to report the canonical spelling of a code
191
+ rather than echoing what the caller wrote
192
+ - Validating a language code now says which subtag is wrong and why - "the extLanguage 'cmn' in
193
+ the code 'en-cmn' may only be used with 'zh'" - instead of only "unknown code". The mechanism
194
+ for this already existed in the validator; the language provider had been discarding the reason
195
+ - `$subsumes` can now report that it cannot determine the relationship, as a 422 carrying
196
+ `cannot-determine` from the tx-issue-type code system (FHIR-58748). There is no outcome code
197
+ meaning "unknown", so a server that cannot decide has no honest answer to give and must report
198
+ an error; answering `not-subsumed` asserts something it has not established. The condition is
199
+ real in more than one code system, so the error is raised through one shared helper
200
+ (`cannotDetermineSubsumption`)
201
+ - Media types: subsumption is only decided on parameters the server understands - `charset`,
202
+ `format`, `delsp` and `version`. Reasoning that a parameter narrows the type, and that two of
203
+ its values exclude one another, are properties of that parameter's definition, and neither can
204
+ be asserted for one the server has never seen: `text/plain` vs `text/plain; foo=bar` might be
205
+ subsumption or might be the same thing. Where an unknown parameter is what the two codes differ
206
+ in, the answer is now cannot-determine. A parameter carried identically by both codes cannot
207
+ affect the relationship and is ignored, so it does not trigger a decline, and a type or subtype
208
+ mismatch stays decidable whatever parameters are present. `boundary` and `profile` are
209
+ deliberately excluded from the understood list: a differing boundary does not narrow anything
210
+ (two multipart bodies with different boundaries are the same media type) and profile values can
211
+ be hierarchical, so neither can be judged by comparing values
212
+
213
+ - SNOMED CT: `$subsumes` no longer answers `not-subsumed` when it has not established it. Comparing
214
+ normal forms is sound when it succeeds and not when it fails: SNOMED's OWL axiom reference set
215
+ carries general concept inclusions and property chains whose entailments are baked into the
216
+ distributed inferred relationships for precoordinated concepts, but a postcoordinated expression
217
+ is one the classifier never saw, so they are not available for it. Where either code is an
218
+ expression and no relationship follows structurally, the server now returns a 422 rather than
219
+ claiming there is none - the claim would be the one that wrongly drops a code out of a cohort,
220
+ and `$subsumes` has no outcome code meaning "unknown". Two plain concepts are unaffected: there
221
+ the transitive closure comes from the classifier's own output and all four outcomes are sound
222
+
223
+ - SNOMED CT: the MRCM `grouped` flag was parsed into the attribute domain rules and then never
224
+ read, so an attribute the concept model requires to be in a relationship group could be written
225
+ outside one and pass validation - `40468003:363698007=10200004` was accepted even though
226
+ `363698007 |Finding site|` is grouped and the expression is not classifiable. It is now
227
+ enforced, for expressions wherever they appear ($validate-code, $lookup and $subsumes all
228
+ validate through the same path), and the message names the attribute and shows the corrected
229
+ form. Attributes the MRCM marks as ungrouped, such as `272741003 |Laterality|`, are unaffected:
230
+ the flag is read from the concept model rather than every brace-less refinement being rejected.
231
+ SNOMED's postcoordination guide treats the ungrouped form as Close To User Form and defines a
232
+ transformation to the classifiable form; that transformation is not implemented, so the
233
+ expression is rejected rather than silently repaired
234
+ - Publisher: with no `sessionSecret` configured, the session middleware fell back to a constant
235
+ written into the source. In an open-source project that is a published signing key - anyone
236
+ could mint themselves an admin session on any deployment that had missed the setting, and
237
+ nothing about the server would look wrong. The fallback is now a random secret plus a warning,
238
+ which fails safe: logins simply do not survive a restart. Session cookies are also marked
239
+ `Secure` by default now (set `modules.publisher.cookieSecure` false to run over plain HTTP),
240
+ and `POST /login` is rate limited - it is the one unauthenticated route that runs bcrypt, and
241
+ bcrypt holds a libuv threadpool thread for ~100ms, so unlimited attempts stall the server's
242
+ file I/O as well as inviting guessing
243
+ - Publisher: twenty more values reached the task, history and admin pages unescaped -
244
+ `npm_package_id`, `version`, `status`, `user_name`, `website_name`, `local_folder`,
245
+ `failure_reason`, the task log messages, and the user and website names on the admin pages.
246
+ Most are constrained elsewhere, but `failure_reason` and the log messages carry build output
247
+ from the IG being published, which is not. The earlier fix covered `github_org`, `github_repo`
248
+ and `git_branch` only; output escaping is the boundary that has to be complete to be worth
249
+ anything
250
+ - The static file handler for `server.webBase` built its filename with `path.join(webBase, req.path)`
251
+ and no containment check, so a literal `..` in the request path walked straight out of the
252
+ directory: `GET /../../etc/passwd` read any file the server user could read. (The encoded forms
253
+ never worked - Express does not decode `req.path` - and a proxy in front normalises `..` away,
254
+ which is why this survived.) Paths are now resolved and required to stay inside the configured
255
+ directory, in `library/path-safety.js` so the rule is testable and reusable; a request that
256
+ escapes falls through to the next handler instead of being served
257
+ - `POST /packages/crawl` triggered a full registry crawl for anyone who asked. It now requires a
258
+ shared secret, `modules.packages.crawlToken`, in an `x-crawl-token` header, compared in constant
259
+ time. With no token configured the endpoint is disabled rather than open: an administrative
260
+ trigger that silently defaults to unauthenticated is how a deployment ends up exposed with
261
+ nothing about it looking wrong. The scheduled crawler is unaffected
262
+
263
+ - `CodeSystem.concept` and `ValueSet.expansion.contains` nest without limit in FHIR, and both are
264
+ walked recursively in a dozen places - the expansion map, the concept counts, the renderer, the
265
+ expander's index, the R5-to-R4 converter. A resource of a couple of hundred KB could nest deeply
266
+ enough to exhaust the JS stack, and hardening those walkers one at a time would not have been
267
+ enough anyway: `JSON.stringify` itself, which we do not control, overflows at a nesting depth of
268
+ about 1800 on node 22, so a deep enough resource could not be serialised even if every walker
269
+ were iterative. Nesting is now bounded once, at construction, at 100 levels - far past SNOMED
270
+ CT's ~30 or LOINC's ~10, and well clear of anything that breaks - which makes every walker safe
271
+ by construction, including ones not yet written. The bounding walk is iterative and doubles as
272
+ the concept count, so the tree is still traversed only once, and an over-nested resource comes
273
+ back as a 400 rather than a 500
274
+
275
+ - Publisher: the task output page wrote `github_org`, `github_repo` and `git_branch` into the HTML
276
+ without escaping. The first two are constrained to letters, digits and punctuation, but a branch
277
+ name was only checked against git's own rules, which say nothing about markup - so a branch
278
+ called `<script>...</script>` was stored XSS on a page that needs no login to read. The fields
279
+ are escaped now, and a branch name carrying `< > " ' &` is refused at the boundary as well
280
+
281
+ - `$expand`: `status` was lost from imported property declarations, and a concept carrying the
282
+ same property more than once had the repeats collapsed to a single value
283
+ - Expansion properties are de-duplicated when they arrive from more than one place (the request,
284
+ an expansion parameter extension, `compose.property`) - a repeat emitted the property twice and
285
+ changed the cache key
286
+ - `no-cache=true` never busted the cache: the parameter wrote `uid`, which nothing read, instead of
287
+ the field the cache key hashes
288
+ - Boolean parameters passed as strings (as they always are on a GET) are now accepted, which
289
+ revives five parameters that were dead on GET requests
290
+ - `$subsumes`: an unknown `codeA`/`codeB` returned a bare OperationOutcome - no
291
+ `operationoutcome-message-id` extension and no `tx-issue-type#invalid-code` detail code - so
292
+ clients could not tell what kind of failure it was. It now reports the same issue that
293
+ `$validate-code` does for an unknown code
294
+ - SNOMED CT import did not record definition status, so every concept looked fully defined. A
295
+ normal form was then generated for concepts that have no definition to expand, and the result
296
+ was stored unparseable - which made `$subsumes` fail with a 500 for any post-coordinated
297
+ expression. **Caches built by the affected importer have to be rebuilt**
298
+ - SNOMED CT `isPrimitive` tested bit 0 of the concept flags, which is part of the status, instead
299
+ of the primitive bit
300
+ - A concept's own stored normal form was run through the MRCM postcoordination checks when it was
301
+ read back. Those rules are about expressions a client sends, not about precoordinated content,
302
+ and they rejected the normal form of any concept defined with a precoordinated-only attribute
303
+ - The expression parser did not resolve attribute names to concept references, so refinements were
304
+ compared by reference in one direction and by code in the other. Subsumption between two refined
305
+ expressions could come out true one way and false the other
306
+ - Subsumption checked each refinement group against only the first group whose attribute names
307
+ lined up. Where several groups share attribute names, the answer depended on the order the
308
+ groups happened to be in after normalisation
309
+ - `$subsumes` reported an unexpected error in `diagnostics`, which the test runner strips from
310
+ every issue it compares, so a failing test showed nothing but `severity`/`code`. The message now
311
+ goes in `details.text` (the same helper elsewhere in the server has the same problem)
312
+ - `$subsumes` ignored a front-loaded cache on a GET: it only looked for `tx-resource` and the
313
+ cache-id when the request had a Parameters body, so a client that put its code systems in a
314
+ server-side cache and sent the id in `X-Cache-Id` got "could not be found" for every one of
315
+ them
316
+ - `$subsumes` reported an unknown code against `codeA`/`codeB` in `expression` even when the code
317
+ came in as `codingA`/`codingB`
318
+
319
+ ### Changed
320
+
321
+ - `$subsumes` on UCUM now answers `equivalent` for two different codes that mean the same unit -
322
+ `1/min` and `min-1`, `N` and `kg.m/s2`, `mg{total}` and `mg`. UCUM still has no hierarchy, so
323
+ nothing ever subsumes anything; this compares the canonical *form* (which carries the conversion
324
+ factor), not the canonical units, so `m` and `cm` remain distinct rather than collapsing onto
325
+ their shared canonical unit
326
+ - `$subsumes` on LOINC is implemented against the multiaxial hierarchy. The importer already
327
+ builds a full transitive closure from `PATH_TO_ROOT` in ComponentHierarchyBySystem - the same
328
+ table the `is-a` / `descendent-of` filters use - so subsumption and expansion now agree.
329
+ Relationships run Part-to-Part and Part-to-code, so two LOINC codes still never subsume each
330
+ other
331
+ - Mime types support a `base` filter, taking either `type` or `type/subtype`, which selects the
332
+ codes consistent with it - parameters on the code are ignored, so `text/plain; charset=utf-8`
333
+ matches a base of `text` and of `text/plain`. Media types cannot be enumerated, so the filter
334
+ validates a code rather than expanding; a value carrying parameters, a wildcard, or anything
335
+ that is not a type or type/subtype is rejected
336
+ - A `registered = true` mime type filter can be expanded: the IANA registry is a finite list, so
337
+ the expander walks it, and the expansion is marked `valueset-unclosed` because every registered
338
+ type also has unboundedly many parameterised forms that belong to the value set. A second filter
339
+ in the same include still narrows it, so `registered = true` + `base = text/plain` expands to
340
+ one code
341
+ - Everything else that cannot be enumerated - the whole code system, a `base` filter on its own,
342
+ `registered = false` - answers 422 `not-supported` with `CODESYSTEM_NOT_ENUMERABLE`, the same as
343
+ HGVS, instead of the 500 exception a filter used to raise. Expanding every registered media type
344
+ at once is a couple of thousand codes, so without a `count` it answers `too-costly` like any
345
+ other oversized expansion
346
+ - Mime types support a `registered` filter (`true`/`false`), testing whether a media type is in
347
+ the IANA registry. The provider downloads
348
+ https://www.iana.org/assignments/media-types/media-types.xml into the terminology cache at
349
+ startup and keeps only the names; a failed download falls back to the cached copy, and if there
350
+ is no copy at all, using the filter reports that rather than answering. The download is written
351
+ to a temporary file and only replaces the cached copy once it has parsed, so a truncated
352
+ transfer cannot leave behind a registry that looks valid but is short
353
+ - `$subsumes` on mime types understands parameters. A parameter narrows the media type, so
354
+ `text/plain` subsumes `text/plain; charset=utf-8`, and two spellings of the same type are
355
+ equivalent - type, subtype and parameter names are matched case-insensitively, as are charset
356
+ values, and quoted values are unquoted. Type and subtype themselves still have no hierarchy,
357
+ so a structured syntax suffix does not make `application/fhir+xml` a kind of `application/xml`
358
+ - **Breaking (LOINC import):** the accessory files are now required, not optional. An import
359
+ missing ComponentHierarchyBySystem previously succeeded and produced a cache with an empty
360
+ closure, so the server answered `not-subsumed` to everything with no way to tell that apart
361
+ from a real answer; the same argument applies to the part links and answer lists. Only the
362
+ linguistic variants stay optional, since a main-only import is a reasonable thing to want and
363
+ their absence costs designations rather than correctness
364
+
365
+ - The mime type tests are their own test mode (`mimetypes`) rather than part of `general`, and
366
+ FHIRsmith's test run declares it. fhir-core gains it in all four places the mode set is
367
+ declared: the runner default (`TxTestHTTPHandler`) and the local and external terminology
368
+ service test runs, which will be red until tx-dev implements the filters
369
+
370
+ ### Removed
371
+
372
+ - `POST /packages/update-package`, along with `forceUpdatePackage` and
373
+ `deleteVersionsByIdVersion`. Its authentication was conditional on `updateToken`, which was set
374
+ in no configuration file and not even mentioned in the template, so in practice the endpoint let
375
+ anyone hand the server a URL to fetch and store - server-side request forgery against anything
376
+ the host can reach, and a write into the package registry. It existed to push out a corrected
377
+ package after a bad publish; that is rare enough to do by hand
378
+
379
+ ### Tx Conformance Statement
380
+
381
+ FHIRsmith passed all 2822 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.3)
382
+
383
+ ## [0.11.2] - 2026-08-12
9
384
 
10
385
  ### Fixed
11
386
 
@@ -14,7 +389,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
14
389
  ### Fixed
15
390
 
16
391
  - Fix snomed designation handling for languages
17
- - Fix for SSEF protection in the validator-
392
+ - Fix for SSRF protection in the validator-
18
393
 
19
394
  ### Tx Conformance Statement
20
395
 
package/README.md CHANGED
@@ -283,7 +283,7 @@ GitHub Actions will automatically:
283
283
  ### Tx Conformance Statement
284
284
  {copy content from text-cases-summary.txt}
285
285
  ```
286
- 2. Update `package.json` to have the same release version
286
+ 2. Update `package.json` & `package-lock.json` to have the same release version
287
287
 
288
288
  3. Commit your changes:
289
289
  ```bash
@@ -6,6 +6,18 @@
6
6
  "credentials": true
7
7
  }
8
8
  },
9
+ // Usage statistics. Request counts are kept per module, per endpoint and per
10
+ // operation, and written to a SQLite database every intervalMinutes so they
11
+ // survive restarts and upgrades. Each row carries both the count for that
12
+ // interval and the all-time total. The whole block is optional.
13
+ "stats": {
14
+ // set false to keep statistics in memory only (nothing is written to disk)
15
+ "enabled": true,
16
+ // relative paths resolve under the data folder's databases directory
17
+ "database": "stats.db",
18
+ // how often the counters are written out
19
+ "intervalMinutes": 10
20
+ },
9
21
  "modules": {
10
22
  "shl": {
11
23
  "enabled": false,
@@ -46,6 +58,10 @@
46
58
  "enabled": false,
47
59
  "database": "/absolute/path/to/packages.db",
48
60
  "mirrorPath": "/absolute/path/to/mirror",
61
+ // shared secret for POST /packages/crawl, which triggers a full registry crawl on
62
+ // demand. Send it in the x-crawl-token header. Omit this and the endpoint is
63
+ // disabled entirely - the scheduled crawler below is unaffected either way.
64
+ "crawlToken": "change-this-to-a-secure-random-string",
49
65
  "crawler": {
50
66
  "enabled": true,
51
67
  "schedule": "0 * * * *"
@@ -62,9 +78,23 @@
62
78
  "publisher": {
63
79
  "enabled": true,
64
80
  "database": "/absolute/path/to/publisher.db",
81
+ // required. Without it the publisher generates a random secret at startup, logs a
82
+ // warning, and logins stop working across restarts.
65
83
  "sessionSecret": "change-this-to-a-secure-random-string",
84
+ // session cookies are marked Secure by default, so they only travel over HTTPS.
85
+ // Set false only to run the publisher over plain HTTP - otherwise the browser will
86
+ // not send the cookie back and logins will appear to do nothing. Behind a TLS
87
+ // proxy, make sure it sets X-Forwarded-Proto.
88
+ "cookieSecure": true,
89
+ // failed-login attempts allowed per IP per 15 minutes (default 20)
90
+ "loginAttemptsPerWindow": 20,
66
91
  "workspaceRoot": "/absolute/path/to/workspaces",
67
- "igPublisherTimeoutMinutes": 60
92
+ "igPublisherTimeoutMinutes": 60,
93
+ // where to put files the IG Publisher leaves in the web folder that are too big for GitHub
94
+ // (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
95
+ // Omit to leave them in place - the push will then fail. Relative paths resolve under the
96
+ // publisher data folder
97
+ "large-file-archive": "/absolute/path/to/large-file-archive"
68
98
  },
69
99
  "npmprojector": {
70
100
  "enabled": true,