jskos-server 2.4.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 (112) hide show
  1. package/.dockerignore +20 -0
  2. package/.editorconfig +9 -0
  3. package/.github/workflows/docker.yml +59 -0
  4. package/.github/workflows/gh-pages.yml +23 -0
  5. package/.github/workflows/gh-release.yml +19 -0
  6. package/.github/workflows/test.yml +39 -0
  7. package/.husky/pre-commit +1 -0
  8. package/CHANGELOG.md +18 -0
  9. package/LICENSE +21 -0
  10. package/README.md +2710 -0
  11. package/bin/extra.js +81 -0
  12. package/bin/import.js +438 -0
  13. package/bin/mongodb.js +21 -0
  14. package/bin/reset.js +257 -0
  15. package/bin/upgrade.js +34 -0
  16. package/config/config.default.json +88 -0
  17. package/config/config.schema.json +877 -0
  18. package/config/config.test.json +107 -0
  19. package/config/index.js +77 -0
  20. package/config/setup.js +212 -0
  21. package/depdendencies.png +0 -0
  22. package/docker/.env +1 -0
  23. package/docker/Dockerfile +20 -0
  24. package/docker/README.md +175 -0
  25. package/docker/docker-compose.yml +29 -0
  26. package/docker/docker-entrypoint.sh +8 -0
  27. package/docker/mongo-initdb.d/mongo_setup.sh +22 -0
  28. package/ecosystem.example.json +7 -0
  29. package/errors/index.js +94 -0
  30. package/eslint.config.js +17 -0
  31. package/index.js +10 -0
  32. package/models/annotations.js +13 -0
  33. package/models/concepts.js +12 -0
  34. package/models/concordances.js +12 -0
  35. package/models/index.js +33 -0
  36. package/models/mappings.js +20 -0
  37. package/models/meta.js +21 -0
  38. package/models/registries.js +12 -0
  39. package/models/schemes.js +12 -0
  40. package/package.json +91 -0
  41. package/routes/annotations.js +83 -0
  42. package/routes/common.js +86 -0
  43. package/routes/concepts.js +64 -0
  44. package/routes/concordances.js +86 -0
  45. package/routes/data.js +19 -0
  46. package/routes/mappings.js +108 -0
  47. package/routes/registries.js +24 -0
  48. package/routes/schemes.js +72 -0
  49. package/routes/validate.js +37 -0
  50. package/server.js +190 -0
  51. package/services/abstract.js +328 -0
  52. package/services/annotations.js +237 -0
  53. package/services/concepts.js +459 -0
  54. package/services/concordances.js +264 -0
  55. package/services/data.js +30 -0
  56. package/services/index.js +34 -0
  57. package/services/mappings.js +978 -0
  58. package/services/registries.js +319 -0
  59. package/services/schemes.js +318 -0
  60. package/services/validate.js +39 -0
  61. package/status.schema.json +145 -0
  62. package/test/abstract-service.js +36 -0
  63. package/test/annotations/annotation.json +13 -0
  64. package/test/api.js +2481 -0
  65. package/test/chai.js +14 -0
  66. package/test/changes.js +179 -0
  67. package/test/concepts/conceptNoFileEnding +4 -0
  68. package/test/concepts/concepts-ddc-6-60-61-62.json +123 -0
  69. package/test/concordances/concordances.ndjson +2 -0
  70. package/test/config.js +26 -0
  71. package/test/configs/complex-config.json +90 -0
  72. package/test/configs/empty-object.json +1 -0
  73. package/test/configs/fail-array.json +1 -0
  74. package/test/configs/fail-empty.json +0 -0
  75. package/test/configs/fail-mapping-only-props1.json +5 -0
  76. package/test/configs/fail-mapping-only-props2.json +5 -0
  77. package/test/configs/fail-mapping-only-props3.json +5 -0
  78. package/test/configs/fail-mapping-only-props4.json +5 -0
  79. package/test/configs/fail-nonexisting-prop.json +3 -0
  80. package/test/configs/fail-port-string.json +3 -0
  81. package/test/configs/fail-registry-types.json +16 -0
  82. package/test/configs/registry-types.json +16 -0
  83. package/test/data-write.js +784 -0
  84. package/test/eslint.js +22 -0
  85. package/test/import-reset.js +287 -0
  86. package/test/infer-mappings.js +340 -0
  87. package/test/ipcheck.js +287 -0
  88. package/test/mappings/README.md +1 -0
  89. package/test/mappings/ddc-gnd-1.mapping.json +33 -0
  90. package/test/mappings/ddc-gnd-2.mapping.json +67 -0
  91. package/test/mappings/mapping-ddc-gnd-noScheme.json +145 -0
  92. package/test/mappings/mapping-ddc-gnd.json +175 -0
  93. package/test/mappings/mappings-ddc.json +214 -0
  94. package/test/registries/registries.ndjson +2 -0
  95. package/test/services.js +557 -0
  96. package/test/terminologies/terminologies.json +94 -0
  97. package/test/test-utils.js +182 -0
  98. package/test/utils.js +425 -0
  99. package/test/validate.js +226 -0
  100. package/utils/adjust.js +206 -0
  101. package/utils/auth.js +154 -0
  102. package/utils/changes.js +88 -0
  103. package/utils/db.js +106 -0
  104. package/utils/ipcheck.js +76 -0
  105. package/utils/middleware.js +636 -0
  106. package/utils/searchHelper.js +153 -0
  107. package/utils/status.js +77 -0
  108. package/utils/users.js +7 -0
  109. package/utils/utils.js +114 -0
  110. package/utils/uuid.js +6 -0
  111. package/utils/version.js +324 -0
  112. package/views/base.ejs +172 -0
@@ -0,0 +1,226 @@
1
+ // Tests for /validate endpoints
2
+
3
+ import chai from "./chai.js"
4
+ import * as server from "../server.js"
5
+ import { globSync } from "glob"
6
+ import fs from "node:fs"
7
+ import assert from "node:assert"
8
+ import { assertMongoDB, dropDatabaseBeforeAndAfter, setupInMemoryMongo, createCollectionsAndIndexes, teardownInMemoryMongo } from "./test-utils.js"
9
+
10
+ let types = ["resource", "item", "concept", "scheme", "mapping", "concordance", "registry", "distributions", "occurrence", "bundle", "annotation"]
11
+ let examples = {}
12
+
13
+ // Import local examples
14
+ for (let type of types) {
15
+ examples[type] = []
16
+ for (let expected of [true, false]) {
17
+ let files = globSync(`./node_modules/jskos-validate/examples/${type}/${expected ? "pass" : "fail"}/*.json`)
18
+ for (let file of files) {
19
+ try {
20
+ let object = JSON.parse(fs.readFileSync(file))
21
+ examples[type].push({
22
+ object,
23
+ expected,
24
+ file,
25
+ })
26
+ } catch(error) {
27
+ console.log("Unable to parse file", file)
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ describe("Validation endpoint testing", () => {
34
+ before(async () => {
35
+ const mongoUri = await setupInMemoryMongo({ replSet: false })
36
+ process.env.MONGO_URI = mongoUri
37
+ await createCollectionsAndIndexes()
38
+ })
39
+
40
+ after(async () => {
41
+ // close server if you started one
42
+ await teardownInMemoryMongo()
43
+ })
44
+
45
+ // 🗑 Drop DB before *and* after every single `it()` in this file
46
+ dropDatabaseBeforeAndAfter()
47
+
48
+ // 🔌 Sanity‐check that mongoose really is connected
49
+ assertMongoDB()
50
+
51
+
52
+ describe("Validation endpoint: jskos-validate tests", () => {
53
+
54
+ // Validate difference object types
55
+ for (let type of types) {
56
+ let typePlural =
57
+ type.endsWith("y") && !/[aeiou]y$/i.test(type)
58
+ ? type.slice(0, -1) + "ies"
59
+ : type + "s"
60
+ describe(typePlural, () => {
61
+ for (let { object, expected, file } of examples[type]) {
62
+ it(`should validate ${typePlural} (${file})`, done => {
63
+ // Support for arrays of objects
64
+ let objects = [object]
65
+ if (Array.isArray(object)) {
66
+ objects = object
67
+ }
68
+ for (let object of objects) {
69
+ chai.request.execute(server.app)
70
+ .post("/validate")
71
+ .query({
72
+ type,
73
+ })
74
+ .send(object)
75
+ .end((error, res) => {
76
+ assert.equal(error, null)
77
+ res.should.have.status(201)
78
+ res.body.should.be.an("array")
79
+ if (expected) {
80
+ assert.strictEqual(res.body[0], true)
81
+ } else {
82
+ res.body[0].should.be.an("array")
83
+ assert.notEqual(res.body[0].length, 0)
84
+ }
85
+ done()
86
+ })
87
+ }
88
+ })
89
+ }
90
+ })
91
+ }
92
+ })
93
+
94
+ describe("Validation endpoint: parameters", () => {
95
+
96
+ it("should validate empty object without type parameter", done => {
97
+ chai.request.execute(server.app)
98
+ .post("/validate")
99
+ .send({})
100
+ .end((error, res) => {
101
+ assert.equal(error, null)
102
+ res.should.have.status(201)
103
+ res.body.should.be.an("array")
104
+ assert.equal(res.body[0], true)
105
+ done()
106
+ })
107
+ })
108
+
109
+ it("should fail validation for object with unknown parameter, but pass when `unknownFields` is set", done => {
110
+ const object = { abcdef: 1 }
111
+ chai.request.execute(server.app)
112
+ .post("/validate?type=concept")
113
+ .send(object)
114
+ .end((error, res) => {
115
+ assert.equal(error, null)
116
+ res.should.have.status(201)
117
+ res.body.should.be.an("array")
118
+ res.body[0].should.be.an("array")
119
+ assert.notEqual(res.body[0].length, 0)
120
+
121
+ // Set parameter
122
+ chai.request.execute(server.app)
123
+ .post("/validate")
124
+ .query({
125
+ unknownFields: true,
126
+ })
127
+ .send(object)
128
+ .end((error, res) => {
129
+ assert.equal(error, null)
130
+ res.should.have.status(201)
131
+ res.body.should.be.an("array")
132
+ assert.equal(res.body[0], true)
133
+ done()
134
+ })
135
+ })
136
+ })
137
+
138
+ it("should remember validated schemes if no type is set", done => {
139
+ const objects = [
140
+ {
141
+ type: ["http://www.w3.org/2004/02/skos/core#ConceptScheme"],
142
+ uri: "http://example.org/voc",
143
+ notationPattern: "[a-z]+",
144
+ },
145
+ {
146
+ type: ["http://www.w3.org/2004/02/skos/core#Concept"],
147
+ uri: "http://example.org/1",
148
+ notation: ["abc"],
149
+ inScheme: [{uri: "http://example.org/voc"}],
150
+ },
151
+ {
152
+ type: ["http://www.w3.org/2004/02/skos/core#Concept"],
153
+ uri: "http://example.org/2",
154
+ notation: ["123"],
155
+ inScheme: [{uri: "http://example.org/voc"}],
156
+ },
157
+ ]
158
+ chai.request.execute(server.app)
159
+ .post("/validate")
160
+ .send(objects)
161
+ .end((error, res) => {
162
+ assert.equal(error, null)
163
+ res.should.have.status(201)
164
+ res.body.should.be.an("array")
165
+ assert.equal(res.body.length, objects.length)
166
+ assert.equal(res.body[0], true)
167
+ assert.equal(res.body[1], true)
168
+ // Last concept should fail because notation does not match scheme's notationPattern
169
+ res.body[2].should.be.an("array")
170
+ assert.equal(res.body[2].length, 1)
171
+ done()
172
+ })
173
+ })
174
+
175
+ it("should POST a scheme, then use that scheme's notationPattern to validate objects when `knownSchemes` is set", done => {
176
+ const scheme = {
177
+ type: ["http://www.w3.org/2004/02/skos/core#ConceptScheme"],
178
+ uri: "http://example.org/voc",
179
+ notationPattern: "[a-z]+",
180
+ }
181
+ const objects = [
182
+ {
183
+ uri: "http://example.org/1",
184
+ notation: ["abc"],
185
+ inScheme: [{ uri: scheme.uri }],
186
+ },
187
+ {
188
+ uri: "http://example.org/2",
189
+ notation: ["123"],
190
+ inScheme: [{ uri: scheme.uri }],
191
+ },
192
+ ]
193
+ // 1. POST scheme
194
+ chai.request.execute(server.app)
195
+ .post("/voc")
196
+ .send(scheme)
197
+ .end((error, res) => {
198
+ assert.equal(error, null)
199
+ res.should.have.status(201)
200
+ res.body.should.be.an("object")
201
+ assert.equal(res.body.uri, scheme.uri)
202
+ // 2. Validate objects
203
+ chai.request.execute(server.app)
204
+ .post("/validate")
205
+ .query({
206
+ knownSchemes: true,
207
+ // type: concept is implied
208
+ })
209
+ .send(objects)
210
+ .end((error, res) => {
211
+ assert.equal(error, null)
212
+ res.should.have.status(201)
213
+ res.body.should.be.an("array")
214
+ // First concept should pass
215
+ assert.equal(res.body[0], true)
216
+ // Second concept should fail
217
+ res.body[1].should.be.an("array")
218
+ assert.notEqual(res.body[1].length, 0)
219
+ done()
220
+ })
221
+ })
222
+ })
223
+
224
+ })
225
+
226
+ })
@@ -0,0 +1,206 @@
1
+ import _ from "lodash"
2
+ import { AnnotationService } from "../services/annotations.js"
3
+ import { ConceptService } from "../services/concepts.js"
4
+
5
+ function createAdjuster(config) {
6
+ const services = {
7
+ annotations: new AnnotationService(config),
8
+ concepts: new ConceptService(config),
9
+ }
10
+
11
+ // Adjust data in req.data based on req.type (which is set by `addMiddlewareProperties`)
12
+ const adjust = async (req, res, next) => {
13
+ /**
14
+ * Skip adjustments if either:
15
+ * - there is no data
16
+ * - there is no data type (i.e. we don't know which adjustment method to use)
17
+ * - the request was a bulk operation
18
+ */
19
+ if (!req.data || !req.type || req.query.bulk) {
20
+ next()
21
+ }
22
+ req.data = await adjust.data({ req })
23
+ next()
24
+ }
25
+
26
+ // Wrapper around adjustments; `req` only has required property path `query.properties` if `data` and `type` are given.
27
+ adjust.data = async ({ req, data, type }) => {
28
+ data = data ?? req.data
29
+ type = type ?? req.type
30
+ // If data is still a mongoose object, convert it to plain object
31
+ if (Array.isArray(data) && data[0]?.toObject) {
32
+ data = data.map(item => item.toObject ? item.toObject() : item)
33
+ } else if (!Array.isArray(data) && data.toObject) {
34
+ data = data.toObject()
35
+ }
36
+ // Remove "s" from the end of type if it's not an array
37
+ if (!Array.isArray(data)) {
38
+ type = type.substring(0, type.length - 1)
39
+ }
40
+ if (adjust[type]) {
41
+ let addProperties = [], removeProperties = [], mode = 0 // mode 0 = add, mode 1 = remove
42
+ for (let prop of _.get(req, "query.properties", "").split(",")) {
43
+ if (prop.startsWith("*")) {
44
+ addProperties.push("narrower")
45
+ addProperties.push("ancestors")
46
+ addProperties.push("annotations")
47
+ continue
48
+ }
49
+ if (prop.startsWith("-")) {
50
+ mode = 1
51
+ prop = prop.slice(1)
52
+ } else if (prop.startsWith("+")) {
53
+ mode = 0
54
+ prop = prop.slice(1)
55
+ }
56
+ if (mode === 1) {
57
+ removeProperties.push(prop)
58
+ } else {
59
+ addProperties.push(prop)
60
+ // If a property is explicitly added after it was removed, it should not be removed anymore
61
+ removeProperties = removeProperties.filter(p => p !== prop)
62
+ }
63
+ }
64
+ addProperties = addProperties.filter(Boolean)
65
+ removeProperties = removeProperties.filter(Boolean)
66
+ // Adjust data with properties
67
+ data = await adjust[type](data, addProperties)
68
+ // Remove properties if necessary
69
+ const dataToAdjust = Array.isArray(data) ? data : [data]
70
+ removeProperties.forEach(property => {
71
+ dataToAdjust.filter(Boolean).forEach(entity => {
72
+ delete entity[property]
73
+ })
74
+ })
75
+ }
76
+ return data
77
+ }
78
+
79
+ // Add @context and type to annotations.
80
+ adjust.annotation = (annotation) => {
81
+ if (annotation) {
82
+ annotation["@context"] = "http://www.w3.org/ns/anno.jsonld"
83
+ annotation.type = "Annotation"
84
+ }
85
+ return annotation
86
+ }
87
+ adjust.annotations = annotations => {
88
+ return annotations.map(annotation => adjust.annotation(annotation))
89
+ }
90
+
91
+ // Add @context and type to concepts. Also load properties narrower, ancestors, and annotations if necessary.
92
+ adjust.concept = async (concept, properties = []) => {
93
+ if (concept) {
94
+ concept["@context"] = "https://gbv.github.io/jskos/context.json"
95
+ concept.type = concept.type || ["http://www.w3.org/2004/02/skos/core#Concept"]
96
+ // Add properties (narrower, ancestors)
97
+ for (let property of ["narrower", "ancestors"].filter(p => properties.includes(p))) {
98
+ concept[property] = await Promise.all((await services.concepts[`get${property.charAt(0).toUpperCase() + property.slice(1)}`]({ uri: concept.uri })).map(concept => adjust.concept(concept)))
99
+ }
100
+ // Add properties (annotations)
101
+ if (config.annotations && properties.includes("annotations") && concept.uri) {
102
+ concept.annotations = (await services.annotations.queryItems({ target: concept.uri })).map(annotation => adjust.annotation(annotation))
103
+ }
104
+ }
105
+ return concept
106
+ }
107
+ adjust.concepts = async (concepts, properties) => {
108
+ return await Promise.all(concepts.map(concept => adjust.concept(concept, properties)))
109
+ }
110
+
111
+ // Add @context to concordances.
112
+ adjust.concordance = (concordance) => {
113
+ if (concordance) {
114
+ concordance["@context"] = "https://gbv.github.io/jskos/context.json"
115
+ // Remove existing "distributions" array (except for external URLs)
116
+ concordance.distributions = (concordance.distributions || []).filter(dist => !dist.download || !dist.download.startsWith(config.baseUrl))
117
+ // Add distributions for JSKOS and CSV
118
+ concordance.distributions = [
119
+ {
120
+ download: `${config.baseUrl}mappings?partOf=${encodeURIComponent(concordance.uri)}&download=ndjson`,
121
+ format: "http://format.gbv.de/jskos",
122
+ mimetype: "application/x-ndjson; charset=utf-8",
123
+ },
124
+ {
125
+ download: `${config.baseUrl}mappings?partOf=${encodeURIComponent(concordance.uri)}&download=csv`,
126
+ mimetype: "text/csv; charset=utf-8",
127
+ },
128
+ ].concat(concordance.distributions)
129
+ }
130
+ return concordance
131
+ }
132
+ adjust.concordances = (concordances) => {
133
+ return concordances.map(concordance => adjust.concordance(concordance))
134
+ }
135
+
136
+ // Add @context to mappings. Also load annotations if necessary.
137
+ adjust.mapping = async (mapping, properties = []) => {
138
+ if (mapping) {
139
+ mapping["@context"] = "https://gbv.github.io/jskos/context.json"
140
+ // Add properties (annotations)
141
+ if (config.annotations && properties.includes("annotations") && mapping.uri) {
142
+ mapping.annotations = (await services.annotations.queryItems({ target: mapping.uri })).map(annotation => adjust.annotation(annotation))
143
+ }
144
+ }
145
+ return mapping
146
+ }
147
+ adjust.mappings = async (mappings, properties) => {
148
+ return await Promise.all(mappings.map(mapping => adjust.mapping(mapping, properties)))
149
+ }
150
+
151
+ // Add @context and type to schemes.
152
+ adjust.scheme = (scheme) => {
153
+ if (scheme) {
154
+ scheme["@context"] = "https://gbv.github.io/jskos/context.json"
155
+ scheme.type = scheme.type || ["http://www.w3.org/2004/02/skos/core#ConceptScheme"]
156
+ // Remove existing "distributions" array (except for external URLs)
157
+ scheme.distributions = (scheme.distributions || []).filter(dist => !dist.download || !dist.download.startsWith(config.baseUrl))
158
+ if (scheme.concepts && scheme.concepts.length) {
159
+ // If this instance contains concepts for this scheme, add distribution for it
160
+ scheme.distributions = [
161
+ {
162
+ download: `${config.baseUrl}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=ndjson`,
163
+ format: "http://format.gbv.de/jskos",
164
+ mimetype: "application/x-ndjson; charset=utf-8",
165
+ },
166
+ {
167
+ download: `${config.baseUrl}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=json`,
168
+ mimetype: "application/json; charset=utf-8",
169
+ },
170
+ ].concat(scheme.distributions)
171
+ // Also add `API` field if it does not exist
172
+ if (!scheme.API) {
173
+ scheme.API = [
174
+ {
175
+ type: "http://bartoc.org/api-type/jskos",
176
+ url: config.baseUrl,
177
+ },
178
+ ]
179
+ }
180
+ }
181
+ // Add distributions based on API field
182
+ (scheme.API || []).filter(api => api.type === "http://bartoc.org/api-type/jskos" && api.url !== config.baseUrl).forEach(api => {
183
+ scheme.distributions.push({
184
+ download: `${api.url}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=ndjson`,
185
+ format: "http://format.gbv.de/jskos",
186
+ mimetype: "application/x-ndjson; charset=utf-8",
187
+ })
188
+ scheme.distributions.push({
189
+ download: `${api.url}voc/concepts?uri=${encodeURIComponent(scheme.uri)}&download=json`,
190
+ mimetype: "application/json; charset=utf-8",
191
+ })
192
+ })
193
+ if (!scheme.distributions.length) {
194
+ delete scheme.distributions
195
+ }
196
+ }
197
+ return scheme
198
+ }
199
+ adjust.schemes = (schemes) => {
200
+ return schemes.map(scheme => adjust.scheme(scheme))
201
+ }
202
+
203
+ return adjust
204
+ }
205
+
206
+ export { createAdjuster }
package/utils/auth.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Module that prepares authentication middleware via Passport.
3
+ *
4
+ * Exports a function that return default or optional authentication.
5
+ * Optional authentication should be used if `auth` is set to `false` for a particular endpoint.
6
+ * For example: app.get("/mappings", useAuth(config.mappings.read.auth), (req, res) => { ... })
7
+ * req.user will cointain the user if authorized, otherwise stays undefined.
8
+ */
9
+
10
+ import _ from "lodash"
11
+ import { ForbiddenAccessError } from "../errors/index.js"
12
+
13
+ import config from "../config/index.js"
14
+
15
+ import passport from "passport"
16
+ import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"
17
+ import { Strategy as AnonymousStrategy } from "passport-anonymous"
18
+
19
+ passport.use(new AnonymousStrategy())
20
+
21
+ const actions = {
22
+ POST: "create",
23
+ PUT: "update",
24
+ PATCH: "update",
25
+ DELETE: "delete",
26
+ }
27
+
28
+ // Add some properties and methods related to authentication
29
+ // This middleware is added to both auth.main and auth.optional
30
+ const authPreparation = (req, res, next) => {
31
+ // Add action
32
+ req.action = actions[req.method] || "read"
33
+
34
+ // Add user URIs and providers
35
+ req.uris = [req.user?.uri].concat(Object.values(req.user?.identities || {}).map(id => id.uri)).filter(Boolean)
36
+ req.userProviders = Object.keys(req.user?.identities || {})
37
+
38
+ // Add isAuthorizedFor method
39
+ req.isAuthorizedFor = function ({ type, action, whitelist, providers, throwError = false } = {}) {
40
+ type = type ?? this.type
41
+ action = action ?? this.action
42
+
43
+ if (!config[type]?.[action]?.auth && type !== "checkAuth") {
44
+ // If action does not require auth at all, the request is authorized
45
+ return true
46
+ } else if (!this.user) {
47
+ // If action requires auth, but user isn't logged in, the request is not authorized
48
+ // For routes using the `auth.main` middleware, this is called early, but not for routes with `auth.optional`.
49
+ if (throwError) {
50
+ throw new ForbiddenAccessError("Access forbidden. Could not authenticate via JWT.")
51
+ }
52
+ return false
53
+ }
54
+
55
+ if (whitelist === undefined) {
56
+ whitelist = expandWhiteList(config[type]?.[action]?.identities, config.identityGroups)
57
+ }
58
+ providers = providers ?? config[type]?.[action]?.identityProviders
59
+
60
+ if (whitelist && _.intersection(whitelist, this.uris).length == 0) {
61
+ if (throwError) {
62
+ throw new ForbiddenAccessError("Access forbidden. A whitelist is in place, but authenticated user is not on the whitelist.")
63
+ }
64
+ return false
65
+ }
66
+
67
+ if (providers && _.intersection(providers, this.userProviders).length == 0) {
68
+ if (throwError) {
69
+ throw new ForbiddenAccessError("Access forbidden, missing identity provider. One of the following providers is necessary: " + providers.join(", "))
70
+ }
71
+ return false
72
+ }
73
+
74
+ return true
75
+ }
76
+
77
+ next()
78
+ }
79
+
80
+ function expandWhiteList(whitelist, identityGroups) {
81
+ if (whitelist && identityGroups) {
82
+ return whitelist.map(uri => uri in identityGroups ? identityGroups[uri].identities : uri).flat()
83
+ }
84
+ return whitelist
85
+ }
86
+
87
+ let optional = []
88
+ let auth = (req, res, next) => {
89
+ next(new ForbiddenAccessError("Access forbidden. No authentication configured."))
90
+ }
91
+
92
+ // Prepare authorization via JWT
93
+ if (config.auth.algorithm && config.auth.key) {
94
+ const opts = {
95
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
96
+ secretOrKey: config.auth.key,
97
+ algorithms: [config.auth.algorithm],
98
+ }
99
+ try {
100
+ const strategy = new JwtStrategy(opts, (jwt_payload, done) => done(null, jwt_payload.user))
101
+ const strategyName = "jwt" // TODO: derive strategyName from opts
102
+
103
+ passport.use(strategyName, strategy)
104
+ optional.push(strategyName)
105
+
106
+ // Use like this: app.get("/secureEndpoint", auth, (req, res) => { ... })
107
+ // res.user will contain the current authorized user.
108
+ auth = (req, res, next) => {
109
+ passport.authenticate(strategyName, { session: false }, (error, user) => {
110
+ if (error || !user) {
111
+ return next(new ForbiddenAccessError("Access forbidden. Could not authenticate via JWT."))
112
+ }
113
+ req.user = user
114
+ return next()
115
+ })(req, res, next)
116
+ }
117
+ } catch(error) {
118
+ config.error("Error setting up JWT authentication")
119
+ }
120
+ } else {
121
+ config.warn("Note: To provide authentication via JWT, please add `auth.algorithm` and `auth.key` to the configuration file!")
122
+ }
123
+
124
+ // Configure identities whitelists and identity providers
125
+ const authAuthorize = (req, res, next) => {
126
+ let whitelist, providers, type, action
127
+
128
+ if (req.type == "checkAuth") {
129
+ ({ type, action } = req.query || {})
130
+ if (type && action && config[type][action]) {
131
+ whitelist = config[type][action].identities
132
+ providers = config[type][action].identityProviders
133
+ } else {
134
+ whitelist = config.identities
135
+ providers = config.identityProviders
136
+ }
137
+ }
138
+
139
+ whitelist = expandWhiteList(whitelist, config.identityGroups)
140
+
141
+ try {
142
+ req.isAuthorizedFor({ type, action, whitelist, providers, throwError: true })
143
+ next()
144
+ } catch (error) {
145
+ next(error)
146
+ }
147
+ }
148
+
149
+ // Also use anonymous strategy for endpoints that can be used authenticated or not authenticated
150
+ optional.push("anonymous")
151
+
152
+ export const useAuth = required => required
153
+ ? [auth, authPreparation, authAuthorize]
154
+ : [passport.authenticate(optional, { session: false }), authPreparation]
@@ -0,0 +1,88 @@
1
+ // utils/changes.js
2
+ import * as jskos from "jskos-tools"
3
+ import { connection, waitForReplicaSet } from "./db.js"
4
+ import { ConfigurationError } from "../errors/index.js"
5
+
6
+ export const collections = {
7
+ voc: "terminologies",
8
+ concepts: "concepts",
9
+ mappings: "mappings",
10
+ concordances: "concordances",
11
+ annotations: "annotations",
12
+ registries: "registries",
13
+ }
14
+
15
+ export const operationTypeMap = { insert: "create", update: "update", delete: "delete" }
16
+ export let isChangesApiAvailable = false
17
+
18
+ export default function registerChangesRoutes(app) {
19
+ for (const [route, collName] of Object.entries(collections)) {
20
+ app.ws(`/${route}/changes`, (ws) => {
21
+ const stream = connection.db
22
+ .collection(collName)
23
+ .watch([], { fullDocument: "updateLookup" })
24
+
25
+ stream.on("change", change => {
26
+ const { operationType, documentKey, fullDocument } = change
27
+ const evt = {
28
+ objectType: jskos.guessObjectType(fullDocument),
29
+ type: operationTypeMap[operationType],
30
+ id: documentKey._id,
31
+ timestamp: clusterTimeToISOString(change.clusterTime),
32
+ ...(operationType !== "delete" && { document: fullDocument }),
33
+ }
34
+ ws.send(JSON.stringify(evt))
35
+ })
36
+
37
+ stream.on("error", (err) => {
38
+ if (err?.name === "MongoClientClosedError") {
39
+ return
40
+ }
41
+ console.error(
42
+ `[changes] ChangeStream error on "${route}" (${collName}):`,
43
+ err,
44
+ )
45
+ })
46
+
47
+ ws.on("close", () => stream.close())
48
+ })
49
+ }
50
+ }
51
+
52
+ // After DB connection, conditionally enable change-stream routes
53
+ export async function setupChangesApi(app, config) {
54
+ if (!config.changes) {
55
+ console.log("Changes API is disabled by configuration.")
56
+ return
57
+ }
58
+
59
+ if (!await waitForReplicaSet(config.changes)) {
60
+ throw new ConfigurationError(
61
+ "Changes API enabled, but MongoDB replica set did not initialize in time.",
62
+ )
63
+ }
64
+
65
+ // Register WebSocket change-stream endpoints
66
+ registerChangesRoutes(app)
67
+ isChangesApiAvailable = true
68
+ console.log("Changes API enabled: replica set confirmed, endpoints are registered.")
69
+ }
70
+
71
+ /**
72
+ * Converts a MongoDB cluster time object to an ISO 8601 string.
73
+ *
74
+ * @param {{ getHighBits: () => number } | null} clusterTime - The cluster time object.
75
+ * @returns {string | null} ISO timestamp derived from the cluster time, or null if input is invalid.
76
+ */
77
+ function clusterTimeToISOString(clusterTime) {
78
+ if ((!clusterTime) || (typeof clusterTime.getHighBits !== "function")) {
79
+ return null
80
+ }
81
+
82
+ const seconds = clusterTime.getHighBits()
83
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
84
+ return null
85
+ }
86
+
87
+ return new Date(seconds * 1000).toISOString()
88
+ }