You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
There was a sprint focused on cache where implementations of HEAD requests were required. Artifacts remain in the API controller for /since, /history, and /query HEAD requests.
I believe it has been determined that these are not valid HEAD requests. HEAD requests are only for single pieces of data behind a PURL. If we aren't going to use them, we should delete them.
Picking this up while doing the paging work in #310, since #304 is blocked on the answer here.
The scope has shrunk since 2022. Of the three endpoints named above, only /query still has a HEAD artifact:
Route
Declaration
HEAD artifact
/v1/api/query
routes/query.js:8-9 — .post()and.head()
queryHeadRequest, controllers/history.js:86
/v1/since/:_id
routes/since.js:7 — .get() only
none
/v1/history/:_id
routes/history.js:7 — .get() only
none
/since and /history need no decision. They are plain GET routes, Express serves HEAD from the GET handler natively, and the contract test already encodes exactly that at __tests__/core_provider_contract.test.js:80 (// Express serves HEAD from the GET handler natively.). Verified live against the dev deployment — GET and HEAD both return 200 for /v1/id/{id}, /v1/history/{id} and /v1/since/{id}.
So the question is only about HEAD /v1/api/query, and I think the answer is the one suggested above: remove it.
Why it is not a valid HEAD request
RFC 9110 §9.3.2 defines HEAD strictly in terms of GET:
The HEAD method is identical to GET except that the server MUST NOT send content in the response.
There is no "HEAD of a POST" in HTTP. /query is a POST endpoint because the filter travels in the request body, so HEAD /v1/api/query is answering a question about a GET /v1/api/query that does not exist.
And it cannot be rescued by keeping the body, because that is the second half of the problem. RFC 9110 §9.3.1 says a GET request body "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request." HEAD inherits that. The endpoint needs a body to mean anything, and a body is exactly what the method has no defined semantics for.
This is not theoretical
Our own test tooling already refuses it. From routes/__tests__/query.test.js:76:
supertest cannot send a JSON body on HEAD cleanly (superagent rejects the object), so the request body is omitted here.
The test was written around the refusal rather than the refusal being treated as the signal it is.
And when the body is dropped, the handler queries for everything.queryHeadRequest has no empty-body guard, where POST /query has an explicit one (controllers/crud.js:78-85, "Hey now, don't ask for everything...this can happen by accident. Don't allow it."). Verified read-only against localhost:3001:
POST /v1/api/query?limit=2 {} -> 400 guard fires
HEAD /v1/api/query?limit=2 {} -> 200 no guard
HEAD /v1/api/query?limit=100 (no body) -> 200 Content-Length: 111788
db.find(undefined) matches everything, so a bodiless HEAD returns a full unfiltered page. Any client, proxy or intermediary that strips the body — which the spec permits — gets that. It is not an access-control problem, since /query is public either way, but it does mean the one verb that cannot reliably carry a filter is also the one with no protection against having none.
There is a smaller signal pointing the same direction: queryHeadRequest lives in controllers/history.js, not controllers/crud.js alongside query. That is where the 2022 sprint left it.
What removal touches
routes/query.js:9 — drop .head(controller.queryHeadRequest)
controllers/history.js:86-107 and its export at :109
db-controller.js:16, 38 — the import and re-export
openapi/contracts/core-provider.openapi.yaml — drop head: under /api/query. The other four head: declarations (/id/{id}, /id/{id}/expanded, /since/{id}, /history/{id}) are all GET-backed and stay. __tests__/core_provider_contract.test.js cross-checks the spec against routes/api-routes.js, so both move together or the test fails.
routes/__tests__/query.test.js:67-100 — the HEAD /query block
Breaking for anyone calling it, though a client relying on a bodiless HEAD is currently receiving an unfiltered page, so it is worth asking what they think they are getting.
#304 proposes changing HEAD /query from 404 to 200 with Content-Length: 2 on an empty page, and correctly says to answer this issue first. If the handler is removed, that work is moot and #304 should close with it.
Worth noting that #310 has already fixed the other half of #304 for free, since HEAD /query shares getPagination():
before after #310
?limit=abc 200 / 200 -> 400 / 400 POST and HEAD agree
?skip=150000 200 / 200 -> 400 / 400 agree
?limit=1&limit=2 200 / 200 -> 400 / 400 agree
empty page 200 / 404 -> 200 / 404 still disagrees
The empty-page status is the only divergence left, and it disappears along with the handler.
Activity
thehabes commentedon Sep 8, 2026
Picking this up while doing the paging work in #310, since #304 is blocked on the answer here.
The scope has shrunk since 2022. Of the three endpoints named above, only
/querystill has a HEAD artifact:/v1/api/queryroutes/query.js:8-9—.post()and.head()queryHeadRequest,controllers/history.js:86/v1/since/:_idroutes/since.js:7—.get()only/v1/history/:_idroutes/history.js:7—.get()only/sinceand/historyneed no decision. They are plain GET routes, Express serves HEAD from the GET handler natively, and the contract test already encodes exactly that at__tests__/core_provider_contract.test.js:80(// Express serves HEAD from the GET handler natively.). Verified live against the dev deployment —GETandHEADboth return 200 for/v1/id/{id},/v1/history/{id}and/v1/since/{id}.So the question is only about
HEAD /v1/api/query, and I think the answer is the one suggested above: remove it.Why it is not a valid HEAD request
RFC 9110 §9.3.2 defines HEAD strictly in terms of GET:
There is no "HEAD of a POST" in HTTP.
/queryis a POST endpoint because the filter travels in the request body, soHEAD /v1/api/queryis answering a question about aGET /v1/api/querythat does not exist.And it cannot be rescued by keeping the body, because that is the second half of the problem. RFC 9110 §9.3.1 says a GET request body "has no generally defined semantics, cannot alter the meaning or target of the request, and might lead some implementations to reject the request." HEAD inherits that. The endpoint needs a body to mean anything, and a body is exactly what the method has no defined semantics for.
This is not theoretical
Our own test tooling already refuses it. From
routes/__tests__/query.test.js:76:The test was written around the refusal rather than the refusal being treated as the signal it is.
And when the body is dropped, the handler queries for everything.
queryHeadRequesthas no empty-body guard, wherePOST /queryhas an explicit one (controllers/crud.js:78-85, "Hey now, don't ask for everything...this can happen by accident. Don't allow it."). Verified read-only againstlocalhost:3001:db.find(undefined)matches everything, so a bodiless HEAD returns a full unfiltered page. Any client, proxy or intermediary that strips the body — which the spec permits — gets that. It is not an access-control problem, since/queryis public either way, but it does mean the one verb that cannot reliably carry a filter is also the one with no protection against having none.There is a smaller signal pointing the same direction:
queryHeadRequestlives incontrollers/history.js, notcontrollers/crud.jsalongsidequery. That is where the 2022 sprint left it.What removal touches
routes/query.js:9— drop.head(controller.queryHeadRequest)controllers/history.js:86-107and its export at:109db-controller.js:16, 38— the import and re-exportopenapi/contracts/core-provider.openapi.yaml— drophead:under/api/query. The other fourhead:declarations (/id/{id},/id/{id}/expanded,/since/{id},/history/{id}) are all GET-backed and stay.__tests__/core_provider_contract.test.jscross-checks the spec againstroutes/api-routes.js, so both move together or the test fails.routes/__tests__/query.test.js:67-100— theHEAD /queryblockBreaking for anyone calling it, though a client relying on a bodiless HEAD is currently receiving an unfiltered page, so it is worth asking what they think they are getting.
Effect on #304
#304 proposes changing
HEAD /queryfrom 404 to200withContent-Length: 2on an empty page, and correctly says to answer this issue first. If the handler is removed, that work is moot and #304 should close with it.Worth noting that #310 has already fixed the other half of #304 for free, since
HEAD /querysharesgetPagination():The empty-page status is the only divergence left, and it disappears along with the handler.