Summary
getPublicChannelIds in src/lib/public-channels.ts builds its public-channel allowlist from client.channels.getChannels({ workspaceId }) only. That endpoint is membership-scoped — it excludes public channels the current user hasn't joined.
This is the same bug class that #249 fixed in resolveChannelRef (src/lib/refs.ts), but in a file #249 didn't touch.
// src/lib/public-channels.ts:12
const channels = await client.channels.getChannels({ workspaceId })
const publicIds = new Set<number>()
for (const ch of channels) {
if (ch.public) publicIds.add(ch.id)
}
Impact
getPublicChannelIds feeds two callers:
-
assertChannelIsPublic (public-channels.ts:25) — gates thread access. A thread in a public channel the user hasn't joined is wrongly rejected with NOT_FOUND: "This thread belongs to a private channel.", even though it's public and readable. This is the exact threads/get workaround path that the newsletter-crawler skill relies on.
-
filterVisibleSearchResults (src/lib/search-helpers.ts:183) — client-side filter on search results. Practically masked today because the server /api/v3/search index is itself membership-scoped (returns zero for unjoined channels), so there's nothing to wrongly filter out. But it's latent: if/when search scope widens server-side, this filter would silently drop unjoined-public results.
Scale check (from #249): Doist workspace 1585 has 998 public channels, 829 of them unjoined for the test user — so this bites in production, not just edge cases.
Suggested fix
Mirror #249: merge getChannels with workspaces.getPublicChannels and dedupe by id. Same shape already used in src/commands/channel/list.ts:177-180 for the --scope public path.
Out of scope
Summary
getPublicChannelIdsinsrc/lib/public-channels.tsbuilds its public-channel allowlist fromclient.channels.getChannels({ workspaceId })only. That endpoint is membership-scoped — it excludes public channels the current user hasn't joined.This is the same bug class that #249 fixed in
resolveChannelRef(src/lib/refs.ts), but in a file #249 didn't touch.Impact
getPublicChannelIdsfeeds two callers:assertChannelIsPublic(public-channels.ts:25) — gates thread access. A thread in a public channel the user hasn't joined is wrongly rejected withNOT_FOUND: "This thread belongs to a private channel.", even though it's public and readable. This is the exactthreads/getworkaround path that the newsletter-crawler skill relies on.filterVisibleSearchResults(src/lib/search-helpers.ts:183) — client-side filter on search results. Practically masked today because the server/api/v3/searchindex is itself membership-scoped (returns zero for unjoined channels), so there's nothing to wrongly filter out. But it's latent: if/when search scope widens server-side, this filter would silently drop unjoined-public results.Scale check (from #249): Doist workspace 1585 has 998 public channels, 829 of them unjoined for the test user — so this bites in production, not just edge cases.
Suggested fix
Mirror #249: merge
getChannelswithworkspaces.getPublicChannelsand dedupe by id. Same shape already used insrc/commands/channel/list.ts:177-180for the--scope publicpath.Out of scope
tw searchonly indexing joined channels) — that's a Twist API concern, not fixable in the CLI. Context: https://github.com/Doist/doist-os/pull/245#issuecomment-4606838736