ldrouter 1.6.7 → 1.10.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,66 @@ 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.10.0] - 2026-09-01
8
+
9
+ ### Added
10
+
11
+ - **Real streaming model test**: clicking Test on a model now streams the upstream response token-by-token into the test dialog (`POST /api/admin/models/:id/test-stream` SSE endpoint, newline-delimited). Previously the dialog waited ~1s with no feedback before showing a static result; now content appears live as the model generates, with a blinking cursor, live TTFT/elapsed counter, and a final `test_meta` event with full latency/token/attempt stats. The old non-streaming `POST .../test` endpoint remains for backwards compatibility.
12
+
13
+ ### Fixed
14
+
15
+ - **Test dialog no loading state**: the model test modal previously opened instantly but showed nothing for the ~1s the request took, looking like a hang. It now shows an immediate streaming view with progress as soon as the modal opens.
16
+
17
+ ## [1.9.1] - 2026-09-01
18
+
19
+ ### Fixed
20
+
21
+ - **Request-content logging now visible**: Settings → Logging "Request-content logging" was saving payloads to the database (`prompt` / `prompt_and_response` modes) but the admin UI never displayed them, making the setting appear broken. The request detail dialog on `/requests` now shows "Request content" and "Response content" sections (sanitized, scrollable) whenever payloads were logged. Added integration test covering all four `contentLogMode` values end-to-end.
22
+
23
+ ## [1.9.0] - 2026-09-01
24
+
25
+ ### Added
26
+
27
+ - **Realtime monitoring dashboard (/statistics)**: redesigned into a production-grade overview — summary cards now show icons, animated count-up, % delta vs previous period, and mini sparklines; a live **request routing flow** diagram (Incoming Traffic → AI Gateway → Providers with curved paths and animated pulse dots on each active route); a **Recent Requests** table with green/red status dots and time-ago labels; bottom metrics with circular Success Rate progress and Average Latency sparkline. All driven by the existing SSE stream (no new dependencies, CSS/SVG-native animations).
28
+ - **Stats API extensions**: `GET /api/admin/stats` now returns `previous` (same-window comparison for deltas), `recent` (last 10 requests with provider info), `providers` (traffic/error-rate/latency/health per provider), and per-bucket `avgLatency`/`cacheRead` in `series`; `RequestLogSummary` gained `providerId`/`providerName`.
29
+
30
+
31
+
32
+ ### Added
33
+
34
+ - **Notification toggles in Settings**: request notification cards and the notification sound can each be turned on/off in Settings → System ("Notifications" card); preferences persist server-side (`app_settings`) and apply immediately across the whole admin UI.
35
+ - **Real-time /requests page**: the Requests log now subscribes to the SSE stream — new requests appear in the table live (page 1, honoring active filters) plus a "N new requests — refresh" badge, no manual page refresh needed.
36
+
37
+ ### Fixed
38
+
39
+ - **Setup redirect (permanent fix)**: after creating the admin account the app now hard-reloads to `/login` instead of soft-navigating. Root cause: `SetupGate` cached `setupComplete=false` on mount and re-bounced every post-setup route back to `/setup`; a full reload clears the stale state.
40
+
41
+ ## [1.7.0] - 2026-08-31
42
+
43
+ ### Added
44
+
45
+ - **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.
46
+ - **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.
47
+
48
+ ## [1.6.8] - 2026-08-31
49
+
50
+ ### Added
51
+
52
+ - **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.
53
+ - **Model delete action**: Replace enable/disable toggle with explicit Delete button + confirmation modal and Test button showing results.
54
+ - **Combo edit functionality**: New edit dialog (via `/api/admin/combos/:id`) and PATCH handler for modifying combo metadata/members.
55
+ - **Searchable member picker**: Dropdown in create/edit combo dialogs now filters models by public ID or display name.
56
+ - **API key actions**: Split Revoke into Disable/Enable toggle + Delete button; persist secret visibility for each key row.
57
+ - **Dynamic sidebar version**: Footer displays real app version fetched from server instead of hardcoded `v0.1.0`.
58
+
59
+ ### Fixed
60
+
61
+ - **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.
62
+
63
+ ### Changed
64
+
65
+ - **Language**: UI labels updated to Vietnamese where appropriate ("Xoá", "Sửa").
66
+
7
67
  ## [1.6.7] - 2026-08-30
8
68
 
9
69
  ### Fixed
@@ -87,6 +87,8 @@ function buildInitialSchemaSql() {
87
87
  gateway_cache_max_size_mb INTEGER NOT NULL DEFAULT 256,
88
88
  master_key_version INTEGER NOT NULL DEFAULT 1,
89
89
  master_key_configured INTEGER NOT NULL DEFAULT 0,
90
+ notifications_enabled INTEGER NOT NULL DEFAULT 1,
91
+ notification_sound_enabled INTEGER NOT NULL DEFAULT 1,
90
92
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
91
93
  );
92
94
 
@@ -17,6 +17,8 @@ function rowToSettings(row) {
17
17
  gatewayCacheMaxSizeMb: row.gatewayCacheMaxSizeMb,
18
18
  masterKeyConfigured: row.masterKeyConfigured,
19
19
  masterKeyVersion: row.masterKeyVersion,
20
+ notificationsEnabled: row.notificationsEnabled,
21
+ notificationSoundEnabled: row.notificationSoundEnabled,
20
22
  };
21
23
  }
22
24
  export function getSettings() {
@@ -55,6 +57,10 @@ export function updateSettings(patch) {
55
57
  update.gatewayCacheMaxSizeMb = patch.gatewayCacheMaxSizeMb;
56
58
  if (patch.masterKeyConfigured !== undefined)
57
59
  update.masterKeyConfigured = patch.masterKeyConfigured;
60
+ if (patch.notificationsEnabled !== undefined)
61
+ update.notificationsEnabled = patch.notificationsEnabled;
62
+ if (patch.notificationSoundEnabled !== undefined)
63
+ update.notificationSoundEnabled = patch.notificationSoundEnabled;
58
64
  db.update(schema.appSettings).set(update).where(eq(schema.appSettings.id, 1)).run();
59
65
  return getSettings();
60
66
  }
@@ -19,6 +19,9 @@ export const appSettings = sqliteTable('app_settings', {
19
19
  gatewayCacheMaxSizeMb: integer('gateway_cache_max_size_mb').notNull().default(256),
20
20
  masterKeyVersion: integer('master_key_version').notNull().default(1),
21
21
  masterKeyConfigured: integer('master_key_configured', { mode: 'boolean' }).notNull().notNull().default(false),
22
+ // Admin UI notification preferences (v1.8.0). Default true: notifications on, sound on.
23
+ notificationsEnabled: integer('notifications_enabled', { mode: 'boolean' }).notNull().default(true),
24
+ notificationSoundEnabled: integer('notification_sound_enabled', { mode: 'boolean' }).notNull().default(true),
22
25
  updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
23
26
  });
24
27
  // ============================================================================
@@ -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
- // 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,151 @@ 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
+ // Streaming test endpoint: streams SSE tokens to the client in real time.
171
+ app.post('/api/admin/models/:id/test-stream', async (req, reply) => {
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
+ // Hijack reply so runner streams SSE directly to the client.
185
+ reply.hijack();
186
+ const res = reply.raw;
187
+ const SSE_HEADERS = {
188
+ 'Content-Type': 'text/event-stream',
189
+ 'Cache-Control': 'no-cache',
190
+ Connection: 'keep-alive',
191
+ };
192
+ let headWritten = false;
193
+ const writeHeadOnce = () => {
194
+ if (headWritten)
195
+ return;
196
+ headWritten = true;
197
+ res.writeHead(200, SSE_HEADERS);
198
+ };
199
+ const send = (event, data) => {
200
+ writeHeadOnce();
201
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
202
+ };
203
+ // The runner calls reply.raw.end() as soon as the upstream completes.
204
+ // Defer that so we can append our own test_meta event before the
205
+ // real end(). Writes are forwarded live, so tokens still stream.
206
+ const fakeRaw = {
207
+ writeHead: () => writeHeadOnce(),
208
+ write: (chunk) => res.write(chunk),
209
+ end: () => { },
210
+ };
211
+ const { GatewayRunner } = await import('../../gateway/runner.js');
212
+ const runner = new GatewayRunner();
213
+ const canonicalReq = {
214
+ model: m.publicModelId,
215
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Bạn là model gì?' }] }],
216
+ stream: true,
217
+ maxOutputTokens: 256,
218
+ temperature: 0.7,
219
+ };
220
+ const requestId = `test-${uuid()}`;
221
+ const ctx = {
222
+ requestId,
223
+ clientIp: req.ip,
224
+ protocol: 'openai',
225
+ endpoint: 'chat/completions',
226
+ requestedModel: m.publicModelId,
227
+ key: null,
228
+ reply: { raw: fakeRaw },
229
+ };
230
+ const gatewayReq = {
231
+ canonical: canonicalReq,
232
+ protocol: 'openai',
233
+ endpoint: 'chat/completions',
234
+ };
235
+ try {
236
+ const outcome = await runner.execute(gatewayReq, ctx);
237
+ // Runner already wrote [DONE]. Append our own test_meta event so the
238
+ // client knows the test finished with full stats.
239
+ send('test_meta', {
240
+ success: outcome.success,
241
+ latencyMs: outcome.latencyMs,
242
+ ttftMs: outcome.ttftMs ?? null,
243
+ usage: outcome.usage,
244
+ attempts: outcome.attempts.map((a) => ({
245
+ providerName: a.providerName,
246
+ modelId: a.modelId,
247
+ latencyMs: a.latencyMs,
248
+ success: a.success,
249
+ failureReason: a.failureReason,
250
+ })),
251
+ });
252
+ }
253
+ catch (e) {
254
+ send('test_error', { message: e.message });
255
+ }
256
+ finally {
257
+ res.end();
258
+ }
259
+ });
260
+ // Non-streaming test endpoint (kept for backwards compatibility)
261
+ app.post('/api/admin/models/:id/test', async (req) => {
262
+ const { id } = req.params;
263
+ const db = getDb();
264
+ const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
265
+ if (!m)
266
+ throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
267
+ if (!m.enabled)
268
+ throw new GatewayError('invalid_request_error', 'Model is disabled', { status: 400 });
269
+ if (!m.upstreamAvailable)
270
+ throw new GatewayError('invalid_request_error', 'Model is not available upstream', { status: 400 });
271
+ const provider = db.select().from(schema.providers).where(eq(schema.providers.id, m.providerId)).get();
272
+ if (!provider || !provider.enabled)
273
+ throw new GatewayError('invalid_request_error', 'Provider is disabled', { status: 400 });
274
+ const { GatewayRunner } = await import('../../gateway/runner.js');
275
+ const runner = new GatewayRunner();
276
+ const canonicalReq = {
277
+ model: m.publicModelId,
278
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Bạn là model gì?' }] }],
279
+ stream: false,
280
+ maxOutputTokens: 256,
281
+ temperature: 0.7,
282
+ };
283
+ const ctx = {
284
+ requestId: `test-${uuid()}`,
285
+ clientIp: req.ip,
286
+ protocol: 'openai',
287
+ endpoint: 'chat/completions',
288
+ requestedModel: m.publicModelId,
289
+ key: null,
290
+ reply: { raw: {} }, // Fake reply object; non-streaming won't use it
291
+ };
292
+ const gatewayReq = {
293
+ canonical: canonicalReq,
294
+ protocol: 'openai',
295
+ endpoint: 'chat/completions',
296
+ };
297
+ const outcome = await runner.execute(gatewayReq, ctx);
298
+ recordAudit({ action: 'model.test', success: outcome.success, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
299
+ return {
300
+ success: outcome.success,
301
+ text: outcome.text ?? '',
302
+ latencyMs: outcome.latencyMs,
303
+ ttftMs: outcome.ttftMs ?? null,
304
+ usage: outcome.usage,
305
+ attempts: outcome.attempts.map((a) => ({
306
+ providerName: a.providerName,
307
+ modelId: a.modelId,
308
+ latencyMs: a.latencyMs,
309
+ ttftMs: a.ttftMs,
310
+ success: a.success,
311
+ failureReason: a.failureReason,
312
+ })),
313
+ };
314
+ });
170
315
  }
171
316
  function safeJson(s) {
172
317
  try {
@@ -3,6 +3,55 @@ 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
+ export function loadSummaryMaps() {
8
+ const db = getDb();
9
+ const keys = db.select().from(schema.apiKeys).all();
10
+ const models = db.select().from(schema.models).all();
11
+ const providers = db.select().from(schema.providers).all();
12
+ return {
13
+ keyMap: new Map(keys.map((k) => [k.id, k])),
14
+ modelMap: new Map(models.map((m) => [m.id, m])),
15
+ providerMap: new Map(providers.map((p) => [p.id, p])),
16
+ };
17
+ }
18
+ // Shared row → API summary mapping (used by the list endpoint, the SSE stream, and stats).
19
+ export function toSummary(r, maps) {
20
+ const { keyMap, modelMap, providerMap } = maps;
21
+ const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
22
+ const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
23
+ const provider = finalModel?.providerId ? providerMap.get(finalModel.providerId) : null;
24
+ return {
25
+ id: r.id,
26
+ createdAt: r.createdAt,
27
+ completedAt: r.completedAt,
28
+ apiKeyName: key?.name ?? null,
29
+ keyPrefix: r.keyPrefixSnapshot,
30
+ clientIp: r.clientIp,
31
+ protocol: r.protocol,
32
+ endpoint: r.endpoint,
33
+ requestedModel: r.requestedModel,
34
+ resolvedTargetKind: r.resolvedTargetKind,
35
+ finalModelPublicId: finalModel?.publicModelId ?? null,
36
+ providerId: provider ? finalModel.providerId : null,
37
+ providerName: provider?.name ?? null,
38
+ streaming: Boolean(r.streaming),
39
+ httpStatus: r.httpStatus,
40
+ success: Boolean(r.success),
41
+ totalLatencyMs: r.totalLatencyMs,
42
+ ttftMs: r.ttftMs,
43
+ inputTokens: r.inputTokens,
44
+ outputTokens: r.outputTokens,
45
+ cacheReadTokens: r.cacheReadTokens,
46
+ cacheWriteTokens: r.cacheWriteTokens,
47
+ reasoningTokens: r.reasoningTokens,
48
+ totalTokens: r.totalTokens,
49
+ attemptsCount: r.attemptsCount,
50
+ errorType: r.errorType,
51
+ errorMessage: r.errorMessage ?? null,
52
+ gatewayCacheHit: Boolean(r.gatewayCacheHit),
53
+ };
54
+ }
6
55
  export async function registerRequestRoutes(app) {
7
56
  app.addHook('preHandler', requireAdminAuth);
8
57
  app.get('/api/admin/requests', async (req) => {
@@ -38,45 +87,82 @@ export async function registerRequestRoutes(app) {
38
87
  const whereExpr = conds.length ? and(...conds) : undefined;
39
88
  const rows = db.select().from(schema.requests).where(whereExpr).orderBy(desc(schema.requests.createdAt)).limit(limit).offset(offset).all();
40
89
  const totalRow = db.select({ c: sql `COUNT(*)` }).from(schema.requests).where(whereExpr).get();
41
- const keys = db.select().from(schema.apiKeys).all();
42
- const keyMap = new Map(keys.map((k) => [k.id, k]));
43
- const models = db.select().from(schema.models).all();
44
- const modelMap = new Map(models.map((m) => [m.id, m]));
90
+ const maps = loadSummaryMaps();
45
91
  return {
46
92
  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
- }),
93
+ requests: rows.map((r) => toSummary(r, maps)),
94
+ };
95
+ });
96
+ // SSE stream of request completions (client reconnects with `since` of its last seen event).
97
+ app.get('/api/admin/requests/stream', async (req, reply) => {
98
+ reply.hijack();
99
+ const q = req.query;
100
+ const parsedSince = Number(q.since);
101
+ const since = Number.isFinite(parsedSince) && parsedSince > 0 ? parsedSince : Date.now() - 5_000;
102
+ const response = reply.raw;
103
+ // Standard SSE headers
104
+ response.writeHead(200, {
105
+ 'Content-Type': 'text/event-stream',
106
+ 'Cache-Control': 'no-cache',
107
+ Connection: 'keep-alive',
108
+ });
109
+ response.flushHeaders(); // ensure the client sees the stream immediately, even with an empty replay
110
+ let closed = false;
111
+ const send = (data) => {
112
+ if (closed)
113
+ return;
114
+ try {
115
+ response.write(data);
116
+ }
117
+ catch {
118
+ closed = true;
119
+ }
120
+ };
121
+ const db = getDb();
122
+ const maps = loadSummaryMaps();
123
+ // History replay: up to 20 most recent rows after `since` (oldest first so the client renders in order).
124
+ const historyRows = db
125
+ .select()
126
+ .from(schema.requests)
127
+ .where(gte(schema.requests.createdAt, new Date(since).toISOString()))
128
+ .orderBy(desc(schema.requests.createdAt))
129
+ .limit(20)
130
+ .all()
131
+ .reverse();
132
+ const replayIds = new Set();
133
+ for (const r of historyRows) {
134
+ replayIds.add(r.id);
135
+ send(`event: request\ndata: ${JSON.stringify(toSummary(r, maps))}\n\n`);
136
+ }
137
+ // Live: subscribe to the event bus.
138
+ const handleRequestLogged = (requestId) => {
139
+ if (closed || replayIds.has(requestId))
140
+ return;
141
+ const row = db.select().from(schema.requests).where(eq(schema.requests.id, requestId)).get();
142
+ if (!row)
143
+ return;
144
+ send(`event: request\ndata: ${JSON.stringify(toSummary(row, maps))}\n\n`);
145
+ };
146
+ onRequestLogged(handleRequestLogged);
147
+ // Keepalive ping every 25s.
148
+ const keepAlive = setInterval(() => send(': ping\n\n'), 25_000);
149
+ // Auto-close after 5 min; the client reconnects with its last seen timestamp.
150
+ const autoClose = setTimeout(() => {
151
+ response.end();
152
+ response.destroy();
153
+ }, 5 * 60_000);
154
+ const cleanup = () => {
155
+ closed = true;
156
+ clearInterval(keepAlive);
157
+ clearTimeout(autoClose);
158
+ offRequestLogged(handleRequestLogged);
79
159
  };
160
+ // Listen on the *response*: req.raw (IncomingMessage) emits 'close' as soon as the
161
+ // request message is consumed (immediately for a GET), which would tear down the
162
+ // stream before any live event. reply.raw (ServerResponse) 'close' fires when the
163
+ // response completes or the connection terminates — the canonical SSE signal.
164
+ reply.raw.on('close', cleanup);
165
+ reply.raw.on('error', cleanup);
80
166
  });
81
167
  app.get('/api/admin/requests/:id', async (req, reply) => {
82
168
  const { id } = req.params;
@@ -18,6 +18,8 @@ const UpdateBody = z.object({
18
18
  gatewayCacheEnabled: z.boolean().optional(),
19
19
  gatewayCacheDefaultTtlSeconds: z.number().int().min(1).max(86400).optional(),
20
20
  gatewayCacheMaxSizeMb: z.number().int().min(1).max(10240).optional(),
21
+ notificationsEnabled: z.boolean().optional(),
22
+ notificationSoundEnabled: z.boolean().optional(),
21
23
  });
22
24
  const PasswordChange = z.object({
23
25
  currentPassword: z.string().min(1),
@@ -28,6 +30,14 @@ const PasswordChange = z.object({
28
30
  const TotpEnableBegin = z.object({});
29
31
  void TotpEnableBegin;
30
32
  const TotpEnableVerify = z.object({ code: z.string().regex(/^\d{6}$/) });
33
+ // speakeasy v2 exports at top level (no `authenticator` namespace). Under ESM
34
+ // interop the module may arrive as { default: {...} }. Normalize once here so
35
+ // every TOTP site uses the v2 API: generateSecret(), totp.verify({secret,
36
+ // encoding:'base32', token, window}), otpauthURL({secret, label, issuer}).
37
+ async function loadSpeakeasy() {
38
+ const m = await import('speakeasy');
39
+ return m.default ?? m;
40
+ }
31
41
  export async function registerSettingsRoutes(app) {
32
42
  app.addHook('preHandler', requireAdminAuth);
33
43
  app.get('/api/admin/settings', async () => {
@@ -66,8 +76,8 @@ export async function registerSettingsRoutes(app) {
66
76
  }
67
77
  if (account.totpEnabled && body.totp) {
68
78
  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 })) {
79
+ const sp = await loadSpeakeasy();
80
+ if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 })) {
71
81
  recordAudit({ action: 'admin.password_change', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
72
82
  throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
73
83
  }
@@ -83,14 +93,13 @@ export async function registerSettingsRoutes(app) {
83
93
  app.post('/api/admin/account/totp/begin', async (req) => {
84
94
  if (!isMasterKeyConfigured())
85
95
  throw new GatewayError('gateway_error', 'Master key required to enable TOTP', { status: 503 });
86
- const speakeasy = await import('speakeasy');
96
+ const sp = await loadSpeakeasy();
87
97
  const qrcode = (await import('qrcode'));
88
- const auth = speakeasy.authenticator;
89
- const secret = auth.generateSecret({ name: 'LateDev Router', length: 20 });
98
+ const secret = sp.generateSecret({ name: 'LateDev Router', length: 20 });
90
99
  const enc = encryptSecret(secret.base32);
91
100
  const db = getDb();
92
101
  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);
102
+ const otpauth = sp.otpauthURL({ secret: secret.ascii, label: 'admin', issuer: 'LateDev Router' });
94
103
  const qr = await qrcode.toDataURL(otpauth);
95
104
  recordAudit({ action: 'totp.begin', success: true, ip: req.ip });
96
105
  return { secret: secret.base32, otpauth, qr };
@@ -100,10 +109,9 @@ export async function registerSettingsRoutes(app) {
100
109
  const account = req.adminAccount;
101
110
  if (!account.totpSecretEncrypted)
102
111
  throw new GatewayError('invalid_request_error', 'Begin TOTP setup first', { status: 400 });
103
- const speakeasy = await import('speakeasy');
104
- const auth = speakeasy.authenticator;
112
+ const sp = await loadSpeakeasy();
105
113
  const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
106
- if (!auth.verify({ token: body.code, secret, window: 1 })) {
114
+ if (!sp.totp.verify({ token: body.code, secret, encoding: 'base32', window: 1 })) {
107
115
  recordAudit({ action: 'totp.verify', success: false, ip: req.ip });
108
116
  throw new GatewayError('invalid_request_error', 'Invalid code', { status: 400 });
109
117
  }
@@ -133,9 +141,8 @@ export async function registerSettingsRoutes(app) {
133
141
  }
134
142
  if (account.totpEnabled && body.totp) {
135
143
  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 })) {
144
+ const sp = await loadSpeakeasy();
145
+ if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 })) {
139
146
  recordAudit({ action: 'totp.disable', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
140
147
  throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
141
148
  }
@@ -154,10 +161,9 @@ export async function registerSettingsRoutes(app) {
154
161
  const ok = await argon2.verify(account.passwordHash, body.password);
155
162
  if (!ok)
156
163
  throw new GatewayError('authentication_error', 'Invalid password', { status: 401 });
157
- const speakeasy = await import('speakeasy');
158
- const auth = speakeasy.authenticator;
164
+ const sp = await loadSpeakeasy();
159
165
  const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
160
- if (!auth.verify({ token: body.totp, secret, window: 1 }))
166
+ if (!sp.totp.verify({ token: body.totp, secret, encoding: 'base32', window: 1 }))
161
167
  throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
162
168
  const { generateRecoveryCodes } = await import('../../auth/recovery.js');
163
169
  const db = getDb();