@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.
- package/dist/bin/create.d.ts +2 -0
- package/dist/bin/create.js +25 -0
- package/dist/bin/create.js.map +1 -0
- package/dist/src/scaffolder.d.ts +22 -0
- package/dist/src/scaffolder.js +120 -0
- package/dist/src/scaffolder.js.map +1 -0
- package/package.json +24 -0
- package/templates/product-system/.env.example +8 -0
- package/templates/product-system/CLAUDE.md +45 -0
- package/templates/product-system/package.json +32 -0
- package/templates/product-system/packages/backend/package.json +37 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
- package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
- package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
- package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
- package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
- package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
- package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
- package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
- package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
- package/templates/product-system/packages/backend/src/server.ts +159 -0
- package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
- package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
- package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
- package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
- package/templates/product-system/packages/backend/src/services/init.ts +80 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
- package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
- package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
- package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
- package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
- package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
- package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
- package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
- package/templates/product-system/packages/backend/tsconfig.json +22 -0
- package/templates/product-system/packages/frontend/index.html +13 -0
- package/templates/product-system/packages/frontend/package-lock.json +2666 -0
- package/templates/product-system/packages/frontend/package.json +30 -0
- package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
- package/templates/product-system/packages/frontend/src/App.tsx +29 -0
- package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
- package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
- package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
- package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
- package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
- package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
- package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
- package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
- package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
- package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
- package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
- package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
- package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
- package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
- package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
- package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
- package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
- package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
- package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
- package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
- package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
- package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
- package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
- package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/main.tsx +12 -0
- package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
- package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
- package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
- package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
- package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
- package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
- package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
- package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
- package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
- package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
- package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
- package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
- package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
- package/templates/product-system/packages/frontend/tsconfig.json +21 -0
- package/templates/product-system/packages/frontend/vite.config.ts +20 -0
- package/templates/product-system/packages/shared/.env.example +3 -0
- package/templates/product-system/packages/shared/README.md +1 -0
- package/templates/product-system/packages/shared/db/migrate.ts +32 -0
- package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
- package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
- package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
- package/templates/product-system/packages/shared/db/schema.ts +137 -0
- package/templates/product-system/packages/shared/drizzle.config.js +14 -0
- package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
- package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
- package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
- package/templates/product-system/packages/shared/lib/constants.ts +327 -0
- package/templates/product-system/packages/shared/lib/db.ts +81 -0
- package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
- package/templates/product-system/packages/shared/lib/graph.ts +186 -0
- package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
- package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
- package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
- package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
- package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
- package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
- package/templates/product-system/packages/shared/lib/session.ts +152 -0
- package/templates/product-system/packages/shared/lib/validator.ts +117 -0
- package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
- package/templates/product-system/packages/shared/package.json +30 -0
- package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
- package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
- package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
- package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
- package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
- package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
- package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
- package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
- package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
- package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
- package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
- package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
- package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
- package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
- package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
- package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
- package/templates/product-system/packages/shared/tsconfig.json +24 -0
- package/templates/product-system/pnpm-workspace.yaml +2 -0
- package/templates/product-system/smoke_test.ts +219 -0
- package/templates/product-system/tests/coherence_review.test.ts +562 -0
- package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
- package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
- package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
- package/templates/product-system/tests/feature_kind.test.ts +139 -0
- package/templates/product-system/tests/gap_indicators.test.ts +199 -0
- package/templates/product-system/tests/graceful_init.test.ts +142 -0
- package/templates/product-system/tests/graph_legend.test.ts +314 -0
- package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
- package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
- package/templates/product-system/tests/kanban.test.ts +529 -0
- package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
- package/templates/product-system/tests/node_search.test.ts +340 -0
- package/templates/product-system/tests/node_sizing.test.ts +170 -0
- package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
- package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
- package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
- package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
- package/templates/product-system/tests/pipeline.test.ts +195 -0
- package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
- package/templates/product-system/tests/play_all.test.ts +296 -0
- package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
- package/templates/product-system/tests/relevance_search.test.ts +186 -0
- package/templates/product-system/tests/search_reorder.test.ts +88 -0
- package/templates/product-system/tests/serve_ui.test.ts +281 -0
- package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
- package/templates/product-system/tests/session_context_recall.test.ts +135 -0
- package/templates/product-system/tests/side_panel.test.ts +345 -0
- package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
- package/templates/product-system/tests/url_routing_test.ts +122 -0
- package/templates/product-system/tests/user_login.test.ts +150 -0
- package/templates/product-system/tests/user_registration.test.ts +205 -0
- package/templates/product-system/tests/web_terminal.test.ts +572 -0
- package/templates/product-system/tests/work_summary.test.ts +211 -0
- package/templates/product-system/tests/zoom_pan.test.ts +43 -0
- package/templates/product-system/tsconfig.json +24 -0
- package/templates/skills/product-bootstrap/SKILL.md +312 -0
- package/templates/skills/product-code-reviewer/SKILL.md +147 -0
- package/templates/skills/product-debugger/SKILL.md +206 -0
- package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
- package/templates/skills/product-developer/SKILL.md +182 -0
- 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;
|