ldrouter 1.6.1 → 1.6.3
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 +23 -0
- package/dist/cli.js +22 -0
- package/dist/server/app.js +138 -0
- package/dist/server/auth/api-key.js +75 -0
- package/dist/server/auth/crypto.js +94 -0
- package/dist/server/auth/ids.js +40 -0
- package/dist/server/auth/middleware.js +36 -0
- package/dist/server/auth/recovery.js +11 -0
- package/dist/server/caching/store.js +119 -0
- package/dist/server/cli/tui/ansi.js +58 -0
- package/dist/server/cli/tui/noise.js +399 -0
- package/dist/server/config/index.js +97 -0
- package/dist/server/db/index.js +64 -0
- package/dist/server/db/migrate.js +408 -0
- package/dist/server/db/repositories/audit.js +75 -0
- package/dist/server/db/repositories/settings.js +63 -0
- package/dist/server/db/schema.js +396 -0
- package/dist/server/errors.js +65 -0
- package/dist/server/gateway/runner.js +745 -0
- package/dist/server/logging/logger.js +35 -0
- package/dist/server/maintenance/retention.js +48 -0
- package/dist/server/metrics/registry.js +169 -0
- package/dist/server/protocols/anthropic.js +154 -0
- package/dist/server/protocols/canonical.js +201 -0
- package/dist/server/providers/index.js +89 -0
- package/dist/server/routes/admin/aliases.js +98 -0
- package/dist/server/routes/admin/api-keys.js +194 -0
- package/dist/server/routes/admin/audit.js +19 -0
- package/dist/server/routes/admin/auth.js +124 -0
- package/dist/server/routes/admin/backup.js +113 -0
- package/dist/server/routes/admin/combos.js +198 -0
- package/dist/server/routes/admin/dashboard.js +55 -0
- package/dist/server/routes/admin/models.js +178 -0
- package/dist/server/routes/admin/providers.js +212 -0
- package/dist/server/routes/admin/requests.js +156 -0
- package/dist/server/routes/admin/settings.js +197 -0
- package/dist/server/routes/admin/setup.js +80 -0
- package/dist/server/routes/admin/stats.js +180 -0
- package/dist/server/routes/admin.js +39 -0
- package/dist/server/routes/gateway/anthropic.js +112 -0
- package/dist/server/routes/gateway/openai.js +257 -0
- package/dist/server/routes/gateway.js +7 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routing/capabilities.js +52 -0
- package/dist/server/routing/circuit.js +37 -0
- package/dist/server/routing/combo.js +100 -0
- package/dist/server/routing/quota.js +51 -0
- package/dist/server/routing/ratelimit.js +58 -0
- package/dist/server/routing/resolver.js +43 -0
- package/dist/server/security/redact.js +111 -0
- package/dist/server/selfupdate/index.js +208 -0
- package/dist/server/upstream/client.js +179 -0
- package/dist/server/util/cidr.js +91 -0
- package/dist/server/util/client-ip.js +15 -0
- package/dist/server/util/stable-json.js +19 -0
- package/dist/server/version.js +35 -0
- package/dist/shared/types.js +2 -0
- package/dist/web/assets/index-B3mCvc2W.js +251 -0
- package/dist/web/assets/index-DSddXVaT.css +1 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.png +0 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ 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.6.3] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **TUI mode defaults**: Running `ldrouter` without args now enters interactive
|
|
12
|
+
TUI automatically if stdout is a TTY. Added `--no-tui` flag to force plain
|
|
13
|
+
server mode when needed (e.g., CI pipelines, logging redirects).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Log pollution in TUI**: Reduced log level from `fatal` → `error` so that
|
|
18
|
+
deprecation warnings and other routine logs don't break the terminal UI
|
|
19
|
+
layout. Raw stdin mode activated earlier to capture all key presses cleanly.
|
|
20
|
+
|
|
21
|
+
## [1.6.2] - 2026-08-30
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **CI/CD:** GitHub Actions now builds `dist/` before publishing to npm (the
|
|
26
|
+
previous release missed the build step in the `npm-publish` job; manual
|
|
27
|
+
`scripts/publish.sh` always ran `pnpm build`). The published tarball now
|
|
28
|
+
includes the CLI binary so `ldrouter --tui` works after `npm install -g`.
|
|
29
|
+
|
|
7
30
|
## [1.6.1] - 2026-08-30
|
|
8
31
|
|
|
9
32
|
### Added
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// LateDev Router CLI entry point. The package's "bin" field points here.
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
async function main() {
|
|
5
|
+
// Auto-detect TUI mode — defaults to TUI when stdout is interactive
|
|
6
|
+
const hasNoTuiFlag = process.argv.includes('--no-tui');
|
|
7
|
+
const isTuiMode = !hasNoTuiFlag && process.stdin.isTTY && process.stdout.isTTY;
|
|
8
|
+
if (isTuiMode) {
|
|
9
|
+
// Launch TUI mode (zero-dependency console UI)
|
|
10
|
+
const { runCliTui } = await import('./server/cli/tui/noise.js');
|
|
11
|
+
await runCliTui();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
// Normal server mode (no terminal, or explicitly disabled via --no-tui)
|
|
15
|
+
process.env.LATEDEV_CLI_ENTRY = '1';
|
|
16
|
+
const { startApp } = await import('./server/app.js');
|
|
17
|
+
await startApp();
|
|
18
|
+
}
|
|
19
|
+
main().catch((err) => {
|
|
20
|
+
console.error('Fatal:', err);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Fastify application factory.
|
|
2
|
+
import Fastify from 'fastify';
|
|
3
|
+
import cookie from '@fastify/cookie';
|
|
4
|
+
import helmet from '@fastify/helmet';
|
|
5
|
+
import cors from '@fastify/cors';
|
|
6
|
+
import staticPlugin from '@fastify/static';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { loadConfig } from './config/index.js';
|
|
11
|
+
import { getLogger } from './logging/logger.js';
|
|
12
|
+
import { openDb, closeDb, getDb, schema } from './db/index.js';
|
|
13
|
+
import { getSettings, markSetupComplete } from './db/repositories/settings.js';
|
|
14
|
+
import { GatewayError, toAnthropicError, toOpenAIError } from './errors.js';
|
|
15
|
+
import { ZodError } from 'zod';
|
|
16
|
+
import { generateRequestId } from './auth/ids.js';
|
|
17
|
+
import { recordAudit } from './db/repositories/audit.js';
|
|
18
|
+
import { isMasterKeyConfigured } from './auth/crypto.js';
|
|
19
|
+
import { registerAdminRoutes } from './routes/admin.js';
|
|
20
|
+
import { registerGatewayRoutes } from './routes/gateway.js';
|
|
21
|
+
import { registerHealthRoutes } from './routes/health.js';
|
|
22
|
+
import { metricsRegistry } from './metrics/registry.js';
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
export async function buildApp(opts = {}) {
|
|
25
|
+
const cfg = loadConfig();
|
|
26
|
+
const log = getLogger();
|
|
27
|
+
if (!opts.skipOpenDb)
|
|
28
|
+
openDb(cfg.dbFile);
|
|
29
|
+
// Initialize metrics registry after logger is available
|
|
30
|
+
metricsRegistry.init(log);
|
|
31
|
+
const app = Fastify({
|
|
32
|
+
logger: false,
|
|
33
|
+
bodyLimit: 64 * 1024 * 1024,
|
|
34
|
+
trustProxy: cfg.trustProxyHops > 0,
|
|
35
|
+
genReqId: (req) => req.headers['x-request-id'] || generateRequestId(),
|
|
36
|
+
disableRequestLogging: false,
|
|
37
|
+
});
|
|
38
|
+
await app.register(cookie, { secret: cfg.masterKey ?? 'latedev-dev-secret' });
|
|
39
|
+
await app.register(helmet, {
|
|
40
|
+
contentSecurityPolicy: false, // we set a permissive one for the admin UI inline bootstrap
|
|
41
|
+
crossOriginEmbedderPolicy: false,
|
|
42
|
+
});
|
|
43
|
+
await app.register(cors, { origin: false, credentials: true });
|
|
44
|
+
// Per-request logging + error shaping
|
|
45
|
+
app.addHook('onResponse', async (req, reply) => {
|
|
46
|
+
reply.header('x-request-id', req.id);
|
|
47
|
+
});
|
|
48
|
+
app.setErrorHandler((err, req, reply) => {
|
|
49
|
+
// Zod validation failures surface as 400 with readable field messages;
|
|
50
|
+
// otherwise they fall through to the generic 500 "Gateway error" envelope.
|
|
51
|
+
const normalized = err instanceof ZodError
|
|
52
|
+
? new GatewayError('invalid_request_error', err.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '), { status: 400 })
|
|
53
|
+
: err;
|
|
54
|
+
const g = normalized instanceof GatewayError ? normalized : null;
|
|
55
|
+
const status = g?.status ?? 500;
|
|
56
|
+
const requestId = req.id;
|
|
57
|
+
// Fastify runs with `logger: false`, so req.log is a silent no-op — use the app
|
|
58
|
+
// logger so failures actually show up in `docker logs`.
|
|
59
|
+
const log = getLogger();
|
|
60
|
+
const errMsg = normalized instanceof Error ? normalized.message : String(normalized);
|
|
61
|
+
const errDetail = {
|
|
62
|
+
name: normalized instanceof Error ? normalized.name : undefined,
|
|
63
|
+
message: errMsg,
|
|
64
|
+
stack: normalized instanceof Error ? normalized.stack : undefined,
|
|
65
|
+
};
|
|
66
|
+
if (status >= 500)
|
|
67
|
+
log.error({ requestId, url: req.url, err: errDetail }, 'request error');
|
|
68
|
+
else
|
|
69
|
+
log.warn({ requestId, url: req.url, err: { type: (g?.type ?? 'error'), message: errMsg } }, 'request rejected');
|
|
70
|
+
const accept = (req.headers['accept'] ?? '').toString();
|
|
71
|
+
const isAnthropic = accept.includes('application/vnd.anthropic') || req.url.includes('/v1/messages');
|
|
72
|
+
if (g) {
|
|
73
|
+
reply.code(status).send(isAnthropic ? toAnthropicError(g, requestId) : toOpenAIError(g, requestId));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const e = new GatewayError('gateway_error', 'Internal gateway error', { safe: false, cause: err });
|
|
77
|
+
reply.code(500).send(isAnthropic ? toAnthropicError(e, requestId) : toOpenAIError(e, requestId));
|
|
78
|
+
});
|
|
79
|
+
// Static admin UI (if built). The not-found handler is registered once:
|
|
80
|
+
// with a built UI it serves the SPA index.html for non-API paths; without it,
|
|
81
|
+
// every miss returns a JSON 404 in the requesting protocol's shape.
|
|
82
|
+
const webDist = path.resolve(__dirname, '../web');
|
|
83
|
+
const hasWeb = fs.existsSync(webDist);
|
|
84
|
+
app.setNotFoundHandler((req, reply) => {
|
|
85
|
+
if (req.url.startsWith('/api') || req.url.startsWith('/v1') || req.url.startsWith('/health') || req.url.startsWith('/ready') || req.url.startsWith('/metrics') || !hasWeb) {
|
|
86
|
+
const e = new GatewayError('invalid_request_error', 'Route not found', { status: 404 });
|
|
87
|
+
reply.code(404).send(toOpenAIError(e, req.id));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
reply.type('text/html').send(fs.readFileSync(path.join(webDist, 'index.html')));
|
|
91
|
+
});
|
|
92
|
+
if (hasWeb) {
|
|
93
|
+
await app.register(staticPlugin, { root: webDist, prefix: '/', decorateReply: false });
|
|
94
|
+
}
|
|
95
|
+
// Operational routes (always available)
|
|
96
|
+
await registerHealthRoutes(app);
|
|
97
|
+
// Admin + gateway routes
|
|
98
|
+
await registerAdminRoutes(app);
|
|
99
|
+
await registerGatewayRoutes(app);
|
|
100
|
+
// On startup: ensure settings row + detect master key status
|
|
101
|
+
app.addHook('onReady', async () => {
|
|
102
|
+
const s = getSettings();
|
|
103
|
+
if (!s.masterKeyConfigured && isMasterKeyConfigured()) {
|
|
104
|
+
getDb()
|
|
105
|
+
.update(schema.appSettings)
|
|
106
|
+
.set({ masterKeyConfigured: true })
|
|
107
|
+
.where(sql `id=1`)
|
|
108
|
+
.run();
|
|
109
|
+
}
|
|
110
|
+
if (s.setupComplete) {
|
|
111
|
+
log.info({ dbFile: cfg.dbFile }, 'LateDev Router ready');
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
log.info({ dbFile: cfg.dbFile }, 'LateDev Router ready (first-run setup required)');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
return app;
|
|
118
|
+
}
|
|
119
|
+
export async function startApp() {
|
|
120
|
+
const cfg = loadConfig();
|
|
121
|
+
const app = await buildApp();
|
|
122
|
+
const close = async (signal) => {
|
|
123
|
+
app.log.info({ signal }, 'shutting down');
|
|
124
|
+
try {
|
|
125
|
+
await app.close();
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
closeDb();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
process.once('SIGTERM', () => void close('SIGTERM'));
|
|
132
|
+
process.once('SIGINT', () => void close('SIGINT'));
|
|
133
|
+
await app.listen({ host: cfg.host, port: cfg.port });
|
|
134
|
+
return app;
|
|
135
|
+
}
|
|
136
|
+
// re-export for convenience
|
|
137
|
+
import { sql } from 'drizzle-orm';
|
|
138
|
+
export { recordAudit, markSetupComplete };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Gateway API-key authentication: extract credential, hash, compare.
|
|
2
|
+
import { eq, sql } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../db/index.js';
|
|
4
|
+
import { sha256Hex } from './ids.js';
|
|
5
|
+
void sha256Hex;
|
|
6
|
+
import { GatewayError } from '../errors.js';
|
|
7
|
+
export function extractBearerToken(header) {
|
|
8
|
+
if (!header)
|
|
9
|
+
return null;
|
|
10
|
+
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
11
|
+
return m ? m[1].trim() : null;
|
|
12
|
+
}
|
|
13
|
+
export function extractAnthropicKey(headers) {
|
|
14
|
+
const v = headers['x-api-key'];
|
|
15
|
+
if (!v)
|
|
16
|
+
return null;
|
|
17
|
+
if (Array.isArray(v))
|
|
18
|
+
return v[0] ?? null;
|
|
19
|
+
return v;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Authenticate a gateway API key. Deterministic precedence:
|
|
23
|
+
* 1. Authorization: Bearer (OpenAI-style)
|
|
24
|
+
* 2. x-api-key (Anthropic-style)
|
|
25
|
+
* Returns null when no credential is present (caller decides 401 vs anonymous).
|
|
26
|
+
*/
|
|
27
|
+
export function authenticateGatewayKey(req) {
|
|
28
|
+
const bearer = extractBearerToken(Array.isArray(req.headers.authorization) ? req.headers.authorization[0] : req.headers.authorization);
|
|
29
|
+
const anthropic = extractAnthropicKey(req.headers);
|
|
30
|
+
let candidate = null;
|
|
31
|
+
if (bearer && anthropic && bearer !== anthropic) {
|
|
32
|
+
throw new GatewayError('authentication_error', 'Conflicting credentials', { status: 401 });
|
|
33
|
+
}
|
|
34
|
+
if (bearer)
|
|
35
|
+
candidate = bearer;
|
|
36
|
+
else if (anthropic)
|
|
37
|
+
candidate = anthropic;
|
|
38
|
+
if (!candidate)
|
|
39
|
+
return null;
|
|
40
|
+
if (!candidate.startsWith('ld-')) {
|
|
41
|
+
throw new GatewayError('authentication_error', 'Invalid API key format', { status: 401 });
|
|
42
|
+
}
|
|
43
|
+
const digest = sha256Hex(candidate);
|
|
44
|
+
const db = getDb();
|
|
45
|
+
const row = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.keyDigest, digest)).get();
|
|
46
|
+
if (!row) {
|
|
47
|
+
throw new GatewayError('authentication_error', 'Invalid API key', { status: 401 });
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
id: row.id,
|
|
51
|
+
name: row.name,
|
|
52
|
+
keyPrefix: row.keyPrefix,
|
|
53
|
+
allowAllModels: row.allowAllModels,
|
|
54
|
+
enabled: row.enabled,
|
|
55
|
+
expiresAt: row.expiresAt,
|
|
56
|
+
rpmLimit: row.rpmLimit,
|
|
57
|
+
tpmLimit: row.tpmLimit,
|
|
58
|
+
dailyTokenLimit: row.dailyTokenLimit,
|
|
59
|
+
monthlyTokenLimit: row.monthlyTokenLimit,
|
|
60
|
+
maxConcurrent: row.maxConcurrent,
|
|
61
|
+
maxOutputTokensPerRequest: row.maxOutputTokensPerRequest,
|
|
62
|
+
cacheOverrideEnabled: row.cacheOverrideEnabled,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function keyAllowedFor(key, targetKind, targetId) {
|
|
66
|
+
if (key.allowAllModels)
|
|
67
|
+
return true;
|
|
68
|
+
const db = getDb();
|
|
69
|
+
const row = db
|
|
70
|
+
.select()
|
|
71
|
+
.from(schema.apiKeyModelPermissions)
|
|
72
|
+
.where(sql `api_key_id = ${key.id} AND target_kind = ${targetKind} AND target_id = ${targetId}`)
|
|
73
|
+
.get();
|
|
74
|
+
return Boolean(row);
|
|
75
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Master-key encryption utilities (AES-256-GCM).
|
|
2
|
+
// Provider API keys and sensitive custom headers are encrypted at rest.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { loadConfig } from '../config/index.js';
|
|
5
|
+
export class MasterKeyError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'MasterKeyError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
let cachedKey = null;
|
|
12
|
+
let cachedKeyVersion = 1;
|
|
13
|
+
/** Parse a raw master-key string into 32 key bytes. Accepts 32-byte base64
|
|
14
|
+
* (44 chars) or a plain 32-character string. Exported so setup can reject
|
|
15
|
+
* malformed keys up front instead of failing later at first encrypt. */
|
|
16
|
+
export function parseMasterKey(raw) {
|
|
17
|
+
let decoded;
|
|
18
|
+
try {
|
|
19
|
+
decoded = Buffer.from(raw, 'base64');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
decoded = Buffer.alloc(0);
|
|
23
|
+
}
|
|
24
|
+
if (decoded.length === 32)
|
|
25
|
+
return decoded;
|
|
26
|
+
if (raw.length === 32)
|
|
27
|
+
return Buffer.from(raw, 'utf8');
|
|
28
|
+
throw new MasterKeyError('Master key must be 32 bytes encoded as base64 (44 chars) or a plain 32-character string');
|
|
29
|
+
}
|
|
30
|
+
export function getMasterKey() {
|
|
31
|
+
if (cachedKey)
|
|
32
|
+
return cachedKey;
|
|
33
|
+
const cfg = loadConfig();
|
|
34
|
+
if (!cfg.masterKey) {
|
|
35
|
+
throw new MasterKeyError('LATEDEV_MASTER_KEY is not configured');
|
|
36
|
+
}
|
|
37
|
+
cachedKey = parseMasterKey(cfg.masterKey);
|
|
38
|
+
return cachedKey;
|
|
39
|
+
}
|
|
40
|
+
export function isMasterKeyConfigured() {
|
|
41
|
+
return Boolean(loadConfig().masterKey);
|
|
42
|
+
}
|
|
43
|
+
export function masterKeyVersion() {
|
|
44
|
+
return cachedKeyVersion;
|
|
45
|
+
}
|
|
46
|
+
/** Encrypt a UTF-8 string. Returns { ciphertext (base64), nonce (base64), version }. */
|
|
47
|
+
export function encryptSecret(plain) {
|
|
48
|
+
const key = getMasterKey();
|
|
49
|
+
const nonce = crypto.randomBytes(12);
|
|
50
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
|
51
|
+
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
52
|
+
const tag = cipher.getAuthTag();
|
|
53
|
+
return {
|
|
54
|
+
ciphertext: Buffer.concat([ct, tag]).toString('base64'),
|
|
55
|
+
nonce: nonce.toString('base64'),
|
|
56
|
+
version: masterKeyVersion(),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Decrypt an EncryptedPayload. Throws MasterKeyError on failure. */
|
|
60
|
+
export function decryptSecret(payload) {
|
|
61
|
+
try {
|
|
62
|
+
const key = getMasterKey();
|
|
63
|
+
const nonce = Buffer.from(payload.nonce, 'base64');
|
|
64
|
+
const full = Buffer.from(payload.ciphertext, 'base64');
|
|
65
|
+
const ct = full.subarray(0, full.length - 16);
|
|
66
|
+
const tag = full.subarray(full.length - 16);
|
|
67
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
68
|
+
decipher.setAuthTag(tag);
|
|
69
|
+
const out = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
70
|
+
return out.toString('utf8');
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
throw new MasterKeyError(`Failed to decrypt secret: ${e.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function encryptJson(value) {
|
|
77
|
+
return encryptSecret(JSON.stringify(value));
|
|
78
|
+
}
|
|
79
|
+
export function decryptJson(payload) {
|
|
80
|
+
return JSON.parse(decryptSecret(payload));
|
|
81
|
+
}
|
|
82
|
+
/** Encrypt custom headers map preserving header names. */
|
|
83
|
+
export function encryptCustomHeaders(headers) {
|
|
84
|
+
if (!headers || Object.keys(headers).length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return encryptJson(headers);
|
|
87
|
+
}
|
|
88
|
+
export function decryptCustomHeaders(payload) {
|
|
89
|
+
if (!payload)
|
|
90
|
+
return {};
|
|
91
|
+
return decryptJson(payload);
|
|
92
|
+
}
|
|
93
|
+
// --- Hashing helpers ------------------------------------------------------
|
|
94
|
+
export { sha256Hex, timingSafeEqualHex } from './ids.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ID generation helpers — stable random IDs + opaque sortable request IDs.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
export function uuid() {
|
|
4
|
+
return crypto.randomUUID();
|
|
5
|
+
}
|
|
6
|
+
export function randomBytes(n) {
|
|
7
|
+
return crypto.randomBytes(n);
|
|
8
|
+
}
|
|
9
|
+
/** Opaque sortable-ish request ID: time (ms) base36 + random suffix. */
|
|
10
|
+
export function generateRequestId() {
|
|
11
|
+
const t = Date.now().toString(36);
|
|
12
|
+
const r = crypto.randomBytes(6).toString('base64url');
|
|
13
|
+
return `req_${t}${r}`;
|
|
14
|
+
}
|
|
15
|
+
export function generateSessionToken() {
|
|
16
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
/** Generate a gateway API key: ld-<base64url(32 bytes)> */
|
|
19
|
+
export function generateApiKeySecret() {
|
|
20
|
+
const payload = crypto.randomBytes(32).toString('base64url');
|
|
21
|
+
return `ld-${payload}`;
|
|
22
|
+
}
|
|
23
|
+
export function slugify(input) {
|
|
24
|
+
return (input
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '')
|
|
29
|
+
.slice(0, 64) || 'item');
|
|
30
|
+
}
|
|
31
|
+
export function sha256Hex(input) {
|
|
32
|
+
return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
|
|
33
|
+
}
|
|
34
|
+
export function timingSafeEqualHex(a, b) {
|
|
35
|
+
const ba = Buffer.from(a, 'hex');
|
|
36
|
+
const bb = Buffer.from(b, 'hex');
|
|
37
|
+
if (ba.length !== bb.length)
|
|
38
|
+
return false;
|
|
39
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Auth middleware: admin session validation.
|
|
2
|
+
import { getDb, schema } from '../db/index.js';
|
|
3
|
+
import { sql } from 'drizzle-orm';
|
|
4
|
+
import { sha256Hex } from './ids.js';
|
|
5
|
+
import { GatewayError } from '../errors.js';
|
|
6
|
+
import { recordAudit } from '../db/repositories/audit.js';
|
|
7
|
+
const SessionCookie = 'ld_session';
|
|
8
|
+
export async function requireAdminAuth(req, _reply) {
|
|
9
|
+
const token = req.cookies[SessionCookie];
|
|
10
|
+
if (!token)
|
|
11
|
+
throw new GatewayError('authentication_error', 'Login required', { status: 401 });
|
|
12
|
+
const digest = sha256Hex(token);
|
|
13
|
+
const db = getDb();
|
|
14
|
+
const session = db
|
|
15
|
+
.select()
|
|
16
|
+
.from(schema.adminSessions)
|
|
17
|
+
.where(sql `token_digest = ${digest}`)
|
|
18
|
+
.get();
|
|
19
|
+
if (!session)
|
|
20
|
+
throw new GatewayError('authentication_error', 'Invalid session', { status: 401 });
|
|
21
|
+
if (new Date(session.expiresAt).getTime() < Date.now()) {
|
|
22
|
+
db.delete(schema.adminSessions).where(sql `id = ${session.id}`).run();
|
|
23
|
+
throw new GatewayError('authentication_error', 'Session expired', { status: 401 });
|
|
24
|
+
}
|
|
25
|
+
// Lookup admin by session's owning relationship is implicit (single admin). Get the singleton.
|
|
26
|
+
const admin = db.select().from(schema.adminAccount).get();
|
|
27
|
+
if (!admin) {
|
|
28
|
+
db.delete(schema.adminSessions).where(sql `id = ${session.id}`).run();
|
|
29
|
+
recordAudit({ action: 'admin.session.invalid', success: false, ip: req.ip });
|
|
30
|
+
throw new GatewayError('authentication_error', 'Admin account missing', { status: 401 });
|
|
31
|
+
}
|
|
32
|
+
// Touch last_seen_at occasionally (cheap update)
|
|
33
|
+
db.update(schema.adminSessions).set({ lastSeenAt: new Date().toISOString() }).where(sql `id = ${session.id}`).run();
|
|
34
|
+
req.adminAccount = admin;
|
|
35
|
+
req.adminSessionId = session.id;
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Recovery code generator.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
export function generateRecoveryCodes(count) {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (let i = 0; i < count; i++) {
|
|
6
|
+
const b = crypto.randomBytes(6);
|
|
7
|
+
const groups = [b.subarray(0, 3), b.subarray(3, 6)];
|
|
8
|
+
out.push(groups.map((g) => g.toString('hex').toUpperCase()).join('-'));
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Gateway response cache: SQLite-backed exact-key cache.
|
|
2
|
+
// Disabled by default. Per-key/per-target controls. Stream-bypassed.
|
|
3
|
+
import { and, eq, sql, lt } from 'drizzle-orm';
|
|
4
|
+
import { getDb, schema } from '../db/index.js';
|
|
5
|
+
import { sha256Hex } from '../auth/ids.js';
|
|
6
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
7
|
+
import { stableStringify } from '../util/stable-json.js';
|
|
8
|
+
export function cacheAllowed(opts) {
|
|
9
|
+
if (!opts.globalEnabled)
|
|
10
|
+
return false;
|
|
11
|
+
if (opts.streaming)
|
|
12
|
+
return false;
|
|
13
|
+
if (opts.keyAllowed === false)
|
|
14
|
+
return false;
|
|
15
|
+
if (opts.targetAllowed === false)
|
|
16
|
+
return false;
|
|
17
|
+
// Both unspecified or true => allowed
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
export function buildCacheKey(input) {
|
|
21
|
+
const material = JSON.stringify({
|
|
22
|
+
p: input.protocol,
|
|
23
|
+
k: input.resolvedTargetKind,
|
|
24
|
+
id: input.resolvedTargetId,
|
|
25
|
+
v: input.configVersion,
|
|
26
|
+
r: stableStringify(input.canonicalRequest),
|
|
27
|
+
});
|
|
28
|
+
return sha256Hex(material);
|
|
29
|
+
}
|
|
30
|
+
export function lookupCache(cacheKey) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const row = db.select().from(schema.responseCache).where(eq(schema.responseCache.cacheKey, cacheKey)).get();
|
|
33
|
+
if (!row)
|
|
34
|
+
return { hit: false, payload: null, usage: null };
|
|
35
|
+
if (new Date(row.expiresAt).getTime() < Date.now()) {
|
|
36
|
+
db.delete(schema.responseCache).where(eq(schema.responseCache.id, row.id)).run();
|
|
37
|
+
return { hit: false, payload: null, usage: null };
|
|
38
|
+
}
|
|
39
|
+
db.update(schema.responseCache)
|
|
40
|
+
.set({ hitCount: row.hitCount + 1, lastHitAt: new Date().toISOString() })
|
|
41
|
+
.where(eq(schema.responseCache.id, row.id))
|
|
42
|
+
.run();
|
|
43
|
+
let payload;
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(row.responseJson);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { hit: false, payload: null, usage: null };
|
|
49
|
+
}
|
|
50
|
+
let usage = null;
|
|
51
|
+
if (row.usageJson) {
|
|
52
|
+
try {
|
|
53
|
+
usage = JSON.parse(row.usageJson);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
usage = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { hit: true, payload, usage };
|
|
60
|
+
}
|
|
61
|
+
export function storeCache(input) {
|
|
62
|
+
const db = getDb();
|
|
63
|
+
const responseJson = JSON.stringify(input.payload);
|
|
64
|
+
const usageJson = input.usage ? JSON.stringify(input.usage) : null;
|
|
65
|
+
const bytes = Buffer.byteLength(responseJson, 'utf8');
|
|
66
|
+
const expiresAt = new Date(Date.now() + input.ttlSeconds * 1000).toISOString();
|
|
67
|
+
// Enforce max size
|
|
68
|
+
enforceSizeBudget();
|
|
69
|
+
db.insert(schema.responseCache).values({
|
|
70
|
+
id: crypto.randomUUID(),
|
|
71
|
+
cacheKey: input.cacheKey,
|
|
72
|
+
targetKind: input.targetKind,
|
|
73
|
+
targetId: input.targetId,
|
|
74
|
+
targetConfigVersion: input.configVersion,
|
|
75
|
+
protocol: input.protocol,
|
|
76
|
+
responseJson,
|
|
77
|
+
usageJson,
|
|
78
|
+
expiresAt,
|
|
79
|
+
hitCount: 0,
|
|
80
|
+
bytes,
|
|
81
|
+
}).run();
|
|
82
|
+
}
|
|
83
|
+
export function invalidateCacheFor(kind, id) {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
db.delete(schema.responseCache).where(and(eq(schema.responseCache.targetKind, kind), eq(schema.responseCache.targetId, id))).run();
|
|
86
|
+
}
|
|
87
|
+
export function clearExpired() {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const r = db.delete(schema.responseCache).where(lt(schema.responseCache.expiresAt, new Date().toISOString())).run();
|
|
90
|
+
return r.changes ?? 0;
|
|
91
|
+
}
|
|
92
|
+
export function clearAllCache() {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
const r = db.delete(schema.responseCache).run();
|
|
95
|
+
return r.changes ?? 0;
|
|
96
|
+
}
|
|
97
|
+
function enforceSizeBudget() {
|
|
98
|
+
const s = getSettings();
|
|
99
|
+
const db = getDb();
|
|
100
|
+
const row = db.select({ total: sql `COALESCE(SUM(bytes), 0)` }).from(schema.responseCache).get();
|
|
101
|
+
const total = Number(row?.total ?? 0);
|
|
102
|
+
const limit = s.gatewayCacheMaxSizeMb * 1024 * 1024;
|
|
103
|
+
if (total < limit)
|
|
104
|
+
return;
|
|
105
|
+
// Evict oldest 10% by createdAt
|
|
106
|
+
const tenPct = Math.max(1, Math.floor((db.select({ c: sql `COUNT(*)` }).from(schema.responseCache).get()?.c ?? 1) * 0.1));
|
|
107
|
+
const toEvict = db
|
|
108
|
+
.select({ id: schema.responseCache.id })
|
|
109
|
+
.from(schema.responseCache)
|
|
110
|
+
.orderBy(schema.responseCache.createdAt)
|
|
111
|
+
.limit(tenPct)
|
|
112
|
+
.all();
|
|
113
|
+
if (toEvict.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
for (const e of toEvict) {
|
|
116
|
+
db.delete(schema.responseCache).where(eq(schema.responseCache.id, e.id)).run();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
import crypto from 'node:crypto';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ANSI escape codes — no dependencies.
|
|
3
|
+
*/
|
|
4
|
+
export const home = '\x1b[H'; // Move cursor to top-left (0,0)
|
|
5
|
+
export const clear = '\x1b[2J'; // Clear screen and move cursor to home
|
|
6
|
+
export const eraseDown = '\x1b[0J'; // Clear from cursor to end of screen
|
|
7
|
+
export const hideCursor = '\x1b[?25l';
|
|
8
|
+
export const showCursor = '\x1b[?25h';
|
|
9
|
+
export const color = {
|
|
10
|
+
reset: '\x1b[0m',
|
|
11
|
+
dim: '\x1b[2m',
|
|
12
|
+
gray: '\x1b[90m',
|
|
13
|
+
cyan: '\x1b[36m',
|
|
14
|
+
green: '\x1b[32m',
|
|
15
|
+
red: '\x1b[31m',
|
|
16
|
+
yellow: '\x1b[33m',
|
|
17
|
+
white: '\x1b[37m',
|
|
18
|
+
bold: '\x1b[1m',
|
|
19
|
+
};
|
|
20
|
+
export function line(char = '─') {
|
|
21
|
+
return char.repeat(40);
|
|
22
|
+
}
|
|
23
|
+
export function spinnerFrame(tick) {
|
|
24
|
+
const frames = ['◐', '◓', '◑', '◒'];
|
|
25
|
+
return frames[Math.floor(tick / 2) % frames.length];
|
|
26
|
+
}
|
|
27
|
+
export function statusDot(on) {
|
|
28
|
+
return on ? '●' : '○';
|
|
29
|
+
}
|
|
30
|
+
export function check(ok) {
|
|
31
|
+
return ok ? '✓' : '✗';
|
|
32
|
+
}
|
|
33
|
+
export function arrow(direction) {
|
|
34
|
+
switch (direction) {
|
|
35
|
+
case 'up':
|
|
36
|
+
return '↑';
|
|
37
|
+
case 'down':
|
|
38
|
+
return '↓';
|
|
39
|
+
case 'enter':
|
|
40
|
+
return '→';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function getTerminalWidth() {
|
|
44
|
+
if (typeof process.stdout !== 'undefined' && process.stdout.columns) {
|
|
45
|
+
return Math.min(process.stdout.columns, 80);
|
|
46
|
+
}
|
|
47
|
+
return 80;
|
|
48
|
+
}
|
|
49
|
+
export function getTerminalHeight() {
|
|
50
|
+
if (typeof process.stdout !== 'undefined' && process.stdout.rows) {
|
|
51
|
+
return Math.max(process.stdout.rows, 24);
|
|
52
|
+
}
|
|
53
|
+
return 24;
|
|
54
|
+
}
|
|
55
|
+
// Screen helpers
|
|
56
|
+
export function clearAndHome() {
|
|
57
|
+
process.stdout.write(clear + home);
|
|
58
|
+
}
|