diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/db/index.ts | 12 | ||||
| -rw-r--r-- | src/server/db/schema.ts | 116 | ||||
| -rw-r--r-- | src/server/index.test.ts | 12 | ||||
| -rw-r--r-- | src/server/index.ts | 30 | ||||
| -rw-r--r-- | src/server/middleware/auth.test.ts | 190 | ||||
| -rw-r--r-- | src/server/middleware/auth.ts | 65 | ||||
| -rw-r--r-- | src/server/middleware/error-handler.test.ts | 173 | ||||
| -rw-r--r-- | src/server/middleware/error-handler.ts | 95 | ||||
| -rw-r--r-- | src/server/middleware/index.ts | 2 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 3 | ||||
| -rw-r--r-- | src/server/repositories/refresh-token.ts | 35 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 46 | ||||
| -rw-r--r-- | src/server/repositories/user.ts | 57 | ||||
| -rw-r--r-- | src/server/routes/auth.test.ts | 428 | ||||
| -rw-r--r-- | src/server/routes/auth.ts | 199 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 | ||||
| -rw-r--r-- | src/server/schemas/index.ts | 140 | ||||
| -rw-r--r-- | src/server/types/index.ts | 79 |
18 files changed, 1683 insertions, 0 deletions
diff --git a/src/server/db/index.ts b/src/server/db/index.ts new file mode 100644 index 0000000..22da621 --- /dev/null +++ b/src/server/db/index.ts @@ -0,0 +1,12 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "./schema.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is not set"); +} + +export const db = drizzle(databaseUrl, { schema }); + +export * from "./schema.js"; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 0000000..4b9631f --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,116 @@ +import { + integer, + pgTable, + real, + smallint, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +// Card states for FSRS algorithm +export const CardState = { + New: 0, + Learning: 1, + Review: 2, + Relearning: 3, +} as const; + +// Rating values for reviews +export const Rating = { + Again: 1, + Hard: 2, + Good: 3, + Easy: 4, +} as const; + +export const users = pgTable("users", { + id: uuid("id").primaryKey().defaultRandom(), + username: varchar("username", { length: 255 }).notNull().unique(), + passwordHash: varchar("password_hash", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const refreshTokens = pgTable("refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const decks = pgTable("decks", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + newCardsPerDay: integer("new_cards_per_day").notNull().default(20), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const cards = pgTable("cards", { + id: uuid("id").primaryKey().defaultRandom(), + deckId: uuid("deck_id") + .notNull() + .references(() => decks.id), + front: text("front").notNull(), + back: text("back").notNull(), + + // FSRS fields + state: smallint("state").notNull().default(CardState.New), + due: timestamp("due", { withTimezone: true }).notNull().defaultNow(), + stability: real("stability").notNull().default(0), + difficulty: real("difficulty").notNull().default(0), + elapsedDays: integer("elapsed_days").notNull().default(0), + scheduledDays: integer("scheduled_days").notNull().default(0), + reps: integer("reps").notNull().default(0), + lapses: integer("lapses").notNull().default(0), + lastReview: timestamp("last_review", { withTimezone: true }), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const reviewLogs = pgTable("review_logs", { + id: uuid("id").primaryKey().defaultRandom(), + cardId: uuid("card_id") + .notNull() + .references(() => cards.id), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + rating: smallint("rating").notNull(), + state: smallint("state").notNull(), + scheduledDays: integer("scheduled_days").notNull(), + elapsedDays: integer("elapsed_days").notNull(), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }) + .notNull() + .defaultNow(), + durationMs: integer("duration_ms"), + syncVersion: integer("sync_version").notNull().default(0), +}); diff --git a/src/server/index.test.ts b/src/server/index.test.ts new file mode 100644 index 0000000..6d2dda9 --- /dev/null +++ b/src/server/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { app } from "./index.js"; + +describe("Hono app", () => { + describe("GET /api/health", () => { + it("returns ok status", async () => { + const res = await app.request("/api/health"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..01a489f --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,30 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { errorHandler } from "./middleware/index.js"; +import { auth } from "./routes/index.js"; + +const app = new Hono(); + +app.use("*", logger()); +app.onError(errorHandler); + +app.get("/", (c) => { + return c.json({ message: "Kioku API" }); +}); + +app.get("/api/health", (c) => { + return c.json({ status: "ok" }); +}); + +app.route("/api/auth", auth); + +const port = Number(process.env.PORT) || 3000; +console.log(`Server is running on port ${port}`); + +serve({ + fetch: app.fetch, + port, +}); + +export { app }; diff --git a/src/server/middleware/auth.test.ts b/src/server/middleware/auth.test.ts new file mode 100644 index 0000000..a8b7f3d --- /dev/null +++ b/src/server/middleware/auth.test.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it } from "vitest"; +import { authMiddleware, getAuthUser } from "./auth.js"; +import { errorHandler } from "./error-handler.js"; + +const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +interface ErrorResponse { + error: { + code: string; + message: string; + }; +} + +interface SuccessResponse { + userId: string; +} + +describe("authMiddleware", () => { + let app: Hono; + + beforeEach(() => { + app = new Hono(); + app.onError(errorHandler); + app.use("/protected/*", authMiddleware); + app.get("/protected/resource", (c) => { + const user = getAuthUser(c); + return c.json({ userId: user.id }); + }); + }); + + it("allows access with valid token", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now, + exp: now + 3600, + }, + JWT_SECRET, + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SuccessResponse; + expect(body.userId).toBe("user-123"); + }); + + it("returns 401 when Authorization header is missing", async () => { + const res = await app.request("/protected/resource"); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("MISSING_AUTH"); + }); + + it("returns 401 for invalid Authorization format", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Basic sometoken", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); + }); + + it("returns 401 for empty Bearer token", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Bearer", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); + }); + + it("returns 401 for expired token", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now - 7200, + exp: now - 3600, // expired 1 hour ago + }, + JWT_SECRET, + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); + + it("returns 401 for invalid token", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Bearer invalid.token.here", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); + + it("returns 401 for token signed with wrong secret", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now, + exp: now + 3600, + }, + "wrong-secret", + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); +}); + +describe("getAuthUser", () => { + it("returns user from context when authenticated", async () => { + const app = new Hono(); + app.onError(errorHandler); + app.use("/test", authMiddleware); + app.get("/test", (c) => { + const user = getAuthUser(c); + return c.json({ id: user.id }); + }); + + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "test-user-456", + iat: now, + exp: now + 3600, + }, + JWT_SECRET, + ); + + const res = await app.request("/test", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string }; + expect(body.id).toBe("test-user-456"); + }); + + it("throws when user is not in context", async () => { + const app = new Hono(); + app.onError(errorHandler); + // Note: no authMiddleware applied + app.get("/unprotected", (c) => { + const user = getAuthUser(c); + return c.json({ id: user.id }); + }); + + const res = await app.request("/unprotected"); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("NOT_AUTHENTICATED"); + }); +}); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts new file mode 100644 index 0000000..51b4d9d --- /dev/null +++ b/src/server/middleware/auth.ts @@ -0,0 +1,65 @@ +import type { Context, Next } from "hono"; +import { verify } from "hono/jwt"; +import { Errors } from "./error-handler.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} + +export interface AuthUser { + id: string; +} + +interface JWTPayload { + sub: string; + iat: number; + exp: number; +} + +/** + * Auth middleware that validates JWT tokens from Authorization header + * Sets the authenticated user in context variables + */ +export async function authMiddleware(c: Context, next: Next) { + const authHeader = c.req.header("Authorization"); + + if (!authHeader) { + throw Errors.unauthorized("Missing Authorization header", "MISSING_AUTH"); + } + + if (!authHeader.startsWith("Bearer ")) { + throw Errors.unauthorized( + "Invalid Authorization header format", + "INVALID_AUTH_FORMAT", + ); + } + + const token = authHeader.slice(7); + + try { + const payload = (await verify(token, JWT_SECRET)) as unknown as JWTPayload; + + const user: AuthUser = { + id: payload.sub, + }; + + c.set("user", user); + + await next(); + } catch { + throw Errors.unauthorized("Invalid or expired token", "INVALID_TOKEN"); + } +} + +/** + * Helper function to get the authenticated user from context + * Throws if user is not authenticated + */ +export function getAuthUser(c: Context): AuthUser { + const user = c.get("user") as AuthUser | undefined; + if (!user) { + throw Errors.unauthorized("Not authenticated", "NOT_AUTHENTICATED"); + } + return user; +} diff --git a/src/server/middleware/error-handler.test.ts b/src/server/middleware/error-handler.test.ts new file mode 100644 index 0000000..d4be84f --- /dev/null +++ b/src/server/middleware/error-handler.test.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { AppError, Errors, errorHandler } from "./error-handler.js"; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + return app; +} + +describe("errorHandler", () => { + describe("AppError handling", () => { + it("returns correct status and message for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Custom error", 400, "CUSTOM_ERROR"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Custom error", + code: "CUSTOM_ERROR", + }, + }); + }); + + it("uses default values for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Something went wrong"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Something went wrong", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("Errors factory functions", () => { + it("handles badRequest error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.badRequest("Invalid input"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Invalid input", + code: "BAD_REQUEST", + }, + }); + }); + + it("handles unauthorized error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.unauthorized(); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: { + message: "Unauthorized", + code: "UNAUTHORIZED", + }, + }); + }); + + it("handles forbidden error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.forbidden("Access denied"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: { + message: "Access denied", + code: "FORBIDDEN", + }, + }); + }); + + it("handles notFound error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.notFound("Resource not found"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { + message: "Resource not found", + code: "NOT_FOUND", + }, + }); + }); + + it("handles conflict error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.conflict("Already exists"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { + message: "Already exists", + code: "CONFLICT", + }, + }); + }); + + it("handles validationError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.validationError("Invalid data"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + message: "Invalid data", + code: "VALIDATION_ERROR", + }, + }); + }); + + it("handles internal error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.internal("Database connection failed"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Database connection failed", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("unknown error handling", () => { + it("handles generic Error with 500 status", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new Error("Unexpected error"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INTERNAL_ERROR"); + }); + }); +}); diff --git a/src/server/middleware/error-handler.ts b/src/server/middleware/error-handler.ts new file mode 100644 index 0000000..7b92940 --- /dev/null +++ b/src/server/middleware/error-handler.ts @@ -0,0 +1,95 @@ +import type { Context } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +/** + * Application-specific error with status code and optional details + */ +export class AppError extends Error { + readonly statusCode: ContentfulStatusCode; + readonly code: string; + + constructor( + message: string, + statusCode: ContentfulStatusCode = 500, + code = "INTERNAL_ERROR", + ) { + super(message); + this.name = "AppError"; + this.statusCode = statusCode; + this.code = code; + } +} + +/** + * Common error factory functions + */ +export const Errors = { + badRequest: (message = "Bad request", code = "BAD_REQUEST") => + new AppError(message, 400, code), + + unauthorized: (message = "Unauthorized", code = "UNAUTHORIZED") => + new AppError(message, 401, code), + + forbidden: (message = "Forbidden", code = "FORBIDDEN") => + new AppError(message, 403, code), + + notFound: (message = "Not found", code = "NOT_FOUND") => + new AppError(message, 404, code), + + conflict: (message = "Conflict", code = "CONFLICT") => + new AppError(message, 409, code), + + validationError: (message = "Validation failed", code = "VALIDATION_ERROR") => + new AppError(message, 422, code), + + internal: (message = "Internal server error", code = "INTERNAL_ERROR") => + new AppError(message, 500, code), +}; + +interface ErrorResponse { + error: { + message: string; + code: string; + }; +} + +/** + * Global error handler middleware for Hono + */ +export function errorHandler(err: Error, c: Context): Response { + // Handle AppError + if (err instanceof AppError) { + const response: ErrorResponse = { + error: { + message: err.message, + code: err.code, + }, + }; + return c.json(response, err.statusCode); + } + + // Handle Hono's HTTPException + if (err instanceof HTTPException) { + const response: ErrorResponse = { + error: { + message: err.message, + code: "HTTP_ERROR", + }, + }; + return c.json(response, err.status as ContentfulStatusCode); + } + + // Handle unknown errors + console.error("Unhandled error:", err); + const response: ErrorResponse = { + error: { + message: + process.env.NODE_ENV === "production" + ? "Internal server error" + : err.message, + code: "INTERNAL_ERROR", + }, + }; + return c.json(response, 500); +} diff --git a/src/server/middleware/index.ts b/src/server/middleware/index.ts new file mode 100644 index 0000000..e894a42 --- /dev/null +++ b/src/server/middleware/index.ts @@ -0,0 +1,2 @@ +export { type AuthUser, authMiddleware, getAuthUser } from "./auth.js"; +export { AppError, Errors, errorHandler } from "./error-handler.js"; diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts new file mode 100644 index 0000000..04b1f35 --- /dev/null +++ b/src/server/repositories/index.ts @@ -0,0 +1,3 @@ +export { refreshTokenRepository } from "./refresh-token.js"; +export * from "./types.js"; +export { userRepository } from "./user.js"; diff --git a/src/server/repositories/refresh-token.ts b/src/server/repositories/refresh-token.ts new file mode 100644 index 0000000..e92a744 --- /dev/null +++ b/src/server/repositories/refresh-token.ts @@ -0,0 +1,35 @@ +import { and, eq, gt } from "drizzle-orm"; +import { db, refreshTokens } from "../db/index.js"; +import type { RefreshTokenRepository } from "./types.js"; + +export const refreshTokenRepository: RefreshTokenRepository = { + async findValidToken(tokenHash) { + const [token] = await db + .select({ + id: refreshTokens.id, + userId: refreshTokens.userId, + expiresAt: refreshTokens.expiresAt, + }) + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, tokenHash), + gt(refreshTokens.expiresAt, new Date()), + ), + ) + .limit(1); + return token; + }, + + async create(data) { + await db.insert(refreshTokens).values({ + userId: data.userId, + tokenHash: data.tokenHash, + expiresAt: data.expiresAt, + }); + }, + + async deleteById(id) { + await db.delete(refreshTokens).where(eq(refreshTokens.id, id)); + }, +}; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts new file mode 100644 index 0000000..1ab4bdc --- /dev/null +++ b/src/server/repositories/types.ts @@ -0,0 +1,46 @@ +/** + * Repository types for abstracting database operations + */ + +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +export interface UserPublic { + id: string; + username: string; + createdAt: Date; +} + +export interface RefreshToken { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + createdAt: Date; +} + +export interface UserRepository { + findByUsername( + username: string, + ): Promise<Pick<User, "id" | "username" | "passwordHash"> | undefined>; + existsByUsername(username: string): Promise<boolean>; + create(data: { username: string; passwordHash: string }): Promise<UserPublic>; + findById(id: string): Promise<Pick<User, "id" | "username"> | undefined>; +} + +export interface RefreshTokenRepository { + findValidToken( + tokenHash: string, + ): Promise<Pick<RefreshToken, "id" | "userId" | "expiresAt"> | undefined>; + create(data: { + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise<void>; + deleteById(id: string): Promise<void>; +} diff --git a/src/server/repositories/user.ts b/src/server/repositories/user.ts new file mode 100644 index 0000000..e571409 --- /dev/null +++ b/src/server/repositories/user.ts @@ -0,0 +1,57 @@ +import { eq } from "drizzle-orm"; +import { db, users } from "../db/index.js"; +import type { UserPublic, UserRepository } from "./types.js"; + +export const userRepository: UserRepository = { + async findByUsername(username) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + passwordHash: users.passwordHash, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user; + }, + + async existsByUsername(username) { + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user !== undefined; + }, + + async create(data): Promise<UserPublic> { + const [newUser] = await db + .insert(users) + .values({ + username: data.username, + passwordHash: data.passwordHash, + }) + .returning({ + id: users.id, + username: users.username, + createdAt: users.createdAt, + }); + if (!newUser) { + throw new Error("Failed to create user"); + } + return newUser; + }, + + async findById(id) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + return user; + }, +}; diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts new file mode 100644 index 0000000..95fd6e9 --- /dev/null +++ b/src/server/routes/auth.test.ts @@ -0,0 +1,428 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import type { + RefreshTokenRepository, + UserPublic, + UserRepository, +} from "../repositories/index.js"; +import { createAuthRouter } from "./auth.js"; + +vi.mock("argon2", () => ({ + hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), + verify: vi.fn((hash: string, password: string) => + Promise.resolve(hash === `hashed_${password}`), + ), +})); + +function createMockUserRepo(): UserRepository { + return { + findByUsername: vi.fn(), + existsByUsername: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + }; +} + +function createMockRefreshTokenRepo(): RefreshTokenRepository { + return { + findValidToken: vi.fn(), + create: vi.fn(), + deleteById: vi.fn(), + }; +} + +interface RegisterResponse { + user?: { + id: string; + username: string; + createdAt: string; + }; + error?: { + code: string; + message: string; + }; +} + +interface LoginResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /register", () => { + let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("creates a new user with valid credentials", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(false); + vi.mocked(mockUserRepo.create).mockResolvedValue({ + id: "test-uuid-123", + username: "testuser", + createdAt: new Date("2024-01-01T00:00:00Z"), + } as UserPublic); + + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as RegisterResponse; + expect(body.user).toEqual({ + id: "test-uuid-123", + username: "testuser", + createdAt: "2024-01-01T00:00:00.000Z", + }); + expect(mockUserRepo.existsByUsername).toHaveBeenCalledWith("testuser"); + expect(mockUserRepo.create).toHaveBeenCalledWith({ + username: "testuser", + passwordHash: "hashed_securepassword12345", + }); + }); + + it("returns 422 for invalid username", async () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for password too short", async () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "tooshort123456", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 409 for existing username", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true); + + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "existinguser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("USERNAME_EXISTS"); + }); +}); + +describe("POST /login", () => { + let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns access token for valid credentials", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "correctpassword", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as LoginResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); + }); + + it("returns 401 for non-existent user", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "nonexistent", + password: "anypassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 401 for incorrect password", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "wrongpassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 422 for missing username", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "somepassword", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for missing password", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); + +interface RefreshResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /refresh", () => { + let app: Hono; + let mockUserRepo: ReturnType<typeof createMockUserRepo>; + let mockRefreshTokenRepo: ReturnType<typeof createMockRefreshTokenRepo>; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns new tokens for valid refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "user-uuid-123", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + }); + vi.mocked(mockRefreshTokenRepo.deleteById).mockResolvedValue(undefined); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token-hex", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as RefreshResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + expect(mockRefreshTokenRepo.deleteById).toHaveBeenCalledWith( + "token-id-123", + ); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); + }); + + it("returns 401 for invalid refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "invalid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 for expired refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "expired-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 when user not found", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "deleted-user-id", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("USER_NOT_FOUND"); + }); + + it("returns 422 for missing refresh token", async () => { + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for empty refresh token", async () => { + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..25c959b --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,199 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as argon2 from "argon2"; +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { Errors } from "../middleware/index.js"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories/index.js"; +import { + createUserSchema, + loginSchema, + refreshTokenSchema, +} from "../schemas/index.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} +const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes +const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days + +function generateRefreshToken(): string { + return randomBytes(32).toString("hex"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} + +export function createAuthRouter(deps: AuthDependencies) { + const { userRepo, refreshTokenRepo } = deps; + const auth = new Hono(); + + auth.post("/register", async (c) => { + const body = await c.req.json(); + + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const newUser = await userRepo.create({ username, passwordHash }); + + return c.json({ user: newUser }, 201); + }); + + auth.post("/login", async (c) => { + const body = await c.req.json(); + + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Find user by username + const user = await userRepo.findByUsername(username); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + // Store refresh token in database + await refreshTokenRepo.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + auth.post("/refresh", async (c) => { + const body = await c.req.json(); + + const parsed = refreshTokenSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { refreshToken } = parsed.data; + const tokenHash = hashToken(refreshToken); + + // Find valid refresh token + const storedToken = await refreshTokenRepo.findValidToken(tokenHash); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const user = await userRepo.findById(storedToken.userId); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // Delete old refresh token (rotation) + await refreshTokenRepo.deleteById(storedToken.id); + + // Generate new access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate new refresh token (rotation) + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + await refreshTokenRepo.create({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts new file mode 100644 index 0000000..0b89782 --- /dev/null +++ b/src/server/routes/index.ts @@ -0,0 +1 @@ +export { auth } from "./auth.js"; diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts new file mode 100644 index 0000000..05b926a --- /dev/null +++ b/src/server/schemas/index.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +// Card states for FSRS algorithm +export const cardStateSchema = z.union([ + z.literal(0), // New + z.literal(1), // Learning + z.literal(2), // Review + z.literal(3), // Relearning +]); + +// Rating values for reviews +export const ratingSchema = z.union([ + z.literal(1), // Again + z.literal(2), // Hard + z.literal(3), // Good + z.literal(4), // Easy +]); + +// User schema +export const userSchema = z.object({ + id: z.string().uuid(), + username: z.string().min(1).max(255), + passwordHash: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +// User creation input schema +export const createUserSchema = z.object({ + username: z.string().min(1).max(255), + password: z.string().min(15).max(255), +}); + +// Login input schema +export const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +// Refresh token input schema +export const refreshTokenSchema = z.object({ + refreshToken: z.string().min(1), +}); + +// Deck schema +export const deckSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + name: z.string().min(1).max(255), + description: z.string().max(1000).nullable(), + newCardsPerDay: z.number().int().min(0).default(20), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// Deck creation input schema +export const createDeckSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().max(1000).nullable().optional(), + newCardsPerDay: z.number().int().min(0).default(20), +}); + +// Deck update input schema +export const updateDeckSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).nullable().optional(), + newCardsPerDay: z.number().int().min(0).optional(), +}); + +// Card schema +export const cardSchema = z.object({ + id: z.string().uuid(), + deckId: z.string().uuid(), + front: z.string().min(1), + back: z.string().min(1), + + // FSRS fields + state: cardStateSchema, + due: z.coerce.date(), + stability: z.number().min(0), + difficulty: z.number().min(0).max(10), + elapsedDays: z.number().int().min(0), + scheduledDays: z.number().int().min(0), + reps: z.number().int().min(0), + lapses: z.number().int().min(0), + lastReview: z.coerce.date().nullable(), + + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// Card creation input schema +export const createCardSchema = z.object({ + front: z.string().min(1), + back: z.string().min(1), +}); + +// Card update input schema +export const updateCardSchema = z.object({ + front: z.string().min(1).optional(), + back: z.string().min(1).optional(), +}); + +// ReviewLog schema +export const reviewLogSchema = z.object({ + id: z.string().uuid(), + cardId: z.string().uuid(), + userId: z.string().uuid(), + rating: ratingSchema, + state: cardStateSchema, + scheduledDays: z.number().int().min(0), + elapsedDays: z.number().int().min(0), + reviewedAt: z.coerce.date(), + durationMs: z.number().int().min(0).nullable(), + syncVersion: z.number().int().min(0), +}); + +// Submit review input schema +export const submitReviewSchema = z.object({ + rating: ratingSchema, + durationMs: z.number().int().min(0).nullable().optional(), +}); + +// Inferred types from schemas +export type UserSchema = z.infer<typeof userSchema>; +export type CreateUserSchema = z.infer<typeof createUserSchema>; +export type LoginSchema = z.infer<typeof loginSchema>; +export type RefreshTokenSchema = z.infer<typeof refreshTokenSchema>; +export type DeckSchema = z.infer<typeof deckSchema>; +export type CreateDeckSchema = z.infer<typeof createDeckSchema>; +export type UpdateDeckSchema = z.infer<typeof updateDeckSchema>; +export type CardSchema = z.infer<typeof cardSchema>; +export type CreateCardSchema = z.infer<typeof createCardSchema>; +export type UpdateCardSchema = z.infer<typeof updateCardSchema>; +export type ReviewLogSchema = z.infer<typeof reviewLogSchema>; +export type SubmitReviewSchema = z.infer<typeof submitReviewSchema>; diff --git a/src/server/types/index.ts b/src/server/types/index.ts new file mode 100644 index 0000000..bfba06f --- /dev/null +++ b/src/server/types/index.ts @@ -0,0 +1,79 @@ +// Card states for FSRS algorithm +export const CardState = { + New: 0, + Learning: 1, + Review: 2, + Relearning: 3, +} as const; + +export type CardState = (typeof CardState)[keyof typeof CardState]; + +// Rating values for reviews +export const Rating = { + Again: 1, + Hard: 2, + Good: 3, + Easy: 4, +} as const; + +export type Rating = (typeof Rating)[keyof typeof Rating]; + +// User +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +// Deck +export interface Deck { + id: string; + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +// Card with FSRS fields +export interface Card { + id: string; + deckId: string; + front: string; + back: string; + + // FSRS fields + state: CardState; + due: Date; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: Date | null; + + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +// ReviewLog (append-only) +export interface ReviewLog { + id: string; + cardId: string; + userId: string; + rating: Rating; + state: CardState; + scheduledDays: number; + elapsedDays: number; + reviewedAt: Date; + durationMs: number | null; + syncVersion: number; +} |
