Skip to content
8 changes: 8 additions & 0 deletions scripts/ci/fixtures/d1-dynamic-bind-sites.json
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,14 @@
"strategy": "exact-chunk",
"fixedParams": 10
},
{
"key": "src/shared/src/db/queries/community/channel.ts:listServerChannelDirectoryForAdmin:inArray:1",
"strategy": "fixed-literal"
},
{
"key": "src/shared/src/db/queries/community/channel.ts:listServerChannelDirectoryForAdmin:inArray:2",
"strategy": "fixed-literal"
},
{
"key": "src/shared/src/db/queries/community/channel.ts:resolveVisibleChannelIdSet:inArray:1",
"strategy": "exact-chunk",
Expand Down
37 changes: 36 additions & 1 deletion src/shared/src/db/queries/community/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
import type { Database } from "../../index";
import { nanoid } from "nanoid";
import { PARTICIPANT_SOURCE, type ParticipantSource } from "../../../constants/community";
import { canSeePrivateChannel, visibilityIsDmParticipant } from "../../../utils/community-roles";
import { canSeePrivateChannel, visibilityIsDmParticipant, ROLES } from "../../../utils/community-roles";
import { formatHandle } from "../../../lib/discriminator";
import { user } from "../../schema";
import { chunk, D1_MAX_IN_PARAMS, maxInParams, maxRowsPerInsert } from "../_chunk";

Expand Down Expand Up @@ -321,6 +322,40 @@ export async function listServerChannels(db: Database, serverId: string) {
return rows;
}

export async function listServerChannelDirectoryForAdmin(db: Database, serverId: string, userId: string) {
const rows = await db
.select({
id: communityChannel.id,
name: communityChannel.name,
type: communityChannel.type,
category: { id: communityCategory.id, name: communityCategory.name, private: communityCategory.private },
creator: { name: user.name, discriminator: user.discriminator },
createdAt: communityChannel.createdAt,
})
.from(communityChannel)
.innerJoin(communityServerMember, and(
eq(communityServerMember.serverId, communityChannel.serverId),
eq(communityServerMember.userId, userId),
inArray(communityServerMember.role, [ROLES.OWNER, ROLES.ADMIN]),
))
.leftJoin(communityCategory, and(
eq(communityCategory.id, communityChannel.categoryId),
eq(communityCategory.serverId, serverId),
))
.leftJoin(user, and(eq(user.id, communityChannel.creatorId), isNull(user.deletedAt)))
.where(and(
eq(communityChannel.serverId, serverId),
isNull(communityChannel.parentChannelId),
inArray(communityChannel.type, ["text", "forum"]),
))
.orderBy(asc(communityCategory.position), asc(communityCategory.id), asc(communityChannel.position), asc(communityChannel.id));
return rows.map(({ creator, category, ...row }) => ({
...row,
category: category ? { ...category, private: !!category.private } : null,
creator: creator ? { name: creator.name, handle: formatHandle(creator.name, creator.discriminator) } : null,
}));
}

/**
* `resolveTargetForMember`'s channel-name resolver: matches by NAME only,
* scoped to top-level channels (`parentChannelId IS NULL`) — mirrors the
Expand Down
80 changes: 80 additions & 0 deletions src/shared/test/queries/community-channel-admin-directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Sqlite from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Database } from "../../src/db";
import { listServerChannelDirectoryForAdmin } from "../../src/db/queries/community/channel";

describe("listServerChannelDirectoryForAdmin", () => {
let sqlite: Sqlite.Database;
let db: Database;

beforeEach(() => {
sqlite = new Sqlite(":memory:");
sqlite.exec(`
CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT, discriminator TEXT, email TEXT, deletedAt TEXT);
CREATE TABLE community_server_member (id TEXT PRIMARY KEY, server_id TEXT, user_id TEXT, role TEXT);
CREATE TABLE community_category (id TEXT PRIMARY KEY, server_id TEXT, name TEXT, position INTEGER, private INTEGER);
CREATE TABLE community_channel (
id TEXT PRIMARY KEY, server_id TEXT, category_id TEXT, name TEXT, type TEXT,
position INTEGER, parent_channel_id TEXT, creator_id TEXT, created_at TEXT, topic TEXT
);
INSERT INTO user VALUES
('creator', 'Alice', '0042', 'private@example.com', NULL),
('deleted', 'Old name', '0043', 'deleted@example.com', '2026-01-01');
INSERT INTO community_server_member VALUES
('a', 's1', 'admin', 'admin'), ('o', 's1', 'owner', 'owner'),
('m', 's1', 'creator', 'member'), ('a2', 's2', 'other-admin', 'admin');
INSERT INTO community_category VALUES
('public', 's1', 'GENERAL', 2, 0), ('private', 's1', 'PRIVATE', 1, 1),
('s2-group', 's2', 'OTHER SERVER', 0, 0);
INSERT INTO community_channel VALUES
('uncat', 's1', NULL, 'uncategorized', 'text', 0, NULL, NULL, '2026-10-01T00:00:00.000Z', 'secret'),
('public-text', 's1', 'public', 'general', 'text', 1, NULL, 'creator', '2026-10-01T01:00:00.000Z', 'secret'),
('private-forum', 's1', 'private', 'hidden-forum', 'forum', 0, NULL, 'creator', '2026-10-01T02:00:00.000Z', 'secret'),
('private-text', 's1', 'private', 'hidden-text', 'text', 1, NULL, 'creator', '2026-10-01T03:00:00.000Z', 'secret'),
('deleted-creator', 's1', 'public', 'old-channel', 'text', 2, NULL, 'deleted', '2026-10-01T04:00:00.000Z', 'secret'),
('missing-creator', 's1', 'public', 'missing-creator', 'text', 3, NULL, 'gone', '2026-10-01T05:00:00.000Z', 'secret'),
('thread', 's1', 'private', 'child-thread', 'thread', 0, 'private-forum', 'creator', '2026-10-01', 'secret'),
('child-text', 's1', 'private', 'invalid-child', 'text', 0, 'private-text', 'creator', '2026-10-01', 'secret'),
('top-thread', 's1', NULL, 'invalid-top-thread', 'thread', 0, NULL, 'creator', '2026-10-01', 'secret'),
('dm', 's1', NULL, 'dm', 'dm', 0, NULL, 'creator', '2026-10-01', 'secret'),
('other-server', 's2', 's2-group', 'other', 'text', 0, NULL, 'creator', '2026-10-01', 'secret');
`);
db = drizzle(sqlite) as unknown as Database;
});

afterEach(() => sqlite.close());

it.each(["admin", "owner"])("includes unjoined private top-level channels for %s, in group order", async (viewer) => {
const rows = await listServerChannelDirectoryForAdmin(db, "s1", viewer);
expect(rows.map((row) => row.id)).toEqual([
"uncat", "private-forum", "private-text", "public-text", "deleted-creator", "missing-creator",
]);
expect(rows[0]).toEqual({
id: "uncat", name: "uncategorized", type: "text", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z",
});
expect(rows[1]).toEqual({
id: "private-forum", name: "hidden-forum", type: "forum", category: { id: "private", name: "PRIVATE", private: true },
creator: { name: "Alice", handle: "Alice#0042" }, createdAt: "2026-10-01T02:00:00.000Z",
});
expect(rows[3]!.category?.private).toBe(false);
expect(rows[4]!.creator).toBeNull();
expect(rows[5]!.creator).toBeNull();
expect(JSON.stringify(rows)).not.toMatch(/secret|email|discriminator|creatorId|topic/);
});

it.each(["creator", "outsider", "other-admin"])("does not read metadata for unauthorized viewer %s", async (viewer) => {
await expect(listServerChannelDirectoryForAdmin(db, "s1", viewer)).resolves.toEqual([]);
});

it("scopes admin membership to the requested server", async () => {
await expect(listServerChannelDirectoryForAdmin(db, "s2", "admin")).resolves.toEqual([]);
expect((await listServerChannelDirectoryForAdmin(db, "s2", "other-admin")).map((row) => row.id)).toEqual(["other-server"]);
await expect(listServerChannelDirectoryForAdmin(db, "nonexistent", "admin")).resolves.toEqual([]);
});

it("stops returning metadata immediately after an admin is downgraded", async () => {
sqlite.prepare("UPDATE community_server_member SET role = 'member' WHERE user_id = ?").run("admin");
await expect(listServerChannelDirectoryForAdmin(db, "s1", "admin")).resolves.toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { NextRequest } from "next/server"

const mocks = vi.hoisted(() => ({
getMember: vi.fn(),
listDirectory: vi.fn(),
getPrimaryDb: vi.fn(() => ({})),
kind: "human" as "human" | "bot",
}))

vi.mock("@/lib/db", () => ({ getPrimaryDb: mocks.getPrimaryDb }))
vi.mock("@alook/shared", async () => {
const actual = await vi.importActual<typeof import("@alook/shared")>("@alook/shared")
return {
...actual,
queries: {
...actual.queries,
communityMember: { getMember: mocks.getMember },
communityChannel: { listServerChannelDirectoryForAdmin: mocks.listDirectory },
},
}
})
vi.mock("@/lib/middleware/community-actor", async () => {
const actual = await vi.importActual<typeof import("@/lib/middleware/community-actor")>("@/lib/middleware/community-actor")
return {
...actual,
withCommunityActor: (handler: any) => (req: any, ctx: any) => handler(req, {
env: { DB: {} }, actor: { kind: mocks.kind, userId: "viewer" }, params: ctx?.params,
}),
}
})

import { GET } from "./route"

const request = () => new NextRequest("http://localhost/api/community/servers/s1/channels/admin")
const context = (id = "s1") => ({ params: { id } }) as never

describe("GET server admin channel directory", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.kind = "human"
mocks.listDirectory.mockResolvedValue([])
})

it.each(["admin", "owner"])("permits %s and returns only the directory response, without caching", async (role) => {
mocks.getMember.mockResolvedValue({ role })
const channels = [{
id: "private", name: "private", type: "forum", category: { id: "group", name: "PRIVATE", private: true },
creator: { name: "Alice", handle: "Alice#0042" }, createdAt: "2026-10-01T00:00:00.000Z",
}]
mocks.listDirectory.mockResolvedValue(channels)
const response = await GET(request(), context())
expect(response.status).toBe(200)
expect(response.headers.get("Cache-Control")).toBe("private, no-store")
expect(await response.json()).toEqual({ channels })
expect(mocks.getPrimaryDb).toHaveBeenCalledWith({})
expect(mocks.getMember).toHaveBeenCalledWith(expect.anything(), "s1", "viewer")
expect(mocks.listDirectory).toHaveBeenCalledWith(expect.anything(), "s1", "viewer")
})

it.each(["member", null, "unknown"])("rejects role %s before reading any channel metadata", async (role) => {
mocks.getMember.mockResolvedValue({ role })
const response = await GET(request(), context())
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: "admin permission required" })
expect(mocks.listDirectory).not.toHaveBeenCalled()
})

it("rejects an outsider or an admin requesting a different server", async () => {
mocks.getMember.mockImplementation((_db, serverId) => Promise.resolve(serverId === "s1" ? { role: "admin" } : null))
const response = await GET(request(), context("s2"))
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: "not a member of this server" })
expect(mocks.listDirectory).not.toHaveBeenCalled()
})

it("rejects bots before looking up a server", async () => {
mocks.kind = "bot"
const response = await GET(request(), context())
expect(response.status).toBe(403)
expect(mocks.getPrimaryDb).not.toHaveBeenCalled()
expect(mocks.getMember).not.toHaveBeenCalled()
expect(mocks.listDirectory).not.toHaveBeenCalled()
})

it("rejects a missing server id", async () => {
const response = await GET(request(), { params: {} } as never)
expect(response.status).toBe(400)
expect(mocks.getMember).not.toHaveBeenCalled()
expect(mocks.listDirectory).not.toHaveBeenCalled()
})
})
25 changes: 25 additions & 0 deletions src/web/src/app/api/community/servers/[id]/channels/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server"
import { queries } from "@alook/shared"
import { getPrimaryDb } from "@/lib/db"
import { withCommunityActor, rejectBot } from "@/lib/middleware/community-actor"
import { writeError } from "@/lib/middleware/helpers"
import { requireServerAdmin } from "@/lib/community/permissions"

const headers = { "Cache-Control": "private, no-store" }

export const GET = withCommunityActor(async (_req, ctx) => {
const rejected = rejectBot(ctx.actor)
if (rejected) {
rejected.headers.set("Cache-Control", headers["Cache-Control"])
return rejected
}
const serverId = ctx.params?.id
if (!serverId) return writeError("missing server id", 400, headers)

const db = getPrimaryDb(ctx.env.DB)
const auth = await requireServerAdmin(db, serverId, ctx.actor.userId)
if (!auth.ok) return writeError(auth.error, auth.status, headers)

const channels = await queries.communityChannel.listServerChannelDirectoryForAdmin(db, serverId, ctx.actor.userId)
return NextResponse.json({ channels }, { headers })
})
1 change: 1 addition & 0 deletions src/web/src/app/c/channels/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ export default function ServerLayout({ children }: { children: ReactNode }) {
<Dialog open={serverSettingsOpen && !!currentServer && isAdmin} onOpenChange={(o) => { if (!o) closeSettings() }}>
<DialogContent className="flex h-dvh max-h-dvh w-screen max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 sm:h-[calc(100vh-4rem)] sm:max-h-180 sm:w-[calc(100vw-4rem)] sm:max-w-4xl sm:rounded-xl" showCloseButton={false}>
<ServerSettings
isAdmin={isAdmin}
section={settingsSection}
setSection={setSettingsSection}
onClose={closeSettings}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createElement, type PropsWithChildren } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { describe, expect, it, vi } from "vitest"
import { render, screen, setupUser, waitFor } from "@/test/react-dom-harness"
import { tid } from "@/lib/community/testids"

vi.mock("./settings-shell.module.css", () => ({ default: { shell: "settings-shell" } }))
const apiFetchMock = vi.hoisted(() => vi.fn())
vi.mock("@/lib/api/client", () => ({ apiFetch: apiFetchMock, toastApiError: vi.fn() }))
vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" }))
vi.mock("@/hooks/community/use-server-panels", () => ({ useInvites: () => ({ invites: [], isLoading: false }) }))
vi.mock("@/hooks/community/use-bots", () => ({ useBots: () => ({ bots: [] }) }))
vi.mock("@/hooks/community/use-notification-settings", async (importOriginal) => ({
...await importOriginal<typeof import("@/hooks/community/use-notification-settings")>(),
useBotNotificationSetting: () => ({ data: undefined, isError: false, isLoading: false }),
useSetBotNotificationSetting: () => ({ mutate: vi.fn(), isPending: false }),
}))

import { ServerSettings } from "./server-settings"

const props = { section: "overview" as const, setSection: vi.fn(), onClose: vi.fn(), serverId: "server-1", serverName: "Server", members: [] }

function setup() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const wrapper = ({ children }: PropsWithChildren) => createElement(QueryClientProvider, { client }, children)
return { client, wrapper }
}

describe("Channels tab access", () => {
it("lets an administrator select Channels", async () => {
const { client, wrapper } = setup()
const setSection = vi.fn()
const user = setupUser()
const rendered = render(createElement(ServerSettings, { ...props, isAdmin: true, setSection }), { wrapper })
await user.click(screen.getByTestId(tid.settingsTab("channels")))
expect(setSection).toHaveBeenCalledWith("channels")
rendered.unmount()
client.clear()
})

it("hides the entry and rejects a stale Channels selection after role loss", () => {
const { client, wrapper } = setup()
const rendered = render(createElement(ServerSettings, { ...props, isAdmin: false, section: "channels" }), { wrapper })
expect(screen.queryByTestId(tid.settingsTab("channels"))).not.toBeInTheDocument()
expect(screen.getByRole("heading", { name: "overview" })).toBeVisible()
expect(screen.queryByTestId(tid.settingsChannels)).not.toBeInTheDocument()
rendered.unmount()
client.clear()
})

it("requests only when Channels opens, and removes the list when the role is lost", async () => {
apiFetchMock.mockReset().mockResolvedValue({ channels: [{ id: "private-1", name: "private-1", category: null, creator: null, createdAt: "2026-10-01T00:00:00.000Z" }] })
const { client, wrapper } = setup()
const rendered = render(createElement(ServerSettings, { ...props, isAdmin: true }), { wrapper })
expect(apiFetchMock).not.toHaveBeenCalled()
rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: true, section: "members" }))
expect(apiFetchMock).not.toHaveBeenCalled()
rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: true, section: "channels" }))
await waitFor(() => expect(screen.getByTestId(tid.settingsChannel("private-1"))).toBeVisible())
expect(apiFetchMock).toHaveBeenCalledOnce()
rendered.rerender(createElement(ServerSettings, { ...props, isAdmin: false, section: "channels" }))
expect(screen.queryByTestId(tid.settingsChannels)).not.toBeInTheDocument()
expect(screen.queryByText("private-1")).not.toBeInTheDocument()
expect(apiFetchMock).toHaveBeenCalledOnce()
rendered.unmount()
client.clear()
})
})
Loading
Loading