aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/db/index.ts12
-rw-r--r--src/server/db/schema.ts116
-rw-r--r--src/server/index.test.ts12
-rw-r--r--src/server/index.ts30
-rw-r--r--src/server/middleware/auth.test.ts190
-rw-r--r--src/server/middleware/auth.ts65
-rw-r--r--src/server/middleware/error-handler.test.ts173
-rw-r--r--src/server/middleware/error-handler.ts95
-rw-r--r--src/server/middleware/index.ts2
-rw-r--r--src/server/repositories/index.ts3
-rw-r--r--src/server/repositories/refresh-token.ts35
-rw-r--r--src/server/repositories/types.ts46
-rw-r--r--src/server/repositories/user.ts57
-rw-r--r--src/server/routes/auth.test.ts428
-rw-r--r--src/server/routes/auth.ts199
-rw-r--r--src/server/routes/index.ts1
-rw-r--r--src/server/schemas/index.ts140
-rw-r--r--src/server/types/index.ts79
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;
+}