Skip to content

Commit 1faa6aa

Browse files
committed
feat: add teams and API keys modules with CRUD and key hashing
1 parent 7d2afac commit 1faa6aa

7 files changed

Lines changed: 368 additions & 0 deletions

File tree

src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { ConfigModule } from '@nestjs/config';
33
import { HealthModule } from './health/health.module';
44
import { StorageModule } from './storage/storage.module';
55
import { AuthModule } from './auth/auth.module';
6+
import { TeamsModule } from './teams/teams.module';
7+
import { KeysModule } from './keys/keys.module';
68
import configuration from './config/configuration';
79

810
@Module({
@@ -13,6 +15,8 @@ import configuration from './config/configuration';
1315
}),
1416
StorageModule,
1517
AuthModule,
18+
TeamsModule,
19+
KeysModule,
1620
HealthModule,
1721
],
1822
})

src/keys/keys.controller.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
2+
import { KeysService } from './keys.service';
3+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4+
import { TeamRoleGuard, TeamRole } from '../auth/guards/team-role.guard';
5+
6+
@Controller('teams/:id/keys')
7+
@UseGuards(JwtAuthGuard, TeamRoleGuard)
8+
export class KeysController {
9+
constructor(private readonly keysService: KeysService) {}
10+
11+
@Get()
12+
@TeamRole('member')
13+
list(@Param('id') teamId: string) {
14+
return this.keysService.listForTeam(teamId);
15+
}
16+
17+
@Post()
18+
@TeamRole('admin')
19+
create(
20+
@Param('id') teamId: string,
21+
@Body() body: { name: string; environment?: 'live' | 'test' },
22+
) {
23+
return this.keysService.create(teamId, body.name, body.environment);
24+
}
25+
26+
@Patch(':keyId')
27+
@TeamRole('admin')
28+
update(
29+
@Param('id') teamId: string,
30+
@Param('keyId') keyId: string,
31+
@Body() body: { name: string },
32+
) {
33+
return this.keysService.updateName(teamId, keyId, body.name);
34+
}
35+
36+
@Delete(':keyId')
37+
@TeamRole('admin')
38+
revoke(@Param('id') teamId: string, @Param('keyId') keyId: string) {
39+
return this.keysService.revoke(teamId, keyId);
40+
}
41+
}

src/keys/keys.module.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { KeysController } from './keys.controller';
4+
import { KeysService } from './keys.service';
5+
import { ApiKeyEntity } from '../storage/entities/api-key.entity';
6+
import { TeamEntity } from '../storage/entities/team.entity';
7+
import { TeamMemberEntity } from '../storage/entities/team-member.entity';
8+
import { PlanEntity } from '../storage/entities/plan.entity';
9+
10+
@Module({
11+
imports: [TypeOrmModule.forFeature([ApiKeyEntity, TeamEntity, TeamMemberEntity, PlanEntity])],
12+
controllers: [KeysController],
13+
providers: [KeysService],
14+
exports: [KeysService],
15+
})
16+
export class KeysModule {}

src/keys/keys.service.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { createHash, randomBytes } from 'crypto';
5+
import { ApiKeyEntity } from '../storage/entities/api-key.entity';
6+
import { TeamEntity } from '../storage/entities/team.entity';
7+
import { PlanEntity } from '../storage/entities/plan.entity';
8+
9+
@Injectable()
10+
export class KeysService {
11+
constructor(
12+
@InjectRepository(ApiKeyEntity)
13+
private readonly apiKeyRepo: Repository<ApiKeyEntity>,
14+
@InjectRepository(TeamEntity)
15+
private readonly teamRepo: Repository<TeamEntity>,
16+
@InjectRepository(PlanEntity)
17+
private readonly planRepo: Repository<PlanEntity>,
18+
) {}
19+
20+
async listForTeam(teamId: string) {
21+
const keys = await this.apiKeyRepo.find({
22+
where: { teamId },
23+
order: { createdAt: 'DESC' },
24+
});
25+
return keys.map((k) => ({
26+
id: k.id,
27+
name: k.name,
28+
keyPrefix: k.keyPrefix,
29+
environment: k.environment,
30+
lastUsedAt: k.lastUsedAt,
31+
expiresAt: k.expiresAt,
32+
revoked: k.revoked,
33+
createdAt: k.createdAt,
34+
}));
35+
}
36+
37+
async create(teamId: string, name: string, environment: 'live' | 'test' = 'live') {
38+
const team = await this.teamRepo.findOneBy({ id: teamId });
39+
if (!team) throw new NotFoundException('Team not found');
40+
41+
const plan = await this.planRepo.findOneBy({ id: team.plan });
42+
if (plan && plan.maxApiKeys !== -1) {
43+
const count = await this.apiKeyRepo.count({ where: { teamId, revoked: false } });
44+
if (count >= plan.maxApiKeys) {
45+
throw new ForbiddenException('API key limit reached for your plan');
46+
}
47+
}
48+
49+
const prefix = environment === 'live' ? 'wraith_live_' : 'wraith_test_';
50+
const rawKey = prefix + randomBytes(32).toString('base64url');
51+
const keyHash = createHash('sha256').update(rawKey).digest('hex');
52+
const keyPrefix = rawKey.slice(0, 8);
53+
54+
const apiKey = await this.apiKeyRepo.save(
55+
this.apiKeyRepo.create({
56+
teamId,
57+
name,
58+
keyPrefix,
59+
keyHash,
60+
environment,
61+
revoked: false,
62+
}),
63+
);
64+
65+
return {
66+
id: apiKey.id,
67+
name: apiKey.name,
68+
environment: apiKey.environment,
69+
key: rawKey,
70+
keyPrefix: apiKey.keyPrefix,
71+
createdAt: apiKey.createdAt,
72+
};
73+
}
74+
75+
async updateName(teamId: string, keyId: string, name: string) {
76+
const key = await this.apiKeyRepo.findOne({ where: { id: keyId, teamId } });
77+
if (!key) throw new NotFoundException('API key not found');
78+
key.name = name;
79+
return this.apiKeyRepo.save(key);
80+
}
81+
82+
async revoke(teamId: string, keyId: string) {
83+
const key = await this.apiKeyRepo.findOne({ where: { id: keyId, teamId } });
84+
if (!key) throw new NotFoundException('API key not found');
85+
key.revoked = true;
86+
await this.apiKeyRepo.save(key);
87+
return { revoked: true };
88+
}
89+
}

src/teams/teams.controller.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards } from '@nestjs/common';
2+
import { TeamsService } from './teams.service';
3+
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4+
import { TeamRoleGuard, TeamRole } from '../auth/guards/team-role.guard';
5+
import { CurrentDeveloper } from '../auth/decorators/current-developer.decorator';
6+
7+
@Controller('teams')
8+
@UseGuards(JwtAuthGuard)
9+
export class TeamsController {
10+
constructor(private readonly teamsService: TeamsService) {}
11+
12+
@Get()
13+
list(@CurrentDeveloper() dev: { id: string }) {
14+
return this.teamsService.listForDeveloper(dev.id);
15+
}
16+
17+
@Post()
18+
create(@CurrentDeveloper() dev: { id: string }, @Body() body: { name: string; slug?: string }) {
19+
return this.teamsService.create(dev.id, body.name, body.slug);
20+
}
21+
22+
@Get(':id')
23+
@UseGuards(TeamRoleGuard)
24+
@TeamRole('member')
25+
getById(@Param('id') id: string) {
26+
return this.teamsService.getById(id);
27+
}
28+
29+
@Patch(':id')
30+
@UseGuards(TeamRoleGuard)
31+
@TeamRole('admin')
32+
update(@Param('id') id: string, @Body() body: { name?: string; slug?: string }) {
33+
return this.teamsService.update(id, body);
34+
}
35+
36+
@Delete(':id')
37+
delete(@Param('id') id: string, @CurrentDeveloper() dev: { id: string }) {
38+
return this.teamsService.delete(id, dev.id);
39+
}
40+
41+
@Get(':id/members')
42+
@UseGuards(TeamRoleGuard)
43+
@TeamRole('member')
44+
listMembers(@Param('id') id: string) {
45+
return this.teamsService.listMembers(id);
46+
}
47+
48+
@Post(':id/members')
49+
@UseGuards(TeamRoleGuard)
50+
@TeamRole('admin')
51+
addMember(@Param('id') id: string, @Body() body: { email: string; role?: string }) {
52+
return this.teamsService.addMember(id, body.email, body.role);
53+
}
54+
55+
@Patch(':id/members/:memberId')
56+
@UseGuards(TeamRoleGuard)
57+
@TeamRole('admin')
58+
updateMemberRole(
59+
@Param('id') id: string,
60+
@Param('memberId') memberId: string,
61+
@Body() body: { role: string },
62+
) {
63+
return this.teamsService.updateMemberRole(id, memberId, body.role);
64+
}
65+
66+
@Delete(':id/members/:memberId')
67+
@UseGuards(TeamRoleGuard)
68+
@TeamRole('admin')
69+
removeMember(@Param('id') id: string, @Param('memberId') memberId: string) {
70+
return this.teamsService.removeMember(id, memberId);
71+
}
72+
}

src/teams/teams.module.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { TeamsController } from './teams.controller';
4+
import { TeamsService } from './teams.service';
5+
import { TeamEntity } from '../storage/entities/team.entity';
6+
import { TeamMemberEntity } from '../storage/entities/team-member.entity';
7+
import { DeveloperEntity } from '../storage/entities/developer.entity';
8+
9+
@Module({
10+
imports: [TypeOrmModule.forFeature([TeamEntity, TeamMemberEntity, DeveloperEntity])],
11+
controllers: [TeamsController],
12+
providers: [TeamsService],
13+
exports: [TeamsService],
14+
})
15+
export class TeamsModule {}

src/teams/teams.service.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
Injectable,
3+
NotFoundException,
4+
ForbiddenException,
5+
ConflictException,
6+
} from '@nestjs/common';
7+
import { InjectRepository } from '@nestjs/typeorm';
8+
import { Repository } from 'typeorm';
9+
import { randomBytes } from 'crypto';
10+
import { TeamEntity } from '../storage/entities/team.entity';
11+
import { TeamMemberEntity } from '../storage/entities/team-member.entity';
12+
import { DeveloperEntity } from '../storage/entities/developer.entity';
13+
14+
@Injectable()
15+
export class TeamsService {
16+
constructor(
17+
@InjectRepository(TeamEntity)
18+
private readonly teamRepo: Repository<TeamEntity>,
19+
@InjectRepository(TeamMemberEntity)
20+
private readonly memberRepo: Repository<TeamMemberEntity>,
21+
@InjectRepository(DeveloperEntity)
22+
private readonly developerRepo: Repository<DeveloperEntity>,
23+
) {}
24+
25+
async listForDeveloper(developerId: string) {
26+
const memberships = await this.memberRepo.find({
27+
where: { developerId },
28+
relations: ['team'],
29+
});
30+
return memberships.map((m) => ({ ...m.team, role: m.role }));
31+
}
32+
33+
async create(developerId: string, name: string, slug?: string) {
34+
const finalSlug = slug || name.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
35+
const existing = await this.teamRepo.findOneBy({ slug: finalSlug });
36+
if (existing) {
37+
throw new ConflictException('Slug already taken');
38+
}
39+
40+
const team = await this.teamRepo.save(
41+
this.teamRepo.create({
42+
name,
43+
slug: finalSlug + '-' + randomBytes(3).toString('hex'),
44+
ownerId: developerId,
45+
plan: 'free',
46+
}),
47+
);
48+
49+
await this.memberRepo.save(
50+
this.memberRepo.create({
51+
teamId: team.id,
52+
developerId,
53+
role: 'owner',
54+
}),
55+
);
56+
57+
return team;
58+
}
59+
60+
async getById(teamId: string) {
61+
const team = await this.teamRepo.findOneBy({ id: teamId });
62+
if (!team) throw new NotFoundException('Team not found');
63+
return team;
64+
}
65+
66+
async update(teamId: string, updates: { name?: string; slug?: string }) {
67+
const team = await this.getById(teamId);
68+
if (updates.slug) {
69+
const existing = await this.teamRepo.findOneBy({ slug: updates.slug });
70+
if (existing && existing.id !== teamId) {
71+
throw new ConflictException('Slug already taken');
72+
}
73+
}
74+
Object.assign(team, updates);
75+
return this.teamRepo.save(team);
76+
}
77+
78+
async delete(teamId: string, developerId: string) {
79+
const team = await this.getById(teamId);
80+
if (team.ownerId !== developerId) {
81+
throw new ForbiddenException('Only the owner can delete a team');
82+
}
83+
await this.teamRepo.remove(team);
84+
return { deleted: true };
85+
}
86+
87+
async listMembers(teamId: string) {
88+
return this.memberRepo.find({
89+
where: { teamId },
90+
relations: ['developer'],
91+
});
92+
}
93+
94+
async addMember(teamId: string, email: string, role: string = 'member') {
95+
const developer = await this.developerRepo.findOneBy({ email });
96+
if (!developer) throw new NotFoundException('Developer not found');
97+
98+
const existing = await this.memberRepo.findOne({
99+
where: { teamId, developerId: developer.id },
100+
});
101+
if (existing) throw new ConflictException('Already a member');
102+
103+
return this.memberRepo.save(
104+
this.memberRepo.create({
105+
teamId,
106+
developerId: developer.id,
107+
role,
108+
}),
109+
);
110+
}
111+
112+
async updateMemberRole(teamId: string, memberId: string, role: string) {
113+
const member = await this.memberRepo.findOne({
114+
where: { id: memberId, teamId },
115+
});
116+
if (!member) throw new NotFoundException('Member not found');
117+
if (member.role === 'owner') throw new ForbiddenException('Cannot change owner role');
118+
member.role = role;
119+
return this.memberRepo.save(member);
120+
}
121+
122+
async removeMember(teamId: string, memberId: string) {
123+
const member = await this.memberRepo.findOne({
124+
where: { id: memberId, teamId },
125+
});
126+
if (!member) throw new NotFoundException('Member not found');
127+
if (member.role === 'owner') throw new ForbiddenException('Cannot remove the owner');
128+
await this.memberRepo.remove(member);
129+
return { removed: true };
130+
}
131+
}

0 commit comments

Comments
 (0)