upfynai-code 3.0.2 → 3.0.4
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/client/dist/api-docs.html +838 -838
- package/client/dist/assets/{AppContent-Bvg0CPCO.js → AppContent-CwrTP6TW.js} +43 -43
- package/client/dist/assets/BrowserPanel-0TLEl-IC.js +2 -0
- package/client/dist/assets/{CanvasFullScreen-BdiJ35aq.js → CanvasFullScreen-D1GWQsGL.js} +1 -1
- package/client/dist/assets/{CanvasWorkspace-Bk9R9_e0.js → CanvasWorkspace-D7ORj358.js} +1 -1
- package/client/dist/assets/DashboardPanel-BV7ybUDe.js +1 -0
- package/client/dist/assets/FileTree-5qfhBqdE.js +1 -0
- package/client/dist/assets/{GitPanel-RtyZUIWS.js → GitPanel-C_xFM-N2.js} +1 -1
- package/client/dist/assets/{LoginModal-BWep8a6g.js → LoginModal-CImJHRjX.js} +3 -3
- package/client/dist/assets/{MarkdownPreview-DHmk3qzu.js → MarkdownPreview-CESjI261.js} +1 -1
- package/client/dist/assets/{MermaidBlock-BuBc_G-F.js → MermaidBlock-BFM21cwe.js} +2 -2
- package/client/dist/assets/Onboarding-B3cteLu2.js +1 -0
- package/client/dist/assets/SetupForm-P6dsYgHO.js +1 -0
- package/client/dist/assets/WorkflowsPanel-CBoN80kc.js +1 -0
- package/client/dist/assets/index-46kkVu2i.css +1 -0
- package/client/dist/assets/{index-C5ptjuTl.js → index-HaY-3pK1.js} +20 -20
- package/client/dist/assets/{vendor-canvas-D39yWul6.js → vendor-canvas-DvHJ_Pn2.js} +1 -1
- package/client/dist/assets/{vendor-codemirror-CbtmxxaB.js → vendor-codemirror-D2ALgpaX.js} +1 -1
- package/client/dist/assets/{vendor-icons-BaD0x9SL.js → vendor-icons-GyYE35HP.js} +178 -138
- package/client/dist/assets/{vendor-mermaid-CH7SGc99.js → vendor-mermaid-DucWyDEe.js} +3 -3
- package/client/dist/assets/{vendor-syntax-DuHI9Ok6.js → vendor-syntax-LS_Nt30I.js} +1 -1
- package/client/dist/clear-cache.html +85 -85
- package/client/dist/index.html +17 -17
- package/client/dist/manifest.json +3 -3
- package/client/dist/mcp-docs.html +108 -108
- package/client/dist/offline.html +84 -84
- package/client/dist/sw.js +82 -82
- package/package.json +136 -136
- package/server/browser.js +131 -0
- package/server/database/db.js +108 -10
- package/server/index.js +27 -28
- package/server/middleware/auth.js +5 -2
- package/server/routes/browser.js +419 -0
- package/server/routes/projects.js +118 -19
- package/server/routes/vapi-chat.js +1 -1
- package/server/services/browser-ai.js +154 -0
- package/client/dist/assets/DashboardPanel-CblJfTGi.js +0 -1
- package/client/dist/assets/FileTree-BDUnBheV.js +0 -1
- package/client/dist/assets/Onboarding-Drnlt75a.js +0 -1
- package/client/dist/assets/SetupForm-CtCKitZG.js +0 -1
- package/client/dist/assets/WorkflowsPanel-B2mIXDvD.js +0 -1
- package/client/dist/assets/index-BFuqS0tY.css +0 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Client — connects the backend to the Steel browser service on Railway.
|
|
3
|
+
* All browser operations are proxied to the Steel service via HTTP.
|
|
4
|
+
* Mirrors the sandbox.js client pattern.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const BROWSER_SERVICE_URL = process.env.BROWSER_SERVICE_URL || 'http://localhost:4400';
|
|
8
|
+
const BROWSER_SERVICE_SECRET = process.env.BROWSER_SERVICE_SECRET || '';
|
|
9
|
+
|
|
10
|
+
// Warn if no secret in production — inter-service calls will be unauthenticated
|
|
11
|
+
if (!BROWSER_SERVICE_SECRET && process.env.NODE_ENV === 'production') {
|
|
12
|
+
console.error('[browser] WARNING: BROWSER_SERVICE_SECRET not set — browser service calls are unauthenticated');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function browserFetch(path, userId, body = null, method = null) {
|
|
16
|
+
const opts = {
|
|
17
|
+
method: method || (body ? 'POST' : 'GET'),
|
|
18
|
+
headers: {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
'x-browser-secret': BROWSER_SERVICE_SECRET,
|
|
21
|
+
'x-user-id': String(userId),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
if (body) opts.body = JSON.stringify(body);
|
|
25
|
+
|
|
26
|
+
const res = await fetch(`${BROWSER_SERVICE_URL}${path}`, opts);
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
if (!res.ok) throw new Error(data.error || data.message || `Browser service error: ${res.status}`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const browserClient = {
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the browser service is reachable.
|
|
36
|
+
*/
|
|
37
|
+
async isAvailable() {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${BROWSER_SERVICE_URL}/api/health`, {
|
|
40
|
+
signal: AbortSignal.timeout(3000),
|
|
41
|
+
});
|
|
42
|
+
return res.ok;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a new browser session.
|
|
50
|
+
* Returns: { id, wsEndpoint, debugUrl, sessionViewerUrl, ... }
|
|
51
|
+
*/
|
|
52
|
+
async createSession(userId, options = {}) {
|
|
53
|
+
const sessionOpts = {
|
|
54
|
+
sessionTimeout: options.timeout || 1800000, // 30 min default
|
|
55
|
+
blockAds: options.blockAds !== false,
|
|
56
|
+
solveCaptchas: options.solveCaptchas || false,
|
|
57
|
+
useProxy: options.useProxy || false,
|
|
58
|
+
dimensions: options.dimensions || { width: 1280, height: 800 },
|
|
59
|
+
userAgent: options.userAgent || undefined,
|
|
60
|
+
// Tag with userId for isolation
|
|
61
|
+
sessionContext: {
|
|
62
|
+
userId: String(userId),
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return browserFetch('/v1/sessions', userId, sessionOpts);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get session details.
|
|
72
|
+
*/
|
|
73
|
+
async getSession(userId, sessionId) {
|
|
74
|
+
return browserFetch(`/v1/sessions/${sessionId}`, userId);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Release (close) a browser session.
|
|
79
|
+
*/
|
|
80
|
+
async releaseSession(userId, sessionId) {
|
|
81
|
+
return browserFetch(`/v1/sessions/${sessionId}/release`, userId, {}, 'POST');
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List active sessions.
|
|
86
|
+
*/
|
|
87
|
+
async listSessions(userId) {
|
|
88
|
+
return browserFetch('/v1/sessions', userId);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the session viewer URL for embedding in an iframe.
|
|
93
|
+
* Steel's built-in viewer provides an interactive browser view.
|
|
94
|
+
*/
|
|
95
|
+
getSessionViewerUrl(sessionId) {
|
|
96
|
+
return `${BROWSER_SERVICE_URL}/v1/sessions/${sessionId}/viewer?interactive=true&showControls=true`;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get the CDP WebSocket URL for connecting Stagehand/Playwright.
|
|
101
|
+
*/
|
|
102
|
+
getCdpWsUrl(sessionId) {
|
|
103
|
+
const wsBase = BROWSER_SERVICE_URL.replace(/^http/, 'ws');
|
|
104
|
+
return `${wsBase}?sessionId=${sessionId}`;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Capture a screenshot of the current page.
|
|
109
|
+
*/
|
|
110
|
+
async screenshot(userId, sessionId) {
|
|
111
|
+
return browserFetch(`/v1/sessions/${sessionId}/screenshot`, userId, {});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scrape a URL — returns page content.
|
|
116
|
+
*/
|
|
117
|
+
async scrape(userId, sessionId, url) {
|
|
118
|
+
return browserFetch('/v1/scrape', userId, { url, sessionId });
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the sandbox dev server URL that the browser can reach internally.
|
|
123
|
+
* The sandbox and browser share Railway's internal network.
|
|
124
|
+
*/
|
|
125
|
+
getSandboxPreviewUrl(userId, port) {
|
|
126
|
+
const sandboxUrl = process.env.SANDBOX_SERVICE_URL || 'http://localhost:4300';
|
|
127
|
+
return `${sandboxUrl}/proxy/${userId}/${port}`;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export { browserClient, BROWSER_SERVICE_URL, BROWSER_SERVICE_SECRET };
|
package/server/database/db.js
CHANGED
|
@@ -13,10 +13,12 @@ try {
|
|
|
13
13
|
try {
|
|
14
14
|
const DatabaseMod = await import('libsql');
|
|
15
15
|
const Database = DatabaseMod.default || DatabaseMod;
|
|
16
|
-
createClient = function createLocalClient({ url }) {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
sqliteDb
|
|
16
|
+
createClient = function createLocalClient({ url, authToken }) {
|
|
17
|
+
const isRemote = url.startsWith('libsql://') || url.startsWith('https://');
|
|
18
|
+
const dbPath = isRemote ? url : url.replace(/^file:/, '');
|
|
19
|
+
const sqliteDb = authToken ? new Database(dbPath, { authToken }) : new Database(dbPath);
|
|
20
|
+
// WAL mode only for local files, not remote Turso
|
|
21
|
+
if (!isRemote) sqliteDb.pragma('journal_mode = WAL');
|
|
20
22
|
return {
|
|
21
23
|
execute: async (query) => {
|
|
22
24
|
const sql = typeof query === 'string' ? query : query.sql;
|
|
@@ -413,6 +415,22 @@ CREATE TABLE IF NOT EXISTS voice_calls (
|
|
|
413
415
|
|
|
414
416
|
CREATE INDEX IF NOT EXISTS idx_voice_calls_user ON voice_calls(user_id);
|
|
415
417
|
CREATE INDEX IF NOT EXISTS idx_voice_calls_vapi ON voice_calls(vapi_call_id);
|
|
418
|
+
|
|
419
|
+
CREATE TABLE IF NOT EXISTS browser_sessions (
|
|
420
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
421
|
+
user_id INTEGER NOT NULL,
|
|
422
|
+
steel_session_id TEXT NOT NULL UNIQUE,
|
|
423
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
424
|
+
viewer_url TEXT,
|
|
425
|
+
cdp_url TEXT,
|
|
426
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
427
|
+
last_accessed DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
428
|
+
metadata TEXT DEFAULT '{}',
|
|
429
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
CREATE INDEX IF NOT EXISTS idx_browser_sessions_user ON browser_sessions(user_id);
|
|
433
|
+
CREATE INDEX IF NOT EXISTS idx_browser_sessions_status ON browser_sessions(status);
|
|
416
434
|
`;
|
|
417
435
|
|
|
418
436
|
// ─── Migrations ─────────────────────────────────────────────────────────────────
|
|
@@ -510,6 +528,15 @@ const runMigrations = async () => {
|
|
|
510
528
|
}
|
|
511
529
|
} catch { /* column may already exist */ }
|
|
512
530
|
|
|
531
|
+
// Add github_origin column to user_projects if missing
|
|
532
|
+
try {
|
|
533
|
+
const upCols = await db.execute("PRAGMA table_info(user_projects)");
|
|
534
|
+
const upColNames = upCols.rows.map(r => r.name);
|
|
535
|
+
if (!upColNames.includes('github_origin')) {
|
|
536
|
+
await db.execute('ALTER TABLE user_projects ADD COLUMN github_origin TEXT');
|
|
537
|
+
}
|
|
538
|
+
} catch { /* column may already exist */ }
|
|
539
|
+
|
|
513
540
|
console.log(`${c.info('[DB]')} Migrations complete`);
|
|
514
541
|
} catch (error) {
|
|
515
542
|
console.error('Migration error:', error.message);
|
|
@@ -1305,21 +1332,26 @@ const resetTokenDb = {
|
|
|
1305
1332
|
// ─── Projects DB (cloud mode) ─────────────────────────────────────────────────
|
|
1306
1333
|
|
|
1307
1334
|
const projectDb = {
|
|
1308
|
-
upsert: async (userId, originalPath, displayName = null) => {
|
|
1335
|
+
upsert: async (userId, originalPath, displayName = null, githubOrigin = null) => {
|
|
1309
1336
|
const projectName = originalPath.replace(/[\\/:\s~_]/g, '-');
|
|
1310
1337
|
const existing = await db.execute({
|
|
1311
1338
|
sql: 'SELECT id FROM user_projects WHERE user_id = ? AND original_path = ?',
|
|
1312
1339
|
args: [userId, originalPath]
|
|
1313
1340
|
});
|
|
1314
1341
|
if (existing.rows.length > 0) {
|
|
1315
|
-
if (displayName) {
|
|
1316
|
-
|
|
1342
|
+
if (displayName || githubOrigin) {
|
|
1343
|
+
const updates = [];
|
|
1344
|
+
const args = [];
|
|
1345
|
+
if (displayName) { updates.push('display_name = ?'); args.push(displayName); }
|
|
1346
|
+
if (githubOrigin) { updates.push('github_origin = ?'); args.push(githubOrigin); }
|
|
1347
|
+
args.push(existing.rows[0].id);
|
|
1348
|
+
await db.execute({ sql: `UPDATE user_projects SET ${updates.join(', ')} WHERE id = ?`, args });
|
|
1317
1349
|
}
|
|
1318
1350
|
return { id: Number(existing.rows[0].id), projectName, originalPath, alreadyExists: true };
|
|
1319
1351
|
}
|
|
1320
1352
|
const result = await db.execute({
|
|
1321
|
-
sql: 'INSERT INTO user_projects (user_id, project_name, original_path, display_name) VALUES (?, ?, ?, ?)',
|
|
1322
|
-
args: [userId, projectName, originalPath, displayName]
|
|
1353
|
+
sql: 'INSERT INTO user_projects (user_id, project_name, original_path, display_name, github_origin) VALUES (?, ?, ?, ?, ?)',
|
|
1354
|
+
args: [userId, projectName, originalPath, displayName, githubOrigin]
|
|
1323
1355
|
});
|
|
1324
1356
|
return { id: Number(result.lastInsertRowid), projectName, originalPath };
|
|
1325
1357
|
},
|
|
@@ -1346,6 +1378,14 @@ const projectDb = {
|
|
|
1346
1378
|
args: [displayName, userId, originalPath]
|
|
1347
1379
|
});
|
|
1348
1380
|
return result.rowsAffected > 0;
|
|
1381
|
+
},
|
|
1382
|
+
|
|
1383
|
+
getByName: async (userId, projectName) => {
|
|
1384
|
+
const result = await db.execute({
|
|
1385
|
+
sql: 'SELECT * FROM user_projects WHERE user_id = ? AND project_name = ?',
|
|
1386
|
+
args: [userId, projectName]
|
|
1387
|
+
});
|
|
1388
|
+
return result.rows[0] || null;
|
|
1349
1389
|
}
|
|
1350
1390
|
};
|
|
1351
1391
|
|
|
@@ -1446,4 +1486,62 @@ const voiceCallDb = {
|
|
|
1446
1486
|
},
|
|
1447
1487
|
};
|
|
1448
1488
|
|
|
1449
|
-
|
|
1489
|
+
// ─── Browser Sessions DB ─────────────────────────────────────────────────────
|
|
1490
|
+
|
|
1491
|
+
const browserSessionDb = {
|
|
1492
|
+
create: async (userId, steelSessionId, viewerUrl = null, cdpUrl = null, metadata = {}) => {
|
|
1493
|
+
const result = await db.execute({
|
|
1494
|
+
sql: 'INSERT INTO browser_sessions (user_id, steel_session_id, viewer_url, cdp_url, metadata) VALUES (?, ?, ?, ?, ?)',
|
|
1495
|
+
args: [userId, steelSessionId, viewerUrl, cdpUrl, JSON.stringify(metadata)]
|
|
1496
|
+
});
|
|
1497
|
+
return { id: Number(result.lastInsertRowid), steelSessionId };
|
|
1498
|
+
},
|
|
1499
|
+
|
|
1500
|
+
getActive: async (userId) => {
|
|
1501
|
+
const result = await db.execute({
|
|
1502
|
+
sql: "SELECT * FROM browser_sessions WHERE user_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1",
|
|
1503
|
+
args: [userId]
|
|
1504
|
+
});
|
|
1505
|
+
return getRow(result);
|
|
1506
|
+
},
|
|
1507
|
+
|
|
1508
|
+
getBySessionId: async (userId, steelSessionId) => {
|
|
1509
|
+
const result = await db.execute({
|
|
1510
|
+
sql: 'SELECT * FROM browser_sessions WHERE user_id = ? AND steel_session_id = ?',
|
|
1511
|
+
args: [userId, steelSessionId]
|
|
1512
|
+
});
|
|
1513
|
+
return getRow(result);
|
|
1514
|
+
},
|
|
1515
|
+
|
|
1516
|
+
updateAccess: async (userId, steelSessionId) => {
|
|
1517
|
+
await db.execute({
|
|
1518
|
+
sql: "UPDATE browser_sessions SET last_accessed = datetime('now') WHERE user_id = ? AND steel_session_id = ?",
|
|
1519
|
+
args: [userId, steelSessionId]
|
|
1520
|
+
});
|
|
1521
|
+
},
|
|
1522
|
+
|
|
1523
|
+
deactivate: async (userId, steelSessionId) => {
|
|
1524
|
+
await db.execute({
|
|
1525
|
+
sql: "UPDATE browser_sessions SET status = 'closed' WHERE user_id = ? AND steel_session_id = ?",
|
|
1526
|
+
args: [userId, steelSessionId]
|
|
1527
|
+
});
|
|
1528
|
+
},
|
|
1529
|
+
|
|
1530
|
+
listByUser: async (userId) => {
|
|
1531
|
+
const result = await db.execute({
|
|
1532
|
+
sql: 'SELECT * FROM browser_sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
|
|
1533
|
+
args: [userId]
|
|
1534
|
+
});
|
|
1535
|
+
return result.rows;
|
|
1536
|
+
},
|
|
1537
|
+
|
|
1538
|
+
cleanupStale: async (maxAgeMinutes = 30) => {
|
|
1539
|
+
const result = await db.execute({
|
|
1540
|
+
sql: `UPDATE browser_sessions SET status = 'expired' WHERE status = 'active' AND last_accessed < datetime('now', '-' || ? || ' minutes')`,
|
|
1541
|
+
args: [maxAgeMinutes]
|
|
1542
|
+
});
|
|
1543
|
+
return result.rowsAffected || 0;
|
|
1544
|
+
},
|
|
1545
|
+
};
|
|
1546
|
+
|
|
1547
|
+
export { db, initializeDatabase, userDb, apiKeysDb, credentialsDb, relayTokensDb, githubTokensDb, subscriptionDb, paymentDb, webhookDb, workflowDb, fileVersionDb, sessionUsageDb, connectionDb, canvasDb, resetTokenDb, projectDb, voiceCallDb, browserSessionDb, PLAN_DURATIONS };
|
package/server/index.js
CHANGED
|
@@ -88,6 +88,7 @@ import keysRoutes from './routes/keys.js';
|
|
|
88
88
|
import assistantRoutes from './routes/vapi-chat.js';
|
|
89
89
|
import canvasRoutes from './routes/canvas.js';
|
|
90
90
|
import composioRoutes from './routes/composio.js';
|
|
91
|
+
import browserRoutes from './routes/browser.js';
|
|
91
92
|
import sessionRoutes from './routes/sessions.js';
|
|
92
93
|
import { handleSandboxWebSocketCommand } from './middleware/sandboxRouter.js';
|
|
93
94
|
import { createRelayMiddleware } from './middleware/relayHelpers.js';
|
|
@@ -582,13 +583,8 @@ app.locals.wss = wss;
|
|
|
582
583
|
// Security headers — protect against common web attacks
|
|
583
584
|
app.use((req, res, next) => {
|
|
584
585
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
if (allowedFrameOrigins.length > 0) {
|
|
588
|
-
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${allowedFrameOrigins.join(' ')}`);
|
|
589
|
-
} else {
|
|
590
|
-
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
591
|
-
}
|
|
586
|
+
// No iframe embedding — app is served directly via Vercel reverse proxy
|
|
587
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
592
588
|
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
593
589
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
594
590
|
if (process.env.NODE_ENV === 'production') {
|
|
@@ -752,6 +748,7 @@ app.use('/api/keys', authenticateToken, keysRoutes);
|
|
|
752
748
|
app.use('/api/assistant', assistantRoutes);
|
|
753
749
|
app.use('/api/canvas', authenticateToken, canvasRoutes);
|
|
754
750
|
app.use('/api/composio', authenticateToken, composioRoutes);
|
|
751
|
+
app.use('/api/browser', authenticateToken, browserRoutes);
|
|
755
752
|
app.use('/api/vapi', assistantRoutes); // Alias: VAPI dashboard webhook points to /api/vapi/webhook
|
|
756
753
|
|
|
757
754
|
// Voice call history endpoints (per-user isolated)
|
|
@@ -912,7 +909,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|
|
912
909
|
if (IS_LOCAL) {
|
|
913
910
|
// Self-hosted: run locally
|
|
914
911
|
const { execSync } = await import('child_process');
|
|
915
|
-
const output = execSync('npm install -g upfynai-code@latest', { encoding: 'utf8', timeout: 120000 });
|
|
912
|
+
const output = execSync('npm install -g @upfynai-code/app@latest', { encoding: 'utf8', timeout: 120000 });
|
|
916
913
|
res.json({ output, exitCode: 0 });
|
|
917
914
|
} else {
|
|
918
915
|
// Platform mode: server is managed by Railway, no user action needed
|
|
@@ -1084,12 +1081,12 @@ app.get('/api/auth/connection-status', authenticateToken, async (req, res) => {
|
|
|
1084
1081
|
// Serve public files (like api-docs.html)
|
|
1085
1082
|
app.use(express.static(path.join(__dirname, '../client/public')));
|
|
1086
1083
|
|
|
1087
|
-
// Static files served after API routes
|
|
1088
|
-
//
|
|
1089
|
-
app.use(express.static(path.join(__dirname, '../client/dist'), {
|
|
1084
|
+
// Static files served after API routes at /app/ prefix
|
|
1085
|
+
// Assets are built with Vite base: '/app/' so all URLs are /app/assets/...
|
|
1086
|
+
app.use('/app', express.static(path.join(__dirname, '../client/dist'), {
|
|
1087
|
+
index: false, // Don't serve index.html directly — catch-all injects runtime config
|
|
1090
1088
|
setHeaders: (res, filePath) => {
|
|
1091
1089
|
if (filePath.endsWith('.html')) {
|
|
1092
|
-
// Prevent HTML caching to avoid service worker issues after builds
|
|
1093
1090
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
1094
1091
|
res.setHeader('Pragma', 'no-cache');
|
|
1095
1092
|
res.setHeader('Expires', '0');
|
|
@@ -3605,13 +3602,12 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
|
|
|
3605
3602
|
}
|
|
3606
3603
|
});
|
|
3607
3604
|
|
|
3608
|
-
//
|
|
3609
|
-
app.get('
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
// Skip requests for static assets (files with extensions)
|
|
3605
|
+
// Root redirect → /app/
|
|
3606
|
+
app.get('/', (req, res) => res.redirect('/app/'));
|
|
3607
|
+
|
|
3608
|
+
// Serve React app for /app/* routes (SPA catch-all)
|
|
3609
|
+
app.get('/app/*', (req, res) => {
|
|
3610
|
+
// Skip requests for static assets (files with extensions) — already handled by express.static
|
|
3615
3611
|
if (path.extname(req.path)) {
|
|
3616
3612
|
return res.status(404).send('Not found');
|
|
3617
3613
|
}
|
|
@@ -3623,12 +3619,14 @@ app.get('*', (req, res) => {
|
|
|
3623
3619
|
const decoded = jwt.verify(req.query.token, JWT_SECRET);
|
|
3624
3620
|
if (decoded?.userId) {
|
|
3625
3621
|
const isSecure = process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
|
|
3622
|
+
const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
|
|
3626
3623
|
res.cookie('session', req.query.token, {
|
|
3627
3624
|
httpOnly: true,
|
|
3628
3625
|
secure: isSecure,
|
|
3629
|
-
sameSite: isSecure ? '
|
|
3626
|
+
sameSite: isSecure ? 'lax' : 'strict',
|
|
3630
3627
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
3631
3628
|
path: '/',
|
|
3629
|
+
...(isSecure && cookieDomain ? { domain: cookieDomain } : {}),
|
|
3632
3630
|
});
|
|
3633
3631
|
}
|
|
3634
3632
|
} catch (e) {
|
|
@@ -3636,27 +3634,28 @@ app.get('*', (req, res) => {
|
|
|
3636
3634
|
}
|
|
3637
3635
|
}
|
|
3638
3636
|
|
|
3639
|
-
// Only serve index.html for HTML routes, not for static assets
|
|
3640
|
-
// Static assets should already be handled by express.static middleware above
|
|
3641
3637
|
const indexPath = path.join(__dirname, '../client/dist/index.html');
|
|
3642
3638
|
|
|
3643
|
-
// Check if dist/index.html exists (production build available)
|
|
3644
3639
|
if (fs.existsSync(indexPath)) {
|
|
3645
3640
|
// Set no-cache headers for HTML to prevent service worker issues
|
|
3646
3641
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
3647
3642
|
res.setHeader('Pragma', 'no-cache');
|
|
3648
3643
|
res.setHeader('Expires', '0');
|
|
3649
3644
|
|
|
3650
|
-
// Inject runtime config so the frontend knows the server mode
|
|
3651
|
-
// (VITE_IS_PLATFORM is a build-time var that may not match the runtime)
|
|
3645
|
+
// Inject runtime config so the frontend knows the server mode and base path
|
|
3652
3646
|
let html = fs.readFileSync(indexPath, 'utf8');
|
|
3653
|
-
const runtimeConfig = JSON.stringify({
|
|
3647
|
+
const runtimeConfig = JSON.stringify({
|
|
3648
|
+
isPlatform: IS_PLATFORM,
|
|
3649
|
+
isLocal: IS_LOCAL,
|
|
3650
|
+
basename: '/app',
|
|
3651
|
+
wsUrl: process.env.APP_WS_URL || '',
|
|
3652
|
+
});
|
|
3654
3653
|
html = html.replace('</head>', `<script>window.__UPFYN_CONFIG__=${runtimeConfig}</script>\n</head>`);
|
|
3655
3654
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
3656
3655
|
res.send(html);
|
|
3657
3656
|
} else {
|
|
3658
|
-
// In development, redirect to Vite dev server
|
|
3659
|
-
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}
|
|
3657
|
+
// In development, redirect to Vite dev server
|
|
3658
|
+
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}/app/`);
|
|
3660
3659
|
}
|
|
3661
3660
|
});
|
|
3662
3661
|
|
|
@@ -88,14 +88,17 @@ const generateToken = (user) => {
|
|
|
88
88
|
// Cookie config for httpOnly session
|
|
89
89
|
// Works for both self-hosted (same origin) and split deploy (Vercel proxy → Railway)
|
|
90
90
|
const isSecureEnv = process.env.NODE_ENV === 'production' || !!process.env.VERCEL || !!process.env.RAILWAY_ENVIRONMENT;
|
|
91
|
+
const cookieDomain = process.env.COOKIE_DOMAIN || undefined;
|
|
91
92
|
const COOKIE_OPTIONS = {
|
|
92
93
|
httpOnly: true,
|
|
93
94
|
secure: isSecureEnv,
|
|
94
|
-
// '
|
|
95
|
+
// 'lax' — Vercel reverse-proxies to Railway (same domain from browser's perspective)
|
|
95
96
|
// 'strict' used in local/dev mode where everything is same-origin
|
|
96
|
-
sameSite: isSecureEnv ? '
|
|
97
|
+
sameSite: isSecureEnv ? 'lax' : 'strict',
|
|
97
98
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
98
99
|
path: '/',
|
|
100
|
+
// Set domain for cross-subdomain cookie sharing (e.g., '.upfyn.com')
|
|
101
|
+
...(isSecureEnv && cookieDomain ? { domain: cookieDomain } : {}),
|
|
99
102
|
};
|
|
100
103
|
|
|
101
104
|
// Set session cookie on response
|