create-restack-pattern 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/index.js +73 -0
- package/package.json +26 -0
- package/template/backend/.env.example +8 -0
- package/template/backend/README.md +8 -0
- package/template/backend/api/index.ts +9 -0
- package/template/backend/drizzle/0000_grey_lethal_legion.sql +19 -0
- package/template/backend/drizzle/meta/0000_snapshot.json +137 -0
- package/template/backend/drizzle/meta/_journal.json +13 -0
- package/template/backend/drizzle.config.ts +11 -0
- package/template/backend/gitignore +31 -0
- package/template/backend/package.json +34 -0
- package/template/backend/public/.gitkeep +0 -0
- package/template/backend/src/config/.gitkeep +0 -0
- package/template/backend/src/config/env.ts +22 -0
- package/template/backend/src/domains/auth/.gitkeep +0 -0
- package/template/backend/src/domains/auth/auth.model.ts +20 -0
- package/template/backend/src/domains/auth/auth.repo.ts +51 -0
- package/template/backend/src/domains/auth/auth.routes.ts +93 -0
- package/template/backend/src/domains/auth/auth.service.test.ts +143 -0
- package/template/backend/src/domains/auth/auth.service.ts +97 -0
- package/template/backend/src/domains/auth/auth.types.ts +7 -0
- package/template/backend/src/domains/user/.gitkeep +0 -0
- package/template/backend/src/domains/user/user.model.ts +14 -0
- package/template/backend/src/domains/user/user.repo.ts +29 -0
- package/template/backend/src/domains/user/user.routes.test.ts +82 -0
- package/template/backend/src/domains/user/user.routes.ts +48 -0
- package/template/backend/src/domains/user/user.service.ts +40 -0
- package/template/backend/src/hono-app.ts +42 -0
- package/template/backend/src/index.ts +13 -0
- package/template/backend/src/shared/db/.gitkeep +0 -0
- package/template/backend/src/shared/db/index.ts +11 -0
- package/template/backend/src/shared/logger/logger.ts +15 -0
- package/template/backend/src/shared/middlewares/.gitkeep +0 -0
- package/template/backend/src/shared/middlewares/rate-limit.ts +43 -0
- package/template/backend/src/shared/middlewares/require-auth.test.ts +51 -0
- package/template/backend/src/shared/middlewares/require-auth.ts +31 -0
- package/template/backend/tsconfig.json +16 -0
- package/template/backend/vercel.json +6 -0
- package/template/frontend/AGENTS.md +5 -0
- package/template/frontend/CLAUDE.md +1 -0
- package/template/frontend/README.md +36 -0
- package/template/frontend/app/(auth)/login/page.tsx +24 -0
- package/template/frontend/app/(auth)/register/page.tsx +24 -0
- package/template/frontend/app/dashboard/page.tsx +29 -0
- package/template/frontend/app/globals.css +131 -0
- package/template/frontend/app/icon.png +0 -0
- package/template/frontend/app/layout.tsx +36 -0
- package/template/frontend/app/page.tsx +33 -0
- package/template/frontend/app/profile/page.tsx +23 -0
- package/template/frontend/components/ui/button.tsx +67 -0
- package/template/frontend/components/ui/card.tsx +103 -0
- package/template/frontend/components/ui/input.tsx +19 -0
- package/template/frontend/components/ui/label.tsx +24 -0
- package/template/frontend/components.json +24 -0
- package/template/frontend/domains/auth/components/.gitkeep +0 -0
- package/template/frontend/domains/auth/components/login-form.tsx +75 -0
- package/template/frontend/domains/auth/components/logout-button.tsx +30 -0
- package/template/frontend/domains/auth/components/register-form.tsx +87 -0
- package/template/frontend/domains/auth/hooks/.gitkeep +0 -0
- package/template/frontend/domains/auth/hooks/use-login.ts +17 -0
- package/template/frontend/domains/auth/hooks/use-register.ts +17 -0
- package/template/frontend/domains/auth/services/.gitkeep +0 -0
- package/template/frontend/domains/auth/services/auth.api.ts +27 -0
- package/template/frontend/domains/user/components/.gitkeep +0 -0
- package/template/frontend/domains/user/components/profile-card.tsx +33 -0
- package/template/frontend/domains/user/hooks/use-profile.ts +39 -0
- package/template/frontend/domains/user/services/user.api.ts +21 -0
- package/template/frontend/eslint.config.mjs +18 -0
- package/template/frontend/gitignore +43 -0
- package/template/frontend/lib/utils.ts +11 -0
- package/template/frontend/next.config.ts +7 -0
- package/template/frontend/package.json +35 -0
- package/template/frontend/postcss.config.mjs +7 -0
- package/template/frontend/public/favicon.ico +0 -0
- package/template/frontend/public/logo.png +0 -0
- package/template/frontend/shared/components/require-auth.tsx +25 -0
- package/template/frontend/shared/components/ui/.gitkeep +0 -0
- package/template/frontend/shared/hooks/.gitkeep +0 -0
- package/template/frontend/shared/hooks/use-auth-mutation.ts +37 -0
- package/template/frontend/shared/lib/.gitkeep +0 -0
- package/template/frontend/shared/lib/api-client.ts +73 -0
- package/template/frontend/tsconfig.json +34 -0
- package/template/gitignore +35 -0
- package/template/package.json +13 -0
- package/template/packages/shared/package.json +24 -0
- package/template/packages/shared/src/index.ts +2 -0
- package/template/packages/shared/src/schemas/auth.schema.ts +20 -0
- package/template/packages/shared/src/schemas/user.schema.ts +16 -0
- package/template/packages/shared/tsconfig.json +51 -0
- package/template/pnpm-workspace.yaml +4 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
|
+
import bcrypt from "bcryptjs"
|
|
3
|
+
import { authService } from "./auth.service.js"
|
|
4
|
+
import { authRepo } from "./auth.repo.js"
|
|
5
|
+
import { userRepo } from "../user/user.repo.js"
|
|
6
|
+
|
|
7
|
+
vi.mock("./auth.repo.js", () => ({
|
|
8
|
+
authRepo: {
|
|
9
|
+
findByEmail: vi.fn(),
|
|
10
|
+
create: vi.fn(),
|
|
11
|
+
createSession: vi.fn(),
|
|
12
|
+
consumeSessionByHash: vi.fn(),
|
|
13
|
+
deleteSessionByHash: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock("../user/user.repo.js", () => ({
|
|
18
|
+
userRepo: {
|
|
19
|
+
findById: vi.fn(),
|
|
20
|
+
updateProfile: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
const mockedAuthRepo = vi.mocked(authRepo, true)
|
|
25
|
+
const mockedUserRepo = vi.mocked(userRepo, true)
|
|
26
|
+
|
|
27
|
+
const dbUser = (overrides: Partial<{ id: number; name: string; email: string; password: string }> = {}) => ({
|
|
28
|
+
id: 1,
|
|
29
|
+
name: "X",
|
|
30
|
+
email: "a@b.com",
|
|
31
|
+
password: "hash",
|
|
32
|
+
createdAt: new Date(),
|
|
33
|
+
...overrides,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const dbSession = (overrides: Partial<{ id: number; userId: number; refreshTokenHash: string; expiresAt: Date }> = {}) => ({
|
|
37
|
+
id: 1,
|
|
38
|
+
userId: 1,
|
|
39
|
+
refreshTokenHash: "hash",
|
|
40
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
41
|
+
createdAt: new Date(),
|
|
42
|
+
...overrides,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("authService.register", () => {
|
|
50
|
+
it("throws if the email is already in use", async () => {
|
|
51
|
+
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser())
|
|
52
|
+
await expect(
|
|
53
|
+
authService.register({ name: "X", email: "a@b.com", password: "password123" })
|
|
54
|
+
).rejects.toThrow("Email already in use")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("creates the user with a hashed password and a normalized email", async () => {
|
|
58
|
+
mockedAuthRepo.findByEmail.mockResolvedValue(null)
|
|
59
|
+
mockedAuthRepo.create.mockImplementation(async (data) => dbUser(data))
|
|
60
|
+
|
|
61
|
+
const user = await authService.register({ name: "X", email: " A@B.com ", password: "password123" })
|
|
62
|
+
|
|
63
|
+
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
64
|
+
const createdArg = mockedAuthRepo.create.mock.calls[0][0]
|
|
65
|
+
expect(createdArg.email).toBe("a@b.com")
|
|
66
|
+
expect(createdArg.password).not.toBe("password123")
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("authService.login", () => {
|
|
71
|
+
it("throws on an unknown email", async () => {
|
|
72
|
+
mockedAuthRepo.findByEmail.mockResolvedValue(null)
|
|
73
|
+
await expect(authService.login({ email: "nope@example.com", password: "whatever1" })).rejects.toThrow(
|
|
74
|
+
"Invalid credentials"
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("throws on a wrong password", async () => {
|
|
79
|
+
const passwordHash = await bcrypt.hash("correct-password", 4)
|
|
80
|
+
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
|
|
81
|
+
await expect(authService.login({ email: "a@b.com", password: "wrong-password" })).rejects.toThrow(
|
|
82
|
+
"Invalid credentials"
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("returns the public user shape on correct credentials", async () => {
|
|
87
|
+
const passwordHash = await bcrypt.hash("correct-password", 4)
|
|
88
|
+
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
|
|
89
|
+
const user = await authService.login({ email: "a@b.com", password: "correct-password" })
|
|
90
|
+
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe("authService.refresh", () => {
|
|
95
|
+
it("throws when no session matches the hashed token (invalid/expired/already-consumed)", async () => {
|
|
96
|
+
mockedAuthRepo.consumeSessionByHash.mockResolvedValue(null)
|
|
97
|
+
await expect(authService.refresh("some-token")).rejects.toThrow("Invalid refresh token")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("rotates: the old session is consumed and a fresh pair is issued", async () => {
|
|
101
|
+
mockedAuthRepo.consumeSessionByHash.mockResolvedValue(dbSession())
|
|
102
|
+
mockedUserRepo.findById.mockResolvedValue(dbUser())
|
|
103
|
+
mockedAuthRepo.createSession.mockResolvedValue(dbSession({ id: 2 }))
|
|
104
|
+
|
|
105
|
+
const result = await authService.refresh("old-token")
|
|
106
|
+
|
|
107
|
+
expect(result.user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
108
|
+
expect(typeof result.accessToken).toBe("string")
|
|
109
|
+
expect(typeof result.refreshToken).toBe("string")
|
|
110
|
+
expect(mockedAuthRepo.createSession).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("throws if the session's user no longer exists", async () => {
|
|
114
|
+
mockedAuthRepo.consumeSessionByHash.mockResolvedValue(dbSession({ userId: 999 }))
|
|
115
|
+
mockedUserRepo.findById.mockResolvedValue(null)
|
|
116
|
+
await expect(authService.refresh("old-token")).rejects.toThrow("Invalid refresh token")
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("authService.revokeSession", () => {
|
|
121
|
+
it("deletes the session matching the hashed token", async () => {
|
|
122
|
+
await authService.revokeSession("some-token")
|
|
123
|
+
expect(mockedAuthRepo.deleteSessionByHash).toHaveBeenCalledWith(expect.any(String))
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe("authService.verifyAccessToken", () => {
|
|
128
|
+
it("round-trips a token issued by issueTokenPair", async () => {
|
|
129
|
+
mockedAuthRepo.createSession.mockResolvedValue(dbSession())
|
|
130
|
+
const { accessToken } = await authService.issueTokenPair({ id: 1, name: "X", email: "a@b.com" })
|
|
131
|
+
const payload = await authService.verifyAccessToken(accessToken)
|
|
132
|
+
expect(payload.sub).toBe("1")
|
|
133
|
+
expect(payload.email).toBe("a@b.com")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("rejects a tampered token", async () => {
|
|
137
|
+
mockedAuthRepo.createSession.mockResolvedValue(dbSession())
|
|
138
|
+
const { accessToken } = await authService.issueTokenPair({ id: 1, name: "X", email: "a@b.com" })
|
|
139
|
+
const i = accessToken.indexOf(".") + 5
|
|
140
|
+
const tampered = accessToken.slice(0, i) + (accessToken[i] === "a" ? "b" : "a") + accessToken.slice(i + 1)
|
|
141
|
+
await expect(authService.verifyAccessToken(tampered)).rejects.toThrow()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import bcrypt from "bcryptjs"
|
|
2
|
+
import { SignJWT, jwtVerify } from "jose"
|
|
3
|
+
import { randomBytes, createHash } from "node:crypto"
|
|
4
|
+
import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
|
|
5
|
+
import { env } from "../../config/env.js"
|
|
6
|
+
import { authRepo } from "./auth.repo.js"
|
|
7
|
+
import { userRepo } from "../user/user.repo.js"
|
|
8
|
+
import type { AuthTokenPayload } from "./auth.types.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Service layer for the Auth domain.
|
|
12
|
+
* Houses core validation and cryptographic flows (JWT token signing, refresh token generation, hashing).
|
|
13
|
+
*
|
|
14
|
+
* Semi-DDD rules:
|
|
15
|
+
* - `.service.ts` files encapsulate business rules and complex workflows (e.g. session issuance & rotation).
|
|
16
|
+
* - They must never call Drizzle ORM directly; always interact through the repo layer.
|
|
17
|
+
*/
|
|
18
|
+
const secret = new TextEncoder().encode(env.JWT_SECRET)
|
|
19
|
+
const BCRYPT_COST = 12
|
|
20
|
+
// Dummy hash so failed logins on unknown emails still pay the bcrypt cost (timing-attack mitigation).
|
|
21
|
+
const DUMMY_HASH = bcrypt.hashSync("dummy-password-for-timing-safety", BCRYPT_COST)
|
|
22
|
+
const normalizeEmail = (email: string) => email.trim().toLowerCase()
|
|
23
|
+
|
|
24
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 60 * 15 // 15 minutes
|
|
25
|
+
export const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days
|
|
26
|
+
|
|
27
|
+
const buildTokenPayload = (user: { id: number; email: string }): AuthTokenPayload => ({
|
|
28
|
+
sub: String(user.id),
|
|
29
|
+
email: user.email,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const signAccessToken = (user: { id: number; email: string }) =>
|
|
33
|
+
new SignJWT(buildTokenPayload(user))
|
|
34
|
+
.setProtectedHeader({ alg: "HS256" })
|
|
35
|
+
.setIssuedAt()
|
|
36
|
+
.setExpirationTime(`${ACCESS_TOKEN_TTL_SECONDS}s`)
|
|
37
|
+
.sign(secret)
|
|
38
|
+
|
|
39
|
+
// Opaque, high-entropy refresh token — not a JWT, so it carries no forgeable claims.
|
|
40
|
+
// Only its sha256 hash is ever persisted (see auth.model.ts), so a DB leak alone can't be replayed.
|
|
41
|
+
const generateRefreshToken = () => randomBytes(32).toString("hex")
|
|
42
|
+
const hashRefreshToken = (token: string) => createHash("sha256").update(token).digest("hex")
|
|
43
|
+
|
|
44
|
+
const issueTokenPair = async (user: AuthUser) => {
|
|
45
|
+
const accessToken = await signAccessToken(user)
|
|
46
|
+
const refreshToken = generateRefreshToken()
|
|
47
|
+
await authRepo.createSession({
|
|
48
|
+
userId: user.id,
|
|
49
|
+
refreshTokenHash: hashRefreshToken(refreshToken),
|
|
50
|
+
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_SECONDS * 1000),
|
|
51
|
+
})
|
|
52
|
+
return { accessToken, refreshToken, user }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const authService = {
|
|
56
|
+
register: async (data: RegisterInput): Promise<AuthUser> => {
|
|
57
|
+
const email = normalizeEmail(data.email)
|
|
58
|
+
const existing = await authRepo.findByEmail(email)
|
|
59
|
+
if (existing) throw new Error("Email already in use")
|
|
60
|
+
|
|
61
|
+
const password = await bcrypt.hash(data.password, BCRYPT_COST)
|
|
62
|
+
const user = await authRepo.create({ ...data, email, password })
|
|
63
|
+
return { id: user.id, name: user.name, email: user.email }
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
login: async (data: LoginInput): Promise<AuthUser> => {
|
|
67
|
+
const user = await authRepo.findByEmail(normalizeEmail(data.email))
|
|
68
|
+
const valid = await bcrypt.compare(data.password, user ? user.password : DUMMY_HASH)
|
|
69
|
+
if (!user || !valid) throw new Error("Invalid credentials")
|
|
70
|
+
|
|
71
|
+
return { id: user.id, name: user.name, email: user.email }
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
issueTokenPair,
|
|
75
|
+
|
|
76
|
+
// Rotates the refresh token on every use: consuming it (atomic delete) and issuing
|
|
77
|
+
// a brand new pair means a stolen-but-already-used token becomes worthless, and
|
|
78
|
+
// concurrent requests racing on the same token can only ever have one winner.
|
|
79
|
+
refresh: async (refreshToken: string) => {
|
|
80
|
+
const session = await authRepo.consumeSessionByHash(hashRefreshToken(refreshToken))
|
|
81
|
+
if (!session) throw new Error("Invalid refresh token")
|
|
82
|
+
|
|
83
|
+
const user = await userRepo.findById(session.userId)
|
|
84
|
+
if (!user) throw new Error("Invalid refresh token")
|
|
85
|
+
|
|
86
|
+
return issueTokenPair({ id: user.id, name: user.name, email: user.email })
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
revokeSession: async (refreshToken: string) => {
|
|
90
|
+
await authRepo.deleteSessionByHash(hashRefreshToken(refreshToken))
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
verifyAccessToken: async (token: string): Promise<AuthTokenPayload> => {
|
|
94
|
+
const { payload } = await jwtVerify(token, secret)
|
|
95
|
+
return payload as AuthTokenPayload
|
|
96
|
+
},
|
|
97
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { pgTable, text, timestamp, serial } from "drizzle-orm/pg-core"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Users Table Schema.
|
|
5
|
+
* Defines the core database table layout for registered users.
|
|
6
|
+
* Fields: id (sequential serial integer), name (raw string), email (unique), password (bcrypt hash), and createdAt.
|
|
7
|
+
*/
|
|
8
|
+
export const usersTable = pgTable("users", {
|
|
9
|
+
id: serial("id").primaryKey(),
|
|
10
|
+
name: text("name").notNull(),
|
|
11
|
+
email: text("email").notNull().unique(),
|
|
12
|
+
password: text("password").notNull(),
|
|
13
|
+
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
14
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm"
|
|
2
|
+
import { db } from "../../shared/db/index.js"
|
|
3
|
+
import { usersTable } from "./user.model.js"
|
|
4
|
+
|
|
5
|
+
export type UserRow = typeof usersTable.$inferSelect
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Repository layer for the User domain.
|
|
9
|
+
* Encapsulates all direct database queries for users.
|
|
10
|
+
*
|
|
11
|
+
* Semi-DDD rules:
|
|
12
|
+
* - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
|
|
13
|
+
* - Under cross-domain rules, the Auth domain can import from this repo (e.g. user lookup by ID/email),
|
|
14
|
+
* but the User domain must never depend on the Auth domain (one-way dependency flow).
|
|
15
|
+
*/
|
|
16
|
+
export const userRepo = {
|
|
17
|
+
// Explicit return type matters: without noUncheckedIndexedAccess, TS infers array
|
|
18
|
+
// destructuring as always-defined, so `user ?? null` silently loses the null branch
|
|
19
|
+
// and callers' `if (!user)` checks type-check as always-false without this annotation.
|
|
20
|
+
findById: async (id: number): Promise<UserRow | null> => {
|
|
21
|
+
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, id))
|
|
22
|
+
return user ?? null
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
updateProfile: async (id: number, data: { name?: string }): Promise<UserRow | null> => {
|
|
26
|
+
const [user] = await db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning()
|
|
27
|
+
return user ?? null
|
|
28
|
+
},
|
|
29
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
|
2
|
+
import { eq } from "drizzle-orm"
|
|
3
|
+
import { userRoutes } from "./user.routes.js"
|
|
4
|
+
import { authService } from "../auth/auth.service.js"
|
|
5
|
+
import { db } from "../../shared/db/index.js"
|
|
6
|
+
import { usersTable } from "./user.model.js"
|
|
7
|
+
|
|
8
|
+
const TEST_EMAIL = `user-routes-test-${Date.now()}@example.com`
|
|
9
|
+
let accessToken: string
|
|
10
|
+
let userId: number
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const user = await authService.register({ name: "Route Test", email: TEST_EMAIL, password: "password123" })
|
|
14
|
+
userId = user.id
|
|
15
|
+
const tokens = await authService.issueTokenPair(user)
|
|
16
|
+
accessToken = tokens.accessToken
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
await db.delete(usersTable).where(eq(usersTable.id, userId))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const authHeaders = (token?: string) => ({
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
...(token ? { Cookie: `access_token=${token}` } : {}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("GET /me", () => {
|
|
29
|
+
it("returns 401 without a token", async () => {
|
|
30
|
+
const res = await userRoutes.request("/me")
|
|
31
|
+
expect(res.status).toBe(401)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("returns the profile with a valid token", async () => {
|
|
35
|
+
const res = await userRoutes.request("/me", { headers: authHeaders(accessToken) })
|
|
36
|
+
expect(res.status).toBe(200)
|
|
37
|
+
const body = await res.json()
|
|
38
|
+
expect(body.user.email).toBe(TEST_EMAIL)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("PUT /profile", () => {
|
|
43
|
+
it("returns 401 without a token", async () => {
|
|
44
|
+
const res = await userRoutes.request("/profile", {
|
|
45
|
+
method: "PUT",
|
|
46
|
+
headers: authHeaders(),
|
|
47
|
+
body: JSON.stringify({ name: "x" }),
|
|
48
|
+
})
|
|
49
|
+
expect(res.status).toBe(401)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("updates the name with a valid token and body", async () => {
|
|
53
|
+
const res = await userRoutes.request("/profile", {
|
|
54
|
+
method: "PUT",
|
|
55
|
+
headers: authHeaders(accessToken),
|
|
56
|
+
body: JSON.stringify({ name: "Updated Name" }),
|
|
57
|
+
})
|
|
58
|
+
expect(res.status).toBe(200)
|
|
59
|
+
const body = await res.json()
|
|
60
|
+
expect(body.user.name).toBe("Updated Name")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("rejects an invalid body (empty name)", async () => {
|
|
64
|
+
const res = await userRoutes.request("/profile", {
|
|
65
|
+
method: "PUT",
|
|
66
|
+
headers: authHeaders(accessToken),
|
|
67
|
+
body: JSON.stringify({ name: "" }),
|
|
68
|
+
})
|
|
69
|
+
expect(res.status).toBe(400)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("treats an empty body as a no-op instead of erroring", async () => {
|
|
73
|
+
const res = await userRoutes.request("/profile", {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
headers: authHeaders(accessToken),
|
|
76
|
+
body: JSON.stringify({}),
|
|
77
|
+
})
|
|
78
|
+
expect(res.status).toBe(200)
|
|
79
|
+
const body = await res.json()
|
|
80
|
+
expect(body.user.email).toBe(TEST_EMAIL)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import type { Context } from "hono"
|
|
3
|
+
import { zValidator } from "@hono/zod-validator"
|
|
4
|
+
import { UpdateProfileSchema } from "@restack/shared"
|
|
5
|
+
import type { AuthVariables } from "../../shared/middlewares/require-auth.js"
|
|
6
|
+
import { requireAuth } from "../../shared/middlewares/require-auth.js"
|
|
7
|
+
import { userService, UserNotFoundError } from "./user.service.js"
|
|
8
|
+
import { rateLimit } from "../../shared/middlewares/rate-limit.js"
|
|
9
|
+
import { logger } from "../../shared/logger/logger.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Routing layer for the User domain.
|
|
13
|
+
* Configures the router and defines endpoints with appropriate middlewares.
|
|
14
|
+
*
|
|
15
|
+
* Semi-DDD rules:
|
|
16
|
+
* - `.routes.ts` files define HTTP endpoints, parse inputs using schema validators (zValidator),
|
|
17
|
+
* apply guards (requireAuth, rateLimit), and serialize JSON responses.
|
|
18
|
+
* - Routing code should never implement business logic directly; it must delegate to the service layer.
|
|
19
|
+
*/
|
|
20
|
+
export const userRoutes = new Hono<{ Variables: AuthVariables }>()
|
|
21
|
+
|
|
22
|
+
// Only UserNotFoundError is an expected, safe-to-expose outcome. Anything else (DB/driver
|
|
23
|
+
// errors, etc.) gets logged with detail but returned to the caller as a generic 500 —
|
|
24
|
+
// leaking raw error messages/mislabeling unexpected failures as 404 would be misleading.
|
|
25
|
+
const handleProfileError = (c: Context, e: unknown, action: string) => {
|
|
26
|
+
if (e instanceof UserNotFoundError) return c.json({ error: e.message }, 404)
|
|
27
|
+
logger.error({ err: e instanceof Error ? e.message : String(e) }, action)
|
|
28
|
+
return c.json({ error: "Internal server error" }, 500)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
userRoutes.get("/me", requireAuth, async (c) => {
|
|
32
|
+
try {
|
|
33
|
+
const profile = await userService.getProfile(Number(c.get("user").sub))
|
|
34
|
+
return c.json({ user: profile })
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return handleProfileError(c, e, "Get profile failed")
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
userRoutes.put("/profile", requireAuth, rateLimit, zValidator("json", UpdateProfileSchema), async (c) => {
|
|
41
|
+
try {
|
|
42
|
+
const body = c.req.valid("json")
|
|
43
|
+
const profile = await userService.updateProfile(Number(c.get("user").sub), body)
|
|
44
|
+
return c.json({ user: profile })
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return handleProfileError(c, e, "Update profile failed")
|
|
47
|
+
}
|
|
48
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AuthUser, UpdateProfileInput } from "@restack/shared"
|
|
2
|
+
import { userRepo } from "./user.repo.js"
|
|
3
|
+
|
|
4
|
+
export class UserNotFoundError extends Error {}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maps database user object into the public, safe schema shape.
|
|
8
|
+
* Always filters out sensitive columns like password hashes.
|
|
9
|
+
*/
|
|
10
|
+
const toPublicUser = (user: { id: number; name: string; email: string }): AuthUser => ({
|
|
11
|
+
id: user.id,
|
|
12
|
+
name: user.name,
|
|
13
|
+
email: user.email,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Service layer for the User domain.
|
|
18
|
+
* Contains core business logic and validations.
|
|
19
|
+
*
|
|
20
|
+
* Semi-DDD rules:
|
|
21
|
+
* - `.service.ts` encapsulates business logic, mappings, and validations.
|
|
22
|
+
* - It must never interact with the database client directly; always query through the `.repo.ts` layer.
|
|
23
|
+
*/
|
|
24
|
+
export const userService = {
|
|
25
|
+
getProfile: async (id: number): Promise<AuthUser> => {
|
|
26
|
+
const user = await userRepo.findById(id)
|
|
27
|
+
if (!user) throw new UserNotFoundError("User not found")
|
|
28
|
+
return toPublicUser(user)
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
updateProfile: async (id: number, data: UpdateProfileInput): Promise<AuthUser> => {
|
|
32
|
+
// An empty body validates against UpdateProfileSchema (all fields optional) but would
|
|
33
|
+
// reach db.update(...).set({}), which Drizzle/postgres reject — short-circuit to a no-op.
|
|
34
|
+
if (Object.keys(data).length === 0) return userService.getProfile(id)
|
|
35
|
+
|
|
36
|
+
const user = await userRepo.updateProfile(id, data)
|
|
37
|
+
if (!user) throw new UserNotFoundError("User not found")
|
|
38
|
+
return toPublicUser(user)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import { cors } from "hono/cors"
|
|
3
|
+
import { csrf } from "hono/csrf"
|
|
4
|
+
import { logger as honoLogger } from "hono/logger"
|
|
5
|
+
import { env } from "./config/env.js"
|
|
6
|
+
import { logger } from "./shared/logger/logger.js"
|
|
7
|
+
import { authRoutes } from "./domains/auth/auth.routes.js"
|
|
8
|
+
import { userRoutes } from "./domains/user/user.routes.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hono application instance — routes, middleware, everything except the transport.
|
|
12
|
+
* Kept separate from index.ts so this same app can run under a persistent Node
|
|
13
|
+
* server (local dev, Railway/Render/VPS) or as a Vercel serverless function
|
|
14
|
+
* (backend/api/index.ts), which cannot call @hono/node-server's serve().
|
|
15
|
+
*/
|
|
16
|
+
export const app = new Hono()
|
|
17
|
+
|
|
18
|
+
const allowedOrigins = env.FRONTEND_URL
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((url) => url.trim())
|
|
21
|
+
|
|
22
|
+
app.use(honoLogger((...args) => logger.info(args.join(" "))))
|
|
23
|
+
|
|
24
|
+
app.use(
|
|
25
|
+
"/api/*",
|
|
26
|
+
cors({
|
|
27
|
+
origin: allowedOrigins,
|
|
28
|
+
credentials: true,
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app.use(
|
|
33
|
+
"/api/*",
|
|
34
|
+
csrf({
|
|
35
|
+
origin: allowedOrigins,
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
app.route("/api/auth", authRoutes)
|
|
40
|
+
app.route("/api/user", userRoutes)
|
|
41
|
+
|
|
42
|
+
app.get("/", (c) => c.json({ status: "ok" }))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server"
|
|
2
|
+
import { env } from "./config/env.js"
|
|
3
|
+
import { logger } from "./shared/logger/logger.js"
|
|
4
|
+
import { app } from "./hono-app.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local dev / persistent-server entry point (tsx watch, Railway, Render, any VPS).
|
|
8
|
+
* Not used on Vercel — backend/api/index.ts hands the same `app` to the platform
|
|
9
|
+
* as a serverless function instead of binding a port.
|
|
10
|
+
*/
|
|
11
|
+
serve({ fetch: app.fetch, port: env.PORT }, (info) => {
|
|
12
|
+
logger.info(`Server running on http://localhost:${info.port}`)
|
|
13
|
+
})
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/postgres-js"
|
|
2
|
+
import postgres from "postgres"
|
|
3
|
+
import { env } from "../../config/env.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Database client initialization.
|
|
7
|
+
* Starts the `postgres-js` connection pool using the validated DATABASE_URL environment setting
|
|
8
|
+
* and wraps it with the Drizzle ORM client wrapper.
|
|
9
|
+
*/
|
|
10
|
+
const client = postgres(env.DATABASE_URL)
|
|
11
|
+
export const db = drizzle(client)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pino from "pino"
|
|
2
|
+
import { env } from "../../config/env.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Global logger client.
|
|
6
|
+
* Configured using `pino`. Automatically toggles logging verbosity (info vs debug)
|
|
7
|
+
* and formats stdout output using `pino-pretty` during local development.
|
|
8
|
+
*/
|
|
9
|
+
export const logger = pino({
|
|
10
|
+
level: env.NODE_ENV === "production" ? "info" : "debug",
|
|
11
|
+
transport:
|
|
12
|
+
env.NODE_ENV === "production"
|
|
13
|
+
? undefined
|
|
14
|
+
: { target: "pino-pretty", options: { colorize: true } },
|
|
15
|
+
})
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono"
|
|
2
|
+
import { getConnInfo } from "@hono/node-server/conninfo"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-Memory Fixed-Window Rate Limiting Middleware.
|
|
6
|
+
*
|
|
7
|
+
* Performance/Topology Note:
|
|
8
|
+
* - This implementation stores counts in a local `Map` memory segment.
|
|
9
|
+
* - This works perfectly for single-process deployments (like a standalone Railway node).
|
|
10
|
+
* - If horizontal scaling (multiple instances) is introduced, this must be swapped
|
|
11
|
+
* for a shared datastore (e.g. Redis) to ensure limits are synchronized globally.
|
|
12
|
+
*/
|
|
13
|
+
const WINDOW_MS = 60_000
|
|
14
|
+
const MAX_REQUESTS = 10
|
|
15
|
+
|
|
16
|
+
const hits = new Map<string, { count: number; resetAt: number }>()
|
|
17
|
+
|
|
18
|
+
// getConnInfo reaches into the raw Node socket, which only exists when served via a real
|
|
19
|
+
// @hono/node-server connection — it throws in in-memory test harnesses (Hono's `.request()`)
|
|
20
|
+
// and some serverless runtimes. Fall back to a shared bucket rather than crashing the request.
|
|
21
|
+
const clientIp = (c: Context) => {
|
|
22
|
+
try {
|
|
23
|
+
return getConnInfo(c).remote.address ?? "unknown"
|
|
24
|
+
} catch {
|
|
25
|
+
return "unknown"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const rateLimit: MiddlewareHandler = async (c, next) => {
|
|
30
|
+
const key = `${c.req.path}:${clientIp(c)}`
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const entry = hits.get(key)
|
|
33
|
+
|
|
34
|
+
if (!entry || entry.resetAt < now) {
|
|
35
|
+
hits.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
|
36
|
+
} else if (entry.count >= MAX_REQUESTS) {
|
|
37
|
+
return c.json({ error: "Too many requests, try again later" }, 429)
|
|
38
|
+
} else {
|
|
39
|
+
entry.count++
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await next()
|
|
43
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { Hono } from "hono"
|
|
3
|
+
import { SignJWT } from "jose"
|
|
4
|
+
import { requireAuth } from "./require-auth.js"
|
|
5
|
+
import type { AuthVariables } from "./require-auth.js"
|
|
6
|
+
import { env } from "../../config/env.js"
|
|
7
|
+
|
|
8
|
+
const secret = new TextEncoder().encode(env.JWT_SECRET)
|
|
9
|
+
|
|
10
|
+
const signTestToken = (payload: Record<string, unknown>, expiresIn = "15m") =>
|
|
11
|
+
new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime(expiresIn).sign(secret)
|
|
12
|
+
|
|
13
|
+
function buildApp() {
|
|
14
|
+
const app = new Hono<{ Variables: AuthVariables }>()
|
|
15
|
+
app.use("/protected", requireAuth)
|
|
16
|
+
app.get("/protected", (c) => c.json({ user: c.get("user") }))
|
|
17
|
+
return app
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("requireAuth", () => {
|
|
21
|
+
it("returns 401 when no access_token cookie is present", async () => {
|
|
22
|
+
const res = await buildApp().request("/protected")
|
|
23
|
+
expect(res.status).toBe(401)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("returns 401 when the token is garbage", async () => {
|
|
27
|
+
const res = await buildApp().request("/protected", {
|
|
28
|
+
headers: { Cookie: "access_token=not-a-real-token" },
|
|
29
|
+
})
|
|
30
|
+
expect(res.status).toBe(401)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("returns 401 when the token is expired", async () => {
|
|
34
|
+
const expired = await signTestToken({ sub: "user-1", email: "a@b.com" }, "-1s")
|
|
35
|
+
const res = await buildApp().request("/protected", {
|
|
36
|
+
headers: { Cookie: `access_token=${expired}` },
|
|
37
|
+
})
|
|
38
|
+
expect(res.status).toBe(401)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("calls next() and exposes the payload when the token is valid", async () => {
|
|
42
|
+
const token = await signTestToken({ sub: "user-1", email: "a@b.com" })
|
|
43
|
+
const res = await buildApp().request("/protected", {
|
|
44
|
+
headers: { Cookie: `access_token=${token}` },
|
|
45
|
+
})
|
|
46
|
+
expect(res.status).toBe(200)
|
|
47
|
+
const body = await res.json()
|
|
48
|
+
expect(body.user.sub).toBe("user-1")
|
|
49
|
+
expect(body.user.email).toBe("a@b.com")
|
|
50
|
+
})
|
|
51
|
+
})
|