@flame0510/project-aether 1.1.12 → 1.1.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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +84 -22
  3. package/app/api/agents/create/route.ts +15 -3
  4. package/app/api/agents/download-image/route.ts +2 -0
  5. package/app/api/agents/image-status/route.ts +2 -0
  6. package/app/api/agents/route.ts +1 -36
  7. package/app/api/agents/token/route.ts +4 -1
  8. package/app/api/auth/check/route.ts +2 -23
  9. package/app/api/auth/login/route.ts +3 -35
  10. package/app/api/auth/logout/route.ts +1 -1
  11. package/app/api/auth/status/route.ts +2 -21
  12. package/app/api/config/env/route.ts +7 -3
  13. package/app/api/config/restart/route.ts +2 -0
  14. package/app/api/containers/route.ts +3 -34
  15. package/app/api/crons/route.ts +2 -0
  16. package/app/api/debug/route.ts +2 -0
  17. package/app/api/envcheck/route.ts +3 -1
  18. package/app/api/gateway/provider/route.ts +43 -5
  19. package/app/api/gateway/route.ts +2 -0
  20. package/app/api/models/route.ts +2 -0
  21. package/app/api/setup/agent-image/route.ts +8 -3
  22. package/app/api/setup/password/route.ts +32 -37
  23. package/app/api/setup/restart/route.ts +8 -2
  24. package/app/api/skills/delete/route.js +1 -1
  25. package/app/api/skills/promote/route.js +1 -1
  26. package/app/api/skills/route.js +1 -1
  27. package/app/api/skills/save/route.js +18 -5
  28. package/app/api/update-check/route.ts +4 -23
  29. package/app/api/wizard/complete/route.ts +7 -3
  30. package/app/api/wizard/reset/route.ts +8 -3
  31. package/app/api/wizard/status/route.ts +8 -5
  32. package/app/components/AuthGuard.tsx +29 -8
  33. package/app/components/ui/ModalityIcons.tsx +157 -0
  34. package/app/components/ui/index.ts +1 -0
  35. package/app/gateway/PageClient.tsx +104 -16
  36. package/bin/rev4a.js +35 -24
  37. package/docs/ARCHITECTURE.md +31 -13
  38. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  39. package/docs/REV4A.md +36 -6
  40. package/docs/dev/API-REFERENCE.md +9 -4
  41. package/docs/dev/GATEWAY.md +42 -1
  42. package/docs/dev/PROVIDERS.md +4 -3
  43. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  44. package/instrumentation.ts +18 -0
  45. package/lib/agent-setup.ts +8 -9
  46. package/lib/buildAgentImage.ts +5 -1
  47. package/lib/db.ts +0 -10
  48. package/lib/docker-socket.ts +121 -0
  49. package/lib/openrouter-pricing.ts +184 -0
  50. package/lib/rev4a-auth.d.ts +4 -1
  51. package/lib/rev4a-auth.js +68 -20
  52. package/lib/rev4a-paths.ts +19 -0
  53. package/model-pricing.json +50 -50
  54. package/models.config.json +2 -2
  55. package/package.json +4 -1
  56. package/proxy.ts +68 -0
  57. package/scripts/refresh-model-pricing.mjs +171 -0
  58. package/lib/auth.ts +0 -28
  59. package/lib/requireAuth.tsx +0 -38
package/README.md CHANGED
@@ -44,7 +44,10 @@ rev4a serve --port 8080
44
44
  ```
45
45
 
46
46
  > **Production install:** see `CONNECT.md` for systemd deployment on a VPS.
47
- > **Container deploy:** `docker run -d --name rev4a -v rev4a-data:/rev4a/data -v /var/run/docker.sock:/var/run/docker.sock -p 3740:3740 ghcr.io/flame0510/rev4a:latest`
47
+ >
48
+ > **Container deploy:** on hold. The root `Dockerfile` (Docker-in-Docker packaging of
49
+ > the dashboard itself) is an experimental path, not currently built or published —
50
+ > see the file for details. Use the git-clone install above.
48
51
 
49
52
  ---
50
53
 
@@ -216,12 +216,15 @@ export default function AgentsPageClient() {
216
216
  const [loading, setLoading] = useState(true);
217
217
  const [error, setError] = useState('');
218
218
  const [filter, setFilter] = useState<FilterState>('all');
219
- const [selected, setSelected] = useState<Agent | null>(null);
219
+ const [selectedId, setSelectedId] = useState<string | null>(null);
220
220
  const [isMobile, setIsMobile] = useState(false);
221
221
  const [showList, setShowList] = useState(true);
222
222
  const [chSummary, setChSummary] = useState<Record<string, { telegram: boolean }>>({});
223
223
  const [imageReady, setImageReady] = useState(true);
224
- const [providersConfigured, setProvidersConfigured] = useState(false);
224
+ // null = not checked yet. Keeping "unknown" distinct from "none configured" is
225
+ // the whole point: `false` as the initial value makes an unanswered check look
226
+ // exactly like a negative answer, so the warning banner renders on no evidence.
227
+ const [providersConfigured, setProvidersConfigured] = useState<boolean | null>(null);
225
228
  const [gatewayToken, setGatewayToken] = useState('');
226
229
  const [tokenLoading, setTokenLoading] = useState(true);
227
230
  const [tokenSaving, setTokenSaving] = useState(false);
@@ -274,7 +277,8 @@ export default function AgentsPageClient() {
274
277
 
275
278
  // Check if any provider is configured in the Gateway
276
279
  useEffect(() => {
277
- fetch('/api/gateway/provider')
280
+ // summary=1: skips the live-pricing round trip we don't need here.
281
+ fetch('/api/gateway/provider?summary=1')
278
282
  .then((r) => r.json())
279
283
  .then((d: { providers?: Array<{ configured: boolean }> }) =>
280
284
  setProvidersConfigured((d.providers || []).some((p) => p.configured))
@@ -358,8 +362,36 @@ export default function AgentsPageClient() {
358
362
  return list;
359
363
  }, [agents, filter]);
360
364
 
365
+ // Derive the selected agent from the live list rather than holding a snapshot.
366
+ // A snapshot goes stale on every 15s poll (status, ports, imageOutdated) and
367
+ // survives deletion; deriving means a rename shows up on the next load() and a
368
+ // deleted agent can't leave a detail panel pointing at something that's gone.
369
+ const selected = useMemo(
370
+ () => (selectedId ? agents.find((a) => a.agentId === selectedId) ?? null : null),
371
+ [agents, selectedId],
372
+ );
373
+
374
+ // The selection can vanish under us — deleted here, or from another tab/CLI
375
+ // and picked up by the poll. Drop it so mobile falls back to the list instead
376
+ // of rendering a dead detail view.
377
+ useEffect(() => {
378
+ if (selectedId && !agents.some((a) => a.agentId === selectedId)) {
379
+ setSelectedId(null);
380
+ setShowList(true);
381
+ }
382
+ }, [agents, selectedId]);
383
+
384
+ /** An agent was deleted: drop it optimistically, then reconcile with the server. */
385
+ const handleAgentDeleted = useCallback((agentId: string) => {
386
+ // The DELETE already succeeded server-side, so removing it here is safe and
387
+ // spares the user the round trip — the API tears down container, volume and
388
+ // backups before it answers, which takes seconds.
389
+ setAgents((prev) => prev.filter((a) => a.agentId !== agentId));
390
+ void load();
391
+ }, [load]);
392
+
361
393
  function selectAgent(agent: Agent) {
362
- setSelected(agent);
394
+ setSelectedId(agent.agentId);
363
395
  if (isMobile) setShowList(false);
364
396
  }
365
397
 
@@ -396,7 +428,11 @@ export default function AgentsPageClient() {
396
428
  </div>
397
429
  );
398
430
 
399
- if (loading) {
431
+ // Hold the skeleton until the provider check has answered too. Both effects
432
+ // fire on mount, so this costs nothing when the check is warm (~3ms) and only
433
+ // shows up on a cold one (~230ms) — where it's exactly what we want, since
434
+ // that's the window the banner used to flash in.
435
+ if (loading || providersConfigured === null) {
400
436
  return (
401
437
  <div style={{ padding: 14, display: 'grid', gap: 14 }}>
402
438
  {Array.from({ length: 3 }).map((_, i) => (
@@ -433,10 +469,10 @@ export default function AgentsPageClient() {
433
469
  <span style={{ fontSize: 12, color: 'var(--violet)' }}>{selected.name}</span>
434
470
  </div>
435
471
  <ImageDownloadBanner onImageReady={handleImageReady} />
436
- {!providersConfigured && <NoProviderBanner />}
472
+ {providersConfigured === false && <NoProviderBanner />}
437
473
  {/* Scrollable content */}
438
474
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
439
- <DetailPanel agent={selected} />
475
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
440
476
  </div>
441
477
  </div>
442
478
  );
@@ -460,7 +496,7 @@ export default function AgentsPageClient() {
460
496
  ))}
461
497
  </div>
462
498
  <ImageDownloadBanner onImageReady={handleImageReady} />
463
- {!providersConfigured && <NoProviderBanner />}
499
+ {providersConfigured === false && <NoProviderBanner />}
464
500
  {tokenSection}
465
501
  <div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' }}>
466
502
  {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
@@ -499,13 +535,15 @@ export default function AgentsPageClient() {
499
535
  ))}
500
536
  </div>
501
537
  <ImageDownloadBanner onImageReady={handleImageReady} />
502
- {!providersConfigured && <NoProviderBanner />}
538
+ {providersConfigured === false && <NoProviderBanner />}
503
539
  {tokenSection}
504
540
  <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
505
541
  <div style={{ width: '38%', minWidth: 300, borderRight: '1px solid var(--border)', overflow: 'auto' }}>
506
542
  {filtered.length === 0 && <div style={{ padding: 14, color: '#888', fontSize: 12 }}>No agents found</div>}
507
543
  {filtered.map((agent) => {
508
- const isSelected = selected?.id === agent.id;
544
+ // Match on agentId, not the container id: recreate gives the agent a
545
+ // new container (new id) and would otherwise silently deselect it.
546
+ const isSelected = selectedId === agent.agentId;
509
547
 
510
548
  return (
511
549
  <AgentListItem
@@ -514,7 +552,7 @@ export default function AgentsPageClient() {
514
552
  loading={lifecycleLoading === agent.agentId}
515
553
  channelSummary={chSummary}
516
554
  selected={isSelected}
517
- onClick={() => setSelected(agent)}
555
+ onClick={() => setSelectedId(agent.agentId)}
518
556
  onLifecycle={() => handleLifecycle(agent)}
519
557
  />
520
558
  );
@@ -524,7 +562,10 @@ export default function AgentsPageClient() {
524
562
  {!selected ? (
525
563
  <div style={{ color: '#666', fontSize: 12, padding: 20 }}>Select an agent for details</div>
526
564
  ) : (
527
- <DetailPanel agent={selected} />
565
+ // key: without it React reuses one DetailPanel instance across
566
+ // selections, so in-flight state (deleting, recreating, restarting,
567
+ // toasts, open confirms) leaks onto the next agent you click.
568
+ <DetailPanel key={selected.agentId} agent={selected} onDeleted={handleAgentDeleted} onChanged={load} />
528
569
  )}
529
570
  </div>
530
571
  </div>
@@ -533,7 +574,12 @@ export default function AgentsPageClient() {
533
574
  }
534
575
 
535
576
  // ─── Edit Agent modal ───
536
- function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void }) {
577
+ function EditAgentModal({ agent, onClose, onSaved }: {
578
+ agent: Agent;
579
+ onClose: () => void;
580
+ /** Rename succeeded — the parent refetches so the new name propagates. */
581
+ onSaved: () => void;
582
+ }) {
537
583
  const [displayName, setDisplayName] = useState(agent.displayName || agent.name);
538
584
  const [saving, setSaving] = useState(false);
539
585
  const [error, setError] = useState('');
@@ -558,8 +604,9 @@ function EditAgentModal({ agent, onClose }: { agent: Agent; onClose: () => void
558
604
  });
559
605
  const data = await res.json();
560
606
  if (!res.ok) throw new Error(data.error || 'Failed to update');
561
- // Reload to pick up the new display name
562
- window.location.reload();
607
+ // Refetch the list so the new display name propagates — no full reload.
608
+ onSaved();
609
+ onClose();
563
610
  } catch (e: unknown) {
564
611
  setError((e as Error).message);
565
612
  setSaving(false);
@@ -682,7 +729,13 @@ function ConfigModal({ agent, onClose }: { agent: Agent; onClose: () => void })
682
729
  }
683
730
 
684
731
  // ─── Detail panel (shared between mobile & desktop) ───
685
- function DetailPanel({ agent }: { agent: Agent }) {
732
+ function DetailPanel({ agent, onDeleted, onChanged }: {
733
+ agent: Agent;
734
+ /** The agent is gone — the parent drops it from the list. */
735
+ onDeleted: (agentId: string) => void;
736
+ /** The agent changed in place (renamed, recreated) — refetch the list. */
737
+ onChanged: () => void;
738
+ }) {
686
739
  const [deleting, setDeleting] = useState(false);
687
740
  const [deleteError, setDeleteError] = useState('');
688
741
  const [showConfig, setShowConfig] = useState(false);
@@ -693,14 +746,17 @@ function DetailPanel({ agent }: { agent: Agent }) {
693
746
  setDeleting(true);
694
747
  setDeleteError('');
695
748
  try {
696
- const res = await fetch(`/api/agents/${agent.name}`, { method: 'DELETE' });
749
+ const res = await fetch(`/api/agents/${encodeURIComponent(agent.agentId)}`, { method: 'DELETE' });
697
750
  const data = await res.json();
698
751
  if (!res.ok || !data.success) {
699
752
  setDeleteError(data.error || 'Delete failed');
700
753
  setDeleting(false);
701
754
  return;
702
755
  }
703
- window.location.reload();
756
+ setShowDeleteConfirm(false);
757
+ // No setDeleting(false): the parent drops the agent and this panel
758
+ // unmounts. Clearing state on an unmounted component would warn.
759
+ onDeleted(agent.agentId);
704
760
  } catch (e: unknown) {
705
761
  setDeleteError((e as Error).message || 'Network error');
706
762
  setDeleting(false);
@@ -796,7 +852,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
796
852
  {showConfig && <ConfigModal agent={agent} onClose={() => setShowConfig(false)} />}
797
853
 
798
854
  {/* Edit agent modal */}
799
- {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} />}
855
+ {showEdit && <EditAgentModal agent={agent} onClose={() => setShowEdit(false)} onSaved={onChanged} />}
800
856
 
801
857
  {/* Delete confirmation */}
802
858
  <ConfirmModal
@@ -819,7 +875,7 @@ function DetailPanel({ agent }: { agent: Agent }) {
819
875
  </div>
820
876
  </div>
821
877
  )}
822
- <RecreateSection agentName={agent.name} />
878
+ <RecreateSection agentName={agent.name} onRecreated={onChanged} />
823
879
 
824
880
  {/* Delete */}
825
881
  <div style={{ border: '1px solid rgba(239,68,68,0.3)', borderRadius: 0, background: 'rgba(239,68,68,0.04)', overflow: 'hidden' }}>
@@ -1137,7 +1193,11 @@ function RestartSection({ agentName }: { agentName: string }) {
1137
1193
 
1138
1194
  /* ─── Recreate section - rebuild container from same image while keeping volume ─── */
1139
1195
 
1140
- function RecreateSection({ agentName }: { agentName: string }) {
1196
+ function RecreateSection({ agentName, onRecreated }: {
1197
+ agentName: string;
1198
+ /** The container was rebuilt — the parent refetches (new id, fresh image). */
1199
+ onRecreated: () => void;
1200
+ }) {
1141
1201
  const [recreating, setRecreating] = useState(false);
1142
1202
  const [error, setError] = useState('');
1143
1203
  const [toast, setToast] = useState<string | null>(null);
@@ -1157,7 +1217,9 @@ function RecreateSection({ agentName }: { agentName: string }) {
1157
1217
  const data = await res.json();
1158
1218
  if (data.success) {
1159
1219
  showToast(data.backup ? `Recreated (backup: ${data.backup})` : 'Container recreated successfully');
1160
- setTimeout(() => window.location.reload(), 2000);
1220
+ // Refetch instead of reloading: the toast stays readable and the panel
1221
+ // keeps its scroll position while the new container id comes in.
1222
+ onRecreated();
1161
1223
  } else {
1162
1224
  setError(data.error || 'Recreate failed');
1163
1225
  }
@@ -44,11 +44,23 @@ const DEFAULT_BLOCK_SIZE = 10;
44
44
 
45
45
  function getUsedHostPorts(): Set<number> {
46
46
  try {
47
+ // Parse host ports in JS rather than piping through grep. The previous
48
+ // form used `grep -oP '\d+(?=->)'` inside a template literal, where \d
49
+ // loses its backslash — the shell received `d+(?=->)`, which never
50
+ // matches Docker's `0.0.0.0:3700->3000/tcp`, so this always returned an
51
+ // empty set and every agent was offered port 3700. `grep -P` is also
52
+ // GNU-only and absent on macOS. This mirrors the client-side parser in
53
+ // app/agents/create/page.tsx.
47
54
  const raw = execSync(
48
- `docker ps --format '{{.Ports}}' | grep -oP '\d+(?=->)' | sort -n`,
55
+ `docker ps --format '{{.Ports}}'`,
49
56
  { encoding: 'utf-8', timeout: 3000 },
50
- ).trim();
51
- return new Set(raw ? raw.split('\n').map(Number) : []);
57
+ );
58
+ const used = new Set<number>();
59
+ for (const [, port] of raw.matchAll(/(\d+)->/g)) {
60
+ const n = Number(port);
61
+ if (Number.isInteger(n)) used.add(n);
62
+ }
63
+ return used;
52
64
  } catch {
53
65
  return new Set();
54
66
  }
@@ -16,6 +16,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
16
16
  export const dynamic = 'force-dynamic';
17
17
 
18
18
  export async function POST(request: NextRequest): Promise<NextResponse> {
19
+ const denied = await requireAuthJWT(request);
20
+ if (denied) return denied;
19
21
  if (getIsDownloading()) {
20
22
  return NextResponse.json(
21
23
  { error: 'A download is already in progress' },
@@ -71,6 +71,8 @@ function getRemoteDigest(): string | null {
71
71
  }
72
72
 
73
73
  export async function GET(request: NextRequest): Promise<NextResponse> {
74
+ const denied = await requireAuthJWT(request);
75
+ if (denied) return denied;
74
76
  const exists = localImageExists();
75
77
  const localId = getLocalImageId();
76
78
  const downloading = getIsDownloading();
@@ -1,48 +1,13 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import * as http from 'http';
3
2
  import * as fs from 'fs';
4
3
  import * as path from 'path';
5
4
  import { detectPublicHost } from '@/lib/docker-utils';
6
5
  import { AGENTS_TOKEN_FILE } from '@/lib/rev4a-paths';
7
6
  import { requireAuthJWT } from '@/lib/rev4a-auth';
8
-
9
- const DOCKER_SOCKET = '/var/run/docker.sock';
10
-
11
- function dockerFetch(method: string, path: string): Promise<any> {
12
- return new Promise((resolve, reject) => {
13
- const options = {
14
- socketPath: DOCKER_SOCKET,
15
- path,
16
- method,
17
- headers: { 'Host': 'localhost' } as any,
18
- };
19
- const req = http.request(options, (res: any) => {
20
- let data = '';
21
- res.on('data', (chunk: string) => (data += chunk));
22
- res.on('end', () => {
23
- try {
24
- resolve(JSON.parse(data));
25
- } catch {
26
- reject(new Error('Invalid JSON from Docker API'));
27
- }
28
- });
29
- });
30
- req.on('error', reject);
31
- req.end();
32
- });
33
- }
7
+ import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
34
8
 
35
9
  export const dynamic = 'force-dynamic';
36
10
 
37
- function dockerAvailable(): boolean {
38
- try {
39
- fs.accessSync(DOCKER_SOCKET, fs.constants.R_OK | fs.constants.W_OK);
40
- return true;
41
- } catch {
42
- return false;
43
- }
44
- }
45
-
46
11
  export async function GET(request: NextRequest): Promise<NextResponse> {
47
12
  const denied = await requireAuthJWT(request); if (denied) return denied as any;
48
13
  // If Docker is not available (e.g. testing environment), return empty list
@@ -29,11 +29,14 @@ function shellQuote(value: string): string {
29
29
  }
30
30
 
31
31
  export async function GET(request: NextRequest): Promise<NextResponse> {
32
+ const denied = await requireAuthJWT(request);
33
+ if (denied) return denied;
32
34
  return NextResponse.json(readTokenFile());
33
35
  }
34
36
 
35
37
  export async function PUT(request: NextRequest): Promise<NextResponse> {
36
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
38
+ const denied = await requireAuthJWT(request);
39
+ if (denied) return denied;
37
40
  try {
38
41
  const body = await request.json();
39
42
  const token = typeof body?.token === 'string' ? body.token : null;
@@ -1,33 +1,12 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { jwtVerify } from 'jose';
3
- import * as fs from 'fs';
4
- import { ENV_FILE } from '@/lib/rev4a-paths';
5
-
6
- function getJwtSecret(): Uint8Array {
7
- const fromEnv = process.env.REV4A_JWT_SECRET;
8
- if (typeof fromEnv === 'string' && fromEnv.length > 0) {
9
- return new TextEncoder().encode(fromEnv);
10
- }
11
- // Fallback: read from persistent .env
12
- for (const file of [ENV_FILE]) {
13
- try {
14
- if (fs.existsSync(file)) {
15
- const content = fs.readFileSync(file, 'utf-8');
16
- const match = content.match(/^REV4A_JWT_SECRET=(.*)$/m);
17
- if (match && match[1].trim()) {
18
- return new TextEncoder().encode(match[1].trim());
19
- }
20
- }
21
- } catch { /* ignore */ }
22
- }
23
- return new TextEncoder().encode('');
24
- }
3
+ import { getJwtSecret } from '@/lib/rev4a-auth';
25
4
 
26
5
  export async function GET(request: NextRequest): Promise<NextResponse> {
27
6
  const cookieToken = request.cookies.get('rev4a_token')?.value;
28
7
  if (cookieToken) {
29
8
  try {
30
- const secret = getJwtSecret();
9
+ const secret = new TextEncoder().encode(getJwtSecret());
31
10
  await jwtVerify(cookieToken, secret);
32
11
  return NextResponse.json({ authenticated: true });
33
12
  } catch {
@@ -1,43 +1,11 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { SignJWT } from 'jose';
3
- import * as fs from 'fs';
4
- import { ENV_FILE } from '@/lib/rev4a-paths';
5
-
6
- // ---------------------------------------------------------------------------
7
- // Resolve credentials at runtime from the persistent .env file.
8
- // ---------------------------------------------------------------------------
9
- function getEnvPassword(): string {
10
- const fromEnv = process.env.REV4A_PASSWORD;
11
- if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
12
-
13
- try {
14
- if (fs.existsSync(ENV_FILE)) {
15
- const content = fs.readFileSync(ENV_FILE, 'utf-8');
16
- const match = content.match(/^REV4A_PASSWORD=(.*)$/m);
17
- if (match && match[1].trim()) return match[1].trim();
18
- }
19
- } catch { /* ignore */ }
20
- return '';
21
- }
22
-
23
- function getEnvJwtSecret(): string {
24
- const fromEnv = process.env.REV4A_JWT_SECRET;
25
- if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
26
-
27
- try {
28
- if (fs.existsSync(ENV_FILE)) {
29
- const content = fs.readFileSync(ENV_FILE, 'utf-8');
30
- const match = content.match(/^REV4A_JWT_SECRET=(.*)$/m);
31
- if (match && match[1].trim()) return match[1].trim();
32
- }
33
- } catch { /* ignore */ }
34
- return '';
35
- }
3
+ import { getPassword, getJwtSecret } from '@/lib/rev4a-auth';
36
4
 
37
5
  export async function POST(request: NextRequest): Promise<NextResponse> {
38
6
  try {
39
- const password = getEnvPassword();
40
- const jwtSecret = getEnvJwtSecret();
7
+ const password = getPassword();
8
+ const jwtSecret = getJwtSecret();
41
9
 
42
10
  if (!password) {
43
11
  console.error('[auth] No password configured — rejecting login');
@@ -2,6 +2,6 @@ import { NextResponse } from 'next/server';
2
2
 
3
3
  export async function POST(): Promise<NextResponse> {
4
4
  const response = NextResponse.json({ ok: true });
5
- response.cookies.delete('rev4a_token');
5
+ response.cookies.delete({ name: 'rev4a_token', path: '/' });
6
6
  return response;
7
7
  }
@@ -1,6 +1,5 @@
1
1
  import { NextResponse } from 'next/server';
2
- import * as fs from 'fs';
3
- import { ENV_FILE } from '@/lib/rev4a-paths';
2
+ import { isConfigured } from '@/lib/rev4a-auth';
4
3
 
5
4
  /**
6
5
  * GET /api/auth/status
@@ -11,23 +10,5 @@ import { ENV_FILE } from '@/lib/rev4a-paths';
11
10
  * the setup wizard or the login form.
12
11
  */
13
12
  export function GET(): NextResponse {
14
- let password = process.env.REV4A_PASSWORD;
15
-
16
- // Fallback: read .env from config dir if env var is empty (Next.js inlines at build)
17
- if (typeof password !== 'string' || password.length === 0) {
18
- try {
19
- if (fs.existsSync(ENV_FILE)) {
20
- const content = fs.readFileSync(ENV_FILE, 'utf-8');
21
- const match = content.match(/^REV4A_PASSWORD=(.*)$/m);
22
- if (match && match[1].trim()) {
23
- password = match[1].trim();
24
- }
25
- }
26
- } catch {
27
- // ignore
28
- }
29
- }
30
-
31
- const configured = typeof password === 'string' && password.length > 0;
32
- return NextResponse.json({ configured });
13
+ return NextResponse.json({ configured: isConfigured() });
33
14
  }
@@ -5,7 +5,8 @@
5
5
  * PUT /api/config/env — upsert one or more env vars
6
6
  *
7
7
  * Only keys prefixed with REV4A_ or PROVIDER_ are exposed.
8
- * Auth: browser cookie (authenticated session via middleware).
8
+ * Auth: requireAuthJWT (browser cookie or bearer token) — API routes are
9
+ * never covered by proxy.ts, it explicitly exempts /api/*.
9
10
  */
10
11
  import { NextRequest, NextResponse } from 'next/server';
11
12
  import * as fs from 'fs';
@@ -54,6 +55,8 @@ function serializeEnvFile(vars: Record<string, string>): string {
54
55
  }
55
56
 
56
57
  export async function GET(request: NextRequest) {
58
+ const denied = await requireAuthJWT(request);
59
+ if (denied) return denied;
57
60
  try {
58
61
  let content = '';
59
62
  try {
@@ -78,7 +81,8 @@ export async function GET(request: NextRequest) {
78
81
  }
79
82
 
80
83
  export async function PUT(request: NextRequest) {
81
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
84
+ const denied = await requireAuthJWT(request);
85
+ if (denied) return denied;
82
86
  try {
83
87
  const body: { env?: Record<string, string> } = await request.json();
84
88
  if (!body.env || typeof body.env !== 'object') {
@@ -115,7 +119,7 @@ export async function PUT(request: NextRequest) {
115
119
 
116
120
  // Reload systemd so EnvironmentFile= picks up .env changes on next restart.
117
121
  // No need to write individual Environment= lines into unit files —
118
- // rev4a-next.service already reads .env via EnvironmentFile=.
122
+ // rev4a.service already reads .env via EnvironmentFile=.
119
123
  try {
120
124
  execSync('sudo systemctl daemon-reload', { timeout: 5000 });
121
125
  } catch (syncErr) {
@@ -13,6 +13,8 @@ export const dynamic = 'force-dynamic';
13
13
  export const runtime = 'nodejs';
14
14
 
15
15
  export async function POST(request: NextRequest) {
16
+ const denied = await requireAuthJWT(request);
17
+ if (denied) return denied;
16
18
  try {
17
19
  execSync('sudo systemctl daemon-reload && sudo systemctl restart rev4a.service', {
18
20
  timeout: 15000,
@@ -1,44 +1,13 @@
1
1
  import { type NextRequest } from "next/server";
2
2
  import { NextResponse } from 'next/server';
3
- import * as http from 'http';
4
- import * as fs from 'fs';
5
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
6
-
7
- const DOCKER_SOCKET = '/var/run/docker.sock';
8
-
9
- function dockerAvailable(): boolean {
10
- try {
11
- fs.accessSync(DOCKER_SOCKET, fs.constants.R_OK | fs.constants.W_OK);
12
- return true;
13
- } catch {
14
- return false;
15
- }
16
- }
17
-
18
- function dockerFetch(method: string, path: string): Promise<any> {
19
- return new Promise((resolve, reject) => {
20
- const options = {
21
- socketPath: DOCKER_SOCKET,
22
- path,
23
- method,
24
- headers: { 'Host': 'localhost' } as any,
25
- };
26
- const req = http.request(options, (res: any) => {
27
- let data = '';
28
- res.on('data', (chunk: string) => data += chunk);
29
- res.on('end', () => {
30
- try { resolve(JSON.parse(data)); }
31
- catch { reject(new Error('Invalid JSON from Docker')); }
32
- });
33
- });
34
- req.on('error', reject);
35
- req.end();
36
- });
37
- }
4
+ import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
38
5
 
39
6
  export const dynamic = 'force-dynamic';
40
7
 
41
8
  export async function GET(request: NextRequest) {
9
+ const denied = await requireAuthJWT(request);
10
+ if (denied) return denied;
42
11
  if (!dockerAvailable()) {
43
12
  return NextResponse.json([]);
44
13
  }
@@ -7,6 +7,8 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
7
7
  export const dynamic = 'force-dynamic';
8
8
 
9
9
  export async function GET(request: NextRequest): Promise<NextResponse> {
10
+ const denied = await requireAuthJWT(request);
11
+ if (denied) return denied;
10
12
  const result = await listOpenClawCronJobs();
11
13
  return NextResponse.json(result.jobs);
12
14
  }
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
3
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
4
4
 
5
5
  export async function GET(request: NextRequest) {
6
+ const denied = await requireAuthJWT(request);
7
+ if (denied) return denied;
6
8
  return NextResponse.json({
7
9
  hasPassword: !!process.env.REV4A_PASSWORD,
8
10
  hasJwtSecret: !!process.env.REV4A_JWT_SECRET,
@@ -2,9 +2,11 @@ import { type NextRequest } from "next/server";
2
2
  import { NextResponse } from 'next/server';
3
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
4
4
 
5
- const SENSITIVE = ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET'];
5
+ const SENSITIVE = ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET', 'PROVIDER_DEEPSEEK_API_KEY'];
6
6
 
7
7
  export async function GET(request: NextRequest) {
8
+ const denied = await requireAuthJWT(request);
9
+ if (denied) return denied;
8
10
  const safe: Record<string, string> = {};
9
11
  for (const key of ['REV4A_PASSWORD', 'REV4A_TOKEN', 'REV4A_JWT_SECRET', 'REV4A_ALERTS_ENABLED', 'REV4A_DB', 'REV4A_WORKSPACE_ROOT', 'PROVIDER_DEEPSEEK_API_KEY', 'NODE_ENV']) {
10
12
  const val = process.env[key];