From d8f3675afd41158a670f16b6139c3b6a24b687e0 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:23:07 -0500 Subject: [PATCH 01/25] First pass at fixing Content-Type header processing. --- __tests__/contentType.test.js | 142 ++++++++++++++++++++++++++++++++++ app.js | 36 ++++++++- routes/delete.js | 2 +- 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 __tests__/contentType.test.js diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js new file mode 100644 index 0000000..72a4239 --- /dev/null +++ b/__tests__/contentType.test.js @@ -0,0 +1,142 @@ +/** + * Content-Type Header Validation Tests + * + * Verifies that TinyPEN properly validates Content-Type headers + * on incoming requests, returning 400 for missing headers, + * 415 for unsupported types, and passing through valid types. + */ + +import request from "supertest" +import { jest } from "@jest/globals" +import app from "../app.js" + +beforeEach(() => { + checkAccessToken = jest.fn((req, res, next) => next()) + isTokenExpired = jest.fn(() => false) +}) + +describe("Content-Type validation on POST routes", () => { + + it("accepts application/json __contentType __core", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "application/json") + .send(JSON.stringify({ type: "test" })) + expect(res.status).not.toBe(415) + expect(res.status).not.toBe(400) + }) + + it("accepts application/ld+json __contentType __core", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "application/ld+json") + .send(JSON.stringify({ type: "test" })) + expect(res.status).not.toBe(415) + expect(res.status).not.toBe(400) + }) + + it("accepts application/json with charset parameter __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "application/json; charset=utf-8") + .send(JSON.stringify({ type: "test" })) + expect(res.status).not.toBe(415) + expect(res.status).not.toBe(400) + }) + + it("accepts Content-Type case-insensitively __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "Application/JSON") + .send(JSON.stringify({ type: "test" })) + expect(res.status).not.toBe(415) + expect(res.status).not.toBe(400) + }) + + it("returns 400 for missing Content-Type on POST __contentType __core", async () => { + const res = await request(app) + .post("/query") + .unset("Content-Type") + .send(Buffer.from(JSON.stringify({ type: "test" }))) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Missing Content-Type/i) + }) + + it("returns 415 for text/plain __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "text/plain") + .send("some text") + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) + }) + + it("returns 415 for text/html __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "text/html") + .send("") + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) + }) + + it("returns 415 for multipart/form-data __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "multipart/form-data") + .send("--boundary--") + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) + }) + + it("returns 400 for duplicate/multi-value Content-Type headers __contentType __core", async () => { + // Node.js joins duplicate headers with ", " so this simulates that + const res = await request(app) + .post("/query") + .set("Content-Type", "application/json, text/html") + .send(JSON.stringify({ type: "test" })) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Multiple Content-Type/i) + }) +}) + +describe("Content-Type validation skips non-body methods", () => { + + it("does not reject GET requests without Content-Type __contentType", async () => { + const res = await request(app).get("/query") + // GET /query should 404 (no GET handler), not 400/415 + expect(res.status).not.toBe(400) + expect(res.status).not.toBe(415) + }) + + it("does not reject OPTIONS requests __contentType", async () => { + const res = await request(app).options("/query") + expect(res.status).not.toBe(400) + expect(res.status).not.toBe(415) + }) + + it("does not reject HEAD requests __contentType", async () => { + const res = await request(app).head("/query") + expect(res.status).not.toBe(400) + expect(res.status).not.toBe(415) + }) +}) + +describe("Content-Type validation on PUT routes", () => { + + it("returns 415 for text/plain on PUT /update __contentType", async () => { + const res = await request(app) + .put("/update") + .set("Content-Type", "text/plain") + .send("some text") + expect(res.status).toBe(415) + }) + + it("returns 400 for missing Content-Type on PUT /overwrite __contentType", async () => { + const res = await request(app) + .put("/overwrite") + .unset("Content-Type") + .send(Buffer.from(JSON.stringify({ id: "test" }))) + expect(res.status).toBe(400) + }) +}) diff --git a/app.js b/app.js index 569c85b..419a84e 100644 --- a/app.js +++ b/app.js @@ -14,8 +14,7 @@ import overwriteRouter from "./routes/overwrite.js" import cors from "cors" let app = express() -app.use(express.json()) -app.use(express.text()) +app.use(express.json({ type: ['application/json', 'application/ld+json'] })) if(process.env.OPEN_API_CORS !== "false") { // This enables CORS for all requests. We may want to update this in the future and only apply to some routes. app.use(cors()) @@ -95,6 +94,39 @@ if(corsAllowedOrigins !== "*") { }) } +/** + * Validate Content-Type header on requests that carry a body. + * Rejects missing Content-Type with 400 and unsupported types with 415. + * Accepts application/json and application/ld+json (with optional parameters like charset). + */ +const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] +const BODY_METHODS = ['POST', 'PUT', 'PATCH'] + +app.use(function validateContentType(req, res, next) { + if (!BODY_METHODS.includes(req.method)) return next() + + const rawContentType = req.headers['content-type'] + + if (!rawContentType) { + return res.status(400).send('Missing Content-Type header. Expected application/json or application/ld+json.') + } + + // Node.js/Express joins duplicate Content-Type headers with ", " + // A valid Content-Type should never contain a comma — reject multi-value headers + if (rawContentType.includes(',')) { + return res.status(400).send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') + } + + // Strip parameters (e.g., ";charset=utf-8") and normalize + const mediaType = rawContentType.split(';')[0].trim().toLowerCase() + + if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { + return res.status(415).send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) + } + + next() +}) + //New available usage without /app app.use('/query', queryRouter) app.use('/create', createRouter) diff --git a/routes/delete.js b/routes/delete.js index eb85f9e..c613a2d 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -17,7 +17,7 @@ router.delete('/', checkAccessToken, async (req, res, next) => { 'user-agent': 'TinyPen', 'Origin': process.env.ORIGIN, 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, - 'Content-Type' : "application/json" + 'Content-Type' : "application/json;charset=utf-8" } } const deleteURL = `${process.env.RERUM_API_ADDR}delete` From cd98bc4ef3290d600acd228c67725cfe0f785f01 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:32:25 -0500 Subject: [PATCH 02/25] Changes while testing and reviewing --- app.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 419a84e..5620c13 100644 --- a/app.js +++ b/app.js @@ -108,20 +108,20 @@ app.use(function validateContentType(req, res, next) { const rawContentType = req.headers['content-type'] if (!rawContentType) { - return res.status(400).send('Missing Content-Type header. Expected application/json or application/ld+json.') + return res.status(400).type('text/plain').send('Missing Content-Type header. Expected application/json or application/ld+json.') } // Node.js/Express joins duplicate Content-Type headers with ", " // A valid Content-Type should never contain a comma — reject multi-value headers if (rawContentType.includes(',')) { - return res.status(400).send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') + return res.status(400).type('text/plain').send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') } // Strip parameters (e.g., ";charset=utf-8") and normalize const mediaType = rawContentType.split(';')[0].trim().toLowerCase() if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { - return res.status(415).send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) + return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) } next() From 909f6552d1b568c28f33ac820f143d04c6c3e33e Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 09:40:10 -0500 Subject: [PATCH 03/25] Changes while testing and reviewing --- __tests__/contentType.test.js | 37 +++++++++++++++++++++++++++++++++++ routes/delete.js | 14 +++++++++++++ 2 files changed, 51 insertions(+) diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js index 72a4239..e1ed435 100644 --- a/__tests__/contentType.test.js +++ b/__tests__/contentType.test.js @@ -140,3 +140,40 @@ describe("Content-Type validation on PUT routes", () => { expect(res.status).toBe(400) }) }) + +describe("Content-Type validation on body-based DELETE route", () => { + + it("returns 415 for text/plain on DELETE /delete __contentType", async () => { + const res = await request(app) + .delete("/delete") + .set("Content-Type", "text/plain") + .send("some text") + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) + }) + + it("returns 400 for missing Content-Type on DELETE /delete __contentType", async () => { + const res = await request(app) + .delete("/delete") + .unset("Content-Type") + .send(Buffer.from(JSON.stringify({ id: "test" }))) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Missing Content-Type/i) + }) + + it("returns 400 for duplicate Content-Type on DELETE /delete __contentType", async () => { + const res = await request(app) + .delete("/delete") + .set("Content-Type", "application/json, text/html") + .send(JSON.stringify({ id: "test" })) + expect(res.status).toBe(400) + expect(res.text).toMatch(/Multiple Content-Type/i) + }) + + it("does not reject DELETE /delete/:id without Content-Type __contentType", async () => { + const res = await request(app) + .delete("/delete/some-fake-id") + expect(res.status).not.toBe(400) + expect(res.status).not.toBe(415) + }) +}) diff --git a/routes/delete.js b/routes/delete.js index c613a2d..1830791 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -8,6 +8,20 @@ const router = express.Router() /* DELETE a delete to the thing. */ router.delete('/', checkAccessToken, async (req, res, next) => { try { + // Validate Content-Type since this route expects a JSON body. + // The global middleware only covers POST/PUT/PATCH — DELETE /:id has no body so we validate here. + const rawContentType = req.headers['content-type'] + if (!rawContentType) { + return res.status(400).type('text/plain').send('Missing Content-Type header. Expected application/json or application/ld+json.') + } + if (rawContentType.includes(',')) { + return res.status(400).type('text/plain').send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') + } + const mediaType = rawContentType.split(';')[0].trim().toLowerCase() + if (!['application/json', 'application/ld+json'].includes(mediaType)) { + return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) + } + const deleteBody = JSON.stringify(req.body) const deleteOptions = { From 132f03f5f48c9e7af09735fc95510cf39e48d526 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:01:18 -0500 Subject: [PATCH 04/25] Cleanup the DELETE stuff. DELETE requests don't do bodies. --- __tests__/contentType.test.js | 29 +------------------- routes/delete.js | 50 +++++------------------------------ 2 files changed, 7 insertions(+), 72 deletions(-) diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js index e1ed435..8ce6f0c 100644 --- a/__tests__/contentType.test.js +++ b/__tests__/contentType.test.js @@ -141,34 +141,7 @@ describe("Content-Type validation on PUT routes", () => { }) }) -describe("Content-Type validation on body-based DELETE route", () => { - - it("returns 415 for text/plain on DELETE /delete __contentType", async () => { - const res = await request(app) - .delete("/delete") - .set("Content-Type", "text/plain") - .send("some text") - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 400 for missing Content-Type on DELETE /delete __contentType", async () => { - const res = await request(app) - .delete("/delete") - .unset("Content-Type") - .send(Buffer.from(JSON.stringify({ id: "test" }))) - expect(res.status).toBe(400) - expect(res.text).toMatch(/Missing Content-Type/i) - }) - - it("returns 400 for duplicate Content-Type on DELETE /delete __contentType", async () => { - const res = await request(app) - .delete("/delete") - .set("Content-Type", "application/json, text/html") - .send(JSON.stringify({ id: "test" })) - expect(res.status).toBe(400) - expect(res.text).toMatch(/Multiple Content-Type/i) - }) +describe("Content-Type validation skips DELETE routes", () => { it("does not reject DELETE /delete/:id without Content-Type __contentType", async () => { const res = await request(app) diff --git a/routes/delete.js b/routes/delete.js index 1830791..c57cb1c 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -3,50 +3,12 @@ import checkAccessToken from "../tokens.js" const router = express.Router() -/* Legacy delete pattern w/body */ - -/* DELETE a delete to the thing. */ -router.delete('/', checkAccessToken, async (req, res, next) => { - try { - // Validate Content-Type since this route expects a JSON body. - // The global middleware only covers POST/PUT/PATCH — DELETE /:id has no body so we validate here. - const rawContentType = req.headers['content-type'] - if (!rawContentType) { - return res.status(400).type('text/plain').send('Missing Content-Type header. Expected application/json or application/ld+json.') - } - if (rawContentType.includes(',')) { - return res.status(400).type('text/plain').send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') - } - const mediaType = rawContentType.split(';')[0].trim().toLowerCase() - if (!['application/json', 'application/ld+json'].includes(mediaType)) { - return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) - } - - const deleteBody = JSON.stringify(req.body) - - const deleteOptions = { - method: 'DELETE', - body: deleteBody, - headers: { - 'user-agent': 'TinyPen', - 'Origin': process.env.ORIGIN, - 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}`, - 'Content-Type' : "application/json;charset=utf-8" - } - } - const deleteURL = `${process.env.RERUM_API_ADDR}delete` - const result = await fetch(deleteURL, deleteOptions).then(res => res.text()) - res.status(204) - res.send(result) - } - catch (err) { - console.log(err) - res.status(500).send(`Caught Error:${err}`) - } -}) - -/* DELETE a delete to the thing. */ -router.delete('/:id', async (req, res, next) => { +/** + * DELETE an object by ID via the RERUM API. + * @route DELETE /delete/:id + * @param {string} id - The RERUM object ID to delete + */ +router.delete('/:id', checkAccessToken, async (req, res, next) => { try { const deleteURL = `${process.env.RERUM_API_ADDR}delete/${req.params.id}` From 7bc4635f03129d1882ed61545648e06373e042f8 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:09:00 -0500 Subject: [PATCH 05/25] Cleanup the DELETE stuff. DELETE requests don't do bodies. --- routes/delete.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/routes/delete.js b/routes/delete.js index c57cb1c..405a99c 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -19,9 +19,8 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}` } } - const result = await fetch(deleteURL, deleteOptions).then(resp => resp.text()) - res.status(204) - res.send(result) + await fetch(deleteURL, deleteOptions) + res.status(204).end() } catch (err) { console.log(err) From 0367c492a6ccbf423291f1a6844b78063f8d7e82 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:14:30 -0500 Subject: [PATCH 06/25] Cleanup the DELETE stuff. DELETE requests don't do bodies. --- routes/create.js | 8 +++++++- routes/delete.js | 8 +++++++- routes/overwrite.js | 17 +++++++---------- routes/query.js | 8 +++++++- routes/update.js | 8 +++++++- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/routes/create.js b/routes/create.js index 9b37a48..4549e0a 100644 --- a/routes/create.js +++ b/routes/create.js @@ -23,7 +23,13 @@ router.post('/', checkAccessToken, async (req, res, next) => { } } const createURL = `${process.env.RERUM_API_ADDR}create` - const result = await fetch(createURL, createOptions).then(res => res.json()) + const rerumResponse = await fetch(createURL, createOptions) + if (!rerumResponse.ok) { + const errText = await rerumResponse.text() + console.log(`RERUM CREATE error ${rerumResponse.status}: ${errText}`) + return res.status(rerumResponse.status).type('text/plain').send(errText) + } + const result = await rerumResponse.json() res.setHeader("Location", result["@id"] ?? result.id) res.status(201) res.send(result) diff --git a/routes/delete.js b/routes/delete.js index 405a99c..8fcd8d3 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -16,10 +16,16 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { method: "DELETE", headers: { 'user-agent': 'TinyPen', + 'Origin': process.env.ORIGIN, 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}` } } - await fetch(deleteURL, deleteOptions) + const rerumResponse = await fetch(deleteURL, deleteOptions) + if (!rerumResponse.ok) { + const errText = await rerumResponse.text() + console.log(`RERUM DELETE error ${rerumResponse.status}: ${errText}`) + return res.status(rerumResponse.status).type('text/plain').send(errText) + } res.status(204).end() } catch (err) { diff --git a/routes/overwrite.js b/routes/overwrite.js index 4b16a60..13643b4 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -39,19 +39,16 @@ router.put('/', checkAccessToken, async (req, res, next) => { const overwriteURL = `${process.env.RERUM_API_ADDR}overwrite` const response = await fetch(overwriteURL, overwriteOptions) - .then(resp=>{ - if (!resp.ok) throw resp - return resp - }) - .catch(async err => { + if (!response.ok) { // Handle 409 conflict error for version mismatch - if (err.status === 409) { - const currentVersion = await err.json() + if (response.status === 409) { + const currentVersion = await response.json() return res.status(409).json(currentVersion) } - throw new Error(`Error in overwrite request: ${err.status} ${err.statusText}`) - }) - if(res.headersSent) return + const errText = await response.text() + console.log(`RERUM OVERWRITE error ${response.status}: ${errText}`) + return res.status(response.status).type('text/plain').send(errText) + } const result = await response.json() if(response.status === 200) { res.setHeader("Location", result["@id"] ?? result.id) diff --git a/routes/query.js b/routes/query.js index 606df01..d4456c1 100644 --- a/routes/query.js +++ b/routes/query.js @@ -34,7 +34,13 @@ router.post('/', async (req, res, next) => { } } const queryURL = `${process.env.RERUM_API_ADDR}query?limit=${lim}&skip=${skip}` - const results = await fetch(queryURL, queryOptions).then(resp => resp.json()) + const rerumResponse = await fetch(queryURL, queryOptions) + if (!rerumResponse.ok) { + const errText = await rerumResponse.text() + console.log(`RERUM QUERY error ${rerumResponse.status}: ${errText}`) + return res.status(rerumResponse.status).type('text/plain').send(errText) + } + const results = await rerumResponse.json() res.status(200) res.send(results) } diff --git a/routes/update.js b/routes/update.js index 69008f8..70acea8 100644 --- a/routes/update.js +++ b/routes/update.js @@ -24,7 +24,13 @@ router.put('/', checkAccessToken, async (req, res, next) => { } } const updateURL = `${process.env.RERUM_API_ADDR}update` - const result = await fetch(updateURL, updateOptions).then(resp => resp.json()) + const rerumResponse = await fetch(updateURL, updateOptions) + if (!rerumResponse.ok) { + const errText = await rerumResponse.text() + console.log(`RERUM UPDATE error ${rerumResponse.status}: ${errText}`) + return res.status(rerumResponse.status).type('text/plain').send(errText) + } + const result = await rerumResponse.json() res.setHeader("Location", result["@id"] ?? result.id) res.status(200) res.send(result) From 074396d2ab8ce4c783f4575d13f0933e5fbb3cbc Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:22:26 -0500 Subject: [PATCH 07/25] Changes while reviewing and testing --- routes/create.js | 2 +- routes/delete.js | 2 +- routes/overwrite.js | 2 +- routes/query.js | 2 +- routes/update.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/routes/create.js b/routes/create.js index 4549e0a..2ac253d 100644 --- a/routes/create.js +++ b/routes/create.js @@ -36,7 +36,7 @@ router.post('/', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).send(`Caught Error:${err}`) + res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/delete.js b/routes/delete.js index 8fcd8d3..e3f3df0 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -30,7 +30,7 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).send(`Caught Error:${err}`) + res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/overwrite.js b/routes/overwrite.js index 13643b4..3e26066 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -58,7 +58,7 @@ router.put('/', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).send("Caught Error:" + err) + res.status(500).type('text/plain').send("Caught Error: " + err) } }) diff --git a/routes/query.js b/routes/query.js index d4456c1..8fd0b8a 100644 --- a/routes/query.js +++ b/routes/query.js @@ -46,7 +46,7 @@ router.post('/', async (req, res, next) => { } catch (err) { console.log(err) - res.status(err.status ?? 500).send("Caught " + err.message) + res.status(err.status ?? 500).type('text/plain').send("Caught " + err.message) } }) diff --git a/routes/update.js b/routes/update.js index 70acea8..caf5e73 100644 --- a/routes/update.js +++ b/routes/update.js @@ -37,7 +37,7 @@ router.put('/', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).send("Caught Error:" + err) + res.status(500).type('text/plain').send("Caught Error: " + err) } }) From dc7f5f72547762c0f4bd0526b86627d3468b2eb9 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 10:26:53 -0500 Subject: [PATCH 08/25] Changes while reviewing and testing --- app.js | 2 +- routes/overwrite.js | 2 +- routes/query.js | 2 +- routes/update.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app.js b/app.js index 5620c13..64188d0 100644 --- a/app.js +++ b/app.js @@ -19,7 +19,7 @@ if(process.env.OPEN_API_CORS !== "false") { // This enables CORS for all requests. We may want to update this in the future and only apply to some routes. app.use(cors()) } -app.use(express.urlencoded({ extended: false })) + app.use(express.static(path.join(__dirname, 'public'))) /** diff --git a/routes/overwrite.js b/routes/overwrite.js index 3e26066..21cc6ff 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -58,7 +58,7 @@ router.put('/', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).type('text/plain').send("Caught Error: " + err) + res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/query.js b/routes/query.js index 8fd0b8a..4a539ee 100644 --- a/routes/query.js +++ b/routes/query.js @@ -46,7 +46,7 @@ router.post('/', async (req, res, next) => { } catch (err) { console.log(err) - res.status(err.status ?? 500).type('text/plain').send("Caught " + err.message) + res.status(err.status ?? 500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/update.js b/routes/update.js index caf5e73..eb9c529 100644 --- a/routes/update.js +++ b/routes/update.js @@ -37,7 +37,7 @@ router.put('/', checkAccessToken, async (req, res, next) => { } catch (err) { console.log(err) - res.status(500).type('text/plain').send("Caught Error: " + err) + res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) From b660ac350ff60c169f9b40fe6e6d58f509574e55 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 11:00:58 -0500 Subject: [PATCH 09/25] Missing, blank, or null Content-Type headers are also 415 --- __tests__/contentType.test.js | 23 ++++++++++++++++------- app.js | 6 +++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js index 8ce6f0c..9921d69 100644 --- a/__tests__/contentType.test.js +++ b/__tests__/contentType.test.js @@ -2,8 +2,8 @@ * Content-Type Header Validation Tests * * Verifies that TinyPEN properly validates Content-Type headers - * on incoming requests, returning 400 for missing headers, - * 415 for unsupported types, and passing through valid types. + * on incoming requests, returning 415 for missing/blank/unsupported types, + * 400 for malformed multi-value headers, and passing through valid types. */ import request from "supertest" @@ -53,13 +53,22 @@ describe("Content-Type validation on POST routes", () => { expect(res.status).not.toBe(400) }) - it("returns 400 for missing Content-Type on POST __contentType __core", async () => { + it("returns 415 for missing Content-Type on POST __contentType __core", async () => { const res = await request(app) .post("/query") .unset("Content-Type") .send(Buffer.from(JSON.stringify({ type: "test" }))) - expect(res.status).toBe(400) - expect(res.text).toMatch(/Missing Content-Type/i) + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) + }) + + it("returns 415 for blank Content-Type on POST __contentType", async () => { + const res = await request(app) + .post("/query") + .set("Content-Type", "") + .send(Buffer.from(JSON.stringify({ type: "test" }))) + expect(res.status).toBe(415) + expect(res.text).toMatch(/Unsupported Media Type/i) }) it("returns 415 for text/plain __contentType", async () => { @@ -132,12 +141,12 @@ describe("Content-Type validation on PUT routes", () => { expect(res.status).toBe(415) }) - it("returns 400 for missing Content-Type on PUT /overwrite __contentType", async () => { + it("returns 415 for missing Content-Type on PUT /overwrite __contentType", async () => { const res = await request(app) .put("/overwrite") .unset("Content-Type") .send(Buffer.from(JSON.stringify({ id: "test" }))) - expect(res.status).toBe(400) + expect(res.status).toBe(415) }) }) diff --git a/app.js b/app.js index 64188d0..caa1a1c 100644 --- a/app.js +++ b/app.js @@ -96,7 +96,7 @@ if(corsAllowedOrigins !== "*") { /** * Validate Content-Type header on requests that carry a body. - * Rejects missing Content-Type with 400 and unsupported types with 415. + * Rejects missing/blank/unsupported Content-Type with 415 and duplicate values with 400. * Accepts application/json and application/ld+json (with optional parameters like charset). */ const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] @@ -107,8 +107,8 @@ app.use(function validateContentType(req, res, next) { const rawContentType = req.headers['content-type'] - if (!rawContentType) { - return res.status(400).type('text/plain').send('Missing Content-Type header. Expected application/json or application/ld+json.') + if (!rawContentType || !rawContentType.trim()) { + return res.status(415).type('text/plain').send('Unsupported Media Type. Content-Type header is required. Expected application/json or application/ld+json.') } // Node.js/Express joins duplicate Content-Type headers with ", " From 4d129d4b9a2b1139d212d03866022e2766283e1b Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 11:06:03 -0500 Subject: [PATCH 10/25] reject content-type headers that have multiple or duplicate types --- __tests__/contentType.test.js | 9 +++++---- app.js | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js index 9921d69..8e26ab5 100644 --- a/__tests__/contentType.test.js +++ b/__tests__/contentType.test.js @@ -2,8 +2,8 @@ * Content-Type Header Validation Tests * * Verifies that TinyPEN properly validates Content-Type headers - * on incoming requests, returning 415 for missing/blank/unsupported types, - * 400 for malformed multi-value headers, and passing through valid types. + * on incoming requests, returning 415 for missing, blank, duplicate, + * or unsupported types, and passing through valid types. */ import request from "supertest" @@ -98,13 +98,14 @@ describe("Content-Type validation on POST routes", () => { expect(res.text).toMatch(/Unsupported Media Type/i) }) - it("returns 400 for duplicate/multi-value Content-Type headers __contentType __core", async () => { + it("returns 415 for duplicate/multi-value Content-Type headers __contentType __core", async () => { // Node.js joins duplicate headers with ", " so this simulates that + // Content-Type is a singleton field per RFC 9110 §8.3 const res = await request(app) .post("/query") .set("Content-Type", "application/json, text/html") .send(JSON.stringify({ type: "test" })) - expect(res.status).toBe(400) + expect(res.status).toBe(415) expect(res.text).toMatch(/Multiple Content-Type/i) }) }) diff --git a/app.js b/app.js index caa1a1c..77b4c2f 100644 --- a/app.js +++ b/app.js @@ -96,7 +96,7 @@ if(corsAllowedOrigins !== "*") { /** * Validate Content-Type header on requests that carry a body. - * Rejects missing/blank/unsupported Content-Type with 415 and duplicate values with 400. + * Rejects missing, blank, duplicate, or unsupported Content-Type with 415. * Accepts application/json and application/ld+json (with optional parameters like charset). */ const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] @@ -112,9 +112,9 @@ app.use(function validateContentType(req, res, next) { } // Node.js/Express joins duplicate Content-Type headers with ", " - // A valid Content-Type should never contain a comma — reject multi-value headers + // Content-Type is a singleton field per RFC 9110 §8.3 — multiple values are invalid if (rawContentType.includes(',')) { - return res.status(400).type('text/plain').send('Multiple Content-Type values are not allowed. Send a single Content-Type header.') + return res.status(415).type('text/plain').send('Unsupported Media Type. Multiple Content-Type values are not allowed. Send a single Content-Type header.') } // Strip parameters (e.g., ";charset=utf-8") and normalize From 10f558925ea2328dde0809b61389b5738df15ab1 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 12:46:01 -0500 Subject: [PATCH 11/25] refactor how Content-Type headers are checked --- app.js | 34 +--------------------------------- routes/create.js | 5 ++++- routes/delete.js | 2 ++ routes/index.js | 2 ++ routes/overwrite.js | 5 ++++- routes/query.js | 6 +++++- routes/update.js | 5 ++++- 7 files changed, 22 insertions(+), 37 deletions(-) diff --git a/app.js b/app.js index 77b4c2f..5642939 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,5 @@ #!/usr/bin/env node + import createError from "http-errors" import express from "express" import path from "path" @@ -94,39 +95,6 @@ if(corsAllowedOrigins !== "*") { }) } -/** - * Validate Content-Type header on requests that carry a body. - * Rejects missing, blank, duplicate, or unsupported Content-Type with 415. - * Accepts application/json and application/ld+json (with optional parameters like charset). - */ -const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] -const BODY_METHODS = ['POST', 'PUT', 'PATCH'] - -app.use(function validateContentType(req, res, next) { - if (!BODY_METHODS.includes(req.method)) return next() - - const rawContentType = req.headers['content-type'] - - if (!rawContentType || !rawContentType.trim()) { - return res.status(415).type('text/plain').send('Unsupported Media Type. Content-Type header is required. Expected application/json or application/ld+json.') - } - - // Node.js/Express joins duplicate Content-Type headers with ", " - // Content-Type is a singleton field per RFC 9110 §8.3 — multiple values are invalid - if (rawContentType.includes(',')) { - return res.status(415).type('text/plain').send('Unsupported Media Type. Multiple Content-Type values are not allowed. Send a single Content-Type header.') - } - - // Strip parameters (e.g., ";charset=utf-8") and normalize - const mediaType = rawContentType.split(';')[0].trim().toLowerCase() - - if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { - return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) - } - - next() -}) - //New available usage without /app app.use('/query', queryRouter) app.use('/create', createRouter) diff --git a/routes/create.js b/routes/create.js index 2ac253d..35f4431 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,10 +1,13 @@ +#!/usr/bin/env node + import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', checkAccessToken, async (req, res, next) => { +router.post('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id if (req.body.id) { diff --git a/routes/delete.js b/routes/delete.js index e3f3df0..de28aac 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import express from "express" import checkAccessToken from "../tokens.js" diff --git a/routes/index.js b/routes/index.js index bfe8888..80c90c2 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import express from 'express' const router = express.Router() diff --git a/routes/overwrite.js b/routes/overwrite.js index 21cc6ff..f56fc38 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -1,10 +1,13 @@ +#!/usr/bin/env node + import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', checkAccessToken, async (req, res, next) => { +router.put('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { try { diff --git a/routes/query.js b/routes/query.js index 4a539ee..d1aa24e 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,8 +1,12 @@ +#!/usr/bin/env node + import express from "express" +import rest from "../rest.js" + const router = express.Router() /* POST a query to the thing. */ -router.post('/', async (req, res, next) => { +router.post('/', rest.jsonContent, async (req, res, next) => { const lim = req.query.limit ?? 10 const skip = req.query.skip ?? 0 try { diff --git a/routes/update.js b/routes/update.js index eb9c529..766019c 100644 --- a/routes/update.js +++ b/routes/update.js @@ -1,10 +1,13 @@ +#!/usr/bin/env node + import express from "express" import checkAccessToken from "../tokens.js" +import rest from "../rest.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', checkAccessToken, async (req, res, next) => { +router.put('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { try { // check for @id; any value is valid From 6541efc04276060c30b44c911ebfcc9eb03e8515 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 12:46:08 -0500 Subject: [PATCH 12/25] refactor how Content-Type headers are checked --- rest.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 rest.js diff --git a/rest.js b/rest.js new file mode 100644 index 0000000..feae849 --- /dev/null +++ b/rest.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +/** + * This module is used for any REST support functionality. It is used as middleware and so + * has access to the http module request and response objects, as well as next() + * + * @author thehabes + */ + +/** + * Validate Content-Type header on requests. + * Rejects missing, blank, duplicate, or unsupported Content-Type with 415. + * Accepts application/json and application/ld+json (with optional parameters like charset). + */ +const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] +function jsonContent(req, res, next) { + const rawContentType = req.headers['content-type'] + if (!rawContentType || !rawContentType.trim()) { + return res.status(415).type('text/plain').send('Unsupported Media Type. Content-Type header is required. Expected application/json or application/ld+json.') + } + // Node.js/Express joins duplicate Content-Type headers with ", " + // Content-Type is a singleton field per RFC 9110 §8.3 — multiple values are invalid + if (rawContentType.includes(',')) { + return res.status(415).type('text/plain').send('Unsupported Media Type. Multiple Content-Type values are not allowed. Send a single Content-Type header.') + } + // Strip parameters (e.g., ";charset=utf-8") and normalize + const mediaType = rawContentType.split(';')[0].trim().toLowerCase() + if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { + return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) + } + next() +} + +export default { jsonContent } From e3a9dc1b146eeeaf40237467f5856be98d1ba5d9 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 12:47:27 -0500 Subject: [PATCH 13/25] refactor how Content-Type headers are checked --- tokens.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tokens.js b/tokens.js index 459463c..dc0707d 100644 --- a/tokens.js +++ b/tokens.js @@ -1,3 +1,5 @@ +#!/usr/bin/env node + import dotenv from "dotenv" const storedEnv = dotenv.config() import fs from "node:fs/promises" From 736473683e1af21adf466258e1e5aef967ab4679 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:14:53 -0500 Subject: [PATCH 14/25] no shebang --- rest.js | 2 -- routes/create.js | 2 -- routes/delete.js | 2 -- routes/index.js | 2 -- routes/overwrite.js | 2 -- routes/query.js | 2 -- routes/update.js | 2 -- tokens.js | 2 -- 8 files changed, 16 deletions(-) diff --git a/rest.js b/rest.js index feae849..4b8437b 100644 --- a/rest.js +++ b/rest.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - /** * This module is used for any REST support functionality. It is used as middleware and so * has access to the http module request and response objects, as well as next() diff --git a/routes/create.js b/routes/create.js index 35f4431..4ccba75 100644 --- a/routes/create.js +++ b/routes/create.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" diff --git a/routes/delete.js b/routes/delete.js index de28aac..e3f3df0 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from "express" import checkAccessToken from "../tokens.js" diff --git a/routes/index.js b/routes/index.js index 80c90c2..bfe8888 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from 'express' const router = express.Router() diff --git a/routes/overwrite.js b/routes/overwrite.js index f56fc38..494d897 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" diff --git a/routes/query.js b/routes/query.js index d1aa24e..e9c5553 100644 --- a/routes/query.js +++ b/routes/query.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from "express" import rest from "../rest.js" diff --git a/routes/update.js b/routes/update.js index 766019c..f3e117c 100644 --- a/routes/update.js +++ b/routes/update.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import express from "express" import checkAccessToken from "../tokens.js" import rest from "../rest.js" diff --git a/tokens.js b/tokens.js index dc0707d..459463c 100644 --- a/tokens.js +++ b/tokens.js @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import dotenv from "dotenv" const storedEnv = dotenv.config() import fs from "node:fs/promises" From 1ae3dba2f61696b370013cea568e838e49628d29 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 13:35:33 -0500 Subject: [PATCH 15/25] changes while testing and reviewing --- routes/create.js | 2 +- routes/overwrite.js | 2 +- routes/update.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/create.js b/routes/create.js index 4ccba75..330a3a3 100644 --- a/routes/create.js +++ b/routes/create.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { +router.post('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id if (req.body.id) { diff --git a/routes/overwrite.js b/routes/overwrite.js index 494d897..a1af2fd 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { +router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { try { diff --git a/routes/update.js b/routes/update.js index f3e117c..808f5b7 100644 --- a/routes/update.js +++ b/routes/update.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', checkAccessToken, rest.jsonContent, async (req, res, next) => { +router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { try { // check for @id; any value is valid From d36fe531e2d6dedd7901e9bc229550c94e9e9f51 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:06:44 -0500 Subject: [PATCH 16/25] Changes while testing and reviewing --- routes/create.js | 4 ++-- routes/delete.js | 4 ++-- routes/overwrite.js | 4 ++-- routes/query.js | 4 ++-- routes/update.js | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/routes/create.js b/routes/create.js index 330a3a3..afaeee0 100644 --- a/routes/create.js +++ b/routes/create.js @@ -27,7 +27,7 @@ router.post('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { const rerumResponse = await fetch(createURL, createOptions) if (!rerumResponse.ok) { const errText = await rerumResponse.text() - console.log(`RERUM CREATE error ${rerumResponse.status}: ${errText}`) + console.error(`RERUM CREATE error ${rerumResponse.status}: ${errText}`) return res.status(rerumResponse.status).type('text/plain').send(errText) } const result = await rerumResponse.json() @@ -36,7 +36,7 @@ router.post('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { res.send(result) } catch (err) { - console.log(err) + console.error(err) res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/delete.js b/routes/delete.js index e3f3df0..e75fb6c 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -23,13 +23,13 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { const rerumResponse = await fetch(deleteURL, deleteOptions) if (!rerumResponse.ok) { const errText = await rerumResponse.text() - console.log(`RERUM DELETE error ${rerumResponse.status}: ${errText}`) + console.error(`RERUM DELETE error ${rerumResponse.status}: ${errText}`) return res.status(rerumResponse.status).type('text/plain').send(errText) } res.status(204).end() } catch (err) { - console.log(err) + console.error(err) res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/overwrite.js b/routes/overwrite.js index a1af2fd..fa6f72e 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -47,7 +47,7 @@ router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { return res.status(409).json(currentVersion) } const errText = await response.text() - console.log(`RERUM OVERWRITE error ${response.status}: ${errText}`) + console.error(`RERUM OVERWRITE error ${response.status}: ${errText}`) return res.status(response.status).type('text/plain').send(errText) } const result = await response.json() @@ -58,7 +58,7 @@ router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { res.send(result) } catch (err) { - console.log(err) + console.error(err) res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/query.js b/routes/query.js index e9c5553..848b922 100644 --- a/routes/query.js +++ b/routes/query.js @@ -39,7 +39,7 @@ router.post('/', rest.jsonContent, async (req, res, next) => { const rerumResponse = await fetch(queryURL, queryOptions) if (!rerumResponse.ok) { const errText = await rerumResponse.text() - console.log(`RERUM QUERY error ${rerumResponse.status}: ${errText}`) + console.error(`RERUM QUERY error ${rerumResponse.status}: ${errText}`) return res.status(rerumResponse.status).type('text/plain').send(errText) } const results = await rerumResponse.json() @@ -47,7 +47,7 @@ router.post('/', rest.jsonContent, async (req, res, next) => { res.send(results) } catch (err) { - console.log(err) + console.error(err) res.status(err.status ?? 500).type('text/plain').send(`Caught Error: ${err}`) } }) diff --git a/routes/update.js b/routes/update.js index 808f5b7..37b7bee 100644 --- a/routes/update.js +++ b/routes/update.js @@ -28,7 +28,7 @@ router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { const rerumResponse = await fetch(updateURL, updateOptions) if (!rerumResponse.ok) { const errText = await rerumResponse.text() - console.log(`RERUM UPDATE error ${rerumResponse.status}: ${errText}`) + console.error(`RERUM UPDATE error ${rerumResponse.status}: ${errText}`) return res.status(rerumResponse.status).type('text/plain').send(errText) } const result = await rerumResponse.json() @@ -37,7 +37,7 @@ router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { res.send(result) } catch (err) { - console.log(err) + console.error(err) res.status(500).type('text/plain').send(`Caught Error: ${err}`) } }) From ab6cf16e626366cb4d9d94a4a5664d34032bafed Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:16:02 -0500 Subject: [PATCH 17/25] Clean out dead index handler and router --- app.js | 1 - routes/index.js | 9 --------- 2 files changed, 10 deletions(-) delete mode 100644 routes/index.js diff --git a/app.js b/app.js index 5642939..3a10b91 100644 --- a/app.js +++ b/app.js @@ -6,7 +6,6 @@ import path from "path" import { fileURLToPath } from "url" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -import indexRouter from "./routes/index.js" import queryRouter from "./routes/query.js" import createRouter from "./routes/create.js" import updateRouter from "./routes/update.js" diff --git a/routes/index.js b/routes/index.js deleted file mode 100644 index bfe8888..0000000 --- a/routes/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express' -const router = express.Router() - -/* There is no home page for now */ -router.get('/', (req, res, next) => { - res.status(404).send() -}) - -export default router From 29e77aa20eeba8a58798216416e9bacdaf65339a Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 24 Mar 2026 14:21:33 -0500 Subject: [PATCH 18/25] rename --- rest.js | 4 ++-- routes/create.js | 2 +- routes/overwrite.js | 2 +- routes/query.js | 2 +- routes/update.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rest.js b/rest.js index 4b8437b..411c85b 100644 --- a/rest.js +++ b/rest.js @@ -11,7 +11,7 @@ * Accepts application/json and application/ld+json (with optional parameters like charset). */ const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] -function jsonContent(req, res, next) { +function verifyJsonContentType(req, res, next) { const rawContentType = req.headers['content-type'] if (!rawContentType || !rawContentType.trim()) { return res.status(415).type('text/plain').send('Unsupported Media Type. Content-Type header is required. Expected application/json or application/ld+json.') @@ -29,4 +29,4 @@ function jsonContent(req, res, next) { next() } -export default { jsonContent } +export default { verifyJsonContentType } diff --git a/routes/create.js b/routes/create.js index afaeee0..714d402 100644 --- a/routes/create.js +++ b/routes/create.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* POST a create to the thing. */ -router.post('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { +router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { // if an id is passed in, pop off the end to make it an _id if (req.body.id) { diff --git a/routes/overwrite.js b/routes/overwrite.js index fa6f72e..e69bff7 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* PUT an overwrite to the thing. */ -router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { +router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { diff --git a/routes/query.js b/routes/query.js index 848b922..296238d 100644 --- a/routes/query.js +++ b/routes/query.js @@ -4,7 +4,7 @@ import rest from "../rest.js" const router = express.Router() /* POST a query to the thing. */ -router.post('/', rest.jsonContent, async (req, res, next) => { +router.post('/', rest.verifyJsonContentType, async (req, res, next) => { const lim = req.query.limit ?? 10 const skip = req.query.skip ?? 0 try { diff --git a/routes/update.js b/routes/update.js index 37b7bee..f8b13fc 100644 --- a/routes/update.js +++ b/routes/update.js @@ -5,7 +5,7 @@ import rest from "../rest.js" const router = express.Router() /* PUT an update to the thing. */ -router.put('/', rest.jsonContent, checkAccessToken, async (req, res, next) => { +router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, next) => { try { // check for @id; any value is valid From 9f3eb77797720f993e2b6a8626dd862035876fe0 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 12:29:01 -0500 Subject: [PATCH 19/25] Bring in content-type processing from lessons learned in rerum --- rest.js | 79 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 24 deletions(-) diff --git a/rest.js b/rest.js index 411c85b..e3aa9bf 100644 --- a/rest.js +++ b/rest.js @@ -1,32 +1,63 @@ /** - * This module is used for any REST support functionality. It is used as middleware and so - * has access to the http module request and response objects, as well as next() - * - * @author thehabes + * Detects multiple MIME types smuggled into a single Content-Type header. + * The following are the cases that should result in a 415 (not a 500) + + - application/json text/plain + - application/json, text/plain + - text/plain; application/json + - text/plain; a=b, application/json + - application/json; a=b; text/plain; + - application/json; a=b text/plain; + - application/json; charset=utf-8, text/plain + - application/json; + + * @param {string} contentType - Lowercased Content-Type header value + * @returns {boolean} True if multiple MIME types are detected */ +const hasMultipleContentTypes = (contentType) => { + const segments = contentType.split(";") + const mimeSegment = segments[0].trim() + // No commas or spaces allowed in MIME types + if (mimeSegment.includes(",") || mimeSegment.includes(" ")) return true + // Parameter values are tokens (no spaces/commas) or quoted strings per RFC 2045. + // Commas or spaces outside quotes indicate a smuggled MIME type. + return segments.slice(1).some(segment => { + const trimmed = segment.trim() + if (!trimmed.includes("=")) return true + const withoutQuoted = trimmed.replace(/"[^"]*"/g, "") + if (withoutQuoted.includes(",") || withoutQuoted.includes(" ")) return true + return false + }) +} /** - * Validate Content-Type header on requests. - * Rejects missing, blank, duplicate, or unsupported Content-Type with 415. - * Accepts application/json and application/ld+json (with optional parameters like charset). + * Middleware to verify Content-Type headers for endpoints receiving JSON bodies. + * Responds with a 415 Invalid Media Type for Content-Type headers that are not for JSON bodies. + * + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next middleware function */ -const ALLOWED_CONTENT_TYPES = ['application/json', 'application/ld+json'] -function verifyJsonContentType(req, res, next) { - const rawContentType = req.headers['content-type'] - if (!rawContentType || !rawContentType.trim()) { - return res.status(415).type('text/plain').send('Unsupported Media Type. Content-Type header is required. Expected application/json or application/ld+json.') - } - // Node.js/Express joins duplicate Content-Type headers with ", " - // Content-Type is a singleton field per RFC 9110 §8.3 — multiple values are invalid - if (rawContentType.includes(',')) { - return res.status(415).type('text/plain').send('Unsupported Media Type. Multiple Content-Type values are not allowed. Send a single Content-Type header.') - } - // Strip parameters (e.g., ";charset=utf-8") and normalize - const mediaType = rawContentType.split(';')[0].trim().toLowerCase() - if (!ALLOWED_CONTENT_TYPES.includes(mediaType)) { - return res.status(415).type('text/plain').send(`Unsupported Media Type: ${mediaType}. Expected application/json or application/ld+json.`) - } - next() +const verifyJsonContentType = function (req, res, next) { + const contentType = (req.get("Content-Type") ?? "").toLowerCase() + const mimeType = contentType.split(";")[0].trim() + if (!mimeType) { + return next(utils.createExpressError({ + statusCode: 415, + statusMessage: `Missing or empty Content-Type header.` + })) + } + if (hasMultipleContentTypes(contentType)) { + return next(utils.createExpressError({ + statusCode: 415, + statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` + })) + } + if (mimeType === "application/json" || mimeType === "application/ld+json") return next() + return next(utils.createExpressError({ + statusCode: 415, + statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` + })) } export default { verifyJsonContentType } From 53a63b59f09c99e73cf039e465de28b16f6d14d1 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 12:51:19 -0500 Subject: [PATCH 20/25] Handle success and error responses and send them through --- routes/create.js | 36 +++++++++++++++++++++++++++--------- routes/delete.js | 27 +++++++++++++++++++++------ routes/overwrite.js | 43 +++++++++++++++++++++++++++---------------- routes/query.js | 30 +++++++++++++++++++++--------- routes/update.js | 36 +++++++++++++++++++++++++++--------- 5 files changed, 123 insertions(+), 49 deletions(-) diff --git a/routes/create.js b/routes/create.js index 714d402..388b3ec 100644 --- a/routes/create.js +++ b/routes/create.js @@ -25,19 +25,37 @@ router.post('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, } const createURL = `${process.env.RERUM_API_ADDR}create` const rerumResponse = await fetch(createURL, createOptions) - if (!rerumResponse.ok) { - const errText = await rerumResponse.text() - console.error(`RERUM CREATE error ${rerumResponse.status}: ${errText}`) - return res.status(rerumResponse.status).type('text/plain').send(errText) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${createURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${createURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${createURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError } - const result = await rerumResponse.json() - res.setHeader("Location", result["@id"] ?? result.id) - res.status(201) - res.send(result) + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(201).json(rerumResponse) } catch (err) { console.error(err) - res.status(500).type('text/plain').send(`Caught Error: ${err}`) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/delete.js b/routes/delete.js index e75fb6c..c7d98c3 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -21,16 +21,31 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { } } const rerumResponse = await fetch(deleteURL, deleteOptions) - if (!rerumResponse.ok) { - const errText = await rerumResponse.text() - console.error(`RERUM DELETE error ${rerumResponse.status}: ${errText}`) - return res.status(rerumResponse.status).type('text/plain').send(errText) - } + const rerumResponse = await fetch(deleteURL, deleteOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) res.status(204).end() } catch (err) { console.error(err) - res.status(500).type('text/plain').send(`Caught Error: ${err}`) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/overwrite.js b/routes/overwrite.js index e69bff7..b460974 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -39,23 +39,34 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } const overwriteURL = `${process.env.RERUM_API_ADDR}overwrite` - const response = await fetch(overwriteURL, overwriteOptions) - if (!response.ok) { - // Handle 409 conflict error for version mismatch - if (response.status === 409) { - const currentVersion = await response.json() - return res.status(409).json(currentVersion) - } - const errText = await response.text() - console.error(`RERUM OVERWRITE error ${response.status}: ${errText}`) - return res.status(response.status).type('text/plain').send(errText) - } - const result = await response.json() - if(response.status === 200) { - res.setHeader("Location", result["@id"] ?? result.id) - res.status(200) + const rerumResponse = await fetch(overwriteURL, overwriteOptions) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError } - res.send(result) + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(200).json(rerumResponse) } catch (err) { console.error(err) diff --git a/routes/query.js b/routes/query.js index 296238d..2b376c8 100644 --- a/routes/query.js +++ b/routes/query.js @@ -37,18 +37,30 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => { } const queryURL = `${process.env.RERUM_API_ADDR}query?limit=${lim}&skip=${skip}` const rerumResponse = await fetch(queryURL, queryOptions) - if (!rerumResponse.ok) { - const errText = await rerumResponse.text() - console.error(`RERUM QUERY error ${rerumResponse.status}: ${errText}`) - return res.status(rerumResponse.status).type('text/plain').send(errText) - } - const results = await rerumResponse.json() - res.status(200) - res.send(results) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + res.status(200).json(rerumResponse) } catch (err) { console.error(err) - res.status(err.status ?? 500).type('text/plain').send(`Caught Error: ${err}`) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/update.js b/routes/update.js index f8b13fc..3e72dd3 100644 --- a/routes/update.js +++ b/routes/update.js @@ -26,19 +26,37 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } const updateURL = `${process.env.RERUM_API_ADDR}update` const rerumResponse = await fetch(updateURL, updateOptions) - if (!rerumResponse.ok) { - const errText = await rerumResponse.text() - console.error(`RERUM UPDATE error ${rerumResponse.status}: ${errText}`) - return res.status(rerumResponse.status).type('text/plain').send(errText) + .then(async (resp) => { + if (resp.ok) return resp.json() + // The response from RERUM indicates a failure, likely with a specific code and textual body + let rerumErrorMessage + try { + rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + } catch (e) { + rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + } + const err = new Error(rerumErrorMessage) + err.status = 502 + throw err + }) + .catch(err => { + if (err.status === 502) throw err + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError + }) + if (!(rerumResponse.id || rerumResponse["@id"])) { + // A 200 with garbled data, call it a fail + const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + genericRerumNetworkError.status = 502 + throw genericRerumNetworkError } - const result = await rerumResponse.json() - res.setHeader("Location", result["@id"] ?? result.id) - res.status(200) - res.send(result) + res.setHeader("Location", rerumResponse["@id"] ?? rerumResponse.id) + res.status(200).json(rerumResponse) } catch (err) { console.error(err) - res.status(500).type('text/plain').send(`Caught Error: ${err}`) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) From 4dec674b22102187252e84e083440d1e5bb1c2aa Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 13:03:30 -0500 Subject: [PATCH 21/25] changes during review --- rest.js | 21 +++++++++------------ routes/delete.js | 9 ++++----- routes/overwrite.js | 17 ++++++++++++----- routes/query.js | 6 +++--- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/rest.js b/rest.js index e3aa9bf..e47a28c 100644 --- a/rest.js +++ b/rest.js @@ -42,22 +42,19 @@ const verifyJsonContentType = function (req, res, next) { const contentType = (req.get("Content-Type") ?? "").toLowerCase() const mimeType = contentType.split(";")[0].trim() if (!mimeType) { - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Missing or empty Content-Type header.` - })) + const err = new Error(`Missing or empty Content-Type header.`) + err.status = 415 + return next(err) } if (hasMultipleContentTypes(contentType)) { - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.` - })) + const err = new Error(`Multiple Content-Type values are not allowed. Provide exactly one Content-Type header.`) + err.status = 415 + return next(err) } if (mimeType === "application/json" || mimeType === "application/ld+json") return next() - return next(utils.createExpressError({ - statusCode: 415, - statusMessage: `Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.` - })) + const err = new Error(`Unsupported Content-Type: ${contentType}. This endpoint requires application/json or application/ld+json.`) + err.status = 415 + return next(err) } export default { verifyJsonContentType } diff --git a/routes/delete.js b/routes/delete.js index c7d98c3..a79cd4e 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -21,15 +21,14 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { } } const rerumResponse = await fetch(deleteURL, deleteOptions) - const rerumResponse = await fetch(deleteURL, deleteOptions) .then(async (resp) => { - if (resp.ok) return resp.json() + if (resp.ok) return // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { - rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + rerumErrorMessage = `${resp.status ?? 500}: ${deleteURL} - ${await resp.text()}` } catch (e) { - rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + rerumErrorMessage = `500: ${deleteURL} - A RERUM error occurred` } const err = new Error(rerumErrorMessage) err.status = 502 @@ -37,7 +36,7 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { }) .catch(err => { if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + const genericRerumNetworkError = new Error(`500: ${deleteURL} - A RERUM error occurred`) genericRerumNetworkError.status = 502 throw genericRerumNetworkError }) diff --git a/routes/overwrite.js b/routes/overwrite.js index b460974..5b704da 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -42,12 +42,18 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n const rerumResponse = await fetch(overwriteURL, overwriteOptions) .then(async (resp) => { if (resp.ok) return resp.json() + // Handle 409 conflict error for version mismatch (optimistic locking) + if (resp.status === 409) { + const conflictBody = await resp.json() + res.status(409).json(conflictBody) + return null + } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { - rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + rerumErrorMessage = `${resp.status ?? 500}: ${overwriteURL} - ${await resp.text()}` } catch (e) { - rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + rerumErrorMessage = `500: ${overwriteURL} - A RERUM error occurred` } const err = new Error(rerumErrorMessage) err.status = 502 @@ -55,13 +61,14 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n }) .catch(err => { if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) genericRerumNetworkError.status = 502 throw genericRerumNetworkError }) + if (!rerumResponse) return // 409 was already handled if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) genericRerumNetworkError.status = 502 throw genericRerumNetworkError } @@ -70,7 +77,7 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } catch (err) { console.error(err) - res.status(500).type('text/plain').send(`Caught Error: ${err}`) + res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) diff --git a/routes/query.js b/routes/query.js index 2b376c8..fcc45f4 100644 --- a/routes/query.js +++ b/routes/query.js @@ -42,9 +42,9 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => { // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage try { - rerumErrorMessage = `${resp.status ?? 500}: ${updateURL} - ${await resp.text()}` + rerumErrorMessage = `${resp.status ?? 500}: ${queryURL} - ${await resp.text()}` } catch (e) { - rerumErrorMessage = `500: ${updateURL} - A RERUM error occurred` + rerumErrorMessage = `500: ${queryURL} - A RERUM error occurred` } const err = new Error(rerumErrorMessage) err.status = 502 @@ -52,7 +52,7 @@ router.post('/', rest.verifyJsonContentType, async (req, res, next) => { }) .catch(err => { if (err.status === 502) throw err - const genericRerumNetworkError = new Error(`500: ${updateURL} - A RERUM error occurred`) + const genericRerumNetworkError = new Error(`500: ${queryURL} - A RERUM error occurred`) genericRerumNetworkError.status = 502 throw genericRerumNetworkError }) From 4ebe895659395d4c6bab0bb97633587375e94c39 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 13:11:11 -0500 Subject: [PATCH 22/25] changes during review --- __tests__/contentType.test.js | 162 ---------------------------------- routes/delete.js | 2 +- 2 files changed, 1 insertion(+), 163 deletions(-) delete mode 100644 __tests__/contentType.test.js diff --git a/__tests__/contentType.test.js b/__tests__/contentType.test.js deleted file mode 100644 index 8e26ab5..0000000 --- a/__tests__/contentType.test.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Content-Type Header Validation Tests - * - * Verifies that TinyPEN properly validates Content-Type headers - * on incoming requests, returning 415 for missing, blank, duplicate, - * or unsupported types, and passing through valid types. - */ - -import request from "supertest" -import { jest } from "@jest/globals" -import app from "../app.js" - -beforeEach(() => { - checkAccessToken = jest.fn((req, res, next) => next()) - isTokenExpired = jest.fn(() => false) -}) - -describe("Content-Type validation on POST routes", () => { - - it("accepts application/json __contentType __core", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "application/json") - .send(JSON.stringify({ type: "test" })) - expect(res.status).not.toBe(415) - expect(res.status).not.toBe(400) - }) - - it("accepts application/ld+json __contentType __core", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "application/ld+json") - .send(JSON.stringify({ type: "test" })) - expect(res.status).not.toBe(415) - expect(res.status).not.toBe(400) - }) - - it("accepts application/json with charset parameter __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "application/json; charset=utf-8") - .send(JSON.stringify({ type: "test" })) - expect(res.status).not.toBe(415) - expect(res.status).not.toBe(400) - }) - - it("accepts Content-Type case-insensitively __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "Application/JSON") - .send(JSON.stringify({ type: "test" })) - expect(res.status).not.toBe(415) - expect(res.status).not.toBe(400) - }) - - it("returns 415 for missing Content-Type on POST __contentType __core", async () => { - const res = await request(app) - .post("/query") - .unset("Content-Type") - .send(Buffer.from(JSON.stringify({ type: "test" }))) - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 415 for blank Content-Type on POST __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "") - .send(Buffer.from(JSON.stringify({ type: "test" }))) - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 415 for text/plain __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "text/plain") - .send("some text") - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 415 for text/html __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "text/html") - .send("") - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 415 for multipart/form-data __contentType", async () => { - const res = await request(app) - .post("/query") - .set("Content-Type", "multipart/form-data") - .send("--boundary--") - expect(res.status).toBe(415) - expect(res.text).toMatch(/Unsupported Media Type/i) - }) - - it("returns 415 for duplicate/multi-value Content-Type headers __contentType __core", async () => { - // Node.js joins duplicate headers with ", " so this simulates that - // Content-Type is a singleton field per RFC 9110 §8.3 - const res = await request(app) - .post("/query") - .set("Content-Type", "application/json, text/html") - .send(JSON.stringify({ type: "test" })) - expect(res.status).toBe(415) - expect(res.text).toMatch(/Multiple Content-Type/i) - }) -}) - -describe("Content-Type validation skips non-body methods", () => { - - it("does not reject GET requests without Content-Type __contentType", async () => { - const res = await request(app).get("/query") - // GET /query should 404 (no GET handler), not 400/415 - expect(res.status).not.toBe(400) - expect(res.status).not.toBe(415) - }) - - it("does not reject OPTIONS requests __contentType", async () => { - const res = await request(app).options("/query") - expect(res.status).not.toBe(400) - expect(res.status).not.toBe(415) - }) - - it("does not reject HEAD requests __contentType", async () => { - const res = await request(app).head("/query") - expect(res.status).not.toBe(400) - expect(res.status).not.toBe(415) - }) -}) - -describe("Content-Type validation on PUT routes", () => { - - it("returns 415 for text/plain on PUT /update __contentType", async () => { - const res = await request(app) - .put("/update") - .set("Content-Type", "text/plain") - .send("some text") - expect(res.status).toBe(415) - }) - - it("returns 415 for missing Content-Type on PUT /overwrite __contentType", async () => { - const res = await request(app) - .put("/overwrite") - .unset("Content-Type") - .send(Buffer.from(JSON.stringify({ id: "test" }))) - expect(res.status).toBe(415) - }) -}) - -describe("Content-Type validation skips DELETE routes", () => { - - it("does not reject DELETE /delete/:id without Content-Type __contentType", async () => { - const res = await request(app) - .delete("/delete/some-fake-id") - expect(res.status).not.toBe(400) - expect(res.status).not.toBe(415) - }) -}) diff --git a/routes/delete.js b/routes/delete.js index a79cd4e..be95e12 100644 --- a/routes/delete.js +++ b/routes/delete.js @@ -20,7 +20,7 @@ router.delete('/:id', checkAccessToken, async (req, res, next) => { 'Authorization': `Bearer ${process.env.ACCESS_TOKEN}` } } - const rerumResponse = await fetch(deleteURL, deleteOptions) + await fetch(deleteURL, deleteOptions) .then(async (resp) => { if (resp.ok) return // The response from RERUM indicates a failure, likely with a specific code and textual body From 50da75724b8f1ecefe2a4e54aa856d7428f06d44 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 13:13:45 -0500 Subject: [PATCH 23/25] changes during review --- routes/overwrite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/overwrite.js b/routes/overwrite.js index 5b704da..3b86e42 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -65,7 +65,7 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n genericRerumNetworkError.status = 502 throw genericRerumNetworkError }) - if (!rerumResponse) return // 409 was already handled + if (rerumResponse === null) return // 409 was sent if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) From c3bda63a867f3a914c176c54ac51e46ea01d649f Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 13:50:30 -0500 Subject: [PATCH 24/25] changes during review --- app.js | 2 +- routes/overwrite.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app.js b/app.js index 3a10b91..68c3541 100644 --- a/app.js +++ b/app.js @@ -116,7 +116,7 @@ app.use(function(req, res, next) { // error handler app.use(function(err, req, res, next) { res.status(err.status || 500) - res.send(err.message) + res.type('text/plain').send(err.message) }) export default app \ No newline at end of file diff --git a/routes/overwrite.js b/routes/overwrite.js index 3b86e42..43e41ea 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -45,8 +45,10 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n // Handle 409 conflict error for version mismatch (optimistic locking) if (resp.status === 409) { const conflictBody = await resp.json() - res.status(409).json(conflictBody) - return null + const err = new Error("Version conflict") + err.status = 409 + err.body = conflictBody + throw err } // The response from RERUM indicates a failure, likely with a specific code and textual body let rerumErrorMessage @@ -60,12 +62,11 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n throw err }) .catch(err => { - if (err.status === 502) throw err + if (err.status === 502 || err.status === 409) throw err const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) genericRerumNetworkError.status = 502 throw genericRerumNetworkError }) - if (rerumResponse === null) return // 409 was sent if (!(rerumResponse.id || rerumResponse["@id"])) { // A 200 with garbled data, call it a fail const genericRerumNetworkError = new Error(`500: ${overwriteURL} - A RERUM error occurred`) @@ -77,6 +78,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n } catch (err) { console.error(err) + if (err.status === 409) { + return res.status(409).json(err.body) + } res.status(err.status ?? 500).type('text/plain').send(err.message ?? 'An error occurred') } }) From c254e77abab850b2d2e907138b926de4b64980fb Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 25 Mar 2026 13:56:55 -0500 Subject: [PATCH 25/25] changes during review --- routes/overwrite.js | 4 +++- routes/update.js | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/routes/overwrite.js b/routes/overwrite.js index 43e41ea..b09a42a 100644 --- a/routes/overwrite.js +++ b/routes/overwrite.js @@ -12,7 +12,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n const overwriteBody = req.body // check for @id; any value is valid if (!(overwriteBody['@id'] ?? overwriteBody.id)) { - throw Error("No record id to overwrite! (https://store.rerum.io/API.html#overwrite)") + const err = new Error("No record id to overwrite! (https://store.rerum.io/API.html#overwrite)") + err.status = 400 + throw err } const overwriteOptions = { diff --git a/routes/update.js b/routes/update.js index 3e72dd3..a025875 100644 --- a/routes/update.js +++ b/routes/update.js @@ -10,7 +10,9 @@ router.put('/', rest.verifyJsonContentType, checkAccessToken, async (req, res, n try { // check for @id; any value is valid if (!(req.body['@id'] ?? req.body.id)) { - throw Error("No record id to update! (https://store.rerum.io/API.html#update)") + const err = new Error("No record id to update! (https://store.rerum.io/API.html#update)") + err.status = 400 + throw err } const updateBody = JSON.stringify(req.body)