diff --git a/.changeset/breezy-foxes-look.md b/.changeset/breezy-foxes-look.md new file mode 100644 index 0000000000..267f9b44d2 --- /dev/null +++ b/.changeset/breezy-foxes-look.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/api": minor +--- + +Feat: Added a V2 endpoint for team management. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index d53c4056a5..0e28c1aa1b 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5896,6 +5896,131 @@ } } }, + "/api/v2/team": { + "get": { + "summary": "Get Team", + "description": "Retrieves the authenticated team's basic settings.", + "operationId": "getTeam", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved team" + }, + "403": { + "description": "Forbidden" + } + } + } + }, + "/api/v2/team/members": { + "get": { + "summary": "List Team Members", + "description": "Retrieves the team's members.", + "operationId": "listTeamMembers", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved members" + } + } + } + }, + "/api/v2/team/invitation": { + "post": { + "summary": "Invite Team Member", + "description": "Creates a pending invitation for a new email address.", + "operationId": "inviteTeamMember", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully created the invitation" + }, + "400": { + "description": "User already exists" + } + } + } + }, + "/api/v2/team/invitations": { + "get": { + "summary": "List Team Invitations", + "description": "Retrieves the team's pending invitations.", + "operationId": "listTeamInvitations", + "tags": [ + "Team" + ], + "responses": { + "200": { + "description": "Successfully retrieved invitations" + } + } + } + }, + "/api/v2/team/invitation/{id}": { + "delete": { + "summary": "Delete Team Invitation", + "description": "Deletes a pending team invitation.", + "operationId": "deleteTeamInvitation", + "tags": [ + "Team" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted the invitation" + }, + "404": { + "description": "Invitation not found" + } + } + } + }, + "/api/v2/team/member/{id}": { + "delete": { + "summary": "Remove Team Member", + "description": "Removes a member from the team.", + "operationId": "removeTeamMember", + "tags": [ + "Team" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully removed the member" + }, + "400": { + "description": "Cannot remove yourself from the team" + }, + "404": { + "description": "Member not found" + } + } + } + }, "/api/v2/webhooks": { "get": { "summary": "List Webhooks", diff --git a/packages/api/src/controllers/__tests__/team.test.ts b/packages/api/src/controllers/__tests__/team.test.ts index a97a06989c..1330ae83ba 100644 --- a/packages/api/src/controllers/__tests__/team.test.ts +++ b/packages/api/src/controllers/__tests__/team.test.ts @@ -1,6 +1,20 @@ -import { createTeam, getTeam, getTeamByApiKey } from '@/controllers/team'; +import * as config from '@/config'; +import { + createTeam, + getTeam, + getTeamByApiKey, + getTeamInviteUrl, +} from '@/controllers/team'; import { clearDBCollections, closeDB, connectDB } from '@/fixtures'; +describe('getTeamInviteUrl', () => { + it('builds the join-team URL for a token', () => { + expect(getTeamInviteUrl('abc123')).toBe( + `${config.FRONTEND_URL}/join-team?token=abc123`, + ); + }); +}); + describe('team controller', () => { beforeAll(async () => { await connectDB(); diff --git a/packages/api/src/controllers/team.ts b/packages/api/src/controllers/team.ts index be3c841a03..1fecce5e0a 100644 --- a/packages/api/src/controllers/team.ts +++ b/packages/api/src/controllers/team.ts @@ -8,6 +8,10 @@ import Dashboard from '@/models/dashboard'; import { SavedSearch } from '@/models/savedSearch'; import Team, { type ITeam, type TeamDocument } from '@/models/team'; +export function getTeamInviteUrl(token: string) { + return `${config.FRONTEND_URL}/join-team?token=${token}`; +} + const LOCAL_APP_TEAM_ID = '_local_team_'; export const LOCAL_APP_TEAM = { _id: new mongoose.Types.ObjectId(LOCAL_APP_TEAM_ID), diff --git a/packages/api/src/routers/api/team.ts b/packages/api/src/routers/api/team.ts index 941a020571..32f8e11ecd 100644 --- a/packages/api/src/routers/api/team.ts +++ b/packages/api/src/routers/api/team.ts @@ -13,10 +13,10 @@ import pick from 'lodash/pick'; import { z } from 'zod'; import { processRequest, validateRequest } from 'zod-express-middleware'; -import * as config from '@/config'; import { getTags, getTeam, + getTeamInviteUrl, rotateTeamApiKey, setTeamName, updateTeamClickhouseSettings, @@ -180,7 +180,7 @@ router.post( } res.json({ - url: `${config.FRONTEND_URL}/join-team?token=${teamInvite.token}`, + url: getTeamInviteUrl(teamInvite.token), }); } catch (e) { next(e); @@ -210,7 +210,7 @@ router.get('/invitations', async (req, res: TeamInviteExpressRes, next) => { createdAt: ti.createdAt.toISOString(), email: ti.email, name: ti.name, - url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`, + url: getTeamInviteUrl(ti.token), })), }); } catch (e) { diff --git a/packages/api/src/routers/external-api/__tests__/team.test.ts b/packages/api/src/routers/external-api/__tests__/team.test.ts new file mode 100644 index 0000000000..1d8c148829 --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/team.test.ts @@ -0,0 +1,124 @@ +import request, { SuperAgentTest } from 'supertest'; + +import { getLoggedInAgent, getServer } from '@/fixtures'; +import { ITeam } from '@/models/team'; +import { IUser } from '@/models/user'; + +const TEAM_BASE_URL = '/api/v2/team'; + +describe('External API v2 Team', () => { + const server = getServer(); + let agent: SuperAgentTest; + let team: ITeam; + let user: IUser; + + beforeAll(async () => { + await server.start(); + }); + + beforeEach(async () => { + const result = await getLoggedInAgent(server); + agent = result.agent; + team = result.team; + user = result.user; + }); + + afterEach(async () => { + await server.clearDBs(); + }); + + afterAll(async () => { + await server.stop(); + }); + + const authRequest = ( + method: 'get' | 'post' | 'put' | 'delete', + url: string, + ) => agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`); + + describe('GET /api/v2/team', () => { + it('returns the team id and name', async () => { + const response = await authRequest('get', TEAM_BASE_URL).expect(200); + expect(response.body.data.id).toBe(team._id.toString()); + expect(response.body.data).toHaveProperty('name'); + }); + + it('requires authentication', async () => { + await request(server.getHttpServer()).get(TEAM_BASE_URL).expect(401); + }); + }); + + describe('GET /api/v2/team/members', () => { + it('returns the current user', async () => { + const response = await authRequest( + 'get', + `${TEAM_BASE_URL}/members`, + ).expect(200); + const me = response.body.data.find( + (m: { email: string }) => m.email === user.email, + ); + expect(me).toBeTruthy(); + expect(me.isCurrentUser).toBe(true); + }); + }); + + describe('Invitations', () => { + it('creates a pending invitation and lists then deletes it', async () => { + const inviteRes = await authRequest('post', `${TEAM_BASE_URL}/invitation`) + .send({ email: 'oss-invitee@deploysentinel.com' }) + .expect(200); + expect(inviteRes.body.data.status).toBe('pending'); + expect(inviteRes.body.data.url).toContain('/join-team?token='); + const invitationId = inviteRes.body.data.invitationId; + + const listRes = await authRequest( + 'get', + `${TEAM_BASE_URL}/invitations`, + ).expect(200); + expect( + listRes.body.data.some( + (i: { email: string }) => + i.email === 'oss-invitee@deploysentinel.com', + ), + ).toBe(true); + + await authRequest( + 'delete', + `${TEAM_BASE_URL}/invitation/${invitationId}`, + ).expect(200); + }); + + it('rejects inviting an existing user', async () => { + await authRequest('post', `${TEAM_BASE_URL}/invitation`) + .send({ email: user.email }) + .expect(400); + }); + + it('handles concurrent invites for the same email idempotently', async () => { + const email = 'race@deploysentinel.com'; + const responses = await Promise.all( + Array.from({ length: 5 }, () => + authRequest('post', `${TEAM_BASE_URL}/invitation`).send({ email }), + ), + ); + + // No request should 500 on the {teamId, email} unique-index race. + for (const res of responses) { + expect(res.status).toBe(200); + } + + // All requests resolve to the same single invitation. + const ids = new Set(responses.map(r => r.body.data.invitationId)); + expect(ids.size).toBe(1); + + const listRes = await authRequest( + 'get', + `${TEAM_BASE_URL}/invitations`, + ).expect(200); + expect( + listRes.body.data.filter((i: { email: string }) => i.email === email) + .length, + ).toBe(1); + }); + }); +}); diff --git a/packages/api/src/routers/external-api/v2/index.ts b/packages/api/src/routers/external-api/v2/index.ts index 59fdf95dc1..bf3adbce73 100644 --- a/packages/api/src/routers/external-api/v2/index.ts +++ b/packages/api/src/routers/external-api/v2/index.ts @@ -7,6 +7,7 @@ import connectionsRouter from '@/routers/external-api/v2/connections'; import dashboardRouter from '@/routers/external-api/v2/dashboards'; import searchRouter from '@/routers/external-api/v2/search'; import sourcesRouter from '@/routers/external-api/v2/sources'; +import teamRouter from '@/routers/external-api/v2/team'; import webhooksRouter from '@/routers/external-api/v2/webhooks'; import rateLimiter, { rateLimiterKeyGenerator } from '@/utils/rateLimiter'; @@ -61,4 +62,6 @@ router.use( webhooksRouter, ); +router.use('/team', defaultRateLimiter, validateUserAccessKey, teamRouter); + export default router; diff --git a/packages/api/src/routers/external-api/v2/team.ts b/packages/api/src/routers/external-api/v2/team.ts new file mode 100644 index 0000000000..2b5dd7584a --- /dev/null +++ b/packages/api/src/routers/external-api/v2/team.ts @@ -0,0 +1,310 @@ +import crypto from 'crypto'; +import express from 'express'; +import { z } from 'zod'; +import { validateRequest } from 'zod-express-middleware'; + +import { getTeam, getTeamInviteUrl } from '@/controllers/team'; +import { + deleteTeamMember, + findUserByEmail, + findUsersByTeam, +} from '@/controllers/user'; +import TeamInvite from '@/models/teamInvite'; +import { objectIdSchema } from '@/utils/zod'; + +const router = express.Router(); + +/** + * @openapi + * /api/v2/team: + * get: + * summary: Get Team + * description: Retrieves the authenticated team's basic settings. + * operationId: getTeam + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved team + * '403': + * description: Forbidden + */ +router.get('/', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (!teamId) { + return res.sendStatus(403); + } + + const team = await getTeam(teamId.toString()); + if (!team) { + return res.sendStatus(404); + } + + return res.json({ + data: { + id: team._id.toString(), + name: team.name, + }, + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/members: + * get: + * summary: List Team Members + * description: Retrieves the team's members. + * operationId: listTeamMembers + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved members + */ +router.get('/members', async (req, res, next) => { + try { + const teamId = req.user?.team; + const userId = req.user?._id; + if (!teamId) { + return res.sendStatus(403); + } + + const teamUsers = await findUsersByTeam(teamId.toString()); + return res.json({ + data: teamUsers.map(user => ({ + id: user._id.toString(), + email: user.email, + name: user.name, + isCurrentUser: !!userId && user._id.equals(userId), + })), + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/invitation: + * post: + * summary: Invite Team Member + * description: Creates a pending invitation for a new email address. + * operationId: inviteTeamMember + * tags: [Team] + * responses: + * '200': + * description: Successfully created the invitation + * '400': + * description: User already exists + */ +router.post( + '/invitation', + validateRequest({ + body: z.object({ + email: z.string().email(), + name: z.string().optional(), + }), + }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (!teamId) { + return res.sendStatus(403); + } + + const { email, name } = req.body; + const normalizedEmail = email.toLowerCase(); + + const existingUser = await findUserByEmail(normalizedEmail); + if (existingUser) { + return res.status(400).json({ + message: + 'User already exists. Please contact HyperDX team for support', + }); + } + + let teamInvite = await TeamInvite.findOne({ + teamId: teamId.toString(), + email: normalizedEmail, + }); + + if (!teamInvite) { + try { + teamInvite = await new TeamInvite({ + teamId: teamId.toString(), + name, + email: normalizedEmail, + token: crypto.randomBytes(32).toString('hex'), + }).save(); + } catch (err: any) { + // ponytail: concurrent insert lost the race on the {teamId, email} + // unique index — re-fetch the winner's doc to stay idempotent. + if (err?.code === 11000) { + teamInvite = await TeamInvite.findOne({ + teamId: teamId.toString(), + email: normalizedEmail, + }); + } + if (!teamInvite) { + throw err; + } + } + } + + return res.json({ + data: { + email: normalizedEmail, + invitationId: teamInvite._id.toString(), + status: 'pending', + url: getTeamInviteUrl(teamInvite.token), + }, + }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/team/invitations: + * get: + * summary: List Team Invitations + * description: Retrieves the team's pending invitations. + * operationId: listTeamInvitations + * tags: [Team] + * responses: + * '200': + * description: Successfully retrieved invitations + */ +router.get('/invitations', async (req, res, next) => { + try { + const teamId = req.user?.team; + if (!teamId) { + return res.sendStatus(403); + } + + const teamInvites = await TeamInvite.find( + { teamId: teamId.toString() }, + { createdAt: 1, email: 1, name: 1, token: 1 }, + ); + + return res.json({ + data: teamInvites.map(ti => ({ + id: ti._id.toString(), + createdAt: ti.createdAt, + email: ti.email, + name: ti.name, + url: getTeamInviteUrl(ti.token), + })), + }); + } catch (e) { + next(e); + } +}); + +/** + * @openapi + * /api/v2/team/invitation/{id}: + * delete: + * summary: Delete Team Invitation + * description: Deletes a pending team invitation. + * operationId: deleteTeamInvitation + * tags: [Team] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Successfully deleted the invitation + * '404': + * description: Invitation not found + */ +router.delete( + '/invitation/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + if (!teamId) { + return res.sendStatus(403); + } + + const teamInvite = await TeamInvite.findOneAndDelete({ + _id: req.params.id, + teamId: teamId.toString(), + }); + + if (!teamInvite) { + return res.sendStatus(404); + } + + return res.json({ data: { success: true } }); + } catch (e) { + next(e); + } + }, +); + +/** + * @openapi + * /api/v2/team/member/{id}: + * delete: + * summary: Remove Team Member + * description: Removes a member from the team. + * operationId: removeTeamMember + * tags: [Team] + * parameters: + * - name: id + * in: path + * required: true + * schema: + * type: string + * responses: + * '200': + * description: Successfully removed the member + * '400': + * description: Cannot remove yourself from the team + * '404': + * description: Member not found + */ +router.delete( + '/member/:id', + validateRequest({ params: z.object({ id: objectIdSchema }) }), + async (req, res, next) => { + try { + const teamId = req.user?.team; + const requestingUserId = req.user?._id; + if (!teamId || !requestingUserId) { + return res.sendStatus(403); + } + + if (req.params.id === requestingUserId.toString()) { + return res.status(400).json({ + message: 'You cannot remove yourself from the team', + }); + } + + const deletedUser = await deleteTeamMember( + teamId.toString(), + req.params.id, + requestingUserId, + ); + + if (!deletedUser) { + return res.sendStatus(404); + } + + return res.json({ data: { success: true } }); + } catch (e) { + next(e); + } + }, +); + +export default router;