index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. // KirinCode Cloud Server v0.4.0
  2. // Enterprise Usage Tracking + JWT Auth + SQLite
  3. import { Elysia, t } from "elysia"
  4. import { jwt } from "@elysiajs/jwt"
  5. import { cors } from "@elysiajs/cors"
  6. import { Database } from "bun:sqlite"
  7. import { hash, compare } from "bcryptjs"
  8. import { randomUUID } from "node:crypto"
  9. const JWT_SECRET = process.env.JWT_SECRET || "kirincode-cloud-secret-change-in-production"
  10. const PORT = parseInt(process.env.PORT || "3000")
  11. const DB_PATH = process.env.DB_PATH || "kirincode.db"
  12. // Initialize SQLite
  13. const db = new Database(DB_PATH)
  14. db.run("PRAGMA journal_mode=WAL")
  15. db.run("PRAGMA foreign_keys=ON")
  16. // Run schema
  17. const schema = await Bun.file("./src/db/schema.sql").text()
  18. db.run(schema)
  19. // Helper: calculate cost
  20. function calculateCost(provider: string, model: string, inputTokens: number, outputTokens: number, cacheTokens: number = 0): number {
  21. const row = db.query("SELECT input_price_per_1m, output_price_per_1m, cache_price_per_1m FROM pricing WHERE provider = ? AND model = ?").get(provider, model) as any
  22. if (!row) return 0
  23. const inputCost = (inputTokens / 1_000_000) * row.input_price_per_1m
  24. const outputCost = (outputTokens / 1_000_000) * row.output_price_per_1m
  25. const cacheCost = (cacheTokens / 1_000_000) * (row.cache_price_per_1m || 0)
  26. return Math.round((inputCost + outputCost + cacheCost) * 1e6) / 1e6
  27. }
  28. const app = new Elysia()
  29. .use(cors())
  30. .use(jwt({ name: "jwt", secret: JWT_SECRET }))
  31. // --- Auth Middleware ---
  32. .derive(async ({ jwt, headers, set }) => {
  33. const auth = headers["authorization"]
  34. if (!auth?.startsWith("Bearer ")) return { userId: null, userRole: null, userTeamId: null }
  35. try {
  36. const payload = await jwt.verify(auth.slice(7)) as any
  37. const user = db.query("SELECT role, team_id FROM users WHERE id = ?").get(payload.sub) as any
  38. return { userId: payload.sub as string, userRole: user?.role || null, userTeamId: user?.team_id || null }
  39. } catch {
  40. return { userId: null, userRole: null, userTeamId: null }
  41. }
  42. })
  43. // --- Public Routes ---
  44. .post("/api/v1/auth/register", async ({ body, set }) => {
  45. const existing = db.query("SELECT id FROM users WHERE email = ?").get(body.email)
  46. if (existing) { set.status = 409; return { error: "Email already registered" } }
  47. const id = randomUUID()
  48. const passwordHash = await hash(body.password, 10)
  49. db.run("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)", [id, body.email, passwordHash, body.display_name || body.email.split("@")[0]])
  50. const token = await app.decorator.jwt.sign({ sub: id })
  51. return { token, user: { id, email: body.email, display_name: body.display_name } }
  52. }, { body: t.Object({ email: t.String(), password: t.String(), display_name: t.Optional(t.String()) }) })
  53. .post("/api/v1/auth/login", async ({ body, set }) => {
  54. const user = db.query("SELECT id, email, password_hash, display_name, role, team_id FROM users WHERE email = ?").get(body.email) as any
  55. if (!user || !(await compare(body.password, user.password_hash))) {
  56. set.status = 401; return { error: "Invalid credentials" }
  57. }
  58. const token = await app.decorator.jwt.sign({ sub: user.id })
  59. return { token, user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id } }
  60. }, { body: t.Object({ email: t.String(), password: t.String() }) })
  61. .post("/api/v1/auth/refresh", async ({ jwt, headers }) => {
  62. const auth = headers["authorization"]?.slice(7)
  63. if (!auth) return { error: "No token" }
  64. try {
  65. const payload = await jwt.verify(auth) as any
  66. const token = await app.decorator.jwt.sign({ sub: payload.sub })
  67. return { token }
  68. } catch { return { error: "Invalid token" } }
  69. })
  70. // --- Protected Routes ---
  71. .guard({ beforeHandle: ({ userId, set }) => { if (!userId) { set.status = 401; return "Unauthorized" } } })
  72. // --- Sessions ---
  73. .get("/api/v1/sessions", async ({ userId }) => {
  74. const sessions = db.query("SELECT id, title, agent, created_at, updated_at FROM sessions WHERE user_id = ? ORDER BY updated_at DESC LIMIT 50").all(userId)
  75. return { data: sessions }
  76. })
  77. .post("/api/v1/sessions", async ({ userId, body }) => {
  78. const id = randomUUID()
  79. db.run("INSERT INTO sessions (id, user_id, title, agent) VALUES (?, ?, ?, ?)", [id, userId, body.title || "New Chat", body.agent || "build"])
  80. return { data: { id, title: body.title, agent: body.agent } }
  81. }, { body: t.Object({ title: t.Optional(t.String()), agent: t.Optional(t.String()) }) })
  82. .post("/api/v1/sessions/:id/prompt", async ({ userId, params, body, set }) => {
  83. const session = db.query("SELECT id FROM sessions WHERE id = ? AND user_id = ?").get(params.id, userId)
  84. if (!session) { set.status = 404; return { error: "Session not found" } }
  85. const msgId = randomUUID()
  86. const text = (body.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n")
  87. db.run("INSERT INTO messages (id, session_id, role, content) VALUES (?, ?, ?, ?)", [msgId, params.id, "user", text])
  88. db.run("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?", [params.id])
  89. // AI response placeholder — records usage with estimated tokens
  90. const inputTokens = Math.ceil(text.length / 4)
  91. const outputTokens = Math.ceil(text.length / 2)
  92. const provider = body.model?.providerID || "anthropic"
  93. const model = body.model?.modelID || "claude-sonnet-4-5"
  94. const cost = calculateCost(provider, model, inputTokens, outputTokens)
  95. const user = db.query("SELECT team_id FROM users WHERE id = ?").get(userId) as any
  96. db.run(
  97. "INSERT INTO usage_events (user_id, team_id, provider, model, session_id, input_tokens, output_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
  98. [userId, user?.team_id || null, provider, model, params.id, inputTokens, outputTokens, cost]
  99. )
  100. const aiMsgId = randomUUID()
  101. db.run("INSERT INTO messages (id, session_id, role, content) VALUES (?, ?, ?, ?)", [aiMsgId, params.id, "assistant", `[AI response — ${outputTokens} tokens, $${cost}]`])
  102. const msgs = db.query("SELECT id, role, content, created_at FROM messages WHERE session_id = ? ORDER BY created_at").all(params.id)
  103. return { data: { messages: msgs, usage: { input_tokens: inputTokens, output_tokens: outputTokens, cost_usd: cost } } }
  104. })
  105. .get("/api/v1/sessions/:id/messages", async ({ userId, params }) => {
  106. return { data: db.query("SELECT id, role, content, created_at FROM messages WHERE session_id = (SELECT id FROM sessions WHERE id = ? AND user_id = ?) ORDER BY created_at").all(params.id, userId) }
  107. })
  108. // --- Admin: Usage Stats ---
  109. .guard({ beforeHandle: ({ userId, userRole, set }) => {
  110. if (!userId || !["admin", "team_admin"].includes(userRole || "")) { set.status = 403; return "Forbidden: admin access required" }
  111. }})
  112. .get("/api/v1/admin/usage/team", async ({ userTeamId, userRole, userId, query }) => {
  113. const teamId = userRole === "admin" ? query.team_id : userTeamId
  114. const from = query.from || "1970-01-01"
  115. const to = query.to || "2099-12-31"
  116. const rows = db.query(`
  117. SELECT date(created_at) as day, provider, model,
  118. SUM(input_tokens) as input_tokens,
  119. SUM(output_tokens) as output_tokens,
  120. COUNT(*) as request_count,
  121. SUM(cost_usd) as total_cost
  122. FROM usage_events
  123. WHERE team_id = ? AND created_at BETWEEN ? AND ?
  124. GROUP BY 1, 2, 3
  125. ORDER BY 1 DESC
  126. `).all(teamId, from, to)
  127. return { data: rows }
  128. })
  129. .get("/api/v1/admin/usage/users", async ({ userTeamId, userRole, query }) => {
  130. const teamId = userRole === "admin" ? query.team_id : userTeamId
  131. const from = query.from || "1970-01-01"
  132. const to = query.to || "2099-12-31"
  133. const rows = db.query(`
  134. SELECT u.display_name, u.email, ue.user_id,
  135. SUM(ue.input_tokens) as input_tokens,
  136. SUM(ue.output_tokens) as output_tokens,
  137. COUNT(*) as request_count,
  138. SUM(ue.cost_usd) as total_cost
  139. FROM usage_events ue
  140. JOIN users u ON u.id = ue.user_id
  141. WHERE ue.team_id = ? AND ue.created_at BETWEEN ? AND ?
  142. GROUP BY 1, 2, 3
  143. ORDER BY total_cost DESC
  144. `).all(teamId, from, to)
  145. return { data: rows }
  146. })
  147. .get("/api/v1/admin/usage/minute", async ({ userTeamId, userRole, userId, query }) => {
  148. const teamId = userRole === "admin" ? query.team_id : userTeamId
  149. const targetUser = query.user_id || userId
  150. const from = query.from || "1970-01-01"
  151. const to = query.to || "2099-12-31"
  152. const rows = db.query(`
  153. SELECT created_at as minute,
  154. input_tokens, output_tokens, provider, model, cost_usd
  155. FROM usage_events
  156. WHERE user_id = ? AND team_id = ? AND created_at BETWEEN ? AND ?
  157. ORDER BY created_at DESC
  158. LIMIT 1000
  159. `).all(targetUser, teamId, from, to)
  160. return { data: rows }
  161. })
  162. .get("/api/v1/admin/usage/today", async ({ userTeamId, userRole }) => {
  163. const teamId = userTeamId
  164. const rows = db.query(`
  165. SELECT
  166. SUM(input_tokens) as input_tokens,
  167. SUM(output_tokens) as output_tokens,
  168. COUNT(*) as request_count,
  169. SUM(cost_usd) as total_cost,
  170. COUNT(DISTINCT user_id) as active_users
  171. FROM usage_events
  172. WHERE team_id = ? AND date(created_at) = date('now')
  173. `).get(teamId)
  174. return { data: rows }
  175. })
  176. .get("/api/v1/admin/teams", async ({ userRole, set }) => {
  177. if (userRole !== "admin") { set.status = 403; return { error: "Admin only" } }
  178. return { data: db.query("SELECT id, name, owner_id, created_at FROM teams ORDER BY created_at").all() }
  179. })
  180. .post("/api/v1/admin/teams", async ({ userRole, body, set }) => {
  181. if (userRole !== "admin") { set.status = 403; return { error: "Admin only" } }
  182. const id = randomUUID()
  183. db.run("INSERT INTO teams (id, name, owner_id) VALUES (?, ?, ?)", [id, body.name, body.owner_id])
  184. db.run("INSERT INTO team_members (team_id, user_id, role) VALUES (?, ?, 'team_admin')", [id, body.owner_id])
  185. db.run("UPDATE users SET team_id = ?, role = 'team_admin' WHERE id = ?", [id, body.owner_id])
  186. return { data: { id, name: body.name } }
  187. }, { body: t.Object({ name: t.String(), owner_id: t.String() }) })
  188. // --- Memory ---
  189. .get("/api/v1/memory", async ({ userId }) => {
  190. const rows = db.query("SELECT id, category, content, created_at FROM memory_entries WHERE user_id = ? ORDER BY created_at DESC LIMIT 100").all(userId)
  191. return { data: rows }
  192. })
  193. .post("/api/v1/memory", async ({ userId, body }) => {
  194. if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='memory_entries'").get()) {
  195. db.run("CREATE TABLE memory_entries (id TEXT PRIMARY KEY, user_id TEXT, category TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
  196. }
  197. const id = randomUUID()
  198. db.run("INSERT INTO memory_entries (id, user_id, category, content) VALUES (?, ?, ?, ?)", [id, userId, body.category, body.content])
  199. return { data: { id, category: body.category } }
  200. })
  201. // --- Skills ---
  202. .get("/api/v1/skills", async ({ userId }) => {
  203. if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='skills'").get()) {
  204. db.run("CREATE TABLE skills (id TEXT PRIMARY KEY, user_id TEXT, name TEXT, description TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
  205. }
  206. return { data: db.query("SELECT id, name, description, created_at FROM skills WHERE user_id = ? ORDER BY created_at DESC").all(userId) }
  207. })
  208. .post("/api/v1/skills", async ({ userId, body }) => {
  209. if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='skills'").get()) {
  210. db.run("CREATE TABLE skills (id TEXT PRIMARY KEY, user_id TEXT, name TEXT, description TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
  211. }
  212. const id = randomUUID()
  213. db.run("INSERT INTO skills (id, user_id, name, description, content) VALUES (?, ?, ?, ?, ?)", [id, userId, body.name, body.description, body.content])
  214. return { data: { id, name: body.name } }
  215. })
  216. // --- Health ---
  217. .get("/api/v1/health", () => ({
  218. status: "ok",
  219. version: "0.4.0",
  220. db: db.query("SELECT COUNT(*) as users FROM users").get(),
  221. }))
  222. .listen(PORT)
  223. console.log(`🦄 KirinCode Cloud Server v0.4.0 — http://localhost:${PORT}`)
  224. console.log(` DB: ${DB_PATH} (${db.query("SELECT COUNT(*) as c FROM users").get() || 0} users)`)
  225. console.log(` API: http://localhost:${PORT}/api/v1/health`)
  226. export type App = typeof app