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.
Files changed (155) hide show
  1. package/dist/cli/index.js +1673 -696
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +16 -10
  4. package/template/.husky/pre-commit +1 -1
  5. package/template/modules.config.ts +29 -2
  6. package/template/package.json +12 -12
  7. package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
  8. package/template/playwright.config.ts +7 -1
  9. package/template/src/admin/App.tsx +2 -2
  10. package/template/src/admin/components/CaptchaModal.tsx +7 -9
  11. package/template/src/admin/layouts/Header.tsx +56 -22
  12. package/template/src/admin/layouts/Layout.tsx +14 -9
  13. package/template/src/admin/layouts/Sidebar.tsx +122 -105
  14. package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
  15. package/template/src/admin/pages/ContentPage.tsx +2 -0
  16. package/template/src/admin/pages/DashboardPage.tsx +2 -0
  17. package/template/src/admin/pages/DisputesPage.tsx +2 -0
  18. package/template/src/admin/pages/MediaTestPage.tsx +1 -1
  19. package/template/src/admin/pages/OrdersPage.tsx +2 -0
  20. package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
  21. package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
  22. package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
  23. package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
  24. package/template/src/admin/pages/TicketsPage.tsx +2 -0
  25. package/template/src/admin/pages/UsersPage.tsx +3 -2
  26. package/template/src/admin/stores/adminStore.ts +5 -0
  27. package/template/src/cli/index.ts +17 -19
  28. package/template/src/cli/modules/auth/index.ts +65 -0
  29. package/template/src/cli/modules/config/index.ts +74 -77
  30. package/template/src/cli/modules/index.ts +28 -6
  31. package/template/src/cli/modules/notification/index.ts +95 -79
  32. package/template/src/cli/modules/plugin/index.ts +111 -0
  33. package/template/src/cli/modules/todo/index.ts +99 -51
  34. package/template/src/cli/utils/auto-command.ts +7 -25
  35. package/template/src/cli/utils/index.ts +3 -1
  36. package/template/src/client/App.tsx +36 -16
  37. package/template/src/client/Layout.tsx +67 -10
  38. package/template/src/client/components/AuthButton.tsx +25 -18
  39. package/template/src/client/components/BottomTabBar.tsx +107 -0
  40. package/template/src/client/components/Navigation.tsx +162 -52
  41. package/template/src/client/components/__tests__/App.test.tsx +48 -32
  42. package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
  43. package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
  44. package/template/src/client/components/index.ts +1 -0
  45. package/template/src/client/contexts/PresetContext.tsx +10 -0
  46. package/template/src/client/main.tsx +63 -8
  47. package/template/src/client/pages/CartPage.tsx +244 -0
  48. package/template/src/client/pages/CategoriesPage.tsx +100 -0
  49. package/template/src/client/pages/ContentDetailPage.tsx +9 -14
  50. package/template/src/client/pages/ContentListPage.tsx +4 -11
  51. package/template/src/client/pages/DashboardPage.tsx +261 -0
  52. package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
  53. package/template/src/client/pages/LoginPage.tsx +127 -0
  54. package/template/src/client/pages/OrdersPage.tsx +196 -0
  55. package/template/src/client/pages/PluginDetailPage.tsx +345 -0
  56. package/template/src/client/pages/PluginsPage.tsx +223 -0
  57. package/template/src/client/pages/ProfilePage.tsx +206 -0
  58. package/template/src/client/pages/PublishPage.tsx +336 -0
  59. package/template/src/client/pages/RegisterPage.tsx +136 -0
  60. package/template/src/client/pages/SearchPage.tsx +204 -0
  61. package/template/src/client/pages/SettingsPage.tsx +220 -0
  62. package/template/src/client/pages/TopicsPage.tsx +180 -0
  63. package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
  64. package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
  65. package/template/src/client/preset-ui-config.ts +492 -0
  66. package/template/src/client/services/apiClient.ts +14 -4
  67. package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
  68. package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
  69. package/template/src/client/stores/authStore.ts +58 -5
  70. package/template/src/client/stores/chatWSStore.ts +9 -0
  71. package/template/src/client/stores/notificationStore.ts +4 -0
  72. package/template/src/client/stores/pluginStore.ts +279 -0
  73. package/template/src/client/stores/todoStore.ts +2 -6
  74. package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
  75. package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
  76. package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
  77. package/template/src/server/core/isr-cache.ts +239 -0
  78. package/template/src/server/core/isr-invalidation.ts +45 -0
  79. package/template/src/server/core/module-loader.ts +14 -7
  80. package/template/src/server/core/ssr-renderer.ts +240 -0
  81. package/template/src/server/db/init.ts +257 -10
  82. package/template/src/server/db/schema/developers.ts +20 -0
  83. package/template/src/server/db/schema/index.ts +2 -0
  84. package/template/src/server/db/schema/plugins.ts +114 -0
  85. package/template/src/server/db/test-setup.ts +91 -0
  86. package/template/src/server/entries/cloudflare.ts +79 -7
  87. package/template/src/server/entries/node.ts +48 -5
  88. package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
  89. package/template/src/server/middleware/auth.ts +8 -1
  90. package/template/src/server/middleware/captcha.ts +14 -4
  91. package/template/src/server/middleware/rate-limit.ts +7 -2
  92. package/template/src/server/module-admin/module.ts +9 -5
  93. package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
  94. package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
  95. package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
  96. package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
  97. package/template/src/server/module-admin/services/admin-service.ts +68 -11
  98. package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
  99. package/template/src/server/module-auth/index.ts +7 -0
  100. package/template/src/server/module-auth/module.ts +40 -0
  101. package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
  102. package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
  103. package/template/src/server/module-auth/services/auth-service.ts +100 -0
  104. package/template/src/server/module-content/module.ts +10 -4
  105. package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
  106. package/template/src/server/module-content/routes/topics-routes.ts +205 -0
  107. package/template/src/server/module-content/services/content-service.ts +18 -2
  108. package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
  109. package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
  110. package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
  111. package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
  112. package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
  113. package/template/src/server/module-order/module.ts +15 -0
  114. package/template/src/server/module-order/routes/cart-routes.ts +103 -0
  115. package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
  116. package/template/src/server/module-order/services/order-service.ts +1 -1
  117. package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
  118. package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
  119. package/template/src/server/module-plugin/index.ts +2 -0
  120. package/template/src/server/module-plugin/module.ts +52 -0
  121. package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
  122. package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
  123. package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
  124. package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
  125. package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
  126. package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
  127. package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
  128. package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
  129. package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
  130. package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
  131. package/template/src/server/route-registry.ts +16 -1
  132. package/template/src/server/test-utils/test-client.ts +1 -2
  133. package/template/src/server/utils/auth.ts +6 -0
  134. package/template/src/server/utils/json.ts +13 -0
  135. package/template/src/shared/core/module-manifest.ts +3 -6
  136. package/template/src/shared/modules/auth/index.ts +12 -0
  137. package/template/src/shared/modules/auth/schemas.ts +50 -0
  138. package/template/src/shared/modules/cart/index.ts +1 -0
  139. package/template/src/shared/modules/cart/schemas.ts +41 -0
  140. package/template/src/shared/modules/community/index.ts +1 -0
  141. package/template/src/shared/modules/community/schemas.ts +58 -0
  142. package/template/src/shared/modules/dashboard/index.ts +1 -0
  143. package/template/src/shared/modules/dashboard/schemas.ts +35 -0
  144. package/template/src/shared/modules/index.ts +41 -0
  145. package/template/src/shared/modules/order/schemas.ts +29 -0
  146. package/template/src/shared/modules/plugins/index.ts +48 -0
  147. package/template/src/shared/modules/plugins/schemas.ts +227 -0
  148. package/template/src/shared/schemas/index.ts +130 -0
  149. package/template/tests/e2e/todo.spec.ts +23 -18
  150. package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
  151. package/template/uploads/.gitkeep +0 -0
  152. package/template/vite.config.ts +2 -1
  153. package/template/vitest.setup.ts +11 -0
  154. package/template/wrangler.toml +4 -3
  155. 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 || !(await bcrypt.compare(data.password, MOCK_PASSWORD_HASH))) {
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
- let token: string
106
- if (user.role === Role.SUPER_ADMIN) {
107
- token = `test-super-admin-${user.id}`
108
- } else if (user.role === Role.CUSTOMER_SERVICE) {
109
- token = `test-customer-service-${user.id}`
110
- } else {
111
- token = `test-user-${user.id}`
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,7 @@
1
+ export { authRoutes } from './routes/auth-routes'
2
+ export type {
3
+ DeveloperProfile,
4
+ LoginInput,
5
+ RegisterInput,
6
+ TokenResponse,
7
+ } from '@shared/modules/auth'
@@ -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
- importPath: './routes/public-content-routes',
12
- exportName: 'publicContentRoutes',
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',