ai-cli-online 2.9.9 → 3.0.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.
@@ -0,0 +1,9 @@
1
+ import type { Request, Response } from 'express';
2
+ /** Extract Bearer token from Authorization header */
3
+ export declare function extractToken(req: Request): string | undefined;
4
+ /** Check auth — reads AUTH_TOKEN lazily from env (dotenv loads before route handlers run) */
5
+ export declare function checkAuth(req: Request, res: Response): boolean;
6
+ /** Resolve session: auth check + sessionId validation + build tmux session name */
7
+ export declare function resolveSession(req: Request, res: Response): string | null;
8
+ /** Hash token for settings storage (same prefix as tmux session names) */
9
+ export declare function tokenHash(token: string): string;
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'crypto';
2
+ import { safeTokenCompare } from '../auth.js';
3
+ import { buildSessionName, isValidSessionId } from '../tmux.js';
4
+ /** Extract Bearer token from Authorization header */
5
+ export function extractToken(req) {
6
+ const authHeader = req.headers.authorization;
7
+ if (authHeader && authHeader.startsWith('Bearer ')) {
8
+ return authHeader.slice(7);
9
+ }
10
+ return undefined;
11
+ }
12
+ /** Check auth — reads AUTH_TOKEN lazily from env (dotenv loads before route handlers run) */
13
+ export function checkAuth(req, res) {
14
+ const authToken = process.env.AUTH_TOKEN || '';
15
+ if (!authToken)
16
+ return true;
17
+ const token = extractToken(req);
18
+ if (!token || !safeTokenCompare(token, authToken)) {
19
+ res.status(401).json({ error: 'Unauthorized' });
20
+ return false;
21
+ }
22
+ return true;
23
+ }
24
+ /** Resolve session: auth check + sessionId validation + build tmux session name */
25
+ export function resolveSession(req, res) {
26
+ if (!checkAuth(req, res))
27
+ return null;
28
+ const sessionId = req.params.sessionId;
29
+ if (!isValidSessionId(sessionId)) {
30
+ res.status(400).json({ error: 'Invalid sessionId' });
31
+ return null;
32
+ }
33
+ const token = extractToken(req) || 'default';
34
+ return buildSessionName(token, sessionId);
35
+ }
36
+ /** Hash token for settings storage (same prefix as tmux session names) */
37
+ export function tokenHash(token) {
38
+ return createHash('sha256').update(token).digest('hex').slice(0, 8);
39
+ }
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,94 @@
1
+ import { Router } from 'express';
2
+ import { writeFile } from 'fs/promises';
3
+ import { join, basename } from 'path';
4
+ import { resolveSession } from '../middleware/auth.js';
5
+ import { getCwd } from '../tmux.js';
6
+ import { getDraft, saveDraft as saveDraftDb, getAnnotation, saveAnnotation } from '../db.js';
7
+ import { validateNewPath } from '../files.js';
8
+ const router = Router();
9
+ // Get draft for a session
10
+ router.get('/api/sessions/:sessionId/draft', (req, res) => {
11
+ const sessionName = resolveSession(req, res);
12
+ if (!sessionName)
13
+ return;
14
+ const content = getDraft(sessionName);
15
+ res.json({ content });
16
+ });
17
+ // Save (upsert) draft for a session
18
+ router.put('/api/sessions/:sessionId/draft', (req, res) => {
19
+ const sessionName = resolveSession(req, res);
20
+ if (!sessionName)
21
+ return;
22
+ const { content } = req.body;
23
+ if (typeof content !== 'string') {
24
+ res.status(400).json({ error: 'content must be a string' });
25
+ return;
26
+ }
27
+ saveDraftDb(sessionName, content);
28
+ res.json({ ok: true });
29
+ });
30
+ // Get annotation for a file
31
+ router.get('/api/sessions/:sessionId/annotations', (req, res) => {
32
+ const sessionName = resolveSession(req, res);
33
+ if (!sessionName)
34
+ return;
35
+ const filePath = req.query.path;
36
+ if (!filePath) {
37
+ res.status(400).json({ error: 'path query parameter required' });
38
+ return;
39
+ }
40
+ const result = getAnnotation(sessionName, filePath);
41
+ res.json(result || { content: null, updatedAt: 0 });
42
+ });
43
+ // Save (upsert) annotation for a file
44
+ router.put('/api/sessions/:sessionId/annotations', (req, res) => {
45
+ const sessionName = resolveSession(req, res);
46
+ if (!sessionName)
47
+ return;
48
+ const { path: filePath, content, updatedAt } = req.body;
49
+ if (!filePath || typeof filePath !== 'string') {
50
+ res.status(400).json({ error: 'path must be a string' });
51
+ return;
52
+ }
53
+ if (typeof content !== 'string') {
54
+ res.status(400).json({ error: 'content must be a string' });
55
+ return;
56
+ }
57
+ saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
58
+ res.json({ ok: true });
59
+ });
60
+ // Write .tmp-annotations.json for ai-cli-task plan
61
+ router.post('/api/sessions/:sessionId/task-annotations', async (req, res) => {
62
+ const sessionName = resolveSession(req, res);
63
+ if (!sessionName)
64
+ return;
65
+ try {
66
+ const { modulePath, content } = req.body;
67
+ if (!modulePath || typeof modulePath !== 'string') {
68
+ res.status(400).json({ error: 'modulePath must be a string' });
69
+ return;
70
+ }
71
+ if (!content || typeof content !== 'object') {
72
+ res.status(400).json({ error: 'content must be an object' });
73
+ return;
74
+ }
75
+ const cwd = await getCwd(sessionName);
76
+ const targetFile = join(modulePath, '.tmp-annotations.json');
77
+ const resolved = await validateNewPath(targetFile, cwd);
78
+ if (!resolved) {
79
+ res.status(400).json({ error: 'Invalid path' });
80
+ return;
81
+ }
82
+ if (basename(resolved) !== '.tmp-annotations.json') {
83
+ res.status(400).json({ error: 'Only .tmp-annotations.json is allowed' });
84
+ return;
85
+ }
86
+ await writeFile(resolved, JSON.stringify(content, null, 2), 'utf-8');
87
+ res.json({ ok: true, path: resolved });
88
+ }
89
+ catch (err) {
90
+ console.error(`[api:task-annotations] ${sessionName}:`, err);
91
+ res.status(500).json({ error: 'Failed to write annotation file' });
92
+ }
93
+ });
94
+ export default router;
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,312 @@
1
+ import { Router } from 'express';
2
+ import multer from 'multer';
3
+ import { createReadStream, mkdirSync } from 'fs';
4
+ import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
5
+ import { join, dirname, basename, extname } from 'path';
6
+ import { spawn } from 'child_process';
7
+ import { resolveSession } from '../middleware/auth.js';
8
+ import { getCwd } from '../tmux.js';
9
+ import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from '../files.js';
10
+ const router = Router();
11
+ // Multer setup for file uploads
12
+ const UPLOAD_TMP_DIR = '/tmp/ai-cli-online-uploads';
13
+ mkdirSync(UPLOAD_TMP_DIR, { recursive: true, mode: 0o700 });
14
+ const upload = multer({
15
+ dest: UPLOAD_TMP_DIR,
16
+ limits: { fileSize: MAX_UPLOAD_SIZE, files: 10 },
17
+ });
18
+ // List files in directory
19
+ router.get('/api/sessions/:sessionId/files', async (req, res) => {
20
+ const sessionName = resolveSession(req, res);
21
+ if (!sessionName)
22
+ return;
23
+ try {
24
+ const cwd = await getCwd(sessionName);
25
+ const subPath = req.query.path || '';
26
+ let targetDir = null;
27
+ if (subPath) {
28
+ targetDir = await validatePath(subPath, cwd);
29
+ if (!targetDir) {
30
+ const home = process.env.HOME || '/root';
31
+ targetDir = await validatePath(subPath, home);
32
+ }
33
+ }
34
+ else {
35
+ targetDir = cwd;
36
+ }
37
+ if (!targetDir) {
38
+ res.status(400).json({ error: 'Invalid path' });
39
+ return;
40
+ }
41
+ const { files, truncated } = await listFiles(targetDir);
42
+ res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
43
+ }
44
+ catch (err) {
45
+ console.error(`[api:files] ${sessionName}:`, err);
46
+ res.status(404).json({ error: 'Session not found or directory not accessible' });
47
+ }
48
+ });
49
+ // Upload files to CWD
50
+ router.post('/api/sessions/:sessionId/upload', upload.array('files', 10), async (req, res) => {
51
+ const sessionName = resolveSession(req, res);
52
+ if (!sessionName)
53
+ return;
54
+ try {
55
+ const cwd = await getCwd(sessionName);
56
+ const uploadedFiles = req.files;
57
+ if (!uploadedFiles || uploadedFiles.length === 0) {
58
+ res.status(400).json({ error: 'No files provided' });
59
+ return;
60
+ }
61
+ const results = [];
62
+ for (const file of uploadedFiles) {
63
+ const safeName = basename(file.originalname);
64
+ if (!safeName || safeName === '.' || safeName === '..') {
65
+ await unlink(file.path).catch(() => { });
66
+ continue;
67
+ }
68
+ const destPath = join(cwd, safeName);
69
+ await copyFile(file.path, destPath);
70
+ await unlink(file.path).catch(() => { });
71
+ results.push({ name: safeName, size: file.size });
72
+ }
73
+ res.json({ uploaded: results });
74
+ }
75
+ catch (err) {
76
+ console.error('[upload] Failed:', err);
77
+ const files = req.files;
78
+ if (files) {
79
+ for (const f of files) {
80
+ await unlink(f.path).catch(() => { });
81
+ }
82
+ }
83
+ res.status(500).json({ error: 'Upload failed' });
84
+ }
85
+ });
86
+ // Download a file
87
+ router.get('/api/sessions/:sessionId/download', async (req, res) => {
88
+ const sessionName = resolveSession(req, res);
89
+ if (!sessionName)
90
+ return;
91
+ try {
92
+ const cwd = await getCwd(sessionName);
93
+ const filePath = req.query.path;
94
+ if (!filePath) {
95
+ res.status(400).json({ error: 'path query parameter required' });
96
+ return;
97
+ }
98
+ const resolved = await validatePathNoSymlink(filePath, cwd);
99
+ if (!resolved) {
100
+ res.status(400).json({ error: 'Invalid path' });
101
+ return;
102
+ }
103
+ const fileStat = await stat(resolved);
104
+ if (!fileStat.isFile()) {
105
+ res.status(400).json({ error: 'Not a file' });
106
+ return;
107
+ }
108
+ if (fileStat.size > MAX_DOWNLOAD_SIZE) {
109
+ res.status(413).json({ error: 'File too large (max 100MB)' });
110
+ return;
111
+ }
112
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
113
+ res.setHeader('Content-Length', fileStat.size);
114
+ const stream = createReadStream(resolved);
115
+ let bytesWritten = 0;
116
+ stream.on('data', (chunk) => {
117
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
118
+ bytesWritten += buf.length;
119
+ if (bytesWritten > MAX_DOWNLOAD_SIZE) {
120
+ stream.destroy();
121
+ if (!res.writableEnded)
122
+ res.end();
123
+ return;
124
+ }
125
+ if (!res.writableEnded)
126
+ res.write(chunk);
127
+ });
128
+ stream.on('end', () => { if (!res.writableEnded)
129
+ res.end(); });
130
+ stream.on('error', () => { if (!res.writableEnded)
131
+ res.end(); });
132
+ }
133
+ catch (err) {
134
+ console.error(`[api:download] ${sessionName}:`, err);
135
+ res.status(404).json({ error: 'File not found' });
136
+ }
137
+ });
138
+ // Download CWD as tar.gz
139
+ router.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
140
+ const sessionName = resolveSession(req, res);
141
+ if (!sessionName)
142
+ return;
143
+ try {
144
+ const cwd = await getCwd(sessionName);
145
+ if (!cwd.startsWith('/') || cwd.includes('\0')) {
146
+ res.status(400).json({ error: 'Invalid working directory' });
147
+ return;
148
+ }
149
+ const dirName = basename(cwd);
150
+ res.setHeader('Content-Type', 'application/gzip');
151
+ res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
152
+ const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
153
+ tar.stdout.pipe(res);
154
+ tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
155
+ tar.on('error', (err) => {
156
+ console.error('[api:download-cwd] tar error:', err);
157
+ if (!res.headersSent)
158
+ res.status(500).json({ error: 'Failed to create archive' });
159
+ });
160
+ tar.on('close', (code) => {
161
+ if (code !== 0 && !res.headersSent) {
162
+ res.status(500).json({ error: 'Archive creation failed' });
163
+ }
164
+ });
165
+ }
166
+ catch (err) {
167
+ console.error(`[api:download-cwd] ${sessionName}:`, err);
168
+ if (!res.headersSent)
169
+ res.status(500).json({ error: 'Failed to download' });
170
+ }
171
+ });
172
+ // Create empty file
173
+ router.post('/api/sessions/:sessionId/touch', async (req, res) => {
174
+ const sessionName = resolveSession(req, res);
175
+ if (!sessionName)
176
+ return;
177
+ try {
178
+ const { name } = req.body;
179
+ if (!name || typeof name !== 'string' || name.includes('..')) {
180
+ res.status(400).json({ error: 'Invalid filename' });
181
+ return;
182
+ }
183
+ const cwd = await getCwd(sessionName);
184
+ const resolved = await validateNewPath(join(cwd, name), cwd);
185
+ if (!resolved) {
186
+ res.status(400).json({ error: 'Invalid path' });
187
+ return;
188
+ }
189
+ await mkdir(dirname(resolved), { recursive: true });
190
+ await writeFile(resolved, '', { flag: 'wx' });
191
+ res.json({ ok: true, path: resolved });
192
+ }
193
+ catch (err) {
194
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
195
+ const cwd = await getCwd(sessionName).catch(() => '');
196
+ res.json({ ok: true, existed: true, path: cwd ? join(cwd, String(req.body.name)) : '' });
197
+ }
198
+ else {
199
+ console.error(`[api:touch] ${sessionName}:`, err);
200
+ res.status(500).json({ error: 'Failed to create file' });
201
+ }
202
+ }
203
+ });
204
+ // Create directory
205
+ router.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
206
+ const sessionName = resolveSession(req, res);
207
+ if (!sessionName)
208
+ return;
209
+ try {
210
+ const { path: dirPath } = req.body;
211
+ if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
212
+ res.status(400).json({ error: 'Invalid path' });
213
+ return;
214
+ }
215
+ const cwd = await getCwd(sessionName);
216
+ const resolved = await validateNewPath(join(cwd, dirPath), cwd);
217
+ if (!resolved) {
218
+ res.status(400).json({ error: 'Invalid path' });
219
+ return;
220
+ }
221
+ await mkdir(resolved, { recursive: true });
222
+ res.json({ ok: true, path: resolved });
223
+ }
224
+ catch (err) {
225
+ console.error(`[api:mkdir] ${sessionName}:`, err);
226
+ res.status(500).json({ error: 'Failed to create directory' });
227
+ }
228
+ });
229
+ // Delete file or directory
230
+ router.delete('/api/sessions/:sessionId/rm', async (req, res) => {
231
+ const sessionName = resolveSession(req, res);
232
+ if (!sessionName)
233
+ return;
234
+ try {
235
+ const { path: rmPath } = req.body;
236
+ if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
237
+ res.status(400).json({ error: 'Invalid path' });
238
+ return;
239
+ }
240
+ const cwd = await getCwd(sessionName);
241
+ const resolved = await validatePath(rmPath, cwd);
242
+ if (!resolved) {
243
+ res.status(400).json({ error: 'Invalid path' });
244
+ return;
245
+ }
246
+ const fileStat = await stat(resolved);
247
+ if (fileStat.isDirectory()) {
248
+ await rm(resolved, { recursive: true });
249
+ }
250
+ else {
251
+ await unlink(resolved);
252
+ }
253
+ res.json({ ok: true });
254
+ }
255
+ catch (err) {
256
+ console.error(`[api:rm] ${sessionName}:`, err);
257
+ res.status(500).json({ error: 'Failed to delete' });
258
+ }
259
+ });
260
+ // Read file content (for document viewer)
261
+ const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
262
+ const PDF_EXTENSIONS = new Set(['.pdf']);
263
+ router.get('/api/sessions/:sessionId/file-content', async (req, res) => {
264
+ const sessionName = resolveSession(req, res);
265
+ if (!sessionName)
266
+ return;
267
+ const filePath = req.query.path;
268
+ if (!filePath) {
269
+ res.status(400).json({ error: 'path query parameter required' });
270
+ return;
271
+ }
272
+ try {
273
+ const cwd = await getCwd(sessionName);
274
+ let resolved = await validatePathNoSymlink(filePath, cwd);
275
+ if (!resolved) {
276
+ const home = process.env.HOME || '/root';
277
+ resolved = await validatePathNoSymlink(filePath, home);
278
+ }
279
+ if (!resolved) {
280
+ res.status(400).json({ error: 'Invalid path' });
281
+ return;
282
+ }
283
+ const fileStat = await stat(resolved);
284
+ if (!fileStat.isFile()) {
285
+ res.status(400).json({ error: 'Not a file' });
286
+ return;
287
+ }
288
+ if (fileStat.size > MAX_DOC_SIZE) {
289
+ res.status(413).json({ error: 'File too large (max 10MB)' });
290
+ return;
291
+ }
292
+ const since = parseFloat(req.query.since) || 0;
293
+ if (since > 0 && fileStat.mtimeMs <= since) {
294
+ res.status(304).end();
295
+ return;
296
+ }
297
+ const ext = extname(resolved).toLowerCase();
298
+ const isPdf = PDF_EXTENSIONS.has(ext);
299
+ const content = await readFile(resolved, isPdf ? undefined : 'utf-8');
300
+ res.json({
301
+ content: isPdf ? content.toString('base64') : content,
302
+ mtime: fileStat.mtimeMs,
303
+ size: fileStat.size,
304
+ encoding: isPdf ? 'base64' : 'utf-8',
305
+ });
306
+ }
307
+ catch (err) {
308
+ console.error(`[api:file-content] ${sessionName}:`, err);
309
+ res.status(404).json({ error: 'File not found' });
310
+ }
311
+ });
312
+ export default router;
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,64 @@
1
+ import { Router } from 'express';
2
+ import { extractToken, checkAuth, resolveSession } from '../middleware/auth.js';
3
+ import { listSessions, killSession, buildSessionName, getCwd, getPaneCommand } from '../tmux.js';
4
+ import { getActiveSessionNames } from '../websocket.js';
5
+ import { deleteDraft } from '../db.js';
6
+ const router = Router();
7
+ // List sessions for a token
8
+ router.get('/api/sessions', async (req, res) => {
9
+ if (!checkAuth(req, res))
10
+ return;
11
+ const token = extractToken(req) || 'default';
12
+ const sessions = await listSessions(token);
13
+ const activeNames = getActiveSessionNames();
14
+ const result = sessions.map((s) => ({
15
+ sessionId: s.sessionId,
16
+ sessionName: s.sessionName,
17
+ createdAt: s.createdAt,
18
+ active: activeNames.has(s.sessionName),
19
+ }));
20
+ res.json(result);
21
+ });
22
+ // Kill a specific session
23
+ router.delete('/api/sessions/:sessionId', async (req, res) => {
24
+ if (!checkAuth(req, res))
25
+ return;
26
+ const { sessionId } = req.params;
27
+ if (!/^[\w-]+$/.test(sessionId)) {
28
+ res.status(400).json({ error: 'Invalid sessionId' });
29
+ return;
30
+ }
31
+ const token = extractToken(req) || 'default';
32
+ const sessionName = buildSessionName(token, sessionId);
33
+ await killSession(sessionName);
34
+ deleteDraft(sessionName);
35
+ res.json({ ok: true });
36
+ });
37
+ // Get current working directory
38
+ router.get('/api/sessions/:sessionId/cwd', async (req, res) => {
39
+ const sessionName = resolveSession(req, res);
40
+ if (!sessionName)
41
+ return;
42
+ try {
43
+ const cwd = await getCwd(sessionName);
44
+ res.json({ cwd });
45
+ }
46
+ catch (err) {
47
+ console.error(`[api:cwd] ${sessionName}:`, err);
48
+ res.status(404).json({ error: 'Session not found or not running' });
49
+ }
50
+ });
51
+ // Get current pane command (to detect if claude is running)
52
+ router.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
53
+ const sessionName = resolveSession(req, res);
54
+ if (!sessionName)
55
+ return;
56
+ try {
57
+ const command = await getPaneCommand(sessionName);
58
+ res.json({ command });
59
+ }
60
+ catch {
61
+ res.json({ command: '' });
62
+ }
63
+ });
64
+ export default router;
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,72 @@
1
+ import { Router } from 'express';
2
+ import { checkAuth, extractToken, tokenHash } from '../middleware/auth.js';
3
+ import { getSetting, saveSetting } from '../db.js';
4
+ import { safeTokenCompare } from '../auth.js';
5
+ const router = Router();
6
+ // Get font size
7
+ router.get('/api/settings/font-size', (req, res) => {
8
+ if (!checkAuth(req, res))
9
+ return;
10
+ const token = extractToken(req) || 'default';
11
+ const value = getSetting(tokenHash(token), 'font-size');
12
+ const fontSize = value !== null ? parseInt(value, 10) : 14;
13
+ res.json({ fontSize: isNaN(fontSize) ? 14 : fontSize });
14
+ });
15
+ // Save font size
16
+ router.put('/api/settings/font-size', (req, res) => {
17
+ if (!checkAuth(req, res))
18
+ return;
19
+ const token = extractToken(req) || 'default';
20
+ const { fontSize } = req.body;
21
+ if (typeof fontSize !== 'number' || fontSize < 10 || fontSize > 24) {
22
+ res.status(400).json({ error: 'fontSize must be a number between 10 and 24' });
23
+ return;
24
+ }
25
+ saveSetting(tokenHash(token), 'font-size', String(fontSize));
26
+ res.json({ ok: true });
27
+ });
28
+ // Get tabs layout
29
+ router.get('/api/settings/tabs-layout', (req, res) => {
30
+ if (!checkAuth(req, res))
31
+ return;
32
+ const token = extractToken(req) || 'default';
33
+ const value = getSetting(tokenHash(token), 'tabs-layout');
34
+ let layout = null;
35
+ if (value) {
36
+ try {
37
+ layout = JSON.parse(value);
38
+ }
39
+ catch { /* corrupt data */ }
40
+ }
41
+ res.json({ layout });
42
+ });
43
+ // Save tabs layout (supports both Authorization header and body token for sendBeacon)
44
+ router.put('/api/settings/tabs-layout', (req, res) => {
45
+ const { layout, token: bodyToken } = req.body;
46
+ const authToken = process.env.AUTH_TOKEN || '';
47
+ let token;
48
+ if (authToken) {
49
+ token = extractToken(req);
50
+ if (!token && bodyToken) {
51
+ if (!safeTokenCompare(bodyToken, authToken)) {
52
+ res.status(401).json({ error: 'Unauthorized' });
53
+ return;
54
+ }
55
+ token = bodyToken;
56
+ }
57
+ if (!token) {
58
+ res.status(401).json({ error: 'Unauthorized' });
59
+ return;
60
+ }
61
+ }
62
+ else {
63
+ token = extractToken(req) || bodyToken || 'default';
64
+ }
65
+ if (!layout || typeof layout !== 'object') {
66
+ res.status(400).json({ error: 'layout must be an object' });
67
+ return;
68
+ }
69
+ saveSetting(tokenHash(token), 'tabs-layout', JSON.stringify(layout));
70
+ res.json({ ok: true });
71
+ });
72
+ export default router;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online-server",
3
- "version": "2.9.9",
3
+ "version": "3.0.2",
4
4
  "description": "CLI-Online Backend Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-online-shared",
3
- "version": "2.9.9",
3
+ "version": "3.0.2",
4
4
  "description": "Shared types for CLI-Online",
5
5
  "type": "module",
6
6
  "main": "dist/types.js",