ldrouter 1.18.0 → 1.18.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 +8 -0
- package/dist/server/config/index.js +10 -1
- package/dist/server/routes/admin/api-keys.js +1 -2
- package/dist/server/routes/admin/backup.js +21 -6
- package/dist/server/routes/admin/combos.js +2 -1
- package/dist/server/routes/admin/models.js +7 -7
- package/dist/server/routes/admin/providers.js +15 -14
- package/dist/server/routes/admin/setup.js +4 -1
- package/dist/web/assets/{index-CyboDiyN.js → index-DYrnLQwk.js} +3 -3
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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.18.2] - 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Restore now adopts the backup's master key, including across restarts.
|
|
12
|
+
- Admin deletion now permanently removes API keys, models, and providers with dependent routing configuration cleaned up first.
|
|
13
|
+
- `/setup` always exposes the master-key input and reports runtime key availability.
|
|
14
|
+
|
|
7
15
|
## [1.17.9] - 2026-09-22
|
|
8
16
|
|
|
9
17
|
### Fixed
|
|
@@ -49,12 +49,21 @@ export function loadConfig(env = process.env, argv = process.argv) {
|
|
|
49
49
|
(parsed.NODE_ENV === 'test' ? path.resolve('../.tmp/test-data') : path.resolve(os.homedir(), '.latedev-router'));
|
|
50
50
|
const isContainer = Boolean(env.LATEDEV_DATA_DIR?.startsWith('/data') || process.env.CONTAINER === '1');
|
|
51
51
|
const dbFile = parsed.LATEDEV_DB_URL ?? path.join(dataDir, 'data.sqlite');
|
|
52
|
+
const restoredMasterKeyPath = path.join(dataDir, 'master.key.restore');
|
|
53
|
+
let restoredMasterKey = null;
|
|
54
|
+
try {
|
|
55
|
+
if (fs.existsSync(restoredMasterKeyPath))
|
|
56
|
+
restoredMasterKey = fs.readFileSync(restoredMasterKeyPath, 'utf8').trim() || null;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* ignore unreadable override — use the configured key */
|
|
60
|
+
}
|
|
52
61
|
cached = {
|
|
53
62
|
host: cliArgs.host ?? parsed.LATEDEV_HOST,
|
|
54
63
|
port: cliArgs.port ?? parsed.LATEDEV_PORT,
|
|
55
64
|
dataDir,
|
|
56
65
|
dbFile,
|
|
57
|
-
masterKey: parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
|
|
66
|
+
masterKey: restoredMasterKey ?? parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
|
|
58
67
|
trustProxyHops: parsed.LATEDEV_TRUST_PROXY,
|
|
59
68
|
logLevel: parsed.LOG_LEVEL ?? parsed.LATEDEV_LOG_LEVEL,
|
|
60
69
|
env: parsed.NODE_ENV,
|
|
@@ -215,8 +215,7 @@ export async function registerApiKeyRoutes(app) {
|
|
|
215
215
|
const k = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).get();
|
|
216
216
|
if (!k)
|
|
217
217
|
throw new GatewayError('invalid_request_error', 'API key not found', { status: 404 });
|
|
218
|
-
|
|
219
|
-
db.update(schema.apiKeys).set({ enabled: false, updatedAt: new Date().toISOString(), name: `${k.name} (deleted ${new Date().toISOString().slice(0, 10)})` }).where(eq(schema.apiKeys.id, id)).run();
|
|
218
|
+
db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)).run();
|
|
220
219
|
recordAudit({ action: 'api_key.delete', success: true, targetType: 'api_key', targetId: id, targetName: k.name, ip: req.ip });
|
|
221
220
|
return { ok: true };
|
|
222
221
|
});
|
|
@@ -156,6 +156,10 @@ export async function registerBackupRoutes(app) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
const liveDb = cfg.dbFile;
|
|
159
|
+
const previousMasterKey = cfg.masterKey;
|
|
160
|
+
const restoreKeyPath = path.join(cfg.dataDir, 'master.key.restore');
|
|
161
|
+
const previousRestoreKey = fs.existsSync(restoreKeyPath) ? fs.readFileSync(restoreKeyPath) : null;
|
|
162
|
+
const restoredKey = restoredMasterKey?.toString('base64');
|
|
159
163
|
// Snapshot current DB before restore (kept for manual rollback).
|
|
160
164
|
const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
|
|
161
165
|
const Database = (await import('better-sqlite3')).default;
|
|
@@ -186,6 +190,13 @@ export async function registerBackupRoutes(app) {
|
|
|
186
190
|
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'rename_failed', err: String(e) } });
|
|
187
191
|
throw new GatewayError('gateway_error', 'Restore atomic replace failed', { status: 500 });
|
|
188
192
|
}
|
|
193
|
+
// The backup key is authoritative. Apply it before reopening the restored
|
|
194
|
+
// database so every post-restore read uses the key that encrypted it.
|
|
195
|
+
if (restoredKey) {
|
|
196
|
+
fs.writeFileSync(restoreKeyPath, restoredKey, { mode: 0o600, encoding: 'utf8' });
|
|
197
|
+
setConfigMasterKey(restoredKey);
|
|
198
|
+
resetMasterKeyCache();
|
|
199
|
+
}
|
|
189
200
|
// Hot-reload the database in-process: reopen the replaced file, validate
|
|
190
201
|
// it, and keep serving. No gateway restart needed.
|
|
191
202
|
try {
|
|
@@ -207,6 +218,16 @@ export async function registerBackupRoutes(app) {
|
|
|
207
218
|
}
|
|
208
219
|
fs.copyFileSync(snapshot, liveDb);
|
|
209
220
|
openDb(liveDb);
|
|
221
|
+
try {
|
|
222
|
+
if (previousRestoreKey)
|
|
223
|
+
fs.writeFileSync(restoreKeyPath, previousRestoreKey, { mode: 0o600 });
|
|
224
|
+
else
|
|
225
|
+
fs.unlinkSync(restoreKeyPath);
|
|
226
|
+
}
|
|
227
|
+
catch { /* no prior override */ }
|
|
228
|
+
if (previousMasterKey)
|
|
229
|
+
setConfigMasterKey(previousMasterKey);
|
|
230
|
+
resetMasterKeyCache();
|
|
210
231
|
}
|
|
211
232
|
catch (rollbackErr) {
|
|
212
233
|
const err = e.message;
|
|
@@ -217,12 +238,6 @@ export async function registerBackupRoutes(app) {
|
|
|
217
238
|
recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'validation_failed', err: e.message } });
|
|
218
239
|
throw new GatewayError('gateway_error', `Restore failed: ${e.message}`, { status: 500 });
|
|
219
240
|
}
|
|
220
|
-
if (restoredMasterKey) {
|
|
221
|
-
const restoredKey = restoredMasterKey.toString('base64');
|
|
222
|
-
fs.writeFileSync(path.join(cfg.dataDir, 'master.key'), restoredKey, { mode: 0o600, encoding: 'utf8' });
|
|
223
|
-
setConfigMasterKey(restoredKey);
|
|
224
|
-
resetMasterKeyCache();
|
|
225
|
-
}
|
|
226
241
|
// The swap invalidates the previous admin session (its row lived in the
|
|
227
242
|
// old database). Re-create the current session in the restored database so
|
|
228
243
|
// the admin stays logged in across the hot restore.
|
|
@@ -8,9 +8,10 @@ import { uuid } from '../../auth/ids.js';
|
|
|
8
8
|
import { GatewayError } from '../../errors.js';
|
|
9
9
|
import { circuitBlocks } from '../../routing/circuit.js';
|
|
10
10
|
const MemberSpec = z.object({ modelId: z.string(), position: z.number().int().min(0), enabled: z.boolean().default(true) });
|
|
11
|
+
const OptionalSlug = z.preprocess((value) => typeof value === 'string' && value.trim() === '' ? undefined : value, z.string().min(1).max(64).optional());
|
|
11
12
|
const ComboCreate = z.object({
|
|
12
13
|
name: z.string().min(1).max(128),
|
|
13
|
-
slug:
|
|
14
|
+
slug: OptionalSlug,
|
|
14
15
|
mode: z.literal('fallback').default('fallback'),
|
|
15
16
|
maxTotalAttempts: z.number().int().min(1).max(8).optional(),
|
|
16
17
|
enabled: z.boolean().optional(),
|
|
@@ -219,13 +219,13 @@ export async function registerModelRoutes(app) {
|
|
|
219
219
|
const m = db.select().from(schema.models).where(eq(schema.models.id, id)).get();
|
|
220
220
|
if (!m)
|
|
221
221
|
throw new GatewayError('invalid_request_error', 'Model not found', { status: 404 });
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
222
|
+
db.transaction((tx) => {
|
|
223
|
+
tx.delete(schema.comboMembers).where(eq(schema.comboMembers.modelId, id)).run();
|
|
224
|
+
tx.delete(schema.modelAliases).where(sql `target_kind = 'model' AND target_id = ${id}`).run();
|
|
225
|
+
tx.delete(schema.apiKeyModelPermissions).where(sql `target_kind = 'model' AND target_id = ${id}`).run();
|
|
226
|
+
tx.delete(schema.responseCache).where(sql `target_kind = 'model' AND target_id = ${id}`).run();
|
|
227
|
+
tx.delete(schema.models).where(eq(schema.models.id, id)).run();
|
|
228
|
+
});
|
|
229
229
|
recordAudit({ action: 'model.delete', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
|
|
230
230
|
return { ok: true };
|
|
231
231
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { sql, eq } from 'drizzle-orm';
|
|
2
|
+
import { sql, eq, inArray } from 'drizzle-orm';
|
|
3
3
|
import { getDb, schema } from '../../db/index.js';
|
|
4
4
|
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
5
|
import { recordAudit } from '../../db/repositories/audit.js';
|
|
@@ -156,19 +156,20 @@ export async function registerProviderRoutes(app) {
|
|
|
156
156
|
const p = db.select().from(schema.providers).where(eq(schema.providers.id, id)).get();
|
|
157
157
|
if (!p)
|
|
158
158
|
throw new GatewayError('invalid_request_error', 'Provider not found', { status: 404 });
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
const models = db.select({ id: schema.models.id }).from(schema.models).where(eq(schema.models.providerId, id)).all();
|
|
160
|
+
const modelIds = models.map((model) => model.id);
|
|
161
|
+
db.transaction((tx) => {
|
|
162
|
+
tx.delete(schema.codexAccounts).where(eq(schema.codexAccounts.providerId, id)).run();
|
|
163
|
+
tx.delete(schema.qoderAccounts).where(eq(schema.qoderAccounts.providerId, id)).run();
|
|
164
|
+
if (modelIds.length > 0) {
|
|
165
|
+
tx.delete(schema.comboMembers).where(inArray(schema.comboMembers.modelId, modelIds)).run();
|
|
166
|
+
tx.delete(schema.modelAliases).where(sql `target_kind = 'model' AND target_id IN (${sql.join(modelIds.map((modelId) => sql `${modelId}`), sql `, `)})`).run();
|
|
167
|
+
tx.delete(schema.apiKeyModelPermissions).where(sql `target_kind = 'model' AND target_id IN (${sql.join(modelIds.map((modelId) => sql `${modelId}`), sql `, `)})`).run();
|
|
168
|
+
tx.delete(schema.responseCache).where(sql `target_kind = 'model' AND target_id IN (${sql.join(modelIds.map((modelId) => sql `${modelId}`), sql `, `)})`).run();
|
|
169
|
+
tx.delete(schema.models).where(inArray(schema.models.id, modelIds)).run();
|
|
170
|
+
}
|
|
171
|
+
tx.delete(schema.providers).where(eq(schema.providers.id, id)).run();
|
|
172
|
+
});
|
|
172
173
|
recordAudit({ action: 'provider.delete', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
|
|
173
174
|
return { ok: true };
|
|
174
175
|
});
|
|
@@ -20,7 +20,10 @@ export async function registerSetupRoutes(app) {
|
|
|
20
20
|
const s = getSettings();
|
|
21
21
|
return {
|
|
22
22
|
setupComplete: s.setupComplete,
|
|
23
|
-
|
|
23
|
+
// The database flag can survive removal of the key file or env var. The
|
|
24
|
+
// setup form needs the runtime truth so it never hides the only recovery
|
|
25
|
+
// input when encryption is not actually available.
|
|
26
|
+
masterKeyConfigured: isMasterKeyConfigured(),
|
|
24
27
|
};
|
|
25
28
|
});
|
|
26
29
|
app.post('/api/admin/setup', async (req, _reply) => {
|