From 6d2fd5a55f5f5ef8168ce2c1fed72ba1decd1aab Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 28 Jul 2026 12:36:32 -0500 Subject: [PATCH 1/8] No 'Last Modified' header signal for the /gog/id/ endpoint --- controllers/gog.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/controllers/gog.js b/controllers/gog.js index e1d6078..2700cba 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -438,7 +438,10 @@ const expandedId = async function (req, res, next) { // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") - res.set(utils.configureLastModifiedHeader(match)) + // No Last-Modified here, unlike GET /v1/id/:_id. It would describe `match`, the root entity + // before expand() merges the targeting Annotations, so it does not move when an Annotation + // changes -- a wrong freshness signal for the composed body. The ETag Express derives from + // that body does move, so it is the correct sole validator for this route. // Include current version for optimistic locking (parity with GET /v1/id/:_id) res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") // No GENERATOR/CREATOR filter: merge every current targeting Annotation, matching the From 26d494e4e1eadceede27635ed38fa08e6ca4ce96 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 28 Jul 2026 12:39:22 -0500 Subject: [PATCH 2/8] cleanup comment --- controllers/gog.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/controllers/gog.js b/controllers/gog.js index 2700cba..8a155e5 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -439,10 +439,7 @@ const expandedId = async function (req, res, next) { res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") // No Last-Modified here, unlike GET /v1/id/:_id. It would describe `match`, the root entity - // before expand() merges the targeting Annotations, so it does not move when an Annotation - // changes -- a wrong freshness signal for the composed body. The ETag Express derives from - // that body does move, so it is the correct sole validator for this route. - // Include current version for optimistic locking (parity with GET /v1/id/:_id) + // before expand() merges the targeting Annotations. res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") // No GENERATOR/CREATOR filter: merge every current targeting Annotation, matching the // client's historical expand() behavior (its checkMatch is short-circuited to true). From 5b0ac26565126d9ff1da7b7fa359d1aa26fee3e1 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 28 Jul 2026 12:40:02 -0500 Subject: [PATCH 3/8] cleanup comment --- controllers/gog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/gog.js b/controllers/gog.js index 8a155e5..66c6b7d 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -438,7 +438,7 @@ const expandedId = async function (req, res, next) { // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") - // No Last-Modified here, unlike GET /v1/id/:_id. It would describe `match`, the root entity + // No Last-Modified here, unlike GET /v1/id/:_id. It would `match`, the root entity // before expand() merges the targeting Annotations. res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") // No GENERATOR/CREATOR filter: merge every current targeting Annotation, matching the From e23728d7bb9d09dad19a6392add18111d1cb0e85 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Tue, 28 Jul 2026 12:51:43 -0500 Subject: [PATCH 4/8] comment cleanup --- controllers/gog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controllers/gog.js b/controllers/gog.js index 66c6b7d..ed5d7e3 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -438,7 +438,7 @@ const expandedId = async function (req, res, next) { // Same browser-caching policy as GET /v1/id/:_id so this stable URI is cached (24h). res.set(utils.configureWebAnnoHeadersFor(match)) res.set("Cache-Control", "max-age=86400, must-revalidate") - // No Last-Modified here, unlike GET /v1/id/:_id. It would `match`, the root entity + // No Last-Modified here, unlike GET /v1/id/:_id. It would compare against the root entity // before expand() merges the targeting Annotations. res.set("Current-Overwritten-Version", match.__rerum?.isOverwritten ?? "") // No GENERATOR/CREATOR filter: merge every current targeting Annotation, matching the From a16d63bfe732d02340005531fe2f9c1b681fbf3d Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 29 Jul 2026 14:18:41 -0500 Subject: [PATCH 5/8] Update for parity between GET and HEAD headers --- __tests__/core_provider_contract.test.js | 6 ++ controllers/history.js | 108 +---------------------- db-controller.js | 5 +- routes/__tests__/history.test.js | 21 ++++- routes/__tests__/id.test.js | 40 ++++++++- routes/__tests__/since.test.js | 21 ++++- routes/history.js | 5 +- routes/id.js | 6 +- routes/since.js | 5 +- 9 files changed, 93 insertions(+), 124 deletions(-) diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index 9cea017..8f9c821 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -76,6 +76,12 @@ function parseRouteOperations(filePath, prefix) { for (const methodMatch of match[2].matchAll(/\.(get|post|put|patch|delete|head)\(/g)) { methods.add(methodMatch[1].toUpperCase()) } + // Express serves HEAD from the GET handler when a route registers no HEAD handler of its + // own, so a GET route implements HEAD whether or not '.head(' appears in the source. Routes + // rely on this deliberately: it is the only way HEAD can carry the ETag, which Express + // derives from the response body. Counting only the literal '.head(' would report those + // routes as not implementing HEAD when they answer it. + if (methods.has('GET')) methods.add('HEAD') for (const method of methods) { operations.add(`${method} ${routePath}`) } diff --git a/controllers/history.js b/controllers/history.js index eb29970..b7b55a5 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -79,36 +79,6 @@ const history = async function (req, res, next) { res.json(ancestors) } -/** - * Allow for HEAD requests by @id via the RERUM getByID pattern /v1/id/ - * No object is returned, but the Content-Length header is set. - * Note /v1/id/{blank} does not route here. It routes to the generic 404 - * */ -const idHeadRequest = async function (req, res, next) { - res.set("Content-Type", "application/json; charset=utf-8") - let id = req.params["_id"] - try { - let match = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) - if (match) { - // Use res.end() instead of res.sendStatus(200) — sendStatus writes "OK" as the body - // and overwrites Content-Type and Content-Length. HEAD must preserve our manual headers. - // Mirror the GET pipeline (idNegotiation) so Content-Length matches the GET payload. - const negotiated = idNegotiation(match) - const size = Buffer.byteLength(JSON.stringify(negotiated)) - res.set("Content-Length", size) - res.status(200).end() - return - } - let err = { - "message": `No RERUM object with id '${id}'`, - "status": 404 - } - return next(utils.createExpressError(err)) - } catch (error) { - return next(utils.createExpressError(error)) - } -} - /** * Allow for HEAD requests via the RERUM getByProperties pattern /v1/api/query * No objects are returned, but the Content-Length header is set. @@ -136,80 +106,4 @@ const queryHeadRequest = async function (req, res, next) { } } -/** - * Allow for HEAD requests via the RERUM since pattern /v1/since/:_id - * No objects are returned, but the Content-Length header is set. - * */ -const sinceHeadRequest = async function (req, res, next) { - res.set("Content-Type", "application/json; charset=utf-8") - let id = req.params["_id"] - let obj - try { - obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) - } catch (error) { - return next(utils.createExpressError(error)) - } - if (null === obj) { - let err = { - message: `Cannot produce a history. There is no object in the database with id '${id}'. Check the URL.`, - status: 404 - } - return next(utils.createExpressError(err)) - } - let all = await getAllVersions(obj) - .catch(error => { - console.error(error) - return [] - }) - let descendants = getAllDescendants(all, obj, []) - if (descendants.length) { - const negotiated = descendants.map(o => idNegotiation(o)) - const size = Buffer.byteLength(JSON.stringify(negotiated)) - res.set("Content-Length", size) - res.status(200).end() - return - } - // GET returns "[]" for the empty case — match its byte length. - res.set("Content-Length", Buffer.byteLength("[]")) - res.status(200).end() -} - -/** - * Allow for HEAD requests via the RERUM since pattern /v1/history/:_id - * No objects are returned, but the Content-Length header is set. - * */ -const historyHeadRequest = async function (req, res, next) { - res.set("Content-Type", "application/json; charset=utf-8") - let id = req.params["_id"] - let obj - try { - obj = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) - } catch (error) { - return next(utils.createExpressError(error)) - } - if (null === obj) { - let err = { - message: "Cannot produce a history. There is no object in the database with this id. Check the URL.", - status: 404 - } - return next(utils.createExpressError(err)) - } - let all = await getAllVersions(obj) - .catch(error => { - console.error(error) - return [] - }) - let ancestors = getAllAncestors(all, obj, []) - if (ancestors.length) { - const negotiated = ancestors.map(o => idNegotiation(o)) - const size = Buffer.byteLength(JSON.stringify(negotiated)) - res.set("Content-Length", size) - res.status(200).end() - return - } - // GET returns "[]" for the empty case — match its byte length. - res.set("Content-Length", Buffer.byteLength("[]")) - res.status(200).end() -} - -export { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } +export { since, history, queryHeadRequest } diff --git a/db-controller.js b/db-controller.js index 44a5f33..7f16166 100644 --- a/db-controller.js +++ b/db-controller.js @@ -13,7 +13,7 @@ import { searchAsWords, searchAsPhrase } from './controllers/search.js' import { deleteObj } from './controllers/delete.js' import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './controllers/update.js' import { bulkCreate, bulkUpdate } from './controllers/bulk.js' -import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from './controllers/history.js' +import { since, history, queryHeadRequest } from './controllers/history.js' import { release } from './controllers/release.js' import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand, expandedId } from './controllers/gog.js' @@ -34,12 +34,9 @@ export default { id, bulkCreate, bulkUpdate, - idHeadRequest, queryHeadRequest, since, history, - sinceHeadRequest, - historyHeadRequest, remove, _gog_glosses_from_manuscript, _gog_fragments_from_manuscript, diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 8e5850c..6884bc1 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -9,9 +9,8 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) -// Mount /history for both GET (controller.history) and HEAD (controller.historyHeadRequest). -// `.head()` must be registered before `.use()` to win over the method-agnostic mount. -routeTester.head("/history/:_id", controller.historyHeadRequest) +// Mount /history matching routes/history.js: GET only, no HEAD handler. Express answers HEAD through +// the GET handler and drops the body itself, which keeps HEAD's headers identical to GET's. routeTester.use("/history/:_id", controller.history) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" @@ -65,4 +64,20 @@ describe('HEAD /history/:id', () => { const response = await request(routeTester).head(`/history/${MOCK_ID}`) assert.strictEqual(response.statusCode, 404) }) + + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to + // routes/history.js would drop the ETag and the LD headers, and this test would catch it. + it("sends the same headers as the GET, including the validators", async () => { + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const getResp = await request(routeTester).get(`/history/${MOCK_ID}`) + + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const headResp = await request(routeTester).head(`/history/${MOCK_ID}`) + + assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + for (const header of ['etag', 'content-type', 'link', 'allow']) { + assert.strictEqual(headResp.headers[header], getResp.headers[header], + `HEAD and GET must agree on ${header}`) + } + }) }) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index cb469cb..07208d1 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -9,10 +9,9 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) -// Mount our own /id route without auth that will use controller.id (GET) and -// controller.idHeadRequest (HEAD). `.use()` is method-agnostic, so the explicit -// `.head()` entry must come first to intercept HEAD before the catch-all GET handler. -routeTester.head("/id/:_id", controller.idHeadRequest) +// Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler. +// Express answers HEAD through the GET handler and drops the body itself, which is what keeps +// HEAD's headers identical to GET's. routeTester.use("/id/:_id", controller.id) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" @@ -71,6 +70,39 @@ describe('HEAD /id/:id', () => { const response = await request(routeTester).head(`/id/${MOCK_ID}`) assert.strictEqual(response.statusCode, 404) }) + + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. A HEAD missing these gives a + // client nothing to revalidate against, so it has to refetch the whole object. This also + // guards the routing choice: adding a .head() handler to routes/id.js would drop the ETag, + // which only Express can derive (it hashes the response body), and this test would catch it. + it("sends the same headers as the GET, including the validators", async () => { + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const getResp = await request(routeTester).get(`/id/${MOCK_ID}`) + + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const headResp = await request(routeTester).head(`/id/${MOCK_ID}`) + + assert.strictEqual(headResp.headers['cache-control'], 'max-age=86400, must-revalidate') + assert.ok(headResp.headers['last-modified'], 'HEAD must report Last-Modified') + assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + for (const header of ['cache-control', 'last-modified', 'etag', 'content-type', + 'link', 'allow', 'current-overwritten-version', 'location']) { + assert.strictEqual(headResp.headers[header], getResp.headers[header], + `HEAD and GET must agree on ${header}`) + } + }) + + it("reports Last-Modified from the overwrite time once an object has been overwritten", async () => { + const overwritten = structuredClone(mockDoc) + overwritten.__rerum.isOverwritten = '2025-06-24T10:00:00' + db.findOne.mockResolvedValueOnce(overwritten) + + const response = await request(routeTester).head(`/id/${MOCK_ID}`) + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.headers['last-modified'], new Date('2025-06-24T10:00:00').toUTCString()) + assert.strictEqual(response.headers['current-overwritten-version'], '2025-06-24T10:00:00') + }) }) describe('id route overwrite headers', () => { diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index 28e02e5..2728839 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -9,9 +9,8 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) -// Mount /since for both GET (controller.since) and HEAD (controller.sinceHeadRequest). -// `.head()` must be registered before `.use()` to win over the method-agnostic mount. -routeTester.head("/since/:_id", controller.sinceHeadRequest) +// Mount /since matching routes/since.js: GET only, no HEAD handler. Express answers HEAD through +// the GET handler and drops the body itself, which keeps HEAD's headers identical to GET's. routeTester.use("/since/:_id", controller.since) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" @@ -65,4 +64,20 @@ describe('HEAD /since/:id', () => { const response = await request(routeTester).head(`/since/${MOCK_ID}`) assert.strictEqual(response.statusCode, 404) }) + + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to + // routes/since.js would drop the ETag and the LD headers, and this test would catch it. + it("sends the same headers as the GET, including the validators", async () => { + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const getResp = await request(routeTester).get(`/since/${MOCK_ID}`) + + db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) + const headResp = await request(routeTester).head(`/since/${MOCK_ID}`) + + assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + for (const header of ['etag', 'content-type', 'link', 'allow']) { + assert.strictEqual(headResp.headers[header], getResp.headers[header], + `HEAD and GET must agree on ${header}`) + } + }) }) diff --git a/routes/history.js b/routes/history.js index 9abd6dd..b4c4a04 100644 --- a/routes/history.js +++ b/routes/history.js @@ -3,9 +3,12 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' +// No .head() here on purpose -- see routes/id.js. Express answers HEAD with the GET handler and +// drops the body itself, so HEAD keeps the LD headers and the ETag a hand-written handler cannot +// produce. The HEAD handler this replaced repeated the whole version-graph traversal to compute +// nothing the GET did not already compute. router.route('/:_id') .get(controller.history) - .head(controller.historyHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method, please use GET.' res.status(405).end() diff --git a/routes/id.js b/routes/id.js index 0e3d8d7..206de00 100644 --- a/routes/id.js +++ b/routes/id.js @@ -3,9 +3,13 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' +// No .head() here on purpose. Express routes HEAD to the GET handler when no HEAD handler is +// registered, and suppresses the body itself, so HEAD answers with exactly the headers GET sends +// -- Cache-Control, Last-Modified, and the ETag Express derives from the body. A hand-written +// HEAD handler cannot produce that ETag, and every header the GET gains later has to be +// duplicated into it or the two silently drift apart. /gog/id/ already relies on this. router.route('/:_id') .get(controller.id) - .head(controller.idHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method, please use GET.' res.status(405).end() diff --git a/routes/since.js b/routes/since.js index c328fd5..0c779a8 100644 --- a/routes/since.js +++ b/routes/since.js @@ -3,9 +3,12 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' +// No .head() here on purpose -- see routes/id.js. Express answers HEAD with the GET handler and +// drops the body itself, so HEAD keeps the LD headers and the ETag a hand-written handler cannot +// produce. The HEAD handler this replaced repeated the whole version-graph traversal to compute +// nothing the GET did not already compute. router.route('/:_id') .get(controller.since) - .head(controller.sinceHeadRequest) .all((req, res, next) => { res.statusMessage = 'Improper request method, please use GET.' res.status(405).end() From 67c0290e4e20f1904910574ecd7070224b1fb198 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Wed, 29 Jul 2026 15:22:52 -0500 Subject: [PATCH 6/8] Comment cleanup --- __tests__/core_provider_contract.test.js | 6 +----- routes/__tests__/history.test.js | 3 --- routes/__tests__/id.test.js | 8 ++------ routes/__tests__/since.test.js | 3 +-- routes/history.js | 4 ---- routes/id.js | 5 ----- routes/since.js | 4 ---- 7 files changed, 4 insertions(+), 29 deletions(-) diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index 8f9c821..c73d130 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -76,11 +76,7 @@ function parseRouteOperations(filePath, prefix) { for (const methodMatch of match[2].matchAll(/\.(get|post|put|patch|delete|head)\(/g)) { methods.add(methodMatch[1].toUpperCase()) } - // Express serves HEAD from the GET handler when a route registers no HEAD handler of its - // own, so a GET route implements HEAD whether or not '.head(' appears in the source. Routes - // rely on this deliberately: it is the only way HEAD can carry the ETag, which Express - // derives from the response body. Counting only the literal '.head(' would report those - // routes as not implementing HEAD when they answer it. + // Express serves HEAD from the GET handler natively. if (methods.has('GET')) methods.add('HEAD') for (const method of methods) { operations.add(`${method} ${routePath}`) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 6884bc1..3b9abd3 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -8,9 +8,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) - -// Mount /history matching routes/history.js: GET only, no HEAD handler. Express answers HEAD through -// the GET handler and drops the body itself, which keeps HEAD's headers identical to GET's. routeTester.use("/history/:_id", controller.history) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 07208d1..51a9a55 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -10,8 +10,6 @@ const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) // Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler. -// Express answers HEAD through the GET handler and drops the body itself, which is what keeps -// HEAD's headers identical to GET's. routeTester.use("/id/:_id", controller.id) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" @@ -71,10 +69,8 @@ describe('HEAD /id/:id', () => { assert.strictEqual(response.statusCode, 404) }) - // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. A HEAD missing these gives a - // client nothing to revalidate against, so it has to refetch the whole object. This also - // guards the routing choice: adding a .head() handler to routes/id.js would drop the ETag, - // which only Express can derive (it hashes the response body), and this test would catch it. + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to + // routes/id.js would drop the ETag and the LD headers, and this test would catch it. it("sends the same headers as the GET, including the validators", async () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const getResp = await request(routeTester).get(`/id/${MOCK_ID}`) diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index 2728839..edf93e0 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -9,8 +9,7 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) -// Mount /since matching routes/since.js: GET only, no HEAD handler. Express answers HEAD through -// the GET handler and drops the body itself, which keeps HEAD's headers identical to GET's. +// Mount /since matching routes/since.js: GET only, no HEAD handler. routeTester.use("/since/:_id", controller.since) const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" diff --git a/routes/history.js b/routes/history.js index b4c4a04..44943e9 100644 --- a/routes/history.js +++ b/routes/history.js @@ -3,10 +3,6 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' -// No .head() here on purpose -- see routes/id.js. Express answers HEAD with the GET handler and -// drops the body itself, so HEAD keeps the LD headers and the ETag a hand-written handler cannot -// produce. The HEAD handler this replaced repeated the whole version-graph traversal to compute -// nothing the GET did not already compute. router.route('/:_id') .get(controller.history) .all((req, res, next) => { diff --git a/routes/id.js b/routes/id.js index 206de00..fdfca44 100644 --- a/routes/id.js +++ b/routes/id.js @@ -3,11 +3,6 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' -// No .head() here on purpose. Express routes HEAD to the GET handler when no HEAD handler is -// registered, and suppresses the body itself, so HEAD answers with exactly the headers GET sends -// -- Cache-Control, Last-Modified, and the ETag Express derives from the body. A hand-written -// HEAD handler cannot produce that ETag, and every header the GET gains later has to be -// duplicated into it or the two silently drift apart. /gog/id/ already relies on this. router.route('/:_id') .get(controller.id) .all((req, res, next) => { diff --git a/routes/since.js b/routes/since.js index 0c779a8..4c545df 100644 --- a/routes/since.js +++ b/routes/since.js @@ -3,10 +3,6 @@ const router = express.Router() //This controller will handle all MongoDB interactions. import controller from '../db-controller.js' -// No .head() here on purpose -- see routes/id.js. Express answers HEAD with the GET handler and -// drops the body itself, so HEAD keeps the LD headers and the ETag a hand-written handler cannot -// produce. The HEAD handler this replaced repeated the whole version-graph traversal to compute -// nothing the GET did not already compute. router.route('/:_id') .get(controller.since) .all((req, res, next) => { From 891202065a6b1b9ef302edb2b4cb909a177e4b0f Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 30 Jul 2026 09:04:41 -0500 Subject: [PATCH 7/8] update comment --- routes/__tests__/history.test.js | 3 +-- routes/__tests__/id.test.js | 3 +-- routes/__tests__/since.test.js | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 3b9abd3..9f1bb9b 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -62,8 +62,7 @@ describe('HEAD /history/:id', () => { assert.strictEqual(response.statusCode, 404) }) - // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to - // routes/history.js would drop the ETag and the LD headers, and this test would catch it. + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. it("sends the same headers as the GET, including the validators", async () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const getResp = await request(routeTester).get(`/history/${MOCK_ID}`) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 51a9a55..e6701fc 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -69,8 +69,7 @@ describe('HEAD /id/:id', () => { assert.strictEqual(response.statusCode, 404) }) - // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to - // routes/id.js would drop the ETag and the LD headers, and this test would catch it. + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. it("sends the same headers as the GET, including the validators", async () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const getResp = await request(routeTester).get(`/id/${MOCK_ID}`) diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index edf93e0..e0ada50 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -64,8 +64,7 @@ describe('HEAD /since/:id', () => { assert.strictEqual(response.statusCode, 404) }) - // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. Adding a .head() handler back to - // routes/since.js would drop the ETag and the LD headers, and this test would catch it. + // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. it("sends the same headers as the GET, including the validators", async () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const getResp = await request(routeTester).get(`/since/${MOCK_ID}`) From a23c161952947254324a9f14b37129cdffea3d68 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 30 Jul 2026 09:10:06 -0500 Subject: [PATCH 8/8] Update test for happy path to ensure they have the proper headers for browser caching --- routes/__tests__/history.test.js | 5 +++++ routes/__tests__/since.test.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index 9f1bb9b..b2ac718 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -70,6 +70,11 @@ describe('HEAD /history/:id', () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const headResp = await request(routeTester).head(`/history/${MOCK_ID}`) + // Anchor to the success path. A 404 pair also agrees on every header below, so without + // these the comparison would pass while proving nothing. + assert.strictEqual(getResp.statusCode, 200) + assert.strictEqual(headResp.statusCode, 200) + assert.ok(headResp.headers['link'], 'HEAD must carry the JSON-LD context Link header') assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') for (const header of ['etag', 'content-type', 'link', 'allow']) { assert.strictEqual(headResp.headers[header], getResp.headers[header], diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index e0ada50..af11dd9 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -72,6 +72,11 @@ describe('HEAD /since/:id', () => { db.findOne.mockResolvedValueOnce(structuredClone(mockDoc)) const headResp = await request(routeTester).head(`/since/${MOCK_ID}`) + // Anchor to the success path. A 404 pair also agrees on every header below, so without + // these the comparison would pass while proving nothing. + assert.strictEqual(getResp.statusCode, 200) + assert.strictEqual(headResp.statusCode, 200) + assert.ok(headResp.headers['link'], 'HEAD must carry the JSON-LD context Link header') assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') for (const header of ['etag', 'content-type', 'link', 'allow']) { assert.strictEqual(headResp.headers[header], getResp.headers[header],