From bf7004d0e07ec405a219f5745b69a05ed6729178 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 14:23:58 +0800 Subject: [PATCH] refactor(http): share server infrastructure --- apps/cli/src/http/auth.test.ts | 2 +- apps/cli/src/http/index.ts | 42 ++--------- apps/cli/src/http/server.ts | 48 ++---------- apps/desktop/electron.vite.config.ts | 6 ++ apps/desktop/main/api/auth.ts | 33 -------- apps/desktop/main/api/server.ts | 51 +++---------- apps/desktop/main/internal-api/auth.test.ts | 64 ---------------- apps/desktop/main/internal-api/auth.ts | 28 ------- .../main/internal-api/config-storage.ts | 19 ----- apps/desktop/main/internal-api/http.ts | 44 +++-------- apps/desktop/main/internal-api/server.ts | 30 +++----- packages/http-routes/package.json | 4 +- packages/http-routes/src/ai-config.ts | 1 + .../ai/auth-profile-llm-config-store.test.ts | 44 +++++++++++ .../src/ai/auth-profile-llm-config-store.ts | 36 +++++++++ packages/http-routes/src/auth.test.ts | 74 ++++++++++++++++++ packages/http-routes/src/auth.ts | 75 +++++++++---------- packages/http-routes/src/index.ts | 4 + packages/http-routes/src/server.test.ts | 48 ++++++++++++ packages/http-routes/src/server.ts | 47 ++++++++++++ .../src/ai/file-config-storage.test.ts | 19 +++++ .../src/ai/file-config-storage.ts | 19 +++++ packages/node-runtime/src/ai/index.ts | 1 + packages/node-runtime/src/index.ts | 1 + 24 files changed, 390 insertions(+), 350 deletions(-) delete mode 100644 apps/desktop/main/api/auth.ts delete mode 100644 apps/desktop/main/internal-api/auth.test.ts delete mode 100644 apps/desktop/main/internal-api/auth.ts delete mode 100644 apps/desktop/main/internal-api/config-storage.ts create mode 100644 packages/http-routes/src/ai-config.ts create mode 100644 packages/http-routes/src/ai/auth-profile-llm-config-store.test.ts create mode 100644 packages/http-routes/src/ai/auth-profile-llm-config-store.ts create mode 100644 packages/http-routes/src/auth.test.ts create mode 100644 packages/http-routes/src/server.test.ts create mode 100644 packages/http-routes/src/server.ts create mode 100644 packages/node-runtime/src/ai/file-config-storage.test.ts create mode 100644 packages/node-runtime/src/ai/file-config-storage.ts diff --git a/apps/cli/src/http/auth.test.ts b/apps/cli/src/http/auth.test.ts index 981a5cc8..94553248 100644 --- a/apps/cli/src/http/auth.test.ts +++ b/apps/cli/src/http/auth.test.ts @@ -1,6 +1,6 @@ import { describe, it, beforeEach } from 'node:test' import assert from 'node:assert/strict' -import { setAuthToken, setRequireAuth, authHook } from '@openchatlab/http-routes' +import { setAuthToken, setRequireAuth, authHook } from '@openchatlab/http-routes/auth' function fakeRequest(url: string, authorization?: string) { return { url, headers: { authorization } } as never diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts index fab8e229..e3ba6b8e 100644 --- a/apps/cli/src/http/index.ts +++ b/apps/cli/src/http/index.ts @@ -14,9 +14,9 @@ import { NodePathProvider, DatabaseManager, AIChatManager, - LLMConfigStore, CustomProviderStore, CustomModelStore, + createFileConfigStorage, applyPendingNodeDataDirMigrationIfNeeded, hasPendingElectronDataWarning, verifyCliDataPath, @@ -25,9 +25,10 @@ import { appLogger, logNativeParserStatus, } from '@openchatlab/node-runtime' -import type { ConfigStorage, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import type { SemanticIndexRuntime } from '@openchatlab/node-runtime' import { createServer } from './server' -import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes' +import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes/auth' +import { createAuthProfileLlmConfigStore } from '@openchatlab/http-routes/ai-config' import { registerWebRoutes } from './routes/web' import { registerProxyRoutes } from './routes/proxy' import { initServerAiLogger, closeServerAiLogger } from '../ai/logger' @@ -35,29 +36,12 @@ import { getAssistantManager, getSkillManagerCore } from '../ai/manager-factory' import { createCliRunAgentStream } from '../ai/agent-stream-runner' import { initSync, cleanupSync } from '../sync' import { resolveCliPath } from '../paths' -import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab/config' import { assertCliDataDirCompatible } from '../runtime-compat' let server: FastifyInstance | null = null let dbManager: DatabaseManager | null = null let aiChatManager: AIChatManager | null = null -function createFileConfigStorage(aiDataDir: string): ConfigStorage { - return { - readJson(key: string): T | null { - try { - return JSON.parse(fs.readFileSync(`${aiDataDir}/${key}.json`, 'utf-8')) as T - } catch { - return null - } - }, - writeJson(key: string, data: T): void { - if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) - fs.writeFileSync(`${aiDataDir}/${key}.json`, JSON.stringify(data, null, 2), 'utf-8') - }, - } -} - export interface HttpServerOptions { port?: number host?: string @@ -160,18 +144,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ const assistantManager = getAssistantManager(aiDataDir) const skillManagerCore = getSkillManagerCore(aiDataDir) - const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), { - resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, - onApiKeyCreated: (config, apiKey) => { - const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider - writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) - return profileName - }, - onApiKeyDeleted: (config) => { - const profileName = (config as unknown as Record).authProfile as string | undefined - if (profileName) deleteAuthProfile(profileName) - }, - }) + const configStorage = createFileConfigStorage(aiDataDir) + const llmConfigStore = createAuthProfileLlmConfigStore(configStorage) initAppLogger(pathProvider.getLogsDir()) initServerAiLogger(pathProvider.getLogsDir()) @@ -217,8 +191,8 @@ export async function startHttpServer(options?: HttpServerOptions): Promise<{ assistantManager, skillManagerCore, llmConfigStore, - customProviderStore: new CustomProviderStore(createFileConfigStorage(aiDataDir)), - customModelStore: new CustomModelStore(createFileConfigStorage(aiDataDir)), + customProviderStore: new CustomProviderStore(configStorage), + customModelStore: new CustomModelStore(configStorage), runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, semanticIndexService), }, }) diff --git a/apps/cli/src/http/server.ts b/apps/cli/src/http/server.ts index b8defdae..8cd8194e 100644 --- a/apps/cli/src/http/server.ts +++ b/apps/cli/src/http/server.ts @@ -4,50 +4,16 @@ * 从 electron/main/api/server.ts 迁移,完全平台无关。 */ -import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' +import type { FastifyInstance } from 'fastify' import { authHook } from '@openchatlab/http-routes/auth' -import { - ApiError, - ApiErrorCode, - apiErrorFromUnknown, - errorResponse, - serverError, -} from '@openchatlab/http-routes/errors' +import { createApiServer } from '@openchatlab/http-routes/server' import { appLogger } from '@openchatlab/node-runtime' -const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB - export function createServer(): FastifyInstance { - const server = Fastify({ - logger: false, - bodyLimit: JSON_BODY_LIMIT, + return createApiServer({ + authHook, + onUnhandledError: (request, error) => { + appLogger.error('http', `${request.method} ${request.url} -> 500`, error) + }, }) - - server.addHook('onRequest', authHook) - - server.setErrorHandler((error: FastifyError, request, reply) => { - const apiError = apiErrorFromUnknown(error) - if (apiError) { - reply.code(apiError.statusCode).send(errorResponse(apiError)) - return - } - - if (error.statusCode === 413) { - const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') - reply.code(413).send(errorResponse(bodyErr)) - return - } - - const statusCode = (error as any).statusCode - if (statusCode && statusCode >= 400 && statusCode < 600) { - reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) - return - } - - appLogger.error('http', `${request.method} ${request.url} -> 500`, error) - const err = serverError(error.message) - reply.code(err.statusCode).send(errorResponse(err)) - }) - - return server } diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 50858bea..356bc51a 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -8,6 +8,12 @@ const rootDir = resolve(__dirname, '../..') const rootPkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8')) const appVersion: string = rootPkg.version const openChatLabAliases = { + '@openchatlab/http-routes/ai-config': resolve(rootDir, 'packages/http-routes/src/ai-config.ts'), + '@openchatlab/http-routes/auth': resolve(rootDir, 'packages/http-routes/src/auth.ts'), + '@openchatlab/http-routes/errors': resolve(rootDir, 'packages/http-routes/src/errors.ts'), + '@openchatlab/http-routes/import': resolve(rootDir, 'packages/http-routes/src/import.ts'), + '@openchatlab/http-routes/rest': resolve(rootDir, 'packages/http-routes/src/rest.ts'), + '@openchatlab/http-routes/server': resolve(rootDir, 'packages/http-routes/src/server.ts'), '@openchatlab/node-runtime/import/archive/source-manager': resolve( rootDir, 'packages/node-runtime/src/import/archive/source-manager.ts' diff --git a/apps/desktop/main/api/auth.ts b/apps/desktop/main/api/auth.ts deleted file mode 100644 index 937d1066..00000000 --- a/apps/desktop/main/api/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * ChatLab API — Bearer Token authentication hook - */ - -import type { FastifyRequest, FastifyReply } from 'fastify' -import { timingSafeEqual } from 'crypto' -import { unauthorized, errorResponse } from '@openchatlab/http-routes/errors' -import { getConfig } from './index' - -function safeTokenCompare(a: string, b: string): boolean { - const bufA = Buffer.from(a) - const bufB = Buffer.from(b) - if (bufA.length !== bufB.length) return false - return timingSafeEqual(bufA, bufB) -} - -export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { - const authHeader = request.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { - const err = unauthorized() - reply.code(err.statusCode).send(errorResponse(err)) - return - } - - const token = authHeader.slice(7) - const config = getConfig() - - if (!config.token || !safeTokenCompare(token, config.token)) { - const err = unauthorized() - reply.code(err.statusCode).send(errorResponse(err)) - return - } -} diff --git a/apps/desktop/main/api/server.ts b/apps/desktop/main/api/server.ts index 4c3d458c..8968f790 100644 --- a/apps/desktop/main/api/server.ts +++ b/apps/desktop/main/api/server.ts @@ -2,49 +2,18 @@ * ChatLab API — Fastify server instance */ -import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' -import { - ApiError, - ApiErrorCode, - apiErrorFromUnknown, - errorResponse, - serverError, -} from '@openchatlab/http-routes/errors' -import { authHook } from './auth' +import type { FastifyInstance } from 'fastify' +import { createBearerAuthHook } from '@openchatlab/http-routes/auth' +import { createApiServer } from '@openchatlab/http-routes/server' +import { getConfig } from './index' import { apiLogger } from './logger' -const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB - export function createServer(): FastifyInstance { - const server = Fastify({ - logger: false, - bodyLimit: JSON_BODY_LIMIT, + return createApiServer({ + authHook: createBearerAuthHook({ + getToken: () => getConfig().token, + allowMissingToken: false, + }), + onUnhandledError: (request, error) => apiLogger.error(`${request.method} ${request.url} -> 500`, error), }) - - server.addHook('onRequest', authHook) - - server.setErrorHandler((error: FastifyError, _request, reply) => { - if (error instanceof ApiError) { - reply.code(error.statusCode).send(errorResponse(error)) - return - } - - const apiError = apiErrorFromUnknown(error) - if (apiError) { - reply.code(apiError.statusCode).send(errorResponse(apiError)) - return - } - - if (error.statusCode === 413) { - const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') - reply.code(413).send(errorResponse(bodyErr)) - return - } - - apiLogger.error('Unhandled error', error) - const err = serverError(error.message) - reply.code(err.statusCode).send(errorResponse(err)) - }) - - return server } diff --git a/apps/desktop/main/internal-api/auth.test.ts b/apps/desktop/main/internal-api/auth.test.ts deleted file mode 100644 index 4c714954..00000000 --- a/apps/desktop/main/internal-api/auth.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' -import type { FastifyReply, FastifyRequest } from 'fastify' -import { createInternalAuthHook } from './auth' - -interface ReplyResult { - statusCode?: number - payload?: unknown -} - -function createReply(result: ReplyResult): FastifyReply { - const reply = { - code(statusCode: number) { - result.statusCode = statusCode - return reply - }, - send(payload: unknown) { - result.payload = payload - return reply - }, - } - return reply as unknown as FastifyReply -} - -function createRequest(method: string, authorization?: string): FastifyRequest { - return { - method, - headers: authorization ? { authorization } : {}, - } as unknown as FastifyRequest -} - -describe('internal API authentication', () => { - it('rejects missing and invalid bearer tokens', async () => { - const hook = createInternalAuthHook('expected-token') - - const missing: ReplyResult = {} - await hook(createRequest('GET'), createReply(missing)) - assert.equal(missing.statusCode, 401) - assert.deepEqual(missing.payload, { - success: false, - error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' }, - }) - - const invalid: ReplyResult = {} - await hook(createRequest('GET', 'Bearer wrong-token'), createReply(invalid)) - assert.equal(invalid.statusCode, 401) - assert.deepEqual(invalid.payload, { - success: false, - error: { code: 'UNAUTHORIZED', message: 'Invalid token' }, - }) - }) - - it('accepts the current token and lets preflight requests pass', async () => { - const hook = createInternalAuthHook('expected-token') - - const authorized: ReplyResult = {} - await hook(createRequest('GET', 'Bearer expected-token'), createReply(authorized)) - assert.equal(authorized.statusCode, undefined) - - const preflight: ReplyResult = {} - await hook(createRequest('OPTIONS'), createReply(preflight)) - assert.equal(preflight.statusCode, undefined) - }) -}) diff --git a/apps/desktop/main/internal-api/auth.ts b/apps/desktop/main/internal-api/auth.ts deleted file mode 100644 index f106769b..00000000 --- a/apps/desktop/main/internal-api/auth.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto' -import type { FastifyReply, FastifyRequest } from 'fastify' - -const hmacKey = randomBytes(32) - -function safeTokenCompare(a: string, b: string): boolean { - const hashA = createHmac('sha256', hmacKey).update(a).digest() - const hashB = createHmac('sha256', hmacKey).update(b).digest() - return timingSafeEqual(hashA, hashB) -} - -/** All internal routes require the per-process bearer token. */ -export function createInternalAuthHook(token: string) { - return async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise { - if (request.method === 'OPTIONS') return - - const authHeader = request.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { - reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }) - return - } - - const provided = authHeader.slice(7) - if (!safeTokenCompare(provided, token)) { - reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } }) - } - } -} diff --git a/apps/desktop/main/internal-api/config-storage.ts b/apps/desktop/main/internal-api/config-storage.ts deleted file mode 100644 index e66544c6..00000000 --- a/apps/desktop/main/internal-api/config-storage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as fs from 'node:fs' -import * as path from 'node:path' -import type { ConfigStorage } from '@openchatlab/node-runtime' - -export function createFileConfigStorage(aiDataDir: string): ConfigStorage { - return { - readJson(key: string): T | null { - try { - return JSON.parse(fs.readFileSync(path.join(aiDataDir, `${key}.json`), 'utf-8')) as T - } catch { - return null - } - }, - writeJson(key: string, data: T): void { - if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) - fs.writeFileSync(path.join(aiDataDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8') - }, - } -} diff --git a/apps/desktop/main/internal-api/http.ts b/apps/desktop/main/internal-api/http.ts index b344bb64..93ae45c2 100644 --- a/apps/desktop/main/internal-api/http.ts +++ b/apps/desktop/main/internal-api/http.ts @@ -1,13 +1,5 @@ -import type { FastifyError, FastifyInstance, FastifyReply } from 'fastify' -import { - ApiError, - ApiErrorCode, - apiErrorFromUnknown, - errorResponse, - serverError, -} from '@openchatlab/http-routes/errors' -import { appLogger } from '@openchatlab/node-runtime' -import { createInternalAuthHook } from './auth' +import type { FastifyInstance, FastifyReply } from 'fastify' +import { createBearerAuthHook } from '@openchatlab/http-routes/auth' interface InternalHttpOptions { token: string @@ -51,26 +43,14 @@ export function configureInternalHttpServer(server: FastifyInstance, options: In done() }) - server.addHook('onRequest', createInternalAuthHook(options.token)) - - server.setErrorHandler((error: FastifyError, request, reply) => { - const apiError = apiErrorFromUnknown(error) - if (apiError) { - reply.code(apiError.statusCode).send(errorResponse(apiError)) - return - } - if (error.statusCode === 413) { - const bodyError = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') - reply.code(413).send(errorResponse(bodyError)) - return - } - const statusCode = (error as { statusCode?: number }).statusCode - if (statusCode && statusCode >= 400 && statusCode < 600) { - reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) - return - } - appLogger.error('http', `${request.method} ${request.url} -> 500`, error) - const internalError = serverError(error.message) - reply.code(internalError.statusCode).send(errorResponse(internalError)) - }) + server.addHook( + 'onRequest', + createBearerAuthHook({ + getToken: () => options.token, + allowMissingToken: false, + shouldAuthenticate: (request) => request.method !== 'OPTIONS', + missingTokenMessage: 'Missing or invalid token', + invalidTokenMessage: 'Invalid token', + }) + ) } diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index d80e6b66..925f8f32 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -11,27 +11,29 @@ import * as path from 'node:path' import { randomBytes } from 'node:crypto' import { app, ipcMain, shell } from 'electron' -import Fastify, { type FastifyInstance } from 'fastify' +import type { FastifyInstance } from 'fastify' import type { PathProvider } from '@openchatlab/core' import { DatabaseManager, createDatabaseManagerAdapter, - LLMConfigStore, CustomProviderStore, CustomModelStore, + createFileConfigStorage, MergeSessionCache, withDataDirImportLock, raiseChatDbCompatibilityGate, streamingImport, createSemanticIndexWorkerRuntimeClient, + appLogger, } from '@openchatlab/node-runtime' import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' import multipart from '@fastify/multipart' import { registerSharedRoutes, registerAutomationRoutes } from '@openchatlab/http-routes' +import { createAuthProfileLlmConfigStore } from '@openchatlab/http-routes/ai-config' +import { createApiServer } from '@openchatlab/http-routes/server' import type { HttpRouteContext } from '@openchatlab/http-routes' import { reloadTimer, stopTimer, type DataSourceManager, type PullEngine } from '@openchatlab/sync' -import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab/config' import { getManager as getAIChatManager } from '../ai/chats' import { getManager as getAssistantManager } from '../ai/assistant/manager' import { getManager as getSkillManager } from '../ai/skills/manager' @@ -42,7 +44,6 @@ import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime import { resolveDesktopNativeBinding } from '../runtime/native-sqlite' import { resolveModelDownloadProxyUrl } from '../network/proxy' import { getDefaultUserDataDir, getDownloadsDir, getUserDataDir } from '../paths/locations' -import { createFileConfigStorage } from './config-storage' import { configureInternalHttpServer } from './http' export interface InternalEndpoint { @@ -86,20 +87,8 @@ export async function startInternalServer( const sessionAdapter = createDatabaseManagerAdapter(newDbManager) const aiDataDir = pathProvider.getAiDataDir() - const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), { - resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, - onApiKeyCreated: (config, apiKey) => { - const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider - writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) - return profileName - }, - onApiKeyDeleted: (config) => { - const profileName = (config as unknown as Record).authProfile as string | undefined - if (profileName) deleteAuthProfile(profileName) - }, - }) - const configStorage = createFileConfigStorage(aiDataDir) + const llmConfigStore = createAuthProfileLlmConfigStore(configStorage) const newMergeCache = new MergeSessionCache(pathProvider, { nativeBinding }) newMergeCache.cleanupOrphans() @@ -186,7 +175,12 @@ export async function startInternalServer( executeAiTool: createExecuteElectronAiTool(newSemanticIndexService ?? undefined), } - newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT }) + newServer = createApiServer({ + bodyLimit: JSON_BODY_LIMIT, + onUnhandledError: (request, error) => { + appLogger.error('http', `${request.method} ${request.url} -> 500`, error) + }, + }) await newServer.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }) configureInternalHttpServer(newServer, { diff --git a/packages/http-routes/package.json b/packages/http-routes/package.json index c1a2bdb5..51fe17da 100644 --- a/packages/http-routes/package.json +++ b/packages/http-routes/package.json @@ -8,10 +8,12 @@ "types": "./src/index.ts", "exports": { ".": "./src/index.ts", + "./ai-config": "./src/ai-config.ts", "./auth": "./src/auth.ts", "./errors": "./src/errors.ts", "./import": "./src/import.ts", - "./rest": "./src/rest.ts" + "./rest": "./src/rest.ts", + "./server": "./src/server.ts" }, "dependencies": { "@openchatlab/config": "workspace:*", diff --git a/packages/http-routes/src/ai-config.ts b/packages/http-routes/src/ai-config.ts new file mode 100644 index 00000000..2cec797f --- /dev/null +++ b/packages/http-routes/src/ai-config.ts @@ -0,0 +1 @@ +export { createAuthProfileLlmConfigStore, type AuthProfileLlmConfigStoreDeps } from './ai/auth-profile-llm-config-store' diff --git a/packages/http-routes/src/ai/auth-profile-llm-config-store.test.ts b/packages/http-routes/src/ai/auth-profile-llm-config-store.test.ts new file mode 100644 index 00000000..b9d20007 --- /dev/null +++ b/packages/http-routes/src/ai/auth-profile-llm-config-store.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { ConfigStorage } from '@openchatlab/node-runtime' +import { createAuthProfileLlmConfigStore } from './auth-profile-llm-config-store' + +function createMemoryStorage(): ConfigStorage & { data: Map } { + const data = new Map() + return { + data, + readJson: (key: string) => (data.get(key) as T | undefined) ?? null, + writeJson: (key: string, value: T) => data.set(key, value), + } +} + +test('createAuthProfileLlmConfigStore shares auth-profile creation, resolution, and deletion wiring', () => { + const storage = createMemoryStorage() + const profiles = new Map() + const deleted: string[] = [] + const store = createAuthProfileLlmConfigStore(storage, { + resolveApiKey: (_provider, profileName) => (profileName ? profiles.get(profileName) : undefined), + writeAuthProfile: (name, profile) => profiles.set(name, profile.type === 'api_key' ? profile.key : ''), + deleteAuthProfile: (name) => { + profiles.delete(name) + deleted.push(name) + }, + }) + + const added = store.addConfig({ + name: 'Team OpenAI', + provider: 'openai', + apiKey: 'secret-key', + model: 'gpt-test', + }) + + assert.equal(added.success, true) + assert.equal(profiles.get('team-openai'), 'secret-key') + const persisted = storage.data.get('llm-config') as { configs: Array> } + assert.equal(persisted.configs[0].apiKey, '') + assert.equal(persisted.configs[0].authProfile, 'team-openai') + assert.equal(store.getAllConfigs()[0].apiKey, 'secret-key') + + assert.equal(store.deleteConfig(added.config!.id).success, true) + assert.deepEqual(deleted, ['team-openai']) +}) diff --git a/packages/http-routes/src/ai/auth-profile-llm-config-store.ts b/packages/http-routes/src/ai/auth-profile-llm-config-store.ts new file mode 100644 index 00000000..5e817cf3 --- /dev/null +++ b/packages/http-routes/src/ai/auth-profile-llm-config-store.ts @@ -0,0 +1,36 @@ +import { deleteAuthProfile, resolveApiKey, writeAuthProfile, type AuthProfile } from '@openchatlab/config' +import { LLMConfigStore, type AIServiceConfig, type ConfigStorage } from '@openchatlab/node-runtime' + +export interface AuthProfileLlmConfigStoreDeps { + resolveApiKey: (provider: string, authProfile?: string) => string | undefined + writeAuthProfile: (name: string, profile: AuthProfile) => void + deleteAuthProfile: (name: string) => void +} + +const defaultDeps: AuthProfileLlmConfigStoreDeps = { + resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, + writeAuthProfile, + deleteAuthProfile, +} + +function getAuthProfile(config: AIServiceConfig): string | undefined { + return (config as unknown as Record).authProfile as string | undefined +} + +export function createAuthProfileLlmConfigStore( + storage: ConfigStorage, + deps: AuthProfileLlmConfigStoreDeps = defaultDeps +): LLMConfigStore { + return new LLMConfigStore(storage, { + resolveApiKey: deps.resolveApiKey, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + deps.writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + return profileName + }, + onApiKeyDeleted: (config) => { + const profileName = getAuthProfile(config) + if (profileName) deps.deleteAuthProfile(profileName) + }, + }) +} diff --git a/packages/http-routes/src/auth.test.ts b/packages/http-routes/src/auth.test.ts new file mode 100644 index 00000000..863388cf --- /dev/null +++ b/packages/http-routes/src/auth.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { FastifyReply, FastifyRequest } from 'fastify' +import { createBearerAuthHook } from './auth' + +interface ReplyResult { + statusCode?: number + payload?: unknown +} + +function createReply(result: ReplyResult): FastifyReply { + const reply = { + code(statusCode: number) { + result.statusCode = statusCode + return reply + }, + send(payload: unknown) { + result.payload = payload + return reply + }, + } + return reply as unknown as FastifyReply +} + +function createRequest(method: string, authorization?: string): FastifyRequest { + return { + method, + url: '/api/v1/status', + headers: authorization ? { authorization } : {}, + } as unknown as FastifyRequest +} + +test('createBearerAuthHook supports strict auth with distinct missing and invalid messages', async () => { + const hook = createBearerAuthHook({ + getToken: () => 'expected-token', + allowMissingToken: false, + missingTokenMessage: 'Missing or invalid token', + invalidTokenMessage: 'Invalid token', + }) + + const missing: ReplyResult = {} + await hook(createRequest('GET'), createReply(missing)) + assert.deepEqual(missing, { + statusCode: 401, + payload: { success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }, + }) + + const invalid: ReplyResult = {} + await hook(createRequest('GET', 'Bearer wrong-token'), createReply(invalid)) + assert.deepEqual(invalid, { + statusCode: 401, + payload: { success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } }, + }) + + const authorized: ReplyResult = {} + await hook(createRequest('GET', 'Bearer expected-token'), createReply(authorized)) + assert.deepEqual(authorized, {}) +}) + +test('createBearerAuthHook can skip preflight requests and allow an intentionally missing token', async () => { + const strictHook = createBearerAuthHook({ + getToken: () => 'expected-token', + allowMissingToken: false, + shouldAuthenticate: (request) => request.method !== 'OPTIONS', + }) + const preflight: ReplyResult = {} + await strictHook(createRequest('OPTIONS'), createReply(preflight)) + assert.deepEqual(preflight, {}) + + const optionalHook = createBearerAuthHook({ getToken: () => null }) + const noToken: ReplyResult = {} + await optionalHook(createRequest('GET'), createReply(noToken)) + assert.deepEqual(noToken, {}) +}) diff --git a/packages/http-routes/src/auth.ts b/packages/http-routes/src/auth.ts index 4dfb95db..edaae653 100644 --- a/packages/http-routes/src/auth.ts +++ b/packages/http-routes/src/auth.ts @@ -1,7 +1,7 @@ /** * ChatLab HTTP API — Bearer Token authentication hook * - * Shared auth middleware for CLI Server and Electron Internal Server. + * Shared auth middleware for CLI Server and Electron APIs. * URL classification: /api/* always requires token, /_web/* conditionally, * static files and SPA fallback are public. */ @@ -13,6 +13,14 @@ import { unauthorized, errorResponse } from './errors' let cachedToken: string | null = null let requireAuthEnabled = false +export interface BearerAuthOptions { + getToken: () => string | null | undefined + shouldAuthenticate?: (request: FastifyRequest) => boolean + allowMissingToken?: boolean + missingTokenMessage?: string + invalidTokenMessage?: string +} + export function setAuthToken(token: string): void { cachedToken = token } @@ -25,47 +33,38 @@ export function setRequireAuth(enabled: boolean): void { requireAuthEnabled = enabled } -// Compare via HMAC digests (fixed 32-byte length) to avoid leaking token length -const hmacKey = randomBytes(32) - -function safeTokenCompare(a: string, b: string): boolean { - const hashA = createHmac('sha256', hmacKey).update(a).digest() - const hashB = createHmac('sha256', hmacKey).update(b).digest() - return timingSafeEqual(hashA, hashB) -} - -export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { - if (!cachedToken) return - - const url = request.url - - if (url.startsWith('/api/')) { - return requireBearerToken(request, reply) +export function createBearerAuthHook(options: BearerAuthOptions) { + // Compare via HMAC digests (fixed 32-byte length) to avoid leaking token length. + const hmacKey = randomBytes(32) + const safeTokenCompare = (provided: string, expected: string): boolean => { + const providedHash = createHmac('sha256', hmacKey).update(provided).digest() + const expectedHash = createHmac('sha256', hmacKey).update(expected).digest() + return timingSafeEqual(providedHash, expectedHash) } - if (url.startsWith('/_web/')) { - if (requireAuthEnabled) return requireBearerToken(request, reply) - return - } + return async function bearerAuthHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (options.shouldAuthenticate && !options.shouldAuthenticate(request)) return - // Static files and SPA fallback are public -} + const expectedToken = options.getToken() + if (!expectedToken && options.allowMissingToken !== false) return -function requireBearerToken(request: FastifyRequest, reply: FastifyReply): void { - if (!cachedToken) return + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + const err = unauthorized(options.missingTokenMessage) + reply.code(err.statusCode).send(errorResponse(err)) + return + } - const authHeader = request.headers.authorization - if (!authHeader || !authHeader.startsWith('Bearer ')) { - const err = unauthorized() - reply.code(err.statusCode).send(errorResponse(err)) - return - } - - const token = authHeader.slice(7) - - if (!safeTokenCompare(token, cachedToken)) { - const err = unauthorized() - reply.code(err.statusCode).send(errorResponse(err)) - return + const providedToken = authHeader.slice(7) + if (!expectedToken || !safeTokenCompare(providedToken, expectedToken)) { + const err = unauthorized(options.invalidTokenMessage) + reply.code(err.statusCode).send(errorResponse(err)) + } } } + +export const authHook = createBearerAuthHook({ + getToken: () => cachedToken, + shouldAuthenticate: (request) => + request.url.startsWith('/api/') || (requireAuthEnabled && request.url.startsWith('/_web/')), +}) diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index fb980a98..b8dede8f 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -30,6 +30,8 @@ export { errorResponse, } from './errors' export { parseTimeFilter } from './helpers' +export { createApiServer, configureApiErrorHandler, DEFAULT_JSON_BODY_LIMIT } from './server' +export type { ApiServerOptions } from './server' export { buildImportIdempotencyCacheKey, createJsonPushImportHandler, @@ -42,6 +44,8 @@ export { export { buildAnalyticsCacheKey, withAnalyticsCache } from './analytics-cache' export { executeRegistryTool } from './ai/tool-executor' export type { AiToolExecutionDeps } from './ai/tool-executor' +export { createAuthProfileLlmConfigStore } from './ai/auth-profile-llm-config-store' +export type { AuthProfileLlmConfigStoreDeps } from './ai/auth-profile-llm-config-store' // Individual route registration for granular testing or selective registration export { registerSystemRoutes } from './routes/system' diff --git a/packages/http-routes/src/server.test.ts b/packages/http-routes/src/server.test.ts new file mode 100644 index 00000000..c6fc071e --- /dev/null +++ b/packages/http-routes/src/server.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/data-dir-compat' +import { createApiServer } from './server' + +test('createApiServer maps shared errors, payload limits, client errors, and unhandled errors consistently', async () => { + const logged: string[] = [] + const server = createApiServer({ + bodyLimit: 16, + onUnhandledError: (request, error) => logged.push(`${request.method} ${request.url}: ${error.message}`), + }) + server.get('/compat', async () => { + throw new DataDirCompatibilityError('DATA_DIR_REQUIRES_NEWER_RUNTIME', 'Newer runtime required', { + userDataDir: '/tmp/chatlab-data', + metaPath: '/tmp/chatlab-data/.chatlab-meta.json', + currentVersion: '0.31.2', + minRuntimeVersion: '0.32.0', + }) + }) + server.post('/body', async () => ({ ok: true })) + server.get('/client', async () => { + throw Object.assign(new Error('Bad request'), { statusCode: 400 }) + }) + server.get('/unhandled', async () => { + throw new Error('Unexpected failure') + }) + + const [compat, body, client, unhandled] = await Promise.all([ + server.inject({ method: 'GET', url: '/compat' }), + server.inject({ method: 'POST', url: '/body', payload: { value: 'this is too large' } }), + server.inject({ method: 'GET', url: '/client' }), + server.inject({ method: 'GET', url: '/unhandled' }), + ]) + + assert.equal(compat.statusCode, 409) + assert.equal(compat.json().error.code, 'DATA_DIR_INCOMPATIBLE') + assert.equal(body.statusCode, 413) + assert.equal(body.json().error.code, 'BODY_TOO_LARGE') + assert.deepEqual(client.json(), { + success: false, + error: { code: 'CLIENT_ERROR', message: 'Bad request' }, + }) + assert.equal(unhandled.statusCode, 500) + assert.equal(unhandled.json().error.code, 'SERVER_ERROR') + assert.deepEqual(logged, ['GET /unhandled: Unexpected failure']) + + await server.close() +}) diff --git a/packages/http-routes/src/server.ts b/packages/http-routes/src/server.ts new file mode 100644 index 00000000..747102e4 --- /dev/null +++ b/packages/http-routes/src/server.ts @@ -0,0 +1,47 @@ +import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify' +import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from './errors' + +export const DEFAULT_JSON_BODY_LIMIT = 50 * 1024 * 1024 + +export interface ApiServerOptions { + bodyLimit?: number + authHook?: (request: FastifyRequest, reply: FastifyReply) => void | Promise + onUnhandledError?: (request: FastifyRequest, error: FastifyError) => void +} + +export function configureApiErrorHandler(server: FastifyInstance, options: ApiServerOptions = {}): void { + server.setErrorHandler((error: FastifyError, request, reply) => { + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + + if (error.statusCode === 413) { + const bodyError = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(bodyError.statusCode).send(errorResponse(bodyError)) + return + } + + const statusCode = error.statusCode + if (statusCode && statusCode >= 400 && statusCode < 600) { + reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) + return + } + + options.onUnhandledError?.(request, error) + const internalError = serverError(error.message) + reply.code(internalError.statusCode).send(errorResponse(internalError)) + }) +} + +export function createApiServer(options: ApiServerOptions = {}): FastifyInstance { + const server = Fastify({ + logger: false, + bodyLimit: options.bodyLimit ?? DEFAULT_JSON_BODY_LIMIT, + }) + + if (options.authHook) server.addHook('onRequest', options.authHook) + configureApiErrorHandler(server, options) + return server +} diff --git a/packages/node-runtime/src/ai/file-config-storage.test.ts b/packages/node-runtime/src/ai/file-config-storage.test.ts new file mode 100644 index 00000000..158bbdae --- /dev/null +++ b/packages/node-runtime/src/ai/file-config-storage.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import test from 'node:test' +import { createFileConfigStorage } from './file-config-storage' + +test('createFileConfigStorage reads and writes JSON under the configured directory', (t) => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-config-storage-')) + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })) + const storage = createFileConfigStorage(path.join(rootDir, 'nested', 'ai')) + + assert.equal(storage.readJson('missing'), null) + storage.writeJson('llm-config', { enabled: true, models: ['fast'] }) + assert.deepEqual(storage.readJson('llm-config'), { enabled: true, models: ['fast'] }) + + fs.writeFileSync(path.join(rootDir, 'nested', 'ai', 'invalid.json'), '{invalid', 'utf-8') + assert.equal(storage.readJson('invalid'), null) +}) diff --git a/packages/node-runtime/src/ai/file-config-storage.ts b/packages/node-runtime/src/ai/file-config-storage.ts new file mode 100644 index 00000000..771d3b3d --- /dev/null +++ b/packages/node-runtime/src/ai/file-config-storage.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import type { ConfigStorage } from './llm-config-store' + +export function createFileConfigStorage(baseDir: string): ConfigStorage { + return { + readJson(key: string): T | null { + try { + return JSON.parse(fs.readFileSync(path.join(baseDir, `${key}.json`), 'utf-8')) as T + } catch { + return null + } + }, + writeJson(key: string, data: T): void { + fs.mkdirSync(baseDir, { recursive: true }) + fs.writeFileSync(path.join(baseDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8') + }, + } +} diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index df9de0ed..92ebe0f5 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -163,6 +163,7 @@ export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, Summar // LLM Config Store export { LLMConfigStore, MAX_CONFIG_COUNT } from './llm-config-store' export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './llm-config-store' +export { createFileConfigStorage } from './file-config-storage' // Custom Provider/Model Store export { CustomProviderStore, CustomModelStore } from './custom-store' diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index ef714074..8da1ad40 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -157,6 +157,7 @@ export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from ' export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai' export { AIChatManager } from './ai' export { countTokens, countMessagesTokens, initTokenizer } from './ai' +export { createFileConfigStorage } from './ai' // Assistant Manager export { AssistantManager } from './ai'