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.
- package/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth_Service — kunci aplikasi (lock screen) untuk akses lewat tunnel.
|
|
3
|
+
*
|
|
4
|
+
* Model: satu pemilik, satu kunci (PIN angka atau password). Tidak ada akun.
|
|
5
|
+
* - Sandi disimpan sebagai hash argon2id (`Bun.password`).
|
|
6
|
+
* - Login membuat sesi per perangkat: token acak 256-bit di cookie
|
|
7
|
+
* HttpOnly; DB hanya menyimpan SHA-256 token.
|
|
8
|
+
* - Kunci otomatis ditegakkan server: sesi yang tidak aktif lebih lama dari
|
|
9
|
+
* `autoLockMinutes` dianggap terkunci (bukan sekadar UI).
|
|
10
|
+
* - Anti brute-force: penundaan bertingkat per IP DAN global (tunnel sering
|
|
11
|
+
* menyamarkan IP asli, jadi batas global tetap melindungi).
|
|
12
|
+
* - Kunci belum diatur -> app terbuka (`protected: false`) dan UI
|
|
13
|
+
* menampilkan peringatan; begitu diatur, perangkat pengatur langsung login.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import type { SessionStore } from "../../db";
|
|
17
|
+
import { type AvatarPresetId, isAvatarPresetId } from "../../lib/avatar-presets";
|
|
18
|
+
import type { AuthSessionRow, LockKind } from "../db/auth";
|
|
19
|
+
import type { Result } from "../result";
|
|
20
|
+
|
|
21
|
+
// ------------------------------------------------------------- konstanta ----
|
|
22
|
+
|
|
23
|
+
export const PIN_MIN = 6;
|
|
24
|
+
export const PIN_MAX = 12;
|
|
25
|
+
export const PASSWORD_MIN = 8;
|
|
26
|
+
export const PASSWORD_MAX = 128;
|
|
27
|
+
export const NICKNAME_MAX = 40;
|
|
28
|
+
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
|
29
|
+
export const AVATAR_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
|
30
|
+
/** Pilihan kunci otomatis (menit); 0 = tidak pernah. */
|
|
31
|
+
export const AUTO_LOCK_CHOICES = [0, 5, 15, 30, 60] as const;
|
|
32
|
+
/** Umur maksimum sesi login (walau aktif terus). */
|
|
33
|
+
export const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
|
|
34
|
+
/** Jangan tulis `last_seen_at` ke DB lebih sering dari ini per sesi. */
|
|
35
|
+
const TOUCH_THROTTLE_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/** Kegagalan beruntun sebelum penundaan mulai berlaku. */
|
|
38
|
+
const FREE_ATTEMPTS = 5;
|
|
39
|
+
/** Penundaan dasar setelah melewati FREE_ATTEMPTS; berlipat tiap gagal. */
|
|
40
|
+
const BASE_LOCKOUT_MS = 30_000;
|
|
41
|
+
const MAX_LOCKOUT_MS = 15 * 60_000;
|
|
42
|
+
/** Batas global lebih longgar (beberapa perangkat sah), tetap bertingkat. */
|
|
43
|
+
const GLOBAL_FREE_ATTEMPTS = 20;
|
|
44
|
+
/** Lupakan riwayat gagal setelah diam selama ini. */
|
|
45
|
+
const FAILURE_RESET_MS = 60 * 60_000;
|
|
46
|
+
|
|
47
|
+
export const SESSION_COOKIE = "kcg_session";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- tipe ----
|
|
50
|
+
|
|
51
|
+
export interface AuthProfile {
|
|
52
|
+
nickname: string | null;
|
|
53
|
+
/** URL foto profil upload (dengan versi untuk cache-busting) atau null. */
|
|
54
|
+
avatarUrl: string | null;
|
|
55
|
+
/** Avatar bawaan (id preset) atau null. Eksklusif dengan `avatarUrl`. */
|
|
56
|
+
avatarPreset: AvatarPresetId | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AuthStatus {
|
|
60
|
+
/** Kunci sudah diatur. */
|
|
61
|
+
protected: boolean;
|
|
62
|
+
/** Request ini membawa sesi yang valid (atau app belum dikunci). */
|
|
63
|
+
authenticated: boolean;
|
|
64
|
+
lockKind: LockKind | null;
|
|
65
|
+
/** Panjang PIN tidak diekspos; hanya jenis kunci. */
|
|
66
|
+
autoLockMinutes: number;
|
|
67
|
+
profile: AuthProfile;
|
|
68
|
+
/** Detik tersisa sebelum boleh mencoba lagi (anti brute-force). */
|
|
69
|
+
retryAfterSec: number;
|
|
70
|
+
/**
|
|
71
|
+
* Instalasi baru yang belum menyelesaikan welcome screen. Hanya `true`
|
|
72
|
+
* bila kunci BELUM diatur — begitu app dikunci, onboarding dianggap
|
|
73
|
+
* selesai (pengunjung lewat tunnel tidak boleh melihat/menjalankannya).
|
|
74
|
+
*/
|
|
75
|
+
needsOnboarding: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LoginContext {
|
|
79
|
+
ip: string;
|
|
80
|
+
userAgent: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface IssuedSession {
|
|
84
|
+
token: string;
|
|
85
|
+
session: AuthSessionRow;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DeviceInfo {
|
|
89
|
+
id: string;
|
|
90
|
+
userAgent: string | null;
|
|
91
|
+
createdAt: number;
|
|
92
|
+
lastSeenAt: number;
|
|
93
|
+
current: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AuthService {
|
|
97
|
+
status(token: string | null, ip: string): AuthStatus;
|
|
98
|
+
/** Validasi token; mengembalikan sesi & memperbarui aktivitasnya. */
|
|
99
|
+
authenticate(token: string | null): AuthSessionRow | null;
|
|
100
|
+
/**
|
|
101
|
+
* Sesi (berdasarkan id) masih hidup? Dipakai WebSocket (hanya tahu id).
|
|
102
|
+
* Dihitung sebagai aktivitas — memperpanjang batas kunci otomatis.
|
|
103
|
+
*/
|
|
104
|
+
isSessionAlive(sessionId: string): boolean;
|
|
105
|
+
/** Kunci diatur? Bila tidak, semua request diizinkan. */
|
|
106
|
+
isProtected(): boolean;
|
|
107
|
+
login(secret: string, ctx: LoginContext): Promise<Result<IssuedSession>>;
|
|
108
|
+
logout(token: string | null): void;
|
|
109
|
+
/**
|
|
110
|
+
* Atur / ganti kunci. Bila kunci sudah ada, `current` wajib benar.
|
|
111
|
+
* Semua sesi lain dicabut; pemanggil mendapat sesi baru.
|
|
112
|
+
*/
|
|
113
|
+
setLock(
|
|
114
|
+
input: { kind: LockKind; secret: string; current?: string },
|
|
115
|
+
ctx: LoginContext,
|
|
116
|
+
): Promise<Result<IssuedSession>>;
|
|
117
|
+
/** Hapus kunci (app kembali terbuka). Wajib sandi saat ini. */
|
|
118
|
+
removeLock(current: string, ctx: LoginContext): Promise<Result<null>>;
|
|
119
|
+
setNickname(nickname: string | null): Result<AuthProfile>;
|
|
120
|
+
setAvatar(bytes: Uint8Array | null, mime: string | null): Result<AuthProfile>;
|
|
121
|
+
/** Pakai avatar bawaan (menggantikan foto upload). */
|
|
122
|
+
setAvatarPreset(preset: string): Result<AuthProfile>;
|
|
123
|
+
getAvatar(): { bytes: Uint8Array; mime: string } | null;
|
|
124
|
+
setAutoLock(minutes: number): Result<number>;
|
|
125
|
+
/** Welcome screen selesai / dilewati (idempoten). */
|
|
126
|
+
completeOnboarding(): void;
|
|
127
|
+
listDevices(currentToken: string | null): DeviceInfo[];
|
|
128
|
+
/** Cabut sesi lain (atau satu sesi tertentu). */
|
|
129
|
+
revokeOthers(currentToken: string | null): void;
|
|
130
|
+
revokeDevice(id: string): void;
|
|
131
|
+
/** Dipanggil saat sesi dicabut / kedaluwarsa (mis. tutup WebSocket-nya). */
|
|
132
|
+
onSessionEnded(cb: (sessionId: string) => void): () => void;
|
|
133
|
+
/** Dipanggil setelah kunci diatur / diganti / dihapus (mis. matikan tunnel). */
|
|
134
|
+
onLockChanged(cb: (protectedNow: boolean) => void): () => void;
|
|
135
|
+
/** Bersihkan sesi kedaluwarsa & idle (dipanggil berkala). */
|
|
136
|
+
sweep(): void;
|
|
137
|
+
/** Reset total dari CLI: hapus kunci & semua sesi. */
|
|
138
|
+
resetLock(): void;
|
|
139
|
+
/**
|
|
140
|
+
* Hook internal untuk modul passkey (`auth-passkeys.ts`) — memakai ulang
|
|
141
|
+
* penerbitan sesi, verifikasi sandi, dan anti brute-force yang sama.
|
|
142
|
+
*/
|
|
143
|
+
internal: {
|
|
144
|
+
issueSession(ctx: LoginContext): IssuedSession;
|
|
145
|
+
/** Verifikasi PIN/password saat ini (dengan rate limit). */
|
|
146
|
+
checkCurrentSecret(secret: string, ip: string): Promise<Result<null>>;
|
|
147
|
+
/** Detik tunggu anti brute-force; 0 = boleh mencoba. */
|
|
148
|
+
retryAfterSec(ip: string): number;
|
|
149
|
+
recordFailure(ip: string): void;
|
|
150
|
+
recordSuccess(ip: string): void;
|
|
151
|
+
findSessionId(token: string | null): string | null;
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface AuthServiceOptions {
|
|
156
|
+
store: SessionStore;
|
|
157
|
+
now?: () => number;
|
|
158
|
+
/** Injeksi hash (test cepat). Default argon2id. */
|
|
159
|
+
hash?: (secret: string) => Promise<string>;
|
|
160
|
+
verify?: (secret: string, hash: string) => Promise<boolean>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ------------------------------------------------------------ util murni ----
|
|
164
|
+
|
|
165
|
+
export function hashToken(token: string): string {
|
|
166
|
+
return createHash("sha256").update(token).digest("hex");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Validasi format kunci; kembalikan kode error atau null. */
|
|
170
|
+
export function validateSecret(kind: LockKind, secret: string): string | null {
|
|
171
|
+
if (kind === "pin") {
|
|
172
|
+
if (!/^\d+$/.test(secret)) return "PIN_DIGITS_ONLY";
|
|
173
|
+
if (secret.length < PIN_MIN || secret.length > PIN_MAX) return "PIN_LENGTH";
|
|
174
|
+
// Pola sangat mudah ditebak.
|
|
175
|
+
if (/^(\d)\1+$/.test(secret)) return "PIN_TOO_SIMPLE";
|
|
176
|
+
const asc = "01234567890123456789";
|
|
177
|
+
const desc = "98765432109876543210";
|
|
178
|
+
if (asc.includes(secret) || desc.includes(secret)) return "PIN_TOO_SIMPLE";
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (secret.length < PASSWORD_MIN) return "PASSWORD_TOO_SHORT";
|
|
182
|
+
if (secret.length > PASSWORD_MAX) return "PASSWORD_TOO_LONG";
|
|
183
|
+
if (secret.trim().length === 0) return "PASSWORD_TOO_SHORT";
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Deteksi tipe gambar dari magic bytes (jangan percaya MIME dari client). */
|
|
188
|
+
export function sniffImageMime(bytes: Uint8Array): (typeof AVATAR_MIMES)[number] | null {
|
|
189
|
+
const b = bytes;
|
|
190
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) {
|
|
191
|
+
return "image/png";
|
|
192
|
+
}
|
|
193
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
|
|
194
|
+
if (b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) {
|
|
195
|
+
return "image/gif";
|
|
196
|
+
}
|
|
197
|
+
if (
|
|
198
|
+
b.length >= 12 &&
|
|
199
|
+
b[0] === 0x52 &&
|
|
200
|
+
b[1] === 0x49 &&
|
|
201
|
+
b[2] === 0x46 &&
|
|
202
|
+
b[3] === 0x46 &&
|
|
203
|
+
b[8] === 0x57 &&
|
|
204
|
+
b[9] === 0x45 &&
|
|
205
|
+
b[10] === 0x42 &&
|
|
206
|
+
b[11] === 0x50
|
|
207
|
+
) {
|
|
208
|
+
return "image/webp";
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Penundaan setelah `failures` gagal beruntun (0 bila masih bebas). */
|
|
214
|
+
export function lockoutMs(failures: number, free: number): number {
|
|
215
|
+
if (failures < free) return 0;
|
|
216
|
+
const steps = failures - free;
|
|
217
|
+
return Math.min(MAX_LOCKOUT_MS, BASE_LOCKOUT_MS * 2 ** steps);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
221
|
+
const ab = Buffer.from(a);
|
|
222
|
+
const bb = Buffer.from(b);
|
|
223
|
+
if (ab.length !== bb.length) return false;
|
|
224
|
+
return timingSafeEqual(ab, bb);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ------------------------------------------------------------- service ----
|
|
228
|
+
|
|
229
|
+
interface FailureState {
|
|
230
|
+
count: number;
|
|
231
|
+
lastAt: number;
|
|
232
|
+
lockedUntil: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Hash dummy agar verifikasi tanpa kunci tetap memakan waktu yang sama. */
|
|
236
|
+
let dummyHashPromise: Promise<string> | null = null;
|
|
237
|
+
|
|
238
|
+
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
|
239
|
+
const store = opts.store;
|
|
240
|
+
const now = opts.now ?? Date.now;
|
|
241
|
+
const hash = opts.hash ?? ((s: string) => Bun.password.hash(s, { algorithm: "argon2id" }));
|
|
242
|
+
const verify = opts.verify ?? ((s: string, h: string) => Bun.password.verify(s, h));
|
|
243
|
+
const failures = new Map<string, FailureState>();
|
|
244
|
+
const GLOBAL = "\u0000global";
|
|
245
|
+
const lastTouch = new Map<string, number>();
|
|
246
|
+
const endedListeners = new Set<(sessionId: string) => void>();
|
|
247
|
+
const lockListeners = new Set<(protectedNow: boolean) => void>();
|
|
248
|
+
|
|
249
|
+
function lockChanged(): void {
|
|
250
|
+
const prot = isProtected();
|
|
251
|
+
for (const cb of lockListeners) {
|
|
252
|
+
try {
|
|
253
|
+
cb(prot);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
console.error("[kcg-code] lock listener gagal:", e);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function settings() {
|
|
261
|
+
return store.getAuthSettings(now());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function profile(): AuthProfile {
|
|
265
|
+
const s = settings();
|
|
266
|
+
return {
|
|
267
|
+
nickname: s.nickname,
|
|
268
|
+
avatarUrl: s.avatarMime ? `/api/auth/avatar?v=${s.avatarVersion}` : null,
|
|
269
|
+
// Preset yang tidak dikenal lagi (katalog berubah) diabaikan.
|
|
270
|
+
avatarPreset: !s.avatarMime && isAvatarPresetId(s.avatarPreset) ? s.avatarPreset : null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isProtected(): boolean {
|
|
275
|
+
const s = settings();
|
|
276
|
+
return s.lockKind !== null && s.lockHash !== null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function ended(sessionId: string): void {
|
|
280
|
+
lastTouch.delete(sessionId);
|
|
281
|
+
for (const cb of endedListeners) cb(sessionId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Sesi valid: belum kedaluwarsa dan belum melewati batas idle. */
|
|
285
|
+
function isAlive(s: AuthSessionRow, t: number, autoLockMinutes: number): boolean {
|
|
286
|
+
if (s.expiresAt <= t) return false;
|
|
287
|
+
if (autoLockMinutes > 0 && t - s.lastSeenAt > autoLockMinutes * 60_000) return false;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findSession(token: string | null): AuthSessionRow | null {
|
|
292
|
+
if (!token || token.length < 32 || token.length > 128) return null;
|
|
293
|
+
const row = store.getAuthSessionByHash(hashToken(token));
|
|
294
|
+
if (!row) return null;
|
|
295
|
+
// Pembanding waktu-konstan (defensif; lookup DB sudah berdasarkan hash).
|
|
296
|
+
if (!constantTimeEqual(row.tokenHash, hashToken(token))) return null;
|
|
297
|
+
return row;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function authenticate(token: string | null): AuthSessionRow | null {
|
|
301
|
+
const row = findSession(token);
|
|
302
|
+
if (!row) return null;
|
|
303
|
+
const t = now();
|
|
304
|
+
if (!isAlive(row, t, settings().autoLockMinutes)) {
|
|
305
|
+
store.deleteAuthSession(row.id);
|
|
306
|
+
ended(row.id);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const last = lastTouch.get(row.id) ?? row.lastSeenAt;
|
|
310
|
+
if (t - last >= TOUCH_THROTTLE_MS) {
|
|
311
|
+
store.touchAuthSession(row.id, t);
|
|
312
|
+
lastTouch.set(row.id, t);
|
|
313
|
+
row.lastSeenAt = t;
|
|
314
|
+
}
|
|
315
|
+
return row;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isSessionAlive(sessionId: string): boolean {
|
|
319
|
+
const row = store.getAuthSessionById(sessionId);
|
|
320
|
+
if (!row) return false;
|
|
321
|
+
const t = now();
|
|
322
|
+
if (!isAlive(row, t, settings().autoLockMinutes)) {
|
|
323
|
+
store.deleteAuthSession(row.id);
|
|
324
|
+
ended(row.id);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
const last = lastTouch.get(row.id) ?? row.lastSeenAt;
|
|
328
|
+
if (t - last >= TOUCH_THROTTLE_MS) {
|
|
329
|
+
store.touchAuthSession(row.id, t);
|
|
330
|
+
lastTouch.set(row.id, t);
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function issue(ctx: LoginContext): IssuedSession {
|
|
336
|
+
const token = randomBytes(32).toString("base64url");
|
|
337
|
+
const t = now();
|
|
338
|
+
const session: AuthSessionRow = {
|
|
339
|
+
id: randomUUID(),
|
|
340
|
+
tokenHash: hashToken(token),
|
|
341
|
+
userAgent: ctx.userAgent ? ctx.userAgent.slice(0, 300) : null,
|
|
342
|
+
createdAt: t,
|
|
343
|
+
lastSeenAt: t,
|
|
344
|
+
expiresAt: t + SESSION_TTL_MS,
|
|
345
|
+
};
|
|
346
|
+
store.insertAuthSession(session);
|
|
347
|
+
lastTouch.set(session.id, t);
|
|
348
|
+
return { token, session };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ---- anti brute-force ----
|
|
352
|
+
|
|
353
|
+
function failureOf(key: string): FailureState {
|
|
354
|
+
const t = now();
|
|
355
|
+
const f = failures.get(key);
|
|
356
|
+
if (!f || t - f.lastAt > FAILURE_RESET_MS) return { count: 0, lastAt: t, lockedUntil: 0 };
|
|
357
|
+
return f;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function retryAfterMs(ip: string): number {
|
|
361
|
+
const t = now();
|
|
362
|
+
return Math.max(0, failureOf(ip).lockedUntil - t, failureOf(GLOBAL).lockedUntil - t);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function recordFailure(ip: string): void {
|
|
366
|
+
const t = now();
|
|
367
|
+
for (const [key, free] of [
|
|
368
|
+
[ip, FREE_ATTEMPTS],
|
|
369
|
+
[GLOBAL, GLOBAL_FREE_ATTEMPTS],
|
|
370
|
+
] as const) {
|
|
371
|
+
const f = failureOf(key);
|
|
372
|
+
const count = f.count + 1;
|
|
373
|
+
failures.set(key, { count, lastAt: t, lockedUntil: t + lockoutMs(count, free) });
|
|
374
|
+
}
|
|
375
|
+
// Batasi ukuran map (banyak IP berbeda).
|
|
376
|
+
if (failures.size > 1000) {
|
|
377
|
+
const oldest = [...failures.entries()].sort((a, b) => a[1].lastAt - b[1].lastAt)[0];
|
|
378
|
+
if (oldest && oldest[0] !== GLOBAL) failures.delete(oldest[0]);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function recordSuccess(ip: string): void {
|
|
383
|
+
failures.delete(ip);
|
|
384
|
+
// Global dikurangi, bukan dihapus: satu login sah tidak boleh membuka
|
|
385
|
+
// jalan bagi penyerang yang sedang menebak dari IP lain.
|
|
386
|
+
const g = failures.get(GLOBAL);
|
|
387
|
+
if (g) failures.set(GLOBAL, { ...g, count: Math.max(0, g.count - FREE_ATTEMPTS) });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Verifikasi sandi dengan penundaan anti brute-force. */
|
|
391
|
+
async function checkSecret(secret: string, ip: string): Promise<Result<null>> {
|
|
392
|
+
const wait = retryAfterMs(ip);
|
|
393
|
+
if (wait > 0) return { ok: false, error: `AUTH_RATE_LIMITED:${Math.ceil(wait / 1000)}` };
|
|
394
|
+
const s = settings();
|
|
395
|
+
if (typeof secret !== "string" || secret.length === 0 || secret.length > PASSWORD_MAX) {
|
|
396
|
+
recordFailure(ip);
|
|
397
|
+
return { ok: false, error: "AUTH_INVALID" };
|
|
398
|
+
}
|
|
399
|
+
let ok = false;
|
|
400
|
+
if (s.lockHash) {
|
|
401
|
+
ok = await verify(secret, s.lockHash);
|
|
402
|
+
} else {
|
|
403
|
+
// Tetap habiskan waktu verifikasi agar tidak membocorkan status.
|
|
404
|
+
dummyHashPromise ??= hash("kcg-dummy-secret");
|
|
405
|
+
await verify(secret, await dummyHashPromise);
|
|
406
|
+
}
|
|
407
|
+
if (!ok) {
|
|
408
|
+
recordFailure(ip);
|
|
409
|
+
const next = retryAfterMs(ip);
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
error: next > 0 ? `AUTH_RATE_LIMITED:${Math.ceil(next / 1000)}` : "AUTH_INVALID",
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
recordSuccess(ip);
|
|
416
|
+
return { ok: true, data: null };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- API ----
|
|
420
|
+
|
|
421
|
+
function status(token: string | null, ip: string): AuthStatus {
|
|
422
|
+
const s = settings();
|
|
423
|
+
const prot = s.lockKind !== null && s.lockHash !== null;
|
|
424
|
+
return {
|
|
425
|
+
protected: prot,
|
|
426
|
+
authenticated: !prot || authenticate(token) !== null,
|
|
427
|
+
lockKind: prot ? s.lockKind : null,
|
|
428
|
+
autoLockMinutes: s.autoLockMinutes,
|
|
429
|
+
profile: profile(),
|
|
430
|
+
retryAfterSec: Math.ceil(retryAfterMs(ip) / 1000),
|
|
431
|
+
needsOnboarding: !prot && s.onboardedAt === null,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function login(secret: string, ctx: LoginContext): Promise<Result<IssuedSession>> {
|
|
436
|
+
if (!isProtected()) return { ok: false, error: "AUTH_NOT_CONFIGURED" };
|
|
437
|
+
const res = await checkSecret(secret, ctx.ip);
|
|
438
|
+
if (!res.ok) return res;
|
|
439
|
+
return { ok: true, data: issue(ctx) };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function logout(token: string | null): void {
|
|
443
|
+
const row = findSession(token);
|
|
444
|
+
if (row) {
|
|
445
|
+
store.deleteAuthSession(row.id);
|
|
446
|
+
ended(row.id);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function revokeAllExcept(keepId: string | null): void {
|
|
451
|
+
for (const s of store.listAuthSessions()) {
|
|
452
|
+
if (s.id !== keepId) {
|
|
453
|
+
store.deleteAuthSession(s.id);
|
|
454
|
+
ended(s.id);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function setLock(
|
|
460
|
+
input: { kind: LockKind; secret: string; current?: string },
|
|
461
|
+
ctx: LoginContext,
|
|
462
|
+
): Promise<Result<IssuedSession>> {
|
|
463
|
+
if (input.kind !== "pin" && input.kind !== "password") {
|
|
464
|
+
return { ok: false, error: "LOCK_KIND_INVALID" };
|
|
465
|
+
}
|
|
466
|
+
const invalid = validateSecret(input.kind, input.secret);
|
|
467
|
+
if (invalid) return { ok: false, error: invalid };
|
|
468
|
+
if (isProtected()) {
|
|
469
|
+
const res = await checkSecret(input.current ?? "", ctx.ip);
|
|
470
|
+
if (!res.ok)
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
error: res.error === "AUTH_INVALID" ? "CURRENT_SECRET_INVALID" : res.error,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const h = await hash(input.secret);
|
|
477
|
+
store.setAuthLock(input.kind, h, now());
|
|
478
|
+
// Kunci diatur = instalasi sudah dikonfigurasi.
|
|
479
|
+
store.setAuthOnboarded(now());
|
|
480
|
+
// Sandi berubah -> seluruh sesi lama tidak berlaku lagi.
|
|
481
|
+
revokeAllExcept(null);
|
|
482
|
+
lockChanged();
|
|
483
|
+
return { ok: true, data: issue(ctx) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function removeLock(current: string, ctx: LoginContext): Promise<Result<null>> {
|
|
487
|
+
if (!isProtected()) return { ok: true, data: null };
|
|
488
|
+
const res = await checkSecret(current, ctx.ip);
|
|
489
|
+
if (!res.ok)
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
error: res.error === "AUTH_INVALID" ? "CURRENT_SECRET_INVALID" : res.error,
|
|
493
|
+
};
|
|
494
|
+
store.setAuthLock(null, null, now());
|
|
495
|
+
// Passkey hanya cara buka kunci TAMBAHAN — tanpa kunci, tidak berarti.
|
|
496
|
+
store.deleteAllPasskeys();
|
|
497
|
+
revokeAllExcept(null);
|
|
498
|
+
lockChanged();
|
|
499
|
+
return { ok: true, data: null };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function setNickname(nickname: string | null): Result<AuthProfile> {
|
|
503
|
+
const clean = nickname === null ? null : nickname.replace(/\s+/g, " ").trim();
|
|
504
|
+
if (clean !== null && clean.length > NICKNAME_MAX) {
|
|
505
|
+
return { ok: false, error: "NICKNAME_TOO_LONG" };
|
|
506
|
+
}
|
|
507
|
+
// Karakter kontrol ditolak (nama tampil di lock screen).
|
|
508
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: memang menyaring kontrol
|
|
509
|
+
if (clean !== null && /[\u0000-\u001f\u007f]/.test(clean)) {
|
|
510
|
+
return { ok: false, error: "NICKNAME_INVALID" };
|
|
511
|
+
}
|
|
512
|
+
store.setAuthNickname(clean === "" ? null : clean, now());
|
|
513
|
+
return { ok: true, data: profile() };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function setAvatar(bytes: Uint8Array | null, mime: string | null): Result<AuthProfile> {
|
|
517
|
+
if (bytes === null) {
|
|
518
|
+
store.setAuthAvatar(null, null, now());
|
|
519
|
+
return { ok: true, data: profile() };
|
|
520
|
+
}
|
|
521
|
+
if (bytes.length === 0) return { ok: false, error: "EMPTY_UPLOAD" };
|
|
522
|
+
if (bytes.length > AVATAR_MAX_BYTES) return { ok: false, error: "AVATAR_TOO_LARGE" };
|
|
523
|
+
const sniffed = sniffImageMime(bytes);
|
|
524
|
+
// Tipe berdasarkan isi file; MIME client hanya pelengkap.
|
|
525
|
+
if (!sniffed || (mime !== null && mime !== "" && !AVATAR_MIMES.includes(mime as never))) {
|
|
526
|
+
return { ok: false, error: "UNSUPPORTED_IMAGE_MIME" };
|
|
527
|
+
}
|
|
528
|
+
store.setAuthAvatar(bytes, sniffed, now());
|
|
529
|
+
return { ok: true, data: profile() };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function setAvatarPreset(preset: string): Result<AuthProfile> {
|
|
533
|
+
if (!isAvatarPresetId(preset)) return { ok: false, error: "AVATAR_PRESET_INVALID" };
|
|
534
|
+
store.setAuthAvatarPreset(preset, now());
|
|
535
|
+
return { ok: true, data: profile() };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function setAutoLock(minutes: number): Result<number> {
|
|
539
|
+
if (!AUTO_LOCK_CHOICES.includes(minutes as never)) {
|
|
540
|
+
return { ok: false, error: "AUTO_LOCK_INVALID" };
|
|
541
|
+
}
|
|
542
|
+
store.setAuthAutoLock(minutes, now());
|
|
543
|
+
return { ok: true, data: minutes };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function listDevices(currentToken: string | null): DeviceInfo[] {
|
|
547
|
+
const cur = findSession(currentToken);
|
|
548
|
+
const t = now();
|
|
549
|
+
const autoLock = settings().autoLockMinutes;
|
|
550
|
+
return store
|
|
551
|
+
.listAuthSessions()
|
|
552
|
+
.filter((s) => isAlive(s, t, autoLock))
|
|
553
|
+
.map((s) => ({
|
|
554
|
+
id: s.id,
|
|
555
|
+
userAgent: s.userAgent,
|
|
556
|
+
createdAt: s.createdAt,
|
|
557
|
+
lastSeenAt: s.lastSeenAt,
|
|
558
|
+
current: s.id === cur?.id,
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function revokeOthers(currentToken: string | null): void {
|
|
563
|
+
revokeAllExcept(findSession(currentToken)?.id ?? null);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function revokeDevice(id: string): void {
|
|
567
|
+
store.deleteAuthSession(id);
|
|
568
|
+
ended(id);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function sweep(): void {
|
|
572
|
+
const t = now();
|
|
573
|
+
const autoLock = settings().autoLockMinutes;
|
|
574
|
+
for (const s of store.listAuthSessions()) {
|
|
575
|
+
if (!isAlive(s, t, autoLock)) {
|
|
576
|
+
store.deleteAuthSession(s.id);
|
|
577
|
+
ended(s.id);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resetLock(): void {
|
|
583
|
+
store.setAuthLock(null, null, now());
|
|
584
|
+
store.deleteAllPasskeys();
|
|
585
|
+
revokeAllExcept(null);
|
|
586
|
+
lockChanged();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
status,
|
|
591
|
+
authenticate,
|
|
592
|
+
isSessionAlive,
|
|
593
|
+
isProtected,
|
|
594
|
+
login,
|
|
595
|
+
logout,
|
|
596
|
+
setLock,
|
|
597
|
+
removeLock,
|
|
598
|
+
setNickname,
|
|
599
|
+
setAvatar,
|
|
600
|
+
setAvatarPreset,
|
|
601
|
+
getAvatar: () => store.getAuthAvatar(),
|
|
602
|
+
setAutoLock,
|
|
603
|
+
completeOnboarding: () => store.setAuthOnboarded(now()),
|
|
604
|
+
listDevices,
|
|
605
|
+
revokeOthers,
|
|
606
|
+
revokeDevice,
|
|
607
|
+
onSessionEnded(cb) {
|
|
608
|
+
endedListeners.add(cb);
|
|
609
|
+
return () => endedListeners.delete(cb);
|
|
610
|
+
},
|
|
611
|
+
onLockChanged(cb) {
|
|
612
|
+
lockListeners.add(cb);
|
|
613
|
+
return () => lockListeners.delete(cb);
|
|
614
|
+
},
|
|
615
|
+
sweep,
|
|
616
|
+
resetLock,
|
|
617
|
+
internal: {
|
|
618
|
+
issueSession: issue,
|
|
619
|
+
async checkCurrentSecret(secret, ip) {
|
|
620
|
+
const res = await checkSecret(secret, ip);
|
|
621
|
+
if (res.ok) return res;
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
error: res.error === "AUTH_INVALID" ? "CURRENT_SECRET_INVALID" : res.error,
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
retryAfterSec: (ip) => Math.ceil(retryAfterMs(ip) / 1000),
|
|
628
|
+
recordFailure,
|
|
629
|
+
recordSuccess,
|
|
630
|
+
findSessionId: (token) => findSession(token)?.id ?? null,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|