The problem
exposeRelationships hides a relation from reads, but not from the relationship endpoints. A resource that deliberately hides a relation still serves it, and still lets a client write it.
export default class MediaSourceResource extends JsonApiResource<MediaSource> {
static model = () => MediaSource
static exposeRelationships = []
}
That relation is absent from documents and rejected in ?include=, as intended. But with a relationships controller registered:
GET /media-sources/1/relationships/binding → serves the linkage
GET /media-sources/1/binding → serves the related resources
PATCH /media-sources/1/relationships/binding → writes it
Why
isRelationExposed in src/resource.ts:30 carries this docstring:
The single home for the relation-visibility rule: a relation is exposed unless the model hides it (serializeAs: null) or the resource leaves it out of exposeRelationships. Shared by include validation and serialization so the two can never drift apart.
It has two callers, src/query.ts:105 for include validation and src/document_builder.ts:154 for serialization. The relationship endpoints do not go through it. They go through getRelationOrFail in src/relationships.ts:27, which checks only half the rule:
if (!relation || relation.serializeAs === null) {
throw new JsonApiException(
{ title: 'Not Found', detail: `"${name}" is not a relationship of ${Model.name}` },
{ status: 404 }
)
}
serializeAs: null is honoured. exposeRelationships is not.
Three call sites are affected, covering every relationship endpoint:
src/relationships.ts:93 → updateRelationship, so PATCH/POST/DELETE
src/relationships.ts:168 → fetchLinkage, so GET /:id/relationships/:name
src/context.ts:245 → renderRelated, so GET /:id/:name
So the "single home" is not single, and the drift the docstring rules out is exactly what happened, just along an axis it did not consider.
Why it matters more than a wrong type
This widens what is reachable rather than getting a detail wrong. exposeRelationships is the tool you reach for to keep a relation out of the API, and someone using it that way would reasonably assume a hidden relation is hidden everywhere. Registering a relationships controller then quietly re-opens it, for reads and writes both.
It is also silent. Nothing in the documents advertises the relation, so there is no reason to go looking.
Proposed fix
getRelationOrFail needs the resource class, not just the model, so it can apply isRelationExposed and 404 an unexposed relation the same way it 404s an unknown one. The registry already resolves a resource from a model, so the three call sites all have what they need.
Worth deciding explicitly whether an unexposed relation should be a 404 (indistinguishable from one that does not exist, which is the safer default) or a 403.
A test that a relation excluded by exposeRelationships is unreachable through all five relationship routes would pin this, since the current suite covers the read paths only.
The problem
exposeRelationshipshides a relation from reads, but not from the relationship endpoints. A resource that deliberately hides a relation still serves it, and still lets a client write it.That relation is absent from documents and rejected in
?include=, as intended. But with a relationships controller registered:Why
isRelationExposedinsrc/resource.ts:30carries this docstring:It has two callers,
src/query.ts:105for include validation andsrc/document_builder.ts:154for serialization. The relationship endpoints do not go through it. They go throughgetRelationOrFailinsrc/relationships.ts:27, which checks only half the rule:serializeAs: nullis honoured.exposeRelationshipsis not.Three call sites are affected, covering every relationship endpoint:
src/relationships.ts:93→updateRelationship, so PATCH/POST/DELETEsrc/relationships.ts:168→fetchLinkage, soGET /:id/relationships/:namesrc/context.ts:245→renderRelated, soGET /:id/:nameSo the "single home" is not single, and the drift the docstring rules out is exactly what happened, just along an axis it did not consider.
Why it matters more than a wrong type
This widens what is reachable rather than getting a detail wrong.
exposeRelationshipsis the tool you reach for to keep a relation out of the API, and someone using it that way would reasonably assume a hidden relation is hidden everywhere. Registering a relationships controller then quietly re-opens it, for reads and writes both.It is also silent. Nothing in the documents advertises the relation, so there is no reason to go looking.
Proposed fix
getRelationOrFailneeds the resource class, not just the model, so it can applyisRelationExposedand 404 an unexposed relation the same way it 404s an unknown one. The registry already resolves a resource from a model, so the three call sites all have what they need.Worth deciding explicitly whether an unexposed relation should be a 404 (indistinguishable from one that does not exist, which is the safer default) or a 403.
A test that a relation excluded by
exposeRelationshipsis unreachable through all five relationship routes would pin this, since the current suite covers the read paths only.