Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,76 @@ All notable changes to the Health Intersections Node Server will be documented i
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.12.0] - 2026-08-27

### Added

- `$cache-control?mode=check`: ask the server whether a cache-id is still alive. The check is
also a keepalive - it resets the cache's idle clock - because a client whose own local cache is
absorbing the work is invisible to the server, and its cache would otherwise time out mid-job.
An unknown id answers 200 with `valid` = false (not 404), so a client can tell "the server says
my cache is gone" from "I could not reach the server"; a live cache also reports `sealed`,
`resource-count`, `idle` and the server's idle timeout, so a client can work out how often to
check instead of guessing
- Cache tombstones: the server now records why a cache-id it issued stopped existing - closed by
the client, expired after N minutes idle, or cleared with everything else - and says which in
the error, with the timings. New `CACHE_ID_CLOSED`, `CACHE_ID_EXPIRED` and `CACHE_ID_CLEARED`
messages; `CACHE_ID_UNKNOWN` now means "never issued here", and says that a cache-id is only
valid on the server instance and endpoint that issued it (#279)
- SNOMED CT: MRCM validation of postcoordinated expressions - attribute domain (including the
lateralizable body structure rule), range (evaluated as ECL, or as a concrete-value range such
as `dec(>#0..)`), cardinality and grouping. A constraint that cannot be resolved leaves the
value unchecked rather than rejecting it. The MRCM and lateralizable reference sets are now
included in the test SNOMED distribution
- `$translate`: R4 parameter names are accepted as aliases for the R5 ones (`source`/`target` for
the scopes, `targetsystem`, `targetcode`), and R4's `reverse` parameter now does literally what
it says - swaps the source and target sides once the parameters are read - while remaining an
error in R5+, which names the target concept directly instead
- `$translate`: reverse translation by naming the target concept (`targetCode`, `targetCoding`,
`targetCodeableConcept`), with the source system saying which system the answers come from
- `$translate`: `ConceptMap.group.element.comment` is read from R6 resources and from the
cross-version extension on R5 ones, so a preadopted R5 map and a native R6 map behave the same
- `$translate`: `originMap` names where a chain of maps started, with every other map consulted
along the way reported as a `used-conceptmap`; `group.unmapped` is applied per group, so a
mapping in one group no longer suppresses another group's fallback
- Persistent usage statistics: request counts per module, endpoint and operation are written to a
SQLite database every `intervalMinutes`, with both the interval count and the all-time total, so
they survive restarts and upgrades. New optional `stats` config block (#255)
- Support for `ValueSet.compose.property` (R6): a value set can name the properties to return in
its own expansion, rather than leaving it to the request
- Publisher: input validation for GitHub owner and repository, git branch, package id and version -
enforced on the server, with matching patterns on the form so the browser objects first
- Publisher: `large-file-archive` config setting - files the IG Publisher leaves in the web output
that are too big for GitHub (>100MB) are moved aside for GitHub-hosted websites, instead of
leaving the push to fail

### Changed

- `tx/params.js`: parameter names are now interpreted in exactly one place (`seeParameter`), used
by both the request `Parameters` resource and the `valueset-expansion-parameter` extension. A
parameter from the request always wins over one embedded in a ValueSet's expansion parameters;
accumulating parameters (version rules, designations, properties, supplements) still add
- An inactive display is now governed by `lenient-display-validation` like every other display
check - a warning (and `result` = true) when lenient, an error when not - rather than always
being a warning. The display is a designation of the concept, just not a current one
- tx.fhir.org now loads `fhir.tx.support` rather than `fhir.tx.support.r4`

### Fixed

- `$expand`: `status` was lost from imported property declarations, and a concept carrying the
same property more than once had the repeats collapsed to a single value
- Expansion properties are de-duplicated when they arrive from more than one place (the request,
an expansion parameter extension, `compose.property`) - a repeat emitted the property twice and
changed the cache key
- `no-cache=true` never busted the cache: the parameter wrote `uid`, which nothing read, instead of
the field the cache key hashes
- Boolean parameters passed as strings (as they always are on a GET) are now accepted, which
revives five parameters that were dead on GET requests

### Tx Conformance Statement

FHIRsmith passed all 2822 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.3)

## [0.11.2] - 2026-08-12

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions config-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
"credentials": true
}
},
// Usage statistics. Request counts are kept per module, per endpoint and per
// operation, and written to a SQLite database every intervalMinutes so they
// survive restarts and upgrades. Each row carries both the count for that
// interval and the all-time total. The whole block is optional.
"stats": {
// set false to keep statistics in memory only (nothing is written to disk)
"enabled": true,
// relative paths resolve under the data folder's databases directory
"database": "stats.db",
// how often the counters are written out
"intervalMinutes": 10
},
"modules": {
"shl": {
"enabled": false,
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fhirsmith",
"version": "0.11.2",
"version": "0.12.0",
"txVersion": "1.9.3",
"description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
"main": "server.js",
Expand Down
20 changes: 15 additions & 5 deletions publisher/publisher.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const validation = require('./validation');
const Database = require('sqlite3').Database;
const bcrypt = require('bcrypt');
const session = require('express-session');
Expand Down Expand Up @@ -320,7 +321,7 @@

this.stats.addTask('Publisher', Utilities.formatDuration(pollInterval)); // or however you want to display the frequency

this.taskProcessor = setInterval(async () => {

Check warning on line 324 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
if (this.shutdownRequested) return;

if (this.isProcessing) {
Expand Down Expand Up @@ -668,11 +669,11 @@
let err = '';
npm.stdout.on('data', () => { /* ignore */ });
npm.stderr.on('data', (d) => { err += d.toString(); });
npm.on('error', async (e) => {

Check warning on line 672 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
await this.logTaskMessage(taskId, 'warn', 'Could not run npm to update SUSHI: ' + e.message);
resolve();
});
npm.on('close', async (code) => {

Check warning on line 676 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
if (code === 0) {
let version = '';
try {
Expand Down Expand Up @@ -712,7 +713,7 @@
stderr += data.toString();
});

git.on('close', async (code) => {

Check warning on line 716 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
if (code === 0) {
await this.logTaskMessage(task.id, 'info', 'Repository cloned successfully');
resolve();
Expand All @@ -723,7 +724,7 @@
}
});

git.on('error', async (error) => {

Check warning on line 727 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
await this.logTaskMessage(task.id, 'error', 'Git clone error: ' + error.message);
reject(error);
});
Expand Down Expand Up @@ -767,7 +768,7 @@
// Heartbeat: emit a status line every 60s regardless of stdout activity,
// so silent phases of the Publisher (e.g. "Validating Resources") still
// surface a signal-of-life in the task log.
const heartbeat = setInterval(async () => {

Check warning on line 771 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
const elapsedMs = Date.now() - buildStart;
const sinceDataMs = Date.now() - lastDataAt;
let logKb = 0;
Expand All @@ -783,7 +784,7 @@
);
}, 60 * 1000);

java.on('close', async (code) => {

Check warning on line 787 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
logStream.end();

if (code === 0) {
Expand All @@ -796,7 +797,7 @@
}
});

java.on('error', async (error) => {

Check warning on line 800 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
logStream.end();
await this.logTaskMessage(taskId, 'error', 'IG Publisher error: ' + error.message);
reject(error);
Expand All @@ -804,7 +805,7 @@

// Timeout configurable via publisher.igPublisherTimeoutMinutes (default: 60 minutes)
const timeoutMinutes = this.config.igPublisherTimeoutMinutes || 60;
const timeout = setTimeout(async () => {

Check warning on line 808 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
java.kill();
logStream.end();
await this.logTaskMessage(taskId, 'error', 'IG Publisher timed out after ' + timeoutMinutes + ' minutes');
Expand Down Expand Up @@ -1231,7 +1232,7 @@

// Heartbeat: emit a status line every 60s regardless of stdout activity,
// so silent phases of the Publisher still surface a signal-of-life.
const heartbeat = setInterval(async () => {

Check warning on line 1235 in publisher/publisher.js

View workflow job for this annotation

GitHub Actions / Code Quality

Promise returned in function argument where a void return was expected
const elapsedMs = Date.now() - buildStart;
const sinceDataMs = Date.now() - lastDataAt;
let logKb = 0;
Expand Down Expand Up @@ -1501,23 +1502,23 @@
content += '</div>';
content += '<div class="col-md-3">';
content += '<label for="github_org" class="form-label">GitHub Org</label>';
content += '<input type="text" class="form-control" id="github_org" name="github_org" required placeholder="hl7">';
content += '<input type="text" class="form-control" id="github_org" name="github_org" required maxlength="39" pattern="' + validation.HTML_PATTERNS.github_org + '" title="Letters, digits and single hyphens" placeholder="hl7">';
content += '</div>';
content += '<div class="col-md-3">';
content += '<label for="github_repo" class="form-label">GitHub Repo</label>';
content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required placeholder="fhir-us-core">';
content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required maxlength="100" pattern="' + validation.HTML_PATTERNS.github_repo + '" title="Letters, digits, dots, hyphens and underscores" placeholder="fhir-us-core">';
content += '</div>';
content += '<div class="col-md-3">';
content += '<label for="git_branch" class="form-label">Branch</label>';
content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required placeholder="main">';
content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required maxlength="255" pattern="' + validation.HTML_PATTERNS.git_branch + '" title="A git branch name - slashes are fine, but not spaces, backslashes or any of ~ ^ : ? * [" placeholder="main">';
content += '</div>';
content += '<div class="col-md-4">';
content += '<label for="npm_package_id" class="form-label">NPM Package ID</label>';
content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required placeholder="hl7.fhir.us.core">';
content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required maxlength="128" pattern="' + validation.HTML_PATTERNS.npm_package_id + '" title="Letters, digits, dots, hyphens and underscores" placeholder="hl7.fhir.us.core">';
content += '</div>';
content += '<div class="col-md-4">';
content += '<label for="version" class="form-label">Version</label>';
content += '<input type="text" class="form-control" id="version" name="version" required placeholder="6.0.0">';
content += '<input type="text" class="form-control" id="version" name="version" required maxlength="64" pattern="' + validation.HTML_PATTERNS.version + '" title="Letters, digits, dots, hyphens and plus signs" placeholder="6.0.0">';
content += '</div>';
content += '<div class="col-md-4 d-flex align-items-end">';
content += '<button type="submit" class="btn btn-primary">Create Task</button>';
Expand Down Expand Up @@ -1633,6 +1634,15 @@
try {
const {website_id, github_org, github_repo, git_branch, npm_package_id, version} = req.body;

// Check the input before it goes anywhere near a git command line or a
// file name. The browser checks these too (see HTML_PATTERNS), but the
// form isn't the only way to reach this route.
const problems = validation.validateTaskInput(req.body);
if (problems.length > 0) {
this.logger.warn('Rejected task creation from user ' + req.session.userId + ': ' + problems.join('; '));
return res.status(400).send('Invalid task details: ' + problems.join('; '));
}

// Verify user has permission for this website
const canQueue = await this.userCanQueue(req.session.userId, website_id);
if (!canQueue) {
Expand Down
198 changes: 198 additions & 0 deletions publisher/validation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Validation for the fields a user types into the publication task form.
*
* None of these values can cause command injection - they're passed to spawn()
* as argv, and nothing goes near a shell - but they do end up in git command
* lines, in URLs, and in file names, so they're checked at the boundary rather
* than trusted to be harmless everywhere they're later used.
*
* The rules are the ones GitHub and git themselves enforce (see
* git-check-ref-format(1) for the branch rules): anything rejected here would
* have failed at clone time anyway, with a much worse error.
*/

// GitHub owner names: letters, digits and single hyphens, no hyphen at either
// end, 39 characters at most.
const MAX_OWNER = 39;
// Repository names: letters, digits, dot, hyphen, underscore, 100 at most.
const MAX_REPO = 100;
// git imposes no length limit on a ref, but a branch name past this is a
// filesystem problem waiting to happen.
const MAX_BRANCH = 255;
const MAX_PACKAGE_ID = 128;
const MAX_VERSION = 64;

/**
* A GitHub organisation or user name.
*/
function validateGithubOwner(value) {
if (!value) {
return 'GitHub org is required';
}
if (value.length > MAX_OWNER) {
return `GitHub org must be ${MAX_OWNER} characters or less`;
}
if (!/^[A-Za-z0-9](?:-?[A-Za-z0-9])*$/.test(value)) {
return 'GitHub org may only contain letters, digits and single hyphens, and may not start or end with a hyphen';
}
return null;
}

/**
* A GitHub repository name.
*/
function validateGithubRepo(value) {
if (!value) {
return 'GitHub repo is required';
}
if (value.length > MAX_REPO) {
return `GitHub repo must be ${MAX_REPO} characters or less`;
}
if (!/^[A-Za-z0-9._-]+$/.test(value)) {
return 'GitHub repo may only contain letters, digits, dots, hyphens and underscores';
}
if (value === '.' || value === '..') {
return 'GitHub repo is not a valid name';
}
// the code appends '.git' when it builds the clone URL, and GitHub won't
// create a repository with that suffix anyway
if (value.toLowerCase().endsWith('.git')) {
return 'GitHub repo should not include the .git suffix';
}
return null;
}

/**
* A branch name, by the rules in git-check-ref-format(1). Slashes are fine -
* that's how branches are grouped - but the characters git reserves for
* revision syntax are not. Note that git itself rejects a backslash in a ref
* name, so this does too.
*/
function validateGitBranch(value) {
if (!value) {
return 'Branch is required';
}
if (value.length > MAX_BRANCH) {
return `Branch must be ${MAX_BRANCH} characters or less`;
}
// space, the ASCII control characters, and DEL
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001F\u007F ]/.test(value)) {
return 'Branch may not contain spaces or control characters';
}
// the characters git reserves for revision syntax
if (/[~^:?*[\\]/.test(value)) {
return 'Branch may not contain any of ~ ^ : ? * [ \\';
}
if (value.includes('..')) {
return 'Branch may not contain ..';
}
if (value.includes('@{')) {
return 'Branch may not contain @{';
}
if (value === '@') {
return 'Branch may not be @';
}
if (value.startsWith('-')) {
return 'Branch may not start with a hyphen';
}
if (value.startsWith('/') || value.endsWith('/') || value.includes('//')) {
return 'Branch may not start or end with /, or contain //';
}
if (value.endsWith('.')) {
return 'Branch may not end with .';
}
for (const part of value.split('/')) {
if (part.startsWith('.')) {
return 'No part of a branch name may start with .';
}
if (part.endsWith('.lock')) {
return 'No part of a branch name may end with .lock';
}
}
return null;
}

/**
* An NPM package id. This one is not about git at all: the package id and the
* version are concatenated into file names under the zips directory
* (<id>#<version>.log, <id>#<version>-announcement.txt), so a value containing
* a path separator would look outside that directory.
*/
function validatePackageId(value) {
if (!value) {
return 'NPM package id is required';
}
if (value.length > MAX_PACKAGE_ID) {
return `NPM package id must be ${MAX_PACKAGE_ID} characters or less`;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
return 'NPM package id may only contain letters, digits, dots, hyphens and underscores, and must start with a letter or digit';
}
if (value.includes('..')) {
return 'NPM package id may not contain ..';
}
return null;
}

/**
* A package version. Loose enough for semver with pre-release and build
* metadata, tight enough that it can't become a path.
*/
function validateVersion(value) {
if (!value) {
return 'Version is required';
}
if (value.length > MAX_VERSION) {
return `Version must be ${MAX_VERSION} characters or less`;
}
if (!/^[A-Za-z0-9][A-Za-z0-9.+-]*$/.test(value)) {
return 'Version may only contain letters, digits, dots, hyphens and plus signs, and must start with a letter or digit';
}
if (value.includes('..')) {
return 'Version may not contain ..';
}
return null;
}

/**
* Validate everything the task form collects.
* @returns {string[]} the problems found, empty if the input is acceptable
*/
function validateTaskInput(input) {
const errors = [];
const checks = [
validateGithubOwner(input.github_org),
validateGithubRepo(input.github_repo),
validateGitBranch(input.git_branch),
validatePackageId(input.npm_package_id),
validateVersion(input.version)
];
for (const error of checks) {
if (error) {
errors.push(error);
}
}
return errors;
}

// Patterns for the HTML form, so the browser objects before the round trip.
// Deliberately a subset of the checks above - the server is what decides -
// but they catch the obvious mistakes as the user types.
const HTML_PATTERNS = {
github_org: '[A-Za-z0-9](-?[A-Za-z0-9])*',
github_repo: '[A-Za-z0-9._-]+',
git_branch: '[^\\\\ ~^:?*\\[]+',
npm_package_id: '[A-Za-z0-9][A-Za-z0-9._-]*',
version: '[A-Za-z0-9][A-Za-z0-9.+-]*'
};

module.exports = {
validateGithubOwner,
validateGithubRepo,
validateGitBranch,
validatePackageId,
validateVersion,
validateTaskInput,
HTML_PATTERNS
};
Loading
Loading