Skip to content

Commit f8b2719

Browse files
craigcarlyleclaude
andauthored
fix: stop treating unjoined public channels as private (#102)
* fix: stop treating unjoined public channels as private The private-channel check only knew about the channels you've joined, so a thread in a public channel you hadn't joined failed with "This thread belongs to a private channel" and needed --include-private-channels. When a channel isn't one you've joined, the check now looks for it among the workspace's public channels before failing. Joined channels work as before with no extra call, and joined private channels still need the flag. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * perf: use the channel's own public flag when the caller has it channel threads and thread create already have the channel they resolved, so the private-channel check now takes its public flag instead of looking it up. For an unjoined channel resolved by name, the lookup was fetching the workspace's public channels a second time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5d18feb commit f8b2719

5 files changed

Lines changed: 106 additions & 15 deletions

File tree

‎src/commands/channel/threads.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ describe('channel threads', () => {
437437

438438
await program.parseAsync(['node', 'tdc', 'channel', 'threads', '12345', '--json'])
439439

440-
expect(vi.mocked(assertChannelIsPublic)).toHaveBeenCalledWith('CH100', 1)
440+
expect(vi.mocked(assertChannelIsPublic)).toHaveBeenCalledWith('CH100', 1, true)
441441
})
442442

443443
it('propagates the error when the channel is private and the guard rejects', async () => {

‎src/commands/channel/threads.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export async function showChannelThreads(
156156
}
157157

158158
const channel = await resolveChannelRef(channelRef, workspaceId)
159-
await assertChannelIsPublic(channel.id, workspaceId)
159+
await assertChannelIsPublic(channel.id, workspaceId, channel.public)
160160

161161
const limitNum = options.limit ? parseInt(options.limit, 10) : 50
162162
const limit = Number.isFinite(limitNum) && limitNum > 0 ? limitNum : 50

‎src/commands/thread/create.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export async function createThread(
4747

4848
const client = await getCommsClient()
4949
const channel = await client.channels.getChannel(channelId)
50-
await assertChannelIsPublic(channelId, channel.workspaceId)
50+
await assertChannelIsPublic(channelId, channel.workspaceId, channel.public)
5151

5252
let resolved: ResolvedNotify | undefined
5353
if (allIds) {

‎src/lib/public-channels.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ const mockGetCommsClient = vi.mocked(getCommsClient)
1616

1717
function makeMockChannels(
1818
channels: Array<{ id: string; public: boolean }>,
19+
workspacePublicChannels: Array<{ id: string }> = [],
1920
): ReturnType<typeof getCommsClient> {
2021
return Promise.resolve({
2122
channels: {
2223
getChannels: vi.fn().mockResolvedValue(channels),
2324
},
25+
workspaces: {
26+
getPublicChannels: vi.fn().mockResolvedValue(workspacePublicChannels),
27+
},
2428
}) as unknown as ReturnType<typeof getCommsClient>
2529
}
2630

@@ -161,6 +165,58 @@ describe('assertChannelIsPublic', () => {
161165
await expect(assertChannelIsPublic('CH5', 100)).resolves.toBeUndefined()
162166
})
163167

168+
it("allows a public channel the user hasn't joined", async () => {
169+
mockGetCommsClient.mockImplementation(() =>
170+
makeMockChannels([{ id: 'CH5', public: true }], [{ id: 'CH5' }, { id: 'CH7' }]),
171+
)
172+
173+
await expect(assertChannelIsPublic('CH7', 100)).resolves.toBeUndefined()
174+
})
175+
176+
it('throws for a channel that is neither joined nor public', async () => {
177+
mockGetCommsClient.mockImplementation(() =>
178+
makeMockChannels([{ id: 'CH5', public: true }], [{ id: 'CH5' }, { id: 'CH7' }]),
179+
)
180+
181+
await expect(assertChannelIsPublic('CH9', 100)).rejects.toThrow('private channel')
182+
})
183+
184+
it("only lists the workspace's public channels for a channel the user hasn't joined", async () => {
185+
const getPublicChannels = vi.fn().mockResolvedValue([{ id: 'CH5' }, { id: 'CH7' }])
186+
mockGetCommsClient.mockResolvedValue({
187+
channels: {
188+
getChannels: vi.fn().mockResolvedValue([
189+
{ id: 'CH5', public: true },
190+
{ id: 'CH6', public: false },
191+
]),
192+
},
193+
workspaces: { getPublicChannels },
194+
} as unknown as Awaited<ReturnType<typeof getCommsClient>>)
195+
196+
await assertChannelIsPublic('CH5', 100)
197+
await expect(assertChannelIsPublic('CH6', 100)).rejects.toThrow('private channel')
198+
expect(getPublicChannels).not.toHaveBeenCalled()
199+
200+
await assertChannelIsPublic('CH7', 100)
201+
await assertChannelIsPublic('CH7', 100)
202+
expect(getPublicChannels).toHaveBeenCalledTimes(1)
203+
expect(getPublicChannels).toHaveBeenCalledWith(100)
204+
})
205+
206+
it('uses the public flag a caller passes instead of looking the channel up', async () => {
207+
const getChannels = vi.fn()
208+
const getPublicChannels = vi.fn()
209+
mockGetCommsClient.mockResolvedValue({
210+
channels: { getChannels },
211+
workspaces: { getPublicChannels },
212+
} as unknown as Awaited<ReturnType<typeof getCommsClient>>)
213+
214+
await expect(assertChannelIsPublic('CH7', 100, true)).resolves.toBeUndefined()
215+
await expect(assertChannelIsPublic('CH6', 100, false)).rejects.toThrow('private channel')
216+
expect(getChannels).not.toHaveBeenCalled()
217+
expect(getPublicChannels).not.toHaveBeenCalled()
218+
})
219+
164220
it('allows private channels when --include-private-channels is set', async () => {
165221
process.argv = ['node', 'tdc', '--include-private-channels']
166222
resetGlobalArgs()

‎src/lib/public-channels.ts‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,65 @@ import { getCommsClient } from './api.js'
22
import { CliError } from './errors.js'
33
import { includePrivateChannels } from './global-args.js'
44

5-
const publicChannelCache = new Map<number, Set<string>>()
5+
// Channel ID to whether it's public, for the channels the user has joined.
6+
const joinedChannelCache = new Map<number, Map<string, boolean>>()
7+
const workspacePublicChannelCache = new Map<number, Set<string>>()
68

7-
export async function getPublicChannelIds(workspaceId: number): Promise<Set<string>> {
8-
const cached = publicChannelCache.get(workspaceId)
9+
async function getJoinedChannels(workspaceId: number): Promise<Map<string, boolean>> {
10+
const cached = joinedChannelCache.get(workspaceId)
911
if (cached) return cached
1012

1113
const client = await getCommsClient()
1214
const channels = await client.channels.getChannels({ workspaceId })
13-
const publicIds = new Set<string>()
14-
for (const ch of channels) {
15-
if (ch.public) publicIds.add(ch.id)
16-
}
17-
publicChannelCache.set(workspaceId, publicIds)
15+
const joined = new Map(channels.map((ch) => [ch.id, ch.public]))
16+
joinedChannelCache.set(workspaceId, joined)
17+
return joined
18+
}
19+
20+
/** IDs of the public channels the user has joined. */
21+
export async function getPublicChannelIds(workspaceId: number): Promise<Set<string>> {
22+
const joined = await getJoinedChannels(workspaceId)
23+
return new Set([...joined].filter(([, isPublic]) => isPublic).map(([id]) => id))
24+
}
25+
26+
// Every public channel in the workspace, joined or not. Much bigger than the joined list (all
27+
// members of every public channel) and empty for guests, so it's only used for unjoined channels.
28+
async function getWorkspacePublicChannelIds(workspaceId: number): Promise<Set<string>> {
29+
const cached = workspacePublicChannelCache.get(workspaceId)
30+
if (cached) return cached
31+
32+
const client = await getCommsClient()
33+
const channels = await client.workspaces.getPublicChannels(workspaceId)
34+
const publicIds = new Set(channels.map((ch) => ch.id))
35+
workspacePublicChannelCache.set(workspaceId, publicIds)
1836
return publicIds
1937
}
2038

2139
export function clearPublicChannelCache(): void {
22-
publicChannelCache.clear()
40+
joinedChannelCache.clear()
41+
workspacePublicChannelCache.clear()
42+
}
43+
44+
async function isPublicChannel(channelId: string, workspaceId: number): Promise<boolean> {
45+
const joined = await getJoinedChannels(workspaceId)
46+
return (
47+
joined.get(channelId) ??
48+
// getChannels only returns joined channels, so an unjoined one could still be public.
49+
(await getWorkspacePublicChannelIds(workspaceId)).has(channelId)
50+
)
2351
}
2452

25-
export async function assertChannelIsPublic(channelId: string, workspaceId: number): Promise<void> {
53+
/**
54+
* Throws unless the channel is public or --include-private-channels is set. Callers that
55+
* already have the channel pass its `public` flag, which skips the lookups.
56+
*/
57+
export async function assertChannelIsPublic(
58+
channelId: string,
59+
workspaceId: number,
60+
isPublic?: boolean,
61+
): Promise<void> {
2662
if (includePrivateChannels()) return
27-
const publicIds = await getPublicChannelIds(workspaceId)
28-
if (!publicIds.has(channelId)) {
63+
if (!(isPublic ?? (await isPublicChannel(channelId, workspaceId)))) {
2964
throw new CliError('NOT_FOUND', 'This thread belongs to a private channel.', [
3065
'Use --include-private-channels to access it',
3166
])

0 commit comments

Comments
 (0)