diff --git a/server/trpc/project-router.ts b/server/trpc/project-router.ts index e2454c65d..ad7bb5d4f 100644 --- a/server/trpc/project-router.ts +++ b/server/trpc/project-router.ts @@ -221,6 +221,10 @@ export const projectRouter = { await ctx.db!.delete(scenes) .where(eq(scenes.projectId, input.id)); + // M2 fix: remove project members + await ctx.db!.delete(projectMembers) + .where(eq(projectMembers.projectId, input.id)); + // Get character IDs for this project const projectCharacters = await ctx.db!.select({ id: characters.id }) .from(characters) @@ -544,7 +548,7 @@ export const projectRouter = { ) ); if (existing.length > 0) { - throw new Error('Relationship already exists between these characters'); + throw new TRPCError({ code: 'CONFLICT', message: 'Relationship already exists between these characters' }); } const result = await ctx.db!.insert(characterRelationships) diff --git a/server/trpc/revisions-router.test.ts b/server/trpc/revisions-router.test.ts index f7e91546e..396dcae97 100644 --- a/server/trpc/revisions-router.test.ts +++ b/server/trpc/revisions-router.test.ts @@ -59,11 +59,18 @@ describe('revisionsRouter', () => { }); describe('listRevisions', () => { - it('should return empty array for unknown script', async () => { - const result = await caller.revisions.listRevisions({ scriptId: 999 }); + it('should return empty array for script with no revisions', async () => { + const result = await caller.revisions.listRevisions({ scriptId: 1 }); expect(result).toEqual([]); }); + it('should throw NOT_FOUND for unknown script', async () => { + const { TRPCError } = await import('./router'); + await expect( + caller.revisions.listRevisions({ scriptId: 999 }) + ).rejects.toThrow(TRPCError); + }); + it('should filter by branch', async () => { await caller.revisions.createRevision({ scriptId: 1, diff --git a/server/trpc/revisions-router.ts b/server/trpc/revisions-router.ts index 74deb5d09..eec7ce336 100644 --- a/server/trpc/revisions-router.ts +++ b/server/trpc/revisions-router.ts @@ -5,8 +5,71 @@ import type { DrizzleDB } from '../../src/db/config/migrations'; import { revisions, revisionChanges, + scripts, + projects, + projectMembers, } from '../../src/db/schema'; +// H1 fix: verifies user has access to the project owning the script a revision belongs to +async function verifyScriptAccess( + db: DrizzleDB, + scriptId: number, + userId: number +) { + const scriptRows = await db + .select({ id: scripts.id, projectId: scripts.projectId }) + .from(scripts) + .where(eq(scripts.id, scriptId)); + + const script = scriptRows[0]; + if (!script) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` }); + } + + const projectRows = await db + .select({ id: projects.id, ownerId: projects.ownerId }) + .from(projects) + .where(eq(projects.id, script.projectId)); + + const project = projectRows[0]; + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${script.projectId} not found` }); + } + + if (project.ownerId === userId) return { script, project }; + + const memberRows = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, script.projectId), eq(projectMembers.userId, userId))); + + if (memberRows.length === 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to script ${scriptId}` }); + } + + return { script, project }; +} + +// Resolves revision → script → project and verifies user access +async function verifyRevisionAccess( + db: DrizzleDB, + revisionId: number, + userId: number +) { + const revisionRows = await db + .select() + .from(revisions) + .where(eq(revisions.id, revisionId)); + + const revision = revisionRows[0]; + if (!revision) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${revisionId} not found` }); + } + + const { script, project } = await verifyScriptAccess(db, revision.scriptId, userId); + return { revision, script, project }; +} + function computeDiffForContent( db: DrizzleDB, oldContent: string, @@ -94,8 +157,9 @@ export const revisionsRouter = { branchName: z.string().optional(), })) .query(async ({ input, ctx }) => { + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const conditions = [eq(revisions.scriptId, input.scriptId)]; - + if (input.branchName) { conditions.push(eq(revisions.branchName, input.branchName)); } @@ -105,7 +169,7 @@ export const revisionsRouter = { .from(revisions) .where(and(...conditions)) .orderBy(desc(revisions.versionNumber)); - + return results; }), @@ -114,15 +178,7 @@ export const revisionsRouter = { id: z.number().int().positive(), })) .query(async ({ input, ctx }) => { - const rows = await ctx.db! - .select() - .from(revisions) - .where(eq(revisions.id, input.id)); - - const revision = rows[0]; - if (!revision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` }); - } + const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); return revision; }), @@ -131,7 +187,7 @@ export const revisionsRouter = { scriptId: z.number().int().positive(), title: z.string().min(1).max(255), summary: z.string().max(2000).optional(), - content: z.string(), + content: z.string().max(100000), branchName: z.string().default('main'), parentRevisionId: z.number().int().positive().optional(), })) @@ -139,6 +195,7 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const nextVersion = await getLatestVersionForScript( ctx.db, @@ -193,14 +250,7 @@ export const revisionsRouter = { status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(), })) .mutation(async ({ input, ctx }) => { - const result = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.id)); - const revision = result[0]; - if (!revision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` }); - } + const { revision } = await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); const updated = await ctx.db .update(revisions) @@ -222,15 +272,13 @@ export const revisionsRouter = { id: z.number().int().positive(), })) .mutation(async ({ input, ctx }) => { + await verifyRevisionAccess(ctx.db!, input.id, ctx.userId!); + const result = await ctx.db .delete(revisions) .where(eq(revisions.id, input.id)) .returning(); - if (result.length === 0) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.id} not found` }); - } - await ctx.db .delete(revisionChanges) .where(eq(revisionChanges.revisionId, input.id)); @@ -243,14 +291,7 @@ export const revisionsRouter = { revisionId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { - const revisionResult = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.revisionId)); - const revision = revisionResult[0]; - if (!revision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` }); - } + await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const changes = await ctx.db .select() @@ -267,24 +308,8 @@ export const revisionsRouter = { targetRevisionId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { - const baseResult = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.baseRevisionId)); - const baseRevision = baseResult[0]; - - const targetResult = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.targetRevisionId)); - const targetRevision = targetResult[0]; - - if (!baseRevision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Base revision ${input.baseRevisionId} not found` }); - } - if (!targetRevision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Target revision ${input.targetRevisionId} not found` }); - } + const { revision: baseRevision } = await verifyRevisionAccess(ctx.db!, input.baseRevisionId, ctx.userId!); + const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.targetRevisionId, ctx.userId!); const oldLines = baseRevision.content.split('\n'); const newLines = targetRevision.content.split('\n'); @@ -328,15 +353,7 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } - - const result = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.revisionId)); - const revision = result[0]; - if (!revision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` }); - } + await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const updated = await ctx.db .update(revisions) @@ -361,15 +378,7 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } - - const result = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.revisionId)); - const revision = result[0]; - if (!revision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` }); - } + const { revision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); const newSummary = input.reason ? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']' @@ -399,15 +408,8 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } - - const targetResult = await ctx.db - .select() - .from(revisions) - .where(eq(revisions.id, input.revisionId)); - const targetRevision = targetResult[0]; - if (!targetRevision) { - throw new TRPCError({ code: 'NOT_FOUND', message: `Revision ${input.revisionId} not found` }); - } + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); + const { revision: targetRevision } = await verifyRevisionAccess(ctx.db!, input.revisionId, ctx.userId!); if (targetRevision.scriptId !== input.scriptId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Revision does not belong to the specified script' }); @@ -449,6 +451,7 @@ export const revisionsRouter = { scriptId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const scriptRevisions = await ctx.db .select() .from(revisions) @@ -484,6 +487,7 @@ export const revisionsRouter = { scriptId: z.number().int().positive(), })) .query(async ({ input, ctx }) => { + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const scriptRevisions = await ctx.db .select() .from(revisions) @@ -522,6 +526,7 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); const existingResult = await ctx.db .select({ id: revisions.id }) @@ -601,6 +606,7 @@ export const revisionsRouter = { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not authenticated' }); } + await verifyScriptAccess(ctx.db!, input.scriptId, ctx.userId!); if (input.sourceBranch === input.targetBranch) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot merge a branch into itself' }); diff --git a/server/trpc/router.ts b/server/trpc/router.ts index 65c2951b6..4ae9916c0 100644 --- a/server/trpc/router.ts +++ b/server/trpc/router.ts @@ -1,7 +1,7 @@ import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { eq } from 'drizzle-orm'; -import { projects } from '../../src/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { projects, projectMembers } from '../../src/db/schema'; import type { TRPCContext } from './types'; // Initialize tRPC with context @@ -61,7 +61,14 @@ const hasProjectAccess = t.middleware(async ({ ctx, next }) => { if (!project) { throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${ctx.projectId} not found` }); } - if (project.ownerId !== dbUser.id) { + if (project.ownerId === dbUser.id) { + return next({ ctx: { ...ctx, projectId: ctx.projectId, userId: dbUser.id } }); + } + // L3 fix: also check project membership + const memberRows = await ctx.db.select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, ctx.projectId), eq(projectMembers.userId, dbUser.id))); + if (memberRows.length === 0) { throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${ctx.projectId}` }); } return next({ ctx: { ...ctx, projectId: ctx.projectId, userId: dbUser.id } }); diff --git a/server/trpc/scripts-router.ts b/server/trpc/scripts-router.ts index 348103e92..a219d6c49 100644 --- a/server/trpc/scripts-router.ts +++ b/server/trpc/scripts-router.ts @@ -1,4 +1,4 @@ -import { protectedProcedure } from './router'; +import { protectedProcedure, TRPCError } from './router'; import { z } from 'zod'; import { eq, and, like, sql, inArray } from 'drizzle-orm'; import type { DrizzleDB } from '../../src/db/config/migrations'; @@ -7,12 +7,43 @@ import { revisions, revisionChanges, projects, + projectMembers, } from '../../src/db/schema'; function slugify(title: string): string { return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } +// H2 fix: verifies user has access to a project (owner or member) +async function verifyProjectAccess( + db: DrizzleDB, + projectId: number, + userId: number +) { + const projectRows = await db + .select({ id: projects.id, ownerId: projects.ownerId }) + .from(projects) + .where(eq(projects.id, projectId)); + + const project = projectRows[0]; + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` }); + } + + if (project.ownerId === userId) return project; + + const memberRows = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId))); + + if (memberRows.length === 0) { + throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` }); + } + + return project; +} + async function verifyScriptOwnership( db: DrizzleDB, scriptId: number, @@ -24,18 +55,11 @@ async function verifyScriptOwnership( const script = scriptRows[0]; if (!script) { - throw new Error(`Script ${scriptId} not found`); + throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${scriptId} not found` }); } - - const projectRows = await db.select({ ownerId: projects.ownerId }) - .from(projects) - .where(eq(projects.id, script.projectId)); - - const project = projectRows[0]; - if (!project || project.ownerId !== userId) { - throw new Error(`You do not have access to script ${scriptId}`); - } - + + await verifyProjectAccess(db, script.projectId, userId); + return script; } @@ -43,10 +67,8 @@ export const scriptsRouter = { listScripts: protectedProcedure .input(z.object({ projectId: z.number().int().positive() })) .query(async ({ input, ctx }) => { - await ctx.db!.select() - .from(projects) - .where(eq(projects.id, input.projectId)); - + await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!); + return await ctx.db!.select() .from(scripts) .where(eq(scripts.projectId, input.projectId)) @@ -56,14 +78,14 @@ export const scriptsRouter = { getScript: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { + await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!); const rows = await ctx.db!.select() .from(scripts) .where(eq(scripts.id, input.id)); const script = rows[0]; if (!script) { - throw new Error(`Script ${input.id} not found`); + throw new TRPCError({ code: 'NOT_FOUND', message: `Script ${input.id} not found` }); } - await verifyScriptOwnership(ctx.db!, input.id, ctx.userId!); return script; }), @@ -76,12 +98,7 @@ export const scriptsRouter = { status: z.enum(['draft', 'revision', 'final', 'published']).optional(), })) .mutation(async ({ input, ctx }) => { - const projectRows = await ctx.db!.select() - .from(projects) - .where(eq(projects.id, input.projectId)); - if (!projectRows[0]) { - throw new Error(`Project ${input.projectId} not found`); - } + await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!); const result = await ctx.db!.insert(scripts) .values({ @@ -156,9 +173,7 @@ export const scriptsRouter = { query: z.string().optional(), })) .query(async ({ input, ctx }) => { - await ctx.db!.select() - .from(projects) - .where(eq(projects.id, input.projectId)); + await verifyProjectAccess(ctx.db!, input.projectId, ctx.userId!); const conditions: any[] = [eq(scripts.projectId, input.projectId)]; diff --git a/server/trpc/team-router.ts b/server/trpc/team-router.ts index af73694c7..79c8cd337 100644 --- a/server/trpc/team-router.ts +++ b/server/trpc/team-router.ts @@ -54,8 +54,9 @@ async function verifyTeamRole( } } -function generateTeamId(): string { - return `team_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +async function generateTeamId(): Promise { + const { randomUUID } = await import('crypto'); + return `team_${randomUUID()}`; } export const teamRouter = { @@ -101,7 +102,7 @@ export const teamRouter = { name: z.string().min(1).max(255), })) .mutation(async ({ input, ctx }) => { - const teamId = generateTeamId(); + const teamId = await generateTeamId(); const result = await ctx.db!.insert(teams) .values({ id: teamId, diff --git a/src/db/schema/scripts.ts b/src/db/schema/scripts.ts index 284529e55..848f1d024 100644 --- a/src/db/schema/scripts.ts +++ b/src/db/schema/scripts.ts @@ -7,7 +7,7 @@ export const scripts = sqliteTable("scripts", { .notNull() .references(() => projects.id), title: text("title").notNull(), - slug: text("slug").notNull(), + slug: text("slug").notNull().unique(), genre: text("genre"), logline: text("logline"), status: text("status", { enum: ["draft", "revision", "final", "published"] }).notNull().default("draft"),