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
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Admin API: request logs + attempts (server-side paginated).
|
|
2
|
+
import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../../db/index.js';
|
|
4
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
import { redactJsonString } from '../../security/redact.js';
|
|
6
|
+
export async function registerRequestRoutes(app) {
|
|
7
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
8
|
+
app.get('/api/admin/requests', async (req) => {
|
|
9
|
+
const q = req.query;
|
|
10
|
+
const db = getDb();
|
|
11
|
+
const limit = Math.min(Math.max(Number(q.limit ?? 50), 1), 200);
|
|
12
|
+
const offset = Math.max(Number(q.offset ?? 0), 0);
|
|
13
|
+
const conds = [];
|
|
14
|
+
if (q.from)
|
|
15
|
+
conds.push(gte(schema.requests.createdAt, q.from));
|
|
16
|
+
if (q.to)
|
|
17
|
+
conds.push(lte(schema.requests.createdAt, q.to));
|
|
18
|
+
if (q.success !== undefined)
|
|
19
|
+
conds.push(eq(schema.requests.success, q.success === 'true'));
|
|
20
|
+
if (q.protocol === 'openai' || q.protocol === 'anthropic')
|
|
21
|
+
conds.push(eq(schema.requests.protocol, q.protocol));
|
|
22
|
+
if (q.providerId)
|
|
23
|
+
conds.push(eq(schema.requests.finalModelId, q.providerId)); // best-effort filter via final model
|
|
24
|
+
if (q.requestedModel)
|
|
25
|
+
conds.push(like(schema.requests.requestedModel, `%${q.requestedModel}%`));
|
|
26
|
+
if (q.apiKeyId)
|
|
27
|
+
conds.push(eq(schema.requests.apiKeyId, q.apiKeyId));
|
|
28
|
+
if (q.ip)
|
|
29
|
+
conds.push(eq(schema.requests.clientIp, q.ip));
|
|
30
|
+
if (q.id)
|
|
31
|
+
conds.push(eq(schema.requests.id, q.id));
|
|
32
|
+
if (q.streaming !== undefined)
|
|
33
|
+
conds.push(eq(schema.requests.streaming, q.streaming === 'true'));
|
|
34
|
+
if (q.minStatus)
|
|
35
|
+
conds.push(gte(schema.requests.httpStatus, Number(q.minStatus)));
|
|
36
|
+
if (q.maxStatus)
|
|
37
|
+
conds.push(lte(schema.requests.httpStatus, Number(q.maxStatus)));
|
|
38
|
+
const whereExpr = conds.length ? and(...conds) : undefined;
|
|
39
|
+
const rows = db.select().from(schema.requests).where(whereExpr).orderBy(desc(schema.requests.createdAt)).limit(limit).offset(offset).all();
|
|
40
|
+
const totalRow = db.select({ c: sql `COUNT(*)` }).from(schema.requests).where(whereExpr).get();
|
|
41
|
+
const keys = db.select().from(schema.apiKeys).all();
|
|
42
|
+
const keyMap = new Map(keys.map((k) => [k.id, k]));
|
|
43
|
+
const models = db.select().from(schema.models).all();
|
|
44
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
45
|
+
return {
|
|
46
|
+
total: totalRow?.c ?? 0,
|
|
47
|
+
requests: rows.map((r) => {
|
|
48
|
+
const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
|
|
49
|
+
const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
|
|
50
|
+
return {
|
|
51
|
+
id: r.id,
|
|
52
|
+
createdAt: r.createdAt,
|
|
53
|
+
completedAt: r.completedAt,
|
|
54
|
+
apiKeyName: key?.name ?? null,
|
|
55
|
+
keyPrefix: r.keyPrefixSnapshot,
|
|
56
|
+
clientIp: r.clientIp,
|
|
57
|
+
protocol: r.protocol,
|
|
58
|
+
endpoint: r.endpoint,
|
|
59
|
+
requestedModel: r.requestedModel,
|
|
60
|
+
resolvedTargetKind: r.resolvedTargetKind,
|
|
61
|
+
finalModelPublicId: finalModel?.publicModelId ?? null,
|
|
62
|
+
streaming: Boolean(r.streaming),
|
|
63
|
+
httpStatus: r.httpStatus,
|
|
64
|
+
success: Boolean(r.success),
|
|
65
|
+
totalLatencyMs: r.totalLatencyMs,
|
|
66
|
+
ttftMs: r.ttftMs,
|
|
67
|
+
inputTokens: r.inputTokens,
|
|
68
|
+
outputTokens: r.outputTokens,
|
|
69
|
+
cacheReadTokens: r.cacheReadTokens,
|
|
70
|
+
cacheWriteTokens: r.cacheWriteTokens,
|
|
71
|
+
reasoningTokens: r.reasoningTokens,
|
|
72
|
+
totalTokens: r.totalTokens,
|
|
73
|
+
attemptsCount: r.attemptsCount,
|
|
74
|
+
errorType: r.errorType,
|
|
75
|
+
errorMessage: r.errorMessage ?? null,
|
|
76
|
+
gatewayCacheHit: Boolean(r.gatewayCacheHit),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
app.get('/api/admin/requests/:id', async (req, reply) => {
|
|
82
|
+
const { id } = req.params;
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const r = db.select().from(schema.requests).where(eq(schema.requests.id, id)).get();
|
|
85
|
+
if (!r) {
|
|
86
|
+
reply.code(404).send({ error: { type: 'not_found', message: 'Request not found' } });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const attempts = db.select().from(schema.requestAttempts).where(eq(schema.requestAttempts.requestId, id)).orderBy(schema.requestAttempts.attemptNumber).all();
|
|
90
|
+
const providers = db.select().from(schema.providers).all();
|
|
91
|
+
const providerMap = new Map(providers.map((p) => [p.id, p]));
|
|
92
|
+
const models = db.select().from(schema.models).all();
|
|
93
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
94
|
+
const keys = db.select().from(schema.apiKeys).all();
|
|
95
|
+
const keyMap = new Map(keys.map((k) => [k.id, k]));
|
|
96
|
+
const key = r.apiKeyId ? keyMap.get(r.apiKeyId) : null;
|
|
97
|
+
const finalModel = r.finalModelId ? modelMap.get(r.finalModelId) : null;
|
|
98
|
+
return {
|
|
99
|
+
request: {
|
|
100
|
+
id: r.id,
|
|
101
|
+
createdAt: r.createdAt,
|
|
102
|
+
completedAt: r.completedAt,
|
|
103
|
+
apiKeyName: key?.name ?? null,
|
|
104
|
+
keyPrefix: r.keyPrefixSnapshot,
|
|
105
|
+
clientIp: r.clientIp,
|
|
106
|
+
protocol: r.protocol,
|
|
107
|
+
endpoint: r.endpoint,
|
|
108
|
+
requestedModel: r.requestedModel,
|
|
109
|
+
resolvedTargetKind: r.resolvedTargetKind,
|
|
110
|
+
finalModelPublicId: finalModel?.publicModelId ?? null,
|
|
111
|
+
streaming: Boolean(r.streaming),
|
|
112
|
+
httpStatus: r.httpStatus,
|
|
113
|
+
success: Boolean(r.success),
|
|
114
|
+
totalLatencyMs: r.totalLatencyMs,
|
|
115
|
+
ttftMs: r.ttftMs,
|
|
116
|
+
inputTokens: r.inputTokens,
|
|
117
|
+
outputTokens: r.outputTokens,
|
|
118
|
+
cacheReadTokens: r.cacheReadTokens,
|
|
119
|
+
cacheWriteTokens: r.cacheWriteTokens,
|
|
120
|
+
reasoningTokens: r.reasoningTokens,
|
|
121
|
+
totalTokens: r.totalTokens,
|
|
122
|
+
attemptsCount: r.attemptsCount,
|
|
123
|
+
errorType: r.errorType,
|
|
124
|
+
errorMessage: r.errorMessage,
|
|
125
|
+
requestPayload: redactJsonString(r.requestPayloadJson),
|
|
126
|
+
responsePayload: redactJsonString(r.responsePayloadJson),
|
|
127
|
+
gatewayCacheHit: Boolean(r.gatewayCacheHit),
|
|
128
|
+
},
|
|
129
|
+
attempts: attempts.map((a) => ({
|
|
130
|
+
id: a.id,
|
|
131
|
+
attemptNumber: a.attemptNumber,
|
|
132
|
+
providerId: a.providerId,
|
|
133
|
+
providerName: providerMap.get(a.providerId)?.name ?? '',
|
|
134
|
+
modelId: a.modelId,
|
|
135
|
+
modelPublicId: modelMap.get(a.modelId)?.publicModelId ?? '',
|
|
136
|
+
startedAt: a.startedAt,
|
|
137
|
+
completedAt: a.completedAt,
|
|
138
|
+
statusCode: a.statusCode,
|
|
139
|
+
success: Boolean(a.success),
|
|
140
|
+
latencyMs: a.latencyMs,
|
|
141
|
+
ttftMs: a.ttftMs,
|
|
142
|
+
inputTokens: a.inputTokens,
|
|
143
|
+
outputTokens: a.outputTokens,
|
|
144
|
+
cacheReadTokens: a.cacheReadTokens,
|
|
145
|
+
cacheWriteTokens: a.cacheWriteTokens,
|
|
146
|
+
reasoningTokens: a.reasoningTokens,
|
|
147
|
+
streamStarted: Boolean(a.streamStarted),
|
|
148
|
+
partialResponse: Boolean(a.partialResponse),
|
|
149
|
+
selectionReason: a.selectionReason,
|
|
150
|
+
failureReason: a.failureReason,
|
|
151
|
+
sanitizedError: a.errorMessage,
|
|
152
|
+
upstreamRequestId: a.upstreamRequestId,
|
|
153
|
+
})),
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Admin API: app settings + password + TOTP + maintenance.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import argon2 from 'argon2';
|
|
4
|
+
import { eq, sql } from 'drizzle-orm';
|
|
5
|
+
import { getDb, schema } from '../../db/index.js';
|
|
6
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
7
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
8
|
+
import { getSettings, updateSettings } from '../../db/repositories/settings.js';
|
|
9
|
+
import { uuid } from '../../auth/ids.js';
|
|
10
|
+
import { isMasterKeyConfigured, encryptSecret, decryptSecret } from '../../auth/crypto.js';
|
|
11
|
+
import { GatewayError } from '../../errors.js';
|
|
12
|
+
import { runRetentionCleanup } from '../../maintenance/retention.js';
|
|
13
|
+
const UpdateBody = z.object({
|
|
14
|
+
retentionDays: z.number().int().min(1).max(3650).optional(),
|
|
15
|
+
contentLogMode: z.enum(['off', 'metadata', 'prompt', 'prompt_and_response']).optional(),
|
|
16
|
+
dbSizeLimitMb: z.number().int().min(64).max(1048576).optional(),
|
|
17
|
+
trustProxyHops: z.number().int().min(0).max(8).optional(),
|
|
18
|
+
gatewayCacheEnabled: z.boolean().optional(),
|
|
19
|
+
gatewayCacheDefaultTtlSeconds: z.number().int().min(1).max(86400).optional(),
|
|
20
|
+
gatewayCacheMaxSizeMb: z.number().int().min(1).max(10240).optional(),
|
|
21
|
+
});
|
|
22
|
+
const PasswordChange = z.object({
|
|
23
|
+
currentPassword: z.string().min(1),
|
|
24
|
+
newPassword: z.string().min(12).max(256),
|
|
25
|
+
totp: z.string().optional(),
|
|
26
|
+
recoveryCode: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
const TotpEnableBegin = z.object({});
|
|
29
|
+
void TotpEnableBegin;
|
|
30
|
+
const TotpEnableVerify = z.object({ code: z.string().regex(/^\d{6}$/) });
|
|
31
|
+
export async function registerSettingsRoutes(app) {
|
|
32
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
33
|
+
app.get('/api/admin/settings', async () => {
|
|
34
|
+
const s = getSettings();
|
|
35
|
+
return { settings: { ...s, masterKeyConfigured: s.masterKeyConfigured || isMasterKeyConfigured() } };
|
|
36
|
+
});
|
|
37
|
+
app.patch('/api/admin/settings', async (req) => {
|
|
38
|
+
const body = UpdateBody.parse(req.body);
|
|
39
|
+
updateSettings(body);
|
|
40
|
+
recordAudit({ action: 'settings.update', success: true, ip: req.ip, metadata: body });
|
|
41
|
+
return { ok: true };
|
|
42
|
+
});
|
|
43
|
+
app.post('/api/admin/settings/cleanup', async (req) => {
|
|
44
|
+
const result = runRetentionCleanup();
|
|
45
|
+
recordAudit({ action: 'retention.run', success: true, ip: req.ip, metadata: { result } });
|
|
46
|
+
return { ok: true, result };
|
|
47
|
+
});
|
|
48
|
+
app.post('/api/admin/settings/cache/clear', async (req) => {
|
|
49
|
+
const { clearAllCache } = await import('../../caching/store.js');
|
|
50
|
+
const n = clearAllCache();
|
|
51
|
+
recordAudit({ action: 'cache.clear', success: true, ip: req.ip, metadata: { deleted: n } });
|
|
52
|
+
return { ok: true, deleted: n };
|
|
53
|
+
});
|
|
54
|
+
// Password change
|
|
55
|
+
app.post('/api/admin/account/password', async (req) => {
|
|
56
|
+
const body = PasswordChange.parse(req.body);
|
|
57
|
+
const account = req.adminAccount;
|
|
58
|
+
const db = getDb();
|
|
59
|
+
const ok = await argon2.verify(account.passwordHash, body.currentPassword);
|
|
60
|
+
if (!ok) {
|
|
61
|
+
recordAudit({ action: 'admin.password_change', success: false, ip: req.ip, metadata: { reason: 'wrong_current' } });
|
|
62
|
+
throw new GatewayError('authentication_error', 'Current password incorrect', { status: 401 });
|
|
63
|
+
}
|
|
64
|
+
if (account.totpEnabled && !body.totp && !body.recoveryCode) {
|
|
65
|
+
throw new GatewayError('invalid_request_error', 'TOTP or recovery code required', { status: 400 });
|
|
66
|
+
}
|
|
67
|
+
if (account.totpEnabled && body.totp) {
|
|
68
|
+
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
69
|
+
const speakeasy = await import('speakeasy');
|
|
70
|
+
if (!speakeasy.authenticator.verify({ token: body.totp, secret, window: 1 })) {
|
|
71
|
+
recordAudit({ action: 'admin.password_change', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
|
|
72
|
+
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const newHash = await argon2.hash(body.newPassword, { type: argon2.argon2id, memoryCost: 64 * 1024, timeCost: 3, parallelism: 1 });
|
|
76
|
+
db.update(schema.adminAccount).set({ passwordHash: newHash, updatedAt: new Date().toISOString() }).where(eq(schema.adminAccount.id, account.id)).run();
|
|
77
|
+
// Invalidate all other sessions
|
|
78
|
+
db.delete(schema.adminSessions).where(sql `id != ${req.adminSessionId}`).run();
|
|
79
|
+
recordAudit({ action: 'admin.password_change', success: true, ip: req.ip });
|
|
80
|
+
return { ok: true };
|
|
81
|
+
});
|
|
82
|
+
// TOTP setup
|
|
83
|
+
app.post('/api/admin/account/totp/begin', async (req) => {
|
|
84
|
+
if (!isMasterKeyConfigured())
|
|
85
|
+
throw new GatewayError('gateway_error', 'Master key required to enable TOTP', { status: 503 });
|
|
86
|
+
const speakeasy = await import('speakeasy');
|
|
87
|
+
const qrcode = (await import('qrcode'));
|
|
88
|
+
const auth = speakeasy.authenticator;
|
|
89
|
+
const secret = auth.generateSecret({ name: 'LateDev Router', length: 20 });
|
|
90
|
+
const enc = encryptSecret(secret.base32);
|
|
91
|
+
const db = getDb();
|
|
92
|
+
db.update(schema.adminAccount).set({ totpSecretEncrypted: enc.ciphertext, totpSecretNonce: enc.nonce, updatedAt: new Date().toISOString() }).where(eq(schema.adminAccount.id, req.adminAccount.id)).run();
|
|
93
|
+
const otpauth = auth.keyuri('admin', 'LateDev Router', secret.base32);
|
|
94
|
+
const qr = await qrcode.toDataURL(otpauth);
|
|
95
|
+
recordAudit({ action: 'totp.begin', success: true, ip: req.ip });
|
|
96
|
+
return { secret: secret.base32, otpauth, qr };
|
|
97
|
+
});
|
|
98
|
+
app.post('/api/admin/account/totp/verify', async (req) => {
|
|
99
|
+
const body = TotpEnableVerify.parse(req.body);
|
|
100
|
+
const account = req.adminAccount;
|
|
101
|
+
if (!account.totpSecretEncrypted)
|
|
102
|
+
throw new GatewayError('invalid_request_error', 'Begin TOTP setup first', { status: 400 });
|
|
103
|
+
const speakeasy = await import('speakeasy');
|
|
104
|
+
const auth = speakeasy.authenticator;
|
|
105
|
+
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
106
|
+
if (!auth.verify({ token: body.code, secret, window: 1 })) {
|
|
107
|
+
recordAudit({ action: 'totp.verify', success: false, ip: req.ip });
|
|
108
|
+
throw new GatewayError('invalid_request_error', 'Invalid code', { status: 400 });
|
|
109
|
+
}
|
|
110
|
+
// Generate recovery codes
|
|
111
|
+
const { generateRecoveryCodes } = await import('../../auth/recovery.js');
|
|
112
|
+
const db = getDb();
|
|
113
|
+
db.delete(schema.adminRecoveryCodes).where(eq(schema.adminRecoveryCodes.adminId, account.id)).run();
|
|
114
|
+
const codes = generateRecoveryCodes(8);
|
|
115
|
+
for (const c of codes) {
|
|
116
|
+
const codeHash = await argon2.hash(c, { type: argon2.argon2id, memoryCost: 64 * 1024, timeCost: 3, parallelism: 1 });
|
|
117
|
+
db.insert(schema.adminRecoveryCodes).values({ id: uuid(), adminId: account.id, codeHash }).run();
|
|
118
|
+
}
|
|
119
|
+
db.update(schema.adminAccount).set({ totpEnabled: true, updatedAt: new Date().toISOString() }).where(eq(schema.adminAccount.id, account.id)).run();
|
|
120
|
+
recordAudit({ action: 'totp.enable', success: true, ip: req.ip, metadata: { recoveryCodes: codes.length } });
|
|
121
|
+
return { ok: true, recoveryCodes: codes };
|
|
122
|
+
});
|
|
123
|
+
app.post('/api/admin/account/totp/disable', async (req) => {
|
|
124
|
+
const body = z.object({ password: z.string().min(1), totp: z.string().optional(), recoveryCode: z.string().optional() }).parse(req.body);
|
|
125
|
+
const account = req.adminAccount;
|
|
126
|
+
const ok = await argon2.verify(account.passwordHash, body.password);
|
|
127
|
+
if (!ok) {
|
|
128
|
+
recordAudit({ action: 'totp.disable', success: false, ip: req.ip, metadata: { reason: 'wrong_password' } });
|
|
129
|
+
throw new GatewayError('authentication_error', 'Invalid password', { status: 401 });
|
|
130
|
+
}
|
|
131
|
+
if (account.totpEnabled && !body.totp && !body.recoveryCode) {
|
|
132
|
+
throw new GatewayError('invalid_request_error', 'TOTP or recovery code required', { status: 400 });
|
|
133
|
+
}
|
|
134
|
+
if (account.totpEnabled && body.totp) {
|
|
135
|
+
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
136
|
+
const speakeasy = await import('speakeasy');
|
|
137
|
+
const auth = speakeasy.authenticator;
|
|
138
|
+
if (!auth.verify({ token: body.totp, secret, window: 1 })) {
|
|
139
|
+
recordAudit({ action: 'totp.disable', success: false, ip: req.ip, metadata: { reason: 'bad_totp' } });
|
|
140
|
+
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const db = getDb();
|
|
144
|
+
db.update(schema.adminAccount).set({ totpEnabled: false, totpSecretEncrypted: null, totpSecretNonce: null, updatedAt: new Date().toISOString() }).where(eq(schema.adminAccount.id, account.id)).run();
|
|
145
|
+
db.delete(schema.adminRecoveryCodes).where(eq(schema.adminRecoveryCodes.adminId, account.id)).run();
|
|
146
|
+
recordAudit({ action: 'totp.disable', success: true, ip: req.ip });
|
|
147
|
+
return { ok: true };
|
|
148
|
+
});
|
|
149
|
+
app.post('/api/admin/account/totp/recovery/regenerate', async (req) => {
|
|
150
|
+
const body = z.object({ password: z.string().min(1), totp: z.string().regex(/^\d{6}$/) }).parse(req.body);
|
|
151
|
+
const account = req.adminAccount;
|
|
152
|
+
if (!account.totpEnabled)
|
|
153
|
+
throw new GatewayError('invalid_request_error', 'TOTP not enabled', { status: 400 });
|
|
154
|
+
const ok = await argon2.verify(account.passwordHash, body.password);
|
|
155
|
+
if (!ok)
|
|
156
|
+
throw new GatewayError('authentication_error', 'Invalid password', { status: 401 });
|
|
157
|
+
const speakeasy = await import('speakeasy');
|
|
158
|
+
const auth = speakeasy.authenticator;
|
|
159
|
+
const secret = decryptSecret({ ciphertext: account.totpSecretEncrypted, nonce: account.totpSecretNonce, version: 1 });
|
|
160
|
+
if (!auth.verify({ token: body.totp, secret, window: 1 }))
|
|
161
|
+
throw new GatewayError('authentication_error', 'Invalid TOTP', { status: 401 });
|
|
162
|
+
const { generateRecoveryCodes } = await import('../../auth/recovery.js');
|
|
163
|
+
const db = getDb();
|
|
164
|
+
db.delete(schema.adminRecoveryCodes).where(eq(schema.adminRecoveryCodes.adminId, account.id)).run();
|
|
165
|
+
const codes = generateRecoveryCodes(8);
|
|
166
|
+
for (const c of codes) {
|
|
167
|
+
const codeHash = await argon2.hash(c, { type: argon2.argon2id, memoryCost: 64 * 1024, timeCost: 3, parallelism: 1 });
|
|
168
|
+
db.insert(schema.adminRecoveryCodes).values({ id: uuid(), adminId: account.id, codeHash }).run();
|
|
169
|
+
}
|
|
170
|
+
recordAudit({ action: 'totp.recovery_regenerate', success: true, ip: req.ip });
|
|
171
|
+
return { ok: true, recoveryCodes: codes };
|
|
172
|
+
});
|
|
173
|
+
// Health-test endpoint
|
|
174
|
+
app.get('/api/admin/settings/system', async () => {
|
|
175
|
+
const { loadConfig } = await import('../../config/index.js');
|
|
176
|
+
const cfg = loadConfig();
|
|
177
|
+
return {
|
|
178
|
+
appVersion: cfg.appVersion,
|
|
179
|
+
dataDir: cfg.dataDir,
|
|
180
|
+
masterKeyConfigured: isMasterKeyConfigured(),
|
|
181
|
+
masterKeyVersion: getSettings().masterKeyVersion,
|
|
182
|
+
environment: cfg.env,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
// Self-update: check the npm registry and (optionally) install + restart.
|
|
186
|
+
app.get('/api/admin/update/check', async (req) => {
|
|
187
|
+
const { getSelfUpdater } = await import('../../selfupdate/index.js');
|
|
188
|
+
const u = getSelfUpdater();
|
|
189
|
+
const force = req.query.force === '1';
|
|
190
|
+
const result = await u.check(force);
|
|
191
|
+
return { ...result, status: u.status() };
|
|
192
|
+
});
|
|
193
|
+
app.post('/api/admin/update/run', async () => {
|
|
194
|
+
const { getSelfUpdater } = await import('../../selfupdate/index.js');
|
|
195
|
+
return getSelfUpdater().run();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import argon2 from 'argon2';
|
|
5
|
+
import { getDb, schema } from '../../db/index.js';
|
|
6
|
+
import { getSettings, markSetupComplete, updateSettings } from '../../db/repositories/settings.js';
|
|
7
|
+
import { recordAudit } from '../../db/repositories/audit.js';
|
|
8
|
+
import { loadConfig, setConfigMasterKey } from '../../config/index.js';
|
|
9
|
+
import { GatewayError } from '../../errors.js';
|
|
10
|
+
import { uuid } from '../../auth/ids.js';
|
|
11
|
+
import { isMasterKeyConfigured, parseMasterKey } from '../../auth/crypto.js';
|
|
12
|
+
const SetupBody = z.object({
|
|
13
|
+
username: z.string().min(3).max(64).regex(/^[a-zA-Z0-9_.-]+$/),
|
|
14
|
+
password: z.string().min(12).max(256),
|
|
15
|
+
setupMasterKey: z.string().trim().min(32, 'Master key must be at least 32 characters').max(256).optional(),
|
|
16
|
+
});
|
|
17
|
+
const PasswordPolicy = z.string().min(12, 'Password must be at least 12 characters').max(256);
|
|
18
|
+
export async function registerSetupRoutes(app) {
|
|
19
|
+
app.get('/api/admin/setup/status', async () => {
|
|
20
|
+
const s = getSettings();
|
|
21
|
+
return {
|
|
22
|
+
setupComplete: s.setupComplete,
|
|
23
|
+
masterKeyConfigured: s.masterKeyConfigured || isMasterKeyConfigured(),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
app.post('/api/admin/setup', async (req, _reply) => {
|
|
27
|
+
const s = getSettings();
|
|
28
|
+
if (s.setupComplete) {
|
|
29
|
+
throw new GatewayError('invalid_request_error', 'Setup already complete', { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
const body = SetupBody.parse(req.body);
|
|
32
|
+
PasswordPolicy.parse(body.password); // throws if too short
|
|
33
|
+
// Resolve the master encryption key BEFORE writing any rows: provider
|
|
34
|
+
// credentials are encrypted with it, and a rejected setup must never leave
|
|
35
|
+
// a half-created admin account behind. Priority: LATEDEV_MASTER_KEY env →
|
|
36
|
+
// existing master.key file (re-setup) → key entered during setup. There is
|
|
37
|
+
// NO auto-generation: losing this key makes stored provider API keys
|
|
38
|
+
// unrecoverable, so the admin must hold a copy.
|
|
39
|
+
const cfg = loadConfig();
|
|
40
|
+
const keyPath = path.join(cfg.dataDir, 'master.key');
|
|
41
|
+
let effectiveKey = cfg.masterKey ?? (fs.existsSync(keyPath) ? fs.readFileSync(keyPath, 'utf8').trim() || null : null);
|
|
42
|
+
if (!effectiveKey) {
|
|
43
|
+
if (!body.setupMasterKey) {
|
|
44
|
+
throw new GatewayError('invalid_request_error', 'Master encryption key is required (32+ characters). Set LATEDEV_MASTER_KEY or enter it during setup.', { status: 400 });
|
|
45
|
+
}
|
|
46
|
+
effectiveKey = body.setupMasterKey;
|
|
47
|
+
}
|
|
48
|
+
// Reject malformed keys up front: a key that cannot be parsed into 32 AES
|
|
49
|
+
// bytes would make every later provider encrypt/decrypt fail.
|
|
50
|
+
try {
|
|
51
|
+
parseMasterKey(effectiveKey);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new GatewayError('invalid_request_error', e.message, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
const db = getDb();
|
|
57
|
+
const id = uuid();
|
|
58
|
+
const passwordHash = await argon2.hash(body.password, {
|
|
59
|
+
type: argon2.argon2id,
|
|
60
|
+
memoryCost: 64 * 1024,
|
|
61
|
+
timeCost: 3,
|
|
62
|
+
parallelism: 1,
|
|
63
|
+
});
|
|
64
|
+
const existing = db.select().from(schema.adminAccount).get();
|
|
65
|
+
if (existing) {
|
|
66
|
+
throw new GatewayError('invalid_request_error', 'Admin account already exists', { status: 400 });
|
|
67
|
+
}
|
|
68
|
+
db.insert(schema.adminAccount).values({ id, username: body.username, passwordHash }).run();
|
|
69
|
+
// Persist the resolved key for container restarts and set it into the
|
|
70
|
+
// running config (validated above, before any rows were written).
|
|
71
|
+
fs.mkdirSync(cfg.dataDir, { recursive: true });
|
|
72
|
+
fs.writeFileSync(keyPath, effectiveKey, { mode: 0o600, encoding: 'utf8' });
|
|
73
|
+
setConfigMasterKey(effectiveKey);
|
|
74
|
+
process.env.LATEDEV_MASTER_KEY = effectiveKey;
|
|
75
|
+
updateSettings({ masterKeyConfigured: true });
|
|
76
|
+
markSetupComplete();
|
|
77
|
+
recordAudit({ action: 'admin.setup', success: true, targetType: 'admin', targetId: id, targetName: body.username, ip: req.ip });
|
|
78
|
+
return { ok: true };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Admin API: statistics (Today/7d/30d).
|
|
2
|
+
import { and, eq, gte, lte, sql, desc } from 'drizzle-orm';
|
|
3
|
+
import { getDb, schema } from '../../db/index.js';
|
|
4
|
+
import { requireAdminAuth } from '../../auth/middleware.js';
|
|
5
|
+
const PRESETS = {
|
|
6
|
+
today: () => {
|
|
7
|
+
const now = new Date();
|
|
8
|
+
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
9
|
+
return { from, to: now, bucket: 'hour' };
|
|
10
|
+
},
|
|
11
|
+
'7d': () => {
|
|
12
|
+
const now = new Date();
|
|
13
|
+
return { from: new Date(now.getTime() - 7 * 24 * 3600 * 1000), to: now, bucket: 'day' };
|
|
14
|
+
},
|
|
15
|
+
'30d': () => {
|
|
16
|
+
const now = new Date();
|
|
17
|
+
return { from: new Date(now.getTime() - 30 * 24 * 3600 * 1000), to: now, bucket: 'day' };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
function rangeFromQuery(q) {
|
|
21
|
+
if (q.preset && PRESETS[q.preset])
|
|
22
|
+
return PRESETS[q.preset]();
|
|
23
|
+
const from = q.from ? new Date(q.from) : new Date(Date.now() - 7 * 24 * 3600 * 1000);
|
|
24
|
+
const to = q.to ? new Date(q.to) : new Date();
|
|
25
|
+
return { from, to, bucket: q.bucket === 'hour' ? 'hour' : 'day' };
|
|
26
|
+
}
|
|
27
|
+
export async function registerStatsRoutes(app) {
|
|
28
|
+
app.addHook('preHandler', requireAdminAuth);
|
|
29
|
+
app.get('/api/admin/stats', async (req) => {
|
|
30
|
+
const q = req.query;
|
|
31
|
+
const { from, to, bucket } = rangeFromQuery(q);
|
|
32
|
+
const db = getDb();
|
|
33
|
+
const fromIso = from.toISOString();
|
|
34
|
+
const toIso = to.toISOString();
|
|
35
|
+
const conds = [gte(schema.requests.createdAt, fromIso), lte(schema.requests.createdAt, toIso)];
|
|
36
|
+
const summary = db
|
|
37
|
+
.select({
|
|
38
|
+
total: sql `COUNT(*)`,
|
|
39
|
+
success: sql `SUM(CASE WHEN success=1 THEN 1 ELSE 0 END)`,
|
|
40
|
+
failed: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
|
|
41
|
+
inputTokens: sql `COALESCE(SUM(input_tokens),0)`,
|
|
42
|
+
outputTokens: sql `COALESCE(SUM(output_tokens),0)`,
|
|
43
|
+
cacheRead: sql `COALESCE(SUM(cache_read_tokens),0)`,
|
|
44
|
+
cacheWrite: sql `COALESCE(SUM(cache_write_tokens),0)`,
|
|
45
|
+
reasoning: sql `COALESCE(SUM(reasoning_tokens),0)`,
|
|
46
|
+
gatewayCacheHits: sql `SUM(CASE WHEN gateway_cache_hit=1 THEN 1 ELSE 0 END)`,
|
|
47
|
+
fallbacks: sql `SUM(CASE WHEN attempts_count > 1 THEN 1 ELSE 0 END)`,
|
|
48
|
+
})
|
|
49
|
+
.from(schema.requests)
|
|
50
|
+
.where(and(...conds))
|
|
51
|
+
.get();
|
|
52
|
+
const latRows = db
|
|
53
|
+
.select({ v: schema.requests.totalLatencyMs })
|
|
54
|
+
.from(schema.requests)
|
|
55
|
+
.where(and(...conds, eq(schema.requests.success, true)))
|
|
56
|
+
.all();
|
|
57
|
+
const latencies = latRows.map((r) => r.v).filter((v) => v > 0).sort((a, b) => a - b);
|
|
58
|
+
const avg = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
|
|
59
|
+
const p95 = percentile(latencies, 95);
|
|
60
|
+
const ttftRows = db
|
|
61
|
+
.select({ v: schema.requests.ttftMs })
|
|
62
|
+
.from(schema.requests)
|
|
63
|
+
.where(and(...conds, sql `ttft_ms IS NOT NULL`))
|
|
64
|
+
.all();
|
|
65
|
+
const ttfts = ttftRows.map((r) => r.v).filter((v) => v !== null).sort((a, b) => a - b);
|
|
66
|
+
const avgTtft = ttfts.length ? ttfts.reduce((a, b) => a + b, 0) / ttfts.length : null;
|
|
67
|
+
const p95Ttft = ttfts.length ? percentile(ttfts, 95) : null;
|
|
68
|
+
const total = Number(summary?.total ?? 0);
|
|
69
|
+
const success = Number(summary?.success ?? 0);
|
|
70
|
+
const failed = Number(summary?.failed ?? 0);
|
|
71
|
+
const statsSummary = {
|
|
72
|
+
totalRequests: total,
|
|
73
|
+
successfulRequests: success,
|
|
74
|
+
failedRequests: failed,
|
|
75
|
+
successRate: total ? success / total : 0,
|
|
76
|
+
inputTokens: Number(summary?.inputTokens ?? 0),
|
|
77
|
+
outputTokens: Number(summary?.outputTokens ?? 0),
|
|
78
|
+
totalTokens: Number(summary?.inputTokens ?? 0) + Number(summary?.outputTokens ?? 0),
|
|
79
|
+
cacheReadTokens: Number(summary?.cacheRead ?? 0),
|
|
80
|
+
cacheWriteTokens: Number(summary?.cacheWrite ?? 0),
|
|
81
|
+
reasoningTokens: Number(summary?.reasoning ?? 0),
|
|
82
|
+
averageLatencyMs: avg,
|
|
83
|
+
p95LatencyMs: p95,
|
|
84
|
+
averageTtftMs: avgTtft,
|
|
85
|
+
p95TtftMs: p95Ttft,
|
|
86
|
+
cacheHitRate: success ? Number(summary?.cacheRead ?? 0) > 0 ? Number(summary?.cacheRead ?? 0) / Math.max(1, Number(summary?.inputTokens ?? 0) + Number(summary?.cacheRead ?? 0)) : 0 : 0,
|
|
87
|
+
gatewayCacheHitRate: total ? Number(summary?.gatewayCacheHits ?? 0) / total : 0,
|
|
88
|
+
fallbackRate: total ? Number(summary?.fallbacks ?? 0) / total : 0,
|
|
89
|
+
};
|
|
90
|
+
// Time-bucketed series
|
|
91
|
+
const bucketExpr = bucket === 'hour' ? sql `strftime('%Y-%m-%dT%H:00:00Z', created_at)` : sql `strftime('%Y-%m-%dT00:00:00Z', created_at)`;
|
|
92
|
+
const seriesRows = db
|
|
93
|
+
.select({
|
|
94
|
+
t: bucketExpr,
|
|
95
|
+
requests: sql `COUNT(*)`,
|
|
96
|
+
errors: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
|
|
97
|
+
inputTokens: sql `COALESCE(SUM(input_tokens),0)`,
|
|
98
|
+
outputTokens: sql `COALESCE(SUM(output_tokens),0)`,
|
|
99
|
+
})
|
|
100
|
+
.from(schema.requests)
|
|
101
|
+
.where(and(...conds))
|
|
102
|
+
.groupBy(bucketExpr)
|
|
103
|
+
.orderBy(bucketExpr)
|
|
104
|
+
.all();
|
|
105
|
+
// Top models
|
|
106
|
+
const topModels = db
|
|
107
|
+
.select({
|
|
108
|
+
modelId: schema.requests.finalModelId,
|
|
109
|
+
c: sql `COUNT(*)`,
|
|
110
|
+
err: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
|
|
111
|
+
tokens: sql `COALESCE(SUM(total_tokens),0)`,
|
|
112
|
+
})
|
|
113
|
+
.from(schema.requests)
|
|
114
|
+
.where(and(...conds))
|
|
115
|
+
.groupBy(schema.requests.finalModelId)
|
|
116
|
+
// Order by the aggregate expression itself: SQLite rejects ORDER BY on a
|
|
117
|
+
// quoted select alias ("no such column: c").
|
|
118
|
+
.orderBy(desc(sql `COUNT(*)`))
|
|
119
|
+
.limit(10)
|
|
120
|
+
.all();
|
|
121
|
+
const models = db.select().from(schema.models).all();
|
|
122
|
+
const modelMap = new Map(models.map((m) => [m.id, m]));
|
|
123
|
+
const topKeys = db
|
|
124
|
+
.select({
|
|
125
|
+
apiKeyId: schema.requests.apiKeyId,
|
|
126
|
+
c: sql `COUNT(*)`,
|
|
127
|
+
tokens: sql `COALESCE(SUM(total_tokens),0)`,
|
|
128
|
+
})
|
|
129
|
+
.from(schema.requests)
|
|
130
|
+
.where(and(...conds))
|
|
131
|
+
.groupBy(schema.requests.apiKeyId)
|
|
132
|
+
.orderBy(desc(sql `COUNT(*)`))
|
|
133
|
+
.limit(10)
|
|
134
|
+
.all();
|
|
135
|
+
const keys = db.select().from(schema.apiKeys).all();
|
|
136
|
+
const keyMap = new Map(keys.map((k) => [k.id, k]));
|
|
137
|
+
const topProviders = db
|
|
138
|
+
.select({
|
|
139
|
+
providerId: schema.requestAttempts.providerId,
|
|
140
|
+
c: sql `COUNT(*)`,
|
|
141
|
+
err: sql `SUM(CASE WHEN success=0 THEN 1 ELSE 0 END)`,
|
|
142
|
+
})
|
|
143
|
+
.from(schema.requestAttempts)
|
|
144
|
+
.where(and(gte(schema.requestAttempts.startedAt, fromIso), lte(schema.requestAttempts.startedAt, toIso)))
|
|
145
|
+
.groupBy(schema.requestAttempts.providerId)
|
|
146
|
+
.orderBy(desc(sql `COUNT(*)`))
|
|
147
|
+
.all();
|
|
148
|
+
const providers = db.select().from(schema.providers).all();
|
|
149
|
+
const providerMap = new Map(providers.map((p) => [p.id, p]));
|
|
150
|
+
const range = { from: fromIso, to: toIso, bucket };
|
|
151
|
+
return {
|
|
152
|
+
range,
|
|
153
|
+
summary: statsSummary,
|
|
154
|
+
series: seriesRows.map((r) => ({ t: r.t, requests: Number(r.requests), errors: Number(r.errors), inputTokens: Number(r.inputTokens), outputTokens: Number(r.outputTokens) })),
|
|
155
|
+
topModels: topModels.map((r) => ({
|
|
156
|
+
publicId: r.modelId ? modelMap.get(r.modelId)?.publicModelId ?? r.modelId : 'unknown',
|
|
157
|
+
requests: Number(r.c),
|
|
158
|
+
errorRate: Number(r.c) > 0 ? Number(r.err) / Number(r.c) : 0,
|
|
159
|
+
totalTokens: Number(r.tokens),
|
|
160
|
+
})),
|
|
161
|
+
topApiKeys: topKeys.map((r) => ({
|
|
162
|
+
name: r.apiKeyId ? keyMap.get(r.apiKeyId)?.name ?? 'deleted' : 'unknown',
|
|
163
|
+
requests: Number(r.c),
|
|
164
|
+
totalTokens: Number(r.tokens),
|
|
165
|
+
})),
|
|
166
|
+
topProviders: topProviders.map((r) => ({
|
|
167
|
+
name: providerMap.get(r.providerId)?.name ?? r.providerId,
|
|
168
|
+
slug: providerMap.get(r.providerId)?.slug ?? '',
|
|
169
|
+
requests: Number(r.c),
|
|
170
|
+
errorRate: Number(r.c) > 0 ? Number(r.err) / Number(r.c) : 0,
|
|
171
|
+
})),
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function percentile(sorted, p) {
|
|
176
|
+
if (sorted.length === 0)
|
|
177
|
+
return 0;
|
|
178
|
+
const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
|
179
|
+
return sorted[idx];
|
|
180
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Admin API routes (mounted at /api/admin/*). All require admin session auth.
|
|
2
|
+
import { registerSetupRoutes } from './admin/setup.js';
|
|
3
|
+
import { registerAuthRoutes } from './admin/auth.js';
|
|
4
|
+
import { registerProviderRoutes } from './admin/providers.js';
|
|
5
|
+
import { registerModelRoutes } from './admin/models.js';
|
|
6
|
+
import { registerComboRoutes } from './admin/combos.js';
|
|
7
|
+
import { registerAliasRoutes } from './admin/aliases.js';
|
|
8
|
+
import { registerApiKeyRoutes } from './admin/api-keys.js';
|
|
9
|
+
import { registerRequestRoutes } from './admin/requests.js';
|
|
10
|
+
import { registerStatsRoutes } from './admin/stats.js';
|
|
11
|
+
import { registerAuditRoutes } from './admin/audit.js';
|
|
12
|
+
import { registerSettingsRoutes } from './admin/settings.js';
|
|
13
|
+
import { registerBackupRoutes } from './admin/backup.js';
|
|
14
|
+
import { registerDashboardRoutes } from './admin/dashboard.js';
|
|
15
|
+
export async function registerAdminRoutes(app) {
|
|
16
|
+
// Setup routes are always reachable (used on first run).
|
|
17
|
+
await app.register(async (instance) => {
|
|
18
|
+
await registerSetupRoutes(instance);
|
|
19
|
+
});
|
|
20
|
+
// Auth routes (login/logout) are public and must NOT inherit the
|
|
21
|
+
// requireAdminAuth hook that the authenticated scope below adds.
|
|
22
|
+
await app.register(async (instance) => {
|
|
23
|
+
await registerAuthRoutes(instance);
|
|
24
|
+
});
|
|
25
|
+
// Authenticated admin routes
|
|
26
|
+
await app.register(async (instance) => {
|
|
27
|
+
await registerProviderRoutes(instance);
|
|
28
|
+
await registerModelRoutes(instance);
|
|
29
|
+
await registerComboRoutes(instance);
|
|
30
|
+
await registerAliasRoutes(instance);
|
|
31
|
+
await registerApiKeyRoutes(instance);
|
|
32
|
+
await registerRequestRoutes(instance);
|
|
33
|
+
await registerStatsRoutes(instance);
|
|
34
|
+
await registerAuditRoutes(instance);
|
|
35
|
+
await registerSettingsRoutes(instance);
|
|
36
|
+
await registerBackupRoutes(instance);
|
|
37
|
+
await registerDashboardRoutes(instance);
|
|
38
|
+
});
|
|
39
|
+
}
|