neoagent 2.1.18-beta.98 → 2.2.0
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/README.md +4 -3
- package/package.json +12 -5
- package/server/db/database.js +22 -0
- package/server/public/assets/NOTICES +33 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/assets/web/icons/Icon-192.png +0 -0
- package/server/public/favicon.png +0 -0
- package/server/public/favicon.svg +6 -6
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/icons/Icon-192.png +0 -0
- package/server/public/icons/Icon-512.png +0 -0
- package/server/public/icons/Icon-maskable-192.png +0 -0
- package/server/public/icons/Icon-maskable-512.png +0 -0
- package/server/public/main.dart.js +69558 -69369
- package/server/routes/account.js +36 -0
- package/server/routes/auth.js +91 -0
- package/server/routes/mcp.js +16 -3
- package/server/routes/scheduler.js +2 -1
- package/server/services/account/qr_login.js +388 -0
- package/server/services/scheduler/cron.js +15 -2
package/server/routes/account.js
CHANGED
|
@@ -34,6 +34,10 @@ const {
|
|
|
34
34
|
sendEmailChangeRequestedNotice,
|
|
35
35
|
sendPasswordChangedNotice,
|
|
36
36
|
} = require('../services/account/service_email');
|
|
37
|
+
const {
|
|
38
|
+
approveChallenge,
|
|
39
|
+
resolveChallengeForApproval,
|
|
40
|
+
} = require('../services/account/qr_login');
|
|
37
41
|
|
|
38
42
|
const accountLimiter = rateLimit({
|
|
39
43
|
windowMs: 15 * 60 * 1000,
|
|
@@ -222,6 +226,38 @@ router.post('/2fa/recovery-codes', accountLimiter, async (req, res) => {
|
|
|
222
226
|
}
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
router.post('/qr-login/resolve', accountLimiter, (req, res) => {
|
|
230
|
+
try {
|
|
231
|
+
const challengeId = String(req.body?.challengeId || '').trim();
|
|
232
|
+
const secret = String(req.body?.secret || '').trim();
|
|
233
|
+
if (!challengeId || !secret) {
|
|
234
|
+
return res.status(400).json({ error: 'Challenge id and QR secret are required.' });
|
|
235
|
+
}
|
|
236
|
+
res.json(resolveChallengeForApproval({ challengeId, secret }));
|
|
237
|
+
} catch (err) {
|
|
238
|
+
sendRouteError(res, err);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
router.post('/qr-login/approve', accountLimiter, (req, res) => {
|
|
243
|
+
try {
|
|
244
|
+
const challengeId = String(req.body?.challengeId || '').trim();
|
|
245
|
+
const secret = String(req.body?.secret || '').trim();
|
|
246
|
+
if (!challengeId || !secret) {
|
|
247
|
+
return res.status(400).json({ error: 'Challenge id and QR secret are required.' });
|
|
248
|
+
}
|
|
249
|
+
res.json(approveChallenge({
|
|
250
|
+
challengeId,
|
|
251
|
+
secret,
|
|
252
|
+
userId: req.session.userId,
|
|
253
|
+
approverSessionId: req.sessionID,
|
|
254
|
+
approvalMetadata: req.body?.approvalMetadata,
|
|
255
|
+
}));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
sendRouteError(res, err);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
225
261
|
router.get('/sessions', (req, res) => {
|
|
226
262
|
try {
|
|
227
263
|
res.json({ sessions: listSessions(req, req.session.userId) });
|
package/server/routes/auth.js
CHANGED
|
@@ -24,6 +24,11 @@ const {
|
|
|
24
24
|
sendSignupConfirmation,
|
|
25
25
|
sendUnusualLoginNotice,
|
|
26
26
|
} = require('../services/account/service_email');
|
|
27
|
+
const {
|
|
28
|
+
claimApprovedChallenge,
|
|
29
|
+
createChallenge,
|
|
30
|
+
getChallengeStatusForPoll,
|
|
31
|
+
} = require('../services/account/qr_login');
|
|
27
32
|
|
|
28
33
|
const authLimiter = rateLimit({
|
|
29
34
|
windowMs: 15 * 60 * 1000,
|
|
@@ -33,6 +38,22 @@ const authLimiter = rateLimit({
|
|
|
33
38
|
legacyHeaders: false,
|
|
34
39
|
});
|
|
35
40
|
|
|
41
|
+
const qrLoginPollLimiter = rateLimit({
|
|
42
|
+
windowMs: 15 * 60 * 1000,
|
|
43
|
+
max: 180,
|
|
44
|
+
message: { error: 'Too many QR login status checks, try again shortly' },
|
|
45
|
+
standardHeaders: true,
|
|
46
|
+
legacyHeaders: false,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const qrLoginClaimLimiter = rateLimit({
|
|
50
|
+
windowMs: 15 * 60 * 1000,
|
|
51
|
+
max: 40,
|
|
52
|
+
message: { error: 'Too many QR login completion attempts, try again shortly' },
|
|
53
|
+
standardHeaders: true,
|
|
54
|
+
legacyHeaders: false,
|
|
55
|
+
});
|
|
56
|
+
|
|
36
57
|
const passwordResetLimiter = rateLimit({
|
|
37
58
|
windowMs: 15 * 60 * 1000,
|
|
38
59
|
max: 8,
|
|
@@ -87,6 +108,12 @@ function establishSession(req, res, user) {
|
|
|
87
108
|
});
|
|
88
109
|
}
|
|
89
110
|
|
|
111
|
+
function baseUrlFor(req) {
|
|
112
|
+
const configured = req.app?.locals?.httpRuntimeConfig?.publicUrl || process.env.PUBLIC_URL || '';
|
|
113
|
+
if (configured) return String(configured).replace(/\/+$/, '');
|
|
114
|
+
return `${req.protocol}://${req.get('host')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
function readAuthenticatedUser(req) {
|
|
91
118
|
if (!req.session || !req.session.userId) {
|
|
92
119
|
return null;
|
|
@@ -550,6 +577,70 @@ router.post('/api/auth/password/forgot', authLimiter, async (req, res) => {
|
|
|
550
577
|
}
|
|
551
578
|
});
|
|
552
579
|
|
|
580
|
+
router.post('/api/auth/qr-login/challenge', authLimiter, (req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const challenge = createChallenge(req, {
|
|
583
|
+
requestMetadata: req.body?.requestMetadata,
|
|
584
|
+
});
|
|
585
|
+
const payload = new URL('neoagent://qr-login');
|
|
586
|
+
payload.searchParams.set('v', '1');
|
|
587
|
+
payload.searchParams.set('backend', baseUrlFor(req));
|
|
588
|
+
payload.searchParams.set('challenge', challenge.challengeId);
|
|
589
|
+
payload.searchParams.set('secret', challenge.approveSecret);
|
|
590
|
+
res.json({
|
|
591
|
+
challengeId: challenge.challengeId,
|
|
592
|
+
pollToken: challenge.pollToken,
|
|
593
|
+
expiresAt: challenge.expiresAt,
|
|
594
|
+
status: challenge.status,
|
|
595
|
+
qrPayload: payload.toString(),
|
|
596
|
+
backendUrl: baseUrlFor(req),
|
|
597
|
+
});
|
|
598
|
+
} catch (error) {
|
|
599
|
+
res.status(Number(error?.statusCode || 500)).json({
|
|
600
|
+
error: error?.message || 'Could not create QR login request.',
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
router.post('/api/auth/qr-login/challenge/:id/status', qrLoginPollLimiter, (req, res) => {
|
|
606
|
+
try {
|
|
607
|
+
const pollToken = String(req.body?.token || '').trim();
|
|
608
|
+
if (!pollToken) {
|
|
609
|
+
return res.status(400).json({ error: 'QR login poll token is required.' });
|
|
610
|
+
}
|
|
611
|
+
res.json(
|
|
612
|
+
getChallengeStatusForPoll({
|
|
613
|
+
challengeId: req.params.id,
|
|
614
|
+
pollToken,
|
|
615
|
+
}),
|
|
616
|
+
);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
res.status(Number(error?.statusCode || 500)).json({
|
|
619
|
+
error: error?.message || 'Could not read QR login status.',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
router.post('/api/auth/qr-login/challenge/:id/claim', qrLoginClaimLimiter, (req, res) => {
|
|
625
|
+
try {
|
|
626
|
+
const pollToken = String(req.body?.token || '').trim();
|
|
627
|
+
if (!pollToken) {
|
|
628
|
+
return res.status(400).json({ error: 'QR login poll token is required.' });
|
|
629
|
+
}
|
|
630
|
+
const result = claimApprovedChallenge({
|
|
631
|
+
challengeId: req.params.id,
|
|
632
|
+
pollToken,
|
|
633
|
+
});
|
|
634
|
+
updateLastLogin(result.user.id);
|
|
635
|
+
return establishSession(req, res, result.user);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
const statusCode = Number(error?.statusCode || 500);
|
|
638
|
+
return res.status(statusCode).json({
|
|
639
|
+
error: error?.message || 'Could not complete QR login.',
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
553
644
|
router.get('/api/auth/password/reset', (req, res) => {
|
|
554
645
|
const token = String(req.query?.token || '').trim();
|
|
555
646
|
if (!token) {
|
package/server/routes/mcp.js
CHANGED
|
@@ -4,7 +4,7 @@ const db = require('../db/database');
|
|
|
4
4
|
const { requireAuth } = require('../middleware/auth');
|
|
5
5
|
const { sanitizeError } = require('../utils/security');
|
|
6
6
|
const { validateRemoteMcpEndpoint } = require('../services/runtime/mcp');
|
|
7
|
-
const { getAgentIdFromRequest, resolveAgentId } = require('../services/agents/manager');
|
|
7
|
+
const { getAgentIdFromRequest, isMainAgent, resolveAgentId } = require('../services/agents/manager');
|
|
8
8
|
const { resolvePublicBaseUrl } = require('../services/integrations/env');
|
|
9
9
|
|
|
10
10
|
const MCP_OAUTH_STATE_RE = /^(\d+)::[a-f0-9]{32}$/;
|
|
@@ -21,9 +21,22 @@ router.use(requireAuth);
|
|
|
21
21
|
|
|
22
22
|
// List configured MCP servers
|
|
23
23
|
router.get('/', (req, res) => {
|
|
24
|
-
const
|
|
24
|
+
const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
|
|
25
|
+
const includeLegacyMainServers = isMainAgent(req.session.userId, agentId);
|
|
26
|
+
const servers = includeLegacyMainServers
|
|
27
|
+
? db.prepare(
|
|
28
|
+
`SELECT * FROM mcp_servers
|
|
29
|
+
WHERE user_id = ?
|
|
30
|
+
AND (agent_id = ? OR agent_id IS NULL)
|
|
31
|
+
ORDER BY name ASC`
|
|
32
|
+
).all(req.session.userId, agentId)
|
|
33
|
+
: db.prepare(
|
|
34
|
+
`SELECT * FROM mcp_servers
|
|
35
|
+
WHERE user_id = ? AND agent_id = ?
|
|
36
|
+
ORDER BY name ASC`
|
|
37
|
+
).all(req.session.userId, agentId);
|
|
25
38
|
const mcpClient = req.app.locals.mcpClient;
|
|
26
|
-
const liveStatuses = mcpClient.getStatus(req.session.userId);
|
|
39
|
+
const liveStatuses = mcpClient.getStatus(req.session.userId, { agentId });
|
|
27
40
|
|
|
28
41
|
const result = servers.map(s => ({
|
|
29
42
|
id: s.id,
|
|
@@ -10,7 +10,8 @@ router.use(requireAuth);
|
|
|
10
10
|
// List scheduled tasks
|
|
11
11
|
router.get('/', (req, res) => {
|
|
12
12
|
const scheduler = req.app.locals.scheduler;
|
|
13
|
-
|
|
13
|
+
const agentId = resolveAgentId(req.session.userId, getAgentIdFromRequest(req));
|
|
14
|
+
res.json(scheduler.listTasks(req.session.userId, { agentId }));
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
// Create a new scheduled task
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { randomUUID } = require('crypto');
|
|
5
|
+
const db = require('../../db/database');
|
|
6
|
+
const { clientIpFromRequest, lookupIpLocation } = require('./geoip');
|
|
7
|
+
const { sessionHash } = require('./sessions');
|
|
8
|
+
|
|
9
|
+
const QR_LOGIN_TTL_MS = 2 * 60 * 1000;
|
|
10
|
+
const QR_LOGIN_TERMINAL_RETENTION_MS = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function sqliteDateFromMs(ms) {
|
|
13
|
+
return new Date(ms).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function tokenHash(token) {
|
|
17
|
+
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseSqliteUtcMs(value) {
|
|
21
|
+
const raw = String(value || '').trim();
|
|
22
|
+
if (!raw) return Number.NaN;
|
|
23
|
+
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
|
24
|
+
const utcValue = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)
|
|
25
|
+
? normalized
|
|
26
|
+
: `${normalized}Z`;
|
|
27
|
+
return Date.parse(utcValue);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function randomToken(bytes = 24) {
|
|
31
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function trimmedString(value, maxLength = 160) {
|
|
35
|
+
return String(value || '').trim().slice(0, maxLength);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function userAgentFromRequest(req) {
|
|
39
|
+
return trimmedString(req.get?.('user-agent') || req.headers?.['user-agent'] || '', 500);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseJsonObject(value) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(value || '{}');
|
|
45
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeMetadata(value) {
|
|
52
|
+
const raw = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
53
|
+
const metadata = {
|
|
54
|
+
deviceLabel: trimmedString(raw.deviceLabel, 120),
|
|
55
|
+
platformLabel: trimmedString(raw.platformLabel, 60),
|
|
56
|
+
browserLabel: trimmedString(raw.browserLabel, 60),
|
|
57
|
+
deviceClass: trimmedString(raw.deviceClass, 24).toLowerCase(),
|
|
58
|
+
appMode: trimmedString(raw.appMode, 24).toLowerCase(),
|
|
59
|
+
platform: trimmedString(raw.platform, 32).toLowerCase(),
|
|
60
|
+
};
|
|
61
|
+
if (!['mobile', 'tablet', 'desktop', 'server', 'unknown'].includes(metadata.deviceClass)) {
|
|
62
|
+
metadata.deviceClass = '';
|
|
63
|
+
}
|
|
64
|
+
return metadata;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseClientDescriptor(userAgent, metadata = {}) {
|
|
68
|
+
const lower = String(userAgent || '').toLowerCase();
|
|
69
|
+
const isTablet = lower.includes('ipad') || lower.includes('tablet');
|
|
70
|
+
const isMobile = !isTablet && (
|
|
71
|
+
lower.includes('iphone')
|
|
72
|
+
|| lower.includes('android') && lower.includes('mobile')
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const platformLabel = metadata.platformLabel || (() => {
|
|
76
|
+
if (lower.includes('iphone')) return 'iPhone';
|
|
77
|
+
if (lower.includes('ipad')) return 'iPad';
|
|
78
|
+
if (lower.includes('android')) return 'Android';
|
|
79
|
+
if (lower.includes('mac os x') || lower.includes('macintosh')) return 'macOS';
|
|
80
|
+
if (lower.includes('windows nt')) return 'Windows';
|
|
81
|
+
if (lower.includes('linux') || lower.includes('x11')) return 'Linux';
|
|
82
|
+
if (lower.includes('curl/') || lower.includes('wget/') || lower.includes('httpie/')) return 'CLI session';
|
|
83
|
+
return 'Unknown device';
|
|
84
|
+
})();
|
|
85
|
+
|
|
86
|
+
const browserLabel = metadata.browserLabel || (() => {
|
|
87
|
+
if (lower.includes('edg/')) return 'Edge';
|
|
88
|
+
if (lower.includes('opr/') || lower.includes('opera/')) return 'Opera';
|
|
89
|
+
if (lower.includes('brave/')) return 'Brave';
|
|
90
|
+
if (lower.includes('firefox/')) return 'Firefox';
|
|
91
|
+
if (lower.includes('chrome/') || lower.includes('crios/') || lower.includes('chromium/')) return 'Chrome';
|
|
92
|
+
if (lower.includes('safari/') && lower.includes('version/')) return 'Safari';
|
|
93
|
+
if (lower.includes('dart/')) return 'Flutter app';
|
|
94
|
+
if (lower.includes('curl/')) return 'curl';
|
|
95
|
+
if (lower.includes('wget/')) return 'wget';
|
|
96
|
+
if (lower.includes('httpie/')) return 'HTTPie';
|
|
97
|
+
return 'Unknown browser';
|
|
98
|
+
})();
|
|
99
|
+
|
|
100
|
+
const deviceClass = metadata.deviceClass || (() => {
|
|
101
|
+
if (platformLabel === 'CLI session') return 'server';
|
|
102
|
+
if (isTablet) return 'tablet';
|
|
103
|
+
if (isMobile) return 'mobile';
|
|
104
|
+
if (['macOS', 'Windows', 'Linux'].includes(platformLabel)) return 'desktop';
|
|
105
|
+
return 'unknown';
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
const primaryLabel = metadata.deviceLabel || (() => {
|
|
109
|
+
const parts = [platformLabel];
|
|
110
|
+
if (browserLabel && browserLabel !== 'Unknown browser' && browserLabel !== 'Flutter app') {
|
|
111
|
+
parts.push(browserLabel);
|
|
112
|
+
} else if (browserLabel === 'Flutter app') {
|
|
113
|
+
parts.push('App');
|
|
114
|
+
}
|
|
115
|
+
return parts.join(' · ');
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
label: primaryLabel,
|
|
120
|
+
platformLabel,
|
|
121
|
+
browserLabel,
|
|
122
|
+
deviceClass,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function challengeIsExpired(row, nowMs = Date.now()) {
|
|
127
|
+
const expiresAtMs = parseSqliteUtcMs(row?.expires_at);
|
|
128
|
+
return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function challengeNotFoundError() {
|
|
132
|
+
const error = new Error('QR login request was not found or has expired.');
|
|
133
|
+
error.statusCode = 404;
|
|
134
|
+
return error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function challengeStateError(message, statusCode = 409) {
|
|
138
|
+
const error = new Error(message);
|
|
139
|
+
error.statusCode = statusCode;
|
|
140
|
+
return error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pruneExpiredChallenges() {
|
|
144
|
+
db.prepare(`
|
|
145
|
+
UPDATE user_qr_login_challenges
|
|
146
|
+
SET status = 'expired'
|
|
147
|
+
WHERE status IN ('pending', 'approved')
|
|
148
|
+
AND datetime(expires_at) <= datetime('now')
|
|
149
|
+
`).run();
|
|
150
|
+
const retentionCutoff = sqliteDateFromMs(
|
|
151
|
+
Date.now() - QR_LOGIN_TERMINAL_RETENTION_MS,
|
|
152
|
+
);
|
|
153
|
+
db.prepare(`
|
|
154
|
+
DELETE FROM user_qr_login_challenges
|
|
155
|
+
WHERE status IN ('expired', 'claimed')
|
|
156
|
+
AND datetime(COALESCE(claimed_at, expires_at, created_at)) <= datetime(?)
|
|
157
|
+
`).run(retentionCutoff);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getChallengeRowByApproveSecret(challengeId, secret) {
|
|
161
|
+
pruneExpiredChallenges();
|
|
162
|
+
return db.prepare(`
|
|
163
|
+
SELECT *
|
|
164
|
+
FROM user_qr_login_challenges
|
|
165
|
+
WHERE id = ?
|
|
166
|
+
AND approve_secret_hash = ?
|
|
167
|
+
LIMIT 1
|
|
168
|
+
`).get(String(challengeId || '').trim(), tokenHash(secret));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getChallengeRowByPollToken(challengeId, pollToken) {
|
|
172
|
+
pruneExpiredChallenges();
|
|
173
|
+
return db.prepare(`
|
|
174
|
+
SELECT *
|
|
175
|
+
FROM user_qr_login_challenges
|
|
176
|
+
WHERE id = ?
|
|
177
|
+
AND poll_token_hash = ?
|
|
178
|
+
LIMIT 1
|
|
179
|
+
`).get(String(challengeId || '').trim(), tokenHash(pollToken));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function serializeChallenge(row) {
|
|
183
|
+
const requestMetadata = parseJsonObject(row.request_metadata_json);
|
|
184
|
+
const approvedMetadata = parseJsonObject(row.approved_metadata_json);
|
|
185
|
+
const requestLocation = parseJsonObject(row.request_location_json);
|
|
186
|
+
const descriptor = parseClientDescriptor(row.request_user_agent, requestMetadata);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
challengeId: row.id,
|
|
190
|
+
status: row.status || 'pending',
|
|
191
|
+
requestedAt: row.created_at || null,
|
|
192
|
+
expiresAt: row.expires_at || null,
|
|
193
|
+
approvedAt: row.approved_at || null,
|
|
194
|
+
claimedAt: row.claimed_at || null,
|
|
195
|
+
requestedDevice: {
|
|
196
|
+
label: descriptor.label,
|
|
197
|
+
platformLabel: descriptor.platformLabel,
|
|
198
|
+
browserLabel: descriptor.browserLabel,
|
|
199
|
+
deviceClass: descriptor.deviceClass,
|
|
200
|
+
userAgent: row.request_user_agent || '',
|
|
201
|
+
metadata: requestMetadata,
|
|
202
|
+
},
|
|
203
|
+
requestLocation: {
|
|
204
|
+
label: row.request_location_label || 'Unknown',
|
|
205
|
+
ipAddress: row.request_ip_address || null,
|
|
206
|
+
city: trimmedString(requestLocation.city, 80) || null,
|
|
207
|
+
region: trimmedString(requestLocation.region, 80) || null,
|
|
208
|
+
country: trimmedString(requestLocation.country, 80) || null,
|
|
209
|
+
timezone: trimmedString(requestLocation.timezone, 80) || null,
|
|
210
|
+
},
|
|
211
|
+
approval: row.approved_by_user_id ? {
|
|
212
|
+
userId: Number(row.approved_by_user_id),
|
|
213
|
+
metadata: approvedMetadata,
|
|
214
|
+
} : null,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createChallenge(req, options = {}) {
|
|
219
|
+
pruneExpiredChallenges();
|
|
220
|
+
const requestMetadata = normalizeMetadata(options.requestMetadata);
|
|
221
|
+
const geo = lookupIpLocation(clientIpFromRequest(req));
|
|
222
|
+
const userAgent = userAgentFromRequest(req);
|
|
223
|
+
const challengeId = randomUUID();
|
|
224
|
+
const pollToken = randomToken();
|
|
225
|
+
const approveSecret = randomToken();
|
|
226
|
+
const expiresAt = sqliteDateFromMs(Date.now() + QR_LOGIN_TTL_MS);
|
|
227
|
+
|
|
228
|
+
db.prepare(`
|
|
229
|
+
INSERT INTO user_qr_login_challenges (
|
|
230
|
+
id,
|
|
231
|
+
poll_token_hash,
|
|
232
|
+
approve_secret_hash,
|
|
233
|
+
status,
|
|
234
|
+
request_user_agent,
|
|
235
|
+
request_ip_address,
|
|
236
|
+
request_location_label,
|
|
237
|
+
request_location_json,
|
|
238
|
+
request_metadata_json,
|
|
239
|
+
expires_at
|
|
240
|
+
)
|
|
241
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?)
|
|
242
|
+
`).run(
|
|
243
|
+
challengeId,
|
|
244
|
+
tokenHash(pollToken),
|
|
245
|
+
tokenHash(approveSecret),
|
|
246
|
+
userAgent,
|
|
247
|
+
geo.ipAddress,
|
|
248
|
+
geo.label,
|
|
249
|
+
JSON.stringify(geo.data || {}),
|
|
250
|
+
JSON.stringify(requestMetadata),
|
|
251
|
+
expiresAt,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
challengeId,
|
|
256
|
+
pollToken,
|
|
257
|
+
approveSecret,
|
|
258
|
+
expiresAt,
|
|
259
|
+
status: 'pending',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function resolveChallengeForApproval({ challengeId, secret }) {
|
|
264
|
+
const row = getChallengeRowByApproveSecret(challengeId, secret);
|
|
265
|
+
if (!row || challengeIsExpired(row) || row.status === 'expired') {
|
|
266
|
+
throw challengeNotFoundError();
|
|
267
|
+
}
|
|
268
|
+
return serializeChallenge(row);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function approveChallenge({
|
|
272
|
+
challengeId,
|
|
273
|
+
secret,
|
|
274
|
+
userId,
|
|
275
|
+
approverSessionId,
|
|
276
|
+
approvalMetadata,
|
|
277
|
+
}) {
|
|
278
|
+
const metadata = normalizeMetadata(approvalMetadata);
|
|
279
|
+
const row = getChallengeRowByApproveSecret(challengeId, secret);
|
|
280
|
+
if (!row || challengeIsExpired(row) || row.status === 'expired') {
|
|
281
|
+
throw challengeNotFoundError();
|
|
282
|
+
}
|
|
283
|
+
if (row.status === 'claimed') {
|
|
284
|
+
throw challengeStateError('This QR login request was already used.');
|
|
285
|
+
}
|
|
286
|
+
if (row.status === 'approved' &&
|
|
287
|
+
row.approved_by_user_id &&
|
|
288
|
+
Number(row.approved_by_user_id) !== Number(userId)) {
|
|
289
|
+
throw challengeStateError('This QR login request was already approved.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const approvedSessionHash = approverSessionId ? sessionHash(approverSessionId) : null;
|
|
293
|
+
const now = sqliteDateFromMs(Date.now());
|
|
294
|
+
|
|
295
|
+
db.prepare(`
|
|
296
|
+
UPDATE user_qr_login_challenges
|
|
297
|
+
SET status = 'approved',
|
|
298
|
+
approved_by_user_id = ?,
|
|
299
|
+
approved_session_hash = ?,
|
|
300
|
+
approved_metadata_json = ?,
|
|
301
|
+
approved_at = ?
|
|
302
|
+
WHERE id = ?
|
|
303
|
+
AND status IN ('pending', 'approved')
|
|
304
|
+
`).run(
|
|
305
|
+
userId,
|
|
306
|
+
approvedSessionHash,
|
|
307
|
+
JSON.stringify(metadata),
|
|
308
|
+
now,
|
|
309
|
+
row.id,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
return resolveChallengeForApproval({ challengeId, secret });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getChallengeStatusForPoll({ challengeId, pollToken }) {
|
|
316
|
+
const row = getChallengeRowByPollToken(challengeId, pollToken);
|
|
317
|
+
if (!row || challengeIsExpired(row) || row.status === 'expired') {
|
|
318
|
+
return {
|
|
319
|
+
challengeId: String(challengeId || '').trim(),
|
|
320
|
+
status: 'expired',
|
|
321
|
+
expiresAt: row?.expires_at || null,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
challengeId: row.id,
|
|
326
|
+
status: row.status || 'pending',
|
|
327
|
+
expiresAt: row.expires_at || null,
|
|
328
|
+
approvedAt: row.approved_at || null,
|
|
329
|
+
claimedAt: row.claimed_at || null,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function claimApprovedChallenge({ challengeId, pollToken }) {
|
|
334
|
+
const row = getChallengeRowByPollToken(challengeId, pollToken);
|
|
335
|
+
if (!row || challengeIsExpired(row) || row.status === 'expired') {
|
|
336
|
+
throw challengeNotFoundError();
|
|
337
|
+
}
|
|
338
|
+
if (row.status === 'claimed') {
|
|
339
|
+
throw challengeStateError('This QR login request was already used.');
|
|
340
|
+
}
|
|
341
|
+
if (row.status !== 'approved' || !row.approved_by_user_id) {
|
|
342
|
+
throw challengeStateError('This QR login request is not approved yet.', 409);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const now = sqliteDateFromMs(Date.now());
|
|
346
|
+
const result = db.prepare(`
|
|
347
|
+
UPDATE user_qr_login_challenges
|
|
348
|
+
SET status = 'claimed',
|
|
349
|
+
claimed_at = ?
|
|
350
|
+
WHERE id = ?
|
|
351
|
+
AND poll_token_hash = ?
|
|
352
|
+
AND status = 'approved'
|
|
353
|
+
`).run(now, row.id, tokenHash(pollToken));
|
|
354
|
+
|
|
355
|
+
if (result.changes !== 1) {
|
|
356
|
+
throw challengeStateError('This QR login request is no longer available.');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const user = db.prepare(`
|
|
360
|
+
SELECT id, username, email, email_verified_at, password_login_enabled, created_at, last_login
|
|
361
|
+
FROM users
|
|
362
|
+
WHERE id = ?
|
|
363
|
+
`).get(row.approved_by_user_id);
|
|
364
|
+
if (!user) {
|
|
365
|
+
throw challengeStateError('The approving account is no longer available.', 404);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
user,
|
|
370
|
+
challenge: serializeChallenge({
|
|
371
|
+
...row,
|
|
372
|
+
status: 'claimed',
|
|
373
|
+
claimed_at: now,
|
|
374
|
+
}),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
module.exports = {
|
|
379
|
+
createChallenge,
|
|
380
|
+
approveChallenge,
|
|
381
|
+
claimApprovedChallenge,
|
|
382
|
+
getChallengeStatusForPoll,
|
|
383
|
+
resolveChallengeForApproval,
|
|
384
|
+
__test: {
|
|
385
|
+
challengeIsExpired,
|
|
386
|
+
parseSqliteUtcMs,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
@@ -177,8 +177,21 @@ class Scheduler {
|
|
|
177
177
|
return { deleted: true };
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
listTasks(userId) {
|
|
181
|
-
const
|
|
180
|
+
listTasks(userId, options = {}) {
|
|
181
|
+
const agentId = resolveAgentId(userId, options.agentId || options.agent_id || null);
|
|
182
|
+
const includeLegacyMainTasks = isMainAgent(userId, agentId);
|
|
183
|
+
const tasks = includeLegacyMainTasks
|
|
184
|
+
? db.prepare(
|
|
185
|
+
`SELECT * FROM scheduled_tasks
|
|
186
|
+
WHERE user_id = ?
|
|
187
|
+
AND (agent_id = ? OR agent_id IS NULL)
|
|
188
|
+
ORDER BY created_at DESC`
|
|
189
|
+
).all(userId, agentId)
|
|
190
|
+
: db.prepare(
|
|
191
|
+
`SELECT * FROM scheduled_tasks
|
|
192
|
+
WHERE user_id = ? AND agent_id = ?
|
|
193
|
+
ORDER BY created_at DESC`
|
|
194
|
+
).all(userId, agentId);
|
|
182
195
|
return tasks.map(t => {
|
|
183
196
|
const config = this._normalizeTaskConfig(t.task_config);
|
|
184
197
|
return {
|