ldrouter 1.6.3 → 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 CHANGED
@@ -4,6 +4,78 @@ 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
+
33
+ ## [1.6.7] - 2026-08-30
34
+
35
+ ### Fixed
36
+
37
+ - **TUI render corruption** (v1.6.4): Fastify deprecation warnings were writing
38
+ directly to stdout/stderr during TUI startup, causing text overlap in the
39
+ terminal UI (e.g., `● Server is runninging…`). Added console output suppression
40
+ in TUI mode: all stdout suppressed, stderr filtered to only allow critical error
41
+ messages that the TUI itself will render in its message screens. Also reduced
42
+ Pino logger level from `info` → `error` for any logs generated by buildApp().
43
+
44
+ ### Changed
45
+
46
+ - **Auto-TUI mode**: Running `ldrouter` without arguments now automatically
47
+ enters interactive TUI when stdout is a TTY. Added `--no-tui` flag to force
48
+ plain server mode (useful for CI pipelines, logging redirects, etc.).
49
+
50
+ ### Added
51
+
52
+ - **Update notification badge in admin UI top bar**: When a new version is
53
+ available, users see an "Update vX.Y.Z" button that links directly to
54
+ Settings → System tab with one-click installation. Previously the check existed
55
+ but required manual navigation; now it's surfaced at glance in the header.
56
+
57
+ - **Settings page auto-tab selection**: Now reads `?tab=system` query param from
58
+ URL to automatically show the System tab (used by the top bar update
59
+ notification link for direct access).
60
+
61
+ ## [1.6.3] - 2026-08-30
62
+
63
+ ### Fixed
64
+
65
+ - **TUI render corruption**: Fastify deprecation warnings and log messages were
66
+ writing directly to stdout/stderr during TUI startup, causing text overlap
67
+ and breaking the terminal UI layout (e.g., `● Server is runninging…`). Added
68
+ console output suppression in TUI mode: all stdout suppressed, stderr filtered
69
+ to only allow critical error messages that the TUI itself will render in its
70
+ message screens. Also reduced Pino logger level from `info` → `error` for any
71
+ logs generated by buildApp().
72
+
73
+ ### Changed
74
+
75
+ - **Auto-TUI mode**: Running `ldrouter` without arguments now automatically
76
+ enters interactive TUI when stdout is a TTY. Added `--no-tui` flag to force
77
+ plain server mode (useful for CI pipelines, logging redirects, etc.).
78
+
7
79
  ## [1.6.3] - 2026-08-30
8
80
 
9
81
  ### Changed
@@ -13,6 +13,24 @@ import { loadConfig } from '../../config/index.js';
13
13
  import { buildApp } from '../../app.js';
14
14
  import { closeDb } from '../../db/index.js';
15
15
  import { getSelfUpdater } from '../../selfupdate/index.js';
16
+ // Suppress stdout completely during TUI mode — all log output breaks the render.
17
+ // stderr still works but is filtered below.
18
+ const suppressConsoleOutput = () => {
19
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
20
+ const writeFn = () => true;
21
+ process.stdout.write = writeFn;
22
+ // Filter stderr to only allow critical errors (others would break TUI)
23
+ process.stderr.write = (data) => {
24
+ if (typeof data !== 'string')
25
+ return false;
26
+ // Allow fatal error messages that TUI will render in its message screens
27
+ if (/^Fatal:|^Error:|^\{ "level":4|^\[.*\] \[error\]/.test(data.trim())) {
28
+ return origStderrWrite(data);
29
+ }
30
+ // Drop deprecation warnings, logs, and other noise
31
+ return true;
32
+ };
33
+ };
16
34
  // Key sequences as escape literals (never raw control bytes in source).
17
35
  const KEY = {
18
36
  up: '\x1B[A', // ESC [ A
@@ -280,7 +298,11 @@ function shutdown(code = 0, respawnAfter = false) {
280
298
  })();
281
299
  }
282
300
  export async function runCliTui() {
283
- // MUST be set BEFORE loadConfig() reads it critical for clean TUI output
301
+ // Suppress console output BEFORE building anythingFastify deprecation warnings
302
+ // and logs go directly to stderr/stdout and would corrupt the TUI render.
303
+ suppressConsoleOutput();
304
+ // Set TUI mode flag FIRST — logger will use error-only level for any Pino logs
305
+ process.env.LATEDEV_TUI_MODE = '1';
284
306
  if (!process.env.LATEDEV_LOG_LEVEL)
285
307
  process.env.LATEDEV_LOG_LEVEL = 'error';
286
308
  cfg = loadConfig();
@@ -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) {
@@ -1,12 +1,18 @@
1
1
  import pino from 'pino';
2
+ import process from 'node:process';
2
3
  import { loadConfig } from '../config/index.js';
4
+ // Environment variable set by TUI mode — suppress all info/debug/warn logs
5
+ // when running in interactive terminal UI.
6
+ const IS_TUI_MODE = Boolean(process.env.LATEDEV_TUI_MODE);
3
7
  let _logger = null;
4
8
  export function getLogger() {
5
9
  if (_logger)
6
10
  return _logger;
7
11
  const cfg = loadConfig();
12
+ // In TUI mode, use error-only level to keep console clean
13
+ const effectiveLogLevel = IS_TUI_MODE ? 'error' : cfg.logLevel;
8
14
  _logger = pino({
9
- level: cfg.logLevel,
15
+ level: effectiveLogLevel,
10
16
  base: { app: 'latedev-router', version: cfg.appVersion },
11
17
  redact: {
12
18
  paths: [
@@ -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
- // Lazy-load to avoid breaking things if crypto module not yet ready
112
+ // speakeasy v2 exports at top level (no `authenticator` namespace). Normalize once here.
113
113
  const { decryptSecret } = await import('../../auth/crypto.js');
114
- const speakeasy = await import('speakeasy');
115
- const payload = { ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 };
114
+ const sp = await loadSpeakeasy();
116
115
  try {
117
- const secret = decryptSecret({ ciphertext: payload.ciphertext, nonce: payload.nonce, version: 1 });
118
- return speakeasy.authenticator.verify({ token: code, secret, window: 1 });
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
- const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
49
- const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
50
- return {
51
- id: r.id,
52
- createdAt: r.createdAt,
53
- completedAt: r.completedAt,
54
- apiKeyName: key?.name ?? null,
55
- keyPrefix: r.keyPrefixSnapshot,
56
- clientIp: r.clientIp,
57
- protocol: r.protocol,
58
- endpoint: r.endpoint,
59
- requestedModel: r.requestedModel,
60
- resolvedTargetKind: r.resolvedTargetKind,
61
- finalModelPublicId: finalModel?.publicModelId ?? null,
62
- streaming: Boolean(r.streaming),
63
- httpStatus: r.httpStatus,
64
- success: Boolean(r.success),
65
- totalLatencyMs: r.totalLatencyMs,
66
- ttftMs: r.ttftMs,
67
- inputTokens: r.inputTokens,
68
- outputTokens: r.outputTokens,
69
- cacheReadTokens: r.cacheReadTokens,
70
- cacheWriteTokens: r.cacheWriteTokens,
71
- reasoningTokens: r.reasoningTokens,
72
- totalTokens: r.totalTokens,
73
- attemptsCount: r.attemptsCount,
74
- errorType: r.errorType,
75
- errorMessage: r.errorMessage ?? null,
76
- gatewayCacheHit: Boolean(r.gatewayCacheHit),
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 speakeasy = await import('speakeasy');
70
- if (!speakeasy.authenticator.verify({ token: body.totp, secret, window: 1 })) {
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 speakeasy = await import('speakeasy');
94
+ const sp = await loadSpeakeasy();
87
95
  const qrcode = (await import('qrcode'));
88
- const auth = speakeasy.authenticator;
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 = auth.keyuri('admin', 'LateDev Router', secret.base32);
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 speakeasy = await import('speakeasy');
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 (!auth.verify({ token: body.code, secret, window: 1 })) {
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 speakeasy = await import('speakeasy');
137
- const auth = speakeasy.authenticator;
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 speakeasy = await import('speakeasy');
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 (!auth.verify({ token: body.totp, secret, window: 1 }))
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();