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 +18 -0
- package/dist/server/app.js +12 -2
- package/dist/server/auth/api-key.js +2 -2
- package/dist/server/auth/backup-crypto.js +31 -0
- package/dist/server/auth/crypto.js +3 -0
- package/dist/server/logging/debug.js +18 -0
- package/dist/server/routes/admin/api-keys.js +2 -2
- package/dist/server/routes/admin/backup.js +38 -4
- package/dist/web/assets/index-CBMHkVXC.js +330 -0
- package/dist/web/assets/index-Dswaxg_c.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -3
- package/dist/web/assets/index-CqVMNQ5F.css +0 -1
- package/dist/web/assets/index-DhNkAaqo.js +0 -330
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
|
package/dist/server/app.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
41
|
-
console.log(
|
|
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(
|
|
118
|
-
console.log(
|
|
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',
|
|
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
|
-
|
|
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 =
|
|
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.
|