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 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
- // Soft-delete: keep name/prefix snapshot for history; mark disabled
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: z.string().min(1).max(64).optional(),
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
- const inCombo = db.select().from(schema.comboMembers).where(eq(schema.comboMembers.modelId, id)).all();
223
- if (inCombo.length > 0) {
224
- db.update(schema.models).set({ enabled: false, upstreamAvailable: false, updatedAt: new Date().toISOString() }).where(eq(schema.models.id, id)).run();
225
- recordAudit({ action: 'model.soft_disable', success: true, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip, metadata: { reason: 'in_combo' } });
226
- return { ok: true, softDisabled: true };
227
- }
228
- db.delete(schema.models).where(eq(schema.models.id, id)).run();
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 used = db.select().from(schema.models).where(eq(schema.models.providerId, id)).all();
160
- if (used.length > 0) {
161
- // Soft-disable if there are dependent models
162
- db.update(schema.providers).set({ enabled: false, updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, id)).run();
163
- recordAudit({ action: 'provider.soft_disable', success: true, targetType: 'provider', targetId: id, targetName: p.name, ip: req.ip });
164
- return { ok: true, softDisabled: true };
165
- }
166
- try {
167
- db.delete(schema.providers).where(eq(schema.providers.id, id)).run();
168
- }
169
- catch (error) {
170
- throw new GatewayError('invalid_request_error', 'Provider is still referenced and cannot be deleted', { status: 409, cause: error });
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
- masterKeyConfigured: s.masterKeyConfigured || isMasterKeyConfigured(),
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) => {