ldrouter 1.12.0 → 1.14.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,24 @@ 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.14.0] - 2026-09-11
8
+
9
+ ### Added
10
+
11
+ - **Docker request logging**: every request now emits structured `info` logs when received and a completion log classified as `info` (2xx/3xx), `warn` (4xx), or `error` (5xx), with request ID, method, route, status, and duration. Query strings, headers, bodies, and secrets are excluded.
12
+
13
+ ## [1.13.1] - 2026-09-11
14
+
15
+ ### Fixed
16
+
17
+ - **Update notification**: force a fresh npm registry check when the admin UI loads, so the update button is not hidden by the 15-minute cache.
18
+
19
+ ## [1.13.0] - 2026-09-11
20
+
21
+ ### Added
22
+
23
+ - **Passphrase-protected full backups**: backups now include administrator state and a master-key envelope protected by a user-entered six-digit passphrase; `/setup` can import them without recreating the admin account.
24
+
7
25
  ## [1.12.0] - 2026-09-05
8
26
 
9
27
  ### Added
@@ -21,7 +21,7 @@ import { registerGatewayRoutes } from './routes/gateway.js';
21
21
  import { registerHealthRoutes } from './routes/health.js';
22
22
  import { registerAdminIpGate } from './security/admin-ip-gate.js';
23
23
  import { metricsRegistry } from './metrics/registry.js';
24
- import { fatal, lifecycle, formatError, getDebugFlags, errorLine } from './logging/debug.js';
24
+ import { fatal, lifecycle, formatError, getDebugFlags, errorLine, requestLogFields, requestLogLevel, requestLogMessage } from './logging/debug.js';
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
26
  export async function buildApp(opts = {}) {
27
27
  const cfg = loadConfig();
@@ -43,9 +43,19 @@ export async function buildApp(opts = {}) {
43
43
  crossOriginEmbedderPolicy: false,
44
44
  });
45
45
  await app.register(cors, { origin: false, credentials: true });
46
- // Per-request logging + error shaping
46
+ // Every request is logged as structured JSON for Docker. Bodies and headers
47
+ // are intentionally excluded; sensitive values must never reach logs.
48
+ app.addHook('onRequest', async (req) => {
49
+ req.requestStartedAt = Date.now();
50
+ log.info({ requestId: req.id, method: req.method, url: req.url }, 'request received');
51
+ });
47
52
  app.addHook('onResponse', async (req, reply) => {
48
53
  reply.header('x-request-id', req.id);
54
+ const startedAt = req.requestStartedAt ?? Date.now();
55
+ const statusCode = reply.statusCode;
56
+ const fields = requestLogFields(String(req.id), req.method, req.url, statusCode, Date.now() - startedAt, req.routeOptions?.url);
57
+ const level = requestLogLevel(statusCode);
58
+ log[level](fields, requestLogMessage(statusCode));
49
59
  });
50
60
  app.setErrorHandler((err, req, reply) => {
51
61
  // Zod validation failures surface as 400 with readable field messages;
@@ -37,8 +37,8 @@ export function authenticateGatewayKey(req) {
37
37
  candidate = anthropic;
38
38
  if (!candidate)
39
39
  return null;
40
- // DEBUG: Log what we're trying to authenticate
41
- console.log(`🔑 API KEY AUTH - Candidate extracted: ${candidate.slice(0, 8)}...${candidate.slice(-4)}`);
40
+ // Never log API-key material, including partial values.
41
+ console.log('🔑 API KEY AUTH - Candidate received');
42
42
  // Custom keys are stored verbatim (no prefix requirement); auto-generated
43
43
  // keys start with ld-, but authentication must accept any stored secret.
44
44
  const digest = sha256Hex(candidate);
@@ -0,0 +1,31 @@
1
+ import crypto from 'node:crypto';
2
+ function passphraseKey(passphrase, salt) {
3
+ if (!/^\d{6}$/.test(passphrase))
4
+ throw new Error('Backup passphrase must contain exactly six digits');
5
+ return crypto.scryptSync(passphrase, salt, 32, { N: 16_384, r: 8, p: 1 });
6
+ }
7
+ export function encryptBackupMasterKey(masterKey, passphrase) {
8
+ const salt = crypto.randomBytes(16);
9
+ const nonce = crypto.randomBytes(12);
10
+ const cipher = crypto.createCipheriv('aes-256-gcm', passphraseKey(passphrase, salt), nonce);
11
+ const ciphertext = Buffer.concat([cipher.update(masterKey), cipher.final()]);
12
+ return {
13
+ algorithm: 'scrypt-aes-256-gcm',
14
+ salt: salt.toString('base64'),
15
+ nonce: nonce.toString('base64'),
16
+ ciphertext: ciphertext.toString('base64'),
17
+ tag: cipher.getAuthTag().toString('base64'),
18
+ };
19
+ }
20
+ export function decryptBackupMasterKey(envelope, passphrase) {
21
+ if (envelope.algorithm !== 'scrypt-aes-256-gcm')
22
+ throw new Error('Unsupported backup key encryption');
23
+ try {
24
+ const decipher = crypto.createDecipheriv('aes-256-gcm', passphraseKey(passphrase, Buffer.from(envelope.salt, 'base64')), Buffer.from(envelope.nonce, 'base64'));
25
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
26
+ return Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, 'base64')), decipher.final()]);
27
+ }
28
+ catch {
29
+ throw new Error('Invalid backup passphrase');
30
+ }
31
+ }
@@ -40,6 +40,9 @@ export function getMasterKey() {
40
40
  export function isMasterKeyConfigured() {
41
41
  return Boolean(loadConfig().masterKey);
42
42
  }
43
+ export function resetMasterKeyCache() {
44
+ cachedKey = null;
45
+ }
43
46
  export function masterKeyVersion() {
44
47
  return cachedKeyVersion;
45
48
  }
@@ -14,6 +14,24 @@
14
14
  // they emit at debug, so set LOG_LEVEL=debug to see them.
15
15
  import process from 'node:process';
16
16
  import { redactValue } from '../security/redact.js';
17
+ export function requestLogLevel(statusCode) {
18
+ if (statusCode >= 500)
19
+ return 'error';
20
+ if (statusCode >= 400)
21
+ return 'warn';
22
+ return 'info';
23
+ }
24
+ export function requestLogMessage(statusCode) {
25
+ if (statusCode >= 500)
26
+ return 'request completed with server error';
27
+ if (statusCode >= 400)
28
+ return 'request completed with client error';
29
+ return 'request completed';
30
+ }
31
+ export function requestLogFields(requestId, method, url, statusCode, durationMs, route) {
32
+ const safeUrl = url.split('?')[0] ?? url;
33
+ return { requestId, method, url: safeUrl, route: route ?? safeUrl, statusCode, durationMs };
34
+ }
17
35
  function envFlag(name) {
18
36
  const v = process.env[name];
19
37
  return v === '1' || v === 'true' || v === 'yes';
@@ -114,8 +114,8 @@ export async function registerApiKeyRoutes(app) {
114
114
  const enc = encryptSecret(secret);
115
115
  // DEBUG: Log what we're about to insert
116
116
  console.log(`📝 CREATE API KEY - Name: ${body.name}, Prefix: ${keyPrefix}`);
117
- console.log(`📝 CREATE API KEY - Secret (full): ${secret}`);
118
- console.log(`📝 CREATE API KEY - Digest: ${keyDigest}`);
117
+ console.log('📝 CREATE API KEY - Secret: [REDACTED]');
118
+ console.log('📝 CREATE API KEY - Digest: [REDACTED]');
119
119
  db.insert(schema.apiKeys).values({
120
120
  id,
121
121
  name: body.name,
@@ -7,11 +7,13 @@ import { getDb, closeDb, openDb, schema } from '../../db/index.js';
7
7
  import { requireAdminAuth } from '../../auth/middleware.js';
8
8
  import { sha256Hex } from '../../auth/ids.js';
9
9
  import { recordAudit } from '../../db/repositories/audit.js';
10
- import { loadConfig } from '../../config/index.js';
10
+ import { loadConfig, setConfigMasterKey } from '../../config/index.js';
11
11
  import { getSettings } from '../../db/repositories/settings.js';
12
12
  import { GatewayError } from '../../errors.js';
13
13
  import { getAppVersion } from '../../version.js';
14
14
  import { eq, sql } from 'drizzle-orm';
15
+ import { decryptBackupMasterKey, encryptBackupMasterKey } from '../../auth/backup-crypto.js';
16
+ import { parseMasterKey, resetMasterKeyCache } from '../../auth/crypto.js';
15
17
  const BACKUP_VERSION = 1;
16
18
  /** Reopen the in-process SQLite connection on the (possibly just-replaced)
17
19
  * database file. The old connection must already be closed: a hot restore
@@ -52,8 +54,16 @@ function assertDatabaseUsable(expectedSchemaVersion) {
52
54
  }
53
55
  }
54
56
  export async function registerBackupRoutes(app) {
55
- app.addHook('preHandler', requireAdminAuth);
57
+ app.addHook('preHandler', async (req, reply) => {
58
+ if (req.url === '/api/admin/backup/restore' && !getSettings().setupComplete)
59
+ return;
60
+ return requireAdminAuth(req, reply);
61
+ });
56
62
  app.post('/api/admin/backup/create', async (req, reply) => {
63
+ const passphrase = req.body?.passphrase ?? '';
64
+ if (!/^\d{6}$/.test(passphrase)) {
65
+ throw new GatewayError('invalid_request_error', 'Backup passphrase must contain exactly six digits', { status: 400 });
66
+ }
57
67
  const cfg = loadConfig();
58
68
  const temp = path.join(cfg.dataDir, `.backup-${Date.now()}.sqlite`);
59
69
  const Database = (await import('better-sqlite3')).default;
@@ -69,6 +79,7 @@ export async function registerBackupRoutes(app) {
69
79
  const compressed = zlib.gzipSync(buf, { level: 6 });
70
80
  const checksum = crypto.createHash('sha256').update(compressed).digest('hex');
71
81
  const settings = getSettings();
82
+ const masterKey = parseMasterKey(cfg.masterKey ?? fs.readFileSync(path.join(cfg.dataDir, 'master.key'), 'utf8').trim());
72
83
  const envelope = {
73
84
  format: 'latedev-backup',
74
85
  version: BACKUP_VERSION,
@@ -78,6 +89,7 @@ export async function registerBackupRoutes(app) {
78
89
  createdAt: new Date().toISOString(),
79
90
  payload: compressed.toString('base64'),
80
91
  checksum,
92
+ keyEnvelope: encryptBackupMasterKey(masterKey, passphrase),
81
93
  };
82
94
  const envBuf = Buffer.from(JSON.stringify(envelope), 'utf8');
83
95
  const fileName = `latedev-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ldb.json`;
@@ -88,12 +100,17 @@ export async function registerBackupRoutes(app) {
88
100
  });
89
101
  app.post('/api/admin/backup/restore', async (req, reply) => {
90
102
  const cfg = loadConfig();
91
- // Expect raw JSON envelope in body
103
+ const incoming = req.body;
104
+ const wrapped = Boolean(incoming && 'backup' in incoming);
105
+ const wrappedBody = incoming;
106
+ const passphrase = wrapped ? wrappedBody.passphrase : undefined;
92
107
  let envelope;
93
108
  try {
94
- envelope = req.body;
109
+ envelope = (wrapped ? wrappedBody.backup : incoming);
95
110
  if (!envelope || envelope.format !== 'latedev-backup')
96
111
  throw new Error('not a backup envelope');
112
+ if (envelope.keyEnvelope && !/^\d{6}$/.test(passphrase ?? ''))
113
+ throw new Error('backup passphrase required');
97
114
  }
98
115
  catch {
99
116
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_envelope' } });
@@ -122,6 +139,17 @@ export async function registerBackupRoutes(app) {
122
139
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'not_sqlite' } });
123
140
  throw new GatewayError('invalid_request_error', 'Backup does not contain a valid SQLite database', { status: 400 });
124
141
  }
142
+ let restoredMasterKey;
143
+ if (envelope.keyEnvelope) {
144
+ try {
145
+ restoredMasterKey = decryptBackupMasterKey(envelope.keyEnvelope, passphrase);
146
+ parseMasterKey(restoredMasterKey.toString('base64'));
147
+ }
148
+ catch {
149
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_backup_passphrase' } });
150
+ throw new GatewayError('invalid_request_error', 'Invalid backup passphrase', { status: 400 });
151
+ }
152
+ }
125
153
  const liveDb = cfg.dbFile;
126
154
  // Snapshot current DB before restore (kept for manual rollback).
127
155
  const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
@@ -184,6 +212,12 @@ export async function registerBackupRoutes(app) {
184
212
  recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'validation_failed', err: e.message } });
185
213
  throw new GatewayError('gateway_error', `Restore failed: ${e.message}`, { status: 500 });
186
214
  }
215
+ if (restoredMasterKey) {
216
+ const restoredKey = restoredMasterKey.toString('base64');
217
+ fs.writeFileSync(path.join(cfg.dataDir, 'master.key'), restoredKey, { mode: 0o600, encoding: 'utf8' });
218
+ setConfigMasterKey(restoredKey);
219
+ resetMasterKeyCache();
220
+ }
187
221
  // The swap invalidates the previous admin session (its row lived in the
188
222
  // old database). Re-create the current session in the restored database so
189
223
  // the admin stays logged in across the hot restore.