Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/breezy-foxes-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/api": minor
---

Feat: Added a V2 endpoint for team management.
125 changes: 125 additions & 0 deletions packages/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion packages/api/src/controllers/__tests__/team.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/controllers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
6 changes: 3 additions & 3 deletions packages/api/src/routers/api/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
124 changes: 124 additions & 0 deletions packages/api/src/routers/external-api/__tests__/team.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
3 changes: 3 additions & 0 deletions packages/api/src/routers/external-api/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,4 +62,6 @@ router.use(
webhooksRouter,
);

router.use('/team', defaultRateLimiter, validateUserAccessKey, teamRouter);

export default router;
Loading
Loading