neoagent 2.4.1-beta.45 → 2.4.1-beta.46

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,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",
@@ -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
+ 8038ef582d1a04389ee868993a7f5f02
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"c416acfeb8126e097f758c664aaa3da929e27d
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "2397226113" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "1312157190" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -133824,7 +133824,7 @@ r===$&&A.b()
133824
133824
  o.push(A.j4(p,A.j8(!1,new A.a3(B.up,A.dl(new A.cv(B.hr,new A.a7p(r,p),p),p,p),p),!1,B.I,!0),p,p,0,0,0,p))}r=!1
133825
133825
  if(!s.ay)if(!s.ch){r=s.e
133826
133826
  r===$&&A.b()
133827
- r=B.b.t("mq6g3m8k-1096615").length!==0&&r.b}if(r){r=s.d
133827
+ r=B.b.t("mq6pvw31-1a6d75b").length!==0&&r.b}if(r){r=s.d
133828
133828
  r===$&&A.b()
133829
133829
  r=r.au&&!r.aQ?84:0
133830
133830
  q=s.e
@@ -139266,7 +139266,7 @@ $S:0}
139266
139266
  A.ZM.prototype={}
139267
139267
  A.Sw.prototype={
139268
139268
  n9(a){var s=this
139269
- if(B.b.t("mq6g3m8k-1096615").length===0||s.a!=null)return
139269
+ if(B.b.t("mq6pvw31-1a6d75b").length===0||s.a!=null)return
139270
139270
  s.AN()
139271
139271
  s.a=A.qh(B.Rb,new A.bas(s))},
139272
139272
  AN(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -139284,7 +139284,7 @@ if(!t.f.b(k)){s=1
139284
139284
  break}i=J.a_(k,"buildId")
139285
139285
  h=i==null?null:B.b.t(J.p(i))
139286
139286
  j=h==null?"":h
139287
- if(J.bf(j)===0||J.d(j,"mq6g3m8k-1096615")){s=1
139287
+ if(J.bf(j)===0||J.d(j,"mq6pvw31-1a6d75b")){s=1
139288
139288
  break}n.b=!0
139289
139289
  n.E()
139290
139290
  p=2
@@ -139301,7 +139301,7 @@ case 2:return A.i(o.at(-1),r)}})
139301
139301
  return A.k($async$AN,r)},
139302
139302
  vw(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
139303
139303
  var $async$vw=A.h(function(a2,a3){if(a2===1){o.push(a3)
139304
- s=p}for(;;)switch(s){case 0:if(B.b.t("mq6g3m8k-1096615").length===0||n.c){s=1
139304
+ s=p}for(;;)switch(s){case 0:if(B.b.t("mq6pvw31-1a6d75b").length===0||n.c){s=1
139305
139305
  break}n.c=!0
139306
139306
  n.E()
139307
139307
  p=4