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
@@ -0,0 +1,89 @@
1
+ // Upstream provider adapters: OpenAI-compatible + Anthropic-compatible.
2
+ function buildHeaders(cfg, extra) {
3
+ const h = {
4
+ 'content-type': 'application/json',
5
+ 'user-agent': 'latedev-router/0.1',
6
+ ...cfg.customHeaders,
7
+ ...(extra ?? {}),
8
+ };
9
+ if (cfg.type === 'openai')
10
+ h['authorization'] = `Bearer ${cfg.apiKey}`;
11
+ else
12
+ h['x-api-key'] = cfg.apiKey;
13
+ return h;
14
+ }
15
+ async function fetchWithTimeout(url, init, totalTimeoutMs) {
16
+ const ctl = new AbortController();
17
+ const timer = setTimeout(() => ctl.abort(), totalTimeoutMs);
18
+ try {
19
+ return await fetch(url, { ...init, signal: ctl.signal });
20
+ }
21
+ finally {
22
+ clearTimeout(timer);
23
+ }
24
+ }
25
+ export async function probeProvider(cfg) {
26
+ const start = Date.now();
27
+ try {
28
+ const url = cfg.type === 'openai' ? `${stripSlash(cfg.baseUrl)}/v1/models` : `${stripSlash(cfg.baseUrl)}/v1/models`;
29
+ const res = await fetchWithTimeout(url, { method: 'GET', headers: buildHeaders(cfg) }, cfg.totalTimeoutMs);
30
+ const latency = Date.now() - start;
31
+ if (res.ok) {
32
+ const body = (await res.json());
33
+ const count = Array.isArray(body) ? body.length : Array.isArray(body.data) ? body.data.length : 0;
34
+ return { ok: true, detail: `Connected (${res.status})`, latencyMs: latency, modelCount: count };
35
+ }
36
+ return { ok: false, detail: `HTTP ${res.status}`, latencyMs: latency };
37
+ }
38
+ catch (e) {
39
+ return { ok: false, detail: e.message, latencyMs: Date.now() - start };
40
+ }
41
+ }
42
+ export async function discoverProviderModels(cfg) {
43
+ if (cfg.type === 'openai')
44
+ return discoverOpenAI(cfg);
45
+ return discoverAnthropic(cfg);
46
+ }
47
+ async function discoverOpenAI(cfg) {
48
+ const all = [];
49
+ let url = `${stripSlash(cfg.baseUrl)}/v1/models`;
50
+ while (url) {
51
+ const res = await fetchWithTimeout(url, { method: 'GET', headers: buildHeaders(cfg) }, cfg.totalTimeoutMs);
52
+ if (!res.ok)
53
+ throw new Error(`Provider returned HTTP ${res.status}`);
54
+ const body = (await res.json());
55
+ for (const m of body.data ?? []) {
56
+ all.push({
57
+ upstreamId: m.id,
58
+ displayName: m.id,
59
+ capabilities: inferOpenAICapabilities(m.id),
60
+ });
61
+ }
62
+ break; // OpenAI list models is not paginated by default; if upstream returns next, we could follow.
63
+ }
64
+ return all;
65
+ }
66
+ async function discoverAnthropic(_cfg) {
67
+ // Anthropic has no public model listing API; provide best-effort common defaults.
68
+ // In production deployments, admins add models manually or via custom discovery.
69
+ return [
70
+ { upstreamId: 'claude-3-5-sonnet-latest', displayName: 'Claude 3.5 Sonnet', capabilities: { chat: true, streaming: true, tools: true, image_input: true } },
71
+ { upstreamId: 'claude-3-5-haiku-latest', displayName: 'Claude 3.5 Haiku', capabilities: { chat: true, streaming: true, tools: true } },
72
+ { upstreamId: 'claude-3-opus-latest', displayName: 'Claude 3 Opus', capabilities: { chat: true, streaming: true, tools: true, image_input: true } },
73
+ ];
74
+ }
75
+ function inferOpenAICapabilities(id) {
76
+ const lower = id.toLowerCase();
77
+ return {
78
+ chat: true,
79
+ streaming: true,
80
+ tools: !(lower.includes('embedding') || lower.includes('whisper') || lower.includes('dall-e') || lower.includes('tts')),
81
+ image_input: lower.includes('vision') || lower.includes('gpt-4o') || lower.includes('4-vision') || lower.includes('claude'),
82
+ structured_output: lower.includes('gpt-4') || lower.includes('gpt-3.5') || lower.includes('o1') || lower.includes('claude'),
83
+ reasoning: lower.includes('o1') || lower.includes('o3') || lower.includes('reasoning'),
84
+ };
85
+ }
86
+ function stripSlash(u) {
87
+ return u.endsWith('/') ? u.slice(0, -1) : u;
88
+ }
89
+ export { buildHeaders, fetchWithTimeout, stripSlash };
@@ -0,0 +1,98 @@
1
+ // Admin API: model aliases CRUD.
2
+ import { z } from 'zod';
3
+ import { eq } from 'drizzle-orm';
4
+ import { getDb, schema } from '../../db/index.js';
5
+ import { requireAdminAuth } from '../../auth/middleware.js';
6
+ import { recordAudit } from '../../db/repositories/audit.js';
7
+ import { uuid, slugify } from '../../auth/ids.js';
8
+ import { GatewayError } from '../../errors.js';
9
+ const AliasCreate = z.object({
10
+ alias: z.string().min(1).max(64),
11
+ targetKind: z.enum(['model', 'combo']),
12
+ targetId: z.string(),
13
+ enabled: z.boolean().optional(),
14
+ });
15
+ const AliasUpdate = AliasCreate.partial().extend({ id: z.string() });
16
+ export async function registerAliasRoutes(app) {
17
+ app.addHook('preHandler', requireAdminAuth);
18
+ app.get('/api/admin/aliases', async () => {
19
+ const db = getDb();
20
+ const rows = db.select().from(schema.modelAliases).all();
21
+ const models = db.select().from(schema.models).all();
22
+ const combos = db.select().from(schema.combos).all();
23
+ const modelMap = new Map(models.map((m) => [m.id, m]));
24
+ const comboMap = new Map(combos.map((c) => [c.id, c]));
25
+ return {
26
+ aliases: rows.map((a) => ({
27
+ id: a.id,
28
+ alias: a.alias,
29
+ targetKind: a.targetKind,
30
+ targetId: a.targetId,
31
+ targetName: a.targetKind === 'model' ? modelMap.get(a.targetId)?.publicModelId : comboMap.get(a.targetId)?.publicModelId,
32
+ enabled: a.enabled,
33
+ })),
34
+ };
35
+ });
36
+ app.post('/api/admin/aliases', async (req) => {
37
+ const body = AliasCreate.parse(req.body);
38
+ const db = getDb();
39
+ const alias = slugify(body.alias);
40
+ // Verify target exists and is enabled
41
+ if (body.targetKind === 'model') {
42
+ const m = db.select().from(schema.models).where(eq(schema.models.id, body.targetId)).get();
43
+ if (!m)
44
+ throw new GatewayError('invalid_request_error', 'Target model not found', { status: 400 });
45
+ if (db.select().from(schema.modelAliases).where(eq(schema.modelAliases.alias, alias)).get()) {
46
+ throw new GatewayError('invalid_request_error', 'Alias already in use', { status: 400 });
47
+ }
48
+ if (db.select().from(schema.models).where(eq(schema.models.publicModelId, alias)).get()) {
49
+ throw new GatewayError('invalid_request_error', 'Alias shadows a physical model ID', { status: 400 });
50
+ }
51
+ }
52
+ else {
53
+ const c = db.select().from(schema.combos).where(eq(schema.combos.id, body.targetId)).get();
54
+ if (!c)
55
+ throw new GatewayError('invalid_request_error', 'Target combo not found', { status: 400 });
56
+ if (db.select().from(schema.modelAliases).where(eq(schema.modelAliases.alias, alias)).get()) {
57
+ throw new GatewayError('invalid_request_error', 'Alias already in use', { status: 400 });
58
+ }
59
+ }
60
+ const id = uuid();
61
+ db.insert(schema.modelAliases).values({ id, alias, targetKind: body.targetKind, targetId: body.targetId, enabled: body.enabled ?? true }).run();
62
+ recordAudit({ action: 'alias.create', success: true, targetType: 'alias', targetId: id, targetName: alias, ip: req.ip });
63
+ return { id, alias };
64
+ });
65
+ app.patch('/api/admin/aliases', async (req) => {
66
+ const body = AliasUpdate.parse(req.body);
67
+ const db = getDb();
68
+ const a = db.select().from(schema.modelAliases).where(eq(schema.modelAliases.id, body.id)).get();
69
+ if (!a)
70
+ throw new GatewayError('invalid_request_error', 'Alias not found', { status: 404 });
71
+ const update = { updatedAt: new Date().toISOString(), configVersion: a.configVersion + 1 };
72
+ if (body.alias)
73
+ update.alias = slugify(body.alias);
74
+ if (body.targetKind)
75
+ update.targetKind = body.targetKind;
76
+ if (body.targetId)
77
+ update.targetId = body.targetId;
78
+ if (body.enabled !== undefined)
79
+ update.enabled = body.enabled;
80
+ db.update(schema.modelAliases).set(update).where(eq(schema.modelAliases.id, body.id)).run();
81
+ const { invalidateCacheFor } = await import('../../caching/store.js');
82
+ invalidateCacheFor('alias', body.id);
83
+ recordAudit({ action: 'alias.update', success: true, targetType: 'alias', targetId: body.id, targetName: a.alias, ip: req.ip });
84
+ return { ok: true };
85
+ });
86
+ app.delete('/api/admin/aliases/:id', async (req) => {
87
+ const { id } = req.params;
88
+ const db = getDb();
89
+ const a = db.select().from(schema.modelAliases).where(eq(schema.modelAliases.id, id)).get();
90
+ if (!a)
91
+ throw new GatewayError('invalid_request_error', 'Alias not found', { status: 404 });
92
+ db.delete(schema.modelAliases).where(eq(schema.modelAliases.id, id)).run();
93
+ const { invalidateCacheFor } = await import('../../caching/store.js');
94
+ invalidateCacheFor('alias', id);
95
+ recordAudit({ action: 'alias.delete', success: true, targetType: 'alias', targetId: id, targetName: a.alias, ip: req.ip });
96
+ return { ok: true };
97
+ });
98
+ }
@@ -0,0 +1,194 @@
1
+ // Admin API: API key management (ld-.. keys).
2
+ import { z } from 'zod';
3
+ import { eq } from 'drizzle-orm';
4
+ import { getDb, schema } from '../../db/index.js';
5
+ import { requireAdminAuth } from '../../auth/middleware.js';
6
+ import { recordAudit } from '../../db/repositories/audit.js';
7
+ import { generateApiKeySecret, sha256Hex, uuid } from '../../auth/ids.js';
8
+ import { encryptSecret, decryptSecret } from '../../auth/crypto.js';
9
+ import { GatewayError } from '../../errors.js';
10
+ const PermEntry = z.object({ targetKind: z.enum(['model', 'combo', 'alias']), targetId: z.string() });
11
+ const IPRule = z.object({ mode: z.enum(['allow', 'deny']), cidr: z.string().min(1).max(64) });
12
+ const ApiKeyCreate = z.object({
13
+ name: z.string().min(1).max(128),
14
+ enabled: z.boolean().optional(),
15
+ secret: z.string().min(1).max(256).optional(),
16
+ expiresAt: z.string().nullable().optional(),
17
+ allowAllModels: z.boolean().optional(),
18
+ permissions: z.array(PermEntry).optional(),
19
+ ipRules: z.array(IPRule).optional(),
20
+ rpmLimit: z.number().int().min(1).nullable().optional(),
21
+ tpmLimit: z.number().int().min(1).nullable().optional(),
22
+ dailyTokenLimit: z.number().int().min(1).nullable().optional(),
23
+ monthlyTokenLimit: z.number().int().min(1).nullable().optional(),
24
+ maxConcurrent: z.number().int().min(1).nullable().optional(),
25
+ maxOutputTokensPerRequest: z.number().int().min(1).nullable().optional(),
26
+ cacheOverrideEnabled: z.boolean().nullable().optional(),
27
+ });
28
+ const ApiKeyUpdate = ApiKeyCreate.partial().extend({ id: z.string() });
29
+ export async function registerApiKeyRoutes(app) {
30
+ app.addHook('preHandler', requireAdminAuth);
31
+ app.get('/api/admin/api-keys', async () => {
32
+ const db = getDb();
33
+ const rows = db.select().from(schema.apiKeys).all();
34
+ const perms = db.select().from(schema.apiKeyModelPermissions).all();
35
+ return {
36
+ apiKeys: rows.map((k) => ({
37
+ id: k.id,
38
+ name: k.name,
39
+ keyPrefix: k.keyPrefix,
40
+ enabled: k.enabled,
41
+ expiresAt: k.expiresAt,
42
+ lastUsedAt: k.lastUsedAt,
43
+ allowAllModels: k.allowAllModels,
44
+ modelScopeCount: perms.filter((p) => p.apiKeyId === k.id).length,
45
+ rpmLimit: k.rpmLimit,
46
+ tpmLimit: k.tpmLimit,
47
+ concurrencyLimit: k.maxConcurrent,
48
+ secret: k.keySecretEncrypted && k.keySecretNonce
49
+ ? decryptSecret({ ciphertext: k.keySecretEncrypted, nonce: k.keySecretNonce, version: k.keySecretVersion ?? 1 })
50
+ : null,
51
+ })),
52
+ };
53
+ });
54
+ app.get('/api/admin/api-keys/:id', async (req) => {
55
+ const { id } = req.params;
56
+ const db = getDb();
57
+ const k = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).get();
58
+ if (!k)
59
+ throw new GatewayError('invalid_request_error', 'API key not found', { status: 404 });
60
+ const perms = db.select().from(schema.apiKeyModelPermissions).where(eq(schema.apiKeyModelPermissions.apiKeyId, id)).all();
61
+ const ipRules = db.select().from(schema.apiKeyIpRules).where(eq(schema.apiKeyIpRules.apiKeyId, id)).all();
62
+ return {
63
+ apiKey: {
64
+ id: k.id,
65
+ name: k.name,
66
+ keyPrefix: k.keyPrefix,
67
+ enabled: k.enabled,
68
+ expiresAt: k.expiresAt,
69
+ allowAllModels: k.allowAllModels,
70
+ permissions: perms,
71
+ ipRules,
72
+ rpmLimit: k.rpmLimit,
73
+ tpmLimit: k.tpmLimit,
74
+ dailyTokenLimit: k.dailyTokenLimit,
75
+ monthlyTokenLimit: k.monthlyTokenLimit,
76
+ maxConcurrent: k.maxConcurrent,
77
+ maxOutputTokensPerRequest: k.maxOutputTokensPerRequest,
78
+ cacheOverrideEnabled: k.cacheOverrideEnabled,
79
+ secret: k.keySecretEncrypted && k.keySecretNonce
80
+ ? decryptSecret({ ciphertext: k.keySecretEncrypted, nonce: k.keySecretNonce, version: k.keySecretVersion ?? 1 })
81
+ : null,
82
+ },
83
+ };
84
+ });
85
+ app.post('/api/admin/api-keys', async (req) => {
86
+ const body = ApiKeyCreate.parse(req.body);
87
+ const db = getDb();
88
+ // Custom secret if provided, else auto-generate
89
+ const secret = body.secret && body.secret.trim().length > 0
90
+ ? body.secret.trim()
91
+ : generateApiKeySecret();
92
+ const id = uuid();
93
+ const keyPrefix = secret.slice(0, 11);
94
+ const keyDigest = sha256Hex(secret);
95
+ const enc = encryptSecret(secret);
96
+ db.insert(schema.apiKeys).values({
97
+ id,
98
+ name: body.name,
99
+ keyPrefix,
100
+ keyDigest,
101
+ keySecretEncrypted: enc.ciphertext,
102
+ keySecretNonce: enc.nonce,
103
+ keySecretVersion: enc.version,
104
+ enabled: body.enabled ?? true,
105
+ expiresAt: body.expiresAt ?? null,
106
+ allowAllModels: body.allowAllModels ?? false,
107
+ rpmLimit: body.rpmLimit ?? null,
108
+ tpmLimit: body.tpmLimit ?? null,
109
+ dailyTokenLimit: body.dailyTokenLimit ?? null,
110
+ monthlyTokenLimit: body.monthlyTokenLimit ?? null,
111
+ maxConcurrent: body.maxConcurrent ?? null,
112
+ maxOutputTokensPerRequest: body.maxOutputTokensPerRequest ?? null,
113
+ cacheOverrideEnabled: body.cacheOverrideEnabled ?? null,
114
+ }).run();
115
+ if (body.permissions) {
116
+ for (const p of body.permissions) {
117
+ db.insert(schema.apiKeyModelPermissions).values({ id: uuid(), apiKeyId: id, targetKind: p.targetKind, targetId: p.targetId }).run();
118
+ }
119
+ }
120
+ if (body.ipRules) {
121
+ for (const r of body.ipRules) {
122
+ db.insert(schema.apiKeyIpRules).values({ id: uuid(), apiKeyId: id, mode: r.mode, cidr: r.cidr }).run();
123
+ }
124
+ }
125
+ recordAudit({ action: 'api_key.create', success: true, targetType: 'api_key', targetId: id, targetName: body.name, ip: req.ip, metadata: { permissions: body.permissions?.length ?? 0, custom: body.secret ? true : false } });
126
+ return { id, name: body.name, secret, keyPrefix };
127
+ });
128
+ app.patch('/api/admin/api-keys', async (req) => {
129
+ const body = ApiKeyUpdate.parse(req.body);
130
+ const db = getDb();
131
+ const k = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, body.id)).get();
132
+ if (!k)
133
+ throw new GatewayError('invalid_request_error', 'API key not found', { status: 404 });
134
+ const update = { updatedAt: new Date().toISOString() };
135
+ if (body.name)
136
+ update.name = body.name;
137
+ if (body.enabled !== undefined)
138
+ update.enabled = body.enabled;
139
+ if (body.expiresAt !== undefined)
140
+ update.expiresAt = body.expiresAt;
141
+ if (body.allowAllModels !== undefined)
142
+ update.allowAllModels = body.allowAllModels;
143
+ if (body.rpmLimit !== undefined)
144
+ update.rpmLimit = body.rpmLimit;
145
+ if (body.tpmLimit !== undefined)
146
+ update.tpmLimit = body.tpmLimit;
147
+ if (body.dailyTokenLimit !== undefined)
148
+ update.dailyTokenLimit = body.dailyTokenLimit;
149
+ if (body.monthlyTokenLimit !== undefined)
150
+ update.monthlyTokenLimit = body.monthlyTokenLimit;
151
+ if (body.maxConcurrent !== undefined)
152
+ update.maxConcurrent = body.maxConcurrent;
153
+ if (body.maxOutputTokensPerRequest !== undefined)
154
+ update.maxOutputTokensPerRequest = body.maxOutputTokensPerRequest;
155
+ if (body.cacheOverrideEnabled !== undefined)
156
+ update.cacheOverrideEnabled = body.cacheOverrideEnabled;
157
+ db.update(schema.apiKeys).set(update).where(eq(schema.apiKeys.id, body.id)).run();
158
+ if (body.permissions) {
159
+ db.delete(schema.apiKeyModelPermissions).where(eq(schema.apiKeyModelPermissions.apiKeyId, body.id)).run();
160
+ for (const p of body.permissions) {
161
+ db.insert(schema.apiKeyModelPermissions).values({ id: uuid(), apiKeyId: body.id, targetKind: p.targetKind, targetId: p.targetId }).run();
162
+ }
163
+ }
164
+ if (body.ipRules) {
165
+ db.delete(schema.apiKeyIpRules).where(eq(schema.apiKeyIpRules.apiKeyId, body.id)).run();
166
+ for (const r of body.ipRules) {
167
+ db.insert(schema.apiKeyIpRules).values({ id: uuid(), apiKeyId: body.id, mode: r.mode, cidr: r.cidr }).run();
168
+ }
169
+ }
170
+ recordAudit({ action: 'api_key.update', success: true, targetType: 'api_key', targetId: body.id, targetName: k.name, ip: req.ip });
171
+ return { ok: true };
172
+ });
173
+ app.post('/api/admin/api-keys/:id/revoke', async (req) => {
174
+ const { id } = req.params;
175
+ const db = getDb();
176
+ const k = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).get();
177
+ if (!k)
178
+ throw new GatewayError('invalid_request_error', 'API key not found', { status: 404 });
179
+ db.update(schema.apiKeys).set({ enabled: false, updatedAt: new Date().toISOString() }).where(eq(schema.apiKeys.id, id)).run();
180
+ recordAudit({ action: 'api_key.revoke', success: true, targetType: 'api_key', targetId: id, targetName: k.name, ip: req.ip });
181
+ return { ok: true };
182
+ });
183
+ app.delete('/api/admin/api-keys/:id', async (req) => {
184
+ const { id } = req.params;
185
+ const db = getDb();
186
+ const k = db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).get();
187
+ if (!k)
188
+ throw new GatewayError('invalid_request_error', 'API key not found', { status: 404 });
189
+ // Soft-delete: keep name/prefix snapshot for history; mark disabled
190
+ db.update(schema.apiKeys).set({ enabled: false, updatedAt: new Date().toISOString(), name: `${k.name} (deleted ${new Date().toISOString().slice(0, 10)})` }).where(eq(schema.apiKeys.id, id)).run();
191
+ recordAudit({ action: 'api_key.delete', success: true, targetType: 'api_key', targetId: id, targetName: k.name, ip: req.ip });
192
+ return { ok: true };
193
+ });
194
+ }
@@ -0,0 +1,19 @@
1
+ // Admin API: audit logs.
2
+ import { requireAdminAuth } from '../../auth/middleware.js';
3
+ import { queryAudit } from '../../db/repositories/audit.js';
4
+ export async function registerAuditRoutes(app) {
5
+ app.addHook('preHandler', requireAdminAuth);
6
+ app.get('/api/admin/audit', async (req) => {
7
+ const q = req.query;
8
+ const { rows, total } = queryAudit({
9
+ from: q.from,
10
+ to: q.to,
11
+ action: q.action,
12
+ success: q.success === undefined ? undefined : q.success === 'true',
13
+ search: q.search,
14
+ limit: q.limit ? Number(q.limit) : undefined,
15
+ offset: q.offset ? Number(q.offset) : undefined,
16
+ });
17
+ return { total, rows };
18
+ });
19
+ }
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import argon2 from 'argon2';
3
+ import { getDb, schema } from '../../db/index.js';
4
+ import { recordAudit } from '../../db/repositories/audit.js';
5
+ import { uuid, generateSessionToken, sha256Hex } from '../../auth/ids.js';
6
+ import { requireAdminAuth } from '../../auth/middleware.js';
7
+ import { GatewayError } from '../../errors.js';
8
+ const LoginBody = z.object({
9
+ username: z.string().min(1).max(64),
10
+ password: z.string().min(1).max(256),
11
+ totp: z.string().regex(/^\d{6}$/).optional(),
12
+ recoveryCode: z.string().optional(),
13
+ });
14
+ const SessionCookie = 'ld_session';
15
+ const COOKIE_MAX_AGE = 60 * 60 * 12; // 12h
16
+ function sessionExpiry() {
17
+ return new Date(Date.now() + COOKIE_MAX_AGE * 1000).toISOString();
18
+ }
19
+ export async function registerAuthRoutes(app) {
20
+ app.post('/api/admin/login', {
21
+ config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
22
+ }, async (req, reply) => {
23
+ const body = LoginBody.parse(req.body);
24
+ const db = getDb();
25
+ const account = db.select().from(schema.adminAccount).get();
26
+ if (!account)
27
+ throw new GatewayError('authentication_error', 'Invalid credentials', { status: 401 });
28
+ const ok = await argon2.verify(account.passwordHash, body.password);
29
+ db.insert(schema.loginAttempts).values({
30
+ id: uuid(),
31
+ username: body.username,
32
+ ip: req.ip,
33
+ success: ok,
34
+ }).run();
35
+ if (!ok) {
36
+ recordAudit({ action: 'admin.login', success: false, ip: req.ip, targetName: body.username });
37
+ throw new GatewayError('authentication_error', 'Invalid credentials', { status: 401 });
38
+ }
39
+ if (account.totpEnabled) {
40
+ if (body.recoveryCode) {
41
+ // Verify against any unused recovery code
42
+ const codes = db.select().from(schema.adminRecoveryCodes).where(sql `admin_id = ${account.id} AND used_at IS NULL`).all();
43
+ let matched = false;
44
+ for (const c of codes) {
45
+ if (await argon2.verify(c.codeHash, body.recoveryCode)) {
46
+ db.update(schema.adminRecoveryCodes).set({ usedAt: new Date().toISOString() }).where(sql `id = ${c.id}`).run();
47
+ matched = true;
48
+ break;
49
+ }
50
+ }
51
+ if (!matched) {
52
+ recordAudit({ action: 'admin.login', success: false, ip: req.ip, targetName: body.username, metadata: { reason: 'recovery_code' } });
53
+ throw new GatewayError('authentication_error', 'Invalid recovery code', { status: 401 });
54
+ }
55
+ }
56
+ else if (body.totp) {
57
+ const ok2 = await verifyTotp(account, body.totp);
58
+ if (!ok2) {
59
+ recordAudit({ action: 'admin.login', success: false, ip: req.ip, targetName: body.username, metadata: { reason: 'totp' } });
60
+ throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
61
+ }
62
+ }
63
+ else {
64
+ // TOTP required, not provided
65
+ reply.code(401).send({ error: { type: 'totp_required', message: 'TOTP code required' } });
66
+ return;
67
+ }
68
+ }
69
+ // Issue session
70
+ const token = generateSessionToken();
71
+ const id = uuid();
72
+ db.insert(schema.adminSessions).values({
73
+ id,
74
+ tokenDigest: sha256Hex(token),
75
+ expiresAt: sessionExpiry(),
76
+ ip: req.ip,
77
+ userAgent: (req.headers['user-agent'] ?? '').toString().slice(0, 256),
78
+ }).run();
79
+ db.update(schema.adminAccount).set({ lastLoginAt: new Date().toISOString() }).where(sql `id = ${account.id}`).run();
80
+ recordAudit({ action: 'admin.login', success: true, ip: req.ip, targetType: 'admin', targetId: account.id, targetName: account.username });
81
+ reply.setCookie(SessionCookie, token, {
82
+ path: '/',
83
+ httpOnly: true,
84
+ sameSite: 'lax',
85
+ secure: req.protocol === 'https',
86
+ maxAge: COOKIE_MAX_AGE,
87
+ });
88
+ return { ok: true, username: account.username, totpEnabled: account.totpEnabled };
89
+ });
90
+ app.post('/api/admin/logout', async (req, reply) => {
91
+ const token = req.cookies[SessionCookie];
92
+ if (token) {
93
+ const digest = sha256Hex(token);
94
+ const db = getDb();
95
+ db.delete(schema.adminSessions).where(sql `token_digest = ${digest}`).run();
96
+ }
97
+ reply.clearCookie(SessionCookie, { path: '/' });
98
+ return { ok: true };
99
+ });
100
+ app.get('/api/admin/me', { preHandler: requireAdminAuth }, async (req) => {
101
+ const account = req.adminAccount;
102
+ return {
103
+ id: account.id,
104
+ username: account.username,
105
+ totpEnabled: account.totpEnabled,
106
+ };
107
+ });
108
+ }
109
+ async function verifyTotp(account, code) {
110
+ if (!account.totpSecretEncrypted || !account.totpSecretNonce)
111
+ return false;
112
+ // Lazy-load to avoid breaking things if crypto module not yet ready
113
+ const { decryptSecret } = await import('../../auth/crypto.js');
114
+ const speakeasy = await import('speakeasy');
115
+ const payload = { ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 };
116
+ try {
117
+ const secret = decryptSecret({ ciphertext: payload.ciphertext, nonce: payload.nonce, version: 1 });
118
+ return speakeasy.authenticator.verify({ token: code, secret, window: 1 });
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ import { sql } from 'drizzle-orm';
@@ -0,0 +1,113 @@
1
+ // Admin API: database backup / restore.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import zlib from 'node:zlib';
5
+ import crypto from 'node:crypto';
6
+ import { getDb } from '../../db/index.js';
7
+ import { requireAdminAuth } from '../../auth/middleware.js';
8
+ import { recordAudit } from '../../db/repositories/audit.js';
9
+ import { loadConfig } from '../../config/index.js';
10
+ import { getSettings } from '../../db/repositories/settings.js';
11
+ import { GatewayError } from '../../errors.js';
12
+ const BACKUP_VERSION = 1;
13
+ const APP_VERSION = '0.1.0';
14
+ export async function registerBackupRoutes(app) {
15
+ app.addHook('preHandler', requireAdminAuth);
16
+ app.post('/api/admin/backup/create', async (req, reply) => {
17
+ const cfg = loadConfig();
18
+ const temp = path.join(cfg.dataDir, `.backup-${Date.now()}.sqlite`);
19
+ const Database = (await import('better-sqlite3')).default;
20
+ const source = new Database(cfg.dbFile, { readonly: true });
21
+ try {
22
+ await source.backup(temp);
23
+ }
24
+ finally {
25
+ source.close();
26
+ }
27
+ const buf = fs.readFileSync(temp);
28
+ fs.unlinkSync(temp);
29
+ const compressed = zlib.gzipSync(buf, { level: 6 });
30
+ const checksum = crypto.createHash('sha256').update(compressed).digest('hex');
31
+ const settings = getSettings();
32
+ const envelope = {
33
+ format: 'latedev-backup',
34
+ version: BACKUP_VERSION,
35
+ appVersion: APP_VERSION,
36
+ schemaVersion: settings.schemaVersion,
37
+ masterKeyConfigured: settings.masterKeyConfigured,
38
+ createdAt: new Date().toISOString(),
39
+ payload: compressed.toString('base64'),
40
+ checksum,
41
+ };
42
+ const envBuf = Buffer.from(JSON.stringify(envelope), 'utf8');
43
+ const fileName = `latedev-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.ldb.json`;
44
+ reply.header('content-type', 'application/json');
45
+ reply.header('content-disposition', `attachment; filename="${fileName}"`);
46
+ recordAudit({ action: 'db.backup_download', success: true, ip: req.ip, metadata: { sizeBytes: envBuf.length, checksum } });
47
+ return reply.send(envBuf);
48
+ });
49
+ app.post('/api/admin/backup/restore', async (req, reply) => {
50
+ const cfg = loadConfig();
51
+ // Expect raw JSON envelope in body
52
+ let envelope;
53
+ try {
54
+ envelope = req.body;
55
+ if (!envelope || envelope.format !== 'latedev-backup')
56
+ throw new Error('not a backup envelope');
57
+ }
58
+ catch {
59
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'invalid_envelope' } });
60
+ throw new GatewayError('invalid_request_error', 'Invalid backup file format', { status: 400 });
61
+ }
62
+ if (envelope.version > BACKUP_VERSION) {
63
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'future_version', version: envelope.version } });
64
+ throw new GatewayError('invalid_request_error', `Backup version ${envelope.version} is not supported (max ${BACKUP_VERSION})`, { status: 400 });
65
+ }
66
+ const compressed = Buffer.from(envelope.payload, 'base64');
67
+ const checksum = crypto.createHash('sha256').update(compressed).digest('hex');
68
+ if (checksum !== envelope.checksum) {
69
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'checksum_mismatch' } });
70
+ throw new GatewayError('invalid_request_error', 'Backup checksum mismatch', { status: 400 });
71
+ }
72
+ let buf;
73
+ try {
74
+ buf = zlib.gunzipSync(compressed);
75
+ }
76
+ catch {
77
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'decompress_failed' } });
78
+ throw new GatewayError('invalid_request_error', 'Backup decompression failed', { status: 400 });
79
+ }
80
+ // Validate SQLite header
81
+ if (!(buf[0] === 0x53 && buf[1] === 0x51 && buf[2] === 0x4c && buf[3] === 0x69 && buf[4] === 0x74 && buf[5] === 0x65)) {
82
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'not_sqlite' } });
83
+ throw new GatewayError('invalid_request_error', 'Backup does not contain a valid SQLite database', { status: 400 });
84
+ }
85
+ // Snapshot current DB before restore
86
+ const db = getDb();
87
+ void db;
88
+ const snapshot = path.join(cfg.dataDir, `pre-restore-${Date.now()}.sqlite`);
89
+ const Database = (await import('better-sqlite3')).default;
90
+ const live = new Database(cfg.dbFile);
91
+ try {
92
+ await live.backup(snapshot);
93
+ }
94
+ catch (e) {
95
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'snapshot_failed', err: String(e) } });
96
+ throw new GatewayError('gateway_error', 'Could not snapshot current database', { status: 500 });
97
+ }
98
+ // Atomic replace
99
+ const liveDb = cfg.dbFile;
100
+ const tempDb = `${liveDb}.restore-${Date.now()}`;
101
+ fs.writeFileSync(tempDb, buf);
102
+ try {
103
+ fs.renameSync(tempDb, liveDb);
104
+ }
105
+ catch (e) {
106
+ fs.unlinkSync(tempDb);
107
+ recordAudit({ action: 'db.restore', success: false, ip: req.ip, metadata: { reason: 'rename_failed', err: String(e) } });
108
+ throw new GatewayError('gateway_error', 'Restore atomic replace failed', { status: 500 });
109
+ }
110
+ recordAudit({ action: 'db.restore', success: true, ip: req.ip, metadata: { schemaVersion: envelope.schemaVersion } });
111
+ return reply.code(200).send({ ok: true, message: 'Restore completed. Please restart the gateway for changes to take effect.' });
112
+ });
113
+ }