Files
FrenoCorp/server/trpc/team-router.ts
Senior Engineer c142611470 FRE-588: Fix IDOR vulnerabilities and security findings
H1: Add verifyScriptAccess/verifyRevisionAccess to all 14 revisions endpoints
H2: Add verifyProjectAccess to listScripts and searchScripts
M2: Add cascade delete for projectMembers on project deletion
M4: Replace plain Error throws with TRPCError for consistent error handling
M5: Use crypto.randomUUID for team ID generation (was Date.now + Math.random)
L1: Add 100KB content size limit on revision content
L2: Add unique constraint to script slug column
L3: Update hasProjectAccess middleware to check project membership
2026-04-29 06:57:20 -04:00

265 lines
8.2 KiB
TypeScript

import { protectedProcedure, TRPCError } from './router';
import { z } from 'zod';
import { eq, and, asc } from 'drizzle-orm';
import type { DrizzleDB } from '../../src/db/config/migrations';
import { teams, teamMembers } from '../../src/db/schema';
async function verifyTeamOwnership(
db: DrizzleDB,
teamId: string,
userId: number
) {
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, teamId));
const team = teamRows[0];
if (!team) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Team ${teamId} not found` });
}
if (team.ownerId !== userId) {
const memberRows = await db.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
if (memberRows.length === 0) {
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to team ${teamId}` });
}
}
return team;
}
async function verifyTeamRole(
db: DrizzleDB,
teamId: string,
userId: number,
allowedRoles: string[]
) {
await verifyTeamOwnership(db, teamId, userId);
const teamRows = await db.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, teamId));
const team = teamRows[0];
if (!team) return;
if (team.ownerId === userId) return;
const memberRows = await db.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, teamId), eq(teamMembers.userId, userId)));
const member = memberRows[0];
if (!member || !allowedRoles.includes(member.role)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions' });
}
}
async function generateTeamId(): Promise<string> {
const { randomUUID } = await import('crypto');
return `team_${randomUUID()}`;
}
export const teamRouter = {
// Team CRUD
listTeams: protectedProcedure.query(async ({ ctx }) => {
const owned = await ctx.db!.select()
.from(teams)
.where(eq(teams.ownerId, ctx.userId!))
.orderBy(asc(teams.createdAt));
const memberRows = await ctx.db!.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, ctx.userId!));
const memberTeamIds = new Set(memberRows.map((r) => r.teamId));
const memberTeams: typeof owned = [];
for (const tid of memberTeamIds) {
const row = await ctx.db!.select()
.from(teams)
.where(eq(teams.id, tid))
.then((r) => r[0]);
if (row) memberTeams.push(row);
}
const all = [...owned, ...memberTeams];
const seen = new Set(all.map((t) => t.id));
return all.filter((t) => seen.has(t.id));
}),
getTeam: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
const rows = await ctx.db!.select()
.from(teams)
.where(eq(teams.id, input.id));
return rows[0];
}),
createTeam: protectedProcedure
.input(z.object({
name: z.string().min(1).max(255),
}))
.mutation(async ({ input, ctx }) => {
const teamId = await generateTeamId();
const result = await ctx.db!.insert(teams)
.values({
id: teamId,
name: input.name,
ownerId: ctx.userId!,
})
.returning();
const team = result[0];
if (!team) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create team' });
}
await ctx.db!.insert(teamMembers)
.values({
teamId: team.id,
userId: ctx.userId!,
role: 'owner',
});
return team;
}),
updateTeam: protectedProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).max(255).optional(),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.id, ctx.userId!, ['owner', 'admin']);
const updateData: Record<string, any> = { updatedAt: new Date() };
if (input.name !== undefined) updateData.name = input.name;
const result = await ctx.db!.update(teams)
.set(updateData)
.where(eq(teams.id, input.id))
.returning();
return result[0];
}),
deleteTeam: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.id, ctx.userId!);
const teamRows = await ctx.db!.select({ id: teams.id, ownerId: teams.ownerId })
.from(teams)
.where(eq(teams.id, input.id));
if (teamRows[0]?.ownerId !== ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the owner can delete a team' });
}
await ctx.db!.delete(teamMembers)
.where(eq(teamMembers.teamId, input.id));
await ctx.db!.delete(teams)
.where(eq(teams.id, input.id));
return { success: true };
}),
// Team member management
listMembers: protectedProcedure
.input(z.object({ teamId: z.string().min(1) }))
.query(async ({ input, ctx }) => {
await verifyTeamOwnership(ctx.db!, input.teamId, ctx.userId!);
return await ctx.db!.select()
.from(teamMembers)
.where(eq(teamMembers.teamId, input.teamId))
.orderBy(asc(teamMembers.joinedAt));
}),
addMember: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
role: z.enum(['owner', 'admin', 'editor', 'viewer']).default('editor'),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
const existing = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
if (existing.length > 0) {
throw new TRPCError({ code: 'CONFLICT', message: 'User is already a member of this team' });
}
const result = await ctx.db!.insert(teamMembers)
.values({
teamId: input.teamId,
userId: input.userId,
role: input.role,
})
.returning();
return result[0];
}),
updateMemberRole: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
role: z.enum(['owner', 'admin', 'editor', 'viewer']),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner']);
const result = await ctx.db!.update(teamMembers)
.set({ role: input.role })
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)))
.returning();
return result[0];
}),
removeMember: protectedProcedure
.input(z.object({
teamId: z.string().min(1),
userId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
await verifyTeamRole(ctx.db!, input.teamId, ctx.userId!, ['owner', 'admin']);
if (input.userId === ctx.userId!) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You cannot remove yourself from this team' });
}
const memberRows = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
if (memberRows[0]?.role === 'owner') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Cannot remove the team owner' });
}
await ctx.db!.delete(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, input.userId)));
return { success: true };
}),
leaveTeam: protectedProcedure
.input(z.object({ teamId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const memberRows = await ctx.db!.select()
.from(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
if (memberRows[0]?.role === 'owner') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Owner cannot leave the team. Transfer ownership first.' });
}
await ctx.db!.delete(teamMembers)
.where(and(eq(teamMembers.teamId, input.teamId), eq(teamMembers.userId, ctx.userId!)));
return { success: true };
}),
};