upfynai-code 2.9.1 → 2.9.2

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 (229) hide show
  1. package/README.md +91 -66
  2. package/client/dist/api-docs.html +838 -0
  3. package/client/dist/assets/AppContent-BXZDeSIC.js +545 -0
  4. package/client/dist/assets/CanvasFullScreen-mnpCnLZ9.js +1 -0
  5. package/client/dist/assets/CanvasWorkspace-4CqmjAVQ.js +163 -0
  6. package/client/dist/assets/DashboardPanel-zFIFlw56.js +1 -0
  7. package/client/dist/assets/FileTree-B0c_GaB3.js +1 -0
  8. package/client/dist/assets/GitPanel-DUP4zVU4.js +2 -0
  9. package/client/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  10. package/client/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  11. package/client/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  12. package/client/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  13. package/client/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  14. package/client/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  15. package/client/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  16. package/client/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  17. package/client/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  18. package/client/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  19. package/client/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  20. package/client/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  21. package/client/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  22. package/client/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  23. package/client/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  24. package/client/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  25. package/client/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  26. package/client/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  27. package/client/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  28. package/client/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  29. package/client/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  30. package/client/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  31. package/client/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  32. package/client/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  33. package/client/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  34. package/client/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  35. package/client/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  36. package/client/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  37. package/client/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/client/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  39. package/client/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  40. package/client/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  41. package/client/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  42. package/client/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  43. package/client/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  44. package/client/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  45. package/client/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  46. package/client/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  47. package/client/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  48. package/client/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  49. package/client/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  50. package/client/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  51. package/client/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  52. package/client/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  53. package/client/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  54. package/client/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  55. package/client/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  56. package/client/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  57. package/client/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  58. package/client/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  59. package/client/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  60. package/client/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  61. package/client/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  62. package/client/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  63. package/client/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  64. package/client/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  65. package/client/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  66. package/client/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  67. package/client/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  68. package/client/dist/assets/LoginModal-BRycfsyD.js +13 -0
  69. package/client/dist/assets/MarkdownPreview-DHmk3qzu.js +1 -0
  70. package/client/dist/assets/MermaidBlock-BuBc_G-F.js +2 -0
  71. package/client/dist/assets/Onboarding-BcnaZZ0o.js +1 -0
  72. package/client/dist/assets/PreviewPanel-CqCa92Tf.js +32 -0
  73. package/client/dist/assets/SetupForm-S0g6u5yT.js +1 -0
  74. package/client/dist/assets/WorkflowsPanel-CouH9JDO.js +1 -0
  75. package/client/dist/assets/index-BFuqS0tY.css +1 -0
  76. package/client/dist/assets/index-CNDcVl2g.js +68 -0
  77. package/client/dist/assets/pdf-CE_K4jFx.js +12 -0
  78. package/client/dist/assets/vendor-canvas-BZV40eAE.css +1 -0
  79. package/client/dist/assets/vendor-canvas-D39yWul6.js +49 -0
  80. package/client/dist/assets/vendor-codemirror-CbtmxxaB.js +35 -0
  81. package/client/dist/assets/vendor-diff-DNQpbhrT.js +69 -0
  82. package/client/dist/assets/vendor-i18n-DCFGyhQR.js +1 -0
  83. package/client/dist/assets/vendor-icons-BaD0x9SL.js +711 -0
  84. package/client/dist/assets/vendor-markdown-CimbIo6Y.js +296 -0
  85. package/client/dist/assets/vendor-mermaid-CH7SGc99.js +2556 -0
  86. package/client/dist/assets/vendor-react-96lCPsRK.js +67 -0
  87. package/client/dist/assets/vendor-syntax-DuHI9Ok6.js +16 -0
  88. package/client/dist/assets/vendor-xterm-CZq1hqo1.js +66 -0
  89. package/client/dist/assets/vendor-xterm-qxJ8_QYu.css +32 -0
  90. package/client/dist/clear-cache.html +85 -0
  91. package/client/dist/convert-icons.md +53 -0
  92. package/client/dist/favicon.png +0 -0
  93. package/client/dist/favicon.svg +5 -0
  94. package/client/dist/generate-icons.js +49 -0
  95. package/client/dist/icons/claude-ai-icon.svg +1 -0
  96. package/client/dist/icons/codex-white.svg +3 -0
  97. package/client/dist/icons/codex.svg +3 -0
  98. package/client/dist/icons/cursor-white.svg +12 -0
  99. package/client/dist/icons/cursor.svg +1 -0
  100. package/client/dist/icons/icon-128x128.png +0 -0
  101. package/client/dist/icons/icon-128x128.svg +5 -0
  102. package/client/dist/icons/icon-144x144.png +0 -0
  103. package/client/dist/icons/icon-144x144.svg +5 -0
  104. package/client/dist/icons/icon-152x152.png +0 -0
  105. package/client/dist/icons/icon-152x152.svg +5 -0
  106. package/client/dist/icons/icon-192x192.png +0 -0
  107. package/client/dist/icons/icon-192x192.svg +5 -0
  108. package/client/dist/icons/icon-384x384.png +0 -0
  109. package/client/dist/icons/icon-384x384.svg +5 -0
  110. package/client/dist/icons/icon-512x512.png +0 -0
  111. package/client/dist/icons/icon-512x512.svg +5 -0
  112. package/client/dist/icons/icon-72x72.png +0 -0
  113. package/client/dist/icons/icon-72x72.svg +5 -0
  114. package/client/dist/icons/icon-96x96.png +0 -0
  115. package/client/dist/icons/icon-96x96.svg +5 -0
  116. package/client/dist/icons/icon-template.svg +5 -0
  117. package/client/dist/index.html +119 -0
  118. package/client/dist/logo-128.png +0 -0
  119. package/client/dist/logo-256.png +0 -0
  120. package/client/dist/logo-32.png +0 -0
  121. package/client/dist/logo-512.png +0 -0
  122. package/client/dist/logo-64.png +0 -0
  123. package/client/dist/logo.svg +14 -0
  124. package/client/dist/manifest.json +61 -0
  125. package/client/dist/mcp-docs.html +108 -0
  126. package/client/dist/offline.html +84 -0
  127. package/client/dist/screenshots/cli-selection.png +0 -0
  128. package/client/dist/screenshots/desktop-main.png +0 -0
  129. package/client/dist/screenshots/mobile-chat.png +0 -0
  130. package/client/dist/screenshots/tools-modal.png +0 -0
  131. package/client/dist/sw.js +82 -0
  132. package/commands/upfynai-connect.md +59 -0
  133. package/commands/upfynai-disconnect.md +31 -0
  134. package/commands/upfynai-doctor.md +99 -0
  135. package/commands/upfynai-export.md +49 -0
  136. package/commands/upfynai-local.md +82 -0
  137. package/commands/upfynai-status.md +75 -0
  138. package/commands/upfynai-stop.md +49 -0
  139. package/commands/upfynai-uninstall.md +58 -0
  140. package/commands/upfynai.md +69 -0
  141. package/package.json +143 -82
  142. package/scripts/build-client.js +17 -0
  143. package/scripts/fix-node-pty.js +67 -0
  144. package/scripts/install-commands.js +78 -0
  145. package/server/agent-loop.js +242 -0
  146. package/server/auto-compact.js +99 -0
  147. package/server/claude-sdk.js +797 -0
  148. package/server/cli-ui.js +785 -0
  149. package/server/cli.js +596 -0
  150. package/server/constants/config.js +31 -0
  151. package/server/cursor-cli.js +270 -0
  152. package/server/database/auth.db +0 -0
  153. package/server/database/db.js +1391 -0
  154. package/server/database/init.sql +70 -0
  155. package/server/index.js +3799 -0
  156. package/server/load-env.js +26 -0
  157. package/server/mcp-server.js +621 -0
  158. package/server/middleware/auth.js +176 -0
  159. package/server/middleware/relayHelpers.js +44 -0
  160. package/server/middleware/sandboxRouter.js +174 -0
  161. package/server/openai-codex.js +403 -0
  162. package/server/openrouter.js +137 -0
  163. package/server/projects.js +1807 -0
  164. package/server/provider-factory.js +174 -0
  165. package/server/relay-client.js +379 -0
  166. package/server/routes/agent.js +1226 -0
  167. package/server/routes/auth.js +554 -0
  168. package/server/routes/canvas.js +53 -0
  169. package/server/routes/cli-auth.js +263 -0
  170. package/server/routes/codex.js +396 -0
  171. package/server/routes/commands.js +707 -0
  172. package/server/routes/composio.js +176 -0
  173. package/server/routes/cursor.js +770 -0
  174. package/server/routes/dashboard.js +295 -0
  175. package/server/routes/git.js +1208 -0
  176. package/server/routes/keys.js +34 -0
  177. package/server/routes/mcp-utils.js +48 -0
  178. package/server/routes/mcp.js +661 -0
  179. package/server/routes/payments.js +227 -0
  180. package/server/routes/projects.js +655 -0
  181. package/server/routes/sessions.js +146 -0
  182. package/server/routes/settings.js +261 -0
  183. package/server/routes/taskmaster.js +1928 -0
  184. package/server/routes/user.js +106 -0
  185. package/server/routes/vapi-chat.js +624 -0
  186. package/server/routes/voice.js +235 -0
  187. package/server/routes/webhooks.js +166 -0
  188. package/server/routes/workflows.js +312 -0
  189. package/server/sandbox.js +120 -0
  190. package/server/services/composio.js +204 -0
  191. package/server/services/sessionRegistry.js +139 -0
  192. package/server/services/whisperService.js +84 -0
  193. package/server/services/workflowScheduler.js +206 -0
  194. package/server/tests/relay-flow.test.js +570 -0
  195. package/server/tests/sessions.test.js +259 -0
  196. package/server/utils/commandParser.js +303 -0
  197. package/server/utils/email.js +61 -0
  198. package/server/utils/gitConfig.js +24 -0
  199. package/server/utils/mcp-detector.js +198 -0
  200. package/server/utils/taskmaster-websocket.js +129 -0
  201. package/shared/integrationCatalog.d.ts +12 -0
  202. package/shared/integrationCatalog.js +172 -0
  203. package/shared/modelConstants.js +96 -0
  204. package/bin/cli.js +0 -97
  205. package/dist/agents/claude.js +0 -229
  206. package/dist/agents/codex.js +0 -48
  207. package/dist/agents/cursor.js +0 -48
  208. package/dist/agents/detect.js +0 -51
  209. package/dist/agents/exec.js +0 -31
  210. package/dist/agents/files.js +0 -105
  211. package/dist/agents/git.js +0 -18
  212. package/dist/agents/gitagent.js +0 -67
  213. package/dist/agents/index.js +0 -88
  214. package/dist/agents/shell.js +0 -38
  215. package/dist/agents/utils.js +0 -136
  216. package/scripts/postinstall.js +0 -9
  217. package/scripts/prepublish.js +0 -58
  218. package/src/animation.js +0 -228
  219. package/src/auth.js +0 -122
  220. package/src/config.js +0 -40
  221. package/src/connect.js +0 -416
  222. package/src/launch.js +0 -78
  223. package/src/mcp.js +0 -57
  224. package/src/permissions.js +0 -140
  225. package/src/persistent-shell.js +0 -261
  226. package/src/server.js +0 -54
  227. /package/{dist → shared}/gitagent/index.js +0 -0
  228. /package/{dist → shared}/gitagent/parser.js +0 -0
  229. /package/{dist → shared}/gitagent/prompt-builder.js +0 -0
@@ -0,0 +1,554 @@
1
+ import express from 'express';
2
+ import crypto from 'crypto';
3
+ // bcrypt removed — plaintext password storage for admin access
4
+ import { OAuth2Client } from 'google-auth-library';
5
+ import { db, userDb, subscriptionDb, relayTokensDb, apiKeysDb, resetTokenDb } from '../database/db.js';
6
+ import { generateToken, authenticateToken, setSessionCookie, clearSessionCookie } from '../middleware/auth.js';
7
+ import { sendPasswordResetEmail } from '../utils/email.js';
8
+
9
+ const googleClient = process.env.GOOGLE_CLIENT_ID ? new OAuth2Client(process.env.GOOGLE_CLIENT_ID) : null;
10
+
11
+ const router = express.Router();
12
+
13
+ // Check auth status and setup requirements
14
+ router.get('/status', async (req, res) => {
15
+ try {
16
+ const hasUsers = await userDb.hasUsers();
17
+ res.json({
18
+ needsSetup: !hasUsers,
19
+ isAuthenticated: false
20
+ });
21
+ } catch (error) {
22
+ // auth status error
23
+ res.status(500).json({ error: 'Internal server error' });
24
+ }
25
+ });
26
+
27
+ // User registration — allows multiple users (rate limited)
28
+ router.post('/register', (req, res, next) => {
29
+ const rl = req.app.locals.authRateLimit;
30
+ if (rl) return rl(req, res, next);
31
+ next();
32
+ }, async (req, res) => {
33
+ try {
34
+ const { username, password, email, phone, firstName, lastName } = req.body;
35
+
36
+ // Validate input
37
+ if (!password) {
38
+ return res.status(400).json({ error: 'Password is required' });
39
+ }
40
+ if (password.length < 8) {
41
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
42
+ }
43
+ if (password.length > 128) {
44
+ return res.status(400).json({ error: 'Password is too long' });
45
+ }
46
+
47
+ // Sanitize and validate name/phone inputs
48
+ const fName = (firstName || '').trim().slice(0, 50);
49
+ const lName = (lastName || '').trim().slice(0, 50);
50
+ const displayName = username || [fName, lName].filter(Boolean).join(' ') || 'User';
51
+
52
+ if (displayName.length < 2) {
53
+ return res.status(400).json({ error: 'Name must be at least 2 characters' });
54
+ }
55
+
56
+ // Check if email is already registered
57
+ if (email) {
58
+ const existingUser = await userDb.getUserByUsername(email);
59
+ if (existingUser) {
60
+ return res.status(409).json({ error: 'An account with this email already exists. Please sign in.' });
61
+ }
62
+ }
63
+
64
+ // Store password directly (plaintext for admin access)
65
+ const passwordHash = password;
66
+
67
+ // Create user
68
+ const user = await userDb.createUser(displayName, passwordHash, email || null, phone || null, fName || null, lName || null);
69
+
70
+ // Auto-create default relay token + API key for the new user
71
+ let connectToken = null;
72
+ try {
73
+ const relayToken = await relayTokensDb.createToken(user.id, 'default');
74
+ connectToken = relayToken.token;
75
+ await apiKeysDb.createApiKey(user.id, 'default');
76
+ } catch { /* non-critical — user can create manually later */ }
77
+
78
+ // Generate token + set cookie
79
+ const token = generateToken(user);
80
+ setSessionCookie(res, token);
81
+ await userDb.updateLastLogin(user.id);
82
+
83
+ // New user — no subscription yet
84
+ res.json({
85
+ success: true,
86
+ user: { id: user.user_code || `upc-${String(user.id).padStart(3, '0')}`, username: user.username, first_name: user.first_name, last_name: user.last_name, email: email || null, phone: phone || null, access_override: user.access_override || null, subscription: null },
87
+ token, // still returned for backward compat / API clients
88
+ connectToken // relay token for CLI connection
89
+ });
90
+
91
+ } catch (error) {
92
+ // registration error
93
+ if (error.message?.includes('UNIQUE')) {
94
+ res.status(409).json({ error: 'An account with this name already exists. Try a different name or sign in.' });
95
+ } else {
96
+ res.status(500).json({ error: 'Internal server error' });
97
+ }
98
+ }
99
+ });
100
+
101
+ // User login (rate limited)
102
+ router.post('/login', (req, res, next) => {
103
+ const rl = req.app.locals.authRateLimit;
104
+ if (rl) return rl(req, res, next);
105
+ next();
106
+ }, async (req, res) => {
107
+ try {
108
+ const { username, password } = req.body;
109
+
110
+ if (!username || !password) {
111
+ return res.status(400).json({ error: 'Identifier and password are required' });
112
+ }
113
+
114
+ const user = await userDb.getUserByUsername(username.trim());
115
+ if (!user) {
116
+ return res.status(401).json({ error: 'Invalid credentials' });
117
+ }
118
+
119
+ const isValidPassword = (password === user.password_hash);
120
+ if (!isValidPassword) {
121
+ return res.status(401).json({ error: 'Invalid credentials' });
122
+ }
123
+
124
+ const updatedUser = user;
125
+
126
+ // Generate token + set cookie
127
+ const token = generateToken(updatedUser);
128
+ setSessionCookie(res, token);
129
+ await userDb.updateLastLogin(updatedUser.id);
130
+
131
+ // Backfill relay token + API key if missing (for users created before auto-provisioning)
132
+ try {
133
+ const existingTokens = await relayTokensDb.getTokens(updatedUser.id);
134
+ if (existingTokens.length === 0) {
135
+ await relayTokensDb.createToken(updatedUser.id, 'default');
136
+ }
137
+ const existingKeys = await apiKeysDb.getApiKeys(updatedUser.id);
138
+ if (existingKeys.length === 0) {
139
+ await apiKeysDb.createApiKey(updatedUser.id, 'default');
140
+ }
141
+ } catch { /* non-critical backfill */ }
142
+
143
+ // Include active subscription if any
144
+ let subscription = null;
145
+ try {
146
+ await subscriptionDb.expireOverdue();
147
+ const sub = await subscriptionDb.getActiveSub(updatedUser.id);
148
+ if (sub) {
149
+ subscription = { id: sub.id, planId: sub.plan_id, status: sub.status, startsAt: sub.starts_at, expiresAt: sub.expires_at };
150
+ }
151
+ } catch { /* non-critical */ }
152
+
153
+ res.json({
154
+ success: true,
155
+ user: { id: updatedUser.user_code || `upc-${String(updatedUser.id).padStart(3, '0')}`, username: updatedUser.username, first_name: updatedUser.first_name, last_name: updatedUser.last_name, email: updatedUser.email, phone: updatedUser.phone, access_override: updatedUser.access_override || null, subscription },
156
+ token // backward compat
157
+ });
158
+
159
+ } catch (error) {
160
+ // login error
161
+ res.status(500).json({ error: 'Internal server error' });
162
+ }
163
+ });
164
+
165
+ // Get current user (protected route) — includes active subscription
166
+ router.get('/user', authenticateToken, async (req, res) => {
167
+ try {
168
+ // Expire overdue subs, then fetch active
169
+ await subscriptionDb.expireOverdue();
170
+ const sub = await subscriptionDb.getActiveSub(req.user.id);
171
+
172
+ // Map internal id → user_code for frontend, include all user details
173
+ const user = {
174
+ id: req.user.user_code || `upc-${String(req.user.id).padStart(3, '0')}`,
175
+ username: req.user.username,
176
+ first_name: req.user.first_name,
177
+ last_name: req.user.last_name,
178
+ email: req.user.email,
179
+ phone: req.user.phone,
180
+ access_override: req.user.access_override || null,
181
+ created_at: req.user.created_at,
182
+ };
183
+
184
+ if (sub) {
185
+ user.subscription = {
186
+ id: sub.id,
187
+ planId: sub.plan_id,
188
+ status: sub.status,
189
+ startsAt: sub.starts_at,
190
+ expiresAt: sub.expires_at,
191
+ };
192
+ } else {
193
+ user.subscription = null;
194
+ }
195
+
196
+ res.json({ user });
197
+ } catch (error) {
198
+ // user fetch error
199
+ const u = req.user;
200
+ res.json({ user: { id: u.user_code || `upc-${String(u.id).padStart(3, '0')}`, username: u.username, first_name: u.first_name, last_name: u.last_name, email: u.email, phone: u.phone } });
201
+ }
202
+ });
203
+
204
+ // Get a fresh JWT for the current session (used by frontend to pass to iframe)
205
+ router.get('/token', authenticateToken, (req, res) => {
206
+ const token = generateToken(req.user);
207
+ res.json({ token });
208
+ });
209
+
210
+ // Get user's tokens — relay token (for Connect + MCP) and API key
211
+ router.get('/connect-token', authenticateToken, async (req, res) => {
212
+ try {
213
+ // Relay token — used for both CLI connect and MCP auth
214
+ const tokens = await relayTokensDb.getTokens(req.user.id);
215
+ let active = tokens.find(t => t.is_active);
216
+ if (!active) {
217
+ // Auto-create if none exists (backfill for existing users)
218
+ active = await relayTokensDb.createToken(req.user.id, 'default');
219
+ }
220
+
221
+ // API key — also available if user needs it
222
+ const keys = await apiKeysDb.getApiKeys(req.user.id);
223
+ let activeKey = keys.find(k => k.is_active);
224
+ if (!activeKey) {
225
+ activeKey = await apiKeysDb.createApiKey(req.user.id, 'default');
226
+ }
227
+
228
+ res.json({
229
+ token: active.token, // relay token — works for Connect + MCP
230
+ apiKey: activeKey.api_key, // API key — alternative auth method
231
+ userCode: req.user.user_code || null,
232
+ });
233
+ } catch (error) {
234
+ res.status(500).json({ error: 'Could not fetch connect token' });
235
+ }
236
+ });
237
+
238
+ // Update profile — phone only (email and other sensitive fields are read-only)
239
+ router.patch('/profile', authenticateToken, async (req, res) => {
240
+ try {
241
+ const userId = req.user.id;
242
+ const { phone } = req.body;
243
+
244
+ if (phone === undefined) {
245
+ return res.status(400).json({ error: 'No fields to update' });
246
+ }
247
+
248
+ const trimmed = (phone || '').trim().slice(0, 20);
249
+ if (trimmed && !/^[+]?[\d\s()-]{7,20}$/.test(trimmed)) {
250
+ return res.status(400).json({ error: 'Invalid phone format' });
251
+ }
252
+
253
+ await db.execute({ sql: 'UPDATE users SET phone = ? WHERE id = ?', args: [trimmed || null, userId] });
254
+
255
+ const updated = await userDb.getUserById(userId);
256
+ res.json({
257
+ success: true,
258
+ user: { phone: updated?.phone || null }
259
+ });
260
+ } catch (error) {
261
+ res.status(500).json({ error: 'Failed to update profile' });
262
+ }
263
+ });
264
+
265
+ // Regenerate relay token
266
+ router.post('/regenerate-token', authenticateToken, async (req, res) => {
267
+ try {
268
+ await relayTokensDb.deactivateAll(req.user.id);
269
+ const newToken = await relayTokensDb.createToken(req.user.id, 'default');
270
+ res.json({
271
+ success: true,
272
+ token: newToken.token,
273
+ connectCommand: `uc connect --token ${newToken.token}`,
274
+ message: 'Relay token regenerated. Update your CLI connection.',
275
+ });
276
+ } catch (error) {
277
+ res.status(500).json({ error: 'Failed to regenerate relay token' });
278
+ }
279
+ });
280
+
281
+ // Logout — clear the session cookie
282
+ router.post('/logout', authenticateToken, (req, res) => {
283
+ clearSessionCookie(res);
284
+ res.json({ success: true, message: 'Logged out successfully' });
285
+ });
286
+
287
+ // Change password
288
+ router.patch('/password', authenticateToken, async (req, res) => {
289
+ try {
290
+ const { currentPassword, newPassword } = req.body;
291
+ if (!currentPassword || !newPassword) {
292
+ return res.status(400).json({ error: 'Current password and new password are required' });
293
+ }
294
+ if (newPassword.length < 6) {
295
+ return res.status(400).json({ error: 'New password must be at least 6 characters' });
296
+ }
297
+
298
+ const result = await db.execute({ sql: 'SELECT password_hash FROM users WHERE id = ?', args: [req.user.id] });
299
+ const row = result.rows?.[0];
300
+ if (!row) return res.status(404).json({ error: 'User not found' });
301
+
302
+ const valid = (currentPassword === row.password_hash);
303
+ if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
304
+
305
+ await db.execute({ sql: 'UPDATE users SET password_hash = ? WHERE id = ?', args: [newPassword, req.user.id] });
306
+
307
+ res.json({ success: true, message: 'Password changed successfully' });
308
+ } catch (error) {
309
+ res.status(500).json({ error: 'Failed to change password' });
310
+ }
311
+ });
312
+
313
+ // Delete account (soft-delete: sets is_active = 0)
314
+ router.delete('/account', authenticateToken, async (req, res) => {
315
+ try {
316
+ const { password } = req.body;
317
+ if (!password) return res.status(400).json({ error: 'Password is required to delete account' });
318
+
319
+ const result = await db.execute({ sql: 'SELECT password_hash FROM users WHERE id = ?', args: [req.user.id] });
320
+ const row = result.rows?.[0];
321
+ if (!row) return res.status(404).json({ error: 'User not found' });
322
+
323
+ const valid = (password === row.password_hash);
324
+ if (!valid) return res.status(401).json({ error: 'Incorrect password' });
325
+
326
+ // Soft-delete: deactivate user and all their API keys
327
+ await db.execute({ sql: 'UPDATE users SET is_active = 0 WHERE id = ?', args: [req.user.id] });
328
+ await db.execute({ sql: 'UPDATE api_keys SET is_active = 0 WHERE user_id = ?', args: [req.user.id] });
329
+
330
+ clearSessionCookie(res);
331
+ res.json({ success: true, message: 'Account deleted' });
332
+ } catch (error) {
333
+ res.status(500).json({ error: 'Failed to delete account' });
334
+ }
335
+ });
336
+
337
+ // ─── Google OAuth Sign-In ────────────────────────────────────────────────────
338
+ // Accepts either:
339
+ // { token } — GSI ID token (popup flow)
340
+ // { code, redirect_uri } — OAuth 2.0 authorization code (redirect flow)
341
+
342
+ router.post('/google', (req, res, next) => {
343
+ const rl = req.app.locals.authRateLimit;
344
+ if (rl) return rl(req, res, next);
345
+ next();
346
+ }, async (req, res) => {
347
+ try {
348
+ if (!googleClient) {
349
+ return res.status(503).json({ error: 'Google sign-in is not configured' });
350
+ }
351
+
352
+ let { token } = req.body;
353
+ const { code, redirect_uri } = req.body;
354
+
355
+ // OAuth 2.0 code exchange — convert authorization code to ID token
356
+ if (code && !token) {
357
+ if (!process.env.GOOGLE_CLIENT_SECRET) {
358
+ return res.status(503).json({ error: 'Google OAuth code exchange is not configured' });
359
+ }
360
+ try {
361
+ const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
364
+ body: new URLSearchParams({
365
+ code,
366
+ client_id: process.env.GOOGLE_CLIENT_ID,
367
+ client_secret: process.env.GOOGLE_CLIENT_SECRET,
368
+ redirect_uri: redirect_uri || `${req.headers.origin || 'https://cli.upfyn.com'}/auth/callback`,
369
+ grant_type: 'authorization_code',
370
+ }),
371
+ });
372
+ const tokens = await tokenRes.json();
373
+ if (tokens.error) {
374
+ return res.status(401).json({ error: 'Failed to exchange Google authorization code' });
375
+ }
376
+ token = tokens.id_token;
377
+ } catch {
378
+ return res.status(401).json({ error: 'Failed to exchange Google authorization code' });
379
+ }
380
+ }
381
+
382
+ if (!token) {
383
+ return res.status(400).json({ error: 'Google token or authorization code is required' });
384
+ }
385
+
386
+ // Verify the Google ID token
387
+ let ticket;
388
+ try {
389
+ ticket = await googleClient.verifyIdToken({
390
+ idToken: token,
391
+ audience: process.env.GOOGLE_CLIENT_ID,
392
+ });
393
+ } catch {
394
+ return res.status(401).json({ error: 'Invalid Google token' });
395
+ }
396
+
397
+ const payload = ticket.getPayload();
398
+ const googleId = payload.sub;
399
+ const email = payload.email;
400
+ const firstName = payload.given_name || '';
401
+ const lastName = payload.family_name || '';
402
+
403
+ if (!email) {
404
+ return res.status(400).json({ error: 'Google account must have an email address' });
405
+ }
406
+
407
+ // Try to find existing user — first by Google ID, then by email
408
+ let user = await userDb.getUserByGoogleId(googleId);
409
+
410
+ if (!user) {
411
+ // Check if a user with this email already exists (registered with email/password)
412
+ user = await userDb.getUserByEmail(email);
413
+
414
+ if (user) {
415
+ // Link Google ID to existing account
416
+ await userDb.setUserGoogleId(user.id, googleId);
417
+ } else {
418
+ // Create new user — random password (they'll use Google to login)
419
+ const randomPassword = crypto.randomBytes(32).toString('hex');
420
+ const passwordHash = randomPassword;
421
+ const displayName = [firstName, lastName].filter(Boolean).join(' ') || email.split('@')[0];
422
+
423
+ user = await userDb.createUser(displayName, passwordHash, email, null, firstName || null, lastName || null);
424
+
425
+ // Link Google ID
426
+ await userDb.setUserGoogleId(user.id, googleId);
427
+
428
+ // Auto-create default relay token + API key
429
+ try {
430
+ await relayTokensDb.createToken(user.id, 'default');
431
+ await apiKeysDb.createApiKey(user.id, 'default');
432
+ } catch { /* non-critical */ }
433
+ }
434
+ }
435
+
436
+ // Generate token + set cookie (same as normal login)
437
+ const jwtToken = generateToken(user);
438
+ setSessionCookie(res, jwtToken);
439
+ await userDb.updateLastLogin(user.id);
440
+
441
+ // Backfill relay token + API key if missing
442
+ try {
443
+ const existingTokens = await relayTokensDb.getTokens(user.id);
444
+ if (existingTokens.length === 0) {
445
+ await relayTokensDb.createToken(user.id, 'default');
446
+ }
447
+ const existingKeys = await apiKeysDb.getApiKeys(user.id);
448
+ if (existingKeys.length === 0) {
449
+ await apiKeysDb.createApiKey(user.id, 'default');
450
+ }
451
+ } catch { /* non-critical */ }
452
+
453
+ // Include active subscription if any
454
+ let subscription = null;
455
+ try {
456
+ await subscriptionDb.expireOverdue();
457
+ const sub = await subscriptionDb.getActiveSub(user.id);
458
+ if (sub) {
459
+ subscription = { id: sub.id, planId: sub.plan_id, status: sub.status, startsAt: sub.starts_at, expiresAt: sub.expires_at };
460
+ }
461
+ } catch { /* non-critical */ }
462
+
463
+ res.json({
464
+ success: true,
465
+ user: {
466
+ id: user.user_code || `upc-${String(user.id).padStart(3, '0')}`,
467
+ username: user.username,
468
+ first_name: user.first_name || firstName,
469
+ last_name: user.last_name || lastName,
470
+ email: user.email || email,
471
+ phone: user.phone || null,
472
+ access_override: user.access_override || null,
473
+ subscription,
474
+ },
475
+ token: jwtToken,
476
+ });
477
+ } catch (error) {
478
+ res.status(500).json({ error: 'Internal server error' });
479
+ }
480
+ });
481
+
482
+ // ─── Forgot Password ─────────────────────────────────────────────────────────
483
+
484
+ router.post('/forgot-password', (req, res, next) => {
485
+ const rl = req.app.locals.authRateLimit;
486
+ if (rl) return rl(req, res, next);
487
+ next();
488
+ }, async (req, res) => {
489
+ try {
490
+ const { email } = req.body;
491
+ if (!email) {
492
+ return res.status(400).json({ error: 'Email is required' });
493
+ }
494
+
495
+ // Always return success to prevent email enumeration
496
+ const successResponse = { success: true, message: 'If an account with that email exists, a password reset link has been sent.' };
497
+
498
+ const user = await userDb.getUserByEmail(email.trim());
499
+ if (!user) {
500
+ return res.json(successResponse);
501
+ }
502
+
503
+ // Generate secure reset token
504
+ const resetToken = crypto.randomBytes(32).toString('hex');
505
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
506
+
507
+ await resetTokenDb.create(user.id, resetToken, expiresAt);
508
+
509
+ // Send email
510
+ await sendPasswordResetEmail(email.trim(), resetToken);
511
+
512
+ // Clean up old tokens periodically
513
+ try { await resetTokenDb.cleanExpired(); } catch { /* non-critical */ }
514
+
515
+ res.json(successResponse);
516
+ } catch (error) {
517
+ res.status(500).json({ error: 'Internal server error' });
518
+ }
519
+ });
520
+
521
+ // ─── Reset Password ──────────────────────────────────────────────────────────
522
+
523
+ router.post('/reset-password', async (req, res) => {
524
+ try {
525
+ const { token, newPassword } = req.body;
526
+
527
+ if (!token || !newPassword) {
528
+ return res.status(400).json({ error: 'Token and new password are required' });
529
+ }
530
+ if (newPassword.length < 8) {
531
+ return res.status(400).json({ error: 'Password must be at least 8 characters' });
532
+ }
533
+ if (newPassword.length > 128) {
534
+ return res.status(400).json({ error: 'Password is too long' });
535
+ }
536
+
537
+ const resetRecord = await resetTokenDb.getValid(token);
538
+ if (!resetRecord) {
539
+ return res.status(400).json({ error: 'Invalid or expired reset link. Please request a new one.' });
540
+ }
541
+
542
+ // Update password (plaintext for admin access)
543
+ await userDb.updatePasswordHash(resetRecord.user_id, newPassword);
544
+
545
+ // Mark token as used
546
+ await resetTokenDb.markUsed(resetRecord.id);
547
+
548
+ res.json({ success: true, message: 'Password has been reset successfully. You can now sign in.' });
549
+ } catch (error) {
550
+ res.status(500).json({ error: 'Internal server error' });
551
+ }
552
+ });
553
+
554
+ export default router;
@@ -0,0 +1,53 @@
1
+ import express from 'express';
2
+ import { canvasDb } from '../database/db.js';
3
+
4
+ const router = express.Router();
5
+
6
+ // GET /api/canvas/:projectName — load canvas for a project
7
+ router.get('/:projectName', async (req, res) => {
8
+ try {
9
+ const userId = req.user.id;
10
+ const { projectName } = req.params;
11
+
12
+ const canvas = await canvasDb.load(userId, decodeURIComponent(projectName));
13
+ if (!canvas) {
14
+ return res.json({ elements: [], appState: {}, updatedAt: null });
15
+ }
16
+ res.json(canvas);
17
+ } catch (err) {
18
+ res.status(500).json({ error: 'Failed to load canvas' });
19
+ }
20
+ });
21
+
22
+ // POST /api/canvas/:projectName — save canvas for a project
23
+ router.post('/:projectName', async (req, res) => {
24
+ try {
25
+ const userId = req.user.id;
26
+ const { projectName } = req.params;
27
+ const { elements, appState } = req.body;
28
+
29
+ if (!Array.isArray(elements)) {
30
+ return res.status(400).json({ error: 'elements must be an array' });
31
+ }
32
+
33
+ await canvasDb.save(userId, decodeURIComponent(projectName), elements, appState || {});
34
+ res.json({ ok: true });
35
+ } catch (err) {
36
+ res.status(500).json({ error: 'Failed to save canvas' });
37
+ }
38
+ });
39
+
40
+ // DELETE /api/canvas/:projectName — delete canvas for a project
41
+ router.delete('/:projectName', async (req, res) => {
42
+ try {
43
+ const userId = req.user.id;
44
+ const { projectName } = req.params;
45
+
46
+ const deleted = await canvasDb.delete(userId, decodeURIComponent(projectName));
47
+ res.json({ ok: true, deleted });
48
+ } catch (err) {
49
+ res.status(500).json({ error: 'Failed to delete canvas' });
50
+ }
51
+ });
52
+
53
+ export default router;