httpapi-ui.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. import { createHash } from "node:crypto"
  2. import { describe, expect } from "bun:test"
  3. import { Flag } from "@kirincode-ai/core/flag/flag"
  4. import { ConfigProvider, Effect, Layer, Option } from "effect"
  5. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  6. import {
  7. HttpClient,
  8. HttpClientRequest,
  9. HttpClientResponse,
  10. HttpRouter,
  11. HttpServer,
  12. HttpServerRequest,
  13. HttpServerResponse,
  14. } from "effect/unstable/http"
  15. import { FSUtil } from "@kirincode-ai/core/fs-util"
  16. import { RuntimeFlags } from "../../src/effect/runtime-flags"
  17. import { ServerAuth } from "../../src/server/auth"
  18. import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
  19. import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
  20. import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
  21. import { testEffect } from "../lib/effect"
  22. const testStateLayer = Layer.effectDiscard(
  23. Effect.gen(function* () {
  24. const original = {
  25. KIRINCODE_SERVER_PASSWORD: Flag.KIRINCODE_SERVER_PASSWORD,
  26. KIRINCODE_SERVER_USERNAME: Flag.KIRINCODE_SERVER_USERNAME,
  27. envPassword: process.env.KIRINCODE_SERVER_PASSWORD,
  28. envUsername: process.env.KIRINCODE_SERVER_USERNAME,
  29. }
  30. yield* Effect.addFinalizer(() =>
  31. Effect.sync(() => {
  32. Flag.KIRINCODE_SERVER_PASSWORD = original.KIRINCODE_SERVER_PASSWORD
  33. Flag.KIRINCODE_SERVER_USERNAME = original.KIRINCODE_SERVER_USERNAME
  34. restoreEnv("KIRINCODE_SERVER_PASSWORD", original.envPassword)
  35. restoreEnv("KIRINCODE_SERVER_USERNAME", original.envUsername)
  36. }),
  37. )
  38. }),
  39. )
  40. const fsUtilLayer = AppNodeBuilder.build(FSUtil.node)
  41. const it = testEffect(Layer.mergeAll(testStateLayer, fsUtilLayer, RuntimeFlags.layer()))
  42. function authConfigLayer(input?: { password?: string; username?: string }) {
  43. return ServerAuth.Config.configLayer({
  44. password: input?.password === undefined ? Option.none() : Option.some(input.password),
  45. username: input?.username ?? "kirincode",
  46. })
  47. }
  48. function restoreEnv(key: string, value: string | undefined) {
  49. if (value === undefined) {
  50. delete process.env[key]
  51. return
  52. }
  53. process.env[key] = value
  54. }
  55. function app(input?: { password?: string; username?: string }) {
  56. const handler = HttpRouter.toWebHandler(
  57. HttpApiApp.routes.pipe(
  58. Layer.provide(
  59. ConfigProvider.layer(
  60. ConfigProvider.fromUnknown({
  61. KIRINCODE_SERVER_PASSWORD: input?.password,
  62. KIRINCODE_SERVER_USERNAME: input?.username,
  63. }),
  64. ),
  65. ),
  66. ),
  67. { disableLogger: true },
  68. ).handler
  69. return {
  70. request(input: string | URL | Request, init?: RequestInit) {
  71. return Effect.promise(() =>
  72. Promise.resolve(
  73. handler(
  74. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  75. HttpApiApp.context,
  76. ),
  77. ),
  78. )
  79. },
  80. }
  81. }
  82. function uiApp(input?: {
  83. password?: string
  84. username?: string
  85. client?: Layer.Layer<HttpClient.HttpClient>
  86. disableEmbeddedWebUi?: boolean
  87. }) {
  88. const handler = HttpRouter.toWebHandler(
  89. HttpRouter.use((router) =>
  90. Effect.gen(function* () {
  91. const fs = yield* FSUtil.Service
  92. const client = yield* HttpClient.HttpClient
  93. const flags = yield* RuntimeFlags.Service
  94. yield* router.add("*", "/*", (request) =>
  95. serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
  96. )
  97. }),
  98. ).pipe(
  99. Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer(input)))),
  100. Layer.provide([
  101. fsUtilLayer,
  102. input?.client ?? httpClient(new Response("ui")),
  103. RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
  104. HttpServer.layerServices,
  105. ]),
  106. ),
  107. { disableLogger: true },
  108. ).handler
  109. return {
  110. request(input: string | URL | Request, init?: RequestInit) {
  111. return Effect.promise(() =>
  112. Promise.resolve(
  113. handler(
  114. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  115. HttpApiApp.context,
  116. ),
  117. ),
  118. )
  119. },
  120. }
  121. }
  122. function routeOrderingApp() {
  123. let proxiedUrl: string | undefined
  124. const handler = HttpRouter.toWebHandler(
  125. HttpRouter.use((router) =>
  126. Effect.gen(function* () {
  127. const fs = yield* FSUtil.Service
  128. const client = yield* HttpClient.HttpClient
  129. const flags = yield* RuntimeFlags.Service
  130. yield* router.add("GET", "/session/:sessionID", () =>
  131. Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })),
  132. )
  133. yield* router.add("*", "/*", (request) =>
  134. serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
  135. )
  136. }),
  137. ).pipe(
  138. Layer.provide([
  139. fsUtilLayer,
  140. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  141. httpClient(new Response("ui"), (request) => {
  142. proxiedUrl = request.url
  143. }),
  144. HttpServer.layerServices,
  145. ]),
  146. ),
  147. { disableLogger: true },
  148. ).handler
  149. return {
  150. proxiedUrl: () => proxiedUrl,
  151. request(input: string | URL | Request, init?: RequestInit) {
  152. return Effect.promise(() =>
  153. Promise.resolve(
  154. handler(
  155. input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
  156. HttpApiApp.context,
  157. ),
  158. ),
  159. )
  160. },
  161. }
  162. }
  163. function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
  164. return Layer.succeed(
  165. HttpClient.HttpClient,
  166. HttpClient.make((request) => {
  167. onRequest?.(request)
  168. return Effect.succeed(HttpClientResponse.fromWeb(request, response))
  169. }),
  170. )
  171. }
  172. function responseText(response: Response) {
  173. return Effect.promise(() => response.text())
  174. }
  175. describe("HttpApi UI fallback", () => {
  176. it.live("serves the web UI through the HTTP API app", () =>
  177. Effect.gen(function* () {
  178. let proxiedUrl: string | undefined
  179. const response = yield* uiApp({
  180. disableEmbeddedWebUi: true,
  181. client: httpClient(
  182. new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
  183. (request) => {
  184. proxiedUrl = request.url
  185. },
  186. ),
  187. }).request("/")
  188. expect(response.status).toBe(200)
  189. expect(response.headers.get("content-type")).toContain("text/html")
  190. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  191. expect(proxiedUrl).toBe("https://app.kirincode.ai/")
  192. }),
  193. )
  194. it.live("strips upstream transfer encoding headers from proxied assets", () =>
  195. Effect.gen(function* () {
  196. let proxiedUrl: string | undefined
  197. const response = yield* Effect.gen(function* () {
  198. const fs = yield* FSUtil.Service
  199. const client = yield* HttpClient.HttpClient
  200. const flags = yield* RuntimeFlags.Service
  201. return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
  202. fs,
  203. client,
  204. disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
  205. })
  206. }).pipe(
  207. Effect.provide(
  208. Layer.mergeAll(
  209. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  210. Layer.succeed(
  211. HttpClient.HttpClient,
  212. HttpClient.make((request) => {
  213. proxiedUrl = request.url
  214. return Effect.succeed(
  215. HttpClientResponse.fromWeb(
  216. request,
  217. new Response("console.log('ok')", {
  218. headers: {
  219. "content-encoding": "br",
  220. "content-length": "999",
  221. "content-type": "text/javascript",
  222. },
  223. }),
  224. ),
  225. )
  226. }),
  227. ),
  228. ),
  229. ),
  230. Effect.map(HttpServerResponse.toWeb),
  231. )
  232. expect(response.status).toBe(200)
  233. expect(proxiedUrl).toBe("https://app.kirincode.ai/assets/app.js")
  234. expect(response.headers.get("content-encoding")).toBeNull()
  235. expect(response.headers.get("content-length")).not.toBe("999")
  236. expect(response.headers.get("content-type")).toContain("text/javascript")
  237. expect(yield* responseText(response)).toBe("console.log('ok')")
  238. }),
  239. )
  240. // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
  241. // forwarded through the proxy while the proxy itself re-frames the body,
  242. // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
  243. it.live("strips upstream transfer-encoding header from proxied assets", () =>
  244. Effect.gen(function* () {
  245. const response = yield* Effect.gen(function* () {
  246. const fs = yield* FSUtil.Service
  247. const client = yield* HttpClient.HttpClient
  248. const flags = yield* RuntimeFlags.Service
  249. return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
  250. fs,
  251. client,
  252. disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
  253. })
  254. }).pipe(
  255. Effect.provide(
  256. Layer.mergeAll(
  257. RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
  258. Layer.succeed(
  259. HttpClient.HttpClient,
  260. HttpClient.make((request) =>
  261. Effect.succeed(
  262. HttpClientResponse.fromWeb(
  263. request,
  264. new Response("<html>opencode</html>", {
  265. headers: {
  266. "transfer-encoding": "chunked",
  267. "content-type": "text/html",
  268. },
  269. }),
  270. ),
  271. ),
  272. ),
  273. ),
  274. ),
  275. ),
  276. Effect.map(HttpServerResponse.toWeb),
  277. )
  278. expect(response.status).toBe(200)
  279. expect(response.headers.get("transfer-encoding")).toBeNull()
  280. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  281. }),
  282. )
  283. it.live("serves embedded UI assets when Bun can read them but access reports missing", () =>
  284. Effect.gen(function* () {
  285. let readPath: string | undefined
  286. const fs = yield* FSUtil.Service
  287. const response = yield* serveEmbeddedUIEffect(
  288. "/assets/app.js",
  289. {
  290. ...fs,
  291. existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
  292. readFile: (path) => {
  293. readPath = path
  294. return path === "/$bunfs/root/assets/app.js"
  295. ? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
  296. : Effect.die(`unexpected embedded UI path: ${path}`)
  297. },
  298. },
  299. { "assets/app.js": "/$bunfs/root/assets/app.js" },
  300. ).pipe(Effect.map(HttpServerResponse.toWeb))
  301. expect(response.status).toBe(200)
  302. expect(readPath).toBe("/$bunfs/root/assets/app.js")
  303. expect(response.headers.get("content-type")).toContain("text/javascript")
  304. expect(yield* responseText(response)).toBe("console.log('embedded')")
  305. }),
  306. )
  307. it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
  308. Effect.gen(function* () {
  309. const script = 'document.documentElement.dataset.theme = "dark"'
  310. const fs = yield* FSUtil.Service
  311. const response = yield* serveEmbeddedUIEffect(
  312. "/",
  313. {
  314. ...fs,
  315. readFile: (path) => {
  316. return path === "/$bunfs/root/index.html"
  317. ? Effect.succeed(
  318. new TextEncoder().encode(
  319. `<html><head><script id="oc-theme-preload-script">${script}</script></head></html>`,
  320. ),
  321. )
  322. : Effect.die(`unexpected embedded UI path: ${path}`)
  323. },
  324. },
  325. { "index.html": "/$bunfs/root/index.html" },
  326. ).pipe(Effect.map(HttpServerResponse.toWeb))
  327. const csp = response.headers.get("content-security-policy") ?? ""
  328. expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
  329. expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
  330. expect(csp).toContain("connect-src * data:")
  331. }),
  332. )
  333. it.live("keeps matched API routes ahead of the UI fallback", () =>
  334. Effect.gen(function* () {
  335. const server = routeOrderingApp()
  336. const response = yield* server.request("/session/ses_nope")
  337. expect(response.status).toBe(404)
  338. expect(server.proxiedUrl()).toBeUndefined()
  339. }),
  340. )
  341. it.live("requires server password for the web UI", () =>
  342. Effect.gen(function* () {
  343. const response = yield* uiApp({
  344. password: "secret",
  345. username: "kirincode",
  346. disableEmbeddedWebUi: true,
  347. }).request("/")
  348. expect(response.status).toBe(401)
  349. expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
  350. }),
  351. )
  352. it.live("accepts auth token for the web UI", () =>
  353. Effect.gen(function* () {
  354. const response = yield* uiApp({
  355. password: "secret",
  356. username: "kirincode",
  357. disableEmbeddedWebUi: true,
  358. client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
  359. }).request(`/?auth_token=${btoa("opencode:secret")}`)
  360. expect(response.status).toBe(200)
  361. expect(yield* responseText(response)).toBe("<html>opencode</html>")
  362. }),
  363. )
  364. it.live("accepts basic auth for the web UI", () =>
  365. Effect.gen(function* () {
  366. const response = yield* uiApp({
  367. password: "secret",
  368. username: "kirincode",
  369. disableEmbeddedWebUi: true,
  370. }).request("/", {
  371. headers: { authorization: `Basic ${btoa("opencode:secret")}` },
  372. })
  373. expect(response.status).toBe(200)
  374. }),
  375. )
  376. it.live("accepts basic auth passwords containing colons for the web UI", () =>
  377. Effect.gen(function* () {
  378. const response = yield* uiApp({
  379. password: "sec:ret",
  380. username: "kirincode",
  381. disableEmbeddedWebUi: true,
  382. }).request("/", {
  383. headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` },
  384. })
  385. expect(response.status).toBe(200)
  386. }),
  387. )
  388. // Regression for #25698 (Ope): the browser fetches the PWA manifest and
  389. // its icons via flows that don't carry app-managed credentials (the
  390. // `<link rel="manifest">` request is not under page-auth control), so the
  391. // server returning 401 breaks PWA install. These specific public assets
  392. // should bypass auth.
  393. it.live("serves the PWA manifest without auth even when a server password is set", () =>
  394. Effect.gen(function* () {
  395. for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
  396. const response = yield* uiApp({
  397. password: "secret",
  398. username: "kirincode",
  399. disableEmbeddedWebUi: true,
  400. client: httpClient(new Response("ok")),
  401. }).request(path)
  402. expect(response.status).not.toBe(401)
  403. }
  404. }),
  405. )
  406. it.live("allows web UI preflight without auth", () =>
  407. Effect.gen(function* () {
  408. const response = yield* app({ password: "secret", username: "kirincode" }).request("/", {
  409. method: "OPTIONS",
  410. headers: {
  411. origin: "http://localhost:3000",
  412. "access-control-request-method": "GET",
  413. },
  414. })
  415. expect(response.status).toBe(204)
  416. expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
  417. }),
  418. )
  419. })