kcg-code 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/README.md +230 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +84 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/daemon.test.ts +84 -0
  13. package/src/cli/__tests__/dashboard.test.ts +224 -0
  14. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  15. package/src/cli/__tests__/port.test.ts +86 -0
  16. package/src/cli/args.ts +186 -0
  17. package/src/cli/attach.ts +154 -0
  18. package/src/cli/commands.ts +327 -0
  19. package/src/cli/daemon.ts +275 -0
  20. package/src/cli/dashboard.tsx +1060 -0
  21. package/src/cli/index.ts +47 -0
  22. package/src/cli/keep-awake.ts +148 -0
  23. package/src/cli/port.ts +92 -0
  24. package/src/cli/theme.ts +100 -0
  25. package/src/components/auth/AuthGate.tsx +182 -0
  26. package/src/components/auth/AvatarEditor.tsx +236 -0
  27. package/src/components/auth/LockScreen.tsx +714 -0
  28. package/src/components/auth/ProfileAvatar.tsx +60 -0
  29. package/src/components/auth/WelcomeScreen.tsx +672 -0
  30. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  31. package/src/components/auth/avatar-icons.tsx +114 -0
  32. package/src/components/layout/AppShell.tsx +147 -0
  33. package/src/components/layout/AppSidebar.tsx +419 -0
  34. package/src/components/layout/SearchDialog.tsx +189 -0
  35. package/src/components/layout/SidebarAccount.tsx +111 -0
  36. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  37. package/src/components/layout/ThemeToggle.tsx +67 -0
  38. package/src/components/projects/FolderBrowser.tsx +142 -0
  39. package/src/components/projects/HomeChat.tsx +287 -0
  40. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  41. package/src/components/projects/NewProjectDialog.tsx +249 -0
  42. package/src/components/projects/ProjectInstructions.tsx +218 -0
  43. package/src/components/projects/ProjectRow.tsx +58 -0
  44. package/src/components/projects/ProjectSidePanel.tsx +514 -0
  45. package/src/components/sessions/AgentLogo.tsx +42 -0
  46. package/src/components/sessions/AgentModeList.tsx +265 -0
  47. package/src/components/sessions/AgentPicker.tsx +132 -0
  48. package/src/components/sessions/AssistantTurn.tsx +145 -0
  49. package/src/components/sessions/AttachmentTile.tsx +161 -0
  50. package/src/components/sessions/CodeBlock.tsx +107 -0
  51. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  52. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  53. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
  54. package/src/components/sessions/ContextPanel.tsx +661 -0
  55. package/src/components/sessions/DropPlate.tsx +165 -0
  56. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  57. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  58. package/src/components/sessions/ImageLightbox.tsx +173 -0
  59. package/src/components/sessions/MarkdownContent.tsx +187 -0
  60. package/src/components/sessions/Mascot.tsx +47 -0
  61. package/src/components/sessions/MessageActions.tsx +60 -0
  62. package/src/components/sessions/MessageRail.tsx +121 -0
  63. package/src/components/sessions/ModelPicker.tsx +247 -0
  64. package/src/components/sessions/ModelSearchList.tsx +231 -0
  65. package/src/components/sessions/NewSessionDialog.tsx +172 -0
  66. package/src/components/sessions/PendingAttachments.tsx +127 -0
  67. package/src/components/sessions/PromptCard.tsx +1085 -0
  68. package/src/components/sessions/PromptPanel.tsx +112 -0
  69. package/src/components/sessions/SessionComposer.tsx +641 -0
  70. package/src/components/sessions/SessionHeader.tsx +176 -0
  71. package/src/components/sessions/SessionList.tsx +503 -0
  72. package/src/components/sessions/SessionRow.tsx +237 -0
  73. package/src/components/sessions/SessionTimeline.tsx +275 -0
  74. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  75. package/src/components/sessions/SessionView.tsx +240 -0
  76. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  77. package/src/components/sessions/UserMessage.tsx +177 -0
  78. package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
  79. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  80. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  81. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  82. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  83. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  84. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  85. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  86. package/src/components/settings/AboutGroup.tsx +170 -0
  87. package/src/components/settings/AccountGroup.tsx +122 -0
  88. package/src/components/settings/DevicesGroup.tsx +151 -0
  89. package/src/components/settings/LhrRemoteAccess.tsx +257 -0
  90. package/src/components/settings/PasskeysGroup.tsx +551 -0
  91. package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
  92. package/src/components/settings/SecurityGroup.tsx +566 -0
  93. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  94. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  95. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  96. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  97. package/src/components/settings/prefs.tsx +333 -0
  98. package/src/components/skills/InstallNotifications.tsx +326 -0
  99. package/src/components/skills/InstallSkillDialog.tsx +390 -0
  100. package/src/components/skills/SkillCards.tsx +254 -0
  101. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  102. package/src/components/ui/adw-dialog.tsx +114 -0
  103. package/src/components/ui/adw-toggle-group.tsx +75 -0
  104. package/src/components/ui/alert-dialog.tsx +179 -0
  105. package/src/components/ui/alert.tsx +60 -0
  106. package/src/components/ui/badge.tsx +46 -0
  107. package/src/components/ui/bubble.tsx +125 -0
  108. package/src/components/ui/button.tsx +62 -0
  109. package/src/components/ui/card.tsx +75 -0
  110. package/src/components/ui/collapsible.tsx +19 -0
  111. package/src/components/ui/dialog.tsx +142 -0
  112. package/src/components/ui/dropdown-menu.tsx +225 -0
  113. package/src/components/ui/empty.tsx +94 -0
  114. package/src/components/ui/field.tsx +234 -0
  115. package/src/components/ui/input-group.tsx +161 -0
  116. package/src/components/ui/input.tsx +21 -0
  117. package/src/components/ui/label.tsx +19 -0
  118. package/src/components/ui/message-scroller.tsx +131 -0
  119. package/src/components/ui/message.tsx +85 -0
  120. package/src/components/ui/popover.tsx +46 -0
  121. package/src/components/ui/search-entry.tsx +70 -0
  122. package/src/components/ui/select.tsx +172 -0
  123. package/src/components/ui/separator.tsx +28 -0
  124. package/src/components/ui/sheet.tsx +138 -0
  125. package/src/components/ui/sidebar.tsx +699 -0
  126. package/src/components/ui/skeleton.tsx +15 -0
  127. package/src/components/ui/sonner.tsx +45 -0
  128. package/src/components/ui/spinner.tsx +16 -0
  129. package/src/components/ui/switch.tsx +32 -0
  130. package/src/components/ui/textarea.tsx +18 -0
  131. package/src/components/ui/tooltip.tsx +51 -0
  132. package/src/config.ts +114 -0
  133. package/src/db.ts +146 -0
  134. package/src/frontend.tsx +51 -0
  135. package/src/hooks/__tests__/useMention.test.ts +79 -0
  136. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  137. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  138. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  139. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  140. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  141. package/src/hooks/use-mobile.ts +16 -0
  142. package/src/hooks/useAgentPicker.ts +101 -0
  143. package/src/hooks/useMediaQuery.ts +49 -0
  144. package/src/hooks/useMention.ts +236 -0
  145. package/src/hooks/useProjects.ts +88 -0
  146. package/src/hooks/useResyncOnReconnect.ts +28 -0
  147. package/src/hooks/useRouter.ts +92 -0
  148. package/src/hooks/useSessionChat.ts +427 -0
  149. package/src/hooks/useSessionContext.ts +106 -0
  150. package/src/hooks/useSessionList.ts +327 -0
  151. package/src/hooks/useSidebarData.ts +108 -0
  152. package/src/hooks/useSmoothText.ts +122 -0
  153. package/src/hooks/useTheme.ts +164 -0
  154. package/src/hooks/useWebSocket.ts +196 -0
  155. package/src/index.css +27 -0
  156. package/src/index.html +14 -0
  157. package/src/index.ts +49 -0
  158. package/src/lib/__tests__/attachments.test.ts +58 -0
  159. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  160. package/src/lib/__tests__/collapsible.test.ts +101 -0
  161. package/src/lib/__tests__/composer.test.ts +118 -0
  162. package/src/lib/__tests__/highlight.test.ts +42 -0
  163. package/src/lib/__tests__/home-chat.test.ts +64 -0
  164. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  165. package/src/lib/__tests__/model-picker.test.ts +205 -0
  166. package/src/lib/__tests__/onboarding.test.ts +41 -0
  167. package/src/lib/__tests__/permission-info.test.ts +61 -0
  168. package/src/lib/__tests__/project-extensions.test.ts +83 -0
  169. package/src/lib/__tests__/project-overview.test.ts +188 -0
  170. package/src/lib/__tests__/references.test.ts +121 -0
  171. package/src/lib/__tests__/routes.test.ts +134 -0
  172. package/src/lib/__tests__/session-selection.test.ts +41 -0
  173. package/src/lib/__tests__/session-summary.test.ts +115 -0
  174. package/src/lib/__tests__/session-title.test.ts +58 -0
  175. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  176. package/src/lib/__tests__/skill-installs.test.ts +135 -0
  177. package/src/lib/__tests__/skills-catalog.test.ts +124 -0
  178. package/src/lib/__tests__/tone.test.ts +37 -0
  179. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  180. package/src/lib/agent-types.ts +49 -0
  181. package/src/lib/api.ts +306 -0
  182. package/src/lib/attachments.ts +203 -0
  183. package/src/lib/auth.ts +92 -0
  184. package/src/lib/avatar-presets.ts +31 -0
  185. package/src/lib/collapsible.ts +75 -0
  186. package/src/lib/composer.ts +89 -0
  187. package/src/lib/data-events.ts +17 -0
  188. package/src/lib/headlines.ts +30 -0
  189. package/src/lib/highlight.ts +186 -0
  190. package/src/lib/layout.ts +20 -0
  191. package/src/lib/lock-screen.ts +54 -0
  192. package/src/lib/model-picker.ts +133 -0
  193. package/src/lib/onboarding.ts +38 -0
  194. package/src/lib/passkeys.ts +113 -0
  195. package/src/lib/pending-prompt.ts +53 -0
  196. package/src/lib/permission-info.ts +101 -0
  197. package/src/lib/project-extensions.ts +136 -0
  198. package/src/lib/project-overview.ts +123 -0
  199. package/src/lib/prompts.ts +87 -0
  200. package/src/lib/references.ts +106 -0
  201. package/src/lib/routes.ts +124 -0
  202. package/src/lib/session-handoff.ts +25 -0
  203. package/src/lib/session-selection.ts +40 -0
  204. package/src/lib/session-status.ts +45 -0
  205. package/src/lib/session-summary.ts +68 -0
  206. package/src/lib/session-title.ts +63 -0
  207. package/src/lib/sidebar-groups.ts +78 -0
  208. package/src/lib/skill-installs.ts +362 -0
  209. package/src/lib/skills-catalog.ts +138 -0
  210. package/src/lib/time.ts +22 -0
  211. package/src/lib/tone.ts +80 -0
  212. package/src/lib/turns.ts +320 -0
  213. package/src/lib/utils.ts +6 -0
  214. package/src/logo.svg +31 -0
  215. package/src/pages/projects/ProjectDetailPage.tsx +131 -0
  216. package/src/pages/projects/ProjectsPage.tsx +97 -0
  217. package/src/pages/sessions/SessionPage.tsx +122 -0
  218. package/src/pages/settings/SettingsPage.tsx +366 -0
  219. package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
  220. package/src/pages/skills/SkillsPage.tsx +745 -0
  221. package/src/paths.ts +121 -0
  222. package/src/runtime.ts +24 -0
  223. package/src/server/__tests__/app.test.ts +1044 -0
  224. package/src/server/__tests__/auth.test.ts +370 -0
  225. package/src/server/__tests__/config.test.ts +101 -0
  226. package/src/server/__tests__/db.test.ts +485 -0
  227. package/src/server/__tests__/host.test.ts +14 -0
  228. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  229. package/src/server/app.ts +427 -0
  230. package/src/server/db/auth.ts +311 -0
  231. package/src/server/db/messages.ts +50 -0
  232. package/src/server/db/projects.ts +106 -0
  233. package/src/server/db/prompts.ts +78 -0
  234. package/src/server/db/rows.ts +150 -0
  235. package/src/server/db/schema.ts +221 -0
  236. package/src/server/db/sessions.ts +239 -0
  237. package/src/server/db/tunnel.ts +124 -0
  238. package/src/server/host.ts +12 -0
  239. package/src/server/result.ts +15 -0
  240. package/src/server/routes/auth-guard.ts +156 -0
  241. package/src/server/routes/auth.routes.ts +287 -0
  242. package/src/server/routes/helpers.ts +165 -0
  243. package/src/server/routes/meta.routes.ts +24 -0
  244. package/src/server/routes/passkeys.routes.ts +161 -0
  245. package/src/server/routes/projects.routes.ts +167 -0
  246. package/src/server/routes/sessions.routes.ts +197 -0
  247. package/src/server/routes/skills.routes.ts +136 -0
  248. package/src/server/routes/tunnel.routes.ts +132 -0
  249. package/src/server/routes/types.ts +35 -0
  250. package/src/server/routes/uploads.routes.ts +54 -0
  251. package/src/server/routes/workspace.routes.ts +127 -0
  252. package/src/server/services/__tests__/app-info.test.ts +18 -0
  253. package/src/server/services/__tests__/attachments.test.ts +159 -0
  254. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  255. package/src/server/services/__tests__/auth.test.ts +341 -0
  256. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  257. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  258. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  259. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  260. package/src/server/services/__tests__/git-info.test.ts +74 -0
  261. package/src/server/services/__tests__/opencode.test.ts +320 -0
  262. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  263. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  264. package/src/server/services/__tests__/session-manager.test.ts +3509 -0
  265. package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
  266. package/src/server/services/__tests__/skills-registry.test.ts +365 -0
  267. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  268. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  269. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  270. package/src/server/services/__tests__/workspace.test.ts +249 -0
  271. package/src/server/services/app-info.ts +29 -0
  272. package/src/server/services/attachments.ts +193 -0
  273. package/src/server/services/auth-passkeys.ts +459 -0
  274. package/src/server/services/auth.ts +633 -0
  275. package/src/server/services/claude-code-client.ts +1093 -0
  276. package/src/server/services/claude-code-translate.ts +370 -0
  277. package/src/server/services/claude-mcp.ts +122 -0
  278. package/src/server/services/claude-skills.ts +102 -0
  279. package/src/server/services/frpc-installer.ts +183 -0
  280. package/src/server/services/git-info.ts +87 -0
  281. package/src/server/services/opencode-client.ts +714 -0
  282. package/src/server/services/opencode-server.ts +319 -0
  283. package/src/server/services/opencode-sse.ts +82 -0
  284. package/src/server/services/project-manager.ts +147 -0
  285. package/src/server/services/sandbox.ts +83 -0
  286. package/src/server/services/session-events.ts +256 -0
  287. package/src/server/services/session-manager.ts +1439 -0
  288. package/src/server/services/skill-install-jobs.ts +275 -0
  289. package/src/server/services/skills-registry.ts +480 -0
  290. package/src/server/services/tunnel-api.ts +207 -0
  291. package/src/server/services/tunnel-config.ts +45 -0
  292. package/src/server/services/tunnel-lhr.ts +387 -0
  293. package/src/server/services/tunnel.ts +633 -0
  294. package/src/server/services/turn-stream.ts +339 -0
  295. package/src/server/services/websocket-gateway.ts +374 -0
  296. package/src/server/services/workspace.ts +223 -0
  297. package/src/server/services/ws-transport.ts +121 -0
  298. package/src/types/context.ts +69 -0
  299. package/src/types/index.ts +13 -0
  300. package/src/types/message.ts +58 -0
  301. package/src/types/project.ts +16 -0
  302. package/src/types/prompt.ts +118 -0
  303. package/src/types/session.ts +52 -0
  304. package/src/types/turns.ts +47 -0
  305. package/src/ws-protocol.ts +93 -0
  306. package/styles/globals.css +311 -0
  307. package/tsconfig.json +37 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Penjaga HTTP untuk kunci aplikasi: baca cookie sesi, cek Origin (anti
3
+ * CSRF / cross-site WebSocket hijacking), dan bungkus setiap handler rute
4
+ * `/api/*` agar menolak request tanpa sesi valid (401).
5
+ *
6
+ * Rute publik (lock screen butuh ini sebelum login): status, login, avatar,
7
+ * dan login passkey.
8
+ */
9
+ import type { Server } from "bun";
10
+ import type { AuthService } from "../services/auth";
11
+ import { SESSION_COOKIE, SESSION_TTL_MS } from "../services/auth";
12
+ import { json } from "./helpers";
13
+
14
+ /**
15
+ * Rute + method yang boleh diakses tanpa login (dibutuhkan lock screen).
16
+ * Per method: `GET /api/auth/avatar` publik, tapi `PUT`/`DELETE`-nya tidak.
17
+ */
18
+ export const PUBLIC_API: ReadonlyMap<string, ReadonlySet<string>> = new Map([
19
+ ["/api/auth/status", new Set(["GET"])],
20
+ ["/api/auth/login", new Set(["POST"])],
21
+ ["/api/auth/avatar", new Set(["GET"])],
22
+ // Lock screen: cek ketersediaan passkey & login dengan passkey.
23
+ ["/api/auth/passkeys/availability", new Set(["GET"])],
24
+ ["/api/auth/passkeys/login/options", new Set(["POST"])],
25
+ ["/api/auth/passkeys/login", new Set(["POST"])],
26
+ ]);
27
+
28
+ export function isPublicApi(route: string, method: string): boolean {
29
+ return PUBLIC_API.get(route)?.has(method) ?? false;
30
+ }
31
+
32
+ export function readSessionToken(req: Request): string | null {
33
+ const header = req.headers.get("cookie");
34
+ if (!header) return null;
35
+ for (const part of header.split(";")) {
36
+ const idx = part.indexOf("=");
37
+ if (idx === -1) continue;
38
+ if (part.slice(0, idx).trim() === SESSION_COOKIE) {
39
+ const v = part.slice(idx + 1).trim();
40
+ return v === "" ? null : decodeURIComponent(v);
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** Request datang lewat HTTPS (langsung atau lewat tunnel/proxy). */
47
+ function isSecure(req: Request): boolean {
48
+ if (new URL(req.url).protocol === "https:") return true;
49
+ return req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
50
+ }
51
+
52
+ export function sessionCookie(req: Request, token: string): string {
53
+ const attrs = [
54
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
55
+ "Path=/",
56
+ "HttpOnly",
57
+ "SameSite=Strict",
58
+ `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
59
+ ];
60
+ if (isSecure(req)) attrs.push("Secure");
61
+ return attrs.join("; ");
62
+ }
63
+
64
+ export function clearSessionCookie(req: Request): string {
65
+ const attrs = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"];
66
+ if (isSecure(req)) attrs.push("Secure");
67
+ return attrs.join("; ");
68
+ }
69
+
70
+ /**
71
+ * Origin harus sama dengan host tujuan untuk request yang mengubah state
72
+ * dan untuk upgrade WebSocket. Cookie `SameSite=Strict` sudah menahan
73
+ * sebagian besar CSRF; ini lapis kedua (juga menutup WebSocket lintas situs,
74
+ * yang tidak dibatasi CORS).
75
+ */
76
+ export function originAllowed(req: Request): boolean {
77
+ const origin = req.headers.get("origin");
78
+ // Tanpa Origin (curl, request same-origin GET lama) -> tidak ada konteks
79
+ // lintas situs; cookie tetap wajib valid.
80
+ if (origin === null) return true;
81
+ let originHost: string;
82
+ try {
83
+ originHost = new URL(origin).host;
84
+ } catch {
85
+ return false;
86
+ }
87
+ const hosts = new Set<string>();
88
+ const host = req.headers.get("host");
89
+ if (host) hosts.add(host);
90
+ const fwd = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
91
+ if (fwd) hosts.add(fwd);
92
+ hosts.add(new URL(req.url).host);
93
+ return hosts.has(originHost);
94
+ }
95
+
96
+ /** IP klien untuk pembatasan percobaan (tunnel biasanya lewat localhost). */
97
+ export function clientIp(req: Request, server: Server<unknown> | undefined): string {
98
+ const direct = server?.requestIP(req)?.address ?? "unknown";
99
+ // Header proxy hanya dipercaya bila koneksi datang dari mesin ini (tunnel
100
+ // lokal seperti cloudflared/ngrok) — dari luar, header mudah dipalsukan.
101
+ const loopback = direct === "127.0.0.1" || direct === "::1" || direct === "::ffff:127.0.0.1";
102
+ if (loopback) {
103
+ const fwd =
104
+ req.headers.get("cf-connecting-ip") ??
105
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
106
+ req.headers.get("x-real-ip");
107
+ if (fwd) return fwd.slice(0, 64);
108
+ }
109
+ return direct;
110
+ }
111
+
112
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
113
+
114
+ type AnyHandler = (req: Request, server: Server<unknown>) => unknown;
115
+
116
+ /**
117
+ * Bungkus satu handler: cek Origin untuk method yang mengubah state, lalu
118
+ * wajibkan sesi valid (kecuali rute publik / kunci belum diatur).
119
+ */
120
+ export function guardHandler(auth: AuthService, route: string, handler: AnyHandler): AnyHandler {
121
+ return (req, server) => {
122
+ if (MUTATING.has(req.method) && !originAllowed(req)) {
123
+ return json({ error: "ORIGIN_FORBIDDEN" }, 403);
124
+ }
125
+ if (!isPublicApi(route, req.method) && auth.isProtected()) {
126
+ if (!auth.authenticate(readSessionToken(req))) {
127
+ return json({ error: "AUTH_REQUIRED" }, 401);
128
+ }
129
+ }
130
+ return handler(req, server);
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Terapkan `guardHandler` ke seluruh tabel rute `/api/*` (handler tunggal
136
+ * maupun per-method). Rute non-API dikembalikan apa adanya.
137
+ */
138
+ export function guardRoutes<T extends Record<string, unknown>>(auth: AuthService, routes: T): T {
139
+ const out: Record<string, unknown> = {};
140
+ for (const [path, value] of Object.entries(routes)) {
141
+ if (!path.startsWith("/api/")) {
142
+ out[path] = value;
143
+ } else if (typeof value === "function") {
144
+ out[path] = guardHandler(auth, path, value as AnyHandler);
145
+ } else if (value && typeof value === "object" && !(value instanceof Response)) {
146
+ const methods: Record<string, unknown> = {};
147
+ for (const [m, h] of Object.entries(value as Record<string, unknown>)) {
148
+ methods[m] = typeof h === "function" ? guardHandler(auth, path, h as AnyHandler) : h;
149
+ }
150
+ out[path] = methods;
151
+ } else {
152
+ out[path] = value;
153
+ }
154
+ }
155
+ return out as T;
156
+ }
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Tabel rute API kunci aplikasi (lock screen) & pengaturan keamanan.
3
+ *
4
+ * Publik (dibutuhkan lock screen sebelum login):
5
+ * - `GET /api/auth/status` status kunci + profil + sisa jeda
6
+ * - `POST /api/auth/login` {secret} buka kunci -> cookie sesi
7
+ * - `GET /api/auth/avatar` foto profil
8
+ *
9
+ * Wajib login (bila kunci sudah diatur) — dijaga `guardRoutes`:
10
+ * - `POST /api/auth/logout` kunci perangkat ini (hapus sesi)
11
+ * - `PUT /api/auth/lock` atur/ganti kunci {kind, secret, current?}
12
+ * - `DELETE /api/auth/lock` hapus kunci {current}
13
+ * - `PATCH /api/auth/profile` {nickname}
14
+ * - `PUT /api/auth/avatar` multipart `file` | `DELETE` hapus
15
+ * - `PUT /api/auth/avatar/preset` {preset} avatar bawaan
16
+ * - `PATCH /api/auth/settings` {autoLockMinutes}
17
+ * - `POST /api/auth/onboarding/complete` welcome screen selesai
18
+ * - `GET /api/auth/devices` sesi aktif per perangkat
19
+ * - `POST /api/auth/devices/revoke-others`
20
+ * - `DELETE /api/auth/devices/:id`
21
+ */
22
+ import type { BunRequest, Server } from "bun";
23
+ import type { LockKind } from "../db/auth";
24
+ import type { AuthService } from "../services/auth";
25
+ import { clearSessionCookie, clientIp, readSessionToken, sessionCookie } from "./auth-guard";
26
+ import { errorStatus, json, readJson, serverError } from "./helpers";
27
+
28
+ /** Kode rate-limit membawa detik tunggu: `AUTH_RATE_LIMITED:<sec>`. */
29
+ function rateLimited(code: string): number | null {
30
+ if (!code.startsWith("AUTH_RATE_LIMITED:")) return null;
31
+ const n = Number(code.slice("AUTH_RATE_LIMITED:".length));
32
+ return Number.isFinite(n) ? n : null;
33
+ }
34
+
35
+ function authError(code: string): Response {
36
+ const wait = rateLimited(code);
37
+ if (wait !== null) {
38
+ return new Response(JSON.stringify({ error: "AUTH_RATE_LIMITED", retryAfterSec: wait }), {
39
+ status: 429,
40
+ headers: { "content-type": "application/json", "retry-after": String(wait) },
41
+ });
42
+ }
43
+ return json({ error: code }, errorStatus(code));
44
+ }
45
+
46
+ function withCookie(res: Response, cookie: string): Response {
47
+ res.headers.append("set-cookie", cookie);
48
+ return res;
49
+ }
50
+
51
+ export function authRoutes(auth: AuthService) {
52
+ const ctxOf = (req: Request, server: Server<unknown>) => ({
53
+ ip: clientIp(req, server),
54
+ userAgent: req.headers.get("user-agent"),
55
+ });
56
+
57
+ return {
58
+ "/api/auth/status": {
59
+ GET: (req: Request, server: Server<unknown>) => {
60
+ try {
61
+ const st = auth.status(readSessionToken(req), clientIp(req, server));
62
+ // Sesi kedaluwarsa / terkunci otomatis -> bersihkan cookie basi.
63
+ const res = json(st);
64
+ if (st.protected && !st.authenticated && readSessionToken(req)) {
65
+ return withCookie(res, clearSessionCookie(req));
66
+ }
67
+ return res;
68
+ } catch (e) {
69
+ return serverError(e);
70
+ }
71
+ },
72
+ },
73
+
74
+ "/api/auth/login": {
75
+ POST: async (req: Request, server: Server<unknown>) => {
76
+ try {
77
+ const body = await readJson(req);
78
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
79
+ const { secret } = (body.data ?? {}) as { secret?: unknown };
80
+ const res = await auth.login(
81
+ typeof secret === "string" ? secret : "",
82
+ ctxOf(req, server),
83
+ );
84
+ if (!res.ok) return authError(res.error);
85
+ return withCookie(json({ ok: true }), sessionCookie(req, res.data.token));
86
+ } catch (e) {
87
+ return serverError(e);
88
+ }
89
+ },
90
+ },
91
+
92
+ "/api/auth/logout": {
93
+ POST: (req: Request) => {
94
+ try {
95
+ auth.logout(readSessionToken(req));
96
+ return withCookie(json({ ok: true }), clearSessionCookie(req));
97
+ } catch (e) {
98
+ return serverError(e);
99
+ }
100
+ },
101
+ },
102
+
103
+ "/api/auth/lock": {
104
+ PUT: async (req: Request, server: Server<unknown>) => {
105
+ try {
106
+ const body = await readJson(req);
107
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
108
+ const { kind, secret, current } = (body.data ?? {}) as {
109
+ kind?: unknown;
110
+ secret?: unknown;
111
+ current?: unknown;
112
+ };
113
+ if ((kind !== "pin" && kind !== "password") || typeof secret !== "string") {
114
+ return json({ error: "LOCK_KIND_INVALID" }, 400);
115
+ }
116
+ const res = await auth.setLock(
117
+ {
118
+ kind: kind as LockKind,
119
+ secret,
120
+ current: typeof current === "string" ? current : undefined,
121
+ },
122
+ ctxOf(req, server),
123
+ );
124
+ if (!res.ok) return authError(res.error);
125
+ // Perangkat yang mengatur kunci langsung login dengan sesi baru.
126
+ return withCookie(json({ ok: true }), sessionCookie(req, res.data.token));
127
+ } catch (e) {
128
+ return serverError(e);
129
+ }
130
+ },
131
+ DELETE: async (req: Request, server: Server<unknown>) => {
132
+ try {
133
+ const body = await readJson(req);
134
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
135
+ const { current } = (body.data ?? {}) as { current?: unknown };
136
+ const res = await auth.removeLock(
137
+ typeof current === "string" ? current : "",
138
+ ctxOf(req, server),
139
+ );
140
+ if (!res.ok) return authError(res.error);
141
+ return withCookie(json({ ok: true }), clearSessionCookie(req));
142
+ } catch (e) {
143
+ return serverError(e);
144
+ }
145
+ },
146
+ },
147
+
148
+ "/api/auth/profile": {
149
+ PATCH: async (req: Request) => {
150
+ try {
151
+ const body = await readJson(req);
152
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
153
+ const { nickname } = (body.data ?? {}) as { nickname?: unknown };
154
+ if (nickname !== null && typeof nickname !== "string") {
155
+ return json({ error: "NICKNAME_INVALID" }, 400);
156
+ }
157
+ const res = auth.setNickname(nickname ?? null);
158
+ if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
159
+ return json({ profile: res.data });
160
+ } catch (e) {
161
+ return serverError(e);
162
+ }
163
+ },
164
+ },
165
+
166
+ "/api/auth/avatar": {
167
+ GET: () => {
168
+ try {
169
+ const av = auth.getAvatar();
170
+ if (!av) return json({ error: "AVATAR_NOT_FOUND" }, 404);
171
+ return new Response(new Blob([new Uint8Array(av.bytes)]), {
172
+ headers: {
173
+ "content-type": av.mime,
174
+ // URL memakai ?v=<versi>, jadi aman di-cache lama.
175
+ "cache-control": "private, max-age=31536000, immutable",
176
+ "x-content-type-options": "nosniff",
177
+ "content-security-policy": "default-src 'none'",
178
+ },
179
+ });
180
+ } catch (e) {
181
+ return serverError(e);
182
+ }
183
+ },
184
+ PUT: async (req: Request) => {
185
+ try {
186
+ let form: FormData;
187
+ try {
188
+ form = await req.formData();
189
+ } catch {
190
+ return json({ error: "EMPTY_UPLOAD" }, 400);
191
+ }
192
+ const file = form.get("file");
193
+ if (!(file instanceof File)) return json({ error: "EMPTY_UPLOAD" }, 400);
194
+ const res = auth.setAvatar(new Uint8Array(await file.arrayBuffer()), file.type || null);
195
+ if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
196
+ return json({ profile: res.data });
197
+ } catch (e) {
198
+ return serverError(e);
199
+ }
200
+ },
201
+ DELETE: () => {
202
+ try {
203
+ const res = auth.setAvatar(null, null);
204
+ if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
205
+ return json({ profile: res.data });
206
+ } catch (e) {
207
+ return serverError(e);
208
+ }
209
+ },
210
+ },
211
+
212
+ "/api/auth/avatar/preset": {
213
+ PUT: async (req: Request) => {
214
+ try {
215
+ const body = await readJson(req);
216
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
217
+ const { preset } = (body.data ?? {}) as { preset?: unknown };
218
+ const res = auth.setAvatarPreset(typeof preset === "string" ? preset : "");
219
+ if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
220
+ return json({ profile: res.data });
221
+ } catch (e) {
222
+ return serverError(e);
223
+ }
224
+ },
225
+ },
226
+
227
+ "/api/auth/settings": {
228
+ PATCH: async (req: Request) => {
229
+ try {
230
+ const body = await readJson(req);
231
+ if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
232
+ const { autoLockMinutes } = (body.data ?? {}) as { autoLockMinutes?: unknown };
233
+ const res = auth.setAutoLock(typeof autoLockMinutes === "number" ? autoLockMinutes : -1);
234
+ if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
235
+ return json({ autoLockMinutes: res.data });
236
+ } catch (e) {
237
+ return serverError(e);
238
+ }
239
+ },
240
+ },
241
+
242
+ // Welcome screen selesai / dilewati. Dijaga guard: setelah app dikunci,
243
+ // hanya sesi login yang bisa memanggilnya (dan saat itu sudah onboarded).
244
+ "/api/auth/onboarding/complete": {
245
+ POST: () => {
246
+ try {
247
+ auth.completeOnboarding();
248
+ return json({ ok: true });
249
+ } catch (e) {
250
+ return serverError(e);
251
+ }
252
+ },
253
+ },
254
+
255
+ "/api/auth/devices": {
256
+ GET: (req: Request) => {
257
+ try {
258
+ return json({ devices: auth.listDevices(readSessionToken(req)) });
259
+ } catch (e) {
260
+ return serverError(e);
261
+ }
262
+ },
263
+ },
264
+
265
+ "/api/auth/devices/revoke-others": {
266
+ POST: (req: Request) => {
267
+ try {
268
+ auth.revokeOthers(readSessionToken(req));
269
+ return json({ ok: true });
270
+ } catch (e) {
271
+ return serverError(e);
272
+ }
273
+ },
274
+ },
275
+
276
+ "/api/auth/devices/:id": {
277
+ DELETE: (req: BunRequest<"/api/auth/devices/:id">) => {
278
+ try {
279
+ auth.revokeDevice(req.params.id);
280
+ return json({ ok: true });
281
+ } catch (e) {
282
+ return serverError(e);
283
+ }
284
+ },
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Helper HTTP bersama untuk handler rute API (pola kcgcode: helper rute
3
+ * dipisah dari composition root). Dulu menumpuk di `app.ts`; kini dipakai
4
+ * semua tabel rute di `routes/*.routes.ts`.
5
+ */
6
+ import type { SessionModel } from "../../types";
7
+
8
+ export function json(data: unknown, status = 200): Response {
9
+ return new Response(JSON.stringify(data), {
10
+ status,
11
+ headers: { "content-type": "application/json" },
12
+ });
13
+ }
14
+
15
+ export function serverError(err: unknown): Response {
16
+ console.error("[kcg-code] error tidak terduga:", err);
17
+ return json({ error: "INTERNAL_ERROR" }, 500);
18
+ }
19
+
20
+ /**
21
+ * Pemetaan error domain ke status HTTP (design.md — Error Handling):
22
+ * 400 validasi, 404 tidak ditemukan, 409 konflik, sisanya 500.
23
+ */
24
+ export function errorStatus(code: string): number {
25
+ switch (code) {
26
+ case "NAME_REQUIRED":
27
+ case "PATH_OUTSIDE_SANDBOX":
28
+ case "INVALID_PATH_CHARS":
29
+ case "UNSUPPORTED_AGENT_TYPE":
30
+ case "INVALID_SIZE":
31
+ case "MODEL_NOT_FOUND":
32
+ case "INVALID_INSTRUCTIONS":
33
+ case "INSTRUCTIONS_TOO_LONG":
34
+ case "INVALID_SKILL_QUERY":
35
+ case "INVALID_SKILL_ID":
36
+ case "PIN_DIGITS_ONLY":
37
+ case "PIN_LENGTH":
38
+ case "PIN_TOO_SIMPLE":
39
+ case "PASSWORD_TOO_SHORT":
40
+ case "PASSWORD_TOO_LONG":
41
+ case "LOCK_KIND_INVALID":
42
+ case "NICKNAME_TOO_LONG":
43
+ case "NICKNAME_INVALID":
44
+ case "AUTO_LOCK_INVALID":
45
+ case "AVATAR_PRESET_INVALID":
46
+ case "AUTH_NOT_CONFIGURED":
47
+ case "INVALID_PROVIDER":
48
+ case "WORKSPACE_PATH_REQUIRED":
49
+ case "WORKSPACE_PATH_INVALID":
50
+ case "WORKSPACE_PATH_NOT_ABSOLUTE":
51
+ case "WORKSPACE_NOT_DIRECTORY":
52
+ case "WORKSPACE_TOO_BROAD":
53
+ case "WORKSPACE_CONTAINS_APP_DATA":
54
+ case "WORKSPACE_NOT_WRITABLE":
55
+ return 400;
56
+ case "WORKSPACE_NOT_FOUND":
57
+ case "WORKSPACE_PARENT_NOT_FOUND":
58
+ return 404;
59
+ case "PASSKEY_NAME_INVALID":
60
+ case "PASSKEY_UNSUPPORTED_ORIGIN":
61
+ case "PASSKEY_CHALLENGE_INVALID":
62
+ case "PASSKEY_VERIFY_FAILED":
63
+ return 400;
64
+ case "PASSKEY_LOCK_REQUIRED":
65
+ case "PASSKEY_ALREADY_REGISTERED":
66
+ case "PASSKEY_LIMIT":
67
+ case "PASSKEY_NONE":
68
+ return 409;
69
+ case "PASSKEY_INVALID":
70
+ return 401;
71
+ case "PASSKEY_NOT_FOUND":
72
+ return 404;
73
+ // Tunnel: prasyarat belum terpenuhi.
74
+ case "TUNNEL_LOCK_REQUIRED":
75
+ case "TUNNEL_SIGNED_OUT":
76
+ return 409;
77
+ case "TUNNEL_DEVICE_REVOKED":
78
+ // Bukan 401: itu sinyal lock screen kcgcode di klien.
79
+ return 403;
80
+ case "TUNNEL_RATE_LIMITED":
81
+ return 429;
82
+ case "FRPC_UNSUPPORTED_PLATFORM":
83
+ case "TUNNEL_NOT_CONFIGURED":
84
+ case "LHR_SSH_MISSING":
85
+ return 501;
86
+ case "TUNNEL_API_UNREACHABLE":
87
+ case "FRPC_DOWNLOAD_FAILED":
88
+ case "FRPC_CHECKSUM_MISMATCH":
89
+ case "FRPC_NOT_FOUND":
90
+ case "FRPC_START_FAILED":
91
+ case "LHR_START_FAILED":
92
+ case "LHR_DENIED":
93
+ return 502;
94
+ case "AUTH_INVALID":
95
+ case "CURRENT_SECRET_INVALID":
96
+ case "AUTH_REQUIRED":
97
+ return 401;
98
+ case "ORIGIN_FORBIDDEN":
99
+ return 403;
100
+ case "AVATAR_NOT_FOUND":
101
+ return 404;
102
+ case "AVATAR_TOO_LARGE":
103
+ return 413;
104
+ case "PROJECT_NOT_FOUND":
105
+ case "PROJECT_DIR_NOT_FOUND":
106
+ case "SESSION_NOT_FOUND":
107
+ case "PATH_NOT_FOUND":
108
+ return 404;
109
+ case "NAME_TAKEN":
110
+ case "PATH_TAKEN":
111
+ case "SESSION_NOT_RUNNING":
112
+ case "SESSION_NOT_ACTIVE":
113
+ case "SESSION_ALREADY_RUNNING":
114
+ case "PROJECT_HAS_SESSIONS":
115
+ case "SKILL_INSTALL_IN_PROGRESS":
116
+ case "INSTALL_JOB_NOT_RUNNING":
117
+ return 409;
118
+ case "SKILLS_RATE_LIMITED":
119
+ return 429;
120
+ case "SKILL_INSTALL_FAILED":
121
+ return 502;
122
+ // Server headless menolak operasi (mis. hapus session remote gagal).
123
+ case "OC_DELETE_SESSION_FAILED":
124
+ return 502;
125
+ case "UNSUPPORTED_IMAGE_MIME":
126
+ case "EMPTY_UPLOAD":
127
+ return 400;
128
+ case "IMAGE_TOO_LARGE":
129
+ case "FILE_TOO_LARGE":
130
+ return 413;
131
+ case "ATTACHMENT_NOT_FOUND":
132
+ case "INSTALL_JOB_NOT_FOUND":
133
+ return 404;
134
+ default:
135
+ // Kegagalan layanan skills.sh (hulu) -> 502.
136
+ if (code.startsWith("SKILLS_SEARCH_FAILED") || code.startsWith("SKILLS_AUDIT_FAILED")) {
137
+ return 502;
138
+ }
139
+ return 500;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Membaca body JSON; body tidak valid -> `{ ok: false }` sehingga handler
145
+ * dapat membalas 400 (bukan 500 dari `serverError`).
146
+ */
147
+ export async function readJson(req: Request): Promise<{ ok: true; data: unknown } | { ok: false }> {
148
+ try {
149
+ return { ok: true, data: await req.json() };
150
+ } catch {
151
+ return { ok: false };
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Body `model` pada POST/PUT Session: `{ providerID, modelID }` atau `null`
157
+ * (artinya pakai model default opencode). Bentuk lain dianggap null.
158
+ */
159
+ export function parseModelBody(raw: unknown): SessionModel | null {
160
+ if (typeof raw !== "object" || raw === null) return null;
161
+ const { providerID, modelID } = raw as { providerID?: unknown; modelID?: unknown };
162
+ if (typeof providerID !== "string" || providerID === "") return null;
163
+ if (typeof modelID !== "string" || modelID === "") return null;
164
+ return { providerID, modelID };
165
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tabel rute info aplikasi.
3
+ *
4
+ * - `GET /api/meta` `{ version }` — dipakai Settings -> About.
5
+ *
6
+ * Dijaga `guardRoutes` seperti rute lain: versi bukan rahasia, tapi tidak
7
+ * perlu diekspos ke pengunjung yang belum membuka kunci.
8
+ */
9
+ import { readAppInfo } from "../services/app-info";
10
+ import { json, serverError } from "./helpers";
11
+
12
+ export function metaRoutes() {
13
+ return {
14
+ "/api/meta": {
15
+ GET: async () => {
16
+ try {
17
+ return json(await readAppInfo());
18
+ } catch (e) {
19
+ return serverError(e);
20
+ }
21
+ },
22
+ },
23
+ };
24
+ }