mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
refactor(http): share server infrastructure
This commit is contained in:
1 parent
2d69e836ea
commit
bf7004d0e0
24 files changed
+390
-350
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<T>(key: string): T | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(`${aiDataDir}/${key}.json`, 'utf-8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
writeJson<T>(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<string, unknown>).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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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' } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T>(key: string): T | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(aiDataDir, `${key}.json`), 'utf-8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
writeJson<T>(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')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown>).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, {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createAuthProfileLlmConfigStore, type AuthProfileLlmConfigStoreDeps } from './ai/auth-profile-llm-config-store'
|
||||
@@ -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<string, unknown> } {
|
||||
const data = new Map<string, unknown>()
|
||||
return {
|
||||
data,
|
||||
readJson: <T>(key: string) => (data.get(key) as T | undefined) ?? null,
|
||||
writeJson: <T>(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<string, string>()
|
||||
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<Record<string, unknown>> }
|
||||
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'])
|
||||
})
|
||||
@@ -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<string, unknown>).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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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, {})
|
||||
})
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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/')),
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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<void>
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<T>(key: string): T | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(baseDir, `${key}.json`), 'utf-8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
writeJson<T>(key: string, data: T): void {
|
||||
fs.mkdirSync(baseDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(baseDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user