neoagent 2.4.1-beta.45 → 2.4.1-beta.47

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.
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>NeoAgent Admin</title>
7
+ <link rel="icon" href="/admin/logo.svg" type="image/svg+xml">
7
8
  <link rel="stylesheet" href="/admin/admin.css">
8
9
  <style>
9
10
  body {
@@ -70,62 +71,148 @@
70
71
  font-size: 14px;
71
72
  margin-top: 4px;
72
73
  }
74
+
75
+ /* ── Admin hero mark ────────────────────────────── */
76
+ .admin-hero {
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: center;
80
+ margin-bottom: 28px;
81
+ gap: 14px;
82
+ }
83
+
84
+ .admin-a-mark {
85
+ width: 80px;
86
+ height: 80px;
87
+ border-radius: 22px;
88
+ background: var(--bg-secondary);
89
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
90
+ box-shadow:
91
+ 0 0 0 4px color-mix(in srgb, var(--accent) 8%, transparent),
92
+ 0 12px 32px rgba(0,0,0,0.45);
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ font-size: 42px;
97
+ font-weight: 800;
98
+ color: var(--accent);
99
+ letter-spacing: -2px;
100
+ font-family: var(--font-sans);
101
+ line-height: 1;
102
+ user-select: none;
103
+ }
104
+
105
+ .admin-hero .brand {
106
+ margin-bottom: 0;
107
+ justify-content: center;
108
+ }
73
109
  </style>
74
110
  </head>
75
111
  <body>
76
112
  <div class="login-wrap">
77
113
  <div class="login-card">
78
- <div class="brand">
79
- <div class="brand-mark">
80
- <img src="/admin/logo.svg" width="32" height="32" alt="">
81
- </div>
82
- <div>
83
- <div class="brand-name">NeoAgent</div>
84
- <div class="brand-sub">Admin</div>
114
+ <div class="admin-hero">
115
+ <div class="admin-a-mark" aria-hidden="true">A</div>
116
+ <div class="brand">
117
+ <div class="brand-mark">
118
+ <img src="/admin/logo.svg" width="32" height="32" alt="">
119
+ </div>
120
+ <div>
121
+ <div class="brand-name">NeoAgent</div>
122
+ <div class="brand-sub">Admin</div>
123
+ </div>
85
124
  </div>
86
125
  </div>
87
126
 
88
- <h1 class="login-heading">Sign in</h1>
89
- <p class="login-sub">
90
- Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
91
- </p>
92
-
93
- <div class="alert alert-error" id="error" role="alert"></div>
94
-
95
- <form id="form" novalidate>
96
- <div class="field">
97
- <label for="username">Username</label>
98
- <input
99
- id="username"
100
- type="text"
101
- autocomplete="username"
102
- autofocus
103
- required
104
- spellcheck="false"
105
- autocorrect="off"
106
- autocapitalize="none"
107
- >
108
- </div>
109
- <div class="field">
110
- <label for="password">Password</label>
111
- <input
112
- id="password"
113
- type="password"
114
- autocomplete="current-password"
115
- required
116
- >
127
+ <!-- Step 1: credentials -->
128
+ <div id="step-credentials">
129
+ <h1 class="login-heading">Sign in</h1>
130
+ <p class="login-sub">
131
+ Credentials are in your <code>.env</code> or via <code>neoagent admin</code>
132
+ </p>
133
+
134
+ <div class="alert alert-error" id="error" role="alert"></div>
135
+
136
+ <form id="form" novalidate>
137
+ <div class="field">
138
+ <label for="username">Username</label>
139
+ <input
140
+ id="username"
141
+ type="text"
142
+ autocomplete="username"
143
+ autofocus
144
+ required
145
+ spellcheck="false"
146
+ autocorrect="off"
147
+ autocapitalize="none"
148
+ >
149
+ </div>
150
+ <div class="field">
151
+ <label for="password">Password</label>
152
+ <input
153
+ id="password"
154
+ type="password"
155
+ autocomplete="current-password"
156
+ required
157
+ >
158
+ </div>
159
+ <button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
160
+ Sign in
161
+ </button>
162
+ </form>
163
+ </div>
164
+
165
+ <!-- Step 2: 2FA code (shown after password is accepted) -->
166
+ <div id="step-totp" style="display:none;">
167
+ <h1 class="login-heading">Two-factor authentication</h1>
168
+ <p class="login-sub">Enter the 6-digit code from your authenticator app, or a recovery code.</p>
169
+
170
+ <div class="alert alert-error" id="totp-error" role="alert"></div>
171
+
172
+ <form id="totp-form" novalidate>
173
+ <div class="field">
174
+ <label for="totp-code">Authenticator code</label>
175
+ <input
176
+ id="totp-code"
177
+ type="text"
178
+ inputmode="numeric"
179
+ autocomplete="one-time-code"
180
+ maxlength="11"
181
+ placeholder="123 456"
182
+ spellcheck="false"
183
+ autocorrect="off"
184
+ autocapitalize="none"
185
+ required
186
+ >
187
+ </div>
188
+ <button type="submit" class="btn btn-primary submit-btn" id="totp-btn">
189
+ Verify
190
+ </button>
191
+ </form>
192
+ <div style="margin-top:14px;text-align:center;">
193
+ <button type="button" class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
194
+ onclick="backToCredentials()">← Back</button>
117
195
  </div>
118
- <button type="submit" class="btn btn-primary submit-btn" id="submit-btn">
119
- Sign in
120
- </button>
121
- </form>
196
+ </div>
122
197
  </div>
123
198
  </div>
124
199
 
125
200
  <script>
126
- const form = document.getElementById('form');
201
+ const form = document.getElementById('form');
127
202
  const submitBtn = document.getElementById('submit-btn');
128
- const errorEl = document.getElementById('error');
203
+ const errorEl = document.getElementById('error');
204
+ const totpForm = document.getElementById('totp-form');
205
+ const totpBtn = document.getElementById('totp-btn');
206
+ const totpError = document.getElementById('totp-error');
207
+
208
+ function showStep(step) {
209
+ document.getElementById('step-credentials').style.display = step === 1 ? '' : 'none';
210
+ document.getElementById('step-totp').style.display = step === 2 ? '' : 'none';
211
+ if (step === 2) setTimeout(() => document.getElementById('totp-code')?.focus(), 50);
212
+ if (step === 1) setTimeout(() => document.getElementById('username')?.focus(), 50);
213
+ }
214
+
215
+ function backToCredentials() { showStep(1); }
129
216
 
130
217
  form.addEventListener('submit', async (e) => {
131
218
  e.preventDefault();
@@ -143,12 +230,18 @@
143
230
  }),
144
231
  });
145
232
 
146
- if (res.ok) {
147
- window.location.replace('/admin');
233
+ const body = await res.json().catch(() => ({}));
234
+
235
+ if (res.ok || body.requiresTwoFactor) {
236
+ if (body.requiresTwoFactor) {
237
+ // Password accepted — go to 2FA step
238
+ showStep(2);
239
+ } else {
240
+ window.location.replace('/admin');
241
+ }
148
242
  return;
149
243
  }
150
244
 
151
- const body = await res.json().catch(() => ({}));
152
245
  errorEl.textContent = body.error || 'Invalid credentials';
153
246
  errorEl.classList.add('visible');
154
247
  } catch {
@@ -159,6 +252,39 @@
159
252
  submitBtn.textContent = 'Sign in';
160
253
  }
161
254
  });
255
+
256
+ totpForm.addEventListener('submit', async (e) => {
257
+ e.preventDefault();
258
+ totpError.classList.remove('visible');
259
+ totpBtn.disabled = true;
260
+ totpBtn.textContent = 'Verifying…';
261
+
262
+ try {
263
+ const code = document.getElementById('totp-code').value.trim();
264
+ const res = await fetch('/admin/api/2fa/verify', {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({ code }),
268
+ });
269
+
270
+ if (res.ok) {
271
+ window.location.replace('/admin');
272
+ return;
273
+ }
274
+
275
+ const body = await res.json().catch(() => ({}));
276
+ totpError.textContent = body.error || 'Invalid code';
277
+ totpError.classList.add('visible');
278
+ document.getElementById('totp-code').value = '';
279
+ document.getElementById('totp-code').focus();
280
+ } catch {
281
+ totpError.textContent = 'Network error';
282
+ totpError.classList.add('visible');
283
+ } finally {
284
+ totpBtn.disabled = false;
285
+ totpBtn.textContent = 'Verify';
286
+ }
287
+ });
162
288
  </script>
163
289
  </body>
164
290
  </html>
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // ── SQL Editor ─────────────────────────────────────────────────────────────
4
+
5
+ const SQL_TEMPLATES = [
6
+ { label: '— Select a template —', query: '' },
7
+ { label: 'User summary', query: `SELECT u.id, u.username, u.email,\n u.created_at, u.last_login,\n COUNT(DISTINCT r.id) AS runs,\n COALESCE(SUM(r.total_tokens),0) AS tokens\nFROM users u\nLEFT JOIN agent_runs r ON r.user_id = u.id\nGROUP BY u.id\nORDER BY runs DESC\nLIMIT 50` },
8
+ { label: 'Recent failed runs', query: `SELECT r.id, u.username, r.title, r.status,\n r.error, r.created_at\nFROM agent_runs r\nJOIN users u ON u.id = r.user_id\nWHERE r.status = 'failed'\nORDER BY r.created_at DESC\nLIMIT 50` },
9
+ { label: 'Artifact storage by user', query: `SELECT u.username,\n COUNT(a.id) AS files,\n SUM(a.byte_size) AS bytes,\n ROUND(SUM(a.byte_size) / 1048576.0, 2) AS mb\nFROM users u\nLEFT JOIN artifacts a ON a.user_id = u.id\nGROUP BY u.id\nORDER BY bytes DESC` },
10
+ { label: 'Active sessions', query: `SELECT u.username, s.ip_address,\n s.user_agent, s.created_at, s.last_seen_at\nFROM user_sessions s\nJOIN users u ON u.id = s.user_id\nWHERE s.revoked_at IS NULL\n AND s.expires_at > datetime('now')\nORDER BY s.last_seen_at DESC\nLIMIT 50` },
11
+ { label: 'Runs per day (30 days)', query: `SELECT DATE(created_at) AS day,\n COUNT(*) AS runs,\n COALESCE(SUM(total_tokens),0) AS tokens\nFROM agent_runs\nWHERE created_at >= datetime('now', '-30 days')\nGROUP BY day\nORDER BY day DESC` },
12
+ { label: 'Most used agents', query: `SELECT a.slug, a.display_name, u.username AS owner,\n COUNT(r.id) AS runs\nFROM agents a\nJOIN users u ON u.id = a.user_id\nLEFT JOIN agent_runs r ON r.agent_id = a.id\nGROUP BY a.id\nORDER BY runs DESC\nLIMIT 20` },
13
+ { label: 'Integration connections', query: `SELECT u.username, ic.provider_key,\n ic.status, ic.account_email, ic.last_connected_at\nFROM integration_connections ic\nJOIN users u ON u.id = ic.user_id\nORDER BY ic.last_connected_at DESC\nLIMIT 50` },
14
+ { label: 'All tables', query: `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name` },
15
+ { label: 'Table info (users)', query: `SELECT * FROM pragma_table_info('users')` },
16
+ ];
17
+
18
+ function loadSql() {
19
+ // Only initialize the editor once
20
+ if (document.getElementById('sql-editor')?.dataset.ready === '1') return;
21
+ initSqlEditor();
22
+ }
23
+
24
+ function initSqlEditor() {
25
+ const el = document.getElementById('sql-content');
26
+ if (!el) return;
27
+
28
+ const templateOptions = SQL_TEMPLATES.map((t, i) =>
29
+ `<option value="${i}">${esc(t.label)}</option>`).join('');
30
+
31
+ el.innerHTML = `
32
+ <div class="sql-toolbar">
33
+ <select id="sql-template" onchange="onSqlTemplate(this)" style="width:auto;min-width:220px;" aria-label="Query template">
34
+ ${templateOptions}
35
+ </select>
36
+ <span class="spacer"></span>
37
+ <button class="btn btn-primary" id="sql-run-btn" onclick="runSql()" style="padding:7px 16px;">
38
+ <svg viewBox="0 0 20 20" fill="currentColor" style="width:13px;height:13px;" aria-hidden="true">
39
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
40
+ </svg>
41
+ Run
42
+ </button>
43
+ </div>
44
+ <textarea id="sql-editor" data-ready="1" class="sql-textarea" spellcheck="false" autocorrect="off" autocapitalize="off"
45
+ placeholder="SELECT * FROM users LIMIT 10"
46
+ onkeydown="onSqlKeydown(event)"
47
+ aria-label="SQL query editor"
48
+ ></textarea>
49
+ <div id="sql-results"></div>`;
50
+ }
51
+
52
+ function onSqlTemplate(sel) {
53
+ const idx = parseInt(sel.value, 10);
54
+ const q = SQL_TEMPLATES[idx]?.query || '';
55
+ const editor = document.getElementById('sql-editor');
56
+ if (editor && q) { editor.value = q; editor.focus(); }
57
+ }
58
+
59
+ function onSqlKeydown(e) {
60
+ // Ctrl+Enter / Cmd+Enter to run
61
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
62
+ e.preventDefault();
63
+ runSql();
64
+ }
65
+ // Tab → insert 2 spaces
66
+ if (e.key === 'Tab') {
67
+ e.preventDefault();
68
+ const ta = e.target;
69
+ const s = ta.selectionStart, end = ta.selectionEnd;
70
+ ta.value = ta.value.slice(0, s) + ' ' + ta.value.slice(end);
71
+ ta.selectionStart = ta.selectionEnd = s + 2;
72
+ }
73
+ }
74
+
75
+ async function runSql() {
76
+ const query = document.getElementById('sql-editor')?.value?.trim();
77
+ if (!query) return;
78
+ const resultsEl = document.getElementById('sql-results');
79
+ const runBtn = document.getElementById('sql-run-btn');
80
+ if (!resultsEl || !runBtn) return;
81
+
82
+ runBtn.disabled = true;
83
+ resultsEl.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
84
+
85
+ try {
86
+ const res = await api('/admin/api/sql', {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'application/json' },
89
+ body: JSON.stringify({ query }),
90
+ });
91
+ const data = await res.json();
92
+
93
+ if (!res.ok) {
94
+ resultsEl.innerHTML = `<div class="sql-error">${esc(data.error || 'Query failed')}</div>`;
95
+ return;
96
+ }
97
+
98
+ if (!data.rows?.length) {
99
+ resultsEl.innerHTML = '<div class="empty">Query returned 0 rows</div>';
100
+ return;
101
+ }
102
+
103
+ resultsEl.innerHTML = `
104
+ <div class="sql-meta">
105
+ ${fmtNum(data.rows.length)}${data.truncated ? ` of ${fmtNum(data.total)}` : ''} row${data.rows.length === 1 ? '' : 's'}
106
+ ${data.truncated ? '<span class="badge badge-warn" style="margin-left:6px;">truncated at 500</span>' : ''}
107
+ <button class="btn btn-ghost" style="margin-left:auto;padding:4px 10px;font-size:11px;" onclick="copySqlResults()">Copy CSV</button>
108
+ </div>
109
+ <div class="sql-result-wrap">
110
+ <table class="sql-result-table">
111
+ <thead><tr>${data.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead>
112
+ <tbody>${data.rows.map((row) =>
113
+ `<tr>${data.columns.map((c) => `<td>${esc(row[c] == null ? 'NULL' : String(row[c]))}</td>`).join('')}</tr>`
114
+ ).join('')}</tbody>
115
+ </table>
116
+ </div>`;
117
+ // stash for copy
118
+ window._lastSqlData = data;
119
+ } catch (err) {
120
+ if (err.message !== 'unauthorized') {
121
+ resultsEl.innerHTML = `<div class="sql-error">Network error</div>`;
122
+ }
123
+ } finally {
124
+ runBtn.disabled = false;
125
+ }
126
+ }
127
+
128
+ function copySqlResults() {
129
+ const d = window._lastSqlData;
130
+ if (!d) return;
131
+ const header = d.columns.join(',');
132
+ const rows = d.rows.map((r) => d.columns.map((c) => JSON.stringify(r[c] ?? '')).join(','));
133
+ navigator.clipboard.writeText([header, ...rows].join('\n')).catch(() => {});
134
+ }
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ // ── User Management ────────────────────────────────────────────────────────
4
+
5
+ let _userSearchTimer = null;
6
+
7
+ async function loadUsers() {
8
+ const q = document.getElementById('user-search')?.value?.trim() || '';
9
+ await fetchUsers(q);
10
+ }
11
+
12
+ async function fetchUsers(q = '') {
13
+ const el = document.getElementById('users-table-wrap');
14
+ if (!el) return;
15
+ el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
16
+ try {
17
+ const url = q ? `/admin/api/users?q=${encodeURIComponent(q)}` : '/admin/api/users';
18
+ const data = await api(url).then((r) => r.json());
19
+ renderUsersTable(el, data.users || []);
20
+ const cnt = document.getElementById('users-count');
21
+ if (cnt) cnt.textContent = `${(data.users || []).length} user${(data.users || []).length === 1 ? '' : 's'}`;
22
+ } catch (err) {
23
+ if (err.message !== 'unauthorized') el.innerHTML = '<div class="empty">Failed to load users</div>';
24
+ }
25
+ }
26
+
27
+ function onUserSearch() {
28
+ clearTimeout(_userSearchTimer);
29
+ _userSearchTimer = setTimeout(() => {
30
+ const q = document.getElementById('user-search')?.value?.trim() || '';
31
+ fetchUsers(q);
32
+ }, 300);
33
+ }
34
+
35
+ function renderUsersTable(el, users) {
36
+ if (!users.length) {
37
+ el.innerHTML = '<div class="empty">No users found</div>';
38
+ return;
39
+ }
40
+ el.innerHTML = `
41
+ <table class="users-table">
42
+ <thead><tr>
43
+ <th>User</th>
44
+ <th>Email</th>
45
+ <th>Joined</th>
46
+ <th>Last Login</th>
47
+ <th>Runs</th>
48
+ <th>Storage</th>
49
+ <th style="text-align:right;">Actions</th>
50
+ </tr></thead>
51
+ <tbody>${users.map((u) => `
52
+ <tr data-uid="${esc(u.id)}">
53
+ <td>
54
+ <div style="display:flex;align-items:center;gap:9px;">
55
+ <span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span>
56
+ <div>
57
+ <div style="font-weight:600;color:var(--text);">${esc(u.display_name || u.username)}</div>
58
+ ${u.display_name && u.display_name !== u.username ? `<div style="font-size:11px;color:var(--text-muted);">@${esc(u.username)}</div>` : ''}
59
+ </div>
60
+ </div>
61
+ </td>
62
+ <td>
63
+ <span style="font-size:12px;">${esc(u.email || '—')}</span>
64
+ ${u.email_verified_at
65
+ ? '<span class="badge badge-ok" style="font-size:10px;margin-left:5px;">verified</span>'
66
+ : u.email ? '<span class="badge badge-warn" style="font-size:10px;margin-left:5px;">unverified</span>' : ''}
67
+ </td>
68
+ <td style="font-size:12px;color:var(--text-muted);">${fmtDate(u.created_at)}</td>
69
+ <td style="font-size:12px;color:var(--text-muted);">${u.last_login ? fmtDate(u.last_login) : '—'}</td>
70
+ <td style="font-family:var(--font-mono);font-size:12px;">${fmtNum(u.run_count)}</td>
71
+ <td style="font-family:var(--font-mono);font-size:12px;">${fmtBytes(u.storage_bytes)}</td>
72
+ <td>
73
+ <div style="display:flex;gap:6px;justify-content:flex-end;">
74
+ <button class="btn btn-ghost" style="padding:5px 10px;font-size:11px;"
75
+ onclick="forceLogout('${esc(u.id)}','${esc(u.username)}',this)" title="Revoke all sessions">
76
+ Logout
77
+ </button>
78
+ <button class="btn btn-danger" style="padding:5px 10px;font-size:11px;"
79
+ onclick="deleteUser('${esc(u.id)}','${esc(u.display_name || u.username)}',this)" title="GDPR: delete all user data">
80
+ Delete
81
+ </button>
82
+ </div>
83
+ </td>
84
+ </tr>`).join('')}
85
+ </tbody>
86
+ </table>
87
+ <p class="gdpr-note">
88
+ ⚠ <strong>Delete</strong> permanently erases all user data (runs, messages, memories, artifacts, sessions) — irreversible. Required by GDPR Art. 17 right to erasure.
89
+ </p>`;
90
+ }
91
+
92
+ async function forceLogout(id, username, btn) {
93
+ if (!confirm(`Force logout ${username}?\n\nThis will revoke all active sessions. They can log back in.`)) return;
94
+ btn.disabled = true;
95
+ try {
96
+ const res = await api(`/admin/api/users/${id}/sessions`, { method: 'DELETE' });
97
+ if (res.ok) {
98
+ btn.textContent = 'Done';
99
+ setTimeout(loadUsers, 800);
100
+ } else {
101
+ const b = await res.json().catch(() => ({}));
102
+ alert(b.error || 'Failed');
103
+ btn.disabled = false;
104
+ }
105
+ } catch (err) {
106
+ if (err.message !== 'unauthorized') alert('Network error');
107
+ btn.disabled = false;
108
+ }
109
+ }
110
+
111
+ async function deleteUser(id, displayName, btn) {
112
+ if (!confirm(
113
+ `⚠ GDPR Erasure — permanently delete "${displayName}"?\n\n` +
114
+ 'This will delete the account and ALL associated data:\n' +
115
+ '• Agent runs, steps, and messages\n• Memories and conversations\n• Integrations and platform connections\n• Artifacts and files on disk\n• All sessions\n\n' +
116
+ 'This action CANNOT be undone.'
117
+ )) return;
118
+ btn.disabled = true;
119
+ btn.textContent = 'Deleting…';
120
+ try {
121
+ const res = await api(`/admin/api/users/${id}`, { method: 'DELETE' });
122
+ if (res.ok) {
123
+ btn.closest('tr')?.remove();
124
+ const cnt = document.getElementById('users-count');
125
+ if (cnt) {
126
+ const n = parseInt(cnt.textContent) - 1;
127
+ cnt.textContent = `${n} user${n === 1 ? '' : 's'}`;
128
+ }
129
+ } else {
130
+ const b = await res.json().catch(() => ({}));
131
+ alert(b.error || 'Failed to delete user');
132
+ btn.disabled = false;
133
+ btn.textContent = 'Delete';
134
+ }
135
+ } catch (err) {
136
+ if (err.message !== 'unauthorized') alert('Network error');
137
+ btn.disabled = false;
138
+ btn.textContent = 'Delete';
139
+ }
140
+ }
141
+
142
+ function fmtDate(iso) {
143
+ if (!iso) return '—';
144
+ try {
145
+ return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
146
+ } catch { return iso; }
147
+ }
@@ -1074,6 +1074,25 @@ try {
1074
1074
  // Ignore cleanup failures for obsolete wearable tables.
1075
1075
  }
1076
1076
 
1077
+ // Admin 2FA tables (singleton — no foreign key, no user_id)
1078
+ db.exec(`
1079
+ CREATE TABLE IF NOT EXISTS admin_two_factor (
1080
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1081
+ enabled INTEGER NOT NULL DEFAULT 0,
1082
+ secret TEXT,
1083
+ pending_secret TEXT,
1084
+ enabled_at TEXT,
1085
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1086
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1087
+ );
1088
+ CREATE TABLE IF NOT EXISTS admin_recovery_codes (
1089
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1090
+ code_hash TEXT NOT NULL,
1091
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1092
+ used_at TEXT
1093
+ );
1094
+ `);
1095
+
1077
1096
  // Migrations for existing databases
1078
1097
  for (const col of [
1079
1098
  "ALTER TABLE agent_runs ADD COLUMN agent_id TEXT",
@@ -7,6 +7,7 @@ const { DATA_DIR } = require('../../runtime/paths');
7
7
  const { requireAuth } = require('../middleware/auth');
8
8
 
9
9
  const FLUTTER_WEB_DIR = path.join(__dirname, '..', 'public');
10
+ const LANDING_DIR = path.join(__dirname, '..', '..', 'landing');
10
11
  const BUILD_ID_PATH = path.join(FLUTTER_WEB_DIR, '.last_build_id');
11
12
 
12
13
  function setNoStoreHeaders(res) {
@@ -66,11 +67,20 @@ function registerStaticRoutes(app) {
66
67
  const adminRouter = require('../routes/admin');
67
68
  app.use('/admin', adminRouter);
68
69
 
69
- app.use(express.static(FLUTTER_WEB_DIR, {
70
- index: false,
71
- setHeaders: setFlutterStaticHeaders,
72
- }));
73
- app.get(/^\/(?!api|admin|screenshots|telnyx-audio).*/, serveFlutterApp);
70
+ // Flutter app at /app
71
+ app.use(
72
+ '/app',
73
+ express.static(FLUTTER_WEB_DIR, {
74
+ index: false,
75
+ setHeaders: setFlutterStaticHeaders,
76
+ })
77
+ );
78
+ app.get(/^\/app(\/.*)?$/, serveFlutterApp);
79
+
80
+ // Landing page at /
81
+ app.use(express.static(LANDING_DIR));
82
+ app.get('/', (req, res) => res.sendFile(path.join(LANDING_DIR, 'index.html')));
83
+ app.get('/legal.html', (req, res) => res.sendFile(path.join(LANDING_DIR, 'legal.html')));
74
84
  }
75
85
 
76
86
  function serveFlutterApp(req, res) {
@@ -83,11 +93,15 @@ function serveFlutterApp(req, res) {
83
93
  );
84
94
  }
85
95
  setNoStoreHeaders(res);
86
- return res.sendFile(entry);
96
+ // Rewrite <base href="/"> to <base href="/app/"> so Flutter asset paths resolve correctly
97
+ const html = fs.readFileSync(entry, 'utf8').replace('<base href="/">', '<base href="/app/">');
98
+ res.set('Content-Type', 'text/html');
99
+ return res.send(html);
87
100
  }
88
101
 
89
102
  module.exports = {
90
103
  FLUTTER_WEB_DIR,
104
+ LANDING_DIR,
91
105
  registerStaticRoutes,
92
106
  serveFlutterApp
93
107
  };
@@ -1,7 +1,44 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * Constant-time string comparison — prevents timing attacks on API key checks.
7
+ */
8
+ function safeEqual(a, b) {
9
+ if (!a || !b) return false;
10
+ const bufA = Buffer.from(String(a));
11
+ const bufB = Buffer.from(String(b));
12
+ if (bufA.length !== bufB.length) {
13
+ // Lengths differ — do a dummy compare anyway to keep timing consistent
14
+ crypto.timingSafeEqual(bufA, Buffer.alloc(bufA.length));
15
+ return false;
16
+ }
17
+ return crypto.timingSafeEqual(bufA, bufB);
18
+ }
19
+
20
+ /**
21
+ * Allows admin access via either:
22
+ * 1. Browser session (`req.session.isAdmin === true`)
23
+ * 2. API key (`Authorization: Bearer <ADMIN_API_KEY>`)
24
+ *
25
+ * When ADMIN_API_KEY is absent or empty, Bearer auth is disabled entirely.
26
+ */
3
27
  function requireAdminAuth(req, res, next) {
28
+ // 1. Session-based auth (admin web dashboard)
4
29
  if (req.session?.isAdmin === true) return next();
30
+
31
+ // 2. API key auth (programmatic / scripts)
32
+ const configuredKey = process.env.ADMIN_API_KEY;
33
+ if (configuredKey) {
34
+ const authHeader = String(req.headers.authorization || '');
35
+ if (authHeader.startsWith('Bearer ')) {
36
+ const provided = authHeader.slice(7).trim();
37
+ if (safeEqual(provided, configuredKey)) return next();
38
+ }
39
+ }
40
+
41
+ // 3. Reject
5
42
  if (req.path.startsWith('/api/')) {
6
43
  return res.status(401).json({ error: 'Admin authentication required' });
7
44
  }
@@ -1 +1 @@
1
- 8518f61f8e30cec23dd6f87e3ba20ac3
1
+ 1a8195f0cf8ff4ea724469829b5dfbbb