create-fullstack-scaffold 0.1.1 → 0.2.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/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs'
|
|
2
2
|
import { getDb, getRawClient } from '@server/db'
|
|
3
|
-
import { todos } from '@server/db/schema'
|
|
4
3
|
import { desc } from 'drizzle-orm'
|
|
5
4
|
import { toISOString } from '@server/utils/date'
|
|
6
|
-
import { getMockUsers } from '@server/utils/auth'
|
|
5
|
+
import { getMockUsers, getMockTokens, getUserPasswordHashes } from '@server/utils/auth'
|
|
7
6
|
import { Role, getPermissionsByRole } from '@shared/modules/permission'
|
|
8
7
|
import type {
|
|
9
8
|
SystemStats,
|
|
@@ -15,6 +14,24 @@ import type {
|
|
|
15
14
|
UpdateUserRequest,
|
|
16
15
|
CreateUserRequest,
|
|
17
16
|
} from '@shared/modules/admin'
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
let todosTable: any = null
|
|
20
|
+
let todosTableLoaded = false
|
|
21
|
+
|
|
22
|
+
async function getTodosTable() {
|
|
23
|
+
if (todosTableLoaded) return todosTable
|
|
24
|
+
try {
|
|
25
|
+
const schema = (await import('@server/db/schema')) as Record<string, unknown>
|
|
26
|
+
todosTable = schema.todos ?? null
|
|
27
|
+
todosTableLoaded = true
|
|
28
|
+
return todosTable
|
|
29
|
+
} catch {
|
|
30
|
+
todosTableLoaded = true
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
export async function getSystemStats(): Promise<SystemStats> {
|
|
19
36
|
const rawClient = await getRawClient()
|
|
20
37
|
|
|
@@ -40,6 +57,16 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|
|
40
57
|
}
|
|
41
58
|
}
|
|
42
59
|
|
|
60
|
+
const todos = await getTodosTable()
|
|
61
|
+
if (!todos) {
|
|
62
|
+
return {
|
|
63
|
+
totalTodos: 0,
|
|
64
|
+
pendingTodos: 0,
|
|
65
|
+
completedTodos: 0,
|
|
66
|
+
lastUpdated: new Date().toISOString(),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
const db = await getDb()
|
|
44
71
|
const allTodos = await db.select().from(todos)
|
|
45
72
|
|
|
@@ -53,6 +80,13 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|
|
53
80
|
|
|
54
81
|
export async function checkDatabaseHealth(): Promise<HealthCheck> {
|
|
55
82
|
try {
|
|
83
|
+
const todos = await getTodosTable()
|
|
84
|
+
if (!todos) {
|
|
85
|
+
return {
|
|
86
|
+
database: 'disconnected',
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
56
90
|
const db = await getDb()
|
|
57
91
|
await db.select().from(todos).limit(1)
|
|
58
92
|
return {
|
|
@@ -68,8 +102,10 @@ export async function checkDatabaseHealth(): Promise<HealthCheck> {
|
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
export async function clearAllTodos(): Promise<{ deletedCount: number }> {
|
|
105
|
+
const todos = await getTodosTable()
|
|
106
|
+
if (!todos) return { deletedCount: 0 }
|
|
71
107
|
const db = await getDb()
|
|
72
|
-
const result = await db.delete(todos).returning()
|
|
108
|
+
const result = (await db.delete(todos).returning()) as unknown[]
|
|
73
109
|
return { deletedCount: result.length }
|
|
74
110
|
}
|
|
75
111
|
|
|
@@ -81,6 +117,8 @@ export async function getRecentActivity(limit: number = 10): Promise<
|
|
|
81
117
|
updatedAt: string
|
|
82
118
|
}>
|
|
83
119
|
> {
|
|
120
|
+
const todos = await getTodosTable()
|
|
121
|
+
if (!todos) return []
|
|
84
122
|
const db = await getDb()
|
|
85
123
|
const results = await db.select().from(todos).orderBy(desc(todos.updatedAt)).limit(limit)
|
|
86
124
|
|
|
@@ -98,17 +136,31 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|
|
98
136
|
const mockUsers = getMockUsers()
|
|
99
137
|
const user = mockUsers.find(u => u.username === data.username)
|
|
100
138
|
|
|
101
|
-
if (!user
|
|
139
|
+
if (!user) {
|
|
140
|
+
throw new Error('Invalid credentials')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const passwordHashes = getUserPasswordHashes()
|
|
144
|
+
const storedHash = passwordHashes.get(user.id)
|
|
145
|
+
const passwordMatch = storedHash
|
|
146
|
+
? await bcrypt.compare(data.password, storedHash)
|
|
147
|
+
: await bcrypt.compare(data.password, MOCK_PASSWORD_HASH)
|
|
148
|
+
|
|
149
|
+
if (!passwordMatch) {
|
|
102
150
|
throw new Error('Invalid credentials')
|
|
103
151
|
}
|
|
104
152
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
153
|
+
const mockTokens = getMockTokens()
|
|
154
|
+
let token: string | undefined
|
|
155
|
+
for (const [t, userId] of mockTokens) {
|
|
156
|
+
if (userId === user.id) {
|
|
157
|
+
token = t
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!token) {
|
|
162
|
+
token = `test-token-${user.id}-${Date.now()}`
|
|
163
|
+
mockTokens.set(token, user.id)
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
return {
|
|
@@ -132,6 +184,8 @@ export async function register(data: RegisterRequest): Promise<User> {
|
|
|
132
184
|
throw new Error('User already exists')
|
|
133
185
|
}
|
|
134
186
|
|
|
187
|
+
const passwordHash = await bcrypt.hash(data.password, 10)
|
|
188
|
+
|
|
135
189
|
const newUser: User = {
|
|
136
190
|
id: String(mockUsers.length + 1),
|
|
137
191
|
username: data.username,
|
|
@@ -144,6 +198,7 @@ export async function register(data: RegisterRequest): Promise<User> {
|
|
|
144
198
|
}
|
|
145
199
|
|
|
146
200
|
mockUsers.push(newUser)
|
|
201
|
+
getUserPasswordHashes().set(newUser.id, passwordHash)
|
|
147
202
|
|
|
148
203
|
return newUser
|
|
149
204
|
}
|
|
@@ -221,6 +276,8 @@ export async function getAllTodos(): Promise<
|
|
|
221
276
|
createdAt: string
|
|
222
277
|
}>
|
|
223
278
|
> {
|
|
279
|
+
const todos = await getTodosTable()
|
|
280
|
+
if (!todos) return []
|
|
224
281
|
const db = await getDb()
|
|
225
282
|
const results = await db.select().from(todos).orderBy(desc(todos.createdAt))
|
|
226
283
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import * as authService from '../services/auth-service'
|
|
3
|
+
import { getRawClient, getDb } from '@server/db'
|
|
4
|
+
import { setupTestDatabase, cleanupTestDatabase } from '@server/db/test-setup'
|
|
5
|
+
import { hashSync } from 'bcryptjs'
|
|
6
|
+
|
|
7
|
+
describe('Auth Service', () => {
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
await setupTestDatabase()
|
|
10
|
+
const db = await getDb()
|
|
11
|
+
expect(db).toBeDefined()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterAll(async () => {
|
|
15
|
+
await cleanupTestDatabase()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
const client = await getRawClient()
|
|
20
|
+
if (client && 'execute' in client) {
|
|
21
|
+
await client.execute('DELETE FROM developers')
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
const client = await getRawClient()
|
|
27
|
+
if (client && 'execute' in client) {
|
|
28
|
+
await client.execute('DELETE FROM developers')
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function insertTestDeveloper(overrides: Record<string, unknown> = {}) {
|
|
33
|
+
const client = await getRawClient()
|
|
34
|
+
if (!client || !('execute' in client)) throw new Error('No DB client')
|
|
35
|
+
|
|
36
|
+
const id = (overrides.id as string) || 'dev-test-id'
|
|
37
|
+
const username = (overrides.username as string) || 'testuser'
|
|
38
|
+
const email = (overrides.email as string) || 'test@example.com'
|
|
39
|
+
const passwordHash = (overrides.passwordHash as string) || hashSync('password123', 10)
|
|
40
|
+
const apiKey = (overrides.apiKey as string) || 'ak_test-api-key'
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
|
|
43
|
+
await client.execute({
|
|
44
|
+
sql: `INSERT INTO developers (id, username, email, password_hash, role, api_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
45
|
+
args: [id, username, email, passwordHash, 'developer', apiKey, now, now],
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return { id, username, email, apiKey, passwordHash }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('registerDeveloper', () => {
|
|
52
|
+
it('should register a new developer successfully', async () => {
|
|
53
|
+
const result = await authService.registerDeveloper({
|
|
54
|
+
username: 'newdev',
|
|
55
|
+
email: 'newdev@example.com',
|
|
56
|
+
password: 'password123',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.profile).toBeDefined()
|
|
60
|
+
expect(result.profile.username).toBe('newdev')
|
|
61
|
+
expect(result.profile.email).toBe('newdev@example.com')
|
|
62
|
+
expect(result.profile.role).toBe('developer')
|
|
63
|
+
expect(result.profile.id).toBeDefined()
|
|
64
|
+
expect(result.profile.createdAt).toBeDefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should hash the password with bcrypt', async () => {
|
|
68
|
+
await authService.registerDeveloper({
|
|
69
|
+
username: 'hashtest',
|
|
70
|
+
email: 'hashtest@example.com',
|
|
71
|
+
password: 'mypassword',
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const client = await getRawClient()
|
|
75
|
+
if (!client || !('execute' in client)) throw new Error('No DB')
|
|
76
|
+
const result = await client.execute(
|
|
77
|
+
'SELECT password_hash FROM developers WHERE username = ?',
|
|
78
|
+
['hashtest']
|
|
79
|
+
)
|
|
80
|
+
const row = result.rows[0] as unknown as { password_hash: string }
|
|
81
|
+
|
|
82
|
+
expect(row.password_hash).not.toBe('mypassword')
|
|
83
|
+
expect(row.password_hash.startsWith('$2')).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should throw ConflictError on duplicate email', async () => {
|
|
87
|
+
await insertTestDeveloper({ email: 'duplicate@example.com' })
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
authService.registerDeveloper({
|
|
91
|
+
username: 'another',
|
|
92
|
+
email: 'duplicate@example.com',
|
|
93
|
+
password: 'password123',
|
|
94
|
+
})
|
|
95
|
+
).rejects.toThrow('Duplicate entry')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should throw ConflictError on duplicate username', async () => {
|
|
99
|
+
await insertTestDeveloper({ username: 'duplicateuser' })
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
authService.registerDeveloper({
|
|
103
|
+
username: 'duplicateuser',
|
|
104
|
+
email: 'other@example.com',
|
|
105
|
+
password: 'password123',
|
|
106
|
+
})
|
|
107
|
+
).rejects.toThrow('Duplicate entry')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should generate unique id and api_key', async () => {
|
|
111
|
+
const r1 = await authService.registerDeveloper({
|
|
112
|
+
username: 'user1',
|
|
113
|
+
email: 'user1@example.com',
|
|
114
|
+
password: 'password123',
|
|
115
|
+
})
|
|
116
|
+
const r2 = await authService.registerDeveloper({
|
|
117
|
+
username: 'user2',
|
|
118
|
+
email: 'user2@example.com',
|
|
119
|
+
password: 'password123',
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(r1.profile.id).not.toBe(r2.profile.id)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('loginDeveloper', () => {
|
|
127
|
+
it('should login with email', async () => {
|
|
128
|
+
await insertTestDeveloper({
|
|
129
|
+
email: 'login@example.com',
|
|
130
|
+
username: 'loginuser',
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const result = await authService.loginDeveloper({
|
|
134
|
+
email: 'login@example.com',
|
|
135
|
+
password: 'password123',
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(result.profile).toBeDefined()
|
|
139
|
+
expect(result.profile.email).toBe('login@example.com')
|
|
140
|
+
expect(result.profile.username).toBe('loginuser')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should login with username via account field', async () => {
|
|
144
|
+
await insertTestDeveloper({
|
|
145
|
+
email: 'account@example.com',
|
|
146
|
+
username: 'accountuser',
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const result = await authService.loginDeveloper({
|
|
150
|
+
account: 'accountuser',
|
|
151
|
+
password: 'password123',
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(result.profile).toBeDefined()
|
|
155
|
+
expect(result.profile.username).toBe('accountuser')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('should login with email via account field', async () => {
|
|
159
|
+
await insertTestDeveloper({
|
|
160
|
+
email: 'accountemail@example.com',
|
|
161
|
+
username: 'accountemailuser',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const result = await authService.loginDeveloper({
|
|
165
|
+
account: 'accountemail@example.com',
|
|
166
|
+
password: 'password123',
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(result.profile).toBeDefined()
|
|
170
|
+
expect(result.profile.email).toBe('accountemail@example.com')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should throw AuthenticationError on wrong password', async () => {
|
|
174
|
+
await insertTestDeveloper({ email: 'wrongpw@example.com' })
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
authService.loginDeveloper({
|
|
178
|
+
email: 'wrongpw@example.com',
|
|
179
|
+
password: 'wrongpassword',
|
|
180
|
+
})
|
|
181
|
+
).rejects.toThrow('Invalid credentials')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should throw AuthenticationError when user not found', async () => {
|
|
185
|
+
await expect(
|
|
186
|
+
authService.loginDeveloper({
|
|
187
|
+
email: 'nonexistent@example.com',
|
|
188
|
+
password: 'password123',
|
|
189
|
+
})
|
|
190
|
+
).rejects.toThrow('Invalid credentials')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('should not return passwordHash in profile', async () => {
|
|
194
|
+
await insertTestDeveloper({ email: 'safe@example.com', username: 'safeuser' })
|
|
195
|
+
|
|
196
|
+
const result = await authService.loginDeveloper({
|
|
197
|
+
email: 'safe@example.com',
|
|
198
|
+
password: 'password123',
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
const profile = result.profile as Record<string, unknown>
|
|
202
|
+
expect(profile).not.toHaveProperty('passwordHash')
|
|
203
|
+
expect(profile).not.toHaveProperty('password_hash')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('verifyApiKey', () => {
|
|
208
|
+
it('should return profile for valid API key', async () => {
|
|
209
|
+
await insertTestDeveloper({ apiKey: 'ak_valid-key' })
|
|
210
|
+
|
|
211
|
+
const profile = await authService.verifyApiKey('ak_valid-key')
|
|
212
|
+
|
|
213
|
+
expect(profile).toBeDefined()
|
|
214
|
+
expect(profile.username).toBe('testuser')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('should throw AuthenticationError for invalid API key', async () => {
|
|
218
|
+
await expect(authService.verifyApiKey('ak_invalid-key')).rejects.toThrow('Invalid API key')
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('getDeveloperById', () => {
|
|
223
|
+
it('should return profile when developer exists', async () => {
|
|
224
|
+
await insertTestDeveloper({ id: 'dev-find-me' })
|
|
225
|
+
|
|
226
|
+
const profile = await authService.getDeveloperById('dev-find-me')
|
|
227
|
+
|
|
228
|
+
expect(profile).not.toBeNull()
|
|
229
|
+
expect(profile!.id).toBe('dev-find-me')
|
|
230
|
+
expect(profile!.username).toBe('testuser')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should return null when developer does not exist', async () => {
|
|
234
|
+
const profile = await authService.getDeveloperById('nonexistent-id')
|
|
235
|
+
|
|
236
|
+
expect(profile).toBeNull()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ModuleManifest } from '@shared/core/module-manifest'
|
|
2
|
+
|
|
3
|
+
const authManifest: ModuleManifest = {
|
|
4
|
+
name: 'auth',
|
|
5
|
+
description: 'Developer authentication with registration, login, and API key management',
|
|
6
|
+
category: 'system',
|
|
7
|
+
dependsOn: [],
|
|
8
|
+
|
|
9
|
+
routes: {
|
|
10
|
+
client: [
|
|
11
|
+
{
|
|
12
|
+
importPath: './routes/auth-routes',
|
|
13
|
+
exportName: 'authRoutes',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
importPath: './routes/profile-routes',
|
|
17
|
+
exportName: 'profileRoutes',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
sharedSchemas: {
|
|
23
|
+
path: 'auth',
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
clientStores: ['authStore'],
|
|
27
|
+
|
|
28
|
+
clientPages: [
|
|
29
|
+
{ name: 'LoginPage', route: '/login' },
|
|
30
|
+
{ name: 'RegisterPage', route: '/register' },
|
|
31
|
+
{ name: 'ProfilePage', route: '/profile' },
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
dbSchemas: {
|
|
35
|
+
files: ['developers'],
|
|
36
|
+
hasSeed: false,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default authManifest
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import * as authService from '../services/auth-service'
|
|
4
|
+
import {
|
|
5
|
+
DeveloperProfileSchema,
|
|
6
|
+
RegisterSchema,
|
|
7
|
+
LoginSchema,
|
|
8
|
+
TokenResponseSchema,
|
|
9
|
+
} from '@shared/modules/auth'
|
|
10
|
+
import { successResponse, errorResponse } from '@server/utils/route-helpers'
|
|
11
|
+
import { success, created } from '@server/utils/route-helpers'
|
|
12
|
+
import { ValidationError } from '@server/utils/app-error'
|
|
13
|
+
|
|
14
|
+
import jwt from 'jsonwebtoken'
|
|
15
|
+
import type { DeveloperProfile } from '@shared/modules/auth'
|
|
16
|
+
|
|
17
|
+
const secretKey = process.env.AUTH_SECRET_KEY || 'dev-secret-key-change-in-production'
|
|
18
|
+
|
|
19
|
+
function signToken(profile: DeveloperProfile): string {
|
|
20
|
+
return jwt.sign(
|
|
21
|
+
{
|
|
22
|
+
userId: profile.id,
|
|
23
|
+
username: profile.username,
|
|
24
|
+
email: profile.email,
|
|
25
|
+
role: profile.role,
|
|
26
|
+
},
|
|
27
|
+
secretKey,
|
|
28
|
+
{ expiresIn: '7d' }
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const registerRoute = createRoute({
|
|
33
|
+
method: 'post',
|
|
34
|
+
path: '/auth/register',
|
|
35
|
+
tags: ['auth'],
|
|
36
|
+
request: {
|
|
37
|
+
body: {
|
|
38
|
+
content: { 'application/json': { schema: RegisterSchema } },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
responses: {
|
|
42
|
+
201: successResponse(TokenResponseSchema, 'Register a new developer'),
|
|
43
|
+
409: errorResponse('Email or username already exists'),
|
|
44
|
+
400: errorResponse('Invalid input'),
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const loginRoute = createRoute({
|
|
49
|
+
method: 'post',
|
|
50
|
+
path: '/auth/login',
|
|
51
|
+
tags: ['auth'],
|
|
52
|
+
request: {
|
|
53
|
+
body: {
|
|
54
|
+
content: { 'application/json': { schema: LoginSchema } },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
responses: {
|
|
58
|
+
200: successResponse(TokenResponseSchema, 'Login and get token'),
|
|
59
|
+
401: errorResponse('Invalid credentials'),
|
|
60
|
+
400: errorResponse('Invalid input'),
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const verifyRoute = createRoute({
|
|
65
|
+
method: 'get',
|
|
66
|
+
path: '/auth/verify',
|
|
67
|
+
tags: ['auth'],
|
|
68
|
+
responses: {
|
|
69
|
+
200: successResponse(DeveloperProfileSchema, 'Verify token and get profile'),
|
|
70
|
+
401: errorResponse('Unauthorized'),
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export const authRoutes = new OpenAPIHono()
|
|
75
|
+
.openapi(registerRoute, async c => {
|
|
76
|
+
const data = c.req.valid('json')
|
|
77
|
+
const { profile } = await authService.registerDeveloper(data)
|
|
78
|
+
const token = signToken(profile)
|
|
79
|
+
return c.json(created({ token, profile }), 201)
|
|
80
|
+
})
|
|
81
|
+
.openapi(loginRoute, async c => {
|
|
82
|
+
const data = c.req.valid('json')
|
|
83
|
+
const { profile } = await authService.loginDeveloper(data)
|
|
84
|
+
const token = signToken(profile)
|
|
85
|
+
return c.json(success({ token, profile }), 200)
|
|
86
|
+
})
|
|
87
|
+
.openapi(verifyRoute, async c => {
|
|
88
|
+
const user = c.get('authUser')
|
|
89
|
+
const profile = await authService.getDeveloperById(user.id)
|
|
90
|
+
if (!profile) {
|
|
91
|
+
throw new ValidationError('Developer profile not found')
|
|
92
|
+
}
|
|
93
|
+
return c.json(success(profile), 200)
|
|
94
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRoute } from '@hono/zod-openapi'
|
|
2
|
+
import { OpenAPIHono } from '@hono/zod-openapi'
|
|
3
|
+
import { successResponse } from '@server/utils/route-helpers'
|
|
4
|
+
import { ProfileSchema } from '@shared/schemas'
|
|
5
|
+
|
|
6
|
+
const getProfileRoute = createRoute({
|
|
7
|
+
method: 'get',
|
|
8
|
+
path: '/profile',
|
|
9
|
+
responses: {
|
|
10
|
+
200: successResponse(ProfileSchema, 'Get user profile'),
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export const profileRoutes = new OpenAPIHono().openapi(getProfileRoute, async c => {
|
|
15
|
+
return c.json({
|
|
16
|
+
success: true as const,
|
|
17
|
+
data: {
|
|
18
|
+
id: 'user-1',
|
|
19
|
+
username: 'Demo User',
|
|
20
|
+
email: 'demo@example.com',
|
|
21
|
+
bio: 'A demo user profile',
|
|
22
|
+
joinDate: new Date().toISOString(),
|
|
23
|
+
stats: {
|
|
24
|
+
posts: 12,
|
|
25
|
+
followers: 48,
|
|
26
|
+
following: 25,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
timestamp: new Date().toISOString(),
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { eq, or } from 'drizzle-orm'
|
|
2
|
+
import { hashSync, compareSync } from 'bcryptjs'
|
|
3
|
+
import type { DeveloperProfile, LoginInput, RegisterInput } from '@shared/modules/auth'
|
|
4
|
+
import { getDb } from '@server/db'
|
|
5
|
+
import { developers, type DeveloperTable } from '@server/db/schema'
|
|
6
|
+
import { ConflictError, AuthenticationError } from '@server/utils/app-error'
|
|
7
|
+
import { generateUUID } from '@server/utils/uuid'
|
|
8
|
+
import { toISOString } from '@server/utils/date'
|
|
9
|
+
|
|
10
|
+
// eslint-disable-next-line local-rules/no-util-functions-in-service -- module-specific row-to-profile mapping
|
|
11
|
+
function toProfile(row: DeveloperTable): DeveloperProfile {
|
|
12
|
+
return {
|
|
13
|
+
id: row.id,
|
|
14
|
+
username: row.username,
|
|
15
|
+
email: row.email,
|
|
16
|
+
role: row.role,
|
|
17
|
+
createdAt: toISOString(row.createdAt),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function registerDeveloper(
|
|
22
|
+
input: RegisterInput
|
|
23
|
+
): Promise<{ profile: DeveloperProfile }> {
|
|
24
|
+
const db = await getDb()
|
|
25
|
+
|
|
26
|
+
const existing = await db
|
|
27
|
+
.select()
|
|
28
|
+
.from(developers)
|
|
29
|
+
.where(or(eq(developers.email, input.email), eq(developers.username, input.username)))
|
|
30
|
+
|
|
31
|
+
if (existing.length > 0) {
|
|
32
|
+
const field = existing[0].email === input.email ? 'email' : 'username'
|
|
33
|
+
throw ConflictError.duplicateEntry(field, input[field as 'email' | 'username'] ?? '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const id = generateUUID()
|
|
37
|
+
const apiKey = `ak_${generateUUID()}`
|
|
38
|
+
const passwordHash = hashSync(input.password, 10)
|
|
39
|
+
const now = new Date()
|
|
40
|
+
|
|
41
|
+
await db.insert(developers).values({
|
|
42
|
+
id,
|
|
43
|
+
username: input.username,
|
|
44
|
+
email: input.email,
|
|
45
|
+
passwordHash,
|
|
46
|
+
apiKey,
|
|
47
|
+
createdAt: now,
|
|
48
|
+
updatedAt: now,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const rows = await db.select().from(developers).where(eq(developers.id, id))
|
|
52
|
+
return { profile: toProfile(rows[0]) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loginDeveloper(input: LoginInput): Promise<{ profile: DeveloperProfile }> {
|
|
56
|
+
const db = await getDb()
|
|
57
|
+
|
|
58
|
+
const identifier = input.account || input.email
|
|
59
|
+
if (!identifier) {
|
|
60
|
+
throw new AuthenticationError('Account or email is required')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rows = await db
|
|
64
|
+
.select()
|
|
65
|
+
.from(developers)
|
|
66
|
+
.where(or(eq(developers.email, identifier), eq(developers.username, identifier)))
|
|
67
|
+
|
|
68
|
+
if (rows.length === 0) {
|
|
69
|
+
throw new AuthenticationError('Invalid credentials')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const developer = rows[0]
|
|
73
|
+
if (!compareSync(input.password, developer.passwordHash)) {
|
|
74
|
+
throw new AuthenticationError('Invalid credentials')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { profile: toProfile(developer) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function verifyApiKey(apiKey: string): Promise<DeveloperProfile> {
|
|
81
|
+
const db = await getDb()
|
|
82
|
+
|
|
83
|
+
const rows = await db.select().from(developers).where(eq(developers.apiKey, apiKey))
|
|
84
|
+
|
|
85
|
+
if (rows.length === 0) {
|
|
86
|
+
throw new AuthenticationError('Invalid API key')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return toProfile(rows[0])
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function getDeveloperById(id: string): Promise<DeveloperProfile | null> {
|
|
93
|
+
const db = await getDb()
|
|
94
|
+
|
|
95
|
+
const rows = await db.select().from(developers).where(eq(developers.id, id))
|
|
96
|
+
|
|
97
|
+
if (rows.length === 0) return null
|
|
98
|
+
|
|
99
|
+
return toProfile(rows[0])
|
|
100
|
+
}
|
|
@@ -7,10 +7,16 @@ const contentManifest: ModuleManifest = {
|
|
|
7
7
|
dependsOn: ['permission'],
|
|
8
8
|
|
|
9
9
|
routes: {
|
|
10
|
-
client:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
client: [
|
|
11
|
+
{
|
|
12
|
+
importPath: './routes/public-content-routes',
|
|
13
|
+
exportName: 'publicContentRoutes',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
importPath: './routes/topics-routes',
|
|
17
|
+
exportName: 'topicsRoutes',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
14
20
|
admin: [
|
|
15
21
|
{
|
|
16
22
|
importPath: './routes/content-routes',
|