Skip to content

Hide draft and private occurrences from anonymous series reads - #601

Merged
tompscanlan merged 2 commits into
mainfrom
fix/series-events-anon-visibility
Aug 20, 2026
Merged

tompscanlan merged 2 commits into
mainfrom
fix/series-events-anon-visibility

Conversation

@tompscanlan

Copy link
Copy Markdown
Contributor

The problem

GET /api/events/series/:seriesSlug/events is a public route with no status or
visibility filter. Anonymous callers received draft and private occurrences of a
series along with the published ones. This is live today.

VisibilityGuard is applied to the route but never checks it. The guard reads a
:slug route param, and the only param on this route is :seriesSlug, so the
guard finds no event to look up and allows the request through.

The response also carried every loaded column, because neither EventEntity nor
GroupEntity has any @Exclude decorators. That included sourceData, which
holds the original poster's DID, handle, CID and rkey, and the internal
matrixRoomId.

The fix

findEventsBySeriesSlug takes a new optional publicOnly flag. When set, it adds
the same predicate the guard already applies to GET /events/:slug: published or
cancelled status, and public or unlisted visibility. The predicate runs in SQL
rather than after the fetch, so paging stays correct.

The controller sets the flag when the request has no user. Signed-in callers get
the same results they get today.

For anonymous callers the controller also clears sourceData and matrixRoomId.
sourceId is left in place because the web app renders it as the public AT
Protocol attribution link for events imported from Bluesky.

Why the flag is opt-in

About twelve internal callers share this method, including series materialization,
the existence check, and template lookup. Those need to see every occurrence.
If drafts were hidden from them they would treat existing occurrences as missing
and create duplicates. Only the public controller path passes the flag.

VisibilityGuard itself is unchanged. Teaching it about series would mean adding
a series lookup and a new branch to a guard that many routes depend on. The query
predicate gives the same result with a smaller blast radius.

Status values

The filter allows published and cancelled, not published alone. That matches the
guard at visibility.guard.ts and the other public queries in
event-query.service.ts. Without cancelled, a cancelled occurrence would quietly
disappear from a public series list instead of showing as cancelled.

Tests

Seven new tests, at both layers.

Four on the controller cover which callers get the restricted query and that the
two fields are cleared for anonymous callers only.

Three on the query service assert the predicates themselves, including that draft,
pending and private are all excluded, and that leaving publicOnly unset keeps
the query unrestricted so materialization still sees every occurrence.

Both layers are needed. The controller tests mock the query method, so on their
own they only prove the controller asks for a restricted query, not that the
query applies the restriction. Deleting the two predicates left all four
controller tests passing with the leak restored. The query service tests fail in
that case.

Not in this change

Anonymous access is what this fixes. Any authenticated user can still read every
occurrence and every field on this endpoint, which is existing behavior and is
tracked separately.

The endpoint also has no date filter, so a caller with no parameters gets the ten
oldest occurrences rather than upcoming ones. That is a real bug but it is not a
leak, and it is tracked separately.

… reads

GET /api/events/series/:seriesSlug/events is a public route, but it had no
status or visibility filter. Anonymous callers received draft and private
occurrences of a series along with the published ones.

VisibilityGuard is on the route but never checks it. The guard reads a :slug
route param, and the only param on this route is :seriesSlug, so the guard
finds no event to check and allows the request.

findEventsBySeriesSlug now takes an optional publicOnly flag that adds the
same predicate the guard applies to GET /events/:slug, which is published or
cancelled status and public or unlisted visibility. The flag is opt-in
because series materialization and template lookup call the same method and
need to see every occurrence. If they could not see drafts they would create
duplicates of occurrences that already exist.

The controller sets the flag for callers with no user, so signed-in callers
keep the results they get today.

The response also carried two fields that anonymous callers should not get.
Neither entity has @exclude, so every loaded column was sent, including
sourceData with the original poster's DID, handle, CID and rkey, and the
internal matrixRoomId. Both are now removed for anonymous callers. sourceId
is left alone because the web app shows it as public attribution for events
imported from Bluesky.
The controller tests mock findEventsBySeriesSlug, so they check that the
controller asks for a restricted query but not that the query applies the
restriction. Deleting the two predicates left every test passing while the
anonymous leak came back.

These tests assert the status and visibility predicates directly, including
that draft, pending and private are all excluded, and that an unset
publicOnly leaves the query unrestricted so series materialization still
sees every occurrence.
@tompscanlan
tompscanlan merged commit a9e57e9 into main Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant