app.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { randomBytes, createHash } from 'node:crypto'
  2. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  3. import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
  4. import { createServer as createHttpsServer } from 'node:https'
  5. import { join } from 'node:path'
  6. import { bodyParser } from '@koa/bodyparser'
  7. import Koa from 'koa'
  8. import { RealtimeAPI } from './realtimeApi.js'
  9. import type { ServerConfig } from './config.js'
  10. import { loadServerConfig } from './config.js'
  11. import { errorMessage, HttpError } from './errors.js'
  12. import { NodePairingStore } from './pairing.js'
  13. import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  14. import { AccountLoginPairingStore } from './accountPairing.js'
  15. import { UpstreamProfileIdentityService } from './profileIdentities.js'
  16. import { createApiRouter } from './routes.js'
  17. import {
  18. applySecurityHeaders,
  19. CsrfProtection,
  20. isAllowedHostHeader,
  21. isExactOrigin,
  22. } from './security.js'
  23. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  24. import { UploadStore } from './uploads.js'
  25. import { WorkspaceStore } from './workspaceStore.js'
  26. import { WorkspaceNodes } from './workspaceGateway.js'
  27. import { WorkspaceRuntime } from './workspaceRuntime.js'
  28. import { WorkspaceAssets } from './workspaceAssets.js'
  29. import { workspaceRouter } from './workspaceRoutes.js'
  30. import { SystemUpdateManager } from './updateManager.js'
  31. import { PushCoordinator } from './pushCoordinator.js'
  32. import {
  33. APNsConfigurationManager,
  34. type APNsConfigurationSnapshot,
  35. } from './apnsConfiguration.js'
  36. import {
  37. FCMConfigurationManager,
  38. type FCMConfigurationSnapshot,
  39. } from './fcmConfiguration.js'
  40. import { PushCoordinatorEventAdapter } from './pushEventAdapter.js'
  41. import {
  42. AllowedHostsConfigurationManager,
  43. type AllowedHostsConfigurationSnapshot,
  44. } from './allowedHostsConfiguration.js'
  45. import {
  46. ChatPushJobManager,
  47. HermesChatNotificationResolver,
  48. } from './pushEvents.js'
  49. export interface ApplicationOptions {
  50. config?: ServerConfig
  51. fetchImpl?: typeof fetch
  52. csrfSecret?: Buffer
  53. pairings?: NodePairingStore
  54. uploads?: UploadStore
  55. updates?: SystemUpdateManager
  56. auth?: LocalAuthStore
  57. upstreamSession?: UpstreamServiceSession
  58. accountPairings?: AccountLoginPairingStore
  59. profileIdentities?: UpstreamProfileIdentityService
  60. push?: PushCoordinator
  61. apnsConfiguration?: APNsConfigurationManager
  62. fcmConfiguration?: FCMConfigurationManager
  63. allowedHostsConfiguration?: AllowedHostsConfigurationManager
  64. }
  65. export interface ApplicationRuntime {
  66. workspace: WorkspaceStore
  67. workspaceRuntime: WorkspaceRuntime
  68. realtime: RealtimeAPI
  69. app: Koa
  70. config: ServerConfig
  71. csrf: CsrfProtection
  72. pairings: NodePairingStore
  73. upstream: UpstreamClient
  74. uploads: UploadStore
  75. updates: SystemUpdateManager
  76. auth: LocalAuthStore
  77. upstreamSession: UpstreamServiceSession
  78. accountPairings: AccountLoginPairingStore
  79. profileIdentities: UpstreamProfileIdentityService
  80. push: PushCoordinator
  81. apnsConfiguration: APNsConfigurationManager
  82. fcmConfiguration: FCMConfigurationManager
  83. allowedHostsConfiguration: AllowedHostsConfigurationManager
  84. pushEventCoordinator: PushCoordinatorEventAdapter
  85. chatPushJobs: ChatPushJobManager
  86. close(): void
  87. }
  88. function isMutation(method: string): boolean {
  89. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  90. }
  91. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  92. if (override) return override
  93. const path = join(home, 'csrf-secret.bin')
  94. try {
  95. const secret = readFileSync(path)
  96. if (secret.byteLength === 32) return secret
  97. throw new Error('CSRF secret has an invalid length')
  98. } catch (error) {
  99. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  100. }
  101. mkdirSync(home, { recursive: true, mode: 0o700 })
  102. const generated = randomBytes(32)
  103. try {
  104. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  105. return generated
  106. } catch (error) {
  107. // Another 15300 process can finish first during launchd hand-off. Never
  108. // rotate its secret; use the one it safely wrote instead.
  109. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  110. throw error
  111. }
  112. }
  113. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  114. const config = options.config ?? loadServerConfig()
  115. const runtimeAllowedHosts = new Set(config.allowedHosts)
  116. config.allowedHosts = runtimeAllowedHosts
  117. const app = new Koa()
  118. app.proxy = false
  119. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  120. const pairings = options.pairings ?? new NodePairingStore(config.home)
  121. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  122. const uploads = options.uploads ?? new UploadStore(config.home)
  123. const updates = options.updates ?? new SystemUpdateManager(config)
  124. const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
  125. const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
  126. ? { username: config.upstreamUsername, password: config.upstreamPassword }
  127. : undefined
  128. const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
  129. upstream,
  130. () => auth.upstreamCredentials(configuredUpstreamCredentials),
  131. )
  132. const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
  133. const profileIdentities = options.profileIdentities
  134. ?? new UpstreamProfileIdentityService(config, upstreamSession)
  135. const initialAPNsSettings: APNsConfigurationSnapshot = config.apnsSettings ?? {
  136. source: config.apns || config.apnsConfigurationError ? 'environment' : 'none',
  137. editable: !(config.apns || config.apnsConfigurationError),
  138. ...(config.apns ? {
  139. input: {
  140. ...config.apns,
  141. environments: [...(config.apns.environments ?? ['development', 'production'])],
  142. },
  143. config: config.apns,
  144. } : {}),
  145. warnings: [],
  146. ...(config.apnsConfigurationError ? { configurationError: config.apnsConfigurationError } : {}),
  147. }
  148. const apnsConfiguration = options.apnsConfiguration
  149. ?? new APNsConfigurationManager(config.home, initialAPNsSettings)
  150. const initialFCMSettings: FCMConfigurationSnapshot = config.fcmSettings ?? {
  151. source: config.fcm || config.fcmConfigurationError ? 'environment' : 'none',
  152. editable: !(config.fcm || config.fcmConfigurationError),
  153. ...(config.fcm ? { input: { ...config.fcm }, config: config.fcm } : {}),
  154. warnings: [],
  155. ...(config.fcmConfigurationError ? { configurationError: config.fcmConfigurationError } : {}),
  156. }
  157. const fcmConfiguration = options.fcmConfiguration
  158. ?? new FCMConfigurationManager(config.home, initialFCMSettings)
  159. const initialAllowedHostsSettings: AllowedHostsConfigurationSnapshot = config.allowedHostsSettings ?? {
  160. source: runtimeAllowedHosts.size ? 'environment' : 'none',
  161. hosts: [...runtimeAllowedHosts],
  162. editableHosts: [],
  163. environmentHosts: [...runtimeAllowedHosts],
  164. }
  165. const allowedHostsConfiguration = options.allowedHostsConfiguration
  166. ?? new AllowedHostsConfigurationManager(config.home, initialAllowedHostsSettings, runtimeAllowedHosts)
  167. const push = options.push ?? new PushCoordinator({
  168. home: config.home,
  169. apns: config.apns,
  170. apnsConfigurationError: config.apnsConfigurationError,
  171. fcm: config.fcm,
  172. fcmConfigurationError: config.fcmConfigurationError,
  173. isUserActive: userID => auth.isUserActive(userID),
  174. userAuthorizationVersion: userID => auth.pushAuthorizationVersion(userID),
  175. })
  176. const pushEventCoordinator = new PushCoordinatorEventAdapter(push)
  177. const chatPushJobs = new ChatPushJobManager(config, upstreamSession, pushEventCoordinator, {
  178. resolver: new HermesChatNotificationResolver(
  179. upstreamSession,
  180. (localUserID, prompt) => push.promptDigest(localUserID, prompt),
  181. ),
  182. })
  183. // The application owns group events. No Dashboard plugin is queried.
  184. const workspace = new WorkspaceStore(config.home)
  185. const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession })
  186. const workspaceRuntime = new WorkspaceRuntime(workspace, workspaceNodes, uploads, owner => auth.isUserActive(owner))
  187. const workspaceAssets = new WorkspaceAssets(workspace, workspaceNodes, config.home)
  188. workspaceRuntime.onMessage = (owner, message) => workspaceAssets.archive(owner, message)
  189. workspaceRuntime.onNotify = (owner, c, run, message, interaction) => {
  190. if (c.kind === 'group' && !push.isGroupSubscribed(owner,c.id)) return
  191. push.enqueueNotification({ kind: interaction ? interaction.kind === 'approval' ? 'chat.approval.requested' : 'chat.clarification.requested' : run.status === 'failed' ? 'chat.failed' : 'chat.completed', eventID: `workspace:${interaction?.id ?? run.id}`, localUserID: owner, title: c.name, body: (interaction?.message || message?.content || run.error || '回复完成').slice(0,180), collapseID: `workspace:${c.id}`, data: { conversationId:c.id, workspace:'1' }, ...(c.kind === 'group' ? {roomID:c.id} : {}) })
  192. }
  193. const realtime = new RealtimeAPI(config, auth, csrf, pairings, upstream, upstreamSession, {
  194. coordinator: pushEventCoordinator,
  195. resolver: new HermesChatNotificationResolver(upstreamSession, (user, prompt) => push.promptDigest(user, prompt)),
  196. })
  197. realtime.broker.protectedSession = id => workspace.ownsUpstream(id)
  198. realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
  199. if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
  200. const data = frame.payload ?? {}, text = JSON.stringify(data)
  201. const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
  202. void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
  203. }
  204. chatPushJobs.setTransportFactory(realtime.recoveryTransport, job => realtime.ownsPushJob(job.id))
  205. try {
  206. uploads.cleanupUncommitted()
  207. } catch {
  208. // A locked cleanup should not prevent startup; upload operations will
  209. // still surface the underlying storage error when used.
  210. }
  211. app.use(async (ctx, next) => {
  212. try {
  213. await next()
  214. } catch (error) {
  215. if (error instanceof UpstreamHttpError) {
  216. ctx.status = error.response.status
  217. ctx.type = 'application/json; charset=utf-8'
  218. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  219. return
  220. }
  221. const status = error instanceof HttpError
  222. ? error.status
  223. : typeof (error as { status?: unknown })?.status === 'number'
  224. ? Number((error as { status: number }).status)
  225. : 500
  226. const expose = status < 500 || error instanceof HttpError
  227. ctx.status = status
  228. ctx.type = 'application/json; charset=utf-8'
  229. ctx.body = {
  230. error: expose ? errorMessage(error) : 'Internal server error',
  231. code: error instanceof HttpError ? error.code : 'internal_error',
  232. }
  233. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  234. }
  235. })
  236. app.use(async (ctx, next) => {
  237. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  238. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  239. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  240. }
  241. await next()
  242. })
  243. app.use(async (ctx, next) => {
  244. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  245. const secure = ctx.secure || Boolean(config.tlsCert)
  246. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  247. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  248. }
  249. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  250. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  251. }
  252. }
  253. await next()
  254. })
  255. app.use(realtime.middleware())
  256. const parseBody = (limit: string) => bodyParser({
  257. encoding: 'utf-8',
  258. enableTypes: ['json'],
  259. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  260. jsonLimit: limit,
  261. onError: (error, ctx) => {
  262. throw new HttpError(ctx.status === 413 || (error as { status?: number }).status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  263. },
  264. })
  265. const ordinaryBody = parseBody('2mb'), workspaceBody = parseBody('4mb')
  266. app.use((ctx,next) => (/^\/api\/app\/(agents|conversations)(\/|$)/.test(ctx.path) || ctx.path === '/api/app/account/avatar' ? workspaceBody : ordinaryBody)(ctx,next))
  267. app.use(async (ctx, next) => {
  268. ctx.state.localAuth = auth
  269. ctx.state.upstreamSession = upstreamSession
  270. const anonymous = ctx.path === '/api/app/bootstrap'
  271. || ctx.path === '/api/app/setup'
  272. || ctx.path === '/api/app/login'
  273. || ctx.path === '/api/status'
  274. || ctx.path === '/api/auth/providers'
  275. || ctx.path === '/auth/password-login'
  276. || ctx.path.startsWith('/api/pair/v1/')
  277. || ctx.path.startsWith('/api/account-pair/v1/')
  278. || ctx.path === '/healthz'
  279. || ctx.path === '/readyz'
  280. if (ctx.path.startsWith('/api/app/') && !anonymous) {
  281. const allowPasswordChange = ctx.path === '/api/app/account/credentials'
  282. || ctx.path === '/api/app/logout'
  283. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  284. const adminOnly = ctx.path.startsWith('/api/app/admin/')
  285. || ctx.path.startsWith('/api/app/system/')
  286. || ctx.path.startsWith('/api/app/plugins/')
  287. || ctx.path.startsWith('/api/app/pairings')
  288. || ctx.path.startsWith('/api/app/paired-devices')
  289. || ctx.path.startsWith('/api/app/groups/nodes')
  290. if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
  291. }
  292. const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
  293. && !ctx.path.startsWith('/api/pair/'))
  294. || ctx.path === '/auth/logout'
  295. if (gatewayCompatibility && !anonymous) {
  296. const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
  297. || ctx.path === '/api/account/credentials'
  298. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  299. if (isMutation(ctx.method)) {
  300. const origin = ctx.get('origin')
  301. if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
  302. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  303. }
  304. }
  305. }
  306. const user = ctx.state.localUser as { id: string } | undefined
  307. if (user) {
  308. await upstream.withReadScope(`user:${user.id}:${auth.pushAuthorizationVersion(user.id)}`, ctx.get('x-yaoyao-cache') === 'bypass', next)
  309. } else await next()
  310. })
  311. const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push)
  312. app.use(applicationRouter.routes())
  313. app.use(async (ctx, next) => {
  314. if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
  315. const id = /\/sessions\/([^/]+)/.exec(ctx.path)?.[1]
  316. if (id && workspace.ownsUpstream(decodeURIComponent(id))) throw new HttpError(404, '会话不存在', 'session_not_found')
  317. await next()
  318. // Native history is a separate surface, including search and profile projections.
  319. if (ctx.path.includes('/sessions') || ctx.path.endsWith('/profiles')) {
  320. let value: any = ctx.body
  321. const buffered = Buffer.isBuffer(value)
  322. try {
  323. if (buffered) value = JSON.parse(value.toString())
  324. const filter = (v: any): any => {
  325. if (Array.isArray(v)) return v.filter(x => !x || typeof x !== 'object' || (x.source !== 'yaoyao_workspace' && !workspace.ownsUpstream(String(x.id ?? x.session_id ?? x.sessionId ?? '')))).map(filter)
  326. if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k,x]) => [k, filter(x)]))
  327. return v
  328. }
  329. ctx.body = buffered ? Buffer.from(JSON.stringify(filter(value))) : filter(value)
  330. } catch { /* Preserve non-JSON error responses. */ }
  331. }
  332. })
  333. const router = createApiRouter({
  334. workspace,
  335. config,
  336. csrf,
  337. upstream,
  338. pairings,
  339. uploads,
  340. updates,
  341. auth,
  342. upstreamSession,
  343. accountPairings,
  344. profileIdentities,
  345. push,
  346. apnsConfiguration,
  347. fcmConfiguration,
  348. allowedHostsConfiguration,
  349. })
  350. app.use(router.routes())
  351. app.use(router.allowedMethods({ throw: false }))
  352. app.use(async (ctx, next) => {
  353. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  354. ctx.status = 404
  355. ctx.type = 'application/json; charset=utf-8'
  356. ctx.body = { error: 'Route not found', code: 'not_found' }
  357. return
  358. }
  359. await next()
  360. })
  361. return {
  362. workspace,
  363. workspaceRuntime,
  364. realtime,
  365. app,
  366. config,
  367. csrf,
  368. pairings,
  369. upstream,
  370. uploads,
  371. updates,
  372. auth,
  373. upstreamSession,
  374. accountPairings,
  375. profileIdentities,
  376. push,
  377. apnsConfiguration,
  378. fcmConfiguration,
  379. allowedHostsConfiguration,
  380. pushEventCoordinator,
  381. chatPushJobs,
  382. close: () => {
  383. workspaceAssets.close()
  384. workspaceRuntime.close()
  385. workspaceNodes.close()
  386. upstream.close()
  387. realtime.close()
  388. chatPushJobs.stop()
  389. push.close()
  390. uploads.close()
  391. workspace.close()
  392. },
  393. }
  394. }
  395. export interface NodeServerRuntime {
  396. server: HttpServer
  397. close(): Promise<void>
  398. }
  399. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  400. const { config } = runtime
  401. const server: HttpServer = config.tlsCert && config.tlsKey
  402. ? createHttpsServer({
  403. cert: readFileSync(config.tlsCert),
  404. key: readFileSync(config.tlsKey),
  405. }, runtime.app.callback())
  406. : createHttpServer(runtime.app.callback())
  407. const synchronizePushObservers = (enabled = runtime.push.isAnyProviderEnabled()) => {
  408. if (enabled) {
  409. runtime.chatPushJobs.start()
  410. } else {
  411. runtime.chatPushJobs.stop()
  412. }
  413. }
  414. runtime.workspaceRuntime.start()
  415. synchronizePushObservers()
  416. const removePushConfigurationListener = runtime.push.onEnabledChange(synchronizePushObservers)
  417. const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
  418. return {
  419. server,
  420. close: async () => {
  421. removePushConfigurationListener()
  422. removeWebSockets()
  423. runtime.realtime.close()
  424. await new Promise<void>((resolve, reject) => {
  425. if (!server.listening) {
  426. resolve()
  427. return
  428. }
  429. server.close((error) => error ? reject(error) : resolve())
  430. })
  431. runtime.close()
  432. },
  433. }
  434. }