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.
Files changed (64) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/dist/cli.js +13 -0
  5. package/dist/server/app.js +138 -0
  6. package/dist/server/auth/api-key.js +75 -0
  7. package/dist/server/auth/crypto.js +94 -0
  8. package/dist/server/auth/ids.js +40 -0
  9. package/dist/server/auth/middleware.js +36 -0
  10. package/dist/server/auth/recovery.js +11 -0
  11. package/dist/server/caching/store.js +119 -0
  12. package/dist/server/config/index.js +96 -0
  13. package/dist/server/db/index.js +64 -0
  14. package/dist/server/db/migrate.js +408 -0
  15. package/dist/server/db/repositories/audit.js +75 -0
  16. package/dist/server/db/repositories/settings.js +63 -0
  17. package/dist/server/db/schema.js +396 -0
  18. package/dist/server/errors.js +65 -0
  19. package/dist/server/gateway/runner.js +745 -0
  20. package/dist/server/logging/logger.js +35 -0
  21. package/dist/server/maintenance/retention.js +48 -0
  22. package/dist/server/metrics/registry.js +169 -0
  23. package/dist/server/protocols/anthropic.js +154 -0
  24. package/dist/server/protocols/canonical.js +201 -0
  25. package/dist/server/providers/index.js +89 -0
  26. package/dist/server/routes/admin/aliases.js +98 -0
  27. package/dist/server/routes/admin/api-keys.js +194 -0
  28. package/dist/server/routes/admin/audit.js +19 -0
  29. package/dist/server/routes/admin/auth.js +124 -0
  30. package/dist/server/routes/admin/backup.js +113 -0
  31. package/dist/server/routes/admin/combos.js +198 -0
  32. package/dist/server/routes/admin/dashboard.js +55 -0
  33. package/dist/server/routes/admin/models.js +178 -0
  34. package/dist/server/routes/admin/providers.js +212 -0
  35. package/dist/server/routes/admin/requests.js +156 -0
  36. package/dist/server/routes/admin/settings.js +197 -0
  37. package/dist/server/routes/admin/setup.js +80 -0
  38. package/dist/server/routes/admin/stats.js +180 -0
  39. package/dist/server/routes/admin.js +39 -0
  40. package/dist/server/routes/gateway/anthropic.js +112 -0
  41. package/dist/server/routes/gateway/openai.js +257 -0
  42. package/dist/server/routes/gateway.js +7 -0
  43. package/dist/server/routes/health.js +27 -0
  44. package/dist/server/routing/capabilities.js +52 -0
  45. package/dist/server/routing/circuit.js +37 -0
  46. package/dist/server/routing/combo.js +100 -0
  47. package/dist/server/routing/quota.js +51 -0
  48. package/dist/server/routing/ratelimit.js +58 -0
  49. package/dist/server/routing/resolver.js +43 -0
  50. package/dist/server/security/redact.js +111 -0
  51. package/dist/server/selfupdate/index.js +154 -0
  52. package/dist/server/upstream/client.js +179 -0
  53. package/dist/server/util/cidr.js +91 -0
  54. package/dist/server/util/client-ip.js +15 -0
  55. package/dist/server/util/stable-json.js +19 -0
  56. package/dist/shared/types.js +2 -0
  57. package/dist/web/assets/index-COSbvF8Z.css +1 -0
  58. package/dist/web/assets/index-DbnEzuxq.js +251 -0
  59. package/dist/web/favicon.png +0 -0
  60. package/dist/web/index.html +15 -0
  61. package/dist/web/logo.png +0 -0
  62. package/migrations/0001_initial_schema.sql +323 -0
  63. package/migrations/0002_source_api_key_secrets.sql +7 -0
  64. 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
+ }