https://github.com/cjy1998/hono-rbac-starter
A production-ready Hono + TypeScript backend starter with RBAC, JWT, Redis & Drizzle ORM | 开箱即用的 Hono 后端模板
https://github.com/cjy1998/hono-rbac-starter
drizzle-orm honojs ioredis jwt rbac redis rest-api starter-template typescript winston zod
Last synced: 10 days ago
JSON representation
A production-ready Hono + TypeScript backend starter with RBAC, JWT, Redis & Drizzle ORM | 开箱即用的 Hono 后端模板
- Host: GitHub
- URL: https://github.com/cjy1998/hono-rbac-starter
- Owner: cjy1998
- License: mit
- Created: 2026-05-13T14:20:47.000Z (about 2 months ago)
- Default Branch: master
- Last Pushed: 2026-06-11T14:26:58.000Z (25 days ago)
- Last Synced: 2026-06-11T16:05:33.037Z (25 days ago)
- Topics: drizzle-orm, honojs, ioredis, jwt, rbac, redis, rest-api, starter-template, typescript, winston, zod
- Language: TypeScript
- Homepage:
- Size: 161 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# hono-rbac-starter
[中文](./docs/README.zh-CN.md) | English
A backend starter template based on **Hono + TypeScript**, featuring:
- [Hono](https://hono.dev/) - A minimal, type-friendly Web framework
- [Drizzle ORM](https://orm.drizzle.team/) + MySQL - A TypeScript-first ORM
- [Zod](https://zod.dev/) - Schema validation and type inference
- [Hono JWT](https://hono.dev/docs/helpers/jwt) - JWT signing and verification
- [Argon2](https://github.com/ranisalt/node-argon2) - Modern password hashing algorithm
- [Winston](https://github.com/winstonjs/winston) + [winston-daily-rotate-file](https://github.com/winstonjs/winston-daily-rotate-file) - Structured logging with daily rotation and automatic gzip compression
- [ioredis](https://github.com/redis/ioredis) - Redis client used for role/permission caching
- Security hardening middleware: security headers, login rate limiting (Redis sliding window), and basic XSS payload blocking
- Layered architecture: `controller / server / dto / vo / db / middleware`
- Unified exception handling, unified response format, and per-request `requestId` tracing
- Complete RBAC schema: four entities (user / role / permission / menu) plus three association tables
> Want to read the full implementation walkthrough and module-by-module explanation? See [`TUTORIAL.md`](./TUTORIAL.md).
---
## Project Structure
```
hono-test/
├── drizzle/ Migration SQL generated by drizzle-kit
├── logs/ Runtime logs (gitignored, daily rotation + gzip compression)
|── scripts/
| ├── seed.ts Seed data initialization script
├── src/
│ ├── index.ts Application entry (route mounting, global exceptions, request logging)
│ ├── redis.ts Redis connection instance (ioredis)
│ ├── controller/ Controller layer
│ ├── service/ Business layer
│ │ ├── user.service.ts User business logic
│ │ └── permissions.ts Permission/role query service (used by roleAuth)
│ ├── db/ Drizzle instance + schema
│ │ ├── index.ts Drizzle connection instance
│ │ ├── schema/ Table definitions (one file per entity or relation)
│ │ │ ├── common.ts Shared columns for all tables (id / createdAt / updatedAt / deletedAt)
│ │ │ ├── users.ts Users table
│ │ │ ├── roles.ts Roles table
│ │ │ ├── permissions.ts Permissions table (button/API)
│ │ │ ├── menus.ts Menus table (separated from permissions)
│ │ │ ├── user_role.ts User-Role association table
│ │ │ ├── role_permission.ts Role-Permission association table
│ │ │ └── role_menu.ts Role-Menu association table
│ │ └── relations.ts Relation definitions among all tables
│ ├── dto/ Request DTOs + zod schemas
│ │ ├── common.dto.ts Common pagination DTO
│ │ └── user.dto.ts User DTOs
│ ├── vo/ Response view objects
│ │ ├── common.vo.ts Common pagination VO
│ │ ├── roles.vo.ts Roles VO
│ │ └── user.vo.ts User VO
│ ├── middleware/ jwtAuth / roleAuth / redis.middleware / zValidator / requestLogger
│ ├── exceptions/ Custom exceptions
│ ├── types/ Shared types such as Hono Variables
│ ├── utils/ Constants, unified response, logger, query utilities
│ │ ├── const.ts HTTP status constants
│ │ ├── logger.ts Winston logger
│ │ ├── query.ts Common query utilities (notDeleted, paginate)
│ │ ├── response.ts Unified response ok / fail
│ │ └── token.ts Bearer token parsing + JWT blacklist key helpers
│ ├── env.ts Runtime environment loading and validation
│ └── env.d.ts process.env types
├── drizzle.config.ts
├── tsconfig.json
└── package.json
```
---
## Database Design (RBAC)
This project implements a complete **RBAC (Role-Based Access Control)** schema consisting of 7 tables:
```mermaid
erDiagram
users {
string id PK "UUID, primary key"
string username UK "Username, unique"
string nickname "Nickname"
string password "Argon2-hashed password"
string email UK "Email, unique"
string phone "Phone number"
string avatar "Avatar URL"
tinyint status "0-disabled 1-enabled"
timestamp createdAt "Created at"
timestamp updatedAt "Updated at"
timestamp deletedAt "Soft-delete time, nullable"
}
roles {
string id PK "UUID, primary key"
string roleName "Role name"
string roleCode UK "Role code, unique"
int sortOrder "Sort order, smaller comes first"
tinyint status "0-disabled 1-enabled"
string remark "Remark"
timestamp createdAt "Created at"
timestamp updatedAt "Updated at"
timestamp deletedAt "Soft-delete time"
}
permissions {
string id PK "UUID, primary key"
string permissionName "Permission name, e.g. 'User List'"
string permissionCode UK "Permission code, e.g. 'user:list', unique"
tinyint permissionType "1-button/action 2-API 3-data permission"
string resource "API resource path (used when permissionType=2)"
string method "HTTP method"
tinyint status "0-disabled 1-enabled"
string remark "Remark"
timestamp createdAt "Created at"
timestamp updatedAt "Updated at"
timestamp deletedAt "Soft-delete time"
}
menus {
string id PK "UUID, primary key"
string parentId "Parent menu ID, '0' for top level"
string menuName "Menu name"
tinyint menuType "1-directory 2-menu 3-external link"
string path "Route path"
string component "Frontend component path"
string icon "Icon"
string redirect "Redirect target"
string permissionCode "Associated permission code"
tinyint visible "0-hidden 1-visible"
tinyint keepAlive "0-no cache 1-cached"
int sortOrder "Sort order"
tinyint status "0-disabled 1-enabled"
timestamp createdAt "Created at"
timestamp updatedAt "Updated at"
timestamp deletedAt "Soft-delete time"
}
user_role {
string userId PK,FK "UUID referencing user"
string roleId PK,FK "UUID referencing role"
timestamp createdAt "Created at"
}
role_permission {
string roleId PK,FK "UUID referencing role"
string permissionId PK,FK "UUID referencing permission"
timestamp createdAt "Created at"
}
role_menu {
string roleId PK,FK "UUID referencing role"
string menuId PK,FK "UUID referencing menu"
timestamp createdAt "Created at"
}
users ||--o{ user_role : "A user can have multiple roles"
roles ||--o{ user_role : "A role can be assigned to multiple users"
roles ||--o{ role_permission : "A role can own multiple permissions"
permissions ||--o{ role_permission : "A permission can be assigned to multiple roles"
roles ||--o{ role_menu : "A role can be linked to multiple menus"
menus ||--o{ role_menu : "A menu can be assigned to multiple roles"
```
**Design highlights**:
- All primary keys use **UUID v4** (`char(36)`), which avoids conflicts during cross-environment migration
- All tables include `createdAt / updatedAt / deletedAt` (soft delete), defined uniformly via `commonSchema`
- **Menus are separated from permissions**: menus focus on frontend routing and components, while permissions focus on button actions and API authorization, decoupling the two concerns
- The three association tables (user_role / role_permission / role_menu) have no `id` primary key and use composite primary keys (roleId + XxxId)
---
## Quick Start
### 1. Requirements
- Node.js >= 20
- MySQL >= 8.0
- Redis >= 6.0
- [pnpm](https://pnpm.io/) is recommended
### 2. Install Dependencies
```bash
pnpm install
```
### 3. Configure Environment Variables
Create a `.env` file in the project root:
```dotenv
# MySQL connection string
DATABASE_URL=mysql://username:password@localhost:3306/hono_test
# JWT secret and expiration
JWT_SECRET=replace-with-your-own-long-random-string
JWT_EXPIRES_IN=24h
# Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Optional: logging
# NODE_ENV=production # In production the console output is also JSON
# LOG_LEVEL=info # error / warn / info / http / debug / silly
# LOG_DIR=/var/log/hono # Custom log directory, defaults to /logs
# Optional: CORS
ALLOWED_ORIGINS=*
# Optional: login rate limiting
RATE_LIMIT_LOGIN_MAX=5
RATE_LIMIT_LOGIN_WINDOW_SEC=60
```
See `.env.example` for more details.
### 4. Create Database and Run Migrations
```bash
# Create the database in MySQL beforehand
mysql -uroot -p -e "CREATE DATABASE hono_test DEFAULT CHARSET utf8mb4;"
# Generate migration SQL from src/db/schema/
pnpm db:g
# Apply migrations to the database
pnpm db:m
# Initialize seed data (roles / permissions / menus / super admin account)
pnpm db:seed
```
### 5. Start the Dev Server
```bash
pnpm dev
```
The service runs at by default.
---
## NPM Scripts
| Command | Description |
| -------------- | ---------------------------------------------------------------------- |
| `pnpm dev` | `tsx watch src/index.ts`, dev mode with hot reload |
| `pnpm build` | `tsc`, compile TypeScript into `dist/` |
| `pnpm start` | `node dist/index.js`, run the compiled artifact |
| `pnpm db:g` | `drizzle-kit generate`, generate migrations from schema |
| `pnpm db:m` | `drizzle-kit migrate`, apply migrations to MySQL |
| `pnpm db:seed` | `tsx scripts/seed.ts`, seed data (roles / permissions / menus / users) |
---
## Default Accounts
After running `pnpm db:seed`, the following accounts are inserted automatically:
| Role | Username | Password | Description |
| ------------ | -------- | ------------- | ---------------------------------- |
| Super Admin | `admin` | `admin123456` | Has all permissions and menus |
| Regular User | `user` | `user123456` | Can only access the Dashboard page |
---
## API Overview
### User
| Method | Path | Auth | Parameters | Description |
| ------ | -------------------- | ---- | ------------------------------------------------ | ----------------------------------------------------- |
| POST | `/user/login` | - | `{ email, password }` (json) | Login, returns user + token (protected by rate limit) |
| POST | `/user/logout` | JWT | - | Logout, adds current token to blacklist |
| POST | `/user` | JWT | `{ username, nickname, email, password }` (json) | Create a user |
| GET | `/user` | JWT | `?page&pageSize&username&email` (query) | Pagination + username/email search |
| GET | `/user/:id` | JWT | `id` (param, UUID format) | Get a single user |
| PUT | `/user/:id` | JWT | `id` (param) + partial user fields (json) | Update a user, also clears Redis cache |
| PUT | `/user/:id/password` | JWT | `id` (param) + `{ password }` (json) | Change password, also clears Redis cache |
| DELETE | `/user/:id` | JWT | `id` (param, UUID format) | Soft-delete a user, also clears cache |
| POST | `/user/:id/roles` | JWT | `id` (param) + `{ roleIds: string[] }` (json) | Bind user-role associations (idempotent batch) |
| DELETE | `/user/:id/roles` | JWT | `id` (param) + `{ roleIds: string[] }` (json) | Unbind user-role associations (batch) |
### Role
| Method | Path | Auth | Parameters | Description |
| ------ | ----------------------- | ---- | --------------------------------------------------- | ------------------------------------------- |
| POST | `/role` | JWT | role payload (json) | Create role |
| GET | `/role` | JWT | role filters + pagination (query) | List roles |
| GET | `/role/:id` | JWT | `id` (param) | Get role details |
| PUT | `/role/:id` | JWT | `id` (param) + role fields (json) | Update role |
| DELETE | `/role/:id` | JWT | `id` (param) | Soft-delete role |
| POST | `/role/:id/permissions` | JWT | `id` (param) + `{ permissionIds: string[] }` (json) | Bind role-permission associations (batch) |
| DELETE | `/role/:id/permissions` | JWT | `id` (param) + `{ permissionIds: string[] }` (json) | Unbind role-permission associations (batch) |
| POST | `/role/:id/menus` | JWT | `id` (param) + `{ menuIds: string[] }` (json) | Bind role-menu associations (batch) |
| DELETE | `/role/:id/menus` | JWT | `id` (param) + `{ menuIds: string[] }` (json) | Unbind role-menu associations (batch) |
### Permission and Menu
| Method | Path | Auth | Description |
| ------ | -------------- | ---- | --------------- |
| CRUD | `/permissions` | JWT | Permission CRUD |
| CRUD | `/menu` | JWT | Menu CRUD |
| GET | `/menu/tree` | JWT | Query menu tree |
> All endpoints follow a unified response format:
>
> ```jsonc
> // Success
> { "success": true, "data": { ... }, "errorCode": 0, "message": "ok", "responseId": "" }
>
> // Failure
> { "success": false, "data": null, "errorCode": 401, "message": "token expired", "responseId": "" }
> ```
>
> `responseId` mirrors the request-scoped `x-request-id`, so frontend / gateway / log system can correlate a response with its full server-side trace.
---
## Integration Examples
```bash
# 1. Login
curl -X POST http://localhost:3000/user/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"admin123456"}'
# 2. Access protected endpoints with the token
curl http://localhost:3000/user \
-H 'Authorization: Bearer '
# 3. Get a single user (id is a UUID)
curl http://localhost:3000/user/ \
-H 'Authorization: Bearer '
# 4. Logout (token will be added to the Redis blacklist and become unusable)
curl -X POST http://localhost:3000/user/logout \
-H 'Authorization: Bearer '
```
---
## Middleware
The project addresses cross-cutting concerns such as authentication, caching, and request tracing through Hono middleware, all located in `src/middleware/`:
| Middleware | Description |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jwtAuth` | JWT parsing and verification; on success, writes the typed user payload into `c.set("user", payload)` and the raw token into `c.set("token", token)`. It also checks the Redis blacklist so logged-out tokens cannot be reused before they naturally expire |
| `roleAuth` | Role / permission authorization; based on `c.get("user")`, calls the permissions service to check whether the current user has the required role or API permission |
| `redis.middleware` | Role/permission caching middleware. On Redis cache hit it reuses the cached value; on miss it falls back to the database and writes the result back, reducing RBAC query load |
| `securityHeaders` | Sets security-related response headers globally, including `CSP`, `X-Frame-Options`, and `X-Content-Type-Options` |
| `xssProtection` | Performs basic XSS payload checks on request params/query/json body and rejects suspicious inputs |
| `rateLimit` | Redis sliding-window rate limit middleware, currently applied to `/user/login` to prevent brute-force attacks |
| `zValidator` | Request parameter validation based on `@hono/zod-validator`; on failure throws a unified `ValidationException` |
| `requestLogger` | Generates / propagates `x-request-id` and writes per-request trace logs |
### Redis Cache
- `src/redis.ts` creates a singleton connection based on `ioredis`, reading environment variables such as `REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_DB`
- `redis.middleware` works together with `roleAuth` to cache the user's role and permission set, avoiding a database query on every request
- Cache invalidation
- On user `update / delete / change password`, the controller calls `clearUserCache` to remove `user:{id}` and `user:roles:{id}` so the next read goes back to the database
- On logout, the current token is written into `jwt:blacklist:` with a TTL equal to its remaining JWT lifetime (`exp - now`), so the key clears itself automatically and Redis never accumulates expired entries
---
## Logging System
The project ships with an engineering-grade logging solution based on Winston. All logs are written through the `logger` exposed by `src/utils/logger.ts`. **Do not use `console.log` in business code.**
### Output Destinations
| Channel | Content | Format |
| --------------------------------- | ----------------------------- | ------------------------------------ |
| Terminal console | Full output (per `LOG_LEVEL`) | Dev: colored single line; Prod: JSON |
| `logs/application-YYYY-MM-DD.log` | Full output (per `LOG_LEVEL`) | JSON, easy for log collection |
| `logs/error-YYYY-MM-DD.log` | Only `error` level | JSON |
### Auto-Rotation Policy
- Daily rotation: one file per day
- Rolls immediately when a single file exceeds `20m`
- Old files from previous days are automatically `gzip`-compressed into `.log.gz`
- Full logs retained for `14d`, error logs retained for `30d`
- Rotation state is recorded by `.-audit.json`; expired files are deleted automatically
### Environment Variables
| Variable | Default | Description |
| ----------- | ------------------------------- | --------------------------------------------------------------------------- |
| `LOG_LEVEL` | `debug` in dev / `info` in prod | Standard winston level |
| `LOG_DIR` | `/logs` | Custom log directory (in containers usually mounted on a persistent volume) |
| `NODE_ENV` | - | When set to `production`, the console also outputs JSON |
### Request Tracing
The `requestLogger` middleware generates (or propagates from upstream) an `x-request-id` for each request and writes it into:
- The response header `x-request-id`, allowing the frontend / gateway to correlate requests
- The `requestId` field in all subsequent logs of the request, allowing a full trace to be filtered by ID in the log system
Terminal example:
```
[2026-05-16 22:14:45.690] info: request received {"requestId":"3c9d6ba5...","method":"GET","path":"/user/999",...}
[2026-05-16 22:14:45.691] warn: token is required {"requestId":"3c9d6ba5...","status":401}
[2026-05-16 22:14:45.691] info: request completed {"requestId":"3c9d6ba5...","method":"GET","path":"/user/999","status":200,"durationMs":1}
```
### Usage in Business Code
```ts
import { logger } from "./utils/logger.js";
logger.info("user created", { userId, email });
logger.warn("rate limit hit", { ip, route });
logger.error("payment failed", { orderId, error: err });
```
When `logger.error` is called with an `Error` object directly, the stack trace is automatically expanded into the log.
---
## Further Reading
- Full tutorial (module-by-module): [`TUTORIAL.md`](./TUTORIAL.md)
- Hono official docs:
- Drizzle ORM docs:
- Zod docs:
---
## License
MIT