ldrouter 1.5.1
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 +49 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/cli.js +13 -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/config/index.js +96 -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 +154 -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/shared/types.js +2 -0
- package/dist/web/assets/index-COSbvF8Z.css +1 -0
- package/dist/web/assets/index-DbnEzuxq.js +251 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.png +0 -0
- package/migrations/0001_initial_schema.sql +323 -0
- package/migrations/0002_source_api_key_secrets.sql +7 -0
- package/package.json +117 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [1.5.1] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Self-update reported version `0.0.0` in Docker/direct-node runs where
|
|
11
|
+
`npm_package_version` is unset; it now reads the version from the
|
|
12
|
+
package.json on disk.
|
|
13
|
+
|
|
14
|
+
## [1.5.0] - 2026-08-30
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Self-update: check the npm registry for newer versions and update in place
|
|
18
|
+
from the admin UI (Settings → System). Detects the installing package
|
|
19
|
+
manager (npm / pnpm / yarn / bun) and restarts the server after installing.
|
|
20
|
+
- `/v1/models` now lists the full routable surface: physical models, enabled
|
|
21
|
+
combos, and enabled aliases (previously only physical models), honoring
|
|
22
|
+
per-key model ACLs.
|
|
23
|
+
- Combos created without a slug use their name as the model ID (e.g.
|
|
24
|
+
`gpt-5.5`), keeping dots intact; an explicit slug still yields
|
|
25
|
+
`combo/<slug>`. Duplicate IDs across combos/models are rejected.
|
|
26
|
+
- Release tooling: package renamed to `ldrouter` (CLI `ldrouter`), versioning
|
|
27
|
+
policy documented in CLAUDE.md.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- `/statistics` stuck on "Loading…": the stats queries ordered by a quoted
|
|
31
|
+
select alias (`c`), which SQLite rejects ("no such column: c"). They now
|
|
32
|
+
order by the `COUNT(*)` expression; the page also shows an explicit error
|
|
33
|
+
state instead of failing silently.
|
|
34
|
+
- Provider actions (test/delete) failed with "Gateway error": bodyless
|
|
35
|
+
`POST`/`DELETE` calls sent an empty JSON body that Fastify rejects; the
|
|
36
|
+
client now only sends `content-type: application/json` when a body exists.
|
|
37
|
+
- Provider operations failed with an opaque "Gateway error" when the master
|
|
38
|
+
key was unset: empty-string env vars (e.g. from docker-compose) shadowed
|
|
39
|
+
the `master.key` file; config now ignores empty env values. Errors are
|
|
40
|
+
logged with detail instead of being swallowed.
|
|
41
|
+
- Setup now requires the master encryption key up front (no silent
|
|
42
|
+
auto-generate) and validates it before creating any state.
|
|
43
|
+
|
|
44
|
+
## [1.4.2] - 2026-08-29
|
|
45
|
+
|
|
46
|
+
Baseline release of the LateDev Router gateway: Fastify server, SQLite WAL
|
|
47
|
+
storage, canonical OpenAI/Anthropic protocol layer, combo routing
|
|
48
|
+
(fallback / weighted round-robin), encrypted credentials, admin UI, backup
|
|
49
|
+
& restore, request logs and statistics.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 LateDev Router
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# LateDev Router
|
|
2
|
+
|
|
3
|
+
Lightweight self-hosted LLM gateway with a polished admin UI. Presents stable OpenAI-compatible and Anthropic-compatible APIs to clients while routing traffic to one or more upstream providers.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- OpenAI-compatible and Anthropic-compatible gateways (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`, `/v1/models`)
|
|
8
|
+
- Selective model discovery (Fetch → select → import) with **Select All**
|
|
9
|
+
- Virtual **combos** (fallback or weighted round-robin) and one-hop **aliases**
|
|
10
|
+
- Provider API keys encrypted at rest with AES-256-GCM
|
|
11
|
+
- Per-key `ld-` bearer tokens (SHA-256 digest storage, displayed once)
|
|
12
|
+
- IP allow/deny (IPv4 + IPv6 CIDR), trusted-proxy configuration
|
|
13
|
+
- Rate limits: RPM, TPM, daily/monthly token quotas, concurrency, max output tokens
|
|
14
|
+
- TTL-based gateway response cache (disabled by default) + provider prompt-cache accounting
|
|
15
|
+
- Streaming end-to-end with strict "no fallback after stream content sent" rule
|
|
16
|
+
- Request + attempt logs, statistics (Today / 7d / 30d), retention cleanup
|
|
17
|
+
- Admin TOTP 2FA, Argon2id passwords, recovery codes
|
|
18
|
+
- Immutable audit logs
|
|
19
|
+
- Consistent backup / restore (SQLite snapshot + checksum + schema validation)
|
|
20
|
+
- Prometheus `/metrics`, structured logs, graceful shutdown
|
|
21
|
+
- One distributable npm package, multi-stage Dockerfile, Docker Compose
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
### Using Docker Compose
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cp .env.example .env
|
|
29
|
+
# Edit LATEDEV_MASTER_KEY (32+ bytes base64). Generate with:
|
|
30
|
+
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
31
|
+
docker compose up -d
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then visit `http://localhost:8787/` and complete the first-run admin setup.
|
|
35
|
+
|
|
36
|
+
### Using npm
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx latedev-router
|
|
40
|
+
latedev-router --host 0.0.0.0 --port 8787
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The data directory defaults to `~/.latedev-router/` and can be overridden via `LATEDEV_DATA_DIR` or `--data-dir`.
|
|
44
|
+
|
|
45
|
+
## Environment variables
|
|
46
|
+
|
|
47
|
+
| Variable | Description | Default |
|
|
48
|
+
|----------|-------------|---------|
|
|
49
|
+
| `LATEDEV_HOST` | Bind host | `0.0.0.0` |
|
|
50
|
+
| `LATEDEV_PORT` | Bind port | `8787` |
|
|
51
|
+
| `LATEDEV_DATA_DIR` | Persistent data directory | `~/.latedev-router/` |
|
|
52
|
+
| `LATEDEV_MASTER_KEY` | 32-byte base64 key for encrypting provider credentials | _required once providers exist_ |
|
|
53
|
+
| `LATEDEV_TRUST_PROXY` | Number of reverse-proxy hops to trust for X-Forwarded-For | `0` |
|
|
54
|
+
| `LATEDEV_LOG_LEVEL` | trace / debug / info / warn / error / fatal | `info` |
|
|
55
|
+
|
|
56
|
+
## Public API examples
|
|
57
|
+
|
|
58
|
+
OpenAI-compatible:
|
|
59
|
+
```bash
|
|
60
|
+
curl http://localhost:8787/v1/chat/completions \
|
|
61
|
+
-H "Authorization: Bearer ld-..." \
|
|
62
|
+
-H "content-type: application/json" \
|
|
63
|
+
-d '{"model":"provider/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Anthropic-compatible:
|
|
67
|
+
```bash
|
|
68
|
+
curl http://localhost:8787/v1/messages \
|
|
69
|
+
-H "x-api-key: ld-..." \
|
|
70
|
+
-H "anthropic-version: 2023-06-01" \
|
|
71
|
+
-H "content-type: application/json" \
|
|
72
|
+
-d '{"model":"provider/claude-3-5-sonnet-latest","max_tokens":256,"messages":[{"role":"user","content":"hi"}]}'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Build & test
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pnpm install --frozen-lockfile
|
|
79
|
+
pnpm lint
|
|
80
|
+
pnpm typecheck
|
|
81
|
+
pnpm test
|
|
82
|
+
pnpm build
|
|
83
|
+
npm pack --dry-run
|
|
84
|
+
docker build -t latedev-router:test .
|
|
85
|
+
docker compose config
|
|
86
|
+
docker compose up -d
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pnpm install
|
|
93
|
+
pnpm dev
|
|
94
|
+
# In another terminal
|
|
95
|
+
pnpm --filter . typecheck
|
|
96
|
+
pnpm test
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Architecture
|
|
100
|
+
|
|
101
|
+
See `AGENTS.md` and the `docs/` directory for the full specification.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
// Ensure dist/server is importable
|
|
6
|
+
process.env.LATEDEV_CLI_ENTRY = '1';
|
|
7
|
+
const { startApp } = await import('./server/app.js');
|
|
8
|
+
await startApp();
|
|
9
|
+
}
|
|
10
|
+
main().catch((err) => {
|
|
11
|
+
console.error('Fatal:', err);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -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
|
+
}
|