ldrouter 1.6.7 → 1.7.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/CHANGELOG.md +26 -0
- package/dist/server/gateway/events.js +20 -0
- package/dist/server/gateway/runner.js +2 -0
- package/dist/server/routes/admin/auth.js +9 -5
- package/dist/server/routes/admin/models.js +55 -0
- package/dist/server/routes/admin/requests.js +109 -32
- package/dist/server/routes/admin/settings.js +19 -15
- package/dist/web/assets/index-B14JoKiA.js +286 -0
- package/dist/web/assets/index-W4Y1q9lE.css +1 -0
- package/dist/web/index.html +2 -2
- package/dist/web/notification.mp3 +0 -0
- package/package.json +1 -1
- package/dist/web/assets/index-BE3Wf3tc.css +0 -1
- package/dist/web/assets/index-RSdQi2FM.js +0 -256
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.7.0] - 2026-08-31
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Real-time request notifications**: every gateway request completion shows a notification card in the admin UI (stacked, all visible simultaneously). Cards show model/request, in/out tokens, cache tokens, success/failure, duration + TTFT; auto-dismiss after 5s with manual close button; red on failure, amber when slow (>15s), default surface otherwise. Plays `notification.mp3` per notification.
|
|
12
|
+
- **SSE stream endpoint** (`GET /api/admin/requests/stream`): server-push of request log rows behind admin auth, with `since`-based history replay so clients never miss events across reconnects.
|
|
13
|
+
|
|
14
|
+
## [1.6.8] - 2026-08-31
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Model test endpoint** (`POST /api/admin/models/:id/test`): Run a non-streaming request against a model with prompt "Bạn là model gì?", returns TTFT, total latency, token usage, and provider attempts.
|
|
19
|
+
- **Model delete action**: Replace enable/disable toggle with explicit Delete button + confirmation modal and Test button showing results.
|
|
20
|
+
- **Combo edit functionality**: New edit dialog (via `/api/admin/combos/:id`) and PATCH handler for modifying combo metadata/members.
|
|
21
|
+
- **Searchable member picker**: Dropdown in create/edit combo dialogs now filters models by public ID or display name.
|
|
22
|
+
- **API key actions**: Split Revoke into Disable/Enable toggle + Delete button; persist secret visibility for each key row.
|
|
23
|
+
- **Dynamic sidebar version**: Footer displays real app version fetched from server instead of hardcoded `v0.1.0`.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **TOTP speakeasy v2 compatibility**: Fixed API migration — removed deprecated `authenticator` namespace, replaced with direct v2 exports (`generateSecret`, `totp.verify({encoding:'base32'})`, `otpauthURL`). Eliminates "Gateway error" when enabling TOTP. Also fixed login flow verification to use same pattern.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **Language**: UI labels updated to Vietnamese where appropriate ("Xoá", "Sửa").
|
|
32
|
+
|
|
7
33
|
## [1.6.7] - 2026-08-30
|
|
8
34
|
|
|
9
35
|
### Fixed
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Internal event bus: notifies subscribers when a gateway request row is persisted.
|
|
2
|
+
// Fire-and-forget: listeners must never affect the request path.
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
const bus = new EventEmitter();
|
|
5
|
+
bus.setMaxListeners(50);
|
|
6
|
+
const REQUEST_LOGGED = 'request_logged';
|
|
7
|
+
export function emitRequestLogged(requestId) {
|
|
8
|
+
try {
|
|
9
|
+
bus.emit(REQUEST_LOGGED, requestId);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Never let listener failures affect the request path.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function onRequestLogged(cb) {
|
|
16
|
+
bus.on(REQUEST_LOGGED, cb);
|
|
17
|
+
}
|
|
18
|
+
export function offRequestLogged(cb) {
|
|
19
|
+
bus.off(REQUEST_LOGGED, cb);
|
|
20
|
+
}
|
|
@@ -17,6 +17,7 @@ import { redactString, redactValue } from '../security/redact.js';
|
|
|
17
17
|
import { getSettings } from '../db/repositories/settings.js';
|
|
18
18
|
import { buildCacheKey, lookupCache, storeCache, cacheAllowed } from '../caching/store.js';
|
|
19
19
|
import { metrics } from '../metrics/registry.js';
|
|
20
|
+
import { emitRequestLogged } from './events.js';
|
|
20
21
|
export class GatewayRunner {
|
|
21
22
|
async execute(req, ctx) {
|
|
22
23
|
const start = Date.now();
|
|
@@ -683,6 +684,7 @@ export class GatewayRunner {
|
|
|
683
684
|
upstreamRequestId: a.upstreamRequestId,
|
|
684
685
|
}).run();
|
|
685
686
|
}
|
|
687
|
+
emitRequestLogged(requestId);
|
|
686
688
|
}
|
|
687
689
|
}
|
|
688
690
|
function classifyFailure(err) {
|
|
@@ -109,16 +109,20 @@ export async function registerAuthRoutes(app) {
|
|
|
109
109
|
async function verifyTotp(account, code) {
|
|
110
110
|
if (!account.totpSecretEncrypted || !account.totpSecretNonce)
|
|
111
111
|
return false;
|
|
112
|
-
//
|
|
112
|
+
// speakeasy v2 exports at top level (no `authenticator` namespace). Normalize once here.
|
|
113
113
|
const { decryptSecret } = await import('../../auth/crypto.js');
|
|
114
|
-
const
|
|
115
|
-
const payload = { ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 };
|
|
114
|
+
const sp = await loadSpeakeasy();
|
|
116
115
|
try {
|
|
117
|
-
const secret = decryptSecret({ ciphertext:
|
|
118
|
-
return
|
|
116
|
+
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
117
|
+
return sp.totp.verify({ token: code, secret, encoding: 'base32', window: 1 });
|
|
119
118
|
}
|
|
120
119
|
catch {
|
|
121
120
|
return false;
|
|
122
121
|
}
|
|
123
122
|
}
|
|
123
|
+
// Same helper as settings.ts to normalize speakeasy v2 imports
|
|
124
|
+
async function loadSpeakeasy() {
|
|
125
|
+
const m = await import('speakeasy');
|
|
126
|
+
return m.default ?? m;
|
|
127
|
+
}
|
|
124
128
|
import { sql } from 'drizzle-orm';
|
|
@@ -167,6 +167,61 @@ export async function registerModelRoutes(app) {
|
|
|
167
167
|
recordAudit({ action: 'model.delete', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
168
168
|
return { ok: true };
|
|
169
169
|
});
|
|
170
|
+
// Test endpoint: send a real request through the gateway pipeline to verify the model works
|
|
171
|
+
app.post('/api/admin/models/:id/test', async (req) => {
|
|
172
|
+
const { id } = req.params;
|
|
173
|
+
const db = getDb();
|
|
174
|
+
const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
|
|
175
|
+
if (!m)
|
|
176
|
+
throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
|
|
177
|
+
if (!m.enabled)
|
|
178
|
+
throw new GatewayError('invalid_request_error', 'Model is disabled', { status: 400 });
|
|
179
|
+
if (!m.upstreamAvailable)
|
|
180
|
+
throw new GatewayError('invalid_request_error', 'Model is not available upstream', { status: 400 });
|
|
181
|
+
const provider = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
|
|
182
|
+
if (!provider || !provider.enabled)
|
|
183
|
+
throw new GatewayError('invalid_request_error', 'Provider is disabled', { status: 400 });
|
|
184
|
+
const { GatewayRunner } = await import('../../gateway/runner.js');
|
|
185
|
+
const runner = new GatewayRunner();
|
|
186
|
+
const canonicalReq = {
|
|
187
|
+
model: m.publicModelId,
|
|
188
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'Bạn là model gì?' }] }],
|
|
189
|
+
stream: false,
|
|
190
|
+
maxOutputTokens: 256,
|
|
191
|
+
temperature: 0.7,
|
|
192
|
+
};
|
|
193
|
+
const ctx = {
|
|
194
|
+
requestId: `test-${uuid()}`,
|
|
195
|
+
clientIp: req.ip,
|
|
196
|
+
protocol: 'openai',
|
|
197
|
+
endpoint: 'chat/completions',
|
|
198
|
+
requestedModel: m.publicModelId,
|
|
199
|
+
key: null,
|
|
200
|
+
reply: { raw: {} }, // Fake reply object; non-streaming won't use it
|
|
201
|
+
};
|
|
202
|
+
const gatewayReq = {
|
|
203
|
+
canonical: canonicalReq,
|
|
204
|
+
protocol: 'openai',
|
|
205
|
+
endpoint: 'chat/completions',
|
|
206
|
+
};
|
|
207
|
+
const outcome = await runner.execute(gatewayReq, ctx);
|
|
208
|
+
recordAudit({ action: 'model.test', success: outcome.success, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
209
|
+
return {
|
|
210
|
+
success: outcome.success,
|
|
211
|
+
text: outcome.text ?? '',
|
|
212
|
+
latencyMs: outcome.latencyMs,
|
|
213
|
+
ttftMs: outcome.ttftMs ?? null,
|
|
214
|
+
usage: outcome.usage,
|
|
215
|
+
attempts: outcome.attempts.map((a) => ({
|
|
216
|
+
providerName: a.providerName,
|
|
217
|
+
modelId: a.modelId,
|
|
218
|
+
latencyMs: a.latencyMs,
|
|
219
|
+
ttftMs: a.ttftMs,
|
|
220
|
+
success: a.success,
|
|
221
|
+
failureReason: a.failureReason,
|
|
222
|
+
})),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
170
225
|
}
|
|
171
226
|
function safeJson(s) {
|
|
172
227
|
try {
|
|
@@ -3,6 +3,40 @@ import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { getDb, schema } from '../../db/index.js';
|
|
4
4
|
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
5
|
import { redactJsonString } from '../../security/redact.js';
|
|
6
|
+
import { onRequestLogged, offRequestLogged } from '../../gateway/events.js';
|
|
7
|
+
// Shared row → API summary mapping (used by the list endpoint and the SSE stream).
|
|
8
|
+
function toSummary(r, keyMap, modelMap) {
|
|
9
|
+
const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
|
|
10
|
+
const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
|
|
11
|
+
return {
|
|
12
|
+
id: r.id,
|
|
13
|
+
createdAt: r.createdAt,
|
|
14
|
+
completedAt: r.completedAt,
|
|
15
|
+
apiKeyName: key?.name ?? null,
|
|
16
|
+
keyPrefix: r.keyPrefixSnapshot,
|
|
17
|
+
clientIp: r.clientIp,
|
|
18
|
+
protocol: r.protocol,
|
|
19
|
+
endpoint: r.endpoint,
|
|
20
|
+
requestedModel: r.requestedModel,
|
|
21
|
+
resolvedTargetKind: r.resolvedTargetKind,
|
|
22
|
+
finalModelPublicId: finalModel?.publicModelId ?? null,
|
|
23
|
+
streaming: Boolean(r.streaming),
|
|
24
|
+
httpStatus: r.httpStatus,
|
|
25
|
+
success: Boolean(r.success),
|
|
26
|
+
totalLatencyMs: r.totalLatencyMs,
|
|
27
|
+
ttftMs: r.ttftMs,
|
|
28
|
+
inputTokens: r.inputTokens,
|
|
29
|
+
outputTokens: r.outputTokens,
|
|
30
|
+
cacheReadTokens: r.cacheReadTokens,
|
|
31
|
+
cacheWriteTokens: r.cacheWriteTokens,
|
|
32
|
+
reasoningTokens: r.reasoningTokens,
|
|
33
|
+
totalTokens: r.totalTokens,
|
|
34
|
+
attemptsCount: r.attemptsCount,
|
|
35
|
+
errorType: r.errorType,
|
|
36
|
+
errorMessage: r.errorMessage ?? null,
|
|
37
|
+
gatewayCacheHit: Boolean(r.gatewayCacheHit),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
6
40
|
export async function registerRequestRoutes(app) {
|
|
7
41
|
app.addHook('preHandler', requireAdminAuth);
|
|
8
42
|
app.get('/api/admin/requests', async (req) => {
|
|
@@ -44,39 +78,82 @@ export async function registerRequestRoutes(app) {
|
|
|
44
78
|
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
45
79
|
return {
|
|
46
80
|
total: totalRow?.c ?? 0,
|
|
47
|
-
requests: rows.map((r) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
requests: rows.map((r) => toSummary(r, keyMap, modelMap)),
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
// SSE stream of request completions (client reconnects with `since` of its last seen event).
|
|
85
|
+
app.get('/api/admin/requests/stream', async (req, reply) => {
|
|
86
|
+
reply.hijack();
|
|
87
|
+
const q = req.query;
|
|
88
|
+
const parsedSince = Number(q.since);
|
|
89
|
+
const since = Number.isFinite(parsedSince) && parsedSince > 0 ? parsedSince : Date.now() - 5_000;
|
|
90
|
+
const response = reply.raw;
|
|
91
|
+
// Standard SSE headers
|
|
92
|
+
response.writeHead(200, {
|
|
93
|
+
'Content-Type': 'text/event-stream',
|
|
94
|
+
'Cache-Control': 'no-cache',
|
|
95
|
+
Connection: 'keep-alive',
|
|
96
|
+
});
|
|
97
|
+
response.flushHeaders(); // ensure the client sees the stream immediately, even with an empty replay
|
|
98
|
+
let closed = false;
|
|
99
|
+
const send = (data) => {
|
|
100
|
+
if (closed)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
response.write(data);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
closed = true;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const db = getDb();
|
|
110
|
+
const keys = db.select().from(schema.apiKeys).all();
|
|
111
|
+
const keyMap = new Map(keys.map((k) => [k.id, k]));
|
|
112
|
+
const models = db.select().from(schema.models).all();
|
|
113
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
114
|
+
// History replay: up to 20 most recent rows after `since` (oldest first so the client renders in order).
|
|
115
|
+
const historyRows = db
|
|
116
|
+
.select()
|
|
117
|
+
.from(schema.requests)
|
|
118
|
+
.where(gte(schema.requests.createdAt, new Date(since).toISOString()))
|
|
119
|
+
.orderBy(desc(schema.requests.createdAt))
|
|
120
|
+
.limit(20)
|
|
121
|
+
.all()
|
|
122
|
+
.reverse();
|
|
123
|
+
const replayIds = new Set();
|
|
124
|
+
for (const r of historyRows) {
|
|
125
|
+
replayIds.add(r.id);
|
|
126
|
+
send(`event: request\ndata: ${JSON.stringify(toSummary(r, keyMap, modelMap))}\n\n`);
|
|
127
|
+
}
|
|
128
|
+
// Live: subscribe to the event bus.
|
|
129
|
+
const handleRequestLogged = (requestId) => {
|
|
130
|
+
if (closed || replayIds.has(requestId))
|
|
131
|
+
return;
|
|
132
|
+
const row = db.select().from(schema.requests).where(eq(schema.requests.id, requestId)).get();
|
|
133
|
+
if (!row)
|
|
134
|
+
return;
|
|
135
|
+
send(`event: request\ndata: ${JSON.stringify(toSummary(row, keyMap, modelMap))}\n\n`);
|
|
136
|
+
};
|
|
137
|
+
onRequestLogged(handleRequestLogged);
|
|
138
|
+
// Keepalive ping every 25s.
|
|
139
|
+
const keepAlive = setInterval(() => send(': ping\n\n'), 25_000);
|
|
140
|
+
// Auto-close after 5 min; the client reconnects with its last seen timestamp.
|
|
141
|
+
const autoClose = setTimeout(() => {
|
|
142
|
+
response.end();
|
|
143
|
+
response.destroy();
|
|
144
|
+
}, 5 * 60_000);
|
|
145
|
+
const cleanup = () => {
|
|
146
|
+
closed = true;
|
|
147
|
+
clearInterval(keepAlive);
|
|
148
|
+
clearTimeout(autoClose);
|
|
149
|
+
offRequestLogged(handleRequestLogged);
|
|
79
150
|
};
|
|
151
|
+
// Listen on the *response*: req.raw (IncomingMessage) emits 'close' as soon as the
|
|
152
|
+
// request message is consumed (immediately for a GET), which would tear down the
|
|
153
|
+
// stream before any live event. reply.raw (ServerResponse) 'close' fires when the
|
|
154
|
+
// response completes or the connection terminates — the canonical SSE signal.
|
|
155
|
+
reply.raw.on('close', cleanup);
|
|
156
|
+
reply.raw.on('error', cleanup);
|
|
80
157
|
});
|
|
81
158
|
app.get('/api/admin/requests/:id', async (req, reply) => {
|
|
82
159
|
const { id } = req.params;
|
|
@@ -28,6 +28,14 @@ const PasswordChange = z.object({
|
|
|
28
28
|
const TotpEnableBegin = z.object({});
|
|
29
29
|
void TotpEnableBegin;
|
|
30
30
|
const TotpEnableVerify = z.object({ code: z.string().regex(/^\d{6}$/) });
|
|
31
|
+
// speakeasy v2 exports at top level (no `authenticator` namespace). Under ESM
|
|
32
|
+
// interop the module may arrive as { default: {...} }. Normalize once here so
|
|
33
|
+
// every TOTP site uses the v2 API: generateSecret(), totp.verify({secret,
|
|
34
|
+
// encoding:'base32', token, window}), otpauthURL({secret, label, issuer}).
|
|
35
|
+
async function loadSpeakeasy() {
|
|
36
|
+
const m = await import('speakeasy');
|
|
37
|
+
return m.default ?? m;
|
|
38
|
+
}
|
|
31
39
|
export async function registerSettingsRoutes(app) {
|
|
32
40
|
app.addHook('preHandler', requireAdminAuth);
|
|
33
41
|
app.get('/api/admin/settings', async () => {
|
|
@@ -66,8 +74,8 @@ export async function registerSettingsRoutes(app) {
|
|
|
66
74
|
}
|
|
67
75
|
if (account.totpEnabled && body.totp) {
|
|
68
76
|
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
77
|
+
const sp = await loadSpeakeasy();
|
|
78
|
+
if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 })) {
|
|
71
79
|
recordAudit({ action: 'admin.password_change', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
|
|
72
80
|
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
73
81
|
}
|
|
@@ -83,14 +91,13 @@ export async function registerSettingsRoutes(app) {
|
|
|
83
91
|
app.post('/api/admin/account/totp/begin', async (req) => {
|
|
84
92
|
if (!isMasterKeyConfigured())
|
|
85
93
|
throw new GatewayError('gateway_error', 'Master key required to enable TOTP', { status: 503 });
|
|
86
|
-
const
|
|
94
|
+
const sp = await loadSpeakeasy();
|
|
87
95
|
const qrcode = (await import('qrcode'));
|
|
88
|
-
const
|
|
89
|
-
const secret = auth.generateSecret({ name: 'LateDev Router', length: 20 });
|
|
96
|
+
const secret = sp.generateSecret({ name: 'LateDev Router', length: 20 });
|
|
90
97
|
const enc = encryptSecret(secret.base32);
|
|
91
98
|
const db = getDb();
|
|
92
99
|
db.update(schema.adminAccount).set({ totpSecretEncrypted: enc.ciphertext, totpSecretNonce: enc.nonce, updatedAt: new Date().toISOString() }).where(eq(schema.adminAccount.id, req.adminAccount.id)).run();
|
|
93
|
-
const otpauth =
|
|
100
|
+
const otpauth = sp.otpauthURL({ secret: secret.ascii, label: 'admin', issuer: 'LateDev Router' });
|
|
94
101
|
const qr = await qrcode.toDataURL(otpauth);
|
|
95
102
|
recordAudit({ action: 'totp.begin', success: true, ip: req.ip });
|
|
96
103
|
return { secret: secret.base32, otpauth, qr };
|
|
@@ -100,10 +107,9 @@ export async function registerSettingsRoutes(app) {
|
|
|
100
107
|
const account = req.adminAccount;
|
|
101
108
|
if (!account.totpSecretEncrypted)
|
|
102
109
|
throw new GatewayError('invalid_request_error', 'Begin TOTP setup first', { status: 400 });
|
|
103
|
-
const
|
|
104
|
-
const auth = speakeasy.authenticator;
|
|
110
|
+
const sp = await loadSpeakeasy();
|
|
105
111
|
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
106
|
-
if (!
|
|
112
|
+
if (!sp.totp.verify({ token: body.code, secret, encoding: 'base32', window: 1 })) {
|
|
107
113
|
recordAudit({ action: 'totp.verify', success: false, ip: req.ip });
|
|
108
114
|
throw new GatewayError('invalid_request_error', 'Invalid code', { status: 400 });
|
|
109
115
|
}
|
|
@@ -133,9 +139,8 @@ export async function registerSettingsRoutes(app) {
|
|
|
133
139
|
}
|
|
134
140
|
if (account.totpEnabled && body.totp) {
|
|
135
141
|
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
if (!auth.verify({ token: body.totp, secret, window: 1 })) {
|
|
142
|
+
const sp = await loadSpeakeasy();
|
|
143
|
+
if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 })) {
|
|
139
144
|
recordAudit({ action: 'totp.disable', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
|
|
140
145
|
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
141
146
|
}
|
|
@@ -154,10 +159,9 @@ export async function registerSettingsRoutes(app) {
|
|
|
154
159
|
const ok = await argon2.verify(account.passwordHash, body.password);
|
|
155
160
|
if (!ok)
|
|
156
161
|
throw new GatewayError('authentication_error', 'Invalid password', { status: 401 });
|
|
157
|
-
const
|
|
158
|
-
const auth = speakeasy.authenticator;
|
|
162
|
+
const sp = await loadSpeakeasy();
|
|
159
163
|
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
160
|
-
if (!
|
|
164
|
+
if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 }))
|
|
161
165
|
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
162
166
|
const { generateRecoveryCodes } = await import('../../auth/recovery.js');
|
|
163
167
|
const db = getDb();
|