@assistkick/create 1.0.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 (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Auth routes — registration, login, and user info.
3
+ * POST /api/auth/register — only available when no users exist.
4
+ * POST /api/auth/login — email/password login, returns JWT cookies.
5
+ * GET /api/auth/me — returns current user info from JWT cookie.
6
+ */
7
+
8
+ import { Router } from 'express';
9
+ import { eq, count } from 'drizzle-orm';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { users, refreshTokens } from '@interview-system/shared/db/schema.js';
12
+ import type { AuthService } from '../services/auth_service.js';
13
+ import type { PasswordResetService } from '../services/password_reset_service.js';
14
+ import type { InvitationService } from '../services/invitation_service.js';
15
+
16
+ interface AuthRoutesDeps {
17
+ getDb: () => any;
18
+ authService: AuthService;
19
+ passwordResetService: PasswordResetService;
20
+ invitationService: InvitationService;
21
+ log: (tag: string, ...args: any[]) => void;
22
+ }
23
+
24
+ export const createAuthRoutes = ({ getDb, authService, passwordResetService, invitationService, log }: AuthRoutesDeps): Router => {
25
+ const router: Router = Router();
26
+
27
+ // POST /api/auth/register
28
+ router.post('/register', async (req, res) => {
29
+ const { email, password } = req.body;
30
+ log('AUTH', 'POST /api/auth/register');
31
+
32
+ if (!email || !password) {
33
+ return res.status(400).json({ error: 'Email and password are required' });
34
+ }
35
+
36
+ if (typeof email !== 'string' || typeof password !== 'string') {
37
+ return res.status(400).json({ error: 'Email and password must be strings' });
38
+ }
39
+
40
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
41
+ if (!emailRegex.test(email)) {
42
+ return res.status(400).json({ error: 'Invalid email format' });
43
+ }
44
+
45
+ if (password.length < 8) {
46
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
47
+ }
48
+
49
+ try {
50
+ const db = getDb();
51
+
52
+ // Check if any users exist — registration is only open for the first user
53
+ const [{ userCount }] = await db.select({ userCount: count() }).from(users);
54
+ if (userCount > 0) {
55
+ log('AUTH', 'Registration rejected — users already exist');
56
+ return res.status(403).json({ error: 'Registration is closed. Contact an admin for an invitation.' });
57
+ }
58
+
59
+ // Check for duplicate email (defensive — shouldn't happen if count is 0)
60
+ const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email.toLowerCase()));
61
+ if (existing.length > 0) {
62
+ return res.status(409).json({ error: 'Email already registered' });
63
+ }
64
+
65
+ const now = new Date().toISOString();
66
+ const userId = randomUUID();
67
+ const passwordHash = await authService.hashPassword(password);
68
+
69
+ // First user gets admin role
70
+ await db.insert(users).values({
71
+ id: userId,
72
+ email: email.toLowerCase(),
73
+ passwordHash,
74
+ role: 'admin',
75
+ createdAt: now,
76
+ updatedAt: now,
77
+ });
78
+
79
+ log('AUTH', `User registered: ${email} (admin, first user)`);
80
+
81
+ // Generate tokens
82
+ const accessToken = await authService.generateAccessToken(userId, email.toLowerCase(), 'admin');
83
+ const refreshToken = await authService.generateRefreshToken(userId);
84
+
85
+ // Store refresh token hash in DB for revocation support
86
+ const refreshTokenHash = await authService.hashPassword(refreshToken);
87
+ const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
88
+
89
+ await db.insert(refreshTokens).values({
90
+ id: randomUUID(),
91
+ userId,
92
+ tokenHash: refreshTokenHash,
93
+ expiresAt: refreshExpiresAt,
94
+ createdAt: now,
95
+ });
96
+
97
+ // Set HTTP-only cookies
98
+ authService.setAuthCookies(res, accessToken, refreshToken);
99
+
100
+ res.status(201).json({
101
+ user: { id: userId, email: email.toLowerCase(), role: 'admin' },
102
+ });
103
+ } catch (err: any) {
104
+ log('AUTH', `Registration failed: ${err.message}`);
105
+ res.status(500).json({ error: 'Registration failed' });
106
+ }
107
+ });
108
+
109
+ // POST /api/auth/login
110
+ router.post('/login', async (req, res) => {
111
+ const { email, password } = req.body;
112
+ log('AUTH', 'POST /api/auth/login');
113
+
114
+ if (!email || !password) {
115
+ return res.status(400).json({ error: 'Email and password are required' });
116
+ }
117
+
118
+ if (typeof email !== 'string' || typeof password !== 'string') {
119
+ return res.status(400).json({ error: 'Email and password must be strings' });
120
+ }
121
+
122
+ try {
123
+ const db = getDb();
124
+
125
+ const [user] = await db
126
+ .select({
127
+ id: users.id,
128
+ email: users.email,
129
+ passwordHash: users.passwordHash,
130
+ role: users.role,
131
+ })
132
+ .from(users)
133
+ .where(eq(users.email, email.toLowerCase()));
134
+
135
+ if (!user) {
136
+ return res.status(401).json({ error: 'Invalid email or password' });
137
+ }
138
+
139
+ const passwordValid = await authService.verifyPassword(password, user.passwordHash);
140
+ if (!passwordValid) {
141
+ return res.status(401).json({ error: 'Invalid email or password' });
142
+ }
143
+
144
+ // Generate tokens
145
+ const accessToken = await authService.generateAccessToken(user.id, user.email, user.role);
146
+ const refreshToken = await authService.generateRefreshToken(user.id);
147
+
148
+ // Store refresh token hash in DB for revocation support
149
+ const refreshTokenHash = await authService.hashPassword(refreshToken);
150
+ const now = new Date().toISOString();
151
+ const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
152
+
153
+ await db.insert(refreshTokens).values({
154
+ id: randomUUID(),
155
+ userId: user.id,
156
+ tokenHash: refreshTokenHash,
157
+ expiresAt: refreshExpiresAt,
158
+ createdAt: now,
159
+ });
160
+
161
+ // Set HTTP-only cookies
162
+ authService.setAuthCookies(res, accessToken, refreshToken);
163
+
164
+ log('AUTH', `User logged in: ${user.email}`);
165
+
166
+ res.json({
167
+ user: { id: user.id, email: user.email, role: user.role },
168
+ });
169
+ } catch (err: any) {
170
+ log('AUTH', `Login failed: ${err.message}`);
171
+ res.status(500).json({ error: 'Login failed' });
172
+ }
173
+ });
174
+
175
+ // GET /api/auth/me — returns current user info from JWT cookie
176
+ router.get('/me', async (req, res) => {
177
+ const token = (req as any).cookies?.['access_token'];
178
+ if (!token) {
179
+ return res.status(401).json({ error: 'Not authenticated' });
180
+ }
181
+
182
+ try {
183
+ const payload = await authService.verifyToken(token);
184
+ return res.json({ user: { id: payload.sub, email: payload.email, role: payload.role } });
185
+ } catch {
186
+ return res.status(401).json({ error: 'Invalid or expired token' });
187
+ }
188
+ });
189
+
190
+ // POST /api/auth/refresh — exchange refresh token for new access token
191
+ router.post('/refresh', async (req, res) => {
192
+ const refreshTokenCookie = (req as any).cookies?.['refresh_token'];
193
+ if (!refreshTokenCookie) {
194
+ return res.status(401).json({ error: 'No refresh token' });
195
+ }
196
+
197
+ try {
198
+ const payload = await authService.verifyToken(refreshTokenCookie);
199
+ if (payload.type !== 'refresh') {
200
+ return res.status(401).json({ error: 'Invalid token type' });
201
+ }
202
+
203
+ const db = getDb();
204
+
205
+ // Find matching refresh token in DB (verify it hasn't been revoked)
206
+ const storedTokens = await db
207
+ .select()
208
+ .from(refreshTokens)
209
+ .where(eq(refreshTokens.userId, payload.sub));
210
+
211
+ let matchedToken: typeof storedTokens[0] | null = null;
212
+ for (const stored of storedTokens) {
213
+ const isMatch = await authService.verifyPassword(refreshTokenCookie, stored.tokenHash);
214
+ if (isMatch) {
215
+ matchedToken = stored;
216
+ break;
217
+ }
218
+ }
219
+
220
+ if (!matchedToken) {
221
+ return res.status(401).json({ error: 'Refresh token revoked' });
222
+ }
223
+
224
+ // Check if refresh token has expired in DB
225
+ if (new Date(matchedToken.expiresAt) < new Date()) {
226
+ await db.delete(refreshTokens).where(eq(refreshTokens.id, matchedToken.id));
227
+ return res.status(401).json({ error: 'Refresh token expired' });
228
+ }
229
+
230
+ // Look up user to get current email and role
231
+ const [user] = await db
232
+ .select({ id: users.id, email: users.email, role: users.role })
233
+ .from(users)
234
+ .where(eq(users.id, payload.sub));
235
+
236
+ if (!user) {
237
+ return res.status(401).json({ error: 'User not found' });
238
+ }
239
+
240
+ // Generate new access token
241
+ const accessToken = await authService.generateAccessToken(user.id, user.email, user.role);
242
+
243
+ // Set the new access token cookie
244
+ const cookieOptions = {
245
+ httpOnly: true,
246
+ secure: authService.isProductionMode,
247
+ sameSite: 'strict' as const,
248
+ maxAge: 30 * 60 * 1000, // 30 minutes
249
+ };
250
+ res.cookie('access_token', accessToken, cookieOptions);
251
+
252
+ log('AUTH', `Token refreshed for user: ${user.email}`);
253
+ res.json({ message: 'Token refreshed' });
254
+ } catch {
255
+ return res.status(401).json({ error: 'Invalid refresh token' });
256
+ }
257
+ });
258
+
259
+ // POST /api/auth/logout — clear auth cookies and delete refresh token from DB
260
+ router.post('/logout', async (req, res) => {
261
+ log('AUTH', 'POST /api/auth/logout');
262
+
263
+ // Try to delete refresh token from DB if we can identify the user
264
+ const refreshTokenCookie = (req as any).cookies?.['refresh_token'];
265
+ if (refreshTokenCookie) {
266
+ try {
267
+ const payload = await authService.verifyToken(refreshTokenCookie);
268
+ const db = getDb();
269
+
270
+ // Find and delete the matching refresh token
271
+ const storedTokens = await db
272
+ .select()
273
+ .from(refreshTokens)
274
+ .where(eq(refreshTokens.userId, payload.sub));
275
+
276
+ for (const stored of storedTokens) {
277
+ const isMatch = await authService.verifyPassword(refreshTokenCookie, stored.tokenHash);
278
+ if (isMatch) {
279
+ await db.delete(refreshTokens).where(eq(refreshTokens.id, stored.id));
280
+ break;
281
+ }
282
+ }
283
+ } catch {
284
+ // Token may be expired/invalid — still proceed with clearing cookies
285
+ }
286
+ }
287
+
288
+ authService.clearAuthCookies(res);
289
+ res.json({ message: 'Logged out' });
290
+ });
291
+
292
+ // POST /api/auth/forgot-password — request a password reset email
293
+ router.post('/forgot-password', async (req, res) => {
294
+ const { email } = req.body;
295
+ log('AUTH', 'POST /api/auth/forgot-password');
296
+
297
+ if (!email || typeof email !== 'string') {
298
+ return res.status(400).json({ error: 'Email is required' });
299
+ }
300
+
301
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
302
+ if (!emailRegex.test(email)) {
303
+ return res.status(400).json({ error: 'Invalid email format' });
304
+ }
305
+
306
+ try {
307
+ await passwordResetService.requestReset(email);
308
+ // Always return success to prevent user enumeration
309
+ res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
310
+ } catch (err: any) {
311
+ log('AUTH', `Forgot password failed: ${err.message}`);
312
+ res.status(500).json({ error: 'Failed to process reset request' });
313
+ }
314
+ });
315
+
316
+ // POST /api/auth/reset-password — confirm password reset with token
317
+ router.post('/reset-password', async (req, res) => {
318
+ const { token, password } = req.body;
319
+ log('AUTH', 'POST /api/auth/reset-password');
320
+
321
+ if (!token || typeof token !== 'string') {
322
+ return res.status(400).json({ error: 'Reset token is required' });
323
+ }
324
+
325
+ if (!password || typeof password !== 'string') {
326
+ return res.status(400).json({ error: 'New password is required' });
327
+ }
328
+
329
+ if (password.length < 8) {
330
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
331
+ }
332
+
333
+ try {
334
+ await passwordResetService.confirmReset(token, password);
335
+ res.json({ message: 'Password has been reset successfully.' });
336
+ } catch (err: any) {
337
+ log('AUTH', `Reset password failed: ${err.message}`);
338
+ if (err.message === 'Invalid or expired reset token') {
339
+ return res.status(400).json({ error: err.message });
340
+ }
341
+ res.status(500).json({ error: 'Failed to reset password' });
342
+ }
343
+ });
344
+
345
+ // POST /api/auth/invite — admin sends invitation email (requires auth + admin role)
346
+ router.post('/invite', async (req, res) => {
347
+ const { email } = req.body;
348
+ log('AUTH', 'POST /api/auth/invite');
349
+
350
+ // Verify JWT from cookie
351
+ const token = (req as any).cookies?.['access_token'];
352
+ if (!token) {
353
+ return res.status(401).json({ error: 'Not authenticated' });
354
+ }
355
+
356
+ let payload: { sub: string; email?: string; role?: string };
357
+ try {
358
+ payload = await authService.verifyToken(token);
359
+ } catch {
360
+ return res.status(401).json({ error: 'Invalid or expired token' });
361
+ }
362
+
363
+ // Check admin role
364
+ if (payload.role !== 'admin') {
365
+ return res.status(403).json({ error: 'Only admins can send invitations' });
366
+ }
367
+
368
+ if (!email || typeof email !== 'string') {
369
+ return res.status(400).json({ error: 'Email is required' });
370
+ }
371
+
372
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
373
+ if (!emailRegex.test(email)) {
374
+ return res.status(400).json({ error: 'Invalid email format' });
375
+ }
376
+
377
+ try {
378
+ await invitationService.sendInvitation(email, payload.sub);
379
+ res.json({ message: `Invitation sent to ${email.toLowerCase()}` });
380
+ } catch (err: any) {
381
+ log('AUTH', `Invite failed: ${err.message}`);
382
+ if (err.message.includes('already exists') || err.message.includes('active invitation')) {
383
+ return res.status(409).json({ error: err.message });
384
+ }
385
+ res.status(500).json({ error: 'Failed to send invitation' });
386
+ }
387
+ });
388
+
389
+ // GET /api/auth/invitation — validate an invitation token (public)
390
+ router.get('/invitation', async (req, res) => {
391
+ const { token, email } = req.query;
392
+ log('AUTH', 'GET /api/auth/invitation');
393
+
394
+ if (!token || typeof token !== 'string' || !email || typeof email !== 'string') {
395
+ return res.status(400).json({ error: 'Token and email are required' });
396
+ }
397
+
398
+ try {
399
+ const result = await invitationService.validateToken(token, email);
400
+ res.json(result);
401
+ } catch (err: any) {
402
+ log('AUTH', `Invitation validation failed: ${err.message}`);
403
+ res.status(500).json({ error: 'Failed to validate invitation' });
404
+ }
405
+ });
406
+
407
+ // POST /api/auth/accept-invitation — accept invitation and create account (public)
408
+ router.post('/accept-invitation', async (req, res) => {
409
+ const { token, email, password } = req.body;
410
+ log('AUTH', 'POST /api/auth/accept-invitation');
411
+
412
+ if (!token || typeof token !== 'string') {
413
+ return res.status(400).json({ error: 'Invitation token is required' });
414
+ }
415
+
416
+ if (!email || typeof email !== 'string') {
417
+ return res.status(400).json({ error: 'Email is required' });
418
+ }
419
+
420
+ if (!password || typeof password !== 'string') {
421
+ return res.status(400).json({ error: 'Password is required' });
422
+ }
423
+
424
+ if (password.length < 8) {
425
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
426
+ }
427
+
428
+ try {
429
+ const { userId, email: userEmail } = await invitationService.acceptInvitation(token, email, password);
430
+
431
+ // Generate tokens and log the user in
432
+ const accessToken = await authService.generateAccessToken(userId, userEmail, 'user');
433
+ const refreshToken = await authService.generateRefreshToken(userId);
434
+
435
+ const refreshTokenHash = await authService.hashPassword(refreshToken);
436
+ const now = new Date().toISOString();
437
+ const refreshExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
438
+
439
+ const db = getDb();
440
+ await db.insert(refreshTokens).values({
441
+ id: randomUUID(),
442
+ userId,
443
+ tokenHash: refreshTokenHash,
444
+ expiresAt: refreshExpiresAt,
445
+ createdAt: now,
446
+ });
447
+
448
+ authService.setAuthCookies(res, accessToken, refreshToken);
449
+
450
+ res.status(201).json({
451
+ user: { id: userId, email: userEmail, role: 'user' },
452
+ });
453
+ } catch (err: any) {
454
+ log('AUTH', `Accept invitation failed: ${err.message}`);
455
+ if (err.message === 'Invalid or expired invitation token' || err.message.includes('already exists')) {
456
+ return res.status(400).json({ error: err.message });
457
+ }
458
+ res.status(500).json({ error: 'Failed to accept invitation' });
459
+ }
460
+ });
461
+
462
+ return router;
463
+ };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Coherence review API routes — get/run/approve/dismiss proposals.
3
+ */
4
+
5
+ import { Router } from 'express';
6
+ import { detectConflicts } from '@interview-system/shared/lib/coherence.js';
7
+ import { log } from '../services/init.js';
8
+ import {
9
+ coherenceRunning,
10
+ coherenceProgress,
11
+ readCoherenceData,
12
+ writeCoherenceData,
13
+ runCoherenceReview,
14
+ executeProposalAction,
15
+ setCoherenceRunning,
16
+ setCoherenceProgress,
17
+ } from '../services/coherence-review.js';
18
+
19
+ const router: Router = Router();
20
+
21
+ // GET /api/coherence
22
+ router.get('/', async (req, res) => {
23
+ const projectId = req.query.project_id as string | undefined;
24
+ log('API', `GET /api/coherence${projectId ? ` project_id=${projectId}` : ''}`);
25
+ try {
26
+ const data: any = await readCoherenceData(projectId);
27
+ data.progress = coherenceRunning ? coherenceProgress : null;
28
+
29
+ const conflictsMap = detectConflicts(data.proposals);
30
+ const conflicts: Record<string, string[]> = {};
31
+ for (const [id, cSet] of conflictsMap) {
32
+ conflicts[id] = [...cSet];
33
+ }
34
+ data.conflicts = conflicts;
35
+
36
+ res.json(data);
37
+ } catch (err: any) {
38
+ log('API', `GET /api/coherence FAILED: ${err.message}`);
39
+ res.status(500).json({ error: 'Failed to read coherence data' });
40
+ }
41
+ });
42
+
43
+ // POST /api/coherence/run
44
+ router.post('/run', async (req, res) => {
45
+ const projectId = req.query.project_id as string | undefined || req.body.project_id;
46
+ log('API', `POST /api/coherence/run${projectId ? ` project_id=${projectId}` : ''}`);
47
+ if (coherenceRunning) {
48
+ return res.status(409).json({ error: 'Coherence review already running' });
49
+ }
50
+
51
+ try {
52
+ const data = await readCoherenceData(projectId);
53
+ const hasPending = data.proposals.some((p: any) => p.status === 'pending');
54
+ if (hasPending) {
55
+ return res.status(400).json({ error: 'Resolve all pending proposals before starting a new review' });
56
+ }
57
+ } catch (err: any) {
58
+ return res.status(500).json({ error: 'Failed to check coherence state' });
59
+ }
60
+
61
+ setCoherenceRunning(true);
62
+ // Fire and forget
63
+ runCoherenceReview(projectId).catch((err: any) => {
64
+ log('COHERENCE', `Uncaught error: ${err.message}`);
65
+ setCoherenceRunning(false);
66
+ setCoherenceProgress({ current: 0, total: 0, message: '' });
67
+ });
68
+ res.json({ started: true });
69
+ });
70
+
71
+ // POST /api/coherence/batch/approve
72
+ router.post('/batch/approve', async (req, res) => {
73
+ const projectId = req.query.project_id as string | undefined || req.body.project_id;
74
+ log('API', 'POST /api/coherence/batch/approve');
75
+ try {
76
+ const { batch_id, dismiss_ids = [] } = req.body;
77
+
78
+ if (!batch_id) {
79
+ return res.status(400).json({ error: 'Missing batch_id' });
80
+ }
81
+
82
+ const coherenceData = await readCoherenceData(projectId);
83
+ const batchProposals = coherenceData.proposals.filter(
84
+ (p: any) => p.batch_id === batch_id && p.status === 'pending'
85
+ );
86
+
87
+ if (batchProposals.length === 0) {
88
+ return res.status(404).json({ error: `No pending proposals in batch "${batch_id}"` });
89
+ }
90
+
91
+ const now = new Date().toISOString();
92
+ const results: any = { approved: [], dismissed: [] };
93
+
94
+ for (const p of batchProposals) {
95
+ if (dismiss_ids.includes(p.id)) {
96
+ p.status = 'dismissed';
97
+ p.resolved_at = now;
98
+ results.dismissed.push(p.id);
99
+ }
100
+ }
101
+
102
+ await writeCoherenceData(coherenceData, projectId);
103
+
104
+ const errors: any[] = [];
105
+ for (const p of batchProposals) {
106
+ if (p.status === 'pending') {
107
+ try {
108
+ await executeProposalAction(p, coherenceData, projectId);
109
+ p.status = 'approved';
110
+ p.resolved_at = now;
111
+ results.approved.push(p.id);
112
+ } catch (err: any) {
113
+ log('COHERENCE', `Batch: failed to execute proposal ${p.id}: ${err.message}`);
114
+ errors.push({ id: p.id, error: err.message });
115
+ }
116
+ }
117
+ }
118
+
119
+ await writeCoherenceData(coherenceData, projectId);
120
+ if (errors.length > 0) {
121
+ results.errors = errors;
122
+ }
123
+ res.json(results);
124
+ } catch (err: any) {
125
+ log('COHERENCE', `Batch approve error: ${err.message}`);
126
+ res.status(500).json({ error: err.message });
127
+ }
128
+ });
129
+
130
+ // POST /api/coherence/:id/approve
131
+ router.post('/:id/approve', async (req, res) => {
132
+ const proposalId = req.params.id;
133
+ const projectId = req.query.project_id as string | undefined || req.body.project_id;
134
+ log('API', `POST /api/coherence/${proposalId}/approve`);
135
+ try {
136
+ const coherenceData = await readCoherenceData(projectId);
137
+ const proposal = coherenceData.proposals.find((p: any) => p.id === proposalId);
138
+ if (!proposal) {
139
+ return res.status(404).json({ error: `Proposal "${proposalId}" not found` });
140
+ }
141
+ if (proposal.status !== 'pending') {
142
+ return res.status(400).json({ error: `Proposal already ${proposal.status}` });
143
+ }
144
+
145
+ try {
146
+ await executeProposalAction(proposal, coherenceData, projectId);
147
+ } catch (err: any) {
148
+ log('COHERENCE', `Failed to execute proposal action: ${err.message}`);
149
+ return res.status(500).json({ error: `Failed to execute proposal action: ${err.message}` });
150
+ }
151
+
152
+ proposal.status = 'approved';
153
+ proposal.resolved_at = new Date().toISOString();
154
+ await writeCoherenceData(coherenceData, projectId);
155
+ res.json({ approved: true, proposal });
156
+ } catch (err: any) {
157
+ log('COHERENCE', `Approve error: ${err.message}`);
158
+ res.status(500).json({ error: err.message });
159
+ }
160
+ });
161
+
162
+ // POST /api/coherence/:id/dismiss
163
+ router.post('/:id/dismiss', async (req, res) => {
164
+ const proposalId = req.params.id;
165
+ const projectId = req.query.project_id as string | undefined || req.body.project_id;
166
+ log('API', `POST /api/coherence/${proposalId}/dismiss`);
167
+ try {
168
+ const coherenceData = await readCoherenceData(projectId);
169
+ const proposal = coherenceData.proposals.find((p: any) => p.id === proposalId);
170
+ if (!proposal) {
171
+ return res.status(404).json({ error: `Proposal "${proposalId}" not found` });
172
+ }
173
+ if (proposal.status !== 'pending') {
174
+ return res.status(400).json({ error: `Proposal already ${proposal.status}` });
175
+ }
176
+
177
+ proposal.status = 'dismissed';
178
+ proposal.resolved_at = new Date().toISOString();
179
+ await writeCoherenceData(coherenceData, projectId);
180
+ res.json({ dismissed: true, proposal });
181
+ } catch (err: any) {
182
+ log('COHERENCE', `Dismiss error: ${err.message}`);
183
+ res.status(500).json({ error: err.message });
184
+ }
185
+ });
186
+
187
+ export default router;