@atercates/claude-deck 0.2.1 → 0.2.3

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 (49) hide show
  1. package/README.md +4 -18
  2. package/app/api/auth/login/route.ts +57 -0
  3. package/app/api/auth/logout/route.ts +13 -0
  4. package/app/api/auth/session/route.ts +29 -0
  5. package/app/api/auth/setup/route.ts +67 -0
  6. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +1 -1
  7. package/app/api/projects/[id]/repositories/[repoId]/route.ts +1 -1
  8. package/app/api/sessions/[id]/fork/route.ts +1 -1
  9. package/app/api/sessions/[id]/pr/route.ts +1 -1
  10. package/app/api/sessions/[id]/preview/route.ts +1 -1
  11. package/app/api/sessions/[id]/route.ts +13 -4
  12. package/app/api/sessions/[id]/send-keys/route.ts +1 -1
  13. package/app/api/sessions/route.ts +2 -2
  14. package/app/login/page.tsx +192 -0
  15. package/app/setup/page.tsx +279 -0
  16. package/components/ConductorPanel.tsx +1 -1
  17. package/components/DevServers/ServerLogsModal.tsx +24 -21
  18. package/components/DiffViewer/DiffModal.tsx +0 -1
  19. package/components/FileExplorer/index.tsx +1 -1
  20. package/components/GitDrawer/FileEditDialog.tsx +1 -1
  21. package/components/GitPanel/FileChanges.tsx +6 -2
  22. package/components/GitPanel/index.tsx +1 -1
  23. package/components/Pane/index.tsx +16 -15
  24. package/components/Projects/ProjectCard.tsx +1 -1
  25. package/components/QuickSwitcher.tsx +1 -0
  26. package/components/SessionList/SessionList.types.ts +1 -1
  27. package/components/SessionList/index.tsx +8 -8
  28. package/components/Terminal/hooks/useTerminalConnection.ts +3 -2
  29. package/components/Terminal/hooks/websocket-connection.ts +1 -0
  30. package/data/git/queries.ts +0 -1
  31. package/lib/auth/index.ts +15 -0
  32. package/lib/auth/password.ts +14 -0
  33. package/lib/auth/rate-limit.ts +40 -0
  34. package/lib/auth/session.ts +83 -0
  35. package/lib/auth/totp.ts +36 -0
  36. package/lib/claude/process-manager.ts +1 -1
  37. package/lib/code-search.ts +5 -5
  38. package/lib/db/index.ts +1 -1
  39. package/lib/db/queries.ts +64 -0
  40. package/lib/db/schema.ts +19 -0
  41. package/lib/db/types.ts +16 -0
  42. package/lib/git-history.ts +1 -1
  43. package/lib/git.ts +0 -1
  44. package/lib/multi-repo-git.ts +0 -1
  45. package/lib/projects.ts +29 -8
  46. package/package.json +8 -4
  47. package/scripts/agent-os +1 -1
  48. package/scripts/install.sh +2 -2
  49. package/server.ts +20 -1
@@ -0,0 +1,279 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { Loader2, Shield, Eye, EyeOff } from "lucide-react";
9
+
10
+ export default function SetupPage() {
11
+ const router = useRouter();
12
+ const [username, setUsername] = useState("");
13
+ const [password, setPassword] = useState("");
14
+ const [confirmPassword, setConfirmPassword] = useState("");
15
+ const [showPassword, setShowPassword] = useState(false);
16
+ const [enableTotp, setEnableTotp] = useState(false);
17
+ const [totpSecret, setTotpSecret] = useState("");
18
+ const [totpCode, setTotpCode] = useState("");
19
+ const [qrDataUrl, setQrDataUrl] = useState("");
20
+ const [error, setError] = useState("");
21
+ const [loading, setLoading] = useState(false);
22
+
23
+ useEffect(() => {
24
+ fetch("/api/auth/session").then(async (res) => {
25
+ const data = await res.json();
26
+ if (!data.needsSetup) {
27
+ router.push(data.authenticated ? "/" : "/login");
28
+ }
29
+ });
30
+ }, [router]);
31
+
32
+ useEffect(() => {
33
+ if (enableTotp && !totpSecret && username.length >= 3) {
34
+ generateTotp();
35
+ }
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit generateTotp and totpSecret to avoid regenerating secret on every render
37
+ }, [enableTotp, username]);
38
+
39
+ const generateTotp = async () => {
40
+ try {
41
+ const { TOTP, Secret } = await import("otpauth");
42
+ const secret = new Secret({ size: 20 });
43
+ const totp = new TOTP({
44
+ issuer: "ClaudeDeck",
45
+ label: username,
46
+ algorithm: "SHA1",
47
+ digits: 6,
48
+ period: 30,
49
+ secret,
50
+ });
51
+
52
+ const uri = totp.toString();
53
+ setTotpSecret(secret.base32);
54
+
55
+ const QRCode = await import("qrcode");
56
+ const dataUrl = await QRCode.toDataURL(uri, {
57
+ width: 200,
58
+ margin: 2,
59
+ color: { dark: "#ffffff", light: "#00000000" },
60
+ });
61
+ setQrDataUrl(dataUrl);
62
+ } catch (err) {
63
+ console.error("Failed to generate TOTP:", err);
64
+ }
65
+ };
66
+
67
+ const handleSubmit = async (e: React.FormEvent) => {
68
+ e.preventDefault();
69
+ setError("");
70
+
71
+ if (password !== confirmPassword) {
72
+ setError("Passwords do not match");
73
+ return;
74
+ }
75
+
76
+ if (password.length < 8) {
77
+ setError("Password must be at least 8 characters");
78
+ return;
79
+ }
80
+
81
+ if (enableTotp && totpCode.length !== 6) {
82
+ setError("Enter the 6-digit code from your authenticator app");
83
+ return;
84
+ }
85
+
86
+ setLoading(true);
87
+
88
+ try {
89
+ const res = await fetch("/api/auth/setup", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({
93
+ username,
94
+ password,
95
+ ...(enableTotp ? { totpSecret, totpCode } : {}),
96
+ }),
97
+ });
98
+
99
+ const data = await res.json();
100
+
101
+ if (!res.ok) {
102
+ setError(data.error || "Setup failed");
103
+ return;
104
+ }
105
+
106
+ router.push("/");
107
+ router.refresh();
108
+ } catch {
109
+ setError("Connection error");
110
+ } finally {
111
+ setLoading(false);
112
+ }
113
+ };
114
+
115
+ return (
116
+ <div className="bg-background flex min-h-screen items-center justify-center p-4">
117
+ <div className="border-border bg-card w-full max-w-md rounded-xl border p-8 shadow-lg">
118
+ <div className="mb-8 text-center">
119
+ <div className="bg-primary/10 text-primary mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
120
+ <Shield className="h-6 w-6" />
121
+ </div>
122
+ <h1 className="text-foreground text-2xl font-semibold">
123
+ Welcome to ClaudeDeck
124
+ </h1>
125
+ <p className="text-muted-foreground mt-1 text-sm">
126
+ Create your account to get started
127
+ </p>
128
+ </div>
129
+
130
+ <form onSubmit={handleSubmit} className="space-y-4">
131
+ <div className="space-y-2">
132
+ <label
133
+ htmlFor="username"
134
+ className="text-foreground text-sm font-medium"
135
+ >
136
+ Username
137
+ </label>
138
+ <Input
139
+ id="username"
140
+ type="text"
141
+ value={username}
142
+ onChange={(e) => setUsername(e.target.value)}
143
+ placeholder="admin"
144
+ autoComplete="username"
145
+ autoFocus
146
+ required
147
+ minLength={3}
148
+ maxLength={32}
149
+ pattern="[a-zA-Z0-9_]+"
150
+ />
151
+ </div>
152
+
153
+ <div className="space-y-2">
154
+ <label
155
+ htmlFor="password"
156
+ className="text-foreground text-sm font-medium"
157
+ >
158
+ Password
159
+ </label>
160
+ <div className="relative">
161
+ <Input
162
+ id="password"
163
+ type={showPassword ? "text" : "password"}
164
+ value={password}
165
+ onChange={(e) => setPassword(e.target.value)}
166
+ autoComplete="new-password"
167
+ required
168
+ minLength={8}
169
+ />
170
+ <button
171
+ type="button"
172
+ onClick={() => setShowPassword(!showPassword)}
173
+ className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
174
+ tabIndex={-1}
175
+ >
176
+ {showPassword ? (
177
+ <EyeOff className="h-4 w-4" />
178
+ ) : (
179
+ <Eye className="h-4 w-4" />
180
+ )}
181
+ </button>
182
+ </div>
183
+ </div>
184
+
185
+ <div className="space-y-2">
186
+ <label
187
+ htmlFor="confirmPassword"
188
+ className="text-foreground text-sm font-medium"
189
+ >
190
+ Confirm password
191
+ </label>
192
+ <Input
193
+ id="confirmPassword"
194
+ type={showPassword ? "text" : "password"}
195
+ value={confirmPassword}
196
+ onChange={(e) => setConfirmPassword(e.target.value)}
197
+ autoComplete="new-password"
198
+ required
199
+ minLength={8}
200
+ />
201
+ </div>
202
+
203
+ <div className="border-border flex items-center justify-between rounded-lg border p-3">
204
+ <div>
205
+ <p className="text-foreground text-sm font-medium">
206
+ Two-factor authentication
207
+ </p>
208
+ <p className="text-muted-foreground text-xs">
209
+ Secure your account with TOTP
210
+ </p>
211
+ </div>
212
+ <Switch checked={enableTotp} onCheckedChange={setEnableTotp} />
213
+ </div>
214
+
215
+ {enableTotp && qrDataUrl && (
216
+ <div className="border-border space-y-3 rounded-lg border p-4">
217
+ <p className="text-foreground text-center text-sm font-medium">
218
+ Scan with your authenticator app
219
+ </p>
220
+ <div className="flex justify-center">
221
+ {/* eslint-disable-next-line @next/next/no-img-element -- base64 data URL, next/image not applicable */}
222
+ <img
223
+ src={qrDataUrl}
224
+ alt="TOTP QR Code"
225
+ className="h-[200px] w-[200px]"
226
+ />
227
+ </div>
228
+ <div className="space-y-1">
229
+ <p className="text-muted-foreground text-center text-xs">
230
+ Or enter manually:
231
+ </p>
232
+ <code className="bg-muted text-foreground block rounded p-2 text-center font-mono text-xs break-all">
233
+ {totpSecret}
234
+ </code>
235
+ </div>
236
+ <div className="space-y-2">
237
+ <label
238
+ htmlFor="totpVerify"
239
+ className="text-foreground text-sm font-medium"
240
+ >
241
+ Verification code
242
+ </label>
243
+ <Input
244
+ id="totpVerify"
245
+ type="text"
246
+ inputMode="numeric"
247
+ pattern="[0-9]*"
248
+ maxLength={6}
249
+ value={totpCode}
250
+ onChange={(e) =>
251
+ setTotpCode(e.target.value.replace(/\D/g, ""))
252
+ }
253
+ placeholder="000000"
254
+ className="text-center text-lg tracking-[0.3em]"
255
+ autoComplete="one-time-code"
256
+ />
257
+ </div>
258
+ </div>
259
+ )}
260
+
261
+ {enableTotp && !qrDataUrl && username.length < 3 && (
262
+ <p className="text-muted-foreground text-center text-sm">
263
+ Enter a username (3+ characters) to generate the QR code
264
+ </p>
265
+ )}
266
+
267
+ {error && (
268
+ <p className="text-destructive text-center text-sm">{error}</p>
269
+ )}
270
+
271
+ <Button type="submit" className="w-full" disabled={loading}>
272
+ {loading && <Loader2 className="h-4 w-4 animate-spin" />}
273
+ Create account
274
+ </Button>
275
+ </form>
276
+ </div>
277
+ </div>
278
+ );
279
+ }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useEffect, useCallback } from "react";
4
- import { WorkerCard, type WorkerInfo, type WorkerStatus } from "./WorkerCard";
4
+ import { WorkerCard, type WorkerInfo } from "./WorkerCard";
5
5
  import { Button } from "./ui/button";
6
6
  import {
7
7
  RefreshCw,
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import { X, RefreshCw, Loader2 } from "lucide-react";
5
5
  import { cn } from "@/lib/utils";
6
6
 
@@ -20,31 +20,34 @@ export function ServerLogsModal({
20
20
  const [refreshing, setRefreshing] = useState(false);
21
21
  const logsRef = useRef<HTMLDivElement>(null);
22
22
 
23
- const fetchLogs = async (isRefresh = false) => {
24
- if (isRefresh) {
25
- setRefreshing(true);
26
- } else {
27
- setLoading(true);
28
- }
23
+ const fetchLogs = useCallback(
24
+ async (isRefresh = false) => {
25
+ if (isRefresh) {
26
+ setRefreshing(true);
27
+ } else {
28
+ setLoading(true);
29
+ }
29
30
 
30
- try {
31
- const res = await fetch(`/api/dev-servers/${serverId}/logs?lines=200`);
32
- if (res.ok) {
33
- const data = await res.json();
34
- setLogs(data.logs || []);
31
+ try {
32
+ const res = await fetch(`/api/dev-servers/${serverId}/logs?lines=200`);
33
+ if (res.ok) {
34
+ const data = await res.json();
35
+ setLogs(data.logs || []);
36
+ }
37
+ } catch (err) {
38
+ console.error("Failed to fetch logs:", err);
39
+ } finally {
40
+ setLoading(false);
41
+ setRefreshing(false);
35
42
  }
36
- } catch (err) {
37
- console.error("Failed to fetch logs:", err);
38
- } finally {
39
- setLoading(false);
40
- setRefreshing(false);
41
- }
42
- };
43
+ },
44
+ [serverId]
45
+ );
43
46
 
44
47
  // Initial fetch
45
48
  useEffect(() => {
46
49
  fetchLogs();
47
- }, [serverId]);
50
+ }, [fetchLogs]);
48
51
 
49
52
  // Auto-scroll to bottom when logs update
50
53
  useEffect(() => {
@@ -59,7 +62,7 @@ export function ServerLogsModal({
59
62
  fetchLogs(true);
60
63
  }, 3000);
61
64
  return () => clearInterval(interval);
62
- }, [serverId]);
65
+ }, [fetchLogs]);
63
66
 
64
67
  return (
65
68
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
@@ -4,7 +4,6 @@ import { X, Plus, Minus, ChevronLeft } from "lucide-react";
4
4
  import { Button } from "@/components/ui/button";
5
5
  import { UnifiedDiff } from "./UnifiedDiff";
6
6
  import { parseDiff, getDiffFileName, getDiffSummary } from "@/lib/diff-parser";
7
- import { cn } from "@/lib/utils";
8
7
 
9
8
  interface DiffModalProps {
10
9
  diff: string;
@@ -213,7 +213,7 @@ function DesktopFileExplorer({
213
213
  openFiles,
214
214
  activeFilePath,
215
215
  activeFile,
216
- saving,
216
+ saving: _saving,
217
217
  onFileClick,
218
218
  onSelectTab,
219
219
  onCloseTab,
@@ -108,7 +108,7 @@ export function FileEditDialog({
108
108
  : baseDir;
109
109
  const filePath = `${expandedBaseDir}/${file.path}`;
110
110
  const fileName = file.path.split("/").pop() || file.path;
111
- const repoName = "repoName" in file ? file.repoName : null;
111
+ const _repoName = "repoName" in file ? file.repoName : null;
112
112
  const hasChanges = modifiedContent !== initialModified;
113
113
 
114
114
  useEffect(() => {
@@ -44,7 +44,7 @@ const SWIPE_THRESHOLD = 80;
44
44
  export function FileChanges({
45
45
  files,
46
46
  title,
47
- emptyMessage,
47
+ emptyMessage: _emptyMessage,
48
48
  selectedPath,
49
49
  onFileClick,
50
50
  onStage,
@@ -99,7 +99,11 @@ export function FileChanges({
99
99
  <button
100
100
  onClick={(e) => {
101
101
  e.stopPropagation();
102
- isStaged ? onUnstageAll?.() : onStageAll?.();
102
+ if (isStaged) {
103
+ onUnstageAll?.();
104
+ } else {
105
+ onStageAll?.();
106
+ }
103
107
  }}
104
108
  className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs transition-colors"
105
109
  >
@@ -572,7 +572,7 @@ function MobileGitPanel({
572
572
  onUnstageAll,
573
573
  onBack,
574
574
  onCommit,
575
- onShowPRModal,
575
+ onShowPRModal: _onShowPRModal,
576
576
  onClosePRModal,
577
577
  onCreatePR,
578
578
  }: MobileGitPanelProps) {
@@ -4,10 +4,7 @@ import { useRef, useCallback, useEffect, memo, useState, useMemo } from "react";
4
4
  import dynamic from "next/dynamic";
5
5
  import { usePanes } from "@/contexts/PaneContext";
6
6
  import { useViewport } from "@/hooks/useViewport";
7
- import type {
8
- TerminalHandle,
9
- TerminalScrollState,
10
- } from "@/components/Terminal";
7
+ import type { TerminalHandle } from "@/components/Terminal";
11
8
  import type { Session, Project } from "@/lib/db";
12
9
  import { sessionRegistry } from "@/lib/client/session-registry";
13
10
  import { cn } from "@/lib/utils";
@@ -114,16 +111,20 @@ export const Pane = memo(function Pane({
114
111
  ? (terminalRefs.current.get(activeTab.id) ?? null)
115
112
  : null;
116
113
  const isFocused = focusedPaneId === paneId;
117
- const session = activeTab
118
- ? (sessions.find((s) => s.id === activeTab.sessionId) ??
119
- (activeTab.sessionId
120
- ? ({
121
- id: activeTab.sessionId,
122
- name: activeTab.sessionName || activeTab.sessionId.slice(0, 8),
123
- working_directory: activeTab.workingDirectory || "~",
124
- } as Session)
125
- : null))
126
- : null;
114
+ const session = useMemo(
115
+ () =>
116
+ activeTab
117
+ ? (sessions.find((s) => s.id === activeTab.sessionId) ??
118
+ (activeTab.sessionId
119
+ ? ({
120
+ id: activeTab.sessionId,
121
+ name: activeTab.sessionName || activeTab.sessionId.slice(0, 8),
122
+ working_directory: activeTab.workingDirectory || "~",
123
+ } as Session)
124
+ : null))
125
+ : null,
126
+ [activeTab, sessions]
127
+ );
127
128
 
128
129
  // File editor state - lifted here so it persists across view switches
129
130
  const fileEditor = useFileEditor();
@@ -236,7 +237,7 @@ export const Pane = memo(function Pane({
236
237
  setTimeout(() => handle.sendCommand(`tmux attach -t ${tmuxName}`), 100);
237
238
  }
238
239
  },
239
- [paneId, sessions, onRegisterTerminal]
240
+ [paneId, paneData, sessions, onRegisterTerminal]
240
241
  );
241
242
 
242
243
  // Track current tab ID for cleanup
@@ -108,7 +108,7 @@ export function ProjectCard({
108
108
  setIsEditing(false);
109
109
  };
110
110
 
111
- const handleClick = (e: React.MouseEvent) => {
111
+ const handleClick = (_e: React.MouseEvent) => {
112
112
  if (isEditing) return;
113
113
  onClick?.();
114
114
  onToggleExpanded?.(!project.expanded);
@@ -100,6 +100,7 @@ export function QuickSwitcher({
100
100
  (a, b) =>
101
101
  new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
102
102
  );
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when .data changes, not entire query objects
103
104
  }, [s0.data, s1.data, s2.data, s3.data, topProjects]);
104
105
 
105
106
  const filteredSessions = useMemo(() => {
@@ -1,4 +1,4 @@
1
- import type { Session, Group } from "@/lib/db";
1
+ import type { Session } from "@/lib/db";
2
2
 
3
3
  export interface SessionStatus {
4
4
  sessionName: string;
@@ -23,14 +23,14 @@ import type { SessionListProps } from "./SessionList.types";
23
23
  export type { SessionListProps } from "./SessionList.types";
24
24
 
25
25
  export function SessionList({
26
- activeSessionId,
27
- sessionStatuses,
26
+ activeSessionId: _activeSessionId,
27
+ sessionStatuses: _sessionStatuses,
28
28
  onSelect,
29
- onOpenInTab,
30
- onNewSessionInProject,
31
- onOpenTerminal,
32
- onStartDevServer,
33
- onCreateDevServer,
29
+ onOpenInTab: _onOpenInTab,
30
+ onNewSessionInProject: _onNewSessionInProject,
31
+ onOpenTerminal: _onOpenTerminal,
32
+ onStartDevServer: _onStartDevServer,
33
+ onCreateDevServer: _onCreateDevServer,
34
34
  onResumeClaudeSession,
35
35
  onNewSession,
36
36
  }: SessionListProps) {
@@ -66,7 +66,7 @@ export function SessionList({
66
66
  rect: DOMRect;
67
67
  } | null>(null);
68
68
 
69
- const hoverHandlers = {
69
+ const _hoverHandlers = {
70
70
  onHoverStart: useCallback(
71
71
  (_session: Session, _rect: DOMRect) => {
72
72
  if (isMobile) return;
@@ -131,6 +131,7 @@ export function useTerminalConnection({
131
131
  useEffect(() => {
132
132
  if (!terminalRef.current) return;
133
133
 
134
+ const terminalElement = terminalRef.current;
134
135
  let cancelled = false;
135
136
  // Reset intentional close flag (may be true from previous cleanup)
136
137
  intentionalCloseRef.current = false;
@@ -208,9 +209,9 @@ export function useTerminalConnection({
208
209
 
209
210
  // Save scroll state before unmount
210
211
  const term = xtermRef.current;
211
- if (term && callbacksRef.current.onBeforeUnmount && terminalRef.current) {
212
+ if (term && callbacksRef.current.onBeforeUnmount && terminalElement) {
212
213
  const buffer = term.buffer.active;
213
- const viewport = terminalRef.current.querySelector(
214
+ const viewport = terminalElement.querySelector(
214
215
  ".xterm-viewport"
215
216
  ) as HTMLElement;
216
217
  callbacksRef.current.onBeforeUnmount({
@@ -57,6 +57,7 @@ export function createWebSocketConnection(
57
57
 
58
58
  // Force reconnect - kills any existing connection and creates fresh one
59
59
  // Note: savedHandlers is populated after handlers are defined below
60
+ // eslint-disable-next-line prefer-const -- assigned after handler definitions below
60
61
  let savedHandlers: {
61
62
  onopen: typeof ws.onopen;
62
63
  onmessage: typeof ws.onmessage;
@@ -6,7 +6,6 @@ export { gitKeys };
6
6
  import type { CommitSummary, CommitDetail } from "@/lib/git-history";
7
7
  import type { GitStatus } from "@/lib/git-status";
8
8
  import type { MultiRepoGitStatus } from "@/lib/multi-repo-git";
9
- import type { ProjectRepository } from "@/lib/db";
10
9
 
11
10
  export interface PRInfo {
12
11
  number: number;
@@ -0,0 +1,15 @@
1
+ export { hashPassword, verifyPassword } from "./password";
2
+ export { generateTotpSecret, verifyTotpCode } from "./totp";
3
+ export {
4
+ createSession,
5
+ validateSession,
6
+ renewSession,
7
+ deleteSession,
8
+ cleanupExpiredSessions,
9
+ COOKIE_NAME,
10
+ buildSessionCookie,
11
+ buildClearCookie,
12
+ parseCookies,
13
+ hasUsers,
14
+ } from "./session";
15
+ export { checkRateLimit } from "./rate-limit";
@@ -0,0 +1,14 @@
1
+ import bcrypt from "bcryptjs";
2
+
3
+ const BCRYPT_COST = 12;
4
+
5
+ export async function hashPassword(password: string): Promise<string> {
6
+ return bcrypt.hash(password, BCRYPT_COST);
7
+ }
8
+
9
+ export async function verifyPassword(
10
+ password: string,
11
+ hash: string
12
+ ): Promise<boolean> {
13
+ return bcrypt.compare(password, hash);
14
+ }
@@ -0,0 +1,40 @@
1
+ const MAX_ATTEMPTS = 5;
2
+ const WINDOW_MS = 15 * 60 * 1000;
3
+
4
+ interface RateLimitEntry {
5
+ count: number;
6
+ resetAt: number;
7
+ }
8
+
9
+ const attempts = new Map<string, RateLimitEntry>();
10
+
11
+ setInterval(
12
+ () => {
13
+ const now = Date.now();
14
+ for (const [ip, entry] of attempts) {
15
+ if (entry.resetAt < now) attempts.delete(ip);
16
+ }
17
+ },
18
+ 5 * 60 * 1000
19
+ );
20
+
21
+ export function checkRateLimit(ip: string): {
22
+ allowed: boolean;
23
+ retryAfterSeconds?: number;
24
+ } {
25
+ const now = Date.now();
26
+ const entry = attempts.get(ip);
27
+
28
+ if (!entry || entry.resetAt < now) {
29
+ attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
30
+ return { allowed: true };
31
+ }
32
+
33
+ if (entry.count >= MAX_ATTEMPTS) {
34
+ const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
35
+ return { allowed: false, retryAfterSeconds };
36
+ }
37
+
38
+ entry.count++;
39
+ return { allowed: true };
40
+ }