@goscribe/server 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/context.ts +1 -0
- package/src/lib/auth.ts +17 -5
- package/src/routers/_app.ts +2 -2
- package/src/routers/auth.ts +52 -23
- package/src/routers/flashcards.ts +6 -0
- package/src/routers/studyguide.ts +0 -0
- package/src/routers/worksheets.ts +30 -24
- package/src/server.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goscribe/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"compression": "^1.8.1",
|
|
29
29
|
"cookie": "^1.0.2",
|
|
30
30
|
"cors": "^2.8.5",
|
|
31
|
+
"dotenv": "^17.2.1",
|
|
31
32
|
"express": "^5.1.0",
|
|
32
33
|
"helmet": "^8.1.0",
|
|
33
34
|
"morgan": "^1.10.1",
|
package/src/context.ts
CHANGED
|
@@ -9,6 +9,7 @@ export async function createContext({ req, res }: CreateExpressContextOptions) {
|
|
|
9
9
|
|
|
10
10
|
// Only use custom auth cookie
|
|
11
11
|
const custom = verifyCustomAuthCookie(cookies["auth_token"]);
|
|
12
|
+
|
|
12
13
|
if (custom) {
|
|
13
14
|
return { db: prisma, session: { user: { id: custom.userId } } as any, req, res, cookies };
|
|
14
15
|
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -3,26 +3,38 @@ import crypto from "node:crypto";
|
|
|
3
3
|
|
|
4
4
|
// Custom HMAC cookie: auth_token = base64(userId).hex(hmacSHA256(base64(userId), secret))
|
|
5
5
|
export function verifyCustomAuthCookie(cookieValue: string | undefined): { userId: string } | null {
|
|
6
|
-
if (!cookieValue)
|
|
6
|
+
if (!cookieValue) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
7
10
|
const secret = process.env.CUSTOM_AUTH_SECRET;
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
if (!secret) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
const parts = cookieValue.split(".");
|
|
11
|
-
|
|
17
|
+
|
|
18
|
+
if (parts.length !== 2) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
12
21
|
const [base64UserId, signatureHex] = parts;
|
|
13
22
|
|
|
14
23
|
let userId: string;
|
|
15
24
|
try {
|
|
16
25
|
const buf = Buffer.from(base64UserId, "base64url");
|
|
17
26
|
userId = buf.toString("utf8");
|
|
18
|
-
} catch {
|
|
27
|
+
} catch (error) {
|
|
19
28
|
return null;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
const hmac = crypto.createHmac("sha256", secret);
|
|
23
32
|
hmac.update(base64UserId);
|
|
24
33
|
const expected = hmac.digest("hex");
|
|
25
|
-
|
|
34
|
+
|
|
35
|
+
if (!timingSafeEqualHex(signatureHex, expected)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
return { userId };
|
|
28
40
|
}
|
package/src/routers/_app.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
|
|
2
2
|
import { router } from '../trpc.js';
|
|
3
3
|
import { auth } from './auth.js';
|
|
4
4
|
import { workspace } from './workspace.js';
|
|
5
|
-
import { flashcards } from './flashcards';
|
|
6
|
-
import { worksheets } from './worksheets';
|
|
5
|
+
import { flashcards } from './flashcards.js';
|
|
6
|
+
import { worksheets } from './worksheets.js';
|
|
7
7
|
|
|
8
8
|
export const appRouter = router({
|
|
9
9
|
auth,
|
package/src/routers/auth.ts
CHANGED
|
@@ -2,6 +2,21 @@ import { z } from 'zod';
|
|
|
2
2
|
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
3
3
|
import bcrypt from 'bcryptjs';
|
|
4
4
|
import { serialize } from 'cookie';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// Helper to create custom auth token
|
|
8
|
+
function createCustomAuthToken(userId: string): string {
|
|
9
|
+
const secret = process.env.AUTH_SECRET;
|
|
10
|
+
if (!secret) {
|
|
11
|
+
throw new Error("AUTH_SECRET is not set");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
|
|
15
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
16
|
+
hmac.update(base64UserId);
|
|
17
|
+
const signature = hmac.digest('hex');
|
|
18
|
+
return `${base64UserId}.${signature}`;
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
export const auth = router({
|
|
7
22
|
signup: publicProcedure
|
|
@@ -49,47 +64,61 @@ export const auth = router({
|
|
|
49
64
|
throw new Error("Invalid credentials");
|
|
50
65
|
}
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
// Create custom auth token
|
|
68
|
+
const authToken = createCustomAuthToken(user.id);
|
|
69
|
+
|
|
70
|
+
// Set the cookie immediately after successful login
|
|
71
|
+
const cookieValue = serialize("auth_token", authToken, {
|
|
72
|
+
httpOnly: true,
|
|
73
|
+
secure: process.env.NODE_ENV === "production",
|
|
74
|
+
sameSite: "lax",
|
|
75
|
+
path: "/",
|
|
76
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
57
77
|
});
|
|
78
|
+
|
|
79
|
+
ctx.res.setHeader("Set-Cookie", cookieValue);
|
|
58
80
|
|
|
59
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.email,
|
|
84
|
+
name: user.name,
|
|
85
|
+
image: user.image
|
|
86
|
+
};
|
|
60
87
|
}),
|
|
61
88
|
getSession: publicProcedure.query(async ({ ctx }) => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
if (!session) {
|
|
69
|
-
throw new Error("Session not found");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (session.expires < new Date()) {
|
|
73
|
-
throw new Error("Session expired");
|
|
89
|
+
// Just return the current session from context
|
|
90
|
+
if (!ctx.session) {
|
|
91
|
+
throw new Error("No session found");
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
const user = await ctx.db.user.findUnique({
|
|
77
|
-
where: { id: session.
|
|
95
|
+
where: { id: (ctx.session as any).user.id },
|
|
78
96
|
});
|
|
79
97
|
|
|
80
98
|
if (!user) {
|
|
81
99
|
throw new Error("User not found");
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
|
|
102
|
+
return {
|
|
103
|
+
user: {
|
|
104
|
+
id: user.id,
|
|
105
|
+
email: user.email,
|
|
106
|
+
name: user.name,
|
|
107
|
+
image: user.image
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
logout: publicProcedure.mutation(async ({ ctx }) => {
|
|
112
|
+
// Clear the auth cookie
|
|
113
|
+
ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
|
|
85
114
|
httpOnly: true,
|
|
86
115
|
secure: process.env.NODE_ENV === "production",
|
|
87
|
-
sameSite: "
|
|
116
|
+
sameSite: "lax",
|
|
88
117
|
path: "/",
|
|
89
|
-
maxAge:
|
|
118
|
+
maxAge: 0, // Expire immediately
|
|
90
119
|
}));
|
|
91
120
|
|
|
92
|
-
return {
|
|
121
|
+
return { success: true };
|
|
93
122
|
}),
|
|
94
123
|
});
|
|
95
124
|
|
|
@@ -20,6 +20,12 @@ export const flashcards = router({
|
|
|
20
20
|
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
21
21
|
return ctx.db.artifact.findMany({
|
|
22
22
|
where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET },
|
|
23
|
+
include: {
|
|
24
|
+
versions: {
|
|
25
|
+
orderBy: { version: 'desc' },
|
|
26
|
+
take: 1, // Get only the latest version
|
|
27
|
+
},
|
|
28
|
+
},
|
|
23
29
|
orderBy: { updatedAt: 'desc' },
|
|
24
30
|
});
|
|
25
31
|
}),
|
|
File without changes
|
|
@@ -24,12 +24,18 @@ export const worksheets = router({
|
|
|
24
24
|
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
25
25
|
return ctx.db.artifact.findMany({
|
|
26
26
|
where: { workspaceId: input.workspaceId, type: ArtifactType.WORKSHEET },
|
|
27
|
+
include: {
|
|
28
|
+
versions: {
|
|
29
|
+
orderBy: { version: 'desc' },
|
|
30
|
+
take: 1, // Get only the latest version
|
|
31
|
+
},
|
|
32
|
+
},
|
|
27
33
|
orderBy: { updatedAt: 'desc' },
|
|
28
34
|
});
|
|
29
35
|
}),
|
|
30
36
|
|
|
31
37
|
// Create a worksheet set
|
|
32
|
-
|
|
38
|
+
createWorksheet: authedProcedure
|
|
33
39
|
.input(z.object({ workspaceId: z.string().uuid(), title: z.string().min(1).max(120) }))
|
|
34
40
|
.mutation(async ({ ctx, input }) => {
|
|
35
41
|
const workspace = await ctx.db.workspace.findFirst({
|
|
@@ -47,25 +53,25 @@ export const worksheets = router({
|
|
|
47
53
|
}),
|
|
48
54
|
|
|
49
55
|
// Get a worksheet with its questions
|
|
50
|
-
|
|
51
|
-
.input(z.object({
|
|
56
|
+
getWorksheet: authedProcedure
|
|
57
|
+
.input(z.object({ worksheetId: z.string().uuid() }))
|
|
52
58
|
.query(async ({ ctx, input }) => {
|
|
53
|
-
const
|
|
59
|
+
const worksheet = await ctx.db.artifact.findFirst({
|
|
54
60
|
where: {
|
|
55
|
-
id: input.
|
|
61
|
+
id: input.worksheetId,
|
|
56
62
|
type: ArtifactType.WORKSHEET,
|
|
57
63
|
workspace: { ownerId: ctx.session.user.id },
|
|
58
64
|
},
|
|
59
65
|
include: { questions: true },
|
|
60
66
|
});
|
|
61
|
-
if (!
|
|
62
|
-
return
|
|
67
|
+
if (!worksheet) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
68
|
+
return worksheet;
|
|
63
69
|
}),
|
|
64
70
|
|
|
65
71
|
// Add a question to a worksheet
|
|
66
|
-
|
|
72
|
+
createWorksheetQuestion: authedProcedure
|
|
67
73
|
.input(z.object({
|
|
68
|
-
|
|
74
|
+
worksheetId: z.string().uuid(),
|
|
69
75
|
prompt: z.string().min(1),
|
|
70
76
|
answer: z.string().optional(),
|
|
71
77
|
difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(),
|
|
@@ -73,13 +79,13 @@ export const worksheets = router({
|
|
|
73
79
|
meta: z.record(z.string(), z.unknown()).optional(),
|
|
74
80
|
}))
|
|
75
81
|
.mutation(async ({ ctx, input }) => {
|
|
76
|
-
const
|
|
77
|
-
where: { id: input.
|
|
82
|
+
const worksheet = await ctx.db.artifact.findFirst({
|
|
83
|
+
where: { id: input.worksheetId, type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } },
|
|
78
84
|
});
|
|
79
|
-
if (!
|
|
85
|
+
if (!worksheet) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
80
86
|
return ctx.db.worksheetQuestion.create({
|
|
81
87
|
data: {
|
|
82
|
-
artifactId: input.
|
|
88
|
+
artifactId: input.worksheetId,
|
|
83
89
|
prompt: input.prompt,
|
|
84
90
|
answer: input.answer,
|
|
85
91
|
difficulty: (input.difficulty ?? Difficulty.MEDIUM) as any,
|
|
@@ -90,9 +96,9 @@ export const worksheets = router({
|
|
|
90
96
|
}),
|
|
91
97
|
|
|
92
98
|
// Update a question
|
|
93
|
-
|
|
99
|
+
updateWorksheetQuestion: authedProcedure
|
|
94
100
|
.input(z.object({
|
|
95
|
-
|
|
101
|
+
worksheetQuestionId: z.string().uuid(),
|
|
96
102
|
prompt: z.string().optional(),
|
|
97
103
|
answer: z.string().optional(),
|
|
98
104
|
difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(),
|
|
@@ -101,11 +107,11 @@ export const worksheets = router({
|
|
|
101
107
|
}))
|
|
102
108
|
.mutation(async ({ ctx, input }) => {
|
|
103
109
|
const q = await ctx.db.worksheetQuestion.findFirst({
|
|
104
|
-
where: { id: input.
|
|
110
|
+
where: { id: input.worksheetQuestionId, artifact: { type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } } },
|
|
105
111
|
});
|
|
106
112
|
if (!q) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
107
113
|
return ctx.db.worksheetQuestion.update({
|
|
108
|
-
where: { id: input.
|
|
114
|
+
where: { id: input.worksheetQuestionId },
|
|
109
115
|
data: {
|
|
110
116
|
prompt: input.prompt ?? q.prompt,
|
|
111
117
|
answer: input.answer ?? q.answer,
|
|
@@ -117,23 +123,23 @@ export const worksheets = router({
|
|
|
117
123
|
}),
|
|
118
124
|
|
|
119
125
|
// Delete a question
|
|
120
|
-
|
|
121
|
-
.input(z.object({
|
|
126
|
+
deleteWorksheetQuestion: authedProcedure
|
|
127
|
+
.input(z.object({ worksheetQuestionId: z.string().uuid() }))
|
|
122
128
|
.mutation(async ({ ctx, input }) => {
|
|
123
129
|
const q = await ctx.db.worksheetQuestion.findFirst({
|
|
124
|
-
where: { id: input.
|
|
130
|
+
where: { id: input.worksheetQuestionId, artifact: { workspace: { ownerId: ctx.session.user.id } } },
|
|
125
131
|
});
|
|
126
132
|
if (!q) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
127
|
-
await ctx.db.worksheetQuestion.delete({ where: { id: input.
|
|
133
|
+
await ctx.db.worksheetQuestion.delete({ where: { id: input.worksheetQuestionId } });
|
|
128
134
|
return true;
|
|
129
135
|
}),
|
|
130
136
|
|
|
131
137
|
// Delete a worksheet set and its questions
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
deleteWorksheet: authedProcedure
|
|
139
|
+
.input(z.object({ worksheetId: z.string().uuid() }))
|
|
134
140
|
.mutation(async ({ ctx, input }) => {
|
|
135
141
|
const deleted = await ctx.db.artifact.deleteMany({
|
|
136
|
-
where: { id: input.
|
|
142
|
+
where: { id: input.worksheetId, type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } },
|
|
137
143
|
});
|
|
138
144
|
if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
139
145
|
return true;
|
package/src/server.ts
CHANGED