|
|
@@ -1,142 +1,259 @@
|
|
|
-// KirinCode Cloud Sync Server
|
|
|
-// Bun + Elysia + Drizzle ORM + PostgreSQL
|
|
|
-
|
|
|
+// KirinCode Cloud Server v0.4.0
|
|
|
+// Enterprise Usage Tracking + JWT Auth + SQLite
|
|
|
import { Elysia, t } from "elysia"
|
|
|
import { jwt } from "@elysiajs/jwt"
|
|
|
import { cors } from "@elysiajs/cors"
|
|
|
+import { Database } from "bun:sqlite"
|
|
|
import { hash, compare } from "bcryptjs"
|
|
|
-import { v4 as uuid } from "uuid"
|
|
|
+import { randomUUID } from "node:crypto"
|
|
|
|
|
|
-const JWT_SECRET = process.env.JWT_SECRET || "kirincode-cloud-secret-change-me"
|
|
|
+const JWT_SECRET = process.env.JWT_SECRET || "kirincode-cloud-secret-change-in-production"
|
|
|
const PORT = parseInt(process.env.PORT || "3000")
|
|
|
+const DB_PATH = process.env.DB_PATH || "kirincode.db"
|
|
|
+
|
|
|
+// Initialize SQLite
|
|
|
+const db = new Database(DB_PATH)
|
|
|
+db.run("PRAGMA journal_mode=WAL")
|
|
|
+db.run("PRAGMA foreign_keys=ON")
|
|
|
+
|
|
|
+// Run schema
|
|
|
+const schema = await Bun.file("./src/db/schema.sql").text()
|
|
|
+db.run(schema)
|
|
|
|
|
|
-// In-memory store (replace with PostgreSQL + Drizzle in production)
|
|
|
-const db = {
|
|
|
- users: new Map<string, { id: string; email: string; password: string }>(),
|
|
|
- sessions: new Map<string, any>(),
|
|
|
- messages: new Map<string, any[]>(),
|
|
|
- memory: new Map<string, any[]>(),
|
|
|
- skills: new Map<string, any[]>(),
|
|
|
- workflows: new Map<string, any[]>(),
|
|
|
+// Helper: calculate cost
|
|
|
+function calculateCost(provider: string, model: string, inputTokens: number, outputTokens: number, cacheTokens: number = 0): number {
|
|
|
+ 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
|
|
|
+ if (!row) return 0
|
|
|
+ const inputCost = (inputTokens / 1_000_000) * row.input_price_per_1m
|
|
|
+ const outputCost = (outputTokens / 1_000_000) * row.output_price_per_1m
|
|
|
+ const cacheCost = (cacheTokens / 1_000_000) * (row.cache_price_per_1m || 0)
|
|
|
+ return Math.round((inputCost + outputCost + cacheCost) * 1e6) / 1e6
|
|
|
}
|
|
|
|
|
|
const app = new Elysia()
|
|
|
.use(cors())
|
|
|
.use(jwt({ name: "jwt", secret: JWT_SECRET }))
|
|
|
|
|
|
- // Auth middleware
|
|
|
+ // --- Auth Middleware ---
|
|
|
.derive(async ({ jwt, headers, set }) => {
|
|
|
const auth = headers["authorization"]
|
|
|
- if (!auth?.startsWith("Bearer ")) return { userId: null }
|
|
|
+ if (!auth?.startsWith("Bearer ")) return { userId: null, userRole: null, userTeamId: null }
|
|
|
try {
|
|
|
- const payload = await jwt.verify(auth.slice(7))
|
|
|
- return { userId: (payload as any).sub as string }
|
|
|
+ const payload = await jwt.verify(auth.slice(7)) as any
|
|
|
+ const user = db.query("SELECT role, team_id FROM users WHERE id = ?").get(payload.sub) as any
|
|
|
+ return { userId: payload.sub as string, userRole: user?.role || null, userTeamId: user?.team_id || null }
|
|
|
} catch {
|
|
|
- return { userId: null }
|
|
|
+ return { userId: null, userRole: null, userTeamId: null }
|
|
|
}
|
|
|
})
|
|
|
|
|
|
- .guard({ beforeHandle: ({ userId, set }) => {
|
|
|
- if (!userId) { set.status = 401; return "Unauthorized" }
|
|
|
- }})
|
|
|
-
|
|
|
- // === Auth ===
|
|
|
- .post("/api/v1/auth/register", async ({ body }) => {
|
|
|
- const id = uuid()
|
|
|
- const password = await hash(body.password, 10)
|
|
|
- db.users.set(id, { id, email: body.email, password })
|
|
|
+ // --- Public Routes ---
|
|
|
+ .post("/api/v1/auth/register", async ({ body, set }) => {
|
|
|
+ const existing = db.query("SELECT id FROM users WHERE email = ?").get(body.email)
|
|
|
+ if (existing) { set.status = 409; return { error: "Email already registered" } }
|
|
|
+ const id = randomUUID()
|
|
|
+ const passwordHash = await hash(body.password, 10)
|
|
|
+ db.run("INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, ?, ?)", [id, body.email, passwordHash, body.display_name || body.email.split("@")[0]])
|
|
|
const token = await app.decorator.jwt.sign({ sub: id })
|
|
|
- return { token, user: { id, email: body.email } }
|
|
|
- }, { body: t.Object({ email: t.String(), password: t.String() }) })
|
|
|
+ return { token, user: { id, email: body.email, display_name: body.display_name } }
|
|
|
+ }, { body: t.Object({ email: t.String(), password: t.String(), display_name: t.Optional(t.String()) }) })
|
|
|
|
|
|
.post("/api/v1/auth/login", async ({ body, set }) => {
|
|
|
- const user = Array.from(db.users.values()).find(u => u.email === body.email)
|
|
|
- if (!user || !(await compare(body.password, user.password))) {
|
|
|
- set.status = 401
|
|
|
- return { error: "Invalid credentials" }
|
|
|
+ const user = db.query("SELECT id, email, password_hash, display_name, role, team_id FROM users WHERE email = ?").get(body.email) as any
|
|
|
+ if (!user || !(await compare(body.password, user.password_hash))) {
|
|
|
+ set.status = 401; return { error: "Invalid credentials" }
|
|
|
}
|
|
|
const token = await app.decorator.jwt.sign({ sub: user.id })
|
|
|
- return { token, user: { id: user.id, email: user.email } }
|
|
|
+ return { token, user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id } }
|
|
|
}, { body: t.Object({ email: t.String(), password: t.String() }) })
|
|
|
|
|
|
- // === Sessions ===
|
|
|
+ .post("/api/v1/auth/refresh", async ({ jwt, headers }) => {
|
|
|
+ const auth = headers["authorization"]?.slice(7)
|
|
|
+ if (!auth) return { error: "No token" }
|
|
|
+ try {
|
|
|
+ const payload = await jwt.verify(auth) as any
|
|
|
+ const token = await app.decorator.jwt.sign({ sub: payload.sub })
|
|
|
+ return { token }
|
|
|
+ } catch { return { error: "Invalid token" } }
|
|
|
+ })
|
|
|
+
|
|
|
+ // --- Protected Routes ---
|
|
|
+ .guard({ beforeHandle: ({ userId, set }) => { if (!userId) { set.status = 401; return "Unauthorized" } } })
|
|
|
+
|
|
|
+ // --- Sessions ---
|
|
|
.get("/api/v1/sessions", async ({ userId }) => {
|
|
|
- const list = Array.from(db.sessions.values()).filter(s => s.userId === userId)
|
|
|
- return { data: list }
|
|
|
+ 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)
|
|
|
+ return { data: sessions }
|
|
|
})
|
|
|
|
|
|
.post("/api/v1/sessions", async ({ userId, body }) => {
|
|
|
- const session = { id: uuid(), userId, title: body.title || "New Chat", agent: body.agent || "build", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }
|
|
|
- db.sessions.set(session.id, session)
|
|
|
- db.messages.set(session.id, [])
|
|
|
- return { data: session }
|
|
|
+ const id = randomUUID()
|
|
|
+ db.run("INSERT INTO sessions (id, user_id, title, agent) VALUES (?, ?, ?, ?)", [id, userId, body.title || "New Chat", body.agent || "build"])
|
|
|
+ return { data: { id, title: body.title, agent: body.agent } }
|
|
|
}, { body: t.Object({ title: t.Optional(t.String()), agent: t.Optional(t.String()) }) })
|
|
|
|
|
|
- .post("/api/v1/sessions/:id/prompt", async ({ userId, params, body }) => {
|
|
|
- const session = db.sessions.get(params.id)
|
|
|
- if (!session || session.userId !== userId) return { error: "Session not found" }
|
|
|
- const msgs = db.messages.get(params.id) || []
|
|
|
- const userMsg = { id: uuid(), role: "user", content: (body.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n"), createdAt: new Date().toISOString() }
|
|
|
- msgs.push(userMsg)
|
|
|
- // AI response placeholder — in production, call LLM provider
|
|
|
- const aiMsg = { id: uuid(), role: "assistant", content: "[AI response — connect LLM provider]", createdAt: new Date().toISOString() }
|
|
|
- msgs.push(aiMsg)
|
|
|
- db.messages.set(params.id, msgs)
|
|
|
- db.sessions.set(params.id, { ...session, updatedAt: new Date().toISOString() })
|
|
|
- return { data: { messages: msgs } }
|
|
|
+ .post("/api/v1/sessions/:id/prompt", async ({ userId, params, body, set }) => {
|
|
|
+ const session = db.query("SELECT id FROM sessions WHERE id = ? AND user_id = ?").get(params.id, userId)
|
|
|
+ if (!session) { set.status = 404; return { error: "Session not found" } }
|
|
|
+ const msgId = randomUUID()
|
|
|
+ const text = (body.parts || []).filter((p: any) => p.type === "text").map((p: any) => p.text).join("\n")
|
|
|
+ db.run("INSERT INTO messages (id, session_id, role, content) VALUES (?, ?, ?, ?)", [msgId, params.id, "user", text])
|
|
|
+ db.run("UPDATE sessions SET updated_at = datetime('now') WHERE id = ?", [params.id])
|
|
|
+
|
|
|
+ // AI response placeholder — records usage with estimated tokens
|
|
|
+ const inputTokens = Math.ceil(text.length / 4)
|
|
|
+ const outputTokens = Math.ceil(text.length / 2)
|
|
|
+ const provider = body.model?.providerID || "anthropic"
|
|
|
+ const model = body.model?.modelID || "claude-sonnet-4-5"
|
|
|
+ const cost = calculateCost(provider, model, inputTokens, outputTokens)
|
|
|
+
|
|
|
+ const user = db.query("SELECT team_id FROM users WHERE id = ?").get(userId) as any
|
|
|
+ db.run(
|
|
|
+ "INSERT INTO usage_events (user_id, team_id, provider, model, session_id, input_tokens, output_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
+ [userId, user?.team_id || null, provider, model, params.id, inputTokens, outputTokens, cost]
|
|
|
+ )
|
|
|
+
|
|
|
+ const aiMsgId = randomUUID()
|
|
|
+ db.run("INSERT INTO messages (id, session_id, role, content) VALUES (?, ?, ?, ?)", [aiMsgId, params.id, "assistant", `[AI response — ${outputTokens} tokens, $${cost}]`])
|
|
|
+
|
|
|
+ const msgs = db.query("SELECT id, role, content, created_at FROM messages WHERE session_id = ? ORDER BY created_at").all(params.id)
|
|
|
+ return { data: { messages: msgs, usage: { input_tokens: inputTokens, output_tokens: outputTokens, cost_usd: cost } } }
|
|
|
})
|
|
|
|
|
|
.get("/api/v1/sessions/:id/messages", async ({ userId, params }) => {
|
|
|
- const session = db.sessions.get(params.id)
|
|
|
- if (!session || session.userId !== userId) return { error: "Session not found" }
|
|
|
- return { data: db.messages.get(params.id) || [] }
|
|
|
+ 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) }
|
|
|
})
|
|
|
|
|
|
- // === Memory ===
|
|
|
- .get("/api/v1/memory", async ({ userId }) => {
|
|
|
- return { data: db.memory.get(userId!) || [] }
|
|
|
+ // --- Admin: Usage Stats ---
|
|
|
+ .guard({ beforeHandle: ({ userId, userRole, set }) => {
|
|
|
+ if (!userId || !["admin", "team_admin"].includes(userRole || "")) { set.status = 403; return "Forbidden: admin access required" }
|
|
|
+ }})
|
|
|
+
|
|
|
+ .get("/api/v1/admin/usage/team", async ({ userTeamId, userRole, userId, query }) => {
|
|
|
+ const teamId = userRole === "admin" ? query.team_id : userTeamId
|
|
|
+ const from = query.from || "1970-01-01"
|
|
|
+ const to = query.to || "2099-12-31"
|
|
|
+ const rows = db.query(`
|
|
|
+ SELECT date(created_at) as day, provider, model,
|
|
|
+ SUM(input_tokens) as input_tokens,
|
|
|
+ SUM(output_tokens) as output_tokens,
|
|
|
+ COUNT(*) as request_count,
|
|
|
+ SUM(cost_usd) as total_cost
|
|
|
+ FROM usage_events
|
|
|
+ WHERE team_id = ? AND created_at BETWEEN ? AND ?
|
|
|
+ GROUP BY 1, 2, 3
|
|
|
+ ORDER BY 1 DESC
|
|
|
+ `).all(teamId, from, to)
|
|
|
+ return { data: rows }
|
|
|
})
|
|
|
|
|
|
- .post("/api/v1/memory", async ({ userId, body }) => {
|
|
|
- const list = db.memory.get(userId!) || []
|
|
|
- const entry = { id: uuid(), ...body, createdAt: new Date().toISOString() }
|
|
|
- list.push(entry)
|
|
|
- db.memory.set(userId!, list)
|
|
|
- return { data: entry }
|
|
|
+ .get("/api/v1/admin/usage/users", async ({ userTeamId, userRole, query }) => {
|
|
|
+ const teamId = userRole === "admin" ? query.team_id : userTeamId
|
|
|
+ const from = query.from || "1970-01-01"
|
|
|
+ const to = query.to || "2099-12-31"
|
|
|
+ const rows = db.query(`
|
|
|
+ SELECT u.display_name, u.email, ue.user_id,
|
|
|
+ SUM(ue.input_tokens) as input_tokens,
|
|
|
+ SUM(ue.output_tokens) as output_tokens,
|
|
|
+ COUNT(*) as request_count,
|
|
|
+ SUM(ue.cost_usd) as total_cost
|
|
|
+ FROM usage_events ue
|
|
|
+ JOIN users u ON u.id = ue.user_id
|
|
|
+ WHERE ue.team_id = ? AND ue.created_at BETWEEN ? AND ?
|
|
|
+ GROUP BY 1, 2, 3
|
|
|
+ ORDER BY total_cost DESC
|
|
|
+ `).all(teamId, from, to)
|
|
|
+ return { data: rows }
|
|
|
})
|
|
|
|
|
|
- // === Skills ===
|
|
|
- .get("/api/v1/skills", async ({ userId }) => {
|
|
|
- return { data: db.skills.get(userId!) || [] }
|
|
|
+ .get("/api/v1/admin/usage/minute", async ({ userTeamId, userRole, userId, query }) => {
|
|
|
+ const teamId = userRole === "admin" ? query.team_id : userTeamId
|
|
|
+ const targetUser = query.user_id || userId
|
|
|
+ const from = query.from || "1970-01-01"
|
|
|
+ const to = query.to || "2099-12-31"
|
|
|
+ const rows = db.query(`
|
|
|
+ SELECT created_at as minute,
|
|
|
+ input_tokens, output_tokens, provider, model, cost_usd
|
|
|
+ FROM usage_events
|
|
|
+ WHERE user_id = ? AND team_id = ? AND created_at BETWEEN ? AND ?
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT 1000
|
|
|
+ `).all(targetUser, teamId, from, to)
|
|
|
+ return { data: rows }
|
|
|
})
|
|
|
|
|
|
- .post("/api/v1/skills", async ({ userId, body }) => {
|
|
|
- const list = db.skills.get(userId!) || []
|
|
|
- const skill = { id: uuid(), ...body, createdAt: new Date().toISOString() }
|
|
|
- list.push(skill)
|
|
|
- db.skills.set(userId!, list)
|
|
|
- return { data: skill }
|
|
|
+ .get("/api/v1/admin/usage/today", async ({ userTeamId, userRole }) => {
|
|
|
+ const teamId = userTeamId
|
|
|
+ const rows = db.query(`
|
|
|
+ SELECT
|
|
|
+ SUM(input_tokens) as input_tokens,
|
|
|
+ SUM(output_tokens) as output_tokens,
|
|
|
+ COUNT(*) as request_count,
|
|
|
+ SUM(cost_usd) as total_cost,
|
|
|
+ COUNT(DISTINCT user_id) as active_users
|
|
|
+ FROM usage_events
|
|
|
+ WHERE team_id = ? AND date(created_at) = date('now')
|
|
|
+ `).get(teamId)
|
|
|
+ return { data: rows }
|
|
|
})
|
|
|
|
|
|
- // === Workflows ===
|
|
|
- .get("/api/v1/workflows", async ({ userId }) => {
|
|
|
- return { data: db.workflows.get(userId!) || [] }
|
|
|
+ .get("/api/v1/admin/teams", async ({ userRole, set }) => {
|
|
|
+ if (userRole !== "admin") { set.status = 403; return { error: "Admin only" } }
|
|
|
+ return { data: db.query("SELECT id, name, owner_id, created_at FROM teams ORDER BY created_at").all() }
|
|
|
})
|
|
|
|
|
|
- .post("/api/v1/workflows", async ({ userId, body }) => {
|
|
|
- const list = db.workflows.get(userId!) || []
|
|
|
- const wf = { id: uuid(), ...body, createdAt: new Date().toISOString() }
|
|
|
- list.push(wf)
|
|
|
- db.workflows.set(userId!, list)
|
|
|
- return { data: wf }
|
|
|
+ .post("/api/v1/admin/teams", async ({ userRole, body, set }) => {
|
|
|
+ if (userRole !== "admin") { set.status = 403; return { error: "Admin only" } }
|
|
|
+ const id = randomUUID()
|
|
|
+ db.run("INSERT INTO teams (id, name, owner_id) VALUES (?, ?, ?)", [id, body.name, body.owner_id])
|
|
|
+ db.run("INSERT INTO team_members (team_id, user_id, role) VALUES (?, ?, 'team_admin')", [id, body.owner_id])
|
|
|
+ db.run("UPDATE users SET team_id = ?, role = 'team_admin' WHERE id = ?", [id, body.owner_id])
|
|
|
+ return { data: { id, name: body.name } }
|
|
|
+ }, { body: t.Object({ name: t.String(), owner_id: t.String() }) })
|
|
|
+
|
|
|
+ // --- Memory ---
|
|
|
+ .get("/api/v1/memory", async ({ userId }) => {
|
|
|
+ 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)
|
|
|
+ return { data: rows }
|
|
|
+ })
|
|
|
+
|
|
|
+ .post("/api/v1/memory", async ({ userId, body }) => {
|
|
|
+ if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='memory_entries'").get()) {
|
|
|
+ db.run("CREATE TABLE memory_entries (id TEXT PRIMARY KEY, user_id TEXT, category TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
|
|
|
+ }
|
|
|
+ const id = randomUUID()
|
|
|
+ db.run("INSERT INTO memory_entries (id, user_id, category, content) VALUES (?, ?, ?, ?)", [id, userId, body.category, body.content])
|
|
|
+ return { data: { id, category: body.category } }
|
|
|
+ })
|
|
|
+
|
|
|
+ // --- Skills ---
|
|
|
+ .get("/api/v1/skills", async ({ userId }) => {
|
|
|
+ if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='skills'").get()) {
|
|
|
+ db.run("CREATE TABLE skills (id TEXT PRIMARY KEY, user_id TEXT, name TEXT, description TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
|
|
|
+ }
|
|
|
+ return { data: db.query("SELECT id, name, description, created_at FROM skills WHERE user_id = ? ORDER BY created_at DESC").all(userId) }
|
|
|
+ })
|
|
|
+
|
|
|
+ .post("/api/v1/skills", async ({ userId, body }) => {
|
|
|
+ if (!db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='skills'").get()) {
|
|
|
+ db.run("CREATE TABLE skills (id TEXT PRIMARY KEY, user_id TEXT, name TEXT, description TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now')))")
|
|
|
+ }
|
|
|
+ const id = randomUUID()
|
|
|
+ db.run("INSERT INTO skills (id, user_id, name, description, content) VALUES (?, ?, ?, ?, ?)", [id, userId, body.name, body.description, body.content])
|
|
|
+ return { data: { id, name: body.name } }
|
|
|
})
|
|
|
|
|
|
- // === Health ===
|
|
|
- .get("/api/v1/health", () => ({ status: "ok", version: "0.4.0" }))
|
|
|
+ // --- Health ---
|
|
|
+ .get("/api/v1/health", () => ({
|
|
|
+ status: "ok",
|
|
|
+ version: "0.4.0",
|
|
|
+ db: db.query("SELECT COUNT(*) as users FROM users").get(),
|
|
|
+ }))
|
|
|
|
|
|
.listen(PORT)
|
|
|
|
|
|
-console.log(`🦄 KirinCode Cloud Server running on http://localhost:${PORT}`)
|
|
|
+console.log(`🦄 KirinCode Cloud Server v0.4.0 — http://localhost:${PORT}`)
|
|
|
+console.log(` DB: ${DB_PATH} (${db.query("SELECT COUNT(*) as c FROM users").get() || 0} users)`)
|
|
|
console.log(` API: http://localhost:${PORT}/api/v1/health`)
|
|
|
|
|
|
export type App = typeof app
|