fhirsmith 0.17.0 → 0.18.1

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 (131) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +80 -13
  3. package/config-template.json +106 -1
  4. package/ips/ai-client.js +263 -0
  5. package/ips/composer.js +805 -0
  6. package/ips/editor-schema.js +289 -0
  7. package/ips/editor.js +290 -0
  8. package/ips/fhirpath-ips.js +397 -0
  9. package/ips/ips-codeable-concepts.md +1742 -0
  10. package/ips/ips-template.html +125 -0
  11. package/ips/ips-valuesets.js +159 -0
  12. package/ips/ips.js +1736 -0
  13. package/ips/membership.js +179 -0
  14. package/ips/pipeline.js +1012 -0
  15. package/ips/presenter.js +974 -0
  16. package/ips/profile-editor.js +578 -0
  17. package/ips/profiles.js +506 -0
  18. package/ips/readme.md +460 -0
  19. package/ips/render.js +1482 -0
  20. package/ips/search-params.js +83 -0
  21. package/ips/search.js +667 -0
  22. package/ips/share.js +178 -0
  23. package/ips/store.js +650 -0
  24. package/ips/terminology.js +395 -0
  25. package/ips/tx-client.js +202 -0
  26. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  27. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  28. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  29. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  31. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  32. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  33. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  35. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  36. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  37. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  38. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  39. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  40. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  42. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  43. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  44. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  45. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  46. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  47. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  49. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  50. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  51. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  52. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  53. package/ips/valuesets/elements.json +44 -0
  54. package/library/daily-report.js +432 -0
  55. package/library/fhir-search-values.js +138 -0
  56. package/library/html-server.js +32 -23
  57. package/library/mailer.js +77 -0
  58. package/library/narrative-check.js +21 -2
  59. package/library/npm-audit.js +25 -0
  60. package/library/package-manager.js +4 -2
  61. package/library/sqlite-session-store.js +29 -1
  62. package/package.json +8 -3
  63. package/packages/packages.js +34 -3
  64. package/publisher/publisher.js +29 -0
  65. package/registry/auth-store.js +366 -0
  66. package/registry/auth.js +872 -0
  67. package/registry/crawler.js +41 -1
  68. package/registry/documentation/api.md +162 -0
  69. package/registry/documentation/configuration.md +93 -0
  70. package/registry/documentation/documents.md +74 -0
  71. package/registry/documentation/internals.md +151 -0
  72. package/registry/documentation/readme.md +100 -0
  73. package/registry/documentation/security.md +199 -0
  74. package/registry/documents.js +309 -0
  75. package/registry/openapi.yaml +30 -0
  76. package/registry/registry-template.html +1 -1
  77. package/registry/registry.js +230 -44
  78. package/registry/uptime.js +154 -0
  79. package/server.js +105 -44
  80. package/shl/shl.js +64 -9
  81. package/stats-db.js +93 -0
  82. package/stats.js +158 -1
  83. package/testing/search.js +2 -108
  84. package/testing/store.js +5 -0
  85. package/testing/testing.js +20 -0
  86. package/token/token-template.html +5 -4
  87. package/token/token.js +17 -10
  88. package/translations/Messages.properties +10 -0
  89. package/tx/README.md +52 -0
  90. package/tx/closure/closure-store.js +14 -0
  91. package/tx/cs/cs-api.js +92 -14
  92. package/tx/cs/cs-cs.js +64 -3
  93. package/tx/cs/cs-icd11.js +366 -112
  94. package/tx/cs/cs-icf.js +431 -0
  95. package/tx/cs/cs-loinc.js +296 -27
  96. package/tx/cs/cs-omop.js +10 -3
  97. package/tx/cs/cs-snomed.js +1210 -80
  98. package/tx/cs/cs-ucum.js +6 -0
  99. package/tx/cs/icd11-expressions.js +191 -78
  100. package/tx/importers/csv-records.js +35 -0
  101. package/tx/importers/export-icd11-codesystem.js +58 -12
  102. package/tx/importers/icd11-schema.md +9 -5
  103. package/tx/importers/import-loinc.module.js +177 -41
  104. package/tx/importers/import-sct.module.js +66 -1
  105. package/tx/importers/subset-loinc.module.js +25 -6
  106. package/tx/library/codesystem.js +4 -1
  107. package/tx/library/designations.js +23 -30
  108. package/tx/library.js +98 -4
  109. package/tx/openapi-operations.js +6 -1
  110. package/tx/params.js +8 -1
  111. package/tx/provider.js +24 -6
  112. package/tx/sct/documentation.md +251 -0
  113. package/tx/sct/ecl.js +758 -46
  114. package/tx/sct/expressions.js +113 -52
  115. package/tx/sct/structures.js +8 -2
  116. package/tx/syndication/syndication-feed.js +218 -0
  117. package/tx/syndication/syndication-store.js +415 -0
  118. package/tx/tx-html.js +15 -1
  119. package/tx/tx.fhir.org.yml +3 -4
  120. package/tx/tx.js +144 -2
  121. package/tx/vs/vs-vsac.js +25 -6
  122. package/tx/workers/closure.js +20 -0
  123. package/tx/workers/expand.js +35 -8
  124. package/tx/workers/lookup.js +61 -6
  125. package/tx/workers/subsumes.js +1 -1
  126. package/tx/workers/translate.js +166 -15
  127. package/tx/workers/validate.js +138 -6
  128. package/utilities/make-sct-loinc-subset.js +250 -0
  129. package/utilities/make-sct-test-extension.js +264 -0
  130. package/xig/xig.js +32 -0
  131. package/registry/readme.md +0 -124
@@ -0,0 +1,100 @@
1
+ # Terminology Server Registry
2
+
3
+ The registry (`/tx-reg`) is the *coordination server* of a terminology server ecosystem: a
4
+ set of FHIR terminology servers, run by different organisations, that together serve the
5
+ code systems and value sets an implementer community needs. Each server hosts some of the
6
+ content and may be *authoritative* for some of it; the registry knows who hosts what, and
7
+ tells clients which server to ask for a given code system or value set. HL7's ecosystem
8
+ registry runs at https://tx.fhir.org/tx-reg (moving to https://ecosystem.hl7.org).
9
+
10
+ | Page | |
11
+ |---|---|
12
+ | this page | What the registry does, and the specifications it implements |
13
+ | [configuration.md](configuration.md) | `modules.registry` settings |
14
+ | [api.md](api.md) | The API: discovery, resolution, and how resolution decides |
15
+ | [security.md](security.md) | Closing the API: API token, users and tokens, OpenID Connect |
16
+ | [documents.md](documents.md) | Documents the registry stores and serves: ecosystem policy and configuration |
17
+ | [internals.md](internals.md) | Scanning, storage, the pages, the code and the tests |
18
+
19
+ ## The specifications
20
+
21
+ **The terminology ecosystem IG.** The ecosystem, its registration files and the coordination
22
+ server's API are specified by HL7's
23
+ [FHIR Terminology Ecosystem IG](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/), on its
24
+ [ecosystem page](https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/ecosystem.html). That page
25
+ is normative for what the registry answers; where this implementation extends or departs from
26
+ it, [api.md](api.md) says so.
27
+
28
+ **The registration files.** The ecosystem is declared by a *master registration file* that
29
+ lists *registries*, each of which lists *servers*, each of which lists its endpoints (one per
30
+ FHIR version) and its claims. The format is documented in the ig-registry repository's
31
+ [tx-registry-doco.md](https://github.com/FHIR/ig-registry/blob/master/tx-registry-doco.md).
32
+ HL7's master file is https://fhir.github.io/ig-registry/tx-servers.json, and its registries
33
+ are the per-affiliate `*-tx-servers.json` files in the same repository (HL7 International,
34
+ HL7 Australia, HL7 Europe, and so on). A server's registration carries:
35
+
36
+ * `authoritative` and `authoritative-valuesets`: URL masks (with `*` wildcards) of the code
37
+ systems and value sets the server is the authority for;
38
+ * `languages`: language specific authority claims, BCP-47 tag to code system masks;
39
+ * `usage`: if present, the only purposes the server may be used for (the HL7 Java tools say
40
+ `publication`, `validation` or `code-generation`);
41
+ * `exclusions`: code system and value set masks the server hosts but withholds from the
42
+ ecosystem;
43
+ * `fhirVersions`: the endpoints, each a FHIR version and a URL.
44
+
45
+ **What the registry reads from the servers.** Claims are what a server says about itself; what
46
+ it actually hosts, the registry learns by scanning each endpoint with standard FHIR:
47
+
48
+ * [`GET [base]/metadata`](https://hl7.org/fhir/http.html#capabilities), the
49
+ [CapabilityStatement](https://hl7.org/fhir/capabilitystatement.html), for
50
+ `software.name`, `software.version` and `software.releaseDate` (the software page);
51
+ * `GET [base]/metadata?mode=terminology`, the
52
+ [TerminologyCapabilities](https://hl7.org/fhir/terminologycapabilities.html), for the code
53
+ systems and versions the endpoint hosts (`codeSystem[].uri`, `codeSystem[].version[].code`);
54
+ * `GET [base]/ValueSet?_elements=url,version`, a [search](https://hl7.org/fhir/search.html)
55
+ for the value sets it hosts.
56
+
57
+ Content a server hosts is what makes it a *candidate* for that content; a claim makes it
58
+ *authoritative*. A server is never returned for content it does not host, whatever it claims.
59
+
60
+ **FHIR versions.** Endpoints are scanned as R3, R4 (including R4B) or R5 by the major version
61
+ in their registration, and requests may name a version as a release code (`R4`, `R5`) or a
62
+ number (`4.0.1`, `4.0`).
63
+
64
+ **SNOMED CT.** SNOMED CT versions are edition URIs,
65
+ `http://snomed.info/sct/{edition}/version/{date}`
66
+ ([SNOMED CT URI standard](https://confluence.ihtsdotools.org/display/DOCURI)). The registry
67
+ treats a request for an edition (`http://snomed.info/sct|http://snomed.info/sct/32506021000036107`)
68
+ as satisfied by any endpoint hosting any version of that edition; a request for a dated
69
+ version must match exactly.
70
+
71
+ **The API description.** The API is described by an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0)
72
+ document at `/tx-reg/openapi.json` (and `.yaml`), with a browsable reference at
73
+ `/tx-reg/openapi`. Every response advertises it with a
74
+ `Link: </tx-reg/openapi.json>; rel="service-desc"` header
75
+ ([RFC 8631](https://www.rfc-editor.org/rfc/rfc8631)).
76
+
77
+ **Security.** When the API is closed, clients present a token as an HTTP bearer token
78
+ ([RFC 6750](https://www.rfc-editor.org/rfc/rfc6750)) or an `Api-Key` header. Enterprise
79
+ clients can present a JWT ([RFC 7519](https://www.rfc-editor.org/rfc/rfc7519)) from an
80
+ [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html) provider, which the
81
+ registry verifies against the provider's keys
82
+ ([OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html),
83
+ [JWK, RFC 7517](https://www.rfc-editor.org/rfc/rfc7517)), typically obtained with the OAuth 2.0
84
+ client credentials grant ([RFC 6749 §4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)).
85
+ See [security.md](security.md).
86
+
87
+ ## Who uses it
88
+
89
+ The main client is the HL7 Java tooling: `TerminologyClientManager` in `org.hl7.fhir.r5`
90
+ (used by the validator, the IG publisher and everything built on them) calls `/resolve` for
91
+ each code system or value set it meets, remembers the answer per code system and language, and
92
+ routes each terminology operation to the server the registry named. Other clients - the
93
+ registry's own pages, scripts, other servers - use the same two endpoints.
94
+
95
+ ## Running one
96
+
97
+ A registry is a FHIRsmith server with the `registry` module enabled; nothing else is needed
98
+ ([configuration.md](configuration.md)). Pointing `masterUrl` at your own master registration
99
+ file makes a registry for your own ecosystem - a national or enterprise set of servers - with
100
+ the same API, and [security.md](security.md) closes it to your own clients.
@@ -0,0 +1,199 @@
1
+ # Securing the registry
2
+
3
+ By default the registry is open, as HL7's ecosystem registry is: anyone can call the API. A
4
+ registry run for a narrower community - an enterprise, a national programme, a vendor's
5
+ customers - can be closed with `modules.registry.security`, in any combination of three ways:
6
+
7
+ | Method | Who it suits |
8
+ |---|---|
9
+ | `apiToken` | A handful of clients you configure yourself: one token, every client sends it |
10
+ | `users` | Clients you want to tell apart - issue, expire and revoke tokens one by one, see who is calling |
11
+ | `oidc` | An enterprise with an identity provider: it decides who may call, and issues the tokens |
12
+
13
+ The methods combine: a registry can accept its enterprise's JWTs *and* have a few locally
14
+ managed users for clients that can't do OAuth. The users are mostly software - a validator
15
+ in a build pipeline, a server resolving terminology - not people, and the design follows
16
+ from that.
17
+
18
+ ## What is closed
19
+
20
+ Only the JSON API: `GET /tx-reg/` and `GET /tx-reg/resolve` when they answer JSON. The HTML
21
+ pages (home, resolve form, software, log), the OpenAPI description and the login page stay
22
+ open, so a client - or the person configuring one - can always find out how to authenticate.
23
+ A request without a valid token gets:
24
+
25
+ ```http
26
+ HTTP/1.1 401 Unauthorized
27
+ WWW-Authenticate: Bearer realm="tx-reg"
28
+ Content-Type: application/json
29
+
30
+ { "error": "unauthorized",
31
+ "message": "This registry requires a token: send it as Authorization: Bearer {token} or Api-Key: {token}",
32
+ "documentation": "/tx-reg/openapi" }
33
+ ```
34
+
35
+ (The message says `The token is not valid for this registry` when one was sent.) An open
36
+ registry ignores any token sent to it.
37
+
38
+ Failed authentications are limited per address. After `failedAuthPerWindow` requests
39
+ answered 401 in 15 minutes (default 60), the address's requests with a token that isn't
40
+ valid get `429 Too Many Requests` for the rest of the window. The token is checked first, so a
41
+ request with a valid token always goes through, even from an address over the limit. One
42
+ client behind a NAT retrying a bad token doesn't lock out the others. Only 401s count. This
43
+ stops anyone guessing tokens. JWTs without an `exp` claim are refused. General request volume is still the proxy's
44
+ business (nginx). `0` turns the limit off. It applies to the JSON API only; the pages
45
+ aren't guarded, so they aren't limited.
46
+
47
+ ## How a client sends a token
48
+
49
+ As an HTTP bearer token, or in an `Api-Key` header (`X-API-Key` is accepted too):
50
+
51
+ ```http
52
+ GET /tx-reg/resolve?fhirVersion=R4&url=http://loinc.org
53
+ Authorization: Bearer txr_k3Jd9...
54
+
55
+ GET /tx-reg/resolve?fhirVersion=R4&url=http://loinc.org
56
+ Api-Key: txr_k3Jd9...
57
+ ```
58
+
59
+ The HL7 Java tooling (validator, IG publisher, and everything that uses
60
+ `TerminologyClientManager`) sends either for a server listed in its `fhir-settings.json`, so a
61
+ secured registry needs no change to the tools, only a settings entry:
62
+
63
+ ```json
64
+ { "servers": [
65
+ { "url": "https://ecosystem.example.org/tx-reg",
66
+ "authenticationType": "token", "token": "txr_k3Jd9..." }
67
+ ] }
68
+ ```
69
+
70
+ (`"authenticationType": "apikey", "apikey": "..."` sends the `Api-Key` header instead.) Note
71
+ that the tooling can *send* a token but not *obtain* one, so a Java client of an OIDC-secured
72
+ registry needs a long-lived token - from a local user, or an OIDC client credential arranged
73
+ outside the tool.
74
+
75
+ ## Method 1: a single API token
76
+
77
+ ```json
78
+ "security": { "apiToken": "a long random string" }
79
+ ```
80
+
81
+ Every client sends the same token. Compared in constant time; nothing is stored. Rotating it
82
+ means changing the configuration and every client at once, which is why this suits only a few
83
+ clients you control.
84
+
85
+ ## Method 2: users with their own tokens
86
+
87
+ ```json
88
+ "security": {
89
+ "users": {
90
+ "database": "registry-auth.db",
91
+ "adminUsername": "admin",
92
+ "adminPassword": "...",
93
+ "sessionSecret": "a long random string",
94
+ "cookieSecure": true,
95
+ "loginAttemptsPerWindow": 20
96
+ }
97
+ }
98
+ ```
99
+
100
+ Users are kept in a SQLite database (`database`: a file in the data folder's `databases`
101
+ directory, an absolute path, or `:memory:`). The administrator's login comes from the
102
+ configuration, as in the other FHIRsmith modules; without `adminPassword` nobody can create
103
+ users. Sessions are kept in the same database, so with a configured `sessionSecret` logins
104
+ survive a restart. Changing or removing a user's password ends that user's other sessions.
105
+ Pages that a visitor only reads (documents and their history) start no session for them.
106
+ `cookieSecure` (default true) marks
107
+ the login cookie `Secure`, which needs HTTPS and a proxy that passes `X-Forwarded-Proto`
108
+ ([configuration.md](configuration.md#behind-a-proxy)). Logins are rate limited to
109
+ `loginAttemptsPerWindow` per 15 minutes per address.
110
+
111
+ **Administering users.** The administrator logs in at `/tx-reg/login` and manages users at
112
+ `/tx-reg/admin/users`: create a user (a login, a name, optional notes and password, and
113
+ whether it is an administrator), then on the user's page edit it, enable or disable it, set or
114
+ remove its password, issue and revoke tokens, or delete it. A user flagged **administrator**
115
+ can do all of this too, and edit [documents](documents.md); the configured administrator is
116
+ the root that cannot be locked out. The users list shows each user's active tokens, request count and last
117
+ use; an "other clients" table shows requests made with the API token and by OIDC clients.
118
+
119
+ **Tokens.** A token is `txr_` followed by 256 random bits (base64url, 47 characters). It is
120
+ shown once, on the page after it is issued, and only its SHA-256 hash is stored - a stolen
121
+ database yields no tokens, and the hash (rather than bcrypt) is right because the token is
122
+ already high-entropy and is checked on every API call. A token can carry a label and an
123
+ expiry (in days); it stops working when it expires, when it is revoked, or when its user is
124
+ disabled. Each token's requests are counted, in memory, and written to the database once a
125
+ minute. The first 12 characters are kept in clear as the token's name on the pages.
126
+
127
+ **Self service.** A user with a password can log in and manage its own tokens at
128
+ `/tx-reg/account`: see them and their usage, issue new ones, revoke old ones, and change the
129
+ password. To rotate a token without an outage: issue the new one, move the client to it,
130
+ revoke the old one. A user without a password cannot log in at all; its tokens still work.
131
+ The password is only for the pages - it is never accepted on the API.
132
+
133
+ **Web security.** The pages use the same protections as the other FHIRsmith modules: a
134
+ `SameSite=Lax`, `HttpOnly` session cookie; a CSRF token on every form; cross-site form posts
135
+ refused by `Origin`; bcrypt for passwords, with the same comparison time whether or not the
136
+ username exists; the session regenerated at login; every value from the database escaped on
137
+ output. Logins, user changes and token issue/revocation are logged.
138
+
139
+ ## Method 3: OpenID Connect
140
+
141
+ ```json
142
+ "security": {
143
+ "oidc": {
144
+ "issuer": "https://login.microsoftonline.com/{tenant}/v2.0",
145
+ "audience": "api://ecosystem.example.org",
146
+ "requiredScope": "registry.read",
147
+ "jwksUri": "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
148
+ "algorithms": ["RS256"]
149
+ }
150
+ }
151
+ ```
152
+
153
+ The enterprise's identity provider (Entra ID, Okta, Keycloak, Ping, Auth0 - anything that
154
+ implements OpenID Connect) issues JWTs; the registry verifies them and lets the bearer in. The
155
+ enterprise therefore decides who may use the registry, with its own onboarding and
156
+ offboarding, and no secret is ever shared with the registry.
157
+
158
+ A software client registers an application with the provider and obtains a token with the
159
+ OAuth 2.0 client credentials grant, then sends it as `Authorization: Bearer`. (A person's
160
+ token from the usual browser login flow works the same way, if it has the right audience.)
161
+
162
+ **What is checked.** The signature, against the issuer's published keys; `iss` equal to
163
+ `issuer`; `aud` containing `audience` (a string, or a list of acceptable values); `exp` and
164
+ `nbf`, with 60 seconds' tolerance; and, if `requiredScope` is set, that value among the
165
+ token's scopes - the space-separated `scp` or `scope` claim, or the `roles` list (Entra ID puts
166
+ application permissions in `roles`). Any failure is a 401, and the reason is logged.
167
+
168
+ **Keys.** With `jwksUri`, keys are fetched from there. Without it, the registry reads
169
+ `{issuer}/.well-known/openid-configuration` on the first request and uses its `jwks_uri`;
170
+ if that fails the request is refused and discovery is retried on the next. Keys are cached
171
+ and refreshed when an unknown key id appears (at most every 30 seconds). Both URLs must be
172
+ https. `algorithms` restricts the accepted signing algorithms; the default is the asymmetric
173
+ set (RSA, RSA-PSS, ECDSA, EdDSA) - symmetric HMAC algorithms are never accepted, since a shared
174
+ secret would defeat the point.
175
+
176
+ **Identifying the client.** Requests are counted per client, by the token's `azp`,
177
+ `client_id`, `appid` or `sub` claim, and shown in the "other clients" table on the users page
178
+ when `users` is also configured. The registry keeps no record of OIDC clients beyond that.
179
+
180
+ **Which check a token gets.** A token of three dot-separated parts is treated as a JWT and
181
+ verified against the provider when `oidc` is configured; anything else is compared with
182
+ `apiToken`, then looked up among the users' tokens. So the three methods never confuse one
183
+ another's tokens.
184
+
185
+ This needs the `jose` package (a dependency of FHIRsmith); a registry configured for OIDC
186
+ without it refuses to start, with a message saying so.
187
+
188
+ ## What is not done, and could be
189
+
190
+ * **Trusted proxy headers.** An enterprise that fronts the registry with its own gateway
191
+ (mTLS, SSO, API keys it already issues) could pass the caller's identity in a header the
192
+ registry trusts from that proxy only. Not implemented; the gateway can simply hold the
193
+ registry's API token today.
194
+ * **Single sign-on for the pages.** The administrator and users log in with passwords. The
195
+ OIDC provider could authenticate people to the pages too.
196
+ * **Token introspection, LDAP, SCIM.** Considered and set aside until a deployment needs one.
197
+ Introspection (RFC 7662) suits opaque enterprise tokens at the cost of a call per request;
198
+ LDAP is a human-login technology; SCIM would synchronise the enterprise's users into method
199
+ 2 but still leave the tokens to be issued here.
@@ -0,0 +1,309 @@
1
+ // registry/documents.js
2
+ // Documents the registry stores and serves: enterprise configuration and policy, as
3
+ // markdown or JSON. Each has a name, a type, whether it is public, and when (and by whom)
4
+ // it was last edited; every change is logged with the content as it was.
5
+ //
6
+ // public documents anyone can read
7
+ // other documents anyone logged in (a browser session - never an API token)
8
+ // editing, deleting administrators (the configured one, or a user flagged admin)
9
+ //
10
+ // Markdown is rendered without raw HTML (commonmark's safe mode: inline and block HTML is
11
+ // left out, and links to javascript: and the like are not made), so a document can't put
12
+ // script on a page that shares its origin with other modules; JSON is shown escaped.
13
+ // Editing is a textarea.
14
+ //
15
+ // Routes (added to the registry's router by RegistryAuth, since they need its session):
16
+ // GET /docs the list
17
+ // GET /docs/new, POST /docs create
18
+ // GET /docs/{name} the document: rendered for a browser, raw otherwise
19
+ // GET /docs/{name}/edit, POST edit (name, type, public, content)
20
+ // POST /docs/{name}/delete
21
+ // GET /docs/{name}/history the log; /history/{entry} shows a past version
22
+
23
+ const express = require('express');
24
+ const escape = require('escape-html');
25
+ const commonmark = require('commonmark');
26
+
27
+ const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
28
+ const RESERVED = new Set(['new']);
29
+ const TYPES = { md: 'text/markdown; charset=utf-8', json: 'application/json; charset=utf-8' };
30
+ const MAX_SIZE = 2 * 1024 * 1024;
31
+
32
+ function str(v) {
33
+ return typeof v === 'string' ? v : '';
34
+ }
35
+
36
+ /** Render markdown to HTML: no raw HTML, and no links to javascript: and the like */
37
+ function renderMarkdown(md) {
38
+ const parsed = new commonmark.Parser().parse(md);
39
+ return new commonmark.HtmlRenderer({ safe: true }).render(parsed);
40
+ }
41
+
42
+ /** The JSON pretty-printed, or null if it doesn't parse */
43
+ function prettyJson(text) {
44
+ try {
45
+ return JSON.stringify(JSON.parse(text), null, 2);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ class RegistryDocuments {
52
+ /**
53
+ * @param {RegistryAuth} auth the session, csrf and store live there
54
+ * @param {object} logger
55
+ */
56
+ constructor(auth, logger) {
57
+ this.auth = auth;
58
+ this.store = auth.store;
59
+ this.log = logger || console;
60
+ }
61
+
62
+ setupRoutes(router) {
63
+ const a = this.auth;
64
+ const h = fn => (req, res) => Promise.resolve().then(() => fn(req, res)).catch(e => {
65
+ this.log.error(`Registry documents: ${e.message}`);
66
+ a.sendError(req, res, 500, e.message);
67
+ });
68
+ const admin = (req, res, next) => {
69
+ const user = a.currentUser(req);
70
+ if (!user) {
71
+ return res.redirect(req.baseUrl + '/login');
72
+ }
73
+ if (!user.isAdmin) {
74
+ return a.sendError(req, res, 403, 'Only an administrator can do that');
75
+ }
76
+ req.user = user;
77
+ next();
78
+ };
79
+ // the session is read on every page, so that a logged in user sees the private
80
+ // documents; CSRF only matters for the forms
81
+ router.get('/docs', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendList(req, res)));
82
+ router.get('/docs/new', a.session, a.csrf, admin, h((req, res) => this.sendForm(req, res, null)));
83
+ // a document's form carries up to MAX_SIZE of content, URL-encoded (up to three times as
84
+ // long): the auth pages' 64kb form parser is too small for it. One a bit over is caught
85
+ // by save(), with the form's own message
86
+ const docForm = express.urlencoded({ extended: false, limit: 3 * MAX_SIZE + 64 * 1024 });
87
+ router.post('/docs', a.sameOrigin, a.session, docForm, a.csrf, admin, h((req, res) => this.save(req, res, null)));
88
+ router.get('/docs/:name', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendDocument(req, res)));
89
+ router.get('/docs/:name/edit', a.session, a.csrf, admin, h((req, res) => this.sendForm(req, res, this.find(req))));
90
+ router.post('/docs/:name', a.sameOrigin, a.session, docForm, a.csrf, admin, h((req, res) => this.save(req, res, this.find(req))));
91
+ router.post('/docs/:name/delete', a.sameOrigin, a.session, a.form, a.csrf, admin, h((req, res) => this.delete(req, res)));
92
+ router.get('/docs/:name/history', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendHistory(req, res)));
93
+ router.get('/docs/:name/history/:entry', a.session, a.csrfIfLoggedIn, h((req, res) => this.sendVersion(req, res)));
94
+ }
95
+
96
+ find(req) {
97
+ const name = str(req.params.name);
98
+ return NAME_RE.test(name) ? this.store.documentByName(name) : null;
99
+ }
100
+
101
+ /**
102
+ * May this request read the document - or a past version of it (a log entry, which has
103
+ * its own public flag)? Public: anyone; else a logged in user
104
+ */
105
+ canRead(req, doc) {
106
+ return !!doc.public || !!this.auth.currentUser(req);
107
+ }
108
+
109
+ wantsHtml(req) {
110
+ return !!(req.headers.accept && req.headers.accept.includes('text/html'));
111
+ }
112
+
113
+ // ---- reading ----
114
+
115
+ sendList(req, res) {
116
+ const base = escape(req.baseUrl || '');
117
+ const user = this.auth.currentUser(req);
118
+ const docs = this.store.documents().filter(d => d.public || user);
119
+ let h = '';
120
+ if (user && user.isAdmin) {
121
+ h += `<p style="float: right"><a class="btn btn-primary btn-sm" href="${base}/docs/new">New document</a></p>`;
122
+ }
123
+ h += '<p>Documents this registry publishes: configuration and policy for its ecosystem. ' +
124
+ (user ? '' : 'Documents that are not public are shown to logged in users.') + '</p>';
125
+ h += '<table class="grid"><thead><tr><th>Name</th><th>Type</th><th>Visibility</th><th>Last edited</th><th>By</th><th>Size</th></tr></thead><tbody>';
126
+ for (const d of docs) {
127
+ h += `<tr><td><a href="${base}/docs/${encodeURIComponent(d.name)}">${escape(d.name)}</a></td><td>${escape(d.type)}</td>` +
128
+ `<td>${d.public ? 'public' : 'logged in users'}</td><td>${escape(d.updated_at.substring(0, 16).replace('T', ' '))}</td>` +
129
+ `<td>${escape(d.updated_by)}</td><td>${d.size}</td></tr>`;
130
+ }
131
+ if (docs.length === 0) {
132
+ h += '<tr><td colspan="6"><i>no documents</i></td></tr>';
133
+ }
134
+ h += '</tbody></table>';
135
+ this.auth.renderPage(req, res, 'Documents', h, 200);
136
+ }
137
+
138
+ sendDocument(req, res) {
139
+ const doc = this.find(req);
140
+ if (!doc) {
141
+ return this.notFound(req, res);
142
+ }
143
+ if (!this.canRead(req, doc)) {
144
+ return this.denied(req, res);
145
+ }
146
+ if (!this.wantsHtml(req)) {
147
+ res.setHeader('Content-Type', TYPES[doc.type] || 'text/plain; charset=utf-8');
148
+ res.setHeader('Last-Modified', new Date(doc.updated_at).toUTCString());
149
+ res.setHeader('Cache-Control', doc.public ? 'public, max-age=60' : 'private, no-store');
150
+ return res.send(doc.content);
151
+ }
152
+ const base = escape(req.baseUrl || '');
153
+ const user = this.auth.currentUser(req);
154
+ let h = `<p class="text-muted"><small>${escape(doc.type === 'md' ? 'Markdown' : 'JSON')} &middot; ` +
155
+ `${doc.public ? 'public' : 'logged in users'} &middot; last edited ${escape(doc.updated_at.substring(0, 16).replace('T', ' '))} by ${escape(doc.updated_by)} &middot; ` +
156
+ `<a href="${base}/docs/${encodeURIComponent(doc.name)}/history">history</a>`;
157
+ if (user && user.isAdmin) {
158
+ h += ` &middot; <a href="${base}/docs/${encodeURIComponent(doc.name)}/edit">edit</a>`;
159
+ }
160
+ h += '</small></p>';
161
+ h += this.renderContent(doc);
162
+ this.auth.renderPage(req, res, doc.name, h, 200);
163
+ }
164
+
165
+ renderContent(doc) {
166
+ if (doc.type === 'json') {
167
+ const pretty = prettyJson(doc.content);
168
+ return `<pre class="json">${escape(pretty === null ? doc.content : pretty)}</pre>`;
169
+ }
170
+ return `<div class="document">${renderMarkdown(doc.content)}</div>`;
171
+ }
172
+
173
+ sendHistory(req, res) {
174
+ const doc = this.find(req);
175
+ if (!doc) {
176
+ return this.notFound(req, res);
177
+ }
178
+ if (!this.canRead(req, doc)) {
179
+ return this.denied(req, res);
180
+ }
181
+ const base = escape(req.baseUrl || '');
182
+ const url = `${base}/docs/${encodeURIComponent(doc.name)}`;
183
+ let h = `<p><a href="${url}">&laquo; ${escape(doc.name)}</a></p>`;
184
+ h += '<table class="grid"><thead><tr><th>When</th><th>Who</th><th>Action</th><th>Name</th><th>Type</th><th>Visibility</th><th>Size</th></tr></thead><tbody>';
185
+ // the versions this request may read: a version from when the document was private
186
+ // stays private, whatever the document is now
187
+ const versions = this.store.documentLog(doc.id).filter(e => this.canRead(req, e));
188
+ for (const e of versions) {
189
+ h += `<tr><td><a href="${url}/history/${e.id}">${escape(e.at.substring(0, 19).replace('T', ' '))}</a></td><td>${escape(e.by)}</td>` +
190
+ `<td>${escape(e.action)}</td><td>${escape(e.name)}</td><td>${escape(e.type)}</td><td>${e.public ? 'public' : 'logged in users'}</td><td>${e.size}</td></tr>`;
191
+ }
192
+ h += '</tbody></table>';
193
+ this.auth.renderPage(req, res, `History of ${doc.name}`, h, 200);
194
+ }
195
+
196
+ sendVersion(req, res) {
197
+ const doc = this.find(req);
198
+ if (!doc) {
199
+ return this.notFound(req, res);
200
+ }
201
+ if (!this.canRead(req, doc)) {
202
+ return this.denied(req, res);
203
+ }
204
+ const entry = this.store.documentLogEntry(doc.id, Number(req.params.entry));
205
+ if (!entry) {
206
+ return this.notFound(req, res);
207
+ }
208
+ if (!this.canRead(req, entry)) {
209
+ return this.denied(req, res);
210
+ }
211
+ const base = escape(req.baseUrl || '');
212
+ let h = `<p><a href="${base}/docs/${encodeURIComponent(doc.name)}/history">&laquo; history</a></p>`;
213
+ h += `<div class="alert alert-info">The document as ${escape(entry.action)} at ${escape(entry.at.substring(0, 19).replace('T', ' '))} by ${escape(entry.by)}` +
214
+ (entry.name !== doc.name ? `, then named <code>${escape(entry.name)}</code>` : '') + '.</div>';
215
+ h += this.renderContent(entry);
216
+ this.auth.renderPage(req, res, `${doc.name} at ${entry.at.substring(0, 16).replace('T', ' ')}`, h, 200);
217
+ }
218
+
219
+ notFound(req, res) {
220
+ if (this.wantsHtml(req)) {
221
+ return this.auth.sendError(req, res, 404, 'No such document');
222
+ }
223
+ res.status(404).json({ error: 'not-found', message: 'No such document' });
224
+ }
225
+
226
+ // a document that exists but is not public looks the same as one that doesn't, to a
227
+ // stranger: a browser is sent to log in, anything else gets 404
228
+ denied(req, res) {
229
+ if (this.wantsHtml(req)) {
230
+ return res.redirect(`${req.baseUrl || ''}/login`);
231
+ }
232
+ res.status(404).json({ error: 'not-found', message: 'No such document' });
233
+ }
234
+
235
+ // ---- editing ----
236
+
237
+ sendForm(req, res, doc, values, error) {
238
+ if (req.params.name && !doc) {
239
+ return this.notFound(req, res);
240
+ }
241
+ const base = escape(req.baseUrl || '');
242
+ const v = values || (doc ? { name: doc.name, type: doc.type, public: doc.public, content: doc.content } : { name: '', type: 'md', public: false, content: '' });
243
+ const action = doc ? `${base}/docs/${encodeURIComponent(doc.name)}` : `${base}/docs`;
244
+ let h = error ? `<div class="alert alert-danger">${escape(error)}</div>` : '';
245
+ h += `<form method="post" action="${action}"><input type="hidden" name="_csrf" value="${escape(res.locals._csrf || '')}"/>` +
246
+ '<div class="row g-3" style="max-width: 900px">' +
247
+ `<div class="col-md-5"><label class="form-label" for="d-name">Name</label><input class="form-control" id="d-name" name="name" value="${escape(v.name)}" required maxlength="100" pattern="[A-Za-z0-9][A-Za-z0-9._-]*"/>` +
248
+ '<div class="form-text">Letters, digits, dots, hyphens and underscores; it is the document\'s address</div></div>' +
249
+ '<div class="col-md-3"><label class="form-label" for="d-type">Type</label><select class="form-select" id="d-type" name="type">' +
250
+ `<option value="md"${v.type === 'md' ? ' selected' : ''}>Markdown</option><option value="json"${v.type === 'json' ? ' selected' : ''}>JSON</option></select></div>` +
251
+ `<div class="col-md-4"><label class="form-label">&nbsp;</label><div><label><input type="checkbox" name="public" value="1"${v.public ? ' checked' : ''}/> Public (anyone can read it)</label></div></div>` +
252
+ `<div class="col-12"><label class="form-label" for="d-content">Content</label><textarea class="form-control" id="d-content" name="content" rows="28" style="font-family: monospace" spellcheck="false">${escape(v.content)}</textarea></div>` +
253
+ `<div class="col-12"><button type="submit" class="btn btn-primary">${doc ? 'Save' : 'Create'}</button> ` +
254
+ `<a class="btn btn-secondary" href="${doc ? action : base + '/docs'}">Cancel</a></div></div></form>`;
255
+ if (doc) {
256
+ h += `<form method="post" action="${action}/delete" style="margin-top: 2em" onsubmit="return confirm('Delete this document?')">` +
257
+ `<input type="hidden" name="_csrf" value="${escape(res.locals._csrf || '')}"/><button type="submit" class="btn btn-outline-danger btn-sm">Delete document</button></form>`;
258
+ }
259
+ this.auth.renderPage(req, res, doc ? `Edit ${doc.name}` : 'New document', h, error ? 400 : 200);
260
+ }
261
+
262
+ save(req, res, doc) {
263
+ if (req.params.name && !doc) {
264
+ return this.notFound(req, res);
265
+ }
266
+ const values = {
267
+ name: str(req.body.name).trim(),
268
+ type: str(req.body.type) === 'json' ? 'json' : 'md',
269
+ public: req.body.public === '1',
270
+ content: str(req.body.content).replace(/\r\n/g, '\n')
271
+ };
272
+ let error = null;
273
+ if (!NAME_RE.test(values.name) || RESERVED.has(values.name.toLowerCase())) {
274
+ error = 'The name must be 1-100 letters, digits, dots, hyphens or underscores, starting with a letter or digit';
275
+ } else if (values.content.length > MAX_SIZE) {
276
+ error = `The document is too large (${values.content.length} characters; the limit is ${MAX_SIZE})`;
277
+ } else if (values.type === 'json' && prettyJson(values.content) === null) {
278
+ error = 'The content is not valid JSON';
279
+ } else {
280
+ const existing = this.store.documentByName(values.name);
281
+ if (existing && (!doc || existing.id !== doc.id)) {
282
+ error = `There is already a document named '${values.name}'`;
283
+ }
284
+ }
285
+ if (error) {
286
+ return this.sendForm(req, res, doc, values, error);
287
+ }
288
+ if (doc) {
289
+ this.store.updateDocument(doc.id, values, req.user.login);
290
+ this.log.info(`Registry documents: '${values.name}' edited by ${req.user.login}`);
291
+ } else {
292
+ this.store.createDocument(values, req.user.login);
293
+ this.log.info(`Registry documents: '${values.name}' created by ${req.user.login}`);
294
+ }
295
+ res.redirect(`${req.baseUrl || ''}/docs/${encodeURIComponent(values.name)}`);
296
+ }
297
+
298
+ delete(req, res) {
299
+ const doc = this.find(req);
300
+ if (!doc) {
301
+ return this.notFound(req, res);
302
+ }
303
+ this.store.deleteDocument(doc.id, req.user.login);
304
+ this.log.info(`Registry documents: '${doc.name}' deleted by ${req.user.login}`);
305
+ res.redirect(`${req.baseUrl || ''}/docs`);
306
+ }
307
+ }
308
+
309
+ module.exports = { RegistryDocuments, renderMarkdown, NAME_RE };
@@ -34,6 +34,14 @@ info:
34
34
 
35
35
  **Parameters are read leniently**: unknown parameters are ignored, and a repeated
36
36
  parameter takes its first value.
37
+
38
+ **Authentication.** A registry may be configured to require a token on the JSON API
39
+ (`modules.registry.security`; the HTML pages and this description stay open). Send it
40
+ as `Authorization: Bearer {token}` or `Api-Key: {token}`. Depending on the registry's
41
+ configuration the token is one the administrator set, one issued to a user on the
42
+ registry's pages, or a JWT from the enterprise's OpenID Connect provider. Without a
43
+ valid token the API answers `401` with a `WWW-Authenticate: Bearer` header. An open
44
+ registry ignores any token sent.
37
45
  license:
38
46
  name: BSD-3-Clause
39
47
  url: https://opensource.org/license/bsd-3-clause
@@ -96,6 +104,7 @@ paths:
96
104
  text/html:
97
105
  schema: { type: string }
98
106
  '400': { $ref: '#/components/responses/BadRequest' }
107
+ '401': { $ref: '#/components/responses/Unauthorized' }
99
108
  '500': { $ref: '#/components/responses/ServerError' }
100
109
 
101
110
  /resolve:
@@ -171,8 +180,20 @@ paths:
171
180
  content:
172
181
  application/json:
173
182
  schema: { $ref: '#/components/schemas/Error' }
183
+ '401': { $ref: '#/components/responses/Unauthorized' }
174
184
 
175
185
  components:
186
+ securitySchemes:
187
+ bearer:
188
+ type: http
189
+ scheme: bearer
190
+ description: "The token - the registry's own, or an OIDC JWT - as `Authorization: Bearer {token}`"
191
+ apiKey:
192
+ type: apiKey
193
+ in: header
194
+ name: Api-Key
195
+ description: "The same token as `Api-Key: {token}` (what the HL7 Java tooling sends for an `apikey` server in fhir-settings.json)"
196
+
176
197
  parameters:
177
198
  fhirVersionOptional:
178
199
  name: fhirVersion
@@ -196,6 +217,15 @@ components:
196
217
  content:
197
218
  application/json:
198
219
  schema: { $ref: '#/components/schemas/Error' }
220
+ Unauthorized:
221
+ description: "The registry requires a token, and the request carried none or an invalid one. `WWW-Authenticate` names the scheme."
222
+ headers:
223
+ WWW-Authenticate:
224
+ schema: { type: string }
225
+ example: Bearer realm="tx-reg"
226
+ content:
227
+ application/json:
228
+ schema: { $ref: '#/components/schemas/Error' }
199
229
  ServerError:
200
230
  description: The server failed to process the request
201
231
  content: