kcg-code 0.0.0-stage → 0.1.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 (286) hide show
  1. package/README.md +228 -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 +83 -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__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Passkey (WebAuthn) — cara buka kunci TAMBAHAN di samping PIN/password.
3
+ *
4
+ * Model keamanan:
5
+ * - PIN/password tetap wajib ada (cadangan bila passkey tak bisa dipakai).
6
+ * Mendaftarkan passkey butuh sesi login + konfirmasi sandi saat ini.
7
+ * Menghapus kunci / `kcgcode reset-lock` ikut menghapus semua passkey.
8
+ * - Passkey terikat domain (RP ID). RP ID & origin diturunkan dari header
9
+ * `Origin` request yang WAJIB sama dengan host tujuan (pola yang sama
10
+ * dengan `originAllowed`) — tidak pernah dari input body. Hanya secure
11
+ * context (https, atau http://localhost) yang didukung, sesuai aturan
12
+ * WebAuthn di browser.
13
+ * - Challenge sekali pakai, kedaluwarsa 5 menit, disimpan di memori server
14
+ * (bukan di klien). Login passkey kena anti brute-force yang sama dengan
15
+ * PIN/password.
16
+ * - Verifikasi kriptografi (attestation, signature, counter) oleh
17
+ * `@simplewebauthn/server`. User verification (biometrik / PIN perangkat)
18
+ * diwajibkan.
19
+ * - Hanya kunci publik yang disimpan.
20
+ */
21
+ import { randomBytes, randomUUID } from "node:crypto";
22
+ import {
23
+ type AuthenticationResponseJSON,
24
+ generateAuthenticationOptions,
25
+ generateRegistrationOptions,
26
+ type PublicKeyCredentialCreationOptionsJSON,
27
+ type PublicKeyCredentialRequestOptionsJSON,
28
+ type RegistrationResponseJSON,
29
+ verifyAuthenticationResponse,
30
+ verifyRegistrationResponse,
31
+ } from "@simplewebauthn/server";
32
+ import type { SessionStore } from "../../db";
33
+ import type { PasskeyRow } from "../db/auth";
34
+ import type { Result } from "../result";
35
+ import type { AuthService, IssuedSession, LoginContext } from "./auth";
36
+
37
+ export const PASSKEY_NAME_MAX = 60;
38
+ export const PASSKEY_MAX = 20;
39
+ /** Umur challenge (ms). Sama dengan timeout WebAuthn yang dikirim ke browser. */
40
+ export const CHALLENGE_TTL_MS = 5 * 60_000;
41
+ const RP_NAME = "KCG Code";
42
+ /** User handle tetap — app satu pemilik, tidak ada akun. */
43
+ const USER_NAME = "kcgcode-owner";
44
+
45
+ /** Asal WebAuthn yang tervalidasi dari sebuah request. */
46
+ export interface WebAuthnOrigin {
47
+ /** Origin lengkap, mis. `https://irsyad.kcgcode.dev`. */
48
+ origin: string;
49
+ /** RP ID = hostname tanpa port, mis. `irsyad.kcgcode.dev`. */
50
+ rpId: string;
51
+ }
52
+
53
+ /** Info passkey yang aman dikirim ke browser (tanpa kunci publik). */
54
+ export interface PasskeyInfo {
55
+ id: string;
56
+ name: string;
57
+ rpId: string;
58
+ /** Passkey tersinkron (iCloud Keychain, Google Password Manager, dsb.). */
59
+ synced: boolean;
60
+ createdAt: number;
61
+ lastUsedAt: number | null;
62
+ /** Bisa dipakai di alamat yang sedang dibuka. */
63
+ usableHere: boolean;
64
+ }
65
+
66
+ export interface PasskeyAvailability {
67
+ /** Alamat ini mendukung WebAuthn (secure context). */
68
+ supported: boolean;
69
+ /** Jumlah passkey terdaftar untuk alamat ini. */
70
+ count: number;
71
+ }
72
+
73
+ export interface PasskeyService {
74
+ /** Status untuk lock screen (publik): boleh menampilkan tombol passkey? */
75
+ availability(req: Request): PasskeyAvailability;
76
+ list(req: Request): PasskeyInfo[];
77
+ /** Mulai pendaftaran — wajib konfirmasi sandi saat ini. */
78
+ registrationOptions(
79
+ req: Request,
80
+ current: string,
81
+ ip: string,
82
+ ): Promise<Result<{ challengeId: string; options: PublicKeyCredentialCreationOptionsJSON }>>;
83
+ register(
84
+ req: Request,
85
+ input: { challengeId: string; response: RegistrationResponseJSON; name?: string },
86
+ ): Promise<Result<PasskeyInfo>>;
87
+ /** Mulai login passkey (publik). */
88
+ authenticationOptions(
89
+ req: Request,
90
+ ip: string,
91
+ ): Promise<Result<{ challengeId: string; options: PublicKeyCredentialRequestOptionsJSON }>>;
92
+ login(
93
+ req: Request,
94
+ input: { challengeId: string; response: AuthenticationResponseJSON },
95
+ ctx: LoginContext,
96
+ ): Promise<Result<IssuedSession>>;
97
+ rename(id: string, name: string): Result<PasskeyInfo>;
98
+ remove(id: string): Result<null>;
99
+ }
100
+
101
+ export interface PasskeyServiceOptions {
102
+ store: SessionStore;
103
+ auth: AuthService;
104
+ now?: () => number;
105
+ /** Injeksi verifikasi (test) — default `@simplewebauthn/server`. */
106
+ verifyRegistration?: typeof verifyRegistrationResponse;
107
+ verifyAuthentication?: typeof verifyAuthenticationResponse;
108
+ }
109
+
110
+ // ------------------------------------------------------------ util murni ----
111
+
112
+ /**
113
+ * Turunkan origin WebAuthn dari request. `null` bila:
114
+ * - header Origin tidak ada / tidak valid,
115
+ * - Origin tidak sama dengan host tujuan (lihat `originAllowed`),
116
+ * - bukan secure context (http selain localhost),
117
+ * - host berupa alamat IP (WebAuthn menolak RP ID berupa IP).
118
+ */
119
+ export function webAuthnOrigin(req: Request): WebAuthnOrigin | null {
120
+ const raw = req.headers.get("origin");
121
+ if (!raw) return null;
122
+ let url: URL;
123
+ try {
124
+ url = new URL(raw);
125
+ } catch {
126
+ return null;
127
+ }
128
+ const hosts = new Set<string>();
129
+ const host = req.headers.get("host");
130
+ if (host) hosts.add(host);
131
+ const fwd = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
132
+ if (fwd) hosts.add(fwd);
133
+ hosts.add(new URL(req.url).host);
134
+ if (!hosts.has(url.host)) return null;
135
+
136
+ const hostname = url.hostname;
137
+ const isLocalhost = hostname === "localhost" || hostname.endsWith(".localhost");
138
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) return null;
139
+ // IPv4 / IPv6 literal tidak boleh jadi RP ID.
140
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.startsWith("[")) return null;
141
+ return { origin: url.origin, rpId: hostname };
142
+ }
143
+
144
+ /**
145
+ * Varian BACA-SAJA untuk request GET. Browser tidak mengirim header `Origin`
146
+ * pada GET same-origin, jadi `webAuthnOrigin` selalu `null` di sini —
147
+ * akibatnya lock screen tidak pernah melihat passkey. Untuk GET, origin
148
+ * diturunkan dari host tujuan (`X-Forwarded-Host` / `Host`) + skema
149
+ * (`X-Forwarded-Proto` / URL request), dengan aturan secure context & IP
150
+ * yang sama.
151
+ *
152
+ * Hanya dipakai untuk info non-sensitif (jumlah passkey, label "bisa dipakai
153
+ * di sini"). Langkah yang mengubah state / menerbitkan sesi (options,
154
+ * register, login — semuanya POST) tetap wajib `webAuthnOrigin` yang ketat,
155
+ * dan verifikasi WebAuthn sendiri mencocokkan origin yang ditandatangani
156
+ * browser.
157
+ */
158
+ export function webAuthnOriginForRead(req: Request): WebAuthnOrigin | null {
159
+ if (req.headers.get("origin")) return webAuthnOrigin(req);
160
+ const reqUrl = new URL(req.url);
161
+ const host =
162
+ req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ??
163
+ req.headers.get("host") ??
164
+ reqUrl.host;
165
+ const proto =
166
+ req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ??
167
+ reqUrl.protocol.replace(/:$/, "");
168
+ let url: URL;
169
+ try {
170
+ url = new URL(`${proto}://${host}`);
171
+ } catch {
172
+ return null;
173
+ }
174
+ const hostname = url.hostname;
175
+ const isLocalhost = hostname === "localhost" || hostname.endsWith(".localhost");
176
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) return null;
177
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.startsWith("[")) return null;
178
+ return { origin: url.origin, rpId: hostname };
179
+ }
180
+
181
+ /** Nama passkey bawaan dari user agent (bisa diganti user). */
182
+ export function defaultPasskeyName(userAgent: string | null): string {
183
+ const ua = userAgent ?? "";
184
+ const os = /iPhone|iPad/.test(ua)
185
+ ? "iPhone"
186
+ : /Android/.test(ua)
187
+ ? "Android"
188
+ : /Mac OS X|Macintosh/.test(ua)
189
+ ? "Mac"
190
+ : /Windows/.test(ua)
191
+ ? "Windows"
192
+ : /Linux/.test(ua)
193
+ ? "Linux"
194
+ : null;
195
+ return os ? `Passkey on ${os}` : "Passkey";
196
+ }
197
+
198
+ export function cleanPasskeyName(name: string): string | null {
199
+ const clean = name.replace(/\s+/g, " ").trim();
200
+ if (clean.length === 0 || clean.length > PASSKEY_NAME_MAX) return null;
201
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: memang menyaring kontrol
202
+ if (/[\u0000-\u001f\u007f]/.test(clean)) return null;
203
+ return clean;
204
+ }
205
+
206
+ // ------------------------------------------------------------- service ----
207
+
208
+ interface Challenge {
209
+ kind: "register" | "login";
210
+ challenge: string;
211
+ rpId: string;
212
+ origin: string;
213
+ expiresAt: number;
214
+ }
215
+
216
+ export function createPasskeyService(opts: PasskeyServiceOptions): PasskeyService {
217
+ const { store, auth } = opts;
218
+ const now = opts.now ?? Date.now;
219
+ const verifyReg = opts.verifyRegistration ?? verifyRegistrationResponse;
220
+ const verifyAuth = opts.verifyAuthentication ?? verifyAuthenticationResponse;
221
+ const challenges = new Map<string, Challenge>();
222
+
223
+ function sweepChallenges(): void {
224
+ const t = now();
225
+ for (const [id, c] of challenges) if (c.expiresAt <= t) challenges.delete(id);
226
+ // Batas ukuran (permintaan options tanpa diselesaikan).
227
+ while (challenges.size > 200) {
228
+ const first = challenges.keys().next().value;
229
+ if (first === undefined) break;
230
+ challenges.delete(first);
231
+ }
232
+ }
233
+
234
+ function putChallenge(c: Omit<Challenge, "expiresAt">): string {
235
+ sweepChallenges();
236
+ const id = randomUUID();
237
+ challenges.set(id, { ...c, expiresAt: now() + CHALLENGE_TTL_MS });
238
+ return id;
239
+ }
240
+
241
+ /** Ambil & HAPUS challenge (sekali pakai), pastikan jenis & asal sama. */
242
+ function takeChallenge(
243
+ id: unknown,
244
+ kind: Challenge["kind"],
245
+ origin: WebAuthnOrigin,
246
+ ): Challenge | null {
247
+ if (typeof id !== "string") return null;
248
+ const c = challenges.get(id);
249
+ challenges.delete(id);
250
+ if (!c || c.kind !== kind || c.expiresAt <= now()) return null;
251
+ if (c.rpId !== origin.rpId || c.origin !== origin.origin) return null;
252
+ return c;
253
+ }
254
+
255
+ function toInfo(p: PasskeyRow, rpHere: string | null): PasskeyInfo {
256
+ return {
257
+ id: p.id,
258
+ name: p.name,
259
+ rpId: p.rpId,
260
+ synced: p.backedUp || p.deviceType === "multiDevice",
261
+ createdAt: p.createdAt,
262
+ lastUsedAt: p.lastUsedAt,
263
+ usableHere: rpHere !== null && p.rpId === rpHere,
264
+ };
265
+ }
266
+
267
+ function availability(req: Request): PasskeyAvailability {
268
+ // GET dari lock screen: browser tidak mengirim Origin (lihat
269
+ // `webAuthnOriginForRead`).
270
+ const o = webAuthnOriginForRead(req);
271
+ if (!o || !auth.isProtected()) return { supported: o !== null, count: 0 };
272
+ return { supported: true, count: store.listPasskeys(o.rpId).length };
273
+ }
274
+
275
+ function list(req: Request): PasskeyInfo[] {
276
+ const rp = webAuthnOriginForRead(req)?.rpId ?? null;
277
+ return store.listPasskeys().map((p) => toInfo(p, rp));
278
+ }
279
+
280
+ async function registrationOptions(req: Request, current: string, ip: string) {
281
+ if (!auth.isProtected()) return { ok: false as const, error: "PASSKEY_LOCK_REQUIRED" };
282
+ const o = webAuthnOrigin(req);
283
+ if (!o) return { ok: false as const, error: "PASSKEY_UNSUPPORTED_ORIGIN" };
284
+ if (store.listPasskeys().length >= PASSKEY_MAX) {
285
+ return { ok: false as const, error: "PASSKEY_LIMIT" };
286
+ }
287
+ // Pendaftaran = mengubah cara buka kunci -> konfirmasi sandi saat ini.
288
+ const check = await auth.internal.checkCurrentSecret(current, ip);
289
+ if (!check.ok) return { ok: false as const, error: check.error ?? "CURRENT_SECRET_INVALID" };
290
+
291
+ const existing = store.listPasskeys(o.rpId);
292
+ const options = await generateRegistrationOptions({
293
+ rpName: RP_NAME,
294
+ rpID: o.rpId,
295
+ userName: USER_NAME,
296
+ userDisplayName: store.getAuthSettings(now()).nickname ?? RP_NAME,
297
+ // Handle acak per pendaftaran: tidak ada identitas pengguna yang dibagi.
298
+ userID: new Uint8Array(randomBytes(16)),
299
+ timeout: CHALLENGE_TTL_MS,
300
+ attestationType: "none",
301
+ excludeCredentials: existing.map((p) => ({ id: p.credentialId, transports: p.transports })),
302
+ authenticatorSelection: { residentKey: "required", userVerification: "required" },
303
+ });
304
+ const challengeId = putChallenge({
305
+ kind: "register",
306
+ challenge: options.challenge,
307
+ rpId: o.rpId,
308
+ origin: o.origin,
309
+ });
310
+ return { ok: true as const, data: { challengeId, options } };
311
+ }
312
+
313
+ async function register(
314
+ req: Request,
315
+ input: { challengeId: string; response: RegistrationResponseJSON; name?: string },
316
+ ): Promise<Result<PasskeyInfo>> {
317
+ if (!auth.isProtected()) return { ok: false, error: "PASSKEY_LOCK_REQUIRED" };
318
+ const o = webAuthnOrigin(req);
319
+ if (!o) return { ok: false, error: "PASSKEY_UNSUPPORTED_ORIGIN" };
320
+ const c = takeChallenge(input.challengeId, "register", o);
321
+ if (!c) return { ok: false, error: "PASSKEY_CHALLENGE_INVALID" };
322
+ if (!input.response || typeof input.response !== "object") {
323
+ return { ok: false, error: "PASSKEY_VERIFY_FAILED" };
324
+ }
325
+ let verified: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
326
+ try {
327
+ verified = await verifyReg({
328
+ response: input.response,
329
+ expectedChallenge: c.challenge,
330
+ expectedOrigin: c.origin,
331
+ expectedRPID: c.rpId,
332
+ requireUserVerification: true,
333
+ });
334
+ } catch {
335
+ return { ok: false, error: "PASSKEY_VERIFY_FAILED" };
336
+ }
337
+ if (!verified.verified) return { ok: false, error: "PASSKEY_VERIFY_FAILED" };
338
+ const info = verified.registrationInfo;
339
+ if (store.getPasskeyByCredentialId(info.credential.id)) {
340
+ return { ok: false, error: "PASSKEY_ALREADY_REGISTERED" };
341
+ }
342
+ const name =
343
+ (input.name !== undefined ? cleanPasskeyName(input.name) : null) ??
344
+ defaultPasskeyName(req.headers.get("user-agent"));
345
+ const row: PasskeyRow = {
346
+ id: randomUUID(),
347
+ credentialId: info.credential.id,
348
+ publicKey: info.credential.publicKey,
349
+ counter: info.credential.counter,
350
+ transports: info.credential.transports ?? [],
351
+ rpId: c.rpId,
352
+ name,
353
+ deviceType: info.credentialDeviceType,
354
+ backedUp: info.credentialBackedUp,
355
+ createdAt: now(),
356
+ lastUsedAt: null,
357
+ };
358
+ store.insertPasskey(row);
359
+ return { ok: true, data: toInfo(row, c.rpId) };
360
+ }
361
+
362
+ async function authenticationOptions(req: Request, ip: string) {
363
+ if (!auth.isProtected()) return { ok: false as const, error: "AUTH_NOT_CONFIGURED" };
364
+ const o = webAuthnOrigin(req);
365
+ if (!o) return { ok: false as const, error: "PASSKEY_UNSUPPORTED_ORIGIN" };
366
+ const wait = auth.internal.retryAfterSec(ip);
367
+ if (wait > 0) return { ok: false as const, error: `AUTH_RATE_LIMITED:${wait}` };
368
+ const creds = store.listPasskeys(o.rpId);
369
+ if (creds.length === 0) return { ok: false as const, error: "PASSKEY_NONE" };
370
+ const options = await generateAuthenticationOptions({
371
+ rpID: o.rpId,
372
+ timeout: CHALLENGE_TTL_MS,
373
+ userVerification: "required",
374
+ allowCredentials: creds.map((p) => ({ id: p.credentialId, transports: p.transports })),
375
+ });
376
+ const challengeId = putChallenge({
377
+ kind: "login",
378
+ challenge: options.challenge,
379
+ rpId: o.rpId,
380
+ origin: o.origin,
381
+ });
382
+ return { ok: true as const, data: { challengeId, options } };
383
+ }
384
+
385
+ async function login(
386
+ req: Request,
387
+ input: { challengeId: string; response: AuthenticationResponseJSON },
388
+ ctx: LoginContext,
389
+ ): Promise<Result<IssuedSession>> {
390
+ if (!auth.isProtected()) return { ok: false, error: "AUTH_NOT_CONFIGURED" };
391
+ const wait = auth.internal.retryAfterSec(ctx.ip);
392
+ if (wait > 0) return { ok: false, error: `AUTH_RATE_LIMITED:${wait}` };
393
+ const o = webAuthnOrigin(req);
394
+ if (!o) return { ok: false, error: "PASSKEY_UNSUPPORTED_ORIGIN" };
395
+
396
+ const fail = (): Result<IssuedSession> => {
397
+ auth.internal.recordFailure(ctx.ip);
398
+ const next = auth.internal.retryAfterSec(ctx.ip);
399
+ return { ok: false, error: next > 0 ? `AUTH_RATE_LIMITED:${next}` : "PASSKEY_INVALID" };
400
+ };
401
+
402
+ const c = takeChallenge(input.challengeId, "login", o);
403
+ if (!c) return { ok: false, error: "PASSKEY_CHALLENGE_INVALID" };
404
+ const credId = input.response && typeof input.response.id === "string" ? input.response.id : "";
405
+ const cred = store.getPasskeyByCredentialId(credId);
406
+ // Passkey milik domain lain tidak boleh dipakai di sini.
407
+ if (!cred || cred.rpId !== c.rpId) return fail();
408
+
409
+ let verified: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
410
+ try {
411
+ verified = await verifyAuth({
412
+ response: input.response,
413
+ expectedChallenge: c.challenge,
414
+ expectedOrigin: c.origin,
415
+ expectedRPID: c.rpId,
416
+ credential: {
417
+ id: cred.credentialId,
418
+ // Salin ke ArrayBuffer biasa (tipe `Uint8Array_` milik library).
419
+ publicKey: new Uint8Array(cred.publicKey),
420
+ counter: cred.counter,
421
+ transports: cred.transports,
422
+ },
423
+ requireUserVerification: true,
424
+ });
425
+ } catch {
426
+ return fail();
427
+ }
428
+ if (!verified.verified) return fail();
429
+ auth.internal.recordSuccess(ctx.ip);
430
+ store.markPasskeyUsed(cred.id, verified.authenticationInfo.newCounter, now());
431
+ return { ok: true, data: auth.internal.issueSession(ctx) };
432
+ }
433
+
434
+ function rename(id: string, name: string): Result<PasskeyInfo> {
435
+ const p = store.getPasskeyById(id);
436
+ if (!p) return { ok: false, error: "PASSKEY_NOT_FOUND" };
437
+ const clean = typeof name === "string" ? cleanPasskeyName(name) : null;
438
+ if (!clean) return { ok: false, error: "PASSKEY_NAME_INVALID" };
439
+ store.renamePasskey(id, clean);
440
+ return { ok: true, data: toInfo({ ...p, name: clean }, null) };
441
+ }
442
+
443
+ function remove(id: string): Result<null> {
444
+ if (!store.getPasskeyById(id)) return { ok: false, error: "PASSKEY_NOT_FOUND" };
445
+ store.deletePasskey(id);
446
+ return { ok: true, data: null };
447
+ }
448
+
449
+ return {
450
+ availability,
451
+ list,
452
+ registrationOptions,
453
+ register,
454
+ authenticationOptions,
455
+ login,
456
+ rename,
457
+ remove,
458
+ };
459
+ }