create-restack-pattern 0.1.8 → 0.2.1
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/dist/index.js +284 -74
- package/package.json +14 -8
- package/template/{DEVELOPMENT.md → base/DEVELOPMENT.md} +73 -62
- package/template/{backend → base/backend}/package.json +13 -10
- package/template/{backend → base/backend}/src/config/env.ts +2 -2
- package/template/{backend → base/backend}/src/domains/auth/auth.model.ts +5 -3
- package/template/{backend → base/backend}/src/domains/auth/auth.repo.ts +23 -5
- package/template/{backend → base/backend}/src/domains/auth/auth.routes.ts +77 -22
- package/template/{backend → base/backend}/src/domains/auth/auth.service.test.ts +51 -19
- package/template/{backend → base/backend}/src/domains/auth/auth.service.ts +15 -10
- package/template/{backend → base/backend}/src/domains/auth/auth.types.ts +1 -1
- package/template/{backend → base/backend}/src/domains/user/user.model.ts +2 -1
- package/template/{backend → base/backend}/src/domains/user/user.repo.ts +3 -2
- package/template/base/backend/src/domains/user/user.routes.test.ts +127 -0
- package/template/{backend → base/backend}/src/domains/user/user.routes.ts +19 -9
- package/template/{backend → base/backend}/src/domains/user/user.service.ts +8 -2
- package/template/{backend → base/backend}/src/hono-app.ts +6 -11
- package/template/base/backend/src/infra/middlewares/require-auth.test.ts +91 -0
- package/template/base/backend/src/infra/middlewares/require-auth.ts +63 -0
- package/template/base/backend/src/infra/middlewares/require-role.test.ts +66 -0
- package/template/base/backend/src/infra/middlewares/require-role.ts +34 -0
- package/template/{backend → base/backend}/src/utils/logger.ts +7 -10
- package/template/base/backend/src/utils/uuid.test.ts +37 -0
- package/template/{backend → base/backend}/tsconfig.json +4 -0
- package/template/base/frontend/AGENTS.md +11 -0
- package/template/{frontend → base/frontend}/app/(auth)/login/page.tsx +1 -1
- package/template/{frontend → base/frontend}/app/(auth)/register/page.tsx +1 -1
- package/template/{frontend → base/frontend}/app/globals.css +1 -1
- package/template/{frontend → base/frontend}/app/layout.tsx +9 -12
- package/template/{frontend → base/frontend}/app/page.tsx +2 -2
- package/template/{frontend → base/frontend}/app/profile/page.tsx +3 -3
- package/template/{frontend → base/frontend}/components/ui/button.tsx +2 -2
- package/template/{frontend → base/frontend}/components/ui/card.tsx +8 -23
- package/template/{frontend → base/frontend}/components/ui/input.tsx +2 -2
- package/template/{frontend → base/frontend}/components/ui/label.tsx +3 -6
- package/template/{frontend → base/frontend}/domains/auth/components/login-form.tsx +10 -3
- package/template/{frontend → base/frontend}/domains/auth/components/logout-button.tsx +2 -2
- package/template/{frontend → base/frontend}/domains/auth/components/register-form.tsx +10 -3
- package/template/{frontend → base/frontend}/domains/auth/hooks/use-login.ts +2 -2
- package/template/{frontend → base/frontend}/domains/auth/hooks/use-register.ts +6 -3
- package/template/{frontend → base/frontend}/domains/auth/services/auth.api.ts +5 -7
- package/template/{frontend → base/frontend}/domains/user/components/profile-card.tsx +2 -4
- package/template/{frontend → base/frontend}/domains/user/services/user.api.ts +2 -3
- package/template/base/frontend/env.ts +12 -0
- package/template/{frontend → base/frontend}/hooks/use-auth-mutation.ts +6 -3
- package/template/{frontend → base/frontend}/lib/api-client.ts +40 -5
- package/template/{frontend → base/frontend}/lib/utils.ts +1 -1
- package/template/base/frontend/next.config.ts +11 -0
- package/template/{frontend → base/frontend}/package.json +15 -14
- package/template/{frontend → base/frontend}/tsconfig.json +4 -0
- package/template/{gitignore → base/gitignore} +15 -0
- package/template/base/package.json +25 -0
- package/template/{packages → base/packages}/shared/package.json +1 -1
- package/template/{packages → base/packages}/shared/src/index.ts +1 -0
- package/template/{packages → base/packages}/shared/src/schemas/auth.schema.ts +8 -0
- package/template/{packages → base/packages}/shared/src/schemas/user.schema.ts +1 -0
- package/template/base/packages/shared/src/utils/uuid.ts +40 -0
- package/template/{packages → base/packages}/shared/tsconfig.json +26 -0
- package/template/base/pnpm-workspace.yaml +12 -0
- package/template/overlays/db-postgres/backend/.env.example +6 -0
- package/template/overlays/db-postgres/backend/drizzle.config.ts +11 -0
- package/template/overlays/db-postgres/backend/scripts/db-check.js +27 -0
- package/template/overlays/db-postgres/backend/src/infra/db/index.ts +6 -0
- package/template/overlays/db-sqlite/backend/.env.example +6 -0
- package/template/overlays/db-sqlite/backend/drizzle.config.ts +11 -0
- package/template/overlays/db-sqlite/backend/scripts/db-check.js +20 -0
- package/template/overlays/db-sqlite/backend/src/infra/db/index.ts +8 -0
- package/template/overlays/id-serial/packages/shared/src/schemas/user.schema.ts +14 -0
- package/template/overlays/id-uuid/packages/shared/src/schemas/user.schema.ts +14 -0
- package/template/overlays/models/postgres-serial/backend/src/domains/auth/auth.model.ts +12 -0
- package/template/overlays/models/postgres-serial/backend/src/domains/user/user.model.ts +10 -0
- package/template/overlays/models/postgres-uuid/backend/src/domains/auth/auth.model.ts +15 -0
- package/template/overlays/models/postgres-uuid/backend/src/domains/user/user.model.ts +13 -0
- package/template/overlays/models/sqlite-serial/backend/src/domains/auth/auth.model.ts +14 -0
- package/template/overlays/models/sqlite-serial/backend/src/domains/user/user.model.ts +12 -0
- package/template/overlays/models/sqlite-uuid/backend/src/domains/auth/auth.model.ts +17 -0
- package/template/overlays/models/sqlite-uuid/backend/src/domains/user/user.model.ts +15 -0
- package/template/backend/src/domains/user/user.routes.test.ts +0 -82
- package/template/backend/src/infra/middlewares/require-auth.test.ts +0 -51
- package/template/backend/src/infra/middlewares/require-auth.ts +0 -36
- package/template/frontend/AGENTS.md +0 -5
- package/template/frontend/next.config.ts +0 -7
- package/template/package.json +0 -17
- package/template/pnpm-workspace.yaml +0 -4
- package/template/{backend → base/backend}/.env.example +0 -0
- package/template/{backend → base/backend}/README.md +0 -0
- package/template/{backend → base/backend}/api/index.ts +0 -0
- package/template/{backend → base/backend}/drizzle/0000_grey_lethal_legion.sql +0 -0
- package/template/{backend → base/backend}/drizzle/meta/0000_snapshot.json +0 -0
- package/template/{backend → base/backend}/drizzle/meta/_journal.json +0 -0
- package/template/{backend → base/backend}/drizzle.config.ts +0 -0
- package/template/{backend → base/backend}/gitignore +0 -0
- package/template/{backend → base/backend}/public/.gitkeep +0 -0
- package/template/{backend → base/backend}/scripts/db-check.js +0 -0
- package/template/{backend → base/backend}/src/config/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/domains/auth/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/domains/user/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/index.ts +1 -1
- package/template/{backend → base/backend}/src/infra/db/.gitkeep +0 -0
- package/template/{backend → base/backend}/src/infra/db/index.ts +0 -0
- package/template/{backend → base/backend}/src/infra/middlewares/.gitkeep +0 -0
- package/template/{backend → base/backend}/vercel.json +0 -0
- package/template/{frontend → base/frontend}/.env.example +0 -0
- package/template/{frontend → base/frontend}/CLAUDE.md +0 -0
- package/template/{frontend → base/frontend}/README.md +0 -0
- package/template/{frontend → base/frontend}/app/dashboard/page.tsx +2 -2
- package/template/{frontend → base/frontend}/app/icon.png +0 -0
- package/template/{frontend → base/frontend}/components/require-auth.tsx +1 -1
- package/template/{frontend → base/frontend}/components.json +0 -0
- package/template/{frontend → base/frontend}/domains/auth/components/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/auth/hooks/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/auth/services/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/user/components/.gitkeep +0 -0
- package/template/{frontend → base/frontend}/domains/user/hooks/use-profile.ts +2 -2
- /package/template/{frontend → base/frontend}/eslint.config.mjs +0 -0
- /package/template/{frontend → base/frontend}/gitignore +0 -0
- /package/template/{frontend → base/frontend}/postcss.config.mjs +0 -0
- /package/template/{frontend → base/frontend}/public/favicon.ico +0 -0
- /package/template/{frontend → base/frontend}/public/logo.png +0 -0
|
@@ -9,7 +9,7 @@ type SessionRow = typeof authSessionsTable.$inferSelect
|
|
|
9
9
|
/**
|
|
10
10
|
* Repository layer for the Auth domain.
|
|
11
11
|
* Encapsulates sessions management and credential-based queries.
|
|
12
|
-
*
|
|
12
|
+
*
|
|
13
13
|
* Semi-DDD rules:
|
|
14
14
|
* - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
|
|
15
15
|
* - Under cross-domain rules, the Auth domain can query the User domain's tables (e.g. `usersTable`)
|
|
@@ -24,13 +24,24 @@ export const authRepo = {
|
|
|
24
24
|
return user ?? null
|
|
25
25
|
},
|
|
26
26
|
|
|
27
|
-
create: async (data: {
|
|
27
|
+
create: async (data: {
|
|
28
|
+
name: string
|
|
29
|
+
email: string
|
|
30
|
+
password: string
|
|
31
|
+
role?: string
|
|
32
|
+
}): Promise<UserRow> => {
|
|
28
33
|
const [user] = await db.insert(usersTable).values(data).returning()
|
|
34
|
+
if (!user) throw new Error("Failed to create user record")
|
|
29
35
|
return user
|
|
30
36
|
},
|
|
31
37
|
|
|
32
|
-
createSession: async (data: {
|
|
38
|
+
createSession: async (data: {
|
|
39
|
+
userId: number
|
|
40
|
+
refreshTokenHash: string
|
|
41
|
+
expiresAt: Date
|
|
42
|
+
}): Promise<SessionRow> => {
|
|
33
43
|
const [session] = await db.insert(authSessionsTable).values(data).returning()
|
|
44
|
+
if (!session) throw new Error("Failed to create auth session")
|
|
34
45
|
return session
|
|
35
46
|
},
|
|
36
47
|
|
|
@@ -40,12 +51,19 @@ export const authRepo = {
|
|
|
40
51
|
consumeSessionByHash: async (refreshTokenHash: string): Promise<SessionRow | null> => {
|
|
41
52
|
const [session] = await db
|
|
42
53
|
.delete(authSessionsTable)
|
|
43
|
-
.where(
|
|
54
|
+
.where(
|
|
55
|
+
and(
|
|
56
|
+
eq(authSessionsTable.refreshTokenHash, refreshTokenHash),
|
|
57
|
+
gt(authSessionsTable.expiresAt, new Date()),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
44
60
|
.returning()
|
|
45
61
|
return session ?? null
|
|
46
62
|
},
|
|
47
63
|
|
|
48
64
|
deleteSessionByHash: async (refreshTokenHash: string) => {
|
|
49
|
-
await db
|
|
65
|
+
await db
|
|
66
|
+
.delete(authSessionsTable)
|
|
67
|
+
.where(eq(authSessionsTable.refreshTokenHash, refreshTokenHash))
|
|
50
68
|
},
|
|
51
69
|
}
|
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
import { Hono } from "hono"
|
|
2
|
-
import type { Context } from "hono"
|
|
3
|
-
import { setCookie, deleteCookie, getCookie } from "hono/cookie"
|
|
4
1
|
import { zValidator } from "@hono/zod-validator"
|
|
5
2
|
import { LoginSchema, RegisterSchema } from "@restack/shared"
|
|
6
|
-
import {
|
|
3
|
+
import type { Context } from "hono"
|
|
4
|
+
import { Hono } from "hono"
|
|
5
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
|
7
6
|
import { env } from "../../config/env.js"
|
|
8
|
-
import {
|
|
7
|
+
import { type AppVariables, logger } from "../../utils/logger.js"
|
|
8
|
+
import { ACCESS_TOKEN_TTL_SECONDS, authService, REFRESH_TOKEN_TTL_SECONDS } from "./auth.service.js"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Routing layer for the Auth domain.
|
|
12
12
|
* Defines the public endpoints for login, registration, logout, and token rotation.
|
|
13
|
-
*
|
|
13
|
+
*
|
|
14
14
|
* Semi-DDD rules:
|
|
15
15
|
* - `.routes.ts` files define HTTP endpoints, validate request payloads, and apply CORS/CSRF configurations.
|
|
16
16
|
* - Endpoints should delegate business flow execution directly to the service layer.
|
|
17
17
|
*/
|
|
18
18
|
const isProduction = env.NODE_ENV === "production"
|
|
19
|
-
const sameSite = (env.COOKIE_SAME_SITE || (isProduction ? "None" : "Lax")) as
|
|
19
|
+
const sameSite = (env.COOKIE_SAME_SITE || (isProduction ? "None" : "Lax")) as
|
|
20
|
+
| "Lax"
|
|
21
|
+
| "None"
|
|
22
|
+
| "Strict"
|
|
20
23
|
const secure = sameSite === "None" ? true : isProduction
|
|
21
24
|
|
|
22
25
|
const domainOpt = env.COOKIE_DOMAIN ? { domain: env.COOKIE_DOMAIN } : {}
|
|
23
26
|
const deleteCookieOpts = (path: string) => ({ path, secure, sameSite, ...domainOpt })
|
|
24
|
-
const cookieOpts = (path: string, maxAge: number) => ({
|
|
27
|
+
const cookieOpts = (path: string, maxAge: number) => ({
|
|
28
|
+
...deleteCookieOpts(path),
|
|
29
|
+
httpOnly: true,
|
|
30
|
+
maxAge,
|
|
31
|
+
})
|
|
25
32
|
|
|
26
33
|
const accessTokenCookieOptions = cookieOpts("/", ACCESS_TOKEN_TTL_SECONDS)
|
|
27
34
|
const refreshTokenCookieOptions = cookieOpts("/", REFRESH_TOKEN_TTL_SECONDS)
|
|
@@ -47,10 +54,18 @@ export const authRoutes = new Hono<{ Variables: AppVariables }>()
|
|
|
47
54
|
|
|
48
55
|
setAuthCookies(c, tokens)
|
|
49
56
|
|
|
50
|
-
return c.json(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
return c.json(
|
|
58
|
+
{
|
|
59
|
+
user,
|
|
60
|
+
accessToken: tokens.accessToken,
|
|
61
|
+
refreshToken: tokens.refreshToken,
|
|
62
|
+
},
|
|
63
|
+
201,
|
|
64
|
+
)
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
67
|
+
reqLogger.error({ err: msg }, "Register failed")
|
|
68
|
+
return c.json({ error: msg }, 400)
|
|
54
69
|
}
|
|
55
70
|
})
|
|
56
71
|
.post("/login", zValidator("json", LoginSchema), async (c) => {
|
|
@@ -63,32 +78,72 @@ export const authRoutes = new Hono<{ Variables: AppVariables }>()
|
|
|
63
78
|
|
|
64
79
|
setAuthCookies(c, tokens)
|
|
65
80
|
|
|
66
|
-
return c.json(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
return c.json(
|
|
82
|
+
{
|
|
83
|
+
user,
|
|
84
|
+
accessToken: tokens.accessToken,
|
|
85
|
+
refreshToken: tokens.refreshToken,
|
|
86
|
+
},
|
|
87
|
+
200,
|
|
88
|
+
)
|
|
89
|
+
} catch (e: unknown) {
|
|
90
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
91
|
+
reqLogger.error({ err: msg }, "Login failed")
|
|
92
|
+
return c.json({ error: msg }, 401)
|
|
70
93
|
}
|
|
71
94
|
})
|
|
72
95
|
.post("/refresh", async (c) => {
|
|
73
96
|
const reqLogger = c.get("logger") || logger
|
|
74
|
-
|
|
97
|
+
let refreshToken = getCookie(c, "refresh_token")
|
|
98
|
+
|
|
99
|
+
if (!refreshToken) {
|
|
100
|
+
try {
|
|
101
|
+
const body = (await c.req.json().catch(() => null)) as { refreshToken?: string } | null
|
|
102
|
+
if (body && typeof body.refreshToken === "string" && body.refreshToken.length > 0) {
|
|
103
|
+
refreshToken = body.refreshToken
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Ignore body parsing failures
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
75
110
|
if (!refreshToken) return c.json({ error: "Missing refresh token" }, 401)
|
|
76
111
|
|
|
77
112
|
try {
|
|
78
113
|
reqLogger.info("Attempting token rotation")
|
|
79
114
|
const tokens = await authService.refresh(refreshToken)
|
|
80
115
|
setAuthCookies(c, tokens)
|
|
81
|
-
return c.json(
|
|
82
|
-
|
|
116
|
+
return c.json(
|
|
117
|
+
{
|
|
118
|
+
user: tokens.user,
|
|
119
|
+
accessToken: tokens.accessToken,
|
|
120
|
+
refreshToken: tokens.refreshToken,
|
|
121
|
+
},
|
|
122
|
+
200,
|
|
123
|
+
)
|
|
124
|
+
} catch (e: unknown) {
|
|
125
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
83
126
|
clearAuthCookies(c)
|
|
84
|
-
reqLogger.error({ err:
|
|
127
|
+
reqLogger.error({ err: msg }, "Refresh failed")
|
|
85
128
|
return c.json({ error: "Invalid refresh token" }, 401)
|
|
86
129
|
}
|
|
87
130
|
})
|
|
88
131
|
.post("/logout", async (c) => {
|
|
89
|
-
|
|
132
|
+
let refreshToken = getCookie(c, "refresh_token")
|
|
133
|
+
|
|
134
|
+
if (!refreshToken) {
|
|
135
|
+
try {
|
|
136
|
+
const body = (await c.req.json().catch(() => null)) as { refreshToken?: string } | null
|
|
137
|
+
if (body && typeof body.refreshToken === "string" && body.refreshToken.length > 0) {
|
|
138
|
+
refreshToken = body.refreshToken
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Ignore body parsing failures
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
90
145
|
if (refreshToken) await authService.revokeSession(refreshToken)
|
|
91
146
|
|
|
92
147
|
clearAuthCookies(c)
|
|
93
|
-
return c.json({ success: true })
|
|
148
|
+
return c.json({ success: true }, 200)
|
|
94
149
|
})
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
1
|
import bcrypt from "bcryptjs"
|
|
3
|
-
import {
|
|
4
|
-
import { authRepo } from "./auth.repo.js"
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
5
3
|
import { userRepo } from "../user/user.repo.js"
|
|
4
|
+
import { authRepo } from "./auth.repo.js"
|
|
5
|
+
import { authService } from "./auth.service.js"
|
|
6
6
|
|
|
7
7
|
vi.mock("./auth.repo.js", () => ({
|
|
8
8
|
authRepo: {
|
|
@@ -24,16 +24,32 @@ vi.mock("../user/user.repo.js", () => ({
|
|
|
24
24
|
const mockedAuthRepo = vi.mocked(authRepo, true)
|
|
25
25
|
const mockedUserRepo = vi.mocked(userRepo, true)
|
|
26
26
|
|
|
27
|
-
const dbUser = (
|
|
27
|
+
const dbUser = (
|
|
28
|
+
overrides: Partial<{
|
|
29
|
+
id: number
|
|
30
|
+
name: string
|
|
31
|
+
email: string
|
|
32
|
+
password: string
|
|
33
|
+
role: string
|
|
34
|
+
}> = {},
|
|
35
|
+
) => ({
|
|
28
36
|
id: 1,
|
|
29
37
|
name: "X",
|
|
30
38
|
email: "a@b.com",
|
|
31
39
|
password: "hash",
|
|
40
|
+
role: "user",
|
|
32
41
|
createdAt: new Date(),
|
|
33
42
|
...overrides,
|
|
34
43
|
})
|
|
35
44
|
|
|
36
|
-
const dbSession = (
|
|
45
|
+
const dbSession = (
|
|
46
|
+
overrides: Partial<{
|
|
47
|
+
id: number
|
|
48
|
+
userId: number
|
|
49
|
+
refreshTokenHash: string
|
|
50
|
+
expiresAt: Date
|
|
51
|
+
}> = {},
|
|
52
|
+
) => ({
|
|
37
53
|
id: 1,
|
|
38
54
|
userId: 1,
|
|
39
55
|
refreshTokenHash: "hash",
|
|
@@ -50,7 +66,7 @@ describe("authService.register", () => {
|
|
|
50
66
|
it("throws if the email is already in use", async () => {
|
|
51
67
|
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser())
|
|
52
68
|
await expect(
|
|
53
|
-
authService.register({ name: "X", email: "a@b.com", password: "password123" })
|
|
69
|
+
authService.register({ name: "X", email: "a@b.com", password: "password123" }),
|
|
54
70
|
).rejects.toThrow("Email already in use")
|
|
55
71
|
})
|
|
56
72
|
|
|
@@ -58,9 +74,13 @@ describe("authService.register", () => {
|
|
|
58
74
|
mockedAuthRepo.findByEmail.mockResolvedValue(null)
|
|
59
75
|
mockedAuthRepo.create.mockImplementation(async (data) => dbUser(data))
|
|
60
76
|
|
|
61
|
-
const user = await authService.register({
|
|
77
|
+
const user = await authService.register({
|
|
78
|
+
name: "X",
|
|
79
|
+
email: " A@B.com ",
|
|
80
|
+
password: "password123",
|
|
81
|
+
})
|
|
62
82
|
|
|
63
|
-
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
83
|
+
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
|
|
64
84
|
const createdArg = mockedAuthRepo.create.mock.calls[0][0]
|
|
65
85
|
expect(createdArg.email).toBe("a@b.com")
|
|
66
86
|
expect(createdArg.password).not.toBe("password123")
|
|
@@ -70,24 +90,24 @@ describe("authService.register", () => {
|
|
|
70
90
|
describe("authService.login", () => {
|
|
71
91
|
it("throws on an unknown email", async () => {
|
|
72
92
|
mockedAuthRepo.findByEmail.mockResolvedValue(null)
|
|
73
|
-
await expect(
|
|
74
|
-
"
|
|
75
|
-
)
|
|
93
|
+
await expect(
|
|
94
|
+
authService.login({ email: "nope@example.com", password: "whatever1" }),
|
|
95
|
+
).rejects.toThrow("Invalid credentials")
|
|
76
96
|
})
|
|
77
97
|
|
|
78
98
|
it("throws on a wrong password", async () => {
|
|
79
99
|
const passwordHash = await bcrypt.hash("correct-password", 4)
|
|
80
100
|
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
|
|
81
|
-
await expect(
|
|
82
|
-
"
|
|
83
|
-
)
|
|
101
|
+
await expect(
|
|
102
|
+
authService.login({ email: "a@b.com", password: "wrong-password" }),
|
|
103
|
+
).rejects.toThrow("Invalid credentials")
|
|
84
104
|
})
|
|
85
105
|
|
|
86
106
|
it("returns the public user shape on correct credentials", async () => {
|
|
87
107
|
const passwordHash = await bcrypt.hash("correct-password", 4)
|
|
88
108
|
mockedAuthRepo.findByEmail.mockResolvedValue(dbUser({ password: passwordHash }))
|
|
89
109
|
const user = await authService.login({ email: "a@b.com", password: "correct-password" })
|
|
90
|
-
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
110
|
+
expect(user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
|
|
91
111
|
})
|
|
92
112
|
})
|
|
93
113
|
|
|
@@ -104,7 +124,7 @@ describe("authService.refresh", () => {
|
|
|
104
124
|
|
|
105
125
|
const result = await authService.refresh("old-token")
|
|
106
126
|
|
|
107
|
-
expect(result.user).toEqual({ id: 1, name: "X", email: "a@b.com" })
|
|
127
|
+
expect(result.user).toEqual({ id: 1, name: "X", email: "a@b.com", role: "user" })
|
|
108
128
|
expect(typeof result.accessToken).toBe("string")
|
|
109
129
|
expect(typeof result.refreshToken).toBe("string")
|
|
110
130
|
expect(mockedAuthRepo.createSession).toHaveBeenCalledTimes(1)
|
|
@@ -127,17 +147,29 @@ describe("authService.revokeSession", () => {
|
|
|
127
147
|
describe("authService.verifyAccessToken", () => {
|
|
128
148
|
it("round-trips a token issued by issueTokenPair", async () => {
|
|
129
149
|
mockedAuthRepo.createSession.mockResolvedValue(dbSession())
|
|
130
|
-
const { accessToken } = await authService.issueTokenPair({
|
|
150
|
+
const { accessToken } = await authService.issueTokenPair({
|
|
151
|
+
id: 1,
|
|
152
|
+
name: "X",
|
|
153
|
+
email: "a@b.com",
|
|
154
|
+
role: "user",
|
|
155
|
+
})
|
|
131
156
|
const payload = await authService.verifyAccessToken(accessToken)
|
|
132
157
|
expect(payload.sub).toBe("1")
|
|
133
158
|
expect(payload.email).toBe("a@b.com")
|
|
159
|
+
expect(payload.role).toBe("user")
|
|
134
160
|
})
|
|
135
161
|
|
|
136
162
|
it("rejects a tampered token", async () => {
|
|
137
163
|
mockedAuthRepo.createSession.mockResolvedValue(dbSession())
|
|
138
|
-
const { accessToken } = await authService.issueTokenPair({
|
|
164
|
+
const { accessToken } = await authService.issueTokenPair({
|
|
165
|
+
id: 1,
|
|
166
|
+
name: "X",
|
|
167
|
+
email: "a@b.com",
|
|
168
|
+
role: "user",
|
|
169
|
+
})
|
|
139
170
|
const i = accessToken.indexOf(".") + 5
|
|
140
|
-
const tampered =
|
|
171
|
+
const tampered =
|
|
172
|
+
accessToken.slice(0, i) + (accessToken[i] === "a" ? "b" : "a") + accessToken.slice(i + 1)
|
|
141
173
|
await expect(authService.verifyAccessToken(tampered)).rejects.toThrow()
|
|
142
174
|
})
|
|
143
175
|
})
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { SignJWT, jwtVerify } from "jose"
|
|
3
|
-
import { randomBytes, createHash } from "node:crypto"
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
4
2
|
import type { AuthUser, LoginInput, RegisterInput } from "@restack/shared"
|
|
3
|
+
import bcrypt from "bcryptjs"
|
|
4
|
+
import { jwtVerify, SignJWT } from "jose"
|
|
5
5
|
import { env } from "../../config/env.js"
|
|
6
|
-
import { authRepo } from "./auth.repo.js"
|
|
7
6
|
import { userRepo } from "../user/user.repo.js"
|
|
7
|
+
import { authRepo } from "./auth.repo.js"
|
|
8
8
|
import type { AuthTokenPayload } from "./auth.types.js"
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Service layer for the Auth domain.
|
|
12
12
|
* Houses core validation and cryptographic flows (JWT token signing, refresh token generation, hashing).
|
|
13
|
-
*
|
|
13
|
+
*
|
|
14
14
|
* Semi-DDD rules:
|
|
15
15
|
* - `.service.ts` files encapsulate business rules and complex workflows (e.g. session issuance & rotation).
|
|
16
16
|
* - They must never call Drizzle ORM directly; always interact through the repo layer.
|
|
@@ -24,12 +24,17 @@ const normalizeEmail = (email: string) => email.trim().toLowerCase()
|
|
|
24
24
|
export const ACCESS_TOKEN_TTL_SECONDS = 60 * 15 // 15 minutes
|
|
25
25
|
export const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days
|
|
26
26
|
|
|
27
|
-
const buildTokenPayload = (user: {
|
|
27
|
+
const buildTokenPayload = (user: {
|
|
28
|
+
id: number
|
|
29
|
+
email: string
|
|
30
|
+
role: string
|
|
31
|
+
}): AuthTokenPayload => ({
|
|
28
32
|
sub: String(user.id),
|
|
29
33
|
email: user.email,
|
|
34
|
+
role: user.role,
|
|
30
35
|
})
|
|
31
36
|
|
|
32
|
-
const signAccessToken = (user: { id: number; email: string }) =>
|
|
37
|
+
const signAccessToken = (user: { id: number; email: string; role: string }) =>
|
|
33
38
|
new SignJWT(buildTokenPayload(user))
|
|
34
39
|
.setProtectedHeader({ alg: "HS256" })
|
|
35
40
|
.setIssuedAt()
|
|
@@ -60,7 +65,7 @@ export const authService = {
|
|
|
60
65
|
|
|
61
66
|
const password = await bcrypt.hash(data.password, BCRYPT_COST)
|
|
62
67
|
const user = await authRepo.create({ ...data, email, password })
|
|
63
|
-
return { id: user.id, name: user.name, email: user.email }
|
|
68
|
+
return { id: user.id, name: user.name, email: user.email, role: user.role }
|
|
64
69
|
},
|
|
65
70
|
|
|
66
71
|
login: async (data: LoginInput): Promise<AuthUser> => {
|
|
@@ -68,7 +73,7 @@ export const authService = {
|
|
|
68
73
|
const valid = await bcrypt.compare(data.password, user ? user.password : DUMMY_HASH)
|
|
69
74
|
if (!user || !valid) throw new Error("Invalid credentials")
|
|
70
75
|
|
|
71
|
-
return { id: user.id, name: user.name, email: user.email }
|
|
76
|
+
return { id: user.id, name: user.name, email: user.email, role: user.role }
|
|
72
77
|
},
|
|
73
78
|
|
|
74
79
|
issueTokenPair,
|
|
@@ -83,7 +88,7 @@ export const authService = {
|
|
|
83
88
|
const user = await userRepo.findById(session.userId)
|
|
84
89
|
if (!user) throw new Error("Invalid refresh token")
|
|
85
90
|
|
|
86
|
-
return issueTokenPair({ id: user.id, name: user.name, email: user.email })
|
|
91
|
+
return issueTokenPair({ id: user.id, name: user.name, email: user.email, role: user.role })
|
|
87
92
|
},
|
|
88
93
|
|
|
89
94
|
revokeSession: async (refreshToken: string) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { pgTable, text, timestamp
|
|
1
|
+
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Users Table Schema.
|
|
@@ -10,5 +10,6 @@ export const usersTable = pgTable("users", {
|
|
|
10
10
|
name: text("name").notNull(),
|
|
11
11
|
email: text("email").notNull().unique(),
|
|
12
12
|
password: text("password").notNull(),
|
|
13
|
+
role: text("role").default("user").notNull(),
|
|
13
14
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
14
15
|
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { UpdateProfileInput } from "@restack/shared"
|
|
1
2
|
import { eq } from "drizzle-orm"
|
|
2
3
|
import { db } from "../../infra/db/index.js"
|
|
3
4
|
import { usersTable } from "./user.model.js"
|
|
@@ -7,7 +8,7 @@ export type UserRow = typeof usersTable.$inferSelect
|
|
|
7
8
|
/**
|
|
8
9
|
* Repository layer for the User domain.
|
|
9
10
|
* Encapsulates all direct database queries for users.
|
|
10
|
-
*
|
|
11
|
+
*
|
|
11
12
|
* Semi-DDD rules:
|
|
12
13
|
* - Direct database interactions (Drizzle client) must live only inside `.repo.ts` files.
|
|
13
14
|
* - Under cross-domain rules, the Auth domain can import from this repo (e.g. user lookup by ID/email),
|
|
@@ -22,7 +23,7 @@ export const userRepo = {
|
|
|
22
23
|
return user ?? null
|
|
23
24
|
},
|
|
24
25
|
|
|
25
|
-
updateProfile: async (id: number, data:
|
|
26
|
+
updateProfile: async (id: number, data: UpdateProfileInput): Promise<UserRow | null> => {
|
|
26
27
|
const [user] = await db.update(usersTable).set(data).where(eq(usersTable.id, id)).returning()
|
|
27
28
|
return user ?? null
|
|
28
29
|
},
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm"
|
|
2
|
+
import fc from "fast-check"
|
|
3
|
+
import { testClient } from "hono/testing"
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
|
5
|
+
import { db } from "../../infra/db/index.js"
|
|
6
|
+
import { authService } from "../auth/auth.service.js"
|
|
7
|
+
import { usersTable } from "./user.model.js"
|
|
8
|
+
import { userRoutes } from "./user.routes.js"
|
|
9
|
+
|
|
10
|
+
const TEST_EMAIL = `user-routes-test-${Date.now()}@example.com`
|
|
11
|
+
let accessToken: string
|
|
12
|
+
let userId: number
|
|
13
|
+
|
|
14
|
+
const client = testClient(userRoutes)
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
const user = await authService.register({
|
|
18
|
+
name: "Route Test",
|
|
19
|
+
email: TEST_EMAIL,
|
|
20
|
+
password: "password123",
|
|
21
|
+
})
|
|
22
|
+
userId = user.id
|
|
23
|
+
const tokens = await authService.issueTokenPair(user)
|
|
24
|
+
accessToken = tokens.accessToken
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterAll(async () => {
|
|
28
|
+
await db.delete(usersTable).where(eq(usersTable.id, userId))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const authHeaders = (token?: string) => ({
|
|
32
|
+
...(token ? { Cookie: `access_token=${token}` } : {}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("GET /me", () => {
|
|
36
|
+
it("returns 401 without a token", async () => {
|
|
37
|
+
const res = await client.me.$get()
|
|
38
|
+
expect(res.status).toBe(401)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("returns the profile with a valid token", async () => {
|
|
42
|
+
const res = await client.me.$get({}, { headers: authHeaders(accessToken) })
|
|
43
|
+
expect(res.status).toBe(200)
|
|
44
|
+
const body = await res.json()
|
|
45
|
+
if ("error" in body) throw new Error(String(body.error))
|
|
46
|
+
expect(body.user.email).toBe(TEST_EMAIL)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe("PUT /profile", () => {
|
|
51
|
+
it("returns 401 without a token", async () => {
|
|
52
|
+
const res = await client.profile.$put({ json: { name: "x" } })
|
|
53
|
+
expect(res.status).toBe(401)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("updates the name with a valid token and body", async () => {
|
|
57
|
+
const res = await client.profile.$put(
|
|
58
|
+
{ json: { name: "Updated Name" } },
|
|
59
|
+
{ headers: authHeaders(accessToken) },
|
|
60
|
+
)
|
|
61
|
+
expect(res.status).toBe(200)
|
|
62
|
+
const body = await res.json()
|
|
63
|
+
if ("error" in body) throw new Error(String(body.error))
|
|
64
|
+
expect(body.user.name).toBe("Updated Name")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("rejects an invalid body (empty name)", async () => {
|
|
68
|
+
const res = await client.profile.$put(
|
|
69
|
+
{ json: { name: "" } },
|
|
70
|
+
{ headers: authHeaders(accessToken) },
|
|
71
|
+
)
|
|
72
|
+
expect(res.status).toBe(400)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("treats an empty body as a no-op instead of erroring", async () => {
|
|
76
|
+
const res = await client.profile.$put({ json: {} }, { headers: authHeaders(accessToken) })
|
|
77
|
+
expect(res.status).toBe(200)
|
|
78
|
+
const body = await res.json()
|
|
79
|
+
if ("error" in body) throw new Error(String(body.error))
|
|
80
|
+
expect(body.user.email).toBe(TEST_EMAIL)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("never returns a 500 crash on arbitrary string payloads (property fuzzing)", async () => {
|
|
84
|
+
await fc.assert(
|
|
85
|
+
fc.asyncProperty(fc.string(), async (name) => {
|
|
86
|
+
const res = await client.profile.$put(
|
|
87
|
+
{ json: { name } },
|
|
88
|
+
{ headers: authHeaders(accessToken) },
|
|
89
|
+
)
|
|
90
|
+
// Must either succeed (200) or cleanly reject invalid input (400), never crash (500)
|
|
91
|
+
expect([200, 400]).toContain(res.status)
|
|
92
|
+
}),
|
|
93
|
+
{ numRuns: 30 },
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("GET /admin (RBAC Protected Route)", () => {
|
|
99
|
+
it("returns 401 without a token", async () => {
|
|
100
|
+
const res = await client.admin.$get()
|
|
101
|
+
expect(res.status).toBe(401)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("returns 403 when user has default 'user' role", async () => {
|
|
105
|
+
const res = await client.admin.$get({}, { headers: authHeaders(accessToken) })
|
|
106
|
+
expect(res.status).toBe(403)
|
|
107
|
+
const body = (await res.json()) as { error?: string; message?: string }
|
|
108
|
+
expect(body.error).toBe("Forbidden: insufficient permissions")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("returns 200 when user has 'admin' role (via Bearer token)", async () => {
|
|
112
|
+
const adminTokens = await authService.issueTokenPair({
|
|
113
|
+
id: userId,
|
|
114
|
+
name: "Admin User",
|
|
115
|
+
email: TEST_EMAIL,
|
|
116
|
+
role: "admin",
|
|
117
|
+
})
|
|
118
|
+
const res = await client.admin.$get(
|
|
119
|
+
{},
|
|
120
|
+
{ headers: { Authorization: `Bearer ${adminTokens.accessToken}` } },
|
|
121
|
+
)
|
|
122
|
+
expect(res.status).toBe(200)
|
|
123
|
+
const body = await res.json()
|
|
124
|
+
if ("error" in body) throw new Error(String(body.error))
|
|
125
|
+
expect(body.message).toBe("Welcome to the admin area")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { Hono } from "hono"
|
|
2
|
-
import type { Context } from "hono"
|
|
3
1
|
import { zValidator } from "@hono/zod-validator"
|
|
4
2
|
import { UpdateProfileSchema } from "@restack/shared"
|
|
3
|
+
import type { Context } from "hono"
|
|
4
|
+
import { Hono } from "hono"
|
|
5
5
|
import { requireAuth } from "../../infra/middlewares/require-auth.js"
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { requireRole } from "../../infra/middlewares/require-role.js"
|
|
7
|
+
import { type AppVariables, logger } from "../../utils/logger.js"
|
|
8
|
+
import { UserNotFoundError, userService } from "./user.service.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Routing layer for the User domain.
|
|
11
12
|
* Configures the router and defines endpoints with appropriate middlewares.
|
|
12
|
-
*
|
|
13
|
+
*
|
|
13
14
|
* Semi-DDD rules:
|
|
14
15
|
* - `.routes.ts` files define HTTP endpoints, parse inputs using schema validators (zValidator),
|
|
15
|
-
* apply guards (requireAuth), and serialize JSON responses.
|
|
16
|
+
* apply guards (requireAuth, requireRole), and serialize JSON responses.
|
|
16
17
|
* - Routing code should never implement business logic directly; it must delegate to the service layer.
|
|
17
18
|
*/
|
|
18
|
-
const handleProfileError = (
|
|
19
|
+
const handleProfileError = (
|
|
20
|
+
c: Context<{ Variables: AppVariables }>,
|
|
21
|
+
e: unknown,
|
|
22
|
+
action: string,
|
|
23
|
+
) => {
|
|
19
24
|
if (e instanceof UserNotFoundError) return c.json({ error: e.message }, 404)
|
|
20
25
|
const reqLogger = c.get("logger") || logger
|
|
21
26
|
reqLogger.error({ err: e instanceof Error ? e.message : String(e) }, action)
|
|
@@ -30,7 +35,7 @@ export const userRoutes = new Hono<{ Variables: AppVariables }>()
|
|
|
30
35
|
const user = c.get("user")
|
|
31
36
|
if (!user) return c.json({ error: "Unauthorized" }, 401)
|
|
32
37
|
const profile = await userService.getProfile(Number(user.sub))
|
|
33
|
-
return c.json({ user: profile })
|
|
38
|
+
return c.json({ user: profile }, 200)
|
|
34
39
|
} catch (e) {
|
|
35
40
|
return handleProfileError(c, e, "Get profile failed")
|
|
36
41
|
}
|
|
@@ -43,8 +48,13 @@ export const userRoutes = new Hono<{ Variables: AppVariables }>()
|
|
|
43
48
|
const user = c.get("user")
|
|
44
49
|
if (!user) return c.json({ error: "Unauthorized" }, 401)
|
|
45
50
|
const profile = await userService.updateProfile(Number(user.sub), body)
|
|
46
|
-
return c.json({ user: profile })
|
|
51
|
+
return c.json({ user: profile }, 200)
|
|
47
52
|
} catch (e) {
|
|
48
53
|
return handleProfileError(c, e, "Update profile failed")
|
|
49
54
|
}
|
|
50
55
|
})
|
|
56
|
+
.get("/admin", requireAuth, requireRole("admin"), async (c) => {
|
|
57
|
+
const reqLogger = c.get("logger") || logger
|
|
58
|
+
reqLogger.info("Admin route accessed")
|
|
59
|
+
return c.json({ message: "Welcome to the admin area", user: c.get("user") }, 200)
|
|
60
|
+
})
|
|
@@ -7,16 +7,22 @@ export class UserNotFoundError extends Error {}
|
|
|
7
7
|
* Maps database user object into the public, safe schema shape.
|
|
8
8
|
* Always filters out sensitive columns like password hashes.
|
|
9
9
|
*/
|
|
10
|
-
const toPublicUser = (user: {
|
|
10
|
+
const toPublicUser = (user: {
|
|
11
|
+
id: number
|
|
12
|
+
name: string
|
|
13
|
+
email: string
|
|
14
|
+
role: string
|
|
15
|
+
}): AuthUser => ({
|
|
11
16
|
id: user.id,
|
|
12
17
|
name: user.name,
|
|
13
18
|
email: user.email,
|
|
19
|
+
role: user.role,
|
|
14
20
|
})
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Service layer for the User domain.
|
|
18
24
|
* Contains core business logic and validations.
|
|
19
|
-
*
|
|
25
|
+
*
|
|
20
26
|
* Semi-DDD rules:
|
|
21
27
|
* - `.service.ts` encapsulates business logic, mappings, and validations.
|
|
22
28
|
* - It must never interact with the database client directly; always query through the `.repo.ts` layer.
|