Skip to content

exposeRelationships is ignored by the relationship endpoints #10

Description

@evoactivity

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:93updateRelationship, so PATCH/POST/DELETE
  • src/relationships.ts:168fetchLinkage, so GET /:id/relationships/:name
  • src/context.ts:245renderRelated, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions