diff --git a/packages/junior/src/chat/slack/users.ts b/packages/junior/src/chat/slack/users.ts new file mode 100644 index 00000000..6478879d --- /dev/null +++ b/packages/junior/src/chat/slack/users.ts @@ -0,0 +1,238 @@ +import { getSlackClient, withSlackRetries } from "@/chat/slack/client"; + +/** Normalized Slack user profile with custom fields from the Slack workspace. */ +export interface SlackUserProfile { + id: string; + team_id?: string; + name?: string; + real_name?: string; + display_name?: string; + title?: string; + email?: string; + status_text?: string; + status_emoji?: string; + is_bot: boolean; + is_deleted: boolean; + timezone?: string; + profile_fields?: Array<{ + id: string; + label?: string; + value?: string; + alt?: string; + }>; +} + +interface SlackProfileFieldRaw { + value?: string; + alt?: string; + label?: string; +} + +interface SlackUserRaw { + id?: string; + team_id?: string; + name?: string; + real_name?: string; + deleted?: boolean; + is_bot?: boolean; + tz?: string; + profile?: { + display_name?: string; + real_name?: string; + title?: string; + email?: string; + status_text?: string; + status_emoji?: string; + fields?: Record | null; + }; +} + +function normalizeUser(raw: SlackUserRaw): SlackUserProfile { + const rawFields = raw.profile?.fields; + const profileFields: SlackUserProfile["profile_fields"] = []; + + if (rawFields && typeof rawFields === "object") { + for (const [id, field] of Object.entries(rawFields)) { + if (!field) continue; + profileFields.push({ + id, + label: field.label || undefined, + value: field.value || undefined, + alt: field.alt || undefined, + }); + } + } + + return { + id: raw.id ?? "", + team_id: raw.team_id || undefined, + name: raw.name || undefined, + real_name: raw.real_name || raw.profile?.real_name || undefined, + display_name: raw.profile?.display_name || undefined, + title: raw.profile?.title || undefined, + email: raw.profile?.email || undefined, + status_text: raw.profile?.status_text || undefined, + status_emoji: raw.profile?.status_emoji || undefined, + is_bot: raw.is_bot ?? false, + is_deleted: raw.deleted ?? false, + timezone: raw.tz || undefined, + ...(profileFields.length > 0 ? { profile_fields: profileFields } : {}), + }; +} + +/** Look up a Slack user by ID, returning the full profile including custom fields. */ +export async function lookupSlackUserProfile( + userId: string, +): Promise { + const client = getSlackClient(); + const result = await withSlackRetries( + () => client.users.info({ user: userId }), + 3, + { action: "users.info" }, + ); + + const user = result.user as SlackUserRaw | undefined; + if (!user) { + throw new Error(`Slack users.info returned no user for ${userId}`); + } + + return normalizeUser(user); +} + +/** Look up a Slack user by email. Returns null when no user matches. */ +export async function lookupSlackUserByEmail( + email: string, +): Promise { + const client = getSlackClient(); + + let result; + try { + result = await withSlackRetries( + () => client.users.lookupByEmail({ email }), + 3, + { action: "users.lookupByEmail" }, + ); + } catch (error: unknown) { + const apiError = (error as { apiError?: string }).apiError; + if (apiError === "users_not_found") { + return null; + } + throw error; + } + + const user = result.user as SlackUserRaw | undefined; + if (!user) { + return null; + } + + return normalizeUser(user); +} + +export interface SlackUserSearchResult { + users: SlackUserProfile[]; + searched_pages: number; + searched_user_count: number; + truncated: boolean; +} + +/** Rank match quality: exact > prefix > word-boundary > substring > miss. */ +function scoreMatch(user: SlackUserRaw, queryLower: string): number { + const name = (user.name ?? "").toLowerCase(); + const realName = ( + user.real_name ?? + user.profile?.real_name ?? + "" + ).toLowerCase(); + const displayName = (user.profile?.display_name ?? "").toLowerCase(); + + if (name === queryLower || displayName === queryLower) return 100; + if (realName === queryLower) return 90; + if (name.startsWith(queryLower) || displayName.startsWith(queryLower)) + return 70; + if (realName.startsWith(queryLower)) return 60; + + const realNameWords = realName.split(/\s+/); + if (realNameWords.some((w) => w === queryLower)) return 55; + if (realNameWords.some((w) => w.startsWith(queryLower))) return 50; + + if (name.includes(queryLower) || displayName.includes(queryLower)) return 30; + if (realName.includes(queryLower)) return 20; + + return 0; +} + +/** Search workspace users by name with bounded pagination through `users.list`. */ +export async function searchSlackUsers(options: { + query: string; + limit?: number; + maxPages?: number; + includeDeleted?: boolean; + includeBots?: boolean; +}): Promise { + const { + query, + limit = 10, + maxPages = 3, + includeDeleted = false, + includeBots = false, + } = options; + const queryLower = query.toLowerCase().trim(); + + const client = getSlackClient(); + const matches: Array<{ user: SlackUserRaw; score: number }> = []; + let cursor: string | undefined; + let pages = 0; + let totalScanned = 0; + let truncated = false; + + while (pages < maxPages) { + pages++; + + const result = await withSlackRetries( + () => + client.users.list({ + limit: 200, + ...(cursor ? { cursor } : {}), + }), + 3, + { action: "users.list" }, + ); + + const members = (result.members ?? []) as SlackUserRaw[]; + totalScanned += members.length; + + for (const member of members) { + if (!includeDeleted && member.deleted) continue; + if (!includeBots && member.is_bot) continue; + if (member.id === "USLACKBOT") continue; + + const score = scoreMatch(member, queryLower); + if (score > 0) { + matches.push({ user: member, score }); + } + } + + const nextCursor = result.response_metadata?.next_cursor; + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + // True only when we hit the page cap with more data remaining. + if (pages >= maxPages && cursor) { + truncated = true; + } + + matches.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return (a.user.name ?? "").localeCompare(b.user.name ?? ""); + }); + + return { + users: matches.slice(0, limit).map((m) => normalizeUser(m.user)), + searched_pages: pages, + searched_user_count: totalScanned, + truncated, + }; +} diff --git a/packages/junior/src/chat/tools/index.ts b/packages/junior/src/chat/tools/index.ts index dc7ba602..c57afb15 100644 --- a/packages/junior/src/chat/tools/index.ts +++ b/packages/junior/src/chat/tools/index.ts @@ -26,6 +26,7 @@ import { createSlackListUpdateItemTool, } from "@/chat/tools/slack/list-tools"; import { createSlackThreadReadTool } from "@/chat/tools/slack/thread-read"; +import { createSlackUserLookupTool } from "@/chat/tools/slack/user-lookup"; import { createSystemTimeTool } from "@/chat/tools/system-time"; import { createAdvisorTool } from "@/chat/tools/advisor/tool"; import type { ToolDefinition } from "@/chat/tools/definition"; @@ -111,6 +112,7 @@ export function createTools( slackCanvasRead: createSlackCanvasReadTool(), slackCanvasUpdate: createSlackCanvasUpdateTool(state, context), slackThreadRead: createSlackThreadReadTool(context), + slackUserLookup: createSlackUserLookupTool(), slackListCreate: createSlackListCreateTool(state), slackListAddItems: createSlackListAddItemsTool(state), slackListGetItems: createSlackListGetItemsTool(state), diff --git a/packages/junior/src/chat/tools/slack/user-lookup.ts b/packages/junior/src/chat/tools/slack/user-lookup.ts new file mode 100644 index 00000000..18ef6369 --- /dev/null +++ b/packages/junior/src/chat/tools/slack/user-lookup.ts @@ -0,0 +1,136 @@ +import { Type } from "@sinclair/typebox"; +import { SlackActionError } from "@/chat/slack/client"; +import { + lookupSlackUserProfile, + lookupSlackUserByEmail, + searchSlackUsers, +} from "@/chat/slack/users"; +import { tool } from "@/chat/tools/definition"; + +export function createSlackUserLookupTool() { + return tool({ + description: + "Look up Slack user profiles by user ID, email, or name search. Use when you need to identify a user, resolve cross-platform identity, or look up profile details like title or status. Returns profile fields including custom fields. For user ID lookup, pass a Slack user ID (e.g. U039RR91S). For search, pass a name query.", + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: Type.Object({ + user_id: Type.Optional( + Type.String({ + minLength: 1, + description: + "Slack user ID to look up (e.g. U039RR91S). Mutually exclusive with email and query.", + }), + ), + email: Type.Optional( + Type.String({ + minLength: 3, + description: + "Email address to look up. Mutually exclusive with user_id and query.", + }), + ), + query: Type.Optional( + Type.String({ + minLength: 2, + description: + "Name to search for (matches against username, display name, real name). Mutually exclusive with user_id and email.", + }), + ), + limit: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 20, + description: + "Maximum number of results to return for name search. Defaults to 10.", + }), + ), + max_pages: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 5, + description: + "Maximum number of Slack API pages to scan for name search. Defaults to 3.", + }), + ), + include_bots: Type.Optional( + Type.Boolean({ + description: + "Include bot accounts in name search results. Defaults to false.", + }), + ), + }), + execute: async ({ + user_id, + email, + query, + limit, + max_pages, + include_bots, + }) => { + const modes = [user_id, email, query].filter(Boolean); + if (modes.length === 0) { + return { + ok: false, + error: + "Provide exactly one of user_id, email, or query to look up a Slack user.", + }; + } + if (modes.length > 1) { + return { + ok: false, + error: "Only one of user_id, email, or query can be provided.", + }; + } + + try { + if (user_id) { + return { + ok: true, + mode: "user_id", + user: await lookupSlackUserProfile(user_id), + }; + } + + if (email) { + const profile = await lookupSlackUserByEmail(email); + if (!profile) { + return { + ok: false, + mode: "email", + email, + error: "No Slack user found with that email address.", + }; + } + return { ok: true, mode: "email", user: profile }; + } + + const result = await searchSlackUsers({ + query: query!, + limit: limit ?? 10, + maxPages: max_pages ?? 3, + includeBots: include_bots ?? false, + }); + + return { + ok: true, + mode: "query", + query, + count: result.users.length, + searched_pages: result.searched_pages, + searched_user_count: result.searched_user_count, + truncated: result.truncated, + users: result.users, + }; + } catch (error) { + if (error instanceof SlackActionError) { + return { + ok: false, + error: error.message, + slack_error: error.apiError, + code: error.code, + ...(error.needed ? { needed_scope: error.needed } : {}), + }; + } + throw error; + } + }, + }); +} diff --git a/packages/junior/tests/fixtures/slack/factories/api.ts b/packages/junior/tests/fixtures/slack/factories/api.ts index d19446cb..7681d075 100644 --- a/packages/junior/tests/fixtures/slack/factories/api.ts +++ b/packages/junior/tests/fixtures/slack/factories/api.ts @@ -361,25 +361,98 @@ export function usersInfoOk( userName?: string; realName?: string; displayName?: string; + title?: string; + email?: string; + statusText?: string; + statusEmoji?: string; + isBot?: boolean; + deleted?: boolean; + tz?: string; + fields?: Record; } = {}, ): { ok: true; - user: { - id: string; - name: string; - real_name: string; - profile: { display_name: string; real_name: string }; - }; + user: Record; } { return slackOk({ user: { id: input.userId ?? TEST_USER_ID, name: input.userName ?? "testuser", real_name: input.realName ?? "Test User", + deleted: input.deleted ?? false, + is_bot: input.isBot ?? false, + tz: input.tz ?? "America/Los_Angeles", profile: { display_name: input.displayName ?? "Test User", real_name: input.realName ?? "Test User", + title: input.title ?? "", + email: input.email ?? "testuser@example.com", + status_text: input.statusText ?? "", + status_emoji: input.statusEmoji ?? "", + ...(input.fields ? { fields: input.fields } : {}), }, }, }); } + +export function usersLookupByEmailOk( + input: { + userId?: string; + userName?: string; + realName?: string; + displayName?: string; + email?: string; + fields?: Record; + } = {}, +): { + ok: true; + user: Record; +} { + return usersInfoOk({ + ...input, + email: input.email ?? "testuser@example.com", + }); +} + +export function usersListPage( + input: { + members?: Array<{ + id?: string; + name?: string; + realName?: string; + displayName?: string; + deleted?: boolean; + isBot?: boolean; + fields?: Record; + }>; + nextCursor?: string; + } = {}, +): { + ok: true; + members: Array>; + response_metadata: { next_cursor: string }; +} { + const members = (input.members ?? []).map((m) => ({ + id: m.id ?? TEST_USER_ID, + name: m.name ?? "testuser", + real_name: m.realName ?? "Test User", + deleted: m.deleted ?? false, + is_bot: m.isBot ?? false, + profile: { + display_name: m.displayName ?? m.name ?? "Test User", + real_name: m.realName ?? "Test User", + title: "", + email: "", + status_text: "", + status_emoji: "", + ...(m.fields ? { fields: m.fields } : {}), + }, + })); + + return slackOk({ + members, + response_metadata: { + next_cursor: input.nextCursor ?? "", + }, + }); +} diff --git a/packages/junior/tests/integration/advisor/advisor-tool.test.ts b/packages/junior/tests/integration/advisor/advisor-tool.test.ts index ed9daa3d..380bcaf5 100644 --- a/packages/junior/tests/integration/advisor/advisor-tool.test.ts +++ b/packages/junior/tests/integration/advisor/advisor-tool.test.ts @@ -198,6 +198,7 @@ describe("advisor tool", () => { "slackChannelListMessages", "slackListGetItems", "slackThreadRead", + "slackUserLookup", "systemTime", "webFetch", "webSearch", diff --git a/packages/junior/tests/integration/slack-user-lookup.test.ts b/packages/junior/tests/integration/slack-user-lookup.test.ts new file mode 100644 index 00000000..d5a53e0c --- /dev/null +++ b/packages/junior/tests/integration/slack-user-lookup.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "vitest"; +import { createSlackUserLookupTool } from "@/chat/tools/slack/user-lookup"; +import { usersInfoOk, usersListPage } from "../fixtures/slack/factories/api"; +import { + getCapturedSlackApiCalls, + queueSlackApiResponse, + queueSlackApiError, +} from "../msw/handlers/slack-api"; + +async function executeTool(tool: any, input: TInput) { + if (typeof tool?.execute !== "function") { + throw new Error("tool execute function missing"); + } + return await tool.execute(input, {} as any); +} + +describe("slackUserLookup", () => { + describe("user_id mode", () => { + it("returns a rich profile for a known user", async () => { + queueSlackApiResponse("users.info", { + body: usersInfoOk({ + userId: "U039RR91S", + userName: "dcramer", + realName: "David Cramer", + displayName: "David Cramer", + title: "Co-founder & CTO", + email: "david@sentry.io", + fields: { + Xf0123GITHUB: { + value: "https://github.com/dcramer", + alt: "dcramer", + label: "GitHub", + }, + }, + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { user_id: "U039RR91S" }); + + expect(result).toMatchObject({ + ok: true, + mode: "user_id", + user: { + id: "U039RR91S", + name: "dcramer", + real_name: "David Cramer", + display_name: "David Cramer", + title: "Co-founder & CTO", + email: "david@sentry.io", + is_bot: false, + is_deleted: false, + }, + }); + + expect(result.user.profile_fields).toHaveLength(1); + expect(result.user.profile_fields[0]).toMatchObject({ + id: "Xf0123GITHUB", + label: "GitHub", + value: "https://github.com/dcramer", + }); + + expect(getCapturedSlackApiCalls("users.info")).toHaveLength(1); + }); + + it("returns user without custom fields when none are set", async () => { + queueSlackApiResponse("users.info", { + body: usersInfoOk({ + userId: "U_BASIC", + userName: "basic", + realName: "Basic User", + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { user_id: "U_BASIC" }); + + expect(result).toMatchObject({ + ok: true, + mode: "user_id", + user: { + id: "U_BASIC", + name: "basic", + real_name: "Basic User", + is_bot: false, + }, + }); + expect(result.user.profile_fields).toBeUndefined(); + }); + + it("handles user not found", async () => { + queueSlackApiError("users.info", { error: "user_not_found" }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { user_id: "U_NONEXISTENT" }); + + expect(result.ok).toBe(false); + expect(result.slack_error).toBe("user_not_found"); + }); + }); + + describe("email mode", () => { + it("finds a user by email", async () => { + queueSlackApiResponse("users.lookupByEmail", { + body: usersInfoOk({ + userId: "U_EMAIL", + userName: "emailuser", + realName: "Email User", + email: "emailuser@sentry.io", + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { email: "emailuser@sentry.io" }); + + expect(result).toMatchObject({ + ok: true, + mode: "email", + user: { + id: "U_EMAIL", + name: "emailuser", + email: "emailuser@sentry.io", + }, + }); + + expect(getCapturedSlackApiCalls("users.lookupByEmail")).toHaveLength(1); + }); + + it("returns error when email not found", async () => { + queueSlackApiError("users.lookupByEmail", { + error: "users_not_found", + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { email: "nobody@example.com" }); + + expect(result).toMatchObject({ + ok: false, + mode: "email", + error: "No Slack user found with that email address.", + }); + }); + }); + + describe("query mode", () => { + it("searches and ranks users by name", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [ + { id: "U1", name: "alice", realName: "Alice Smith" }, + { id: "U2", name: "bob", realName: "Bob Jones" }, + { + id: "U3", + name: "untitaker", + realName: "Markus Unterwaditzer", + displayName: "Markus", + }, + { id: "U4", name: "charlie", realName: "Charlie Markus Brown" }, + ], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { query: "markus" }); + + expect(result).toMatchObject({ + ok: true, + mode: "query", + query: "markus", + }); + + // Should find Markus matches, ranked by relevance + expect(result.users.length).toBeGreaterThanOrEqual(1); + // Display name exact match should come first + expect(result.users[0].id).toBe("U3"); + }); + + it("returns empty results when no match", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [ + { id: "U1", name: "alice", realName: "Alice Smith" }, + { id: "U2", name: "bob", realName: "Bob Jones" }, + ], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { query: "zzzzzz" }); + + expect(result).toMatchObject({ + ok: true, + mode: "query", + count: 0, + users: [], + }); + }); + + it("skips bots by default", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [ + { id: "U1", name: "junior", realName: "Junior Bot", isBot: true }, + { id: "U2", name: "junior-human", realName: "Junior Person" }, + ], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { query: "junior" }); + + expect(result.users).toHaveLength(1); + expect(result.users[0].id).toBe("U2"); + }); + + it("includes bots when requested", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [ + { id: "U1", name: "junior", realName: "Junior Bot", isBot: true }, + { id: "U2", name: "junior-human", realName: "Junior Person" }, + ], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { + query: "junior", + include_bots: true, + }); + + expect(result.users).toHaveLength(2); + }); + + it("reports truncated when page cap is reached with more data", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [{ id: "U1", name: "alice", realName: "Alice Smith" }], + nextCursor: "cursor_page2", + }), + }); + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [{ id: "U2", name: "alice2", realName: "Alice Jones" }], + nextCursor: "cursor_page3", + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { + query: "alice", + max_pages: 2, + }); + + expect(result).toMatchObject({ + ok: true, + count: 2, + searched_pages: 2, + truncated: true, + }); + }); + + it("reports not truncated when pagination ends naturally", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [{ id: "U1", name: "alice", realName: "Alice Smith" }], + nextCursor: "cursor_page2", + }), + }); + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [{ id: "U2", name: "alice2", realName: "Alice Jones" }], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { + query: "alice", + max_pages: 3, + }); + + expect(result).toMatchObject({ + ok: true, + count: 2, + searched_pages: 2, + truncated: false, + }); + }); + + it("skips deleted users", async () => { + queueSlackApiResponse("users.list", { + body: usersListPage({ + members: [ + { + id: "U1", + name: "deleteduser", + realName: "Deleted User", + deleted: true, + }, + { id: "U2", name: "activeuser", realName: "Active User" }, + ], + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { query: "user" }); + + expect(result.users).toHaveLength(1); + expect(result.users[0].id).toBe("U2"); + }); + }); + + describe("input validation", () => { + it("rejects when no input provided", async () => { + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, {}); + + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("Provide exactly one"), + }); + }); + + it("rejects when multiple inputs provided", async () => { + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { + user_id: "U123", + query: "alice", + }); + + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("Only one of"), + }); + }); + }); + + describe("registration", () => { + it("is registered in createTools", async () => { + const { createTools } = await import("@/chat/tools/index"); + const tools = createTools( + [], + {}, + { + channelId: "C_TEST", + channelCapabilities: { + canCreateCanvas: true, + canPostToChannel: true, + canAddReactions: true, + }, + sandbox: {} as any, + }, + ); + + expect(tools).toHaveProperty("slackUserLookup"); + expect(tools.slackUserLookup.description).toContain("Slack user"); + }); + }); + + describe("custom profile fields", () => { + it("returns custom profile fields as-is", async () => { + queueSlackApiResponse("users.info", { + body: usersInfoOk({ + userId: "U_GH", + userName: "untitaker", + realName: "Markus Unterwaditzer", + fields: { + Xf042GITHUB: { + value: "https://github.com/untitaker", + alt: "untitaker", + label: "GitHub", + }, + }, + }), + }); + + const tool = createSlackUserLookupTool(); + const result = await executeTool(tool, { user_id: "U_GH" }); + + expect(result.user.profile_fields).toHaveLength(1); + expect(result.user.profile_fields[0]).toMatchObject({ + id: "Xf042GITHUB", + label: "GitHub", + value: "https://github.com/untitaker", + }); + }); + }); +}); diff --git a/packages/junior/tests/msw/handlers/slack-api.ts b/packages/junior/tests/msw/handlers/slack-api.ts index 5db6ea53..bba62d5f 100644 --- a/packages/junior/tests/msw/handlers/slack-api.ts +++ b/packages/junior/tests/msw/handlers/slack-api.ts @@ -22,6 +22,8 @@ import { slackListsItemsListPage, slackListsItemsUpdateOk, usersInfoOk, + usersListPage, + usersLookupByEmailOk, } from "../../fixtures/slack/factories/api"; const EXTERNAL_UPLOAD_KEY = "__files.upload.external__"; @@ -54,6 +56,8 @@ export const SUPPORTED_SLACK_API_METHODS = [ "files.getUploadURLExternal", "files.completeUploadExternal", "users.info", + "users.list", + "users.lookupByEmail", ] as const; export type SlackApiMethod = (typeof SUPPORTED_SLACK_API_METHODS)[number]; @@ -229,6 +233,10 @@ function defaultSlackApiResponse( return { body: filesCompleteUploadOk() }; case "users.info": return { body: usersInfoOk() }; + case "users.list": + return { body: usersListPage() }; + case "users.lookupByEmail": + return { body: usersLookupByEmailOk() }; default: return { status: 400,