aboutsummaryrefslogtreecommitdiffhomepage
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
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>
-rw-r--r--docs/dev/roadmap.md2
-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
-rw-r--r--pkgs/shared/package.json9
-rw-r--r--pkgs/shared/src/schemas/index.ts2
-rw-r--r--pnpm-lock.yaml94
9 files changed, 308 insertions, 3 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 57fdaed..bce7a54 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -27,7 +27,7 @@
- [x] Zod validation schemas
### Authentication
-- [ ] User registration endpoint
+- [x] User registration endpoint
- [ ] Login endpoint (JWT)
- [ ] Refresh token endpoint
- [ ] Auth middleware
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";
diff --git a/pkgs/shared/package.json b/pkgs/shared/package.json
index d208456..8c50a8b 100644
--- a/pkgs/shared/package.json
+++ b/pkgs/shared/package.json
@@ -2,7 +2,14 @@
"name": "@kioku/shared",
"version": "0.1.0",
"private": true,
- "main": "index.js",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ }
+ },
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
diff --git a/pkgs/shared/src/schemas/index.ts b/pkgs/shared/src/schemas/index.ts
index c211381..28b5f55 100644
--- a/pkgs/shared/src/schemas/index.ts
+++ b/pkgs/shared/src/schemas/index.ts
@@ -28,7 +28,7 @@ export const userSchema = z.object({
// User creation input schema
export const createUserSchema = z.object({
username: z.string().min(1).max(255),
- password: z.string().min(8).max(255),
+ password: z.string().min(15).max(255),
});
// Login input schema
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f13349..9ce2ee0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,12 @@ importers:
'@hono/node-server':
specifier: ^1.19.6
version: 1.19.6(hono@4.10.7)
+ '@kioku/shared':
+ specifier: workspace:*
+ version: link:../shared
+ argon2:
+ specifier: ^0.44.0
+ version: 0.44.0
drizzle-orm:
specifier: ^0.44.7
version: 0.44.7(@types/pg@8.15.6)(pg@8.16.3)
@@ -109,6 +115,9 @@ packages:
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+ '@epic-web/invariant@1.0.0':
+ resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
+
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
@@ -414,6 +423,10 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@phc/format@1.0.0':
+ resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
+ engines: {node: '>=10'}
+
'@rollup/rollup-android-arm-eabi@4.53.3':
resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==}
cpu: [arm]
@@ -571,6 +584,10 @@ packages:
'@vitest/utils@4.0.14':
resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==}
+ argon2@0.44.0:
+ resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==}
+ engines: {node: '>=16.17.0'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -582,6 +599,15 @@ packages:
resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==}
engines: {node: '>=18'}
+ cross-env@10.1.0:
+ resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -733,6 +759,9 @@ packages:
resolution: {integrity: sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==}
engines: {node: '>=16.9.0'}
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -744,9 +773,21 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ node-addon-api@8.5.0:
+ resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
+ engines: {node: ^18 || ^20 || >= 21}
+
+ node-gyp-build@4.8.4:
+ resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
+ hasBin: true
+
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -819,6 +860,14 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -939,6 +988,11 @@ packages:
jsdom:
optional: true
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -990,6 +1044,8 @@ snapshots:
'@drizzle-team/brocli@0.10.2': {}
+ '@epic-web/invariant@1.0.0': {}
+
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -1150,6 +1206,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@phc/format@1.0.0': {}
+
'@rollup/rollup-android-arm-eabi@4.53.3':
optional: true
@@ -1276,12 +1334,30 @@ snapshots:
'@vitest/pretty-format': 4.0.14
tinyrainbow: 3.0.3
+ argon2@0.44.0:
+ dependencies:
+ '@phc/format': 1.0.0
+ cross-env: 10.1.0
+ node-addon-api: 8.5.0
+ node-gyp-build: 4.8.4
+
assertion-error@2.0.1: {}
buffer-from@1.1.2: {}
chai@6.2.1: {}
+ cross-env@10.1.0:
+ dependencies:
+ '@epic-web/invariant': 1.0.0
+ cross-spawn: 7.0.6
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -1382,6 +1458,8 @@ snapshots:
hono@4.10.7: {}
+ isexe@2.0.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1390,8 +1468,14 @@ snapshots:
nanoid@3.3.11: {}
+ node-addon-api@8.5.0: {}
+
+ node-gyp-build@4.8.4: {}
+
obug@2.1.1: {}
+ path-key@3.1.1: {}
+
pathe@2.0.3: {}
pg-cloudflare@1.2.7:
@@ -1479,6 +1563,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.53.3
fsevents: 2.3.3
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
@@ -1560,6 +1650,10 @@ snapshots:
- tsx
- yaml
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0