Files
FrenoCorp/server/trpc/revisions-router.ts
Michael Freno 754fce269f fix: implement critical security remediation for authentication and authorization
- Add Clerk token verification to tRPC context (server/trpc/index.ts)
- Remove client-controlled authorId/reviewedById from revisions router
- Require JWT_SECRET environment variable, remove hardcoded fallback
- Add table name validation to prevent SQL injection in backup logic
- Fix TRPCContext type to use better-sqlite3 instead of LibSQL
- Update revisions router tests to use proper tRPC v11+ API
- Add resetInMemoryState function for test isolation

Security fixes address:
- Critical: Authentication bypass via missing token verification
- Critical: User impersonation via client-controlled IDs
- High: Insecure WebSocket defaults with hardcoded secrets
- High: SQL injection vulnerability in backup logic

All tests passing (24/24).
2026-04-25 08:24:45 -04:00

588 lines
17 KiB
TypeScript

import { protectedProcedure } from './router';
import { z } from 'zod';
// In-memory storage
const revisions: Map<number, {
id: number;
scriptId: number;
versionNumber: number;
branchName: string;
parentRevisionId: number | null;
title: string;
summary: string | null;
content: string;
authorId: number;
status: 'draft' | 'pending_review' | 'accepted' | 'rejected';
reviewedById: number | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}> = new Map();
const revisionChanges: Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> = new Map();
let revisionIdCounter = 0;
let changeIdCounter = 0;
function getNextRevisionId(): number {
return ++revisionIdCounter;
}
function getNextChangeId(): number {
return ++changeIdCounter;
}
function computeDiffForContent(
oldContent: string,
newContent: string,
revisionId: number
): Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const changes = new Map();
let sceneCounter = 0;
const linesPerPage = 55;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
let changeType: 'addition' | 'deletion' | 'modification';
if (!oldLine && newLine) {
changeType = 'addition';
} else if (oldLine && !newLine) {
changeType = 'deletion';
} else {
changeType = 'modification';
}
if (newLine?.trim().toUpperCase().startsWith('INT.') ||
newLine?.trim().toUpperCase().startsWith('EXT.')) {
sceneCounter++;
}
const change = {
id: getNextChangeId(),
revisionId,
changeType,
elementType: null,
oldContent: changeType !== 'addition' ? oldLine || null : null,
newContent: changeType !== 'deletion' ? newLine || null : null,
sceneNumber: sceneCounter || null,
lineNumber: i + 1,
pageNumber: Math.ceil((i + 1) / linesPerPage),
createdAt: new Date(),
};
changes.set(change.id, change);
revisionChanges.set(change.id, change);
}
return changes;
}
function getLatestVersionForScript(scriptId: number, branchName: string): number {
let maxVersion = 0;
for (const rev of revisions.values()) {
if (rev.scriptId === scriptId && rev.branchName === branchName) {
if (rev.versionNumber > maxVersion) {
maxVersion = rev.versionNumber;
}
}
}
return maxVersion;
}
// Export reset function for testing
export function resetInMemoryState() {
revisions.clear();
revisionChanges.clear();
revisionIdCounter = 0;
changeIdCounter = 0;
}
export const revisionsRouter = {
listRevisions: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().optional(),
}))
.query(async ({ input }) => {
const results = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId)
.filter(r => !input.branchName || r.branchName === input.branchName)
.sort((a, b) => b.versionNumber - a.versionNumber);
return results;
}),
getRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.id);
if (!revision) {
throw new Error(`Revision ${input.id} not found`);
}
return revision;
}),
createRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
title: z.string().min(1).max(255),
summary: z.string().max(2000).optional(),
content: z.string(),
branchName: z.string().default('main'),
parentRevisionId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const nextVersion = getLatestVersionForScript(
input.scriptId,
input.branchName
) + 1;
const revision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.branchName,
parentRevisionId: input.parentRevisionId || null,
title: input.title,
summary: input.summary || null,
content: input.content,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(revision.id, revision);
if (input.parentRevisionId) {
const parent = revisions.get(input.parentRevisionId);
if (parent) {
computeDiffForContent(parent.content, input.content, revision.id);
}
}
return revision;
}),
updateRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
title: z.string().min(1).max(255).optional(),
summary: z.string().max(2000).optional(),
content: z.string().optional(),
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
}))
.mutation(async ({ input }) => {
const revision = revisions.get(input.id);
if (!revision) {
throw new Error(`Revision ${input.id} not found`);
}
const updated = {
...revision,
...(input.title && { title: input.title }),
...(input.summary !== undefined && { summary: input.summary }),
...(input.content !== undefined && { content: input.content }),
...(input.status && { status: input.status }),
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
deleteRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const deleted = revisions.delete(input.id);
if (!deleted) {
throw new Error(`Revision ${input.id} not found`);
}
for (const [changeId, change] of revisionChanges) {
if (change.revisionId === input.id) {
revisionChanges.delete(changeId);
}
}
return { success: true };
}),
getRevisionChanges: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const changes = Array.from(revisionChanges.values())
.filter(c => c.revisionId === input.revisionId)
.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0));
return changes;
}),
compareRevisions: protectedProcedure
.input(z.object({
baseRevisionId: z.number().int().positive(),
targetRevisionId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const baseRevision = revisions.get(input.baseRevisionId);
const targetRevision = revisions.get(input.targetRevisionId);
if (!baseRevision) {
throw new Error(`Base revision ${input.baseRevisionId} not found`);
}
if (!targetRevision) {
throw new Error(`Target revision ${input.targetRevisionId} not found`);
}
const oldLines = baseRevision.content.split('\n');
const newLines = targetRevision.content.split('\n');
let additions = 0;
let deletions = 0;
let modifications = 0;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
if (!oldLine && newLine) {
additions++;
} else if (oldLine && !newLine) {
deletions++;
} else {
modifications++;
}
}
return {
baseRevision,
targetRevision,
diff: {
additions,
deletions,
modifications,
},
};
}),
acceptRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const updated = {
...revision,
status: 'accepted' as const,
reviewedById: ctx.userId,
reviewedAt: new Date(),
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
rejectRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reason: z.string().max(1000).optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const updated = {
...revision,
status: 'rejected' as const,
reviewedById: ctx.userId,
reviewedAt: new Date(),
summary: input.reason
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
: revision.summary,
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
rollbackToRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
revisionId: z.number().int().positive(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const targetRevision = revisions.get(input.revisionId);
if (!targetRevision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
if (targetRevision.scriptId !== input.scriptId) {
throw new Error('Revision does not belong to the specified script');
}
const nextVersion = getLatestVersionForScript(
input.scriptId,
targetRevision.branchName
) + 1;
const rollbackRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: targetRevision.branchName,
parentRevisionId: targetRevision.id,
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
summary: `Rolled back to revision ${targetRevision.id}`,
content: targetRevision.content,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(rollbackRevision.id, rollbackRevision);
return rollbackRevision;
}),
getTimeline: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const timeline = scriptRevisions.map(rev => {
const changes = Array.from(revisionChanges.values())
.filter(c => c.revisionId === rev.id);
const additions = changes.filter(c => c.changeType === 'addition').length;
const deletions = changes.filter(c => c.changeType === 'deletion').length;
const modifications = changes.filter(c => c.changeType === 'modification').length;
return {
revision: rev,
changeCount: changes.length,
additions,
deletions,
modifications,
};
});
return timeline;
}),
getBranches: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId);
const branchMap = new Map<string, typeof scriptRevisions>();
for (const rev of scriptRevisions) {
if (!branchMap.has(rev.branchName)) {
branchMap.set(rev.branchName, []);
}
branchMap.get(rev.branchName)!.push(rev);
}
const branches = Array.from(branchMap.entries()).map(([branchName, revs]) => {
const sorted = revs.sort((a, b) => b.versionNumber - a.versionNumber);
const latest = sorted[0]!;
return {
branchName,
revisionCount: revs.length,
latestVersion: latest.versionNumber,
latestRevision: latest,
};
});
return branches;
}),
createBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().min(1),
fromRevisionId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
const existing = Array.from(revisions.values())
.some(r => r.scriptId === input.scriptId && r.branchName === input.branchName);
if (existing) {
throw new Error(`Branch '${input.branchName}' already exists for this script`);
}
let sourceContent = '';
let parentRevisionId: number | null = null;
if (input.fromRevisionId) {
const source = revisions.get(input.fromRevisionId);
if (!source) {
throw new Error(`Source revision ${input.fromRevisionId} not found`);
}
sourceContent = source.content;
parentRevisionId = source.id;
} else {
const mainRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId && r.branchName === 'main')
.sort((a, b) => b.versionNumber - a.versionNumber);
if (mainRevisions.length > 0) {
sourceContent = mainRevisions[0]!.content;
parentRevisionId = mainRevisions[0]!.id;
}
}
const branchRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: 1,
branchName: input.branchName,
parentRevisionId,
title: `Branch: ${input.branchName}`,
summary: null,
content: sourceContent,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(branchRevision.id, branchRevision);
return branchRevision;
}),
mergeBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
sourceBranch: z.string(),
targetBranch: z.string(),
}))
.mutation(async ({ input, ctx }) => {
if (!ctx.userId) {
throw new Error('User not authenticated');
}
if (input.sourceBranch === input.targetBranch) {
throw new Error('Cannot merge a branch into itself');
}
const sourceRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId && r.branchName === input.sourceBranch)
.sort((a, b) => b.versionNumber - a.versionNumber);
if (sourceRevisions.length === 0) {
throw new Error(`Source branch '${input.sourceBranch}' has no revisions`);
}
const sourceContent = sourceRevisions[0]!.content;
const nextVersion = getLatestVersionForScript(
input.scriptId,
input.targetBranch
) + 1;
const mergeRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.targetBranch,
parentRevisionId: null,
title: `Merge from '${input.sourceBranch}'`,
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
content: sourceContent,
authorId: ctx.userId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(mergeRevision.id, mergeRevision);
return mergeRevision;
}),
};