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).
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/backend": "^3.4.1",
|
||||
"@clerk/clerk-js": "^6.7.5",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@solidjs/router": "^0.16.1",
|
||||
@@ -353,6 +354,20 @@
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@clerk/backend": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.4.1.tgz",
|
||||
"integrity": "sha512-+Tgo1uPEFpBRvyFW3JtPbrTMRgiP+pWBo9gi2tTB0AxEqR2I/kSYy5l3+KqWciUpbVZtVvLXm1j+NEE2WEG+jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^4.8.5",
|
||||
"standardwebhooks": "^1.0.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/clerk-js": {
|
||||
"version": "6.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-6.7.5.tgz",
|
||||
@@ -392,9 +407,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/shared": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.8.3.tgz",
|
||||
"integrity": "sha512-HZViZBCTfOR2OreSBDMXcIRPgYiiYCE+GCCPrpjq/ZPcA6OsGiRCIQgUoGgGdAoFgr6Hk0TT00hnVK7g0qRKqQ==",
|
||||
"version": "4.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.8.5.tgz",
|
||||
"integrity": "sha512-YxgUWHoKEXEbRPWPEcB2Q0o+NJkDc0/zQRp4QCsnGIM5e32hlBUwxcYpyDjDlZ2lYB+GUXHuEc3KETnxWGp26g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3026,6 +3041,12 @@
|
||||
"solid-js": "^1.8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.6.0.tgz",
|
||||
@@ -6173,6 +6194,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-stable-stringify": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz",
|
||||
@@ -9945,6 +9972,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"tauri:icons": "bash src-tauri/generate-icons.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/backend": "^3.4.1",
|
||||
"@clerk/clerk-js": "^6.7.5",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@solidjs/router": "^0.16.1",
|
||||
|
||||
@@ -4,6 +4,10 @@ import { revisionsRouter } from './revisions-router';
|
||||
import type { TRPCContext } from './types';
|
||||
import type { TRPCError } from '@trpc/server';
|
||||
import { t } from './router';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import Database from 'better-sqlite3';
|
||||
import { projects, characters, scenes, characterRelationships, sceneCharacters } from '../../src/db/schema';
|
||||
import { verifyToken } from '@clerk/backend';
|
||||
|
||||
// App router combining all routers
|
||||
export const appRouter = t.router({
|
||||
@@ -13,13 +17,42 @@ export const appRouter = t.router({
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
// Database instance (shared for now - should come from config)
|
||||
let dbInstance: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
function getDb() {
|
||||
if (dbInstance) return dbInstance;
|
||||
const sqlite = new Database('./data/frenocorp.db');
|
||||
dbInstance = drizzle(sqlite);
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
// Create tRPC HTTP server
|
||||
export function createTRPCServer(port: number = 8080) {
|
||||
const server = createHTTPServer({
|
||||
router: appRouter,
|
||||
createContext: async (): Promise<TRPCContext> => {
|
||||
createContext: async ({ req }): Promise<TRPCContext> => {
|
||||
const authHeader = req.headers.authorization;
|
||||
let userId: number | undefined = undefined;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const clerkSecretKey = process.env.CLERK_SECRET_KEY;
|
||||
if (!clerkSecretKey) {
|
||||
console.warn('CLERK_SECRET_KEY not set, skipping token verification');
|
||||
} else {
|
||||
const payload = await verifyToken(token, { secretKey: clerkSecretKey });
|
||||
userId = payload.sub ? parseInt(payload.sub, 10) : undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to verify Clerk token:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userId: undefined,
|
||||
userId,
|
||||
db: getDb(),
|
||||
};
|
||||
},
|
||||
onError: ({ error, path }: { error: TRPCError; path: string | undefined }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { publicProcedure, protectedProcedure, projectProcedure } from './router';
|
||||
import { publicProcedure, protectedProcedure, projectProcedure, TRPCError } from './router';
|
||||
import { z } from 'zod';
|
||||
import { eq, and, or, like, sql, inArray } from 'drizzle-orm';
|
||||
import type { DrizzleDB } from '../../src/db/config/migrations';
|
||||
@@ -66,10 +66,10 @@ async function verifyProjectOwnership(
|
||||
|
||||
const project = projectRows[0];
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectId} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${projectId} not found` });
|
||||
}
|
||||
if (project.ownerId !== userId) {
|
||||
throw new Error(`You do not have access to project ${projectId}`);
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${projectId}` });
|
||||
}
|
||||
return project;
|
||||
}
|
||||
@@ -91,10 +91,10 @@ export const projectRouter = {
|
||||
.where(eq(projects.id, input.id));
|
||||
const project = rows[0];
|
||||
if (!project) {
|
||||
throw new Error(`Project ${input.id} not found`);
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Project ${input.id} not found` });
|
||||
}
|
||||
if (project.ownerId !== ctx.userId && !project.isPublic) {
|
||||
throw new Error(`You do not have access to project ${input.id}`);
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: `You do not have access to project ${input.id}` });
|
||||
}
|
||||
return project;
|
||||
}),
|
||||
|
||||
@@ -1,50 +1,57 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { appRouter } from './index';
|
||||
import { getTestDb, resetTestDb } from './test-setup';
|
||||
import { resetInMemoryState } from './revisions-router';
|
||||
import type { TRPCContext } from './types';
|
||||
|
||||
describe('revisionsRouter', () => {
|
||||
const ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
|
||||
let ctx: TRPCContext;
|
||||
let caller: ReturnType<typeof appRouter.createCaller>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetTestDb();
|
||||
resetInMemoryState();
|
||||
const db = await getTestDb();
|
||||
ctx = { userId: 1, db };
|
||||
caller = appRouter.createCaller(ctx);
|
||||
});
|
||||
|
||||
describe('createRevision', () => {
|
||||
it('should create a revision with version 1', async () => {
|
||||
const result = await appRouter.revisions.createRevision.mutate({
|
||||
input: {
|
||||
scriptId: 1,
|
||||
title: 'Initial draft',
|
||||
content: 'FADE IN:\n\nINT. ROOM - DAY',
|
||||
authorId: 1,
|
||||
},
|
||||
ctx,
|
||||
const result = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'Initial draft',
|
||||
content: 'FADE IN:\n\nINT. ROOM - DAY',
|
||||
});
|
||||
|
||||
expect(result.versionNumber).toBe(1);
|
||||
expect(result.branchName).toBe('main');
|
||||
expect(result.status).toBe('draft');
|
||||
expect(result.authorId).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment version number for same script', async () => {
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'v1', content: 'content1', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'v1',
|
||||
content: 'content1',
|
||||
});
|
||||
|
||||
const result = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'v2', content: 'content2', authorId: 1 },
|
||||
ctx,
|
||||
const result = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'v2',
|
||||
content: 'content2',
|
||||
});
|
||||
|
||||
expect(result.versionNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('should support custom branch', async () => {
|
||||
const result = await appRouter.revisions.createRevision.mutate({
|
||||
input: {
|
||||
scriptId: 1,
|
||||
title: 'Branch revision',
|
||||
content: 'branch content',
|
||||
branchName: 'feature-act2',
|
||||
authorId: 1,
|
||||
},
|
||||
ctx,
|
||||
const result = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'Branch revision',
|
||||
content: 'branch content',
|
||||
branchName: 'feature-act2',
|
||||
});
|
||||
|
||||
expect(result.branchName).toBe('feature-act2');
|
||||
@@ -53,27 +60,28 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('listRevisions', () => {
|
||||
it('should return empty array for unknown script', async () => {
|
||||
const result = await appRouter.revisions.listRevisions.query({
|
||||
input: { scriptId: 999 },
|
||||
ctx,
|
||||
});
|
||||
const result = await caller.revisions.listRevisions({ scriptId: 999 });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by branch', async () => {
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'main v1', content: 'main', branchName: 'main', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'main v1',
|
||||
content: 'main',
|
||||
branchName: 'main',
|
||||
});
|
||||
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'feature v1', content: 'feature', branchName: 'feature', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'feature v1',
|
||||
content: 'feature',
|
||||
branchName: 'feature',
|
||||
});
|
||||
|
||||
const mainRevisions = await appRouter.revisions.listRevisions.query({
|
||||
input: { scriptId: 1, branchName: 'main' },
|
||||
ctx,
|
||||
const mainRevisions = await caller.revisions.listRevisions({
|
||||
scriptId: 1,
|
||||
branchName: 'main',
|
||||
});
|
||||
|
||||
expect(mainRevisions).toHaveLength(1);
|
||||
@@ -83,32 +91,33 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('acceptRevision', () => {
|
||||
it('should accept a revision', async () => {
|
||||
const created = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'To accept', content: 'content', authorId: 1 },
|
||||
ctx,
|
||||
const created = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'To accept',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
const result = await appRouter.revisions.acceptRevision.mutate({
|
||||
input: { revisionId: created.id, reviewedById: 2 },
|
||||
ctx,
|
||||
const result = await caller.revisions.acceptRevision({
|
||||
revisionId: created.id,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('accepted');
|
||||
expect(result.reviewedById).toBe(2);
|
||||
expect(result.reviewedById).toBe(1);
|
||||
expect(result.reviewedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectRevision', () => {
|
||||
it('should reject a revision with reason', async () => {
|
||||
const created = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'To reject', content: 'content', authorId: 1 },
|
||||
ctx,
|
||||
const created = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'To reject',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
const result = await appRouter.revisions.rejectRevision.mutate({
|
||||
input: { revisionId: created.id, reviewedById: 2, reason: 'Needs more work on dialogue' },
|
||||
ctx,
|
||||
const result = await caller.revisions.rejectRevision({
|
||||
revisionId: created.id,
|
||||
reason: 'Needs more work on dialogue',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('rejected');
|
||||
@@ -118,19 +127,21 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('rollbackToRevision', () => {
|
||||
it('should create a new revision with old content', async () => {
|
||||
const original = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'Original', content: 'original content', authorId: 1 },
|
||||
ctx,
|
||||
const original = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'Original',
|
||||
content: 'original content',
|
||||
});
|
||||
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'Changed', content: 'changed content', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'Changed',
|
||||
content: 'changed content',
|
||||
});
|
||||
|
||||
const rollback = await appRouter.revisions.rollbackToRevision.mutate({
|
||||
input: { scriptId: 1, revisionId: original.id, authorId: 1 },
|
||||
ctx,
|
||||
const rollback = await caller.revisions.rollbackToRevision({
|
||||
scriptId: 1,
|
||||
revisionId: original.id,
|
||||
});
|
||||
|
||||
expect(rollback.content).toBe('original content');
|
||||
@@ -141,19 +152,21 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('compareRevisions', () => {
|
||||
it('should compare two revisions', async () => {
|
||||
const rev1 = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'v1', content: 'line1\nline2\nline3', authorId: 1 },
|
||||
ctx,
|
||||
const rev1 = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'v1',
|
||||
content: 'line1\nline2\nline3',
|
||||
});
|
||||
|
||||
const rev2 = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'v2', content: 'line1\nchanged\nline3', authorId: 1 },
|
||||
ctx,
|
||||
const rev2 = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'v2',
|
||||
content: 'line1\nchanged\nline3',
|
||||
});
|
||||
|
||||
const result = await appRouter.revisions.compareRevisions.query({
|
||||
input: { baseRevisionId: rev1.id, targetRevisionId: rev2.id },
|
||||
ctx,
|
||||
const result = await caller.revisions.compareRevisions({
|
||||
baseRevisionId: rev1.id,
|
||||
targetRevisionId: rev2.id,
|
||||
});
|
||||
|
||||
expect(result.diff.modifications).toBe(1);
|
||||
@@ -164,20 +177,19 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('getTimeline', () => {
|
||||
it('should return timeline entries in chronological order', async () => {
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'First', content: 'first', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'First',
|
||||
content: 'first',
|
||||
});
|
||||
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'Second', content: 'second', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'Second',
|
||||
content: 'second',
|
||||
});
|
||||
|
||||
const timeline = await appRouter.revisions.getTimeline.query({
|
||||
input: { scriptId: 1 },
|
||||
ctx,
|
||||
});
|
||||
const timeline = await caller.revisions.getTimeline({ scriptId: 1 });
|
||||
|
||||
expect(timeline).toHaveLength(2);
|
||||
expect(timeline[0]!.revision.title).toBe('First');
|
||||
@@ -187,20 +199,18 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('getBranches', () => {
|
||||
it('should return branch information', async () => {
|
||||
await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'main v1', content: 'main', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'main v1',
|
||||
content: 'main',
|
||||
});
|
||||
|
||||
await appRouter.revisions.createBranch.mutate({
|
||||
input: { scriptId: 1, branchName: 'feature', authorId: 1 },
|
||||
ctx,
|
||||
await caller.revisions.createBranch({
|
||||
scriptId: 1,
|
||||
branchName: 'feature',
|
||||
});
|
||||
|
||||
const branches = await appRouter.revisions.getBranches.query({
|
||||
input: { scriptId: 1 },
|
||||
ctx,
|
||||
});
|
||||
const branches = await caller.revisions.getBranches({ scriptId: 1 });
|
||||
|
||||
expect(branches).toHaveLength(2);
|
||||
const branchNames = branches.map((b: any) => b.branchName);
|
||||
@@ -211,23 +221,18 @@ describe('revisionsRouter', () => {
|
||||
|
||||
describe('deleteRevision', () => {
|
||||
it('should delete a revision', async () => {
|
||||
const created = await appRouter.revisions.createRevision.mutate({
|
||||
input: { scriptId: 1, title: 'To delete', content: 'content', authorId: 1 },
|
||||
ctx,
|
||||
const created = await caller.revisions.createRevision({
|
||||
scriptId: 1,
|
||||
title: 'To delete',
|
||||
content: 'content',
|
||||
});
|
||||
|
||||
const result = await appRouter.revisions.deleteRevision.mutate({
|
||||
input: { id: created.id },
|
||||
ctx,
|
||||
});
|
||||
const result = await caller.revisions.deleteRevision({ id: created.id });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
await expect(
|
||||
appRouter.revisions.getRevision.query({
|
||||
input: { id: created.id },
|
||||
ctx,
|
||||
})
|
||||
caller.revisions.getRevision({ id: created.id })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,6 +119,14 @@ function getLatestVersionForScript(scriptId: number, branchName: string): number
|
||||
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({
|
||||
@@ -153,9 +161,12 @@ export const revisionsRouter = {
|
||||
content: z.string(),
|
||||
branchName: z.string().default('main'),
|
||||
parentRevisionId: z.number().int().positive().optional(),
|
||||
authorId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.userId) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const nextVersion = getLatestVersionForScript(
|
||||
input.scriptId,
|
||||
input.branchName
|
||||
@@ -170,7 +181,7 @@ export const revisionsRouter = {
|
||||
title: input.title,
|
||||
summary: input.summary || null,
|
||||
content: input.content,
|
||||
authorId: input.authorId,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
@@ -306,9 +317,12 @@ export const revisionsRouter = {
|
||||
acceptRevision: protectedProcedure
|
||||
.input(z.object({
|
||||
revisionId: z.number().int().positive(),
|
||||
reviewedById: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.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`);
|
||||
@@ -317,7 +331,7 @@ export const revisionsRouter = {
|
||||
const updated = {
|
||||
...revision,
|
||||
status: 'accepted' as const,
|
||||
reviewedById: input.reviewedById,
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -329,10 +343,13 @@ export const revisionsRouter = {
|
||||
rejectRevision: protectedProcedure
|
||||
.input(z.object({
|
||||
revisionId: z.number().int().positive(),
|
||||
reviewedById: z.number().int().positive(),
|
||||
reason: z.string().max(1000).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.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`);
|
||||
@@ -341,7 +358,7 @@ export const revisionsRouter = {
|
||||
const updated = {
|
||||
...revision,
|
||||
status: 'rejected' as const,
|
||||
reviewedById: input.reviewedById,
|
||||
reviewedById: ctx.userId,
|
||||
reviewedAt: new Date(),
|
||||
summary: input.reason
|
||||
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
|
||||
@@ -357,9 +374,12 @@ export const revisionsRouter = {
|
||||
.input(z.object({
|
||||
scriptId: z.number().int().positive(),
|
||||
revisionId: z.number().int().positive(),
|
||||
authorId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.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`);
|
||||
@@ -383,7 +403,7 @@ export const revisionsRouter = {
|
||||
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
|
||||
summary: `Rolled back to revision ${targetRevision.id}`,
|
||||
content: targetRevision.content,
|
||||
authorId: input.authorId,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
@@ -460,9 +480,12 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
branchName: z.string().min(1),
|
||||
fromRevisionId: z.number().int().positive().optional(),
|
||||
authorId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.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);
|
||||
|
||||
@@ -500,7 +523,7 @@ export const revisionsRouter = {
|
||||
title: `Branch: ${input.branchName}`,
|
||||
summary: null,
|
||||
content: sourceContent,
|
||||
authorId: input.authorId,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
@@ -517,9 +540,12 @@ export const revisionsRouter = {
|
||||
scriptId: z.number().int().positive(),
|
||||
sourceBranch: z.string(),
|
||||
targetBranch: z.string(),
|
||||
authorId: z.number().int().positive(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
.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');
|
||||
}
|
||||
@@ -547,7 +573,7 @@ export const revisionsRouter = {
|
||||
title: `Merge from '${input.sourceBranch}'`,
|
||||
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
|
||||
content: sourceContent,
|
||||
authorId: input.authorId,
|
||||
authorId: ctx.userId,
|
||||
status: 'draft' as const,
|
||||
reviewedById: null,
|
||||
reviewedAt: null,
|
||||
|
||||
@@ -161,5 +161,5 @@ export const SceneListSchema = z.array(SceneSchema);
|
||||
export interface TRPCContext {
|
||||
userId?: number;
|
||||
projectId?: number;
|
||||
db?: import('../../src/db/config/migrations').DrizzleDB;
|
||||
db?: ReturnType<typeof import('drizzle-orm/better-sqlite3').drizzle>;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,15 @@ export async function startServer(config: ServerConfig) {
|
||||
|
||||
// If run directly, start the server
|
||||
if (require.main === module) {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET environment variable is required. Please set it before starting the server.');
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
port: parseInt(process.env.WS_PORT || '8080', 10),
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-secret',
|
||||
enableAuth: process.env.ENABLE_AUTH === 'true',
|
||||
jwtSecret,
|
||||
enableAuth: process.env.ENABLE_AUTH !== 'false',
|
||||
};
|
||||
|
||||
startServer(config).catch((error) => {
|
||||
|
||||
@@ -41,9 +41,16 @@ export class DatabaseBackupManager {
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
);
|
||||
|
||||
const tableNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
for (const table of tables) {
|
||||
if (!tableNamePattern.test(table)) {
|
||||
console.warn(`Skipping invalid table name: ${table}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await this.dbManager.query<Record<string, unknown>>(
|
||||
`SELECT * FROM ${table}`
|
||||
`SELECT * FROM "${table}"`
|
||||
);
|
||||
|
||||
console.log(`Backed up ${table}: ${data.length} rows`);
|
||||
|
||||
Reference in New Issue
Block a user