refactor(http): share server infrastructure

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent 2d69e836ea
commit bf7004d0e0
24 files changed
+390 -350

No files matched your search

+1 -1
View File
@@ -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
+8 -34
View File
@@ -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),
},
})
+7 -41
View File
@@ -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
}
+6
View File
@@ -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'
-33
View File
@@ -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
}
}
+10 -41
View File
@@ -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)
})
})
-28
View File
@@ -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')
},
}
}
+12 -32
View File
@@ -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',
})
)
}
+12 -18
View File
@@ -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, {
+3 -1
View File
@@ -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:*",
+1
View File
@@ -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)
},
})
}
+74
View File
@@ -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, {})
})
+37 -38
View File
@@ -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/')),
})
+4
View File
@@ -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'
+48
View File
@@ -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()
})
+47
View File
@@ -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')
},
}
}
+1
View File
@@ -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'
+1
View File
@@ -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'