@aion0/forge 0.2.11 → 0.2.13

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.
@@ -29,6 +29,165 @@ function SecretInput({ value, onChange, placeholder, className }: {
29
29
  );
30
30
  }
31
31
 
32
+ // ─── Secret Change Dialog ──────────────────────────────────────
33
+
34
+ function SecretChangeDialog({ field, label, isSet, onSave, onClose }: {
35
+ field: string;
36
+ label: string;
37
+ isSet: boolean;
38
+ onSave: (field: string, adminPassword: string, newValue: string) => Promise<string | null>;
39
+ onClose: () => void;
40
+ }) {
41
+ const [mode, setMode] = useState<'change' | 'clear'>('change');
42
+ const [adminPassword, setAdminPassword] = useState('');
43
+ const [newValue, setNewValue] = useState('');
44
+ const [confirmValue, setConfirmValue] = useState('');
45
+ const [error, setError] = useState('');
46
+ const [saving, setSaving] = useState(false);
47
+
48
+ const canSave = mode === 'clear'
49
+ ? adminPassword.length > 0
50
+ : (adminPassword.length > 0 && newValue.length > 0 && newValue === confirmValue);
51
+
52
+ const handleSave = async () => {
53
+ if (mode === 'change' && newValue !== confirmValue) {
54
+ setError('New values do not match');
55
+ return;
56
+ }
57
+ setSaving(true);
58
+ setError('');
59
+ const err = await onSave(field, adminPassword, mode === 'clear' ? '' : newValue);
60
+ setSaving(false);
61
+ if (err) {
62
+ setError(err);
63
+ } else {
64
+ onClose();
65
+ }
66
+ };
67
+
68
+ const inputClass = "w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
69
+
70
+ return (
71
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]" onClick={onClose}>
72
+ <div
73
+ className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[380px] p-4 space-y-3"
74
+ onClick={e => e.stopPropagation()}
75
+ >
76
+ <div className="flex items-center justify-between">
77
+ <h3 className="text-xs font-bold">{isSet ? `Change ${label}` : `Set ${label}`}</h3>
78
+ {isSet && (
79
+ <div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
80
+ <button
81
+ onClick={() => { setMode('change'); setError(''); }}
82
+ className={`text-[10px] px-2 py-0.5 rounded ${mode === 'change' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)]'}`}
83
+ >
84
+ Change
85
+ </button>
86
+ <button
87
+ onClick={() => { setMode('clear'); setError(''); }}
88
+ className={`text-[10px] px-2 py-0.5 rounded ${mode === 'clear' ? 'bg-[var(--red)] text-white shadow-sm' : 'text-[var(--text-secondary)]'}`}
89
+ >
90
+ Clear
91
+ </button>
92
+ </div>
93
+ )}
94
+ </div>
95
+
96
+ <div className="space-y-1">
97
+ <label className="text-[10px] text-[var(--text-secondary)]">Admin password (login password)</label>
98
+ <SecretInput
99
+ value={adminPassword}
100
+ onChange={v => { setAdminPassword(v); setError(''); }}
101
+ placeholder="Enter login password to verify"
102
+ className={inputClass}
103
+ />
104
+ </div>
105
+
106
+ {mode === 'change' && (
107
+ <>
108
+ <div className="space-y-1">
109
+ <label className="text-[10px] text-[var(--text-secondary)]">New value</label>
110
+ <SecretInput
111
+ value={newValue}
112
+ onChange={v => { setNewValue(v); setError(''); }}
113
+ placeholder="Enter new value"
114
+ className={inputClass}
115
+ />
116
+ </div>
117
+
118
+ <div className="space-y-1">
119
+ <label className="text-[10px] text-[var(--text-secondary)]">Confirm new value</label>
120
+ <SecretInput
121
+ value={confirmValue}
122
+ onChange={v => { setConfirmValue(v); setError(''); }}
123
+ placeholder="Re-enter new value"
124
+ className={inputClass}
125
+ />
126
+ {confirmValue && newValue !== confirmValue && (
127
+ <p className="text-[9px] text-[var(--red)]">Values do not match</p>
128
+ )}
129
+ </div>
130
+ </>
131
+ )}
132
+
133
+ {mode === 'clear' && (
134
+ <p className="text-[10px] text-[var(--text-secondary)]">
135
+ Enter admin password to verify, then click Clear to remove this value.
136
+ </p>
137
+ )}
138
+
139
+ {error && <p className="text-[10px] text-[var(--red)]">{error}</p>}
140
+
141
+ <div className="flex justify-end gap-2 pt-1">
142
+ <button
143
+ onClick={onClose}
144
+ className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
145
+ >
146
+ Cancel
147
+ </button>
148
+ <button
149
+ onClick={handleSave}
150
+ disabled={!canSave || saving}
151
+ className={`px-3 py-1.5 text-xs text-white rounded hover:opacity-90 disabled:opacity-50 ${mode === 'clear' ? 'bg-[var(--red)]' : 'bg-[var(--accent)]'}`}
152
+ >
153
+ {saving ? 'Saving...' : mode === 'clear' ? 'Clear' : 'Save'}
154
+ </button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // ─── Secret Field Display ──────────────────────────────────────
162
+
163
+ function SecretField({ label, description, isSet, onEdit }: {
164
+ label: string;
165
+ description?: string;
166
+ isSet: boolean;
167
+ onEdit: () => void;
168
+ }) {
169
+ return (
170
+ <div className="space-y-1">
171
+ {description && (
172
+ <label className="text-[10px] text-[var(--text-secondary)]">{description}</label>
173
+ )}
174
+ <div className="flex items-center gap-2">
175
+ <div className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs font-mono text-[var(--text-secondary)]">
176
+ {isSet ? '••••••••' : <span className="italic">Not set</span>}
177
+ </div>
178
+ <button
179
+ onClick={onEdit}
180
+ className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
181
+ >
182
+ {isSet ? 'Change' : 'Set'}
183
+ </button>
184
+ </div>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ // ─── Settings Modal ────────────────────────────────────────────
190
+
32
191
  interface Settings {
33
192
  projectRoots: string[];
34
193
  docRoots: string[];
@@ -43,6 +202,7 @@ interface Settings {
43
202
  pipelineModel: string;
44
203
  telegramModel: string;
45
204
  skipPermissions: boolean;
205
+ _secretStatus?: Record<string, boolean>;
46
206
  }
47
207
 
48
208
  interface TunnelStatus {
@@ -69,6 +229,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
69
229
  telegramModel: 'sonnet',
70
230
  skipPermissions: false,
71
231
  });
232
+ const [secretStatus, setSecretStatus] = useState<Record<string, boolean>>({});
72
233
  const [newRoot, setNewRoot] = useState('');
73
234
  const [newDocRoot, setNewDocRoot] = useState('');
74
235
  const [saved, setSaved] = useState(false);
@@ -77,15 +238,28 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
77
238
  });
78
239
  const [tunnelLoading, setTunnelLoading] = useState(false);
79
240
  const [confirmStopTunnel, setConfirmStopTunnel] = useState(false);
241
+ const [tunnelPasswordPrompt, setTunnelPasswordPrompt] = useState(false);
242
+ const [tunnelPassword, setTunnelPassword] = useState('');
243
+ const [tunnelPasswordError, setTunnelPasswordError] = useState('');
244
+ const [editingSecret, setEditingSecret] = useState<{ field: string; label: string } | null>(null);
80
245
 
81
246
  const refreshTunnel = useCallback(() => {
82
247
  fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
83
248
  }, []);
84
249
 
250
+ const fetchSettings = useCallback(() => {
251
+ fetch('/api/settings').then(r => r.json()).then((data: Settings) => {
252
+ const status = data._secretStatus || {};
253
+ delete data._secretStatus;
254
+ setSettings(data);
255
+ setSecretStatus(status);
256
+ });
257
+ }, []);
258
+
85
259
  useEffect(() => {
86
- fetch('/api/settings').then(r => r.json()).then(setSettings);
260
+ fetchSettings();
87
261
  refreshTunnel();
88
- }, [refreshTunnel]);
262
+ }, [fetchSettings, refreshTunnel]);
89
263
 
90
264
  // Poll tunnel status while starting
91
265
  useEffect(() => {
@@ -104,6 +278,19 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
104
278
  setTimeout(() => setSaved(false), 2000);
105
279
  };
106
280
 
281
+ const saveSecret = async (field: string, adminPassword: string, newValue: string): Promise<string | null> => {
282
+ const res = await fetch('/api/settings', {
283
+ method: 'PUT',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify({ _secretUpdate: { field, adminPassword, newValue } }),
286
+ });
287
+ const data = await res.json();
288
+ if (!data.ok) return data.error || 'Failed to save';
289
+ // Refresh status
290
+ setSecretStatus(prev => ({ ...prev, [field]: !!newValue }));
291
+ return null;
292
+ };
293
+
107
294
  const addRoot = () => {
108
295
  const path = newRoot.trim();
109
296
  if (!path || settings.projectRoots.includes(path)) return;
@@ -242,12 +429,15 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
242
429
  <p className="text-[10px] text-[var(--text-secondary)]">
243
430
  Get notified when tasks complete or fail. Create a bot via @BotFather, then send /start to it and use the test button below to get your chat ID.
244
431
  </p>
245
- <SecretInput
246
- value={settings.telegramBotToken}
247
- onChange={v => setSettings({ ...settings, telegramBotToken: v })}
248
- placeholder="Bot token (from @BotFather)"
249
- className="w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
432
+
433
+ <SecretField
434
+ label="Bot Token"
435
+ description="Telegram Bot API token (from @BotFather)"
436
+ isSet={!!secretStatus.telegramBotToken}
437
+ onEdit={() => setEditingSecret({ field: 'telegramBotToken', label: 'Bot Token' })}
438
+
250
439
  />
440
+
251
441
  <input
252
442
  value={settings.telegramChatId}
253
443
  onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
@@ -276,7 +466,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
276
466
  />
277
467
  Notify on failure
278
468
  </label>
279
- {settings.telegramBotToken && settings.telegramChatId && (
469
+ {secretStatus.telegramBotToken && settings.telegramChatId && (
280
470
  <button
281
471
  type="button"
282
472
  onClick={async () => {
@@ -377,25 +567,82 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
377
567
 
378
568
  <div className="flex items-center gap-2">
379
569
  {tunnel.status === 'stopped' || tunnel.status === 'error' ? (
570
+ tunnelPasswordPrompt ? (
571
+ <div className="flex items-center gap-2">
572
+ <input
573
+ type="password"
574
+ value={tunnelPassword}
575
+ onChange={e => { setTunnelPassword(e.target.value); setTunnelPasswordError(''); }}
576
+ onKeyDown={async e => {
577
+ if (e.key === 'Enter' && tunnelPassword) {
578
+ setTunnelLoading(true);
579
+ setTunnelPasswordError('');
580
+ try {
581
+ const res = await fetch('/api/tunnel', {
582
+ method: 'POST',
583
+ headers: { 'Content-Type': 'application/json' },
584
+ body: JSON.stringify({ action: 'start', password: tunnelPassword }),
585
+ });
586
+ const data = await res.json();
587
+ if (res.status === 403) {
588
+ setTunnelPasswordError('Wrong password');
589
+ } else {
590
+ setTunnel(data);
591
+ setTunnelPasswordPrompt(false);
592
+ setTunnelPassword('');
593
+ }
594
+ } catch {}
595
+ setTunnelLoading(false);
596
+ }
597
+ }}
598
+ placeholder="Login password"
599
+ autoFocus
600
+ className={`w-[140px] text-[10px] px-2 py-1 bg-[var(--bg-tertiary)] border rounded font-mono focus:outline-none ${
601
+ tunnelPasswordError ? 'border-[var(--red)]' : 'border-[var(--border)] focus:border-[var(--accent)]'
602
+ } text-[var(--text-primary)]`}
603
+ />
604
+ <button
605
+ disabled={!tunnelPassword || tunnelLoading}
606
+ onClick={async () => {
607
+ setTunnelLoading(true);
608
+ setTunnelPasswordError('');
609
+ try {
610
+ const res = await fetch('/api/tunnel', {
611
+ method: 'POST',
612
+ headers: { 'Content-Type': 'application/json' },
613
+ body: JSON.stringify({ action: 'start', password: tunnelPassword }),
614
+ });
615
+ const data = await res.json();
616
+ if (res.status === 403) {
617
+ setTunnelPasswordError('Wrong password');
618
+ } else {
619
+ setTunnel(data);
620
+ setTunnelPasswordPrompt(false);
621
+ setTunnelPassword('');
622
+ }
623
+ } catch {}
624
+ setTunnelLoading(false);
625
+ }}
626
+ className="text-[10px] px-2 py-1 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
627
+ >
628
+ {tunnelLoading ? 'Starting...' : 'Start'}
629
+ </button>
630
+ <button
631
+ onClick={() => { setTunnelPasswordPrompt(false); setTunnelPassword(''); setTunnelPasswordError(''); }}
632
+ className="text-[10px] px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
633
+ >
634
+ Cancel
635
+ </button>
636
+ {tunnelPasswordError && <span className="text-[9px] text-[var(--red)]">{tunnelPasswordError}</span>}
637
+ </div>
638
+ ) : (
380
639
  <button
381
- disabled={tunnelLoading}
382
- onClick={async () => {
383
- setTunnelLoading(true);
384
- try {
385
- const res = await fetch('/api/tunnel', {
386
- method: 'POST',
387
- headers: { 'Content-Type': 'application/json' },
388
- body: JSON.stringify({ action: 'start' }),
389
- });
390
- const data = await res.json();
391
- setTunnel(data);
392
- } catch {}
393
- setTunnelLoading(false);
394
- }}
395
- className="text-[10px] px-3 py-1.5 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
640
+ onClick={() => setTunnelPasswordPrompt(true)}
641
+ className="text-[10px] px-3 py-1.5 bg-[var(--green)] text-black rounded hover:opacity-90"
396
642
  >
397
- {tunnelLoading ? (tunnel.installed ? 'Starting...' : 'Downloading...') : 'Start Tunnel'}
643
+ Start Tunnel
398
644
  </button>
645
+ )
399
646
  ) : confirmStopTunnel ? (
400
647
  <div className="flex items-center gap-2">
401
648
  <span className="text-[10px] text-[var(--text-secondary)]">Stop tunnel?</span>
@@ -487,17 +734,21 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
487
734
  Auto-start tunnel on server startup
488
735
  </label>
489
736
 
490
- <div className="space-y-1">
491
- <label className="text-[10px] text-[var(--text-secondary)]">
492
- Telegram tunnel password (for /tunnel_password command)
493
- </label>
494
- <SecretInput
495
- value={settings.telegramTunnelPassword}
496
- onChange={v => setSettings({ ...settings, telegramTunnelPassword: v })}
497
- placeholder="Set a password to get login credentials via Telegram"
498
- className="w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
499
- />
500
- </div>
737
+ </div>
738
+
739
+ {/* Admin Password */}
740
+ <div className="space-y-2">
741
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
742
+ Admin Password
743
+ </label>
744
+ <p className="text-[10px] text-[var(--text-secondary)]">
745
+ Used for local login, tunnel start, secret changes, and Telegram commands. Remote login requires admin password + session code (generated on tunnel start).
746
+ </p>
747
+ <SecretField
748
+ label="Admin Password"
749
+ isSet={!!secretStatus.telegramTunnelPassword}
750
+ onEdit={() => setEditingSecret({ field: 'telegramTunnelPassword', label: 'Admin Password' })}
751
+ />
501
752
  </div>
502
753
 
503
754
  {/* Actions */}
@@ -521,6 +772,17 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
521
772
  </div>
522
773
  </div>
523
774
  </div>
775
+
776
+ {/* Secret Change Dialog */}
777
+ {editingSecret && (
778
+ <SecretChangeDialog
779
+ field={editingSecret.field}
780
+ label={editingSecret.label}
781
+ isSet={!!secretStatus[editingSecret.field]}
782
+ onSave={saveSecret}
783
+ onClose={() => setEditingSecret(null)}
784
+ />
785
+ )}
524
786
  </div>
525
787
  );
526
788
  }
@@ -14,6 +14,9 @@ export default function TunnelToggle() {
14
14
  const [copied, setCopied] = useState(false);
15
15
  const [isRemote, setIsRemote] = useState(false);
16
16
  const [confirmStop, setConfirmStop] = useState(false);
17
+ const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
18
+ const [password, setPassword] = useState('');
19
+ const [passwordError, setPasswordError] = useState('');
17
20
 
18
21
  useEffect(() => {
19
22
  setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
@@ -50,16 +53,24 @@ export default function TunnelToggle() {
50
53
  setConfirmStop(false);
51
54
  };
52
55
 
53
- const doStart = async () => {
56
+ const doStart = async (pw: string) => {
54
57
  setLoading(true);
58
+ setPasswordError('');
55
59
  try {
56
60
  const res = await fetch('/api/tunnel', {
57
61
  method: 'POST',
58
62
  headers: { 'Content-Type': 'application/json' },
59
- body: JSON.stringify({ action: 'start' }),
63
+ body: JSON.stringify({ action: 'start', password: pw }),
60
64
  });
61
65
  const data = await res.json();
66
+ if (res.status === 403) {
67
+ setPasswordError('Wrong password');
68
+ setLoading(false);
69
+ return;
70
+ }
62
71
  setTunnel(data);
72
+ setShowPasswordPrompt(false);
73
+ setPassword('');
63
74
  } catch {}
64
75
  setLoading(false);
65
76
  };
@@ -77,6 +88,38 @@ export default function TunnelToggle() {
77
88
  return null;
78
89
  }
79
90
 
91
+ // Password prompt
92
+ if (showPasswordPrompt) {
93
+ return (
94
+ <div className="flex items-center gap-1.5">
95
+ <input
96
+ type="password"
97
+ value={password}
98
+ onChange={e => { setPassword(e.target.value); setPasswordError(''); }}
99
+ onKeyDown={e => e.key === 'Enter' && password && doStart(password)}
100
+ placeholder="Login password"
101
+ autoFocus
102
+ className={`w-[120px] text-[10px] px-2 py-0.5 bg-[var(--bg-tertiary)] border rounded font-mono focus:outline-none ${
103
+ passwordError ? 'border-[var(--red)]' : 'border-[var(--border)] focus:border-[var(--accent)]'
104
+ } text-[var(--text-primary)]`}
105
+ />
106
+ <button
107
+ onClick={() => password && doStart(password)}
108
+ disabled={!password || loading}
109
+ className="text-[10px] px-2 py-0.5 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
110
+ >
111
+ {loading ? 'Starting...' : 'Start'}
112
+ </button>
113
+ <button
114
+ onClick={() => { setShowPasswordPrompt(false); setPassword(''); setPasswordError(''); }}
115
+ className="text-[10px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
116
+ >
117
+ Cancel
118
+ </button>
119
+ </div>
120
+ );
121
+ }
122
+
80
123
  // Stop confirmation dialog
81
124
  if (confirmStop) {
82
125
  return (
@@ -102,12 +145,12 @@ export default function TunnelToggle() {
102
145
  if (tunnel.status === 'stopped' && !tunnel.error) {
103
146
  return (
104
147
  <button
105
- onClick={doStart}
148
+ onClick={() => setShowPasswordPrompt(true)}
106
149
  disabled={loading}
107
150
  className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors disabled:opacity-50"
108
151
  title="Start Cloudflare Tunnel for remote access"
109
152
  >
110
- {loading ? 'Starting...' : 'Tunnel'}
153
+ Tunnel
111
154
  </button>
112
155
  );
113
156
  }
@@ -149,7 +192,7 @@ export default function TunnelToggle() {
149
192
  Tunnel error
150
193
  </span>
151
194
  <button
152
- onClick={doStart}
195
+ onClick={() => setShowPasswordPrompt(true)}
153
196
  disabled={loading}
154
197
  className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
155
198
  >
package/install.sh CHANGED
@@ -25,5 +25,5 @@ fi
25
25
 
26
26
  echo ""
27
27
  echo "[forge] Done."
28
- forge-server --version
29
- echo "Run: forge-server"
28
+ forge --version
29
+ echo "Run: forge server start"
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Next.js instrumentation — runs once when the server starts.
3
- * Sets MW_PASSWORD before any request is handled.
3
+ * Loads .env.local and prints login password.
4
4
  */
5
5
  export async function register() {
6
6
  // Only run on server, not Edge
@@ -23,8 +23,13 @@ export async function register() {
23
23
  }
24
24
  }
25
25
 
26
- const { getPassword } = await import('./lib/password');
27
- const password = getPassword();
28
- process.env.MW_PASSWORD = password;
26
+ // Print password info
27
+ const { getAdminPassword } = await import('./lib/password');
28
+ const admin = getAdminPassword();
29
+ if (admin) {
30
+ console.log(`[init] Admin password: configured`);
31
+ } else {
32
+ console.log('[init] No admin password set — configure in Settings');
33
+ }
29
34
  }
30
35
  }
package/lib/auth.ts CHANGED
@@ -10,23 +10,31 @@ if (!process.env.AUTH_SECRET) {
10
10
 
11
11
  export const { handlers, signIn, signOut, auth } = NextAuth({
12
12
  trustHost: true,
13
+ logger: {
14
+ error: () => {}, // Suppress noisy CredentialsSignin stack traces
15
+ },
13
16
  providers: [
14
17
  // Google OAuth — for production use
15
18
  Google({
16
19
  clientId: process.env.GOOGLE_CLIENT_ID,
17
20
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
18
21
  }),
19
- // Local password — auto-generated, rotates daily
22
+ // Local: admin password only
23
+ // Remote (tunnel): admin password + session code (2FA)
20
24
  Credentials({
21
25
  name: 'Local',
22
26
  credentials: {
23
27
  password: { label: 'Password', type: 'password' },
28
+ sessionCode: { label: 'Session Code', type: 'text' },
29
+ isRemote: { label: 'Remote', type: 'text' },
24
30
  },
25
31
  async authorize(credentials) {
26
- // Dynamic import to avoid Edge Runtime pulling in node:path/node:fs
27
- const { getPassword } = await import('./password');
28
- const localPassword = getPassword();
29
- if (credentials?.password === localPassword) {
32
+ const { verifyLogin } = await import('./password');
33
+ const password = (credentials?.password ?? '') as string;
34
+ const sessionCode = (credentials?.sessionCode ?? '') as string;
35
+ const isRemote = String(credentials?.isRemote) === 'true';
36
+
37
+ if (verifyLogin(password, sessionCode, isRemote)) {
30
38
  return { id: 'local', name: 'zliu', email: 'local@forge' };
31
39
  }
32
40
  return null;
@@ -40,13 +48,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
40
48
  authorized({ auth }) {
41
49
  return !!auth;
42
50
  },
43
- // Allow redirects to tunnel URLs (*.trycloudflare.com) after login
44
51
  redirect({ url, baseUrl }) {
45
- // Same origin — always allow
46
52
  if (url.startsWith(baseUrl)) return url;
47
- // Relative path — prepend base
48
53
  if (url.startsWith('/')) return `${baseUrl}${url}`;
49
- // Cloudflare tunnel URLs — allow
50
54
  if (url.includes('.trycloudflare.com')) return url;
51
55
  return baseUrl;
52
56
  },
@@ -154,6 +154,12 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
154
154
  state.error = null;
155
155
  state.log = [];
156
156
 
157
+ // Generate new session code for remote login 2FA
158
+ try {
159
+ const { rotateSessionCode } = require('./password');
160
+ rotateSessionCode();
161
+ } catch {}
162
+
157
163
  let binPath: string;
158
164
  try {
159
165
  binPath = await downloadCloudflared();
package/lib/crypto.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Encryption utilities for storing secrets in settings.yaml
3
+ * Uses AES-256-GCM with a persistent key stored in ~/.forge/.encrypt-key
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join, dirname } from 'node:path';
9
+ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
10
+
11
+ const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
12
+ const KEY_FILE = join(DATA_DIR, '.encrypt-key');
13
+ const PREFIX = 'enc:';
14
+
15
+ function getEncryptionKey(): Buffer {
16
+ if (existsSync(KEY_FILE)) {
17
+ return Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'hex');
18
+ }
19
+ // Generate a new 32-byte key
20
+ const key = randomBytes(32);
21
+ const dir = dirname(KEY_FILE);
22
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23
+ writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 });
24
+ return key;
25
+ }
26
+
27
+ export function encryptSecret(plaintext: string): string {
28
+ if (!plaintext) return '';
29
+ const key = getEncryptionKey();
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
32
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ // Format: enc:<iv>:<tag>:<ciphertext> (all base64)
35
+ return `${PREFIX}${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`;
36
+ }
37
+
38
+ export function decryptSecret(value: string): string {
39
+ if (!value || !isEncrypted(value)) return value;
40
+ try {
41
+ const payload = value.slice(PREFIX.length);
42
+ const [ivB64, tagB64, dataB64] = payload.split('.');
43
+ const key = getEncryptionKey();
44
+ const iv = Buffer.from(ivB64, 'base64');
45
+ const tag = Buffer.from(tagB64, 'base64');
46
+ const data = Buffer.from(dataB64, 'base64');
47
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
48
+ decipher.setAuthTag(tag);
49
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
50
+ } catch {
51
+ // If decryption fails (key changed, corrupted), return empty
52
+ return '';
53
+ }
54
+ }
55
+
56
+ export function isEncrypted(value: string): boolean {
57
+ return typeof value === 'string' && value.startsWith(PREFIX);
58
+ }
59
+
60
+ /** Hash a secret for comparison without exposing plaintext */
61
+ export function hashSecret(value: string): string {
62
+ if (!value) return '';
63
+ return createHash('sha256').update(value).digest('hex').slice(0, 16);
64
+ }
65
+
66
+ /** Secret field names in settings */
67
+ export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword'] as const;
68
+ export type SecretField = typeof SECRET_FIELDS[number];