aboutsummaryrefslogtreecommitdiffhomepage
path: root/pkgs/server
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-03 05:28:29 +0900
committernsfisis <nsfisis@gmail.com>2025-12-04 23:26:25 +0900
commit950217ed3ca93a0aa0e964c2a8474ffc13c71912 (patch)
treef5a8575a74aa8203a8fd49846ab859670009ffd7 /pkgs/server
parent7e75e0fa8f26a7890c210c67e4474778811b15bc (diff)
downloadkioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.tar.gz
kioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.tar.zst
kioku-950217ed3ca93a0aa0e964c2a8474ffc13c71912.zip
feat(auth): add user registration endpoint
Implement POST /api/auth/register endpoint with: - Argon2 password hashing - Zod validation for username (1-255 chars) and password (8-255 chars) - Duplicate username check (returns 409 Conflict) - Returns created user with id, username, and createdAt 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'pkgs/server')
-rw-r--r--pkgs/server/package.json2
-rw-r--r--pkgs/server/src/index.ts3
-rw-r--r--pkgs/server/src/routes/auth.test.ts148
-rw-r--r--pkgs/server/src/routes/auth.ts50
-rw-r--r--pkgs/server/src/routes/index.ts1
5 files changed, 204 insertions, 0 deletions
diff --git a/pkgs/server/package.json b/pkgs/server/package.json
index 8416a55..4159084 100644
--- a/pkgs/server/package.json
+++ b/pkgs/server/package.json
@@ -20,6 +20,8 @@
"type": "module",
"dependencies": {
"@hono/node-server": "^1.19.6",
+ "@kioku/shared": "workspace:*",
+ "argon2": "^0.44.0",
"drizzle-orm": "^0.44.7",
"hono": "^4.10.7",
"pg": "^8.16.3"
diff --git a/pkgs/server/src/index.ts b/pkgs/server/src/index.ts
index a00c9fd..a0ae0a4 100644
--- a/pkgs/server/src/index.ts
+++ b/pkgs/server/src/index.ts
@@ -2,6 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { errorHandler } from "./middleware";
+import { auth } from "./routes";
const app = new Hono();
@@ -16,6 +17,8 @@ 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}`);
diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts
new file mode 100644
index 0000000..2d60636
--- /dev/null
+++ b/pkgs/server/src/routes/auth.test.ts
@@ -0,0 +1,148 @@
+import { Hono } from "hono";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { errorHandler } from "../middleware";
+import { auth } from "./auth";
+
+vi.mock("../db", () => {
+ const mockUsers: Array<{
+ id: string;
+ username: string;
+ passwordHash: string;
+ createdAt: Date;
+ }> = [];
+
+ return {
+ db: {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ limit: vi.fn(() =>
+ Promise.resolve(
+ mockUsers.filter((u) => u.username === "existinguser"),
+ ),
+ ),
+ })),
+ })),
+ })),
+ insert: vi.fn(() => ({
+ values: vi.fn((data: { username: string; passwordHash: string }) => ({
+ returning: vi.fn(() => {
+ const newUser = {
+ id: "test-uuid-123",
+ username: data.username,
+ createdAt: new Date("2024-01-01T00:00:00Z"),
+ };
+ mockUsers.push({ ...newUser, passwordHash: data.passwordHash });
+ return Promise.resolve([newUser]);
+ }),
+ })),
+ })),
+ },
+ users: {
+ id: "id",
+ username: "username",
+ createdAt: "created_at",
+ },
+ };
+});
+
+vi.mock("argon2", () => ({
+ hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)),
+}));
+
+interface RegisterResponse {
+ user?: {
+ id: string;
+ username: string;
+ createdAt: string;
+ };
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+describe("POST /register", () => {
+ let app: Hono;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/auth", auth);
+ });
+
+ it("creates a new user with valid credentials", async () => {
+ 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",
+ });
+ });
+
+ 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 () => {
+ const { db } = await import("../db");
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ vi.mocked(db.select).mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ where: vi.fn().mockReturnValue({
+ limit: vi.fn().mockResolvedValue([{ id: "existing-id" }]),
+ }),
+ }),
+ } as unknown as ReturnType<typeof db.select>);
+
+ 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");
+ });
+});
diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts
new file mode 100644
index 0000000..3906d65
--- /dev/null
+++ b/pkgs/server/src/routes/auth.ts
@@ -0,0 +1,50 @@
+import { createUserSchema } from "@kioku/shared";
+import * as argon2 from "argon2";
+import { eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { db, users } from "../db";
+import { Errors } from "../middleware";
+
+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 existingUser = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.username, username))
+ .limit(1);
+
+ if (existingUser.length > 0) {
+ throw Errors.conflict("Username already exists", "USERNAME_EXISTS");
+ }
+
+ // Hash password with Argon2
+ const passwordHash = await argon2.hash(password);
+
+ // Create user
+ const [newUser] = await db
+ .insert(users)
+ .values({
+ username,
+ passwordHash,
+ })
+ .returning({
+ id: users.id,
+ username: users.username,
+ createdAt: users.createdAt,
+ });
+
+ return c.json({ user: newUser }, 201);
+});
+
+export { auth };
diff --git a/pkgs/server/src/routes/index.ts b/pkgs/server/src/routes/index.ts
new file mode 100644
index 0000000..2925e6d
--- /dev/null
+++ b/pkgs/server/src/routes/index.ts
@@ -0,0 +1 @@
+export { auth } from "./auth";