@aion0/forge 0.10.46 → 0.10.48

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.
@@ -422,9 +422,9 @@ export default function Dashboard({ user }: { user: any }) {
422
422
  <button
423
423
  key={mode}
424
424
  onClick={() => setViewMode(mode)}
425
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
425
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
426
426
  viewMode === mode
427
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
427
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
428
428
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
429
429
  }`}
430
430
  >
@@ -435,9 +435,9 @@ export default function Dashboard({ user }: { user: any }) {
435
435
  {/* Docs */}
436
436
  <button
437
437
  onClick={() => setViewMode('docs')}
438
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
438
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
439
439
  viewMode === 'docs'
440
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
440
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
441
441
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
442
442
  }`}
443
443
  >
@@ -451,9 +451,9 @@ export default function Dashboard({ user }: { user: any }) {
451
451
  onClick={() => {
452
452
  if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
453
453
  }}
454
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
454
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
455
455
  ['tasks', 'pipelines', 'schedules'].includes(viewMode)
456
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
456
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
457
457
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
458
458
  }`}
459
459
  >
@@ -468,9 +468,9 @@ export default function Dashboard({ user }: { user: any }) {
468
468
  {/* Marketplace */}
469
469
  <button
470
470
  onClick={() => setViewMode('skills')}
471
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
471
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
472
472
  viewMode === 'skills'
473
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
473
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
474
474
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
475
475
  }`}
476
476
  >
@@ -490,13 +490,15 @@ export default function Dashboard({ user }: { user: any }) {
490
490
  </span>
491
491
  )}
492
492
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30" />
493
- {/* Alerts */}
493
+ {/* Alerts — bell glyph keeps the same role; unread pill rides the bell. */}
494
494
  <div className="relative">
495
495
  <button
496
496
  onClick={() => { setShowNotifications(v => !v); setShowUserMenu(false); }}
497
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
497
+ className="text-[14px] leading-none text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
498
+ title="Alerts"
499
+ aria-label="Alerts"
498
500
  >
499
- Alerts
501
+ <span aria-hidden>🔔</span>
500
502
  {unreadCount > 0 && (
501
503
  <span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
502
504
  {unreadCount > 99 ? '99+' : unreadCount}
@@ -620,14 +622,17 @@ export default function Dashboard({ user }: { user: any }) {
620
622
  <div className="relative">
621
623
  <button
622
624
  onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
623
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
625
+ className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
624
626
  >
625
- {profileDept && (
626
- <span className="text-[9px] px-1 py-[1px] rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 mr-1" title="Active department">
627
- {profileDept}
628
- </span>
629
- )}
630
- {displayName} <span className="text-[8px]">▾</span>
627
+ <span className="flex flex-col items-end leading-tight">
628
+ {profileDept && (
629
+ <span className="text-[8px] px-1 rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30" title="Active department">
630
+ {profileDept}
631
+ </span>
632
+ )}
633
+ <span className="text-[9px]">{displayName}</span>
634
+ </span>
635
+ <span className="text-[8px]">▾</span>
631
636
  </button>
632
637
  {showUserMenu && (
633
638
  <>
@@ -758,9 +763,9 @@ export default function Dashboard({ user }: { user: any }) {
758
763
  <button
759
764
  key={m}
760
765
  onClick={() => setViewMode(m)}
761
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
766
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
762
767
  viewMode === m
763
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
768
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
764
769
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
765
770
  }`}
766
771
  >
@@ -205,6 +205,126 @@ function SecretField({ label, description, isSet, onEdit }: {
205
205
  );
206
206
  }
207
207
 
208
+ // ─── API Key Section ───────────────────────────────────────────
209
+
210
+ interface ApiKeyPublic { id: string; name: string; prefix: string; createdAt: string; lastUsedAt: string | null }
211
+
212
+ function ApiKeySection() {
213
+ const [keys, setKeys] = useState<ApiKeyPublic[]>([]);
214
+ // Whether an admin password is configured — read from the keys endpoint's
215
+ // purpose-built `adminConfigured` flag, not inferred from an unrelated secret
216
+ // field, so this section isn't coupled to how the admin password is stored.
217
+ const [adminSet, setAdminSet] = useState(false);
218
+ const [dialog, setDialog] = useState<null | { mode: 'generate' } | { mode: 'revoke'; id: string; prefix: string }>(null);
219
+ const [adminPassword, setAdminPassword] = useState('');
220
+ const [name, setName] = useState('agent key');
221
+ const [error, setError] = useState('');
222
+ const [busy, setBusy] = useState(false);
223
+ const [revealed, setRevealed] = useState('');
224
+ const [copied, setCopied] = useState(false);
225
+
226
+ const refresh = async () => {
227
+ try { const r = await fetch('/api/auth/keys'); const d = await r.json(); setKeys(d.keys || []); setAdminSet(!!d.adminConfigured); } catch { /* ignore */ }
228
+ };
229
+ useEffect(() => { refresh(); }, []);
230
+
231
+ const closeDialog = () => { setDialog(null); setAdminPassword(''); setError(''); };
232
+
233
+ const generate = async () => {
234
+ setBusy(true); setError('');
235
+ try {
236
+ const r = await fetch('/api/auth/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, adminPassword }) });
237
+ const d = await r.json();
238
+ if (!r.ok) { setError(d.error || 'Failed to generate key'); setBusy(false); return; }
239
+ setRevealed(d.key); setCopied(false); closeDialog(); refresh();
240
+ } catch (e) { setError((e as Error).message || 'Failed'); }
241
+ setBusy(false);
242
+ };
243
+
244
+ const revoke = async () => {
245
+ if (dialog?.mode !== 'revoke') return;
246
+ setBusy(true); setError('');
247
+ try {
248
+ const r = await fetch(`/api/auth/keys/${dialog.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminPassword }) });
249
+ const d = await r.json().catch(() => ({}));
250
+ if (!r.ok) { setError(d.error || 'Failed to revoke'); setBusy(false); return; }
251
+ closeDialog(); refresh();
252
+ } catch (e) { setError((e as Error).message || 'Failed'); }
253
+ setBusy(false);
254
+ };
255
+
256
+ const inputClass = "w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
257
+
258
+ return (
259
+ <div className="space-y-2" data-testid="api-key-section">
260
+ <label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">API Key</label>
261
+ <p className="text-[10px] text-[var(--text-secondary)]">
262
+ Lets an agent or service act on your behalf — MCP operations and automation — without sharing your admin password. Generated, revocable, shown once.
263
+ </p>
264
+
265
+ {!adminSet ? (
266
+ <p className="text-[10px] text-[var(--text-secondary)] italic">Set an admin password first to generate an API key.</p>
267
+ ) : (
268
+ <>
269
+ {revealed && (
270
+ <div className="p-2 bg-[var(--bg-tertiary)] border border-[var(--accent)] rounded space-y-1" data-testid="api-key-revealed">
271
+ <p className="text-[9px] text-[var(--accent)]">⚠ Copy now — you won't be able to see this again.</p>
272
+ <div className="flex items-center gap-2">
273
+ <code className="flex-1 text-[10px] font-mono text-[var(--text-primary)] break-all">{revealed}</code>
274
+ <button onClick={() => { navigator.clipboard?.writeText(revealed); setCopied(true); }} className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white shrink-0">{copied ? 'Copied' : 'Copy'}</button>
275
+ </div>
276
+ <button onClick={() => { setRevealed(''); setCopied(false); }} className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Dismiss</button>
277
+ </div>
278
+ )}
279
+
280
+ {keys.length === 0 ? (
281
+ <button onClick={() => setDialog({ mode: 'generate' })} data-testid="api-key-generate" className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white">Generate API Key</button>
282
+ ) : (
283
+ <div className="space-y-1">
284
+ {keys.map(k => (
285
+ <div key={k.id} className="flex items-center justify-between gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded">
286
+ <div className="min-w-0">
287
+ <div className="text-xs font-mono text-[var(--text-primary)] truncate">{k.prefix}</div>
288
+ <div className="text-[9px] text-[var(--text-secondary)]">{k.name} · created {k.createdAt?.slice(0, 10)} · last used {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'never'}</div>
289
+ </div>
290
+ <button onClick={() => setDialog({ mode: 'revoke', id: k.id, prefix: k.prefix })} className="text-[10px] px-2 py-1 border border-[var(--red)] text-[var(--red)] rounded hover:bg-[var(--red)] hover:text-white shrink-0">Revoke</button>
291
+ </div>
292
+ ))}
293
+ <button onClick={() => setDialog({ mode: 'generate' })} data-testid="api-key-generate" className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white">Generate New Key</button>
294
+ </div>
295
+ )}
296
+ </>
297
+ )}
298
+
299
+ {dialog && (
300
+ <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]" onClick={closeDialog}>
301
+ <div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[380px] p-4 space-y-3" onClick={e => e.stopPropagation()}>
302
+ <h3 className="text-xs font-bold">{dialog.mode === 'generate' ? 'Generate API Key' : 'Revoke API Key'}</h3>
303
+ {dialog.mode === 'revoke' && (
304
+ <p className="text-[10px] text-[var(--text-secondary)]">Revoke <code className="text-[var(--text-primary)]">{dialog.prefix}</code>? Any agent using it will stop working.</p>
305
+ )}
306
+ {dialog.mode === 'generate' && (
307
+ <div className="space-y-1">
308
+ <label className="text-[10px] text-[var(--text-secondary)]">Key name</label>
309
+ <input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. laptop, ci, agent" className={inputClass} data-testid="api-key-name" />
310
+ </div>
311
+ )}
312
+ <div className="space-y-1">
313
+ <label className="text-[10px] text-[var(--text-secondary)]">Admin password</label>
314
+ <SecretInput value={adminPassword} onChange={v => { setAdminPassword(v); setError(''); }} placeholder="Enter admin password" className={inputClass} />
315
+ </div>
316
+ {error && <p className="text-[10px] text-[var(--red)]" data-testid="api-key-error">{error}</p>}
317
+ <div className="flex justify-end gap-2 pt-1">
318
+ <button onClick={closeDialog} className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
319
+ <button onClick={dialog.mode === 'generate' ? generate : revoke} disabled={!adminPassword || busy} data-testid="api-key-confirm" className={`px-3 py-1.5 text-xs text-white rounded hover:opacity-90 disabled:opacity-50 ${dialog.mode === 'revoke' ? 'bg-[var(--red)]' : 'bg-[var(--accent)]'}`}>{busy ? '...' : dialog.mode === 'generate' ? 'Generate' : 'Revoke'}</button>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ )}
324
+ </div>
325
+ );
326
+ }
327
+
208
328
  // ─── Settings Modal ────────────────────────────────────────────
209
329
 
210
330
  interface Settings {
@@ -873,6 +993,9 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
873
993
  </p>
874
994
  </div>
875
995
 
996
+ {/* API Key */}
997
+ <ApiKeySection />
998
+
876
999
  {/* Actions */}
877
1000
  <div className="flex items-center justify-between pt-2 border-t border-[var(--border)]">
878
1001
  <span className="text-[10px] text-[var(--green)]">
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Request authentication for Forge's API surface (API-key / session).
3
+ *
4
+ * A request is authenticated if it carries a valid persistent API key — a
5
+ * `forge_sk_`-prefixed key in Authorization: Bearer, the x-forge-token header,
6
+ * or the forge-api-token cookie — OR a VALIDATED NextAuth browser session. The
7
+ * session is checked with the framework decoder (auth()), NOT by cookie presence
8
+ * — a forged or unsigned session-token cookie does not pass. The legacy
9
+ * admin-minted in-memory token is NOT handled here; routes that accept it call
10
+ * isValidToken() in addition. Centralizes the key+session check so those routes
11
+ * share one implementation. (Named api-auth to avoid colliding with lib/auth.ts,
12
+ * the NextAuth config.)
13
+ *
14
+ * Route handlers call isAuthenticated() for the combined key-or-session check.
15
+ */
16
+
17
+ import { verifyApiKey } from './api-keys';
18
+ import { auth } from './auth';
19
+
20
+ /** Extract a bearer/token credential from the request, if any. */
21
+ export function extractToken(req: Request): string | null {
22
+ const authz = req.headers.get('authorization');
23
+ if (authz && /^Bearer\s+/i.test(authz)) return authz.replace(/^Bearer\s+/i, '').trim();
24
+ const header = req.headers.get('x-forge-token');
25
+ if (header) return header;
26
+ const cookie = req.headers.get('cookie') || '';
27
+ const m = cookie.match(/forge-api-token=([^;]+)/);
28
+ return m ? m[1] : null;
29
+ }
30
+
31
+ /** True if the request carries a valid persistent API key — a `forge_sk_` key in
32
+ * Authorization: Bearer, x-forge-token, or the forge-api-token cookie. (The
33
+ * legacy admin-minted token is checked separately by isValidToken.) */
34
+ export function hasValidApiKey(req: Request): boolean {
35
+ return verifyApiKey(extractToken(req));
36
+ }
37
+
38
+ /** True if there is a VALIDATED NextAuth browser session for the current request.
39
+ * Uses the framework decoder (auth()) — NOT cookie presence — so a forged or
40
+ * unsigned session-token cookie does not pass. Must be called within a request
41
+ * context (route handler / server component); returns false otherwise. */
42
+ export async function hasSession(): Promise<boolean> {
43
+ try { return !!(await auth()); } catch { return false; }
44
+ }
45
+
46
+ /** True if the request presents a valid API key or a validated browser session. */
47
+ export async function isAuthenticated(req: Request): Promise<boolean> {
48
+ if (hasValidApiKey(req)) return true;
49
+ return hasSession();
50
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * API key store — credentials for services/agents acting on the user's behalf
3
+ * (MCP operations, automation), distinct from the human admin password.
4
+ *
5
+ * Keys are stored HASHED (sha256) in <dataDir>/api-keys.json — the plaintext is
6
+ * shown to the user exactly once at creation and is never recoverable from disk.
7
+ * Verification is constant-time. Minting/revoking is gated by the admin password
8
+ * at the route layer.
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
14
+ import { getDataDir } from './dirs';
15
+
16
+ const KEY_PREFIX = 'forge_sk_';
17
+
18
+ export interface ApiKeyRecord {
19
+ id: string;
20
+ name: string;
21
+ prefix: string; // non-secret display prefix, e.g. "forge_sk_a1b2c3…"
22
+ hash: string; // sha256(full key)
23
+ createdAt: string;
24
+ lastUsedAt: string | null;
25
+ }
26
+
27
+ /** Public view — never includes the hash. */
28
+ export type ApiKeyPublic = Omit<ApiKeyRecord, 'hash'>;
29
+
30
+ function file(): string {
31
+ return join(getDataDir(), 'api-keys.json');
32
+ }
33
+
34
+ // Parsed-records cache keyed by file mtime. verifyApiKey() runs on every /api/mcp
35
+ // POST, so re-reading + JSON.parse per call is wasted I/O. Any write (this process
36
+ // via persist(), or another worker) changes mtime, so the cache self-invalidates.
37
+ let _cache: { mtimeMs: number; keys: ApiKeyRecord[] } | null = null;
38
+
39
+ function load(): ApiKeyRecord[] {
40
+ try {
41
+ const fp = file();
42
+ if (!existsSync(fp)) { _cache = null; return []; }
43
+ const mtimeMs = statSync(fp).mtimeMs;
44
+ if (_cache && _cache.mtimeMs === mtimeMs) return _cache.keys;
45
+ const parsed = JSON.parse(readFileSync(fp, 'utf-8'));
46
+ const keys = Array.isArray(parsed) ? parsed : [];
47
+ _cache = { mtimeMs, keys };
48
+ return keys;
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+
54
+ function persist(keys: ApiKeyRecord[]): void {
55
+ const dir = getDataDir();
56
+ mkdirSync(dir, { recursive: true });
57
+ // Atomic write: serialize to a fresh 0600 temp file in the same dir, then
58
+ // rename over the target. A crash or a concurrent reader never sees a
59
+ // half-written api-keys.json, and the rename is atomic on the same filesystem.
60
+ const tmp = join(dir, `.api-keys.${randomBytes(6).toString('hex')}.tmp`);
61
+ try {
62
+ writeFileSync(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 });
63
+ renameSync(tmp, file());
64
+ _cache = null; // invalidate; next load() re-reads at the new mtime
65
+ } catch (e) {
66
+ try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
67
+ throw e;
68
+ }
69
+ }
70
+
71
+ function sha256(s: string): string {
72
+ return createHash('sha256').update(s).digest('hex');
73
+ }
74
+
75
+ const toPublic = (r: ApiKeyRecord): ApiKeyPublic => ({
76
+ id: r.id, name: r.name, prefix: r.prefix, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
77
+ });
78
+
79
+ /** Create a new key. Returns the plaintext ONCE (caller must show + discard).
80
+ *
81
+ * createApiKey/revokeApiKey load()→persist() the whole array, loading immediately
82
+ * before the write (same minimal in-process window as touchLastUsed). They do NOT
83
+ * take a file lock, so two WORKERS mutating concurrently still last-writer-win and
84
+ * one change is dropped. Acceptable here because minting/revoking is admin-gated and
85
+ * rare; if that stops holding, wrap the read-modify-write below in a short lock. */
86
+ export function createApiKey(name: string): { key: string; record: ApiKeyPublic } {
87
+ const key = `${KEY_PREFIX}${randomBytes(24).toString('base64url')}`;
88
+ const record: ApiKeyRecord = {
89
+ id: randomBytes(6).toString('hex'),
90
+ name: name.trim() || 'agent key',
91
+ prefix: `${key.slice(0, KEY_PREFIX.length + 6)}…`,
92
+ hash: sha256(key),
93
+ createdAt: new Date().toISOString(),
94
+ lastUsedAt: null,
95
+ };
96
+ const keys = load();
97
+ keys.push(record);
98
+ persist(keys);
99
+ return { key, record: toPublic(record) };
100
+ }
101
+
102
+ export function listApiKeys(): ApiKeyPublic[] {
103
+ return load().map(toPublic);
104
+ }
105
+
106
+ export function revokeApiKey(id: string): boolean {
107
+ const keys = load();
108
+ const idx = keys.findIndex((k) => k.id === id);
109
+ if (idx < 0) return false;
110
+ keys.splice(idx, 1);
111
+ persist(keys);
112
+ return true;
113
+ }
114
+
115
+ /** Bump one key's lastUsedAt by id, re-loading the freshest file immediately
116
+ * before the write. verifyApiKey must NOT persist the array it read before the
117
+ * hash compare: a concurrent createApiKey/revokeApiKey in another worker would
118
+ * be clobbered (last-writer-wins on the whole file). Re-loading here shrinks the
119
+ * window to the load→write span and, if the key was revoked meanwhile, leaves it
120
+ * deleted (find returns undefined → no write) instead of resurrecting it. */
121
+ function touchLastUsed(id: string, whenISO: string): void {
122
+ const keys = load();
123
+ const k = keys.find((r) => r.id === id);
124
+ if (!k) return;
125
+ k.lastUsedAt = whenISO;
126
+ persist(keys);
127
+ }
128
+
129
+ /** Constant-time verification of a presented key. Bumps lastUsedAt (throttled). */
130
+ export function verifyApiKey(presented: string | null | undefined): boolean {
131
+ if (!presented || !presented.startsWith(KEY_PREFIX)) return false;
132
+ const presentedHash = Buffer.from(sha256(presented));
133
+ const keys = load();
134
+ let matched: ApiKeyRecord | undefined;
135
+ for (const k of keys) {
136
+ const stored = Buffer.from(k.hash);
137
+ if (stored.length === presentedHash.length && timingSafeEqual(stored, presentedHash)) {
138
+ matched = k;
139
+ break;
140
+ }
141
+ }
142
+ if (!matched) return false;
143
+ // Throttle the lastUsedAt write to at most once a minute to avoid disk churn;
144
+ // write through a fresh re-load (touchLastUsed) so a concurrent mint/revoke
145
+ // isn't lost.
146
+ const now = Date.now();
147
+ // A corrupt (unparseable) lastUsedAt yields NaN; treat it as stale so the bad
148
+ // value self-heals on the next use instead of freezing forever.
149
+ const last = matched.lastUsedAt ? new Date(matched.lastUsedAt).getTime() : NaN;
150
+ if (!Number.isFinite(last) || now - last > 60_000) {
151
+ // Bookkeeping only — a failed lastUsedAt write (e.g. api-keys.json owned by a
152
+ // different user after a local `forge mcp install`) must never turn a valid
153
+ // key into an auth error.
154
+ try { touchLastUsed(matched.id, new Date(now).toISOString()); } catch { /* ignore */ }
155
+ }
156
+ return true;
157
+ }
package/lib/jobs/store.ts CHANGED
@@ -313,6 +313,31 @@ export function setNextRunAt(id: string, nextRunAt: string | null): void {
313
313
  db().prepare("UPDATE jobs SET next_run_at = ?, last_run_at = datetime('now') WHERE id = ?").run(nextRunAt, id);
314
314
  }
315
315
 
316
+ /** Recompute next_run_at from the job's trigger without touching last_run_at.
317
+ * Use on resume: cancelJobDrain nulled next_run_at, and NULL = due now, so
318
+ * a bare re-enable would fire off-schedule. once-in-the-past fires next tick;
319
+ * period seeds now + interval to avoid an instant re-run. */
320
+ export function seedNextRunAt(id: string): void {
321
+ ensureSchema();
322
+ const j = getJob(id);
323
+ if (!j) return;
324
+ const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
325
+ let next: string | null = null;
326
+ if (j.schedule_kind === 'once' && j.schedule_at) {
327
+ const t = new Date(j.schedule_at);
328
+ if (!Number.isNaN(t.getTime())) next = fmt(t);
329
+ } else if (j.schedule_kind === 'cron' && j.schedule_cron) {
330
+ try {
331
+ const iter = CronExpressionParser.parse(j.schedule_cron, { currentDate: new Date() });
332
+ next = fmt(iter.next().toDate());
333
+ } catch { next = null; }
334
+ } else if (j.schedule_kind === 'period') {
335
+ const mins = j.schedule_interval_minutes > 0 ? j.schedule_interval_minutes : 30;
336
+ next = fmt(new Date(Date.now() + mins * 60_000));
337
+ }
338
+ db().prepare('UPDATE jobs SET next_run_at = ? WHERE id = ?').run(next, id);
339
+ }
340
+
316
341
  /** Jobs due to run: enabled AND (next_run_at IS NULL OR next_run_at <= now). */
317
342
  export function getDueJobs(): Job[] {
318
343
  ensureSchema();
package/lib/projects.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { join, resolve, isAbsolute, parse } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { loadSettings } from './settings';
4
+ import { loadSettings, saveSettings } from './settings';
5
5
  import { getDataDir } from './dirs';
6
6
 
7
7
  export interface LocalProject {
@@ -75,11 +75,22 @@ function scratchProject(): LocalProject {
75
75
  };
76
76
  }
77
77
 
78
+ let scanCache: { at: number; key: string; result: LocalProject[] } | null = null;
79
+ const SCAN_TTL_MS = 5_000;
80
+
81
+ /** Drop the scanProjects cache (call after registering/unregistering a root). */
82
+ export function invalidateProjectScan(): void { scanCache = null; }
83
+
78
84
  export function scanProjects(): LocalProject[] {
79
85
  const settings = loadSettings();
80
- const roots = settings.projectRoots;
86
+ const roots = settings.projectRoots || [];
87
+ const cacheKey = roots.join('\n');
88
+ const now = Date.now();
89
+ if (scanCache && scanCache.key === cacheKey && now - scanCache.at < SCAN_TTL_MS) {
90
+ return scanCache.result;
91
+ }
81
92
 
82
- if (roots.length === 0) return [];
93
+ if (roots.length === 0) { scanCache = { at: now, key: cacheKey, result: [] }; return []; }
83
94
 
84
95
  const projects: LocalProject[] = [];
85
96
 
@@ -118,7 +129,9 @@ export function scanProjects(): LocalProject[] {
118
129
  // Prepend the synthetic scratch project so the UI surfaces it as a
119
130
  // first-class option. It's stamped with the current date so it tends
120
131
  // to sort to the top — that's intentional: it's the default fallback.
121
- return [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
132
+ const result = [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
133
+ scanCache = { at: now, key: cacheKey, result };
134
+ return result;
122
135
  }
123
136
 
124
137
  function detectLanguage(projectPath: string): string | null {
@@ -234,3 +247,64 @@ export function getProjectClaudeMd(projectPath: string): string | null {
234
247
  if (!existsSync(claudeMdPath)) return null;
235
248
  return readFileSync(claudeMdPath, 'utf-8');
236
249
  }
250
+
251
+ /** Register a project root — a directory whose immediate subdirectories Forge
252
+ * lists as projects (see scanProjects). Idempotent; validates the path is an
253
+ * existing directory. Returns the normalized root and whether it was already
254
+ * registered. */
255
+ const MAX_ROOT_SUBDIRS = 200;
256
+
257
+ export function addProjectRoot(rootPath: string): {
258
+ ok: boolean; root: string; error?: string; alreadyRegistered?: boolean;
259
+ } {
260
+ const trimmed = (rootPath || '').trim();
261
+ if (!trimmed) return { ok: false, root: '', error: 'Path is required' };
262
+ // Require an absolute path: resolve('') is process.cwd() and a relative path
263
+ // resolves against the server's cwd — neither is a project root the caller meant.
264
+ if (!isAbsolute(trimmed)) return { ok: false, root: trimmed, error: 'Path must be absolute' };
265
+ const root = resolve(trimmed);
266
+ if (!existsSync(root)) return { ok: false, root, error: 'Path does not exist' };
267
+ try {
268
+ if (!statSync(root).isDirectory()) return { ok: false, root, error: 'Path is not a directory' };
269
+ } catch {
270
+ return { ok: false, root, error: 'Path is not accessible' };
271
+ }
272
+ // A root is meant to CONTAIN projects, not be a giant tree. Refuse the
273
+ // filesystem root and absurdly broad dirs so scanProjects stays cheap.
274
+ if (root === parse(root).root) {
275
+ return { ok: false, root, error: 'Refusing to register the filesystem root' };
276
+ }
277
+ let subdirs = 0;
278
+ try {
279
+ for (const e of readdirSync(root, { withFileTypes: true })) {
280
+ if (e.isDirectory() && !e.name.startsWith('.') && ++subdirs > MAX_ROOT_SUBDIRS) break;
281
+ }
282
+ } catch {
283
+ return { ok: false, root, error: 'Path is not accessible' };
284
+ }
285
+ if (subdirs > MAX_ROOT_SUBDIRS) {
286
+ return { ok: false, root, error: `Too many subdirectories (>${MAX_ROOT_SUBDIRS}); point at the folder that directly contains your repos` };
287
+ }
288
+ const settings = loadSettings();
289
+ const roots = settings.projectRoots || [];
290
+ if (roots.includes(root)) return { ok: true, root, alreadyRegistered: true };
291
+ settings.projectRoots = [...roots, root];
292
+ saveSettings(settings);
293
+ invalidateProjectScan();
294
+ return { ok: true, root };
295
+ }
296
+
297
+ /** Unregister a project root. Does not touch files — only stops scanProjects
298
+ * from listing its subdirectories as projects. */
299
+ export function removeProjectRoot(rootPath: string): { ok: boolean; root: string; error?: string } {
300
+ const root = resolve((rootPath || '').trim());
301
+ const settings = loadSettings();
302
+ const roots = settings.projectRoots || [];
303
+ if (!roots.includes(root)) {
304
+ return { ok: false, root, error: `Not a registered root. Registered: ${roots.join(', ') || 'none'}` };
305
+ }
306
+ settings.projectRoots = roots.filter((r) => r !== root);
307
+ saveSettings(settings);
308
+ invalidateProjectScan();
309
+ return { ok: true, root };
310
+ }
package/lib/settings.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
3
4
  import YAML from 'yaml';
4
5
  import { encryptSecret, decryptSecret, isEncrypted, SECRET_FIELDS } from './crypto';
5
6
  import { getDataDir } from './dirs';
@@ -423,7 +424,16 @@ export function saveSettings(settings: Settings) {
423
424
  }
424
425
  // Encrypt nested apiKeys
425
426
  encryptNestedSecrets(toSave);
426
- writeFileSync(SETTINGS_FILE, YAML.stringify(toSave), 'utf-8');
427
+ // Atomic write: serialize to a temp file in the same dir, then rename over the
428
+ // target so a crash or concurrent reader never sees a half-written settings.yaml.
429
+ const tmp = join(dir, `.settings.${randomBytes(6).toString('hex')}.tmp`);
430
+ try {
431
+ writeFileSync(tmp, YAML.stringify(toSave), 'utf-8');
432
+ renameSync(tmp, SETTINGS_FILE);
433
+ } catch (e) {
434
+ try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
435
+ throw e;
436
+ }
427
437
  }
428
438
 
429
439
  /** Verify a secret field's current value */
package/mcp/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # Forge Management MCP
2
+
3
+ Manage Forge — projects, tasks, pipelines, schedules, jobs, connectors, usage — from an
4
+ MCP client like Claude Code, instead of clicking through the UI. It manages Forge; it does
5
+ not read your project files or run coding tasks (that's the separate workspace agent MCP
6
+ on `:8406`).
7
+
8
+ ## Add it to Claude Code
9
+
10
+ ```bash
11
+ forge mcp install
12
+ ```
13
+
14
+ This mints an API key if you don't have one and registers the server with Claude Code.
15
+ Run `/mcp` in a session to confirm. No admin password — running `forge` already proves
16
+ local access, so the key is minted directly on disk (into `FORGE_DATA_DIR`, else
17
+ `~/.forge/data`; set `FORGE_DATA_DIR` for a non-default instance); the running server
18
+ picks it up automatically (and need not even be up).
19
+
20
+ Options: `--http` (register the HTTP transport instead of the stdio proxy), `--scope user`
21
+ (use it in every project), `--remote https://forge.example.com` (target a remote Forge —
22
+ set `FORGE_API_KEY` first, since the remote's data dir isn't local), `--print` (print the
23
+ `claude mcp add` command instead of running it).
24
+
25
+ ## What you can do
26
+
27
+ Tools cover projects, tasks, pipelines, schedules and jobs, connectors, the marketplace,
28
+ usage and status, log tailing, and workspace agents. Ask "what forge tools do you have?"
29
+ to list them.
30
+
31
+ ## How it works
32
+
33
+ Runs in-process at `/api/mcp` (Streamable HTTP), behind Forge's existing auth — same port
34
+ as the web UI. All logic stays in the server: tools call `lib/` in-process and the wire
35
+ carries named intents, never SQL. The `forge mcp` proxy (`cli/mcp-proxy.ts`) is
36
+ transport-only; the same `mcp/server.ts` backs both the HTTP route and the proxy.
37
+
38
+ Manual setup, if you're not using `forge mcp install`:
39
+
40
+ ```bash
41
+ forge key create "claude-code" --save # mint + save a key (no password — local)
42
+ claude mcp add forge -- forge mcp # stdio proxy (reads ~/.forge/mcp-key)
43
+ # or, direct HTTP:
44
+ claude mcp add --transport http forge http://localhost:8403/api/mcp \
45
+ --header "Authorization: Bearer forge_sk_..."
46
+ ```