ldrouter 1.10.2 → 1.11.2
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 +19 -0
- package/dist/server/app.js +4 -0
- package/dist/server/auth/api-key.js +2 -3
- package/dist/server/db/migrate.js +2 -0
- package/dist/server/db/repositories/settings.js +6 -0
- package/dist/server/db/schema.js +6 -1
- package/dist/server/gateway/events.js +15 -0
- package/dist/server/gateway/runner.js +11 -4
- package/dist/server/routes/admin/models.js +11 -1
- package/dist/server/routes/admin/requests.js +12 -1
- package/dist/server/routes/admin/settings.js +40 -2
- package/dist/server/security/admin-ip-gate.js +42 -0
- package/dist/server/security/redact.js +1 -1
- package/dist/server/upstream/client.js +22 -4
- package/dist/web/assets/index-CqVMNQ5F.css +1 -0
- package/dist/web/assets/index-DPH5tP2x.js +330 -0
- package/dist/web/index.html +2 -2
- package/migrations/0004_admin_ip_access.sql +2 -0
- package/package.json +1 -1
- package/dist/web/assets/index-BprlMazL.css +0 -1
- package/dist/web/assets/index-CHg2RW0K.js +0 -324
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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.11.1] - 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Custom API keys without an `ld-` prefix now authenticate**: the API-key creation form accepts any custom key value, but gateway authentication rejected every key not starting with `ld-` (401 "Invalid API key format"), making custom keys unusable. Authentication now accepts any stored secret verbatim — auto-generated keys still use the `ld-<base64url(32 bytes)>` format.
|
|
12
|
+
|
|
13
|
+
## [1.11.0] - 2026-09-01
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Admin-site IP access control** (Settings → Access Control tab): Allow/Block IP lists (CIDR, one per line) now restrict access to the entire admin website — login, setup, and static UI included. Non-matching IPs get a plain 403 "Không có quyền truy cập". Model traffic (`/v1/*`) and `/health` are never affected. Lockout guard: saving a non-empty allow list auto-adds your current IP so you can't lock yourself out.
|
|
18
|
+
- **Live route lighting on /statistics**: while a request is being served — from the first token (TTFT) until completion — the Request → Gateway → Provider line lights up in the brand primary color with a soft pulsing glow; the completion pulse dot still flashes when the request finishes. Powered by a new live-only `request_started` SSE event.
|
|
19
|
+
- **API key editing**: the API keys page now has an Edit (✏️) button per key — name, expiry, RPM/TPM/concurrency limits, and model scope are editable via the existing dialog; the key secret itself is never changed on edit.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Ghost notifications**: reconnects (after the 5-minute stream cycle or a network drop) replayed recent requests over SSE, which re-triggered the notification card + sound even though no new request existed. The notification hook now dedupes by request ID across reconnects.
|
|
24
|
+
- **Model Test no longer returns an opaque 500 "Gateway error"** when the provider credential can't be decrypted (master-key mismatch, e.g. after restoring a backup from another instance): it now returns a readable `authentication_error` telling you to re-save the provider API key; the non-streaming test route also wraps unexpected runner errors instead of leaking them.
|
|
25
|
+
|
|
7
26
|
## [1.10.2] - 2026-09-01
|
|
8
27
|
|
|
9
28
|
### Fixed
|
package/dist/server/app.js
CHANGED
|
@@ -19,6 +19,7 @@ import { isMasterKeyConfigured } from './auth/crypto.js';
|
|
|
19
19
|
import { registerAdminRoutes } from './routes/admin.js';
|
|
20
20
|
import { registerGatewayRoutes } from './routes/gateway.js';
|
|
21
21
|
import { registerHealthRoutes } from './routes/health.js';
|
|
22
|
+
import { registerAdminIpGate } from './security/admin-ip-gate.js';
|
|
22
23
|
import { metricsRegistry } from './metrics/registry.js';
|
|
23
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
export async function buildApp(opts = {}) {
|
|
@@ -76,6 +77,9 @@ export async function buildApp(opts = {}) {
|
|
|
76
77
|
const e = new GatewayError('gateway_error', 'Internal gateway error', { safe: false, cause: err });
|
|
77
78
|
reply.code(500).send(isAnthropic ? toAnthropicError(e, requestId) : toOpenAIError(e, requestId));
|
|
78
79
|
});
|
|
80
|
+
// Admin-site IP access control (Settings → Access Control). Root scope so it
|
|
81
|
+
// covers the static UI, login/setup, and all admin APIs; /health stays open.
|
|
82
|
+
registerAdminIpGate(app);
|
|
79
83
|
// Static admin UI (if built). The not-found handler is registered once:
|
|
80
84
|
// with a built UI it serves the SPA index.html for non-API paths; without it,
|
|
81
85
|
// every miss returns a JSON 404 in the requesting protocol's shape.
|
|
@@ -37,9 +37,8 @@ export function authenticateGatewayKey(req) {
|
|
|
37
37
|
candidate = anthropic;
|
|
38
38
|
if (!candidate)
|
|
39
39
|
return null;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
40
|
+
// Custom keys are stored verbatim (no prefix requirement); auto-generated
|
|
41
|
+
// keys start with ld-, but authentication must accept any stored secret.
|
|
43
42
|
const digest = sha256Hex(candidate);
|
|
44
43
|
const db = getDb();
|
|
45
44
|
const row = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.keyDigest, digest)).get();
|
|
@@ -89,6 +89,8 @@ function buildInitialSchemaSql() {
|
|
|
89
89
|
master_key_configured INTEGER NOT NULL DEFAULT 0,
|
|
90
90
|
notifications_enabled INTEGER NOT NULL DEFAULT 1,
|
|
91
91
|
notification_sound_enabled INTEGER NOT NULL DEFAULT 1,
|
|
92
|
+
admin_ip_allow TEXT,
|
|
93
|
+
admin_ip_block TEXT,
|
|
92
94
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
93
95
|
);
|
|
94
96
|
|
|
@@ -19,6 +19,8 @@ function rowToSettings(row) {
|
|
|
19
19
|
masterKeyVersion: row.masterKeyVersion,
|
|
20
20
|
notificationsEnabled: row.notificationsEnabled,
|
|
21
21
|
notificationSoundEnabled: row.notificationSoundEnabled,
|
|
22
|
+
adminIpAllow: row.adminIpAllow,
|
|
23
|
+
adminIpBlock: row.adminIpBlock,
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
export function getSettings() {
|
|
@@ -61,6 +63,10 @@ export function updateSettings(patch) {
|
|
|
61
63
|
update.notificationsEnabled = patch.notificationsEnabled;
|
|
62
64
|
if (patch.notificationSoundEnabled !== undefined)
|
|
63
65
|
update.notificationSoundEnabled = patch.notificationSoundEnabled;
|
|
66
|
+
if (patch.adminIpAllow !== undefined)
|
|
67
|
+
update.adminIpAllow = patch.adminIpAllow;
|
|
68
|
+
if (patch.adminIpBlock !== undefined)
|
|
69
|
+
update.adminIpBlock = patch.adminIpBlock;
|
|
64
70
|
db.update(schema.appSettings).set(update).where(eq(schema.appSettings.id, 1)).run();
|
|
65
71
|
return getSettings();
|
|
66
72
|
}
|
package/dist/server/db/schema.js
CHANGED
|
@@ -22,6 +22,10 @@ export const appSettings = sqliteTable('app_settings', {
|
|
|
22
22
|
// Admin UI notification preferences (v1.8.0). Default true: notifications on, sound on.
|
|
23
23
|
notificationsEnabled: integer('notifications_enabled', { mode: 'boolean' }).notNull().default(true),
|
|
24
24
|
notificationSoundEnabled: integer('notification_sound_enabled', { mode: 'boolean' }).notNull().default(true),
|
|
25
|
+
// Admin site IP access control (v1.11.0): newline-delimited CIDR lists.
|
|
26
|
+
// null = feature disabled.
|
|
27
|
+
adminIpAllow: text('admin_ip_allow'),
|
|
28
|
+
adminIpBlock: text('admin_ip_block'),
|
|
25
29
|
updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
|
|
26
30
|
});
|
|
27
31
|
// ============================================================================
|
|
@@ -184,7 +188,8 @@ export const modelAliases = sqliteTable('model_aliases', {
|
|
|
184
188
|
aliasIdx: uniqueIndex('uniq_alias').on(t.alias),
|
|
185
189
|
}));
|
|
186
190
|
// ============================================================================
|
|
187
|
-
// API keys (gateway keys with ld
|
|
191
|
+
// API keys (gateway keys; auto-generated ones start with ld-, but custom
|
|
192
|
+
// values without prefix are now supported)
|
|
188
193
|
// ============================================================================
|
|
189
194
|
export const apiKeys = sqliteTable('api_keys', {
|
|
190
195
|
id: text('id').primaryKey(),
|
|
@@ -4,6 +4,7 @@ import { EventEmitter } from 'node:events';
|
|
|
4
4
|
const bus = new EventEmitter();
|
|
5
5
|
bus.setMaxListeners(50);
|
|
6
6
|
const REQUEST_LOGGED = 'request_logged';
|
|
7
|
+
const REQUEST_STARTED = 'request_started';
|
|
7
8
|
export function emitRequestLogged(requestId) {
|
|
8
9
|
try {
|
|
9
10
|
bus.emit(REQUEST_LOGGED, requestId);
|
|
@@ -12,9 +13,23 @@ export function emitRequestLogged(requestId) {
|
|
|
12
13
|
// Never let listener failures affect the request path.
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
export function emitRequestStarted(requestId, providerId, modelId, requestedModel, ttftMs) {
|
|
17
|
+
try {
|
|
18
|
+
bus.emit(REQUEST_STARTED, { requestId, providerId, modelId, requestedModel, ttftMs });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Never let listener failures affect the request path.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
15
24
|
export function onRequestLogged(cb) {
|
|
16
25
|
bus.on(REQUEST_LOGGED, cb);
|
|
17
26
|
}
|
|
27
|
+
export function onRequestStarted(cb) {
|
|
28
|
+
bus.on(REQUEST_STARTED, cb);
|
|
29
|
+
}
|
|
18
30
|
export function offRequestLogged(cb) {
|
|
19
31
|
bus.off(REQUEST_LOGGED, cb);
|
|
20
32
|
}
|
|
33
|
+
export function offRequestStarted(cb) {
|
|
34
|
+
bus.off(REQUEST_STARTED, cb);
|
|
35
|
+
}
|
|
@@ -17,7 +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
|
+
import { emitRequestLogged, emitRequestStarted } from './events.js';
|
|
21
21
|
export class GatewayRunner {
|
|
22
22
|
async execute(req, ctx) {
|
|
23
23
|
const start = Date.now();
|
|
@@ -175,6 +175,11 @@ export class GatewayRunner {
|
|
|
175
175
|
const out = await this.runOneAttempt(req, ctx, candidate, provider.name, cfg, required, (isStreamStarted) => {
|
|
176
176
|
attempt.streamStarted = isStreamStarted;
|
|
177
177
|
attempt.ttftMs = Date.now() - attemptStart;
|
|
178
|
+
}, (ttftMs) => {
|
|
179
|
+
// Live signal for the monitoring dashboard: the request is now
|
|
180
|
+
// being served by this provider (lit up from TTFT until the
|
|
181
|
+
// completion event fires on persist).
|
|
182
|
+
emitRequestStarted(ctx.requestId, provider.id, candidate.modelId, ctx.requestedModel, ttftMs);
|
|
178
183
|
});
|
|
179
184
|
attempt.statusCode = out.statusCode ?? null;
|
|
180
185
|
attempt.success = true;
|
|
@@ -324,9 +329,9 @@ export class GatewayRunner {
|
|
|
324
329
|
capabilities: safeJson(m.capabilitiesJson),
|
|
325
330
|
})).filter((m) => m.enabled && m.upstreamAvailable && providerEnabled.get(m.providerId));
|
|
326
331
|
}
|
|
327
|
-
async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart) {
|
|
332
|
+
async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
|
|
328
333
|
if (req.canonical.stream) {
|
|
329
|
-
return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart);
|
|
334
|
+
return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart, onFirstToken);
|
|
330
335
|
}
|
|
331
336
|
return this.runNonStreamingAttempt(req, candidate, cfg);
|
|
332
337
|
}
|
|
@@ -371,7 +376,7 @@ export class GatewayRunner {
|
|
|
371
376
|
}
|
|
372
377
|
return { statusCode: call.status, ttftMs: call.ttftMs, upstreamRequestId: call.upstreamRequestId, usage, result };
|
|
373
378
|
}
|
|
374
|
-
async runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart) {
|
|
379
|
+
async runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart, onFirstToken) {
|
|
375
380
|
const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
|
|
376
381
|
const encoder = req.protocol === 'openai' ? openaiStreamEncoder : anthropicStreamEncoder;
|
|
377
382
|
// Hard streaming invariant: the client SSE head is NOT written until the
|
|
@@ -392,6 +397,7 @@ export class GatewayRunner {
|
|
|
392
397
|
});
|
|
393
398
|
};
|
|
394
399
|
let streamStarted = false;
|
|
400
|
+
const streamStartTs = Date.now();
|
|
395
401
|
let textBuf = '';
|
|
396
402
|
const toolBuf = [];
|
|
397
403
|
let finishReason = null;
|
|
@@ -445,6 +451,7 @@ export class GatewayRunner {
|
|
|
445
451
|
if (isFirst) {
|
|
446
452
|
streamStarted = true;
|
|
447
453
|
onStreamStart(true);
|
|
454
|
+
onFirstToken(Date.now() - streamStartTs);
|
|
448
455
|
}
|
|
449
456
|
const encoded = encoder(chunk.data, chunk.event);
|
|
450
457
|
if (!headWritten)
|
|
@@ -294,7 +294,17 @@ export async function registerModelRoutes(app) {
|
|
|
294
294
|
protocol: 'openai',
|
|
295
295
|
endpoint: 'chat/completions',
|
|
296
296
|
};
|
|
297
|
-
|
|
297
|
+
let outcome;
|
|
298
|
+
try {
|
|
299
|
+
outcome = await runner.execute(gatewayReq, ctx);
|
|
300
|
+
}
|
|
301
|
+
catch (e) {
|
|
302
|
+
// Never let a raw non-GatewayError (e.g. MasterKeyError from credential
|
|
303
|
+
// decryption) escape into the global handler as an opaque "Gateway error".
|
|
304
|
+
if (e instanceof GatewayError)
|
|
305
|
+
throw e;
|
|
306
|
+
throw new GatewayError('gateway_error', e.message, { cause: e });
|
|
307
|
+
}
|
|
298
308
|
recordAudit({ action: 'model.test', success: outcome.success, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
299
309
|
return {
|
|
300
310
|
success: outcome.success,
|
|
@@ -3,7 +3,7 @@ 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';
|
|
6
|
+
import { onRequestLogged, offRequestLogged, onRequestStarted, offRequestStarted } from '../../gateway/events.js';
|
|
7
7
|
export function loadSummaryMaps() {
|
|
8
8
|
const db = getDb();
|
|
9
9
|
const keys = db.select().from(schema.apiKeys).all();
|
|
@@ -144,6 +144,16 @@ export async function registerRequestRoutes(app) {
|
|
|
144
144
|
send(`event: request\ndata: ${JSON.stringify(toSummary(row, maps))}\n\n`);
|
|
145
145
|
};
|
|
146
146
|
onRequestLogged(handleRequestLogged);
|
|
147
|
+
// A request is being served (first token reached): live-only — not part of
|
|
148
|
+
// history replay. The monitoring dashboard uses this to light the route
|
|
149
|
+
// from TTFT until the completion (`request`) event arrives.
|
|
150
|
+
const handleRequestStarted = (data) => {
|
|
151
|
+
if (closed || !data.providerId)
|
|
152
|
+
return;
|
|
153
|
+
const provider = maps.providerMap.get(data.providerId);
|
|
154
|
+
send(`event: request_started\ndata: ${JSON.stringify({ requestId: data.requestId, providerId: data.providerId, providerName: provider?.name ?? null, modelId: data.modelId, requestedModel: data.requestedModel, ttftMs: data.ttftMs, createdAt: new Date().toISOString() })}\n\n`);
|
|
155
|
+
};
|
|
156
|
+
onRequestStarted(handleRequestStarted);
|
|
147
157
|
// Keepalive ping every 25s.
|
|
148
158
|
const keepAlive = setInterval(() => send(': ping\n\n'), 25_000);
|
|
149
159
|
// Auto-close after 5 min; the client reconnects with its last seen timestamp.
|
|
@@ -156,6 +166,7 @@ export async function registerRequestRoutes(app) {
|
|
|
156
166
|
clearInterval(keepAlive);
|
|
157
167
|
clearTimeout(autoClose);
|
|
158
168
|
offRequestLogged(handleRequestLogged);
|
|
169
|
+
offRequestStarted(handleRequestStarted);
|
|
159
170
|
};
|
|
160
171
|
// Listen on the *response*: req.raw (IncomingMessage) emits 'close' as soon as the
|
|
161
172
|
// request message is consumed (immediately for a GET), which would tear down the
|
|
@@ -10,6 +10,8 @@ import { uuid } from '../../auth/ids.js';
|
|
|
10
10
|
import { isMasterKeyConfigured, encryptSecret, decryptSecret } from '../../auth/crypto.js';
|
|
11
11
|
import { GatewayError } from '../../errors.js';
|
|
12
12
|
import { runRetentionCleanup } from '../../maintenance/retention.js';
|
|
13
|
+
import { parseCidr, ipMatchesAny } from '../../util/cidr.js';
|
|
14
|
+
import { resolveClientIp } from '../../util/client-ip.js';
|
|
13
15
|
const UpdateBody = z.object({
|
|
14
16
|
retentionDays: z.number().int().min(1).max(3650).optional(),
|
|
15
17
|
contentLogMode: z.enum(['off', 'metadata', 'prompt', 'prompt_and_response']).optional(),
|
|
@@ -20,6 +22,8 @@ const UpdateBody = z.object({
|
|
|
20
22
|
gatewayCacheMaxSizeMb: z.number().int().min(1).max(10240).optional(),
|
|
21
23
|
notificationsEnabled: z.boolean().optional(),
|
|
22
24
|
notificationSoundEnabled: z.boolean().optional(),
|
|
25
|
+
adminIpAllow: z.string().max(4096).optional().nullable(),
|
|
26
|
+
adminIpBlock: z.string().max(4096).optional().nullable(),
|
|
23
27
|
});
|
|
24
28
|
const PasswordChange = z.object({
|
|
25
29
|
currentPassword: z.string().min(1),
|
|
@@ -46,9 +50,43 @@ export async function registerSettingsRoutes(app) {
|
|
|
46
50
|
});
|
|
47
51
|
app.patch('/api/admin/settings', async (req) => {
|
|
48
52
|
const body = UpdateBody.parse(req.body);
|
|
53
|
+
let addedIp = null;
|
|
54
|
+
// Validate + normalize the admin IP access lists.
|
|
55
|
+
if (body.adminIpAllow !== undefined) {
|
|
56
|
+
const lines = (body.adminIpAllow ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 100);
|
|
57
|
+
for (const l of lines) {
|
|
58
|
+
try {
|
|
59
|
+
parseCidr(l);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new GatewayError('invalid_request_error', `Invalid entry in IP allow list: "${l}"`, { status: 400 });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Lockout guard: when a non-empty allow list is configured, the caller's
|
|
66
|
+
// own IP must be covered — otherwise saving would lock the admin out of
|
|
67
|
+
// their own site (the gate is enforced on the next request).
|
|
68
|
+
const callerIp = resolveClientIp(req);
|
|
69
|
+
if (lines.length > 0 && !ipMatchesAny(callerIp, lines)) {
|
|
70
|
+
lines.push(callerIp);
|
|
71
|
+
addedIp = callerIp;
|
|
72
|
+
}
|
|
73
|
+
body.adminIpAllow = lines.length ? lines.join('\n') : null;
|
|
74
|
+
}
|
|
75
|
+
if (body.adminIpBlock !== undefined) {
|
|
76
|
+
const lines = (body.adminIpBlock ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 100);
|
|
77
|
+
for (const l of lines) {
|
|
78
|
+
try {
|
|
79
|
+
parseCidr(l);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new GatewayError('invalid_request_error', `Invalid entry in IP block list: "${l}"`, { status: 400 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
body.adminIpBlock = lines.length ? lines.join('\n') : null;
|
|
86
|
+
}
|
|
49
87
|
updateSettings(body);
|
|
50
|
-
recordAudit({ action: 'settings.update', success: true, ip: req.ip, metadata: body });
|
|
51
|
-
return { ok: true };
|
|
88
|
+
recordAudit({ action: 'settings.update', success: true, ip: req.ip, metadata: { fields: Object.keys(body), addedIp } });
|
|
89
|
+
return { ok: true, addedIp };
|
|
52
90
|
});
|
|
53
91
|
app.post('/api/admin/settings/cleanup', async (req) => {
|
|
54
92
|
const result = runRetentionCleanup();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Root-scope admin-site IP access control (Settings → Access Control).
|
|
2
|
+
//
|
|
3
|
+
// Gates the ENTIRE admin website (login, setup, static UI, all /api/admin
|
|
4
|
+
// endpoints): an IP in the block list, or — when an allow list is configured —
|
|
5
|
+
// any IP not covered by it, is rejected with 403 before any route runs.
|
|
6
|
+
//
|
|
7
|
+
// NOT gated (by design): model-traffic endpoints /v1/* (LLM API requests are
|
|
8
|
+
// governed by API keys, not this site access list) and operational endpoints
|
|
9
|
+
// (/health, /ready, /metrics) so Docker health checks keep working.
|
|
10
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
11
|
+
import { ipMatchesAny } from '../util/cidr.js';
|
|
12
|
+
import { resolveClientIp } from '../util/client-ip.js';
|
|
13
|
+
function parseList(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return [];
|
|
16
|
+
return raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
export function registerAdminIpGate(app) {
|
|
19
|
+
app.addHook('onRequest', async (req, reply) => {
|
|
20
|
+
const url = req.url.split('?')[0] ?? req.url;
|
|
21
|
+
// Operational + model-traffic endpoints are exempt.
|
|
22
|
+
if (url === '/health' || url.startsWith('/health/') ||
|
|
23
|
+
url === '/ready' || url.startsWith('/ready/') ||
|
|
24
|
+
url === '/metrics' || url.startsWith('/metrics/') ||
|
|
25
|
+
url.startsWith('/v1/') || url === '/v1')
|
|
26
|
+
return;
|
|
27
|
+
const s = getSettings();
|
|
28
|
+
const allow = parseList(s.adminIpAllow);
|
|
29
|
+
const block = parseList(s.adminIpBlock);
|
|
30
|
+
if (allow.length === 0 && block.length === 0)
|
|
31
|
+
return; // feature disabled
|
|
32
|
+
const ip = resolveClientIp(req);
|
|
33
|
+
if (block.length > 0 && ipMatchesAny(ip, block)) {
|
|
34
|
+
reply.code(403).type('text/plain').send('Không có quyền truy cập');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (allow.length > 0 && !ipMatchesAny(ip, allow)) {
|
|
38
|
+
reply.code(403).type('text/plain').send('Không có quyền truy cập');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -39,7 +39,7 @@ const DEFAULT_SECRET_KEYS = [
|
|
|
39
39
|
];
|
|
40
40
|
const SECRET_KEY_REGEX = /(authorization|api[-_]?key|api[-_]?secret|password|passwd|secret|token|cookie|master[-_]?key|totp|recovery|private[-_]?key|session)/i;
|
|
41
41
|
const PLAINTEXT_PATTERNS = [
|
|
42
|
-
/\bld-[A-Za-z0-9_-]{20,}\b/g, // gateway api keys
|
|
42
|
+
/\bld-[A-Za-z0-9_-]{20,}\b/g, // auto-generated gateway api keys (custom keys are covered by the Bearer/x-api-key patterns below)
|
|
43
43
|
/(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
44
44
|
/sk-[A-Za-z0-9_-]{12,}/g, // OpenAI-style
|
|
45
45
|
/anthropic[_-]?[A-Za-z0-9_-]{20,}/gi,
|
|
@@ -3,13 +3,31 @@ import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
|
|
|
3
3
|
import { GatewayError } from '../errors.js';
|
|
4
4
|
import { buildHeaders, stripSlash } from '../providers/index.js';
|
|
5
5
|
export function providerToUpstreamConfig(p) {
|
|
6
|
+
let apiKey;
|
|
7
|
+
let customHeaders;
|
|
8
|
+
try {
|
|
9
|
+
apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// The stored credential cannot be decrypted with the current master key
|
|
13
|
+
// (e.g. LATEDEV_MASTER_KEY changed, or the DB was restored from another
|
|
14
|
+
// instance). This must surface as a readable error — not an uncaught
|
|
15
|
+
// MasterKeyError that Fastify wraps into an opaque 500 "Gateway error".
|
|
16
|
+
throw new GatewayError('authentication_error', 'Provider credentials cannot be decrypted (master key mismatch). Re-save the provider API key.', { status: 500 });
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
customHeaders = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce
|
|
20
|
+
? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 }
|
|
21
|
+
: null);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new GatewayError('authentication_error', 'Provider custom headers cannot be decrypted (master key mismatch). Re-save the provider.', { status: 500 });
|
|
25
|
+
}
|
|
6
26
|
return {
|
|
7
27
|
type: p.type,
|
|
8
28
|
baseUrl: p.baseUrl,
|
|
9
|
-
apiKey
|
|
10
|
-
customHeaders
|
|
11
|
-
? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 }
|
|
12
|
-
: null),
|
|
29
|
+
apiKey,
|
|
30
|
+
customHeaders,
|
|
13
31
|
connectTimeoutMs: p.connectTimeoutMs,
|
|
14
32
|
firstTokenTimeoutMs: p.firstTokenTimeoutMs,
|
|
15
33
|
streamIdleTimeoutMs: p.streamIdleTimeoutMs,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--popover: 0 0% 100%;--popover-foreground: 240 10% 3.9%;--primary: 341 86% 41%;--primary-foreground: 0 0% 100%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 341 86% 41%;--sidebar: 240 5.9% 98%;--sidebar-foreground: 240 10% 3.9%;--radius: .5rem}.dark{--background: 240 10% 3.9%;--foreground: 0 0% 98%;--card: 240 10% 6%;--card-foreground: 0 0% 98%;--popover: 240 10% 5%;--popover-foreground: 0 0% 98%;--primary: 341 86% 55%;--primary-foreground: 0 0% 100%;--secondary: 240 3.7% 15.9%;--secondary-foreground: 0 0% 98%;--muted: 240 3.7% 15.9%;--muted-foreground: 240 5% 64.9%;--accent: 240 3.7% 15.9%;--accent-foreground: 0 0% 98%;--destructive: 0 72% 51%;--destructive-foreground: 0 0% 98%;--border: 240 3.7% 15.9%;--input: 240 3.7% 15.9%;--ring: 341 86% 55%;--sidebar: 240 10% 5%;--sidebar-foreground: 0 0% 98%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-feature-settings:"rlig" 1,"calt" 1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-2\.5{left:.625rem}.left-\[50\%\]{left:50%}.right-2{right:.5rem}.right-4{right:1rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2\.5{top:.625rem}.top-4{top:1rem}.top-\[50\%\]{top:50%}.z-50{z-index:50}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-px{height:1px}.h-screen{height:100vh}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-8rem\)\]{max-height:calc(100vh - 8rem)}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-2{width:.5rem}.w-20{width:5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-56{width:14rem}.w-60{width:15rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.translate-x-\[-50\%\]{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate: -90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.25rem * var(--tw-space-x-reverse));margin-left:calc(.25rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-amber-500\/40{border-color:#f59e0b66}.border-amber-600\/50{border-color:#d9770680}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-destructive\/40{border-color:hsl(var(--destructive) / .4)}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/90{background-color:#f59e0be6}.bg-background{background-color:hsl(var(--background))}.bg-black\/15{background-color:#00000026}.bg-black\/80{background-color:#000c}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/10{background-color:hsl(var(--destructive) / .1)}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/70{background-color:hsl(var(--primary) / .7)}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-sidebar{background-color:hsl(var(--sidebar))}.bg-transparent{background-color:transparent}.fill-amber-400{fill:#fbbf24}.fill-amber-500\/10{fill:#f59e0b1a}.fill-emerald-400{fill:#34d399}.fill-emerald-500{fill:#10b981}.fill-foreground{fill:hsl(var(--foreground))}.fill-muted-foreground{fill:hsl(var(--muted-foreground))}.fill-primary\/10{fill:hsl(var(--primary) / .1)}.fill-red-400{fill:#f87171}.fill-red-500{fill:#ef4444}.stroke-amber-500{stroke:#f59e0b}.stroke-emerald-500{stroke:#10b981}.stroke-primary{stroke:hsl(var(--primary))}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-text-bottom{vertical-align:text-bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-sidebar-foreground{color:hsl(var(--sidebar-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}@keyframes route-active-pulse{0%{opacity:.35}50%{opacity:1}to{opacity:.35}}.route-active{animation:route-active-pulse 1.4s ease-in-out infinite}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:hsl(var(--border));border-radius:4px}::-webkit-scrollbar-track{background:transparent}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0px}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-95:hover{opacity:.95}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color: hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color: hsl(var(--background))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x: 1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:hsl(var(--background))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:hsl(var(--primary))}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:hsl(var(--input))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:hsl(var(--foreground))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:hsl(var(--primary-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:border-amber-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(146 64 14 / var(--tw-border-opacity, 1))}.dark\:bg-amber-950\/20:is(.dark *){background-color:#451a0333}.dark\:bg-emerald-900\/40:is(.dark *){background-color:#064e3b66}.dark\:bg-green-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900\/40:is(.dark *){background-color:#7f1d1d66}.dark\:text-amber-200:is(.dark *){--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-green-200:is(.dark *){--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:max-w-\[425px\]{max-width:425px}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}}@media(min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-col{flex-direction:column}}@media(min-width:1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}
|