@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.
- package/README.md +4 -1
- package/app/agents/PageClient.tsx +84 -22
- package/app/api/agents/create/route.ts +15 -3
- package/app/api/agents/download-image/route.ts +2 -0
- package/app/api/agents/image-status/route.ts +2 -0
- package/app/api/agents/route.ts +1 -36
- package/app/api/agents/token/route.ts +4 -1
- package/app/api/auth/check/route.ts +2 -23
- package/app/api/auth/login/route.ts +3 -35
- package/app/api/auth/logout/route.ts +1 -1
- package/app/api/auth/status/route.ts +2 -21
- package/app/api/config/env/route.ts +7 -3
- package/app/api/config/restart/route.ts +2 -0
- package/app/api/containers/route.ts +3 -34
- package/app/api/crons/route.ts +2 -0
- package/app/api/debug/route.ts +2 -0
- package/app/api/envcheck/route.ts +3 -1
- package/app/api/gateway/provider/route.ts +43 -5
- package/app/api/gateway/route.ts +2 -0
- package/app/api/models/route.ts +2 -0
- package/app/api/setup/agent-image/route.ts +8 -3
- package/app/api/setup/password/route.ts +32 -37
- package/app/api/setup/restart/route.ts +8 -2
- package/app/api/skills/delete/route.js +1 -1
- package/app/api/skills/promote/route.js +1 -1
- package/app/api/skills/route.js +1 -1
- package/app/api/skills/save/route.js +18 -5
- package/app/api/update-check/route.ts +4 -23
- package/app/api/wizard/complete/route.ts +7 -3
- package/app/api/wizard/reset/route.ts +8 -3
- package/app/api/wizard/status/route.ts +8 -5
- package/app/components/AuthGuard.tsx +29 -8
- package/app/components/ui/ModalityIcons.tsx +157 -0
- package/app/components/ui/index.ts +1 -0
- package/app/gateway/PageClient.tsx +104 -16
- package/bin/rev4a.js +35 -24
- package/docs/ARCHITECTURE.md +31 -13
- package/docs/FRONTEND-ARCHITECTURE.md +1 -0
- package/docs/REV4A.md +36 -6
- package/docs/dev/API-REFERENCE.md +9 -4
- package/docs/dev/GATEWAY.md +42 -1
- package/docs/dev/PROVIDERS.md +4 -3
- package/docs/rag/REV4A-OVERVIEW.md +1 -1
- package/instrumentation.ts +18 -0
- package/lib/agent-setup.ts +8 -9
- package/lib/buildAgentImage.ts +5 -1
- package/lib/db.ts +0 -10
- package/lib/docker-socket.ts +121 -0
- package/lib/openrouter-pricing.ts +184 -0
- package/lib/rev4a-auth.d.ts +4 -1
- package/lib/rev4a-auth.js +68 -20
- package/lib/rev4a-paths.ts +19 -0
- package/model-pricing.json +50 -50
- package/models.config.json +2 -2
- package/package.json +4 -1
- package/proxy.ts +68 -0
- package/scripts/refresh-model-pricing.mjs +171 -0
- package/lib/auth.ts +0 -28
- package/lib/requireAuth.tsx +0 -38
|
@@ -12,6 +12,7 @@ import { syncAllAgents } from '../sync';
|
|
|
12
12
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
13
13
|
import { getPricing } from '@/lib/model-pricing';
|
|
14
14
|
import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
15
|
+
import { getLivePricing } from '@/lib/openrouter-pricing';
|
|
15
16
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
16
17
|
|
|
17
18
|
export const dynamic = 'force-dynamic';
|
|
@@ -136,8 +137,37 @@ function restartService(): { stdout: string; stderr: string } {
|
|
|
136
137
|
/* ------------------------------------------------------------------ */
|
|
137
138
|
|
|
138
139
|
export async function GET(request: NextRequest) {
|
|
139
|
-
const
|
|
140
|
+
const denied = await requireAuthJWT(request);
|
|
141
|
+
if (denied) return denied;
|
|
140
142
|
const providerKeys = readProviderKeys();
|
|
143
|
+
|
|
144
|
+
// ?summary=1 — just "which providers have a key", no models, no pricing.
|
|
145
|
+
// The full response awaits live pricing from OpenRouter, a network round trip
|
|
146
|
+
// that costs ~2.4s cold. Callers that only need the boolean (the agents page,
|
|
147
|
+
// to decide whether to warn about a missing provider) must not pay for it, and
|
|
148
|
+
// must not be left guessing while it's in flight.
|
|
149
|
+
if (request.nextUrl.searchParams.get('summary') === '1') {
|
|
150
|
+
return NextResponse.json({
|
|
151
|
+
providers: Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => ({
|
|
152
|
+
provider: key,
|
|
153
|
+
label: cfg.label,
|
|
154
|
+
configured: !!providerKeys[key],
|
|
155
|
+
})),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allModels = loadModelsConfig();
|
|
160
|
+
|
|
161
|
+
// Live prices overlaid on the static file, which drifts. Fetched regardless
|
|
162
|
+
// of whether OpenRouter is configured: the catalogue is public, and prices
|
|
163
|
+
// are most useful precisely to someone still deciding whether to enable it.
|
|
164
|
+
// Best-effort — an empty map means the static values stand in.
|
|
165
|
+
//
|
|
166
|
+
// Modality deliberately stays on the curated value: sync.ts provisions
|
|
167
|
+
// containers from it, so taking a different one upstream would advertise
|
|
168
|
+
// inputs the agent was never configured to accept.
|
|
169
|
+
const livePricing = await getLivePricing(allModels.map((m) => m.id));
|
|
170
|
+
|
|
141
171
|
const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
|
|
142
172
|
const providerModels = allModels.filter((m) => m.provider === key);
|
|
143
173
|
return {
|
|
@@ -145,8 +175,14 @@ export async function GET(request: NextRequest) {
|
|
|
145
175
|
baseUrl: cfg.baseUrl, docsUrl: cfg.docsUrl,
|
|
146
176
|
models: providerModels.map((m) => {
|
|
147
177
|
const entry: Record<string, unknown> = { id: m.id, name: m.name, enabled: m.enabled };
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
178
|
+
const livePrice = livePricing.get(m.id);
|
|
179
|
+
if (livePrice) {
|
|
180
|
+
entry.pricing = livePrice;
|
|
181
|
+
entry.pricingLive = true;
|
|
182
|
+
} else {
|
|
183
|
+
const staticPrice = getPricing(m.id);
|
|
184
|
+
if (staticPrice) entry.pricing = staticPrice;
|
|
185
|
+
}
|
|
150
186
|
if (m.modality) entry.modality = m.modality;
|
|
151
187
|
return entry;
|
|
152
188
|
}),
|
|
@@ -157,7 +193,8 @@ export async function GET(request: NextRequest) {
|
|
|
157
193
|
}
|
|
158
194
|
|
|
159
195
|
export async function POST(request: NextRequest) {
|
|
160
|
-
const denied = await requireAuthJWT(request);
|
|
196
|
+
const denied = await requireAuthJWT(request);
|
|
197
|
+
if (denied) return denied;
|
|
161
198
|
try {
|
|
162
199
|
const body: { provider?: string; apiKey?: string; _syncOnly?: boolean } = await request.json();
|
|
163
200
|
|
|
@@ -189,7 +226,8 @@ export async function POST(request: NextRequest) {
|
|
|
189
226
|
}
|
|
190
227
|
|
|
191
228
|
export async function PUT(request: NextRequest) {
|
|
192
|
-
const denied = await requireAuthJWT(request);
|
|
229
|
+
const denied = await requireAuthJWT(request);
|
|
230
|
+
if (denied) return denied;
|
|
193
231
|
try {
|
|
194
232
|
const body: { modelId?: string; enabled?: boolean } = await request.json();
|
|
195
233
|
const { modelId, enabled } = body;
|
package/app/api/gateway/route.ts
CHANGED
|
@@ -131,6 +131,8 @@ function getAgentModels(container: string): GatewayModel[] {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
134
|
+
const denied = await requireAuthJWT(request);
|
|
135
|
+
if (denied) return denied;
|
|
134
136
|
try {
|
|
135
137
|
const timestamp = Date.now();
|
|
136
138
|
|
package/app/api/models/route.ts
CHANGED
|
@@ -48,6 +48,8 @@ function loadModelsConfig(): ModelsConfigEntry[] {
|
|
|
48
48
|
|
|
49
49
|
export async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
50
50
|
const request = _req;
|
|
51
|
+
const denied = await requireAuthJWT(request);
|
|
52
|
+
if (denied) return denied;
|
|
51
53
|
const allModels = loadModelsConfig();
|
|
52
54
|
const providerKeys = readProviderKeys();
|
|
53
55
|
const configured = new Set(Object.keys(providerKeys));
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* Only one download at a time — returns 409 if already running.
|
|
13
13
|
* Cleans up the docker process if the client disconnects.
|
|
14
14
|
*/
|
|
15
|
-
import { NextResponse } from 'next/server';
|
|
15
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import { downloadAgentImage, getIsDownloading } from '@/lib/buildAgentImage';
|
|
17
17
|
import { execSync } from 'child_process';
|
|
18
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
18
19
|
|
|
19
20
|
export const dynamic = 'force-dynamic';
|
|
20
21
|
|
|
@@ -30,7 +31,9 @@ function dockerOk(): boolean {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export async function GET(): Promise<NextResponse> {
|
|
34
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
35
|
+
const denied = await requireAuthIfConfigured(request);
|
|
36
|
+
if (denied) return denied;
|
|
34
37
|
if (!dockerOk()) {
|
|
35
38
|
return NextResponse.json({ exists: false });
|
|
36
39
|
}
|
|
@@ -55,7 +58,9 @@ export async function GET(): Promise<NextResponse> {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export async function POST(): Promise<Response> {
|
|
61
|
+
export async function POST(request: NextRequest): Promise<Response> {
|
|
62
|
+
const denied = await requireAuthIfConfigured(request);
|
|
63
|
+
if (denied) return denied;
|
|
59
64
|
if (getIsDownloading()) {
|
|
60
65
|
return new Response(
|
|
61
66
|
JSON.stringify({ error: 'A download is already in progress' }),
|
|
@@ -15,6 +15,7 @@ import * as fs from 'fs';
|
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import * as crypto from 'crypto';
|
|
17
17
|
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
18
|
+
import { isConfigured } from '@/lib/rev4a-auth';
|
|
18
19
|
|
|
19
20
|
export const dynamic = 'force-dynamic';
|
|
20
21
|
export const runtime = 'nodejs';
|
|
@@ -23,21 +24,33 @@ function generateSecret(len: number): string {
|
|
|
23
24
|
return crypto.randomBytes(len).toString('base64url').slice(0, len);
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* True only if an actual assignment line for `key` exists — a commented-out
|
|
29
|
+
* or incidental mention (`# old REV4A_TOKEN=…`) does not count. A substring
|
|
30
|
+
* check here would report the key as present and skip generating it, leaving
|
|
31
|
+
* the instance without a usable secret.
|
|
32
|
+
*/
|
|
33
|
+
function hasEnvKey(content: string, key: string): boolean {
|
|
34
|
+
return content.split('\n').some((line) => line.trimStart().startsWith(`${key}=`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Replace the assignment line for `key`, or append one if none exists. */
|
|
38
|
+
function upsertEnvLine(content: string, key: string, value: string): string {
|
|
39
|
+
const lines = content.split('\n');
|
|
40
|
+
let replaced = false;
|
|
41
|
+
const updated = lines.map((line) => {
|
|
42
|
+
if (!replaced && line.trimStart().startsWith(`${key}=`)) {
|
|
43
|
+
replaced = true;
|
|
44
|
+
return `${key}=${value}`;
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
});
|
|
48
|
+
if (!replaced) updated.push(`${key}=${value}`);
|
|
49
|
+
return updated.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
27
|
-
|
|
28
|
-
let currentPassword = process.env.REV4A_PASSWORD;
|
|
29
|
-
if (typeof currentPassword !== 'string' || currentPassword.length === 0) {
|
|
30
|
-
try {
|
|
31
|
-
if (fs.existsSync(ENV_FILE)) {
|
|
32
|
-
const content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
33
|
-
const match = content.match(/^REV4A_PASSWORD=(.+)$/m);
|
|
34
|
-
if (match && match[1].trim()) {
|
|
35
|
-
currentPassword = match[1].trim();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch { /* ignore */ }
|
|
39
|
-
}
|
|
40
|
-
if (typeof currentPassword === 'string' && currentPassword.length > 0) {
|
|
53
|
+
if (isConfigured()) {
|
|
41
54
|
return NextResponse.json(
|
|
42
55
|
{ error: 'Password is already configured. Use the Config page to change it.' },
|
|
43
56
|
{ status: 409 },
|
|
@@ -72,30 +85,12 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|
|
72
85
|
content = fs.readFileSync(ENV_FILE, 'utf-8');
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
if (content
|
|
77
|
-
content = content
|
|
78
|
-
.split('\n')
|
|
79
|
-
.map((line) =>
|
|
80
|
-
line.startsWith('REV4A_PASSWORD=')
|
|
81
|
-
? 'REV4A_PASSWORD=' + body.password
|
|
82
|
-
: line,
|
|
83
|
-
)
|
|
84
|
-
.join('\n');
|
|
85
|
-
} else {
|
|
86
|
-
content += '\nREV4A_PASSWORD=' + body.password + '\n';
|
|
88
|
+
content = upsertEnvLine(content, 'REV4A_PASSWORD', body.password);
|
|
89
|
+
if (!hasEnvKey(content, 'REV4A_JWT_SECRET')) {
|
|
90
|
+
content = upsertEnvLine(content, 'REV4A_JWT_SECRET', generateSecret(64));
|
|
87
91
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (!content.includes('REV4A_JWT_SECRET=')) {
|
|
91
|
-
const secret = generateSecret(64);
|
|
92
|
-
content += '\nREV4A_JWT_SECRET=' + secret + '\n';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Generate API token if missing
|
|
96
|
-
if (!content.includes('REV4A_TOKEN=')) {
|
|
97
|
-
const token = generateSecret(32);
|
|
98
|
-
content += '\nREV4A_TOKEN=' + token + '\n';
|
|
92
|
+
if (!hasEnvKey(content, 'REV4A_TOKEN')) {
|
|
93
|
+
content = upsertEnvLine(content, 'REV4A_TOKEN', generateSecret(32));
|
|
99
94
|
}
|
|
100
95
|
|
|
101
96
|
fs.writeFileSync(ENV_FILE, content.trim() + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
@@ -12,15 +12,21 @@
|
|
|
12
12
|
* 2. Otherwise send SIGTERM to the current process. The parent process
|
|
13
13
|
* manager (npx rev4a all or a shell wrapper) should restart it.
|
|
14
14
|
*/
|
|
15
|
-
import { NextResponse } from 'next/server';
|
|
15
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import { execSync } from 'child_process';
|
|
18
18
|
import { ENV_FILE } from '@/lib/rev4a-paths';
|
|
19
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
19
20
|
|
|
20
21
|
export const dynamic = 'force-dynamic';
|
|
21
22
|
export const runtime = 'nodejs';
|
|
22
23
|
|
|
23
|
-
export async function POST(): Promise<NextResponse> {
|
|
24
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
25
|
+
// Open only during initial setup (before a password exists). Once
|
|
26
|
+
// configured, this triggers a production service restart and must
|
|
27
|
+
// require a valid session like every other privileged route.
|
|
28
|
+
const denied = await requireAuthIfConfigured(request);
|
|
29
|
+
if (denied) return denied;
|
|
24
30
|
try {
|
|
25
31
|
if (!fs.existsSync(ENV_FILE)) {
|
|
26
32
|
return NextResponse.json({ error: '.env file not found' }, { status: 500 });
|
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* DELETE /api/skills/delete
|
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* POST /api/skills/promote
|
package/app/api/skills/route.js
CHANGED
|
@@ -3,8 +3,8 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
const CONTAINER_BUNDLED_SKILLS_DIR = '/usr/local/lib/node_modules/openclaw/skills';
|
|
9
9
|
|
|
10
10
|
function readSkillMeta(skillDir, name, type, agentId) {
|
|
@@ -3,10 +3,25 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { execSync, execFileSync } from 'child_process';
|
|
5
5
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
6
|
+
import { SHARED_SKILLS_DIR } from '@/lib/rev4a-paths';
|
|
6
7
|
|
|
7
|
-
const SHARED_SKILLS_DIR = '/docker/shared-skills';
|
|
8
8
|
const BUNDLED_SKILLS_DIR = '/usr/lib/node_modules/openclaw/skills';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Pick an HTTP status from a caught error.
|
|
12
|
+
*
|
|
13
|
+
* Only the deliberate `Object.assign(new Error(…), { status: 404 })` throws in
|
|
14
|
+
* this file carry a real HTTP status. A failing `docker exec` also sets
|
|
15
|
+
* `.status`, but to the process exit code — usually 1. Passing that straight
|
|
16
|
+
* to NextResponse makes undici throw `RangeError: init["status"] must be in
|
|
17
|
+
* the range of 200 to 599`, so the caller got an opaque 500 with no body
|
|
18
|
+
* instead of the intended error. Anything outside the valid range falls back.
|
|
19
|
+
*/
|
|
20
|
+
function httpStatus(err, fallback) {
|
|
21
|
+
const s = err?.status;
|
|
22
|
+
return Number.isInteger(s) && s >= 200 && s <= 599 ? s : fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
26
|
* Read SKILL.md content from various sources:
|
|
12
27
|
* - Host path (shared / bundled)
|
|
@@ -96,8 +111,7 @@ export async function POST(request) {
|
|
|
96
111
|
return NextResponse.json({ ok: true, saved: isContainer ? skillPath : absPath });
|
|
97
112
|
} catch (err) {
|
|
98
113
|
const msg = err instanceof Error ? err.message : 'Internal error';
|
|
99
|
-
|
|
100
|
-
return NextResponse.json({ error: msg }, { status });
|
|
114
|
+
return NextResponse.json({ error: msg }, { status: httpStatus(err, 500) });
|
|
101
115
|
}
|
|
102
116
|
}
|
|
103
117
|
|
|
@@ -136,7 +150,6 @@ export async function GET(request) {
|
|
|
136
150
|
return NextResponse.json({ content });
|
|
137
151
|
} catch (err) {
|
|
138
152
|
const msg = err instanceof Error ? err.message : 'Internal error';
|
|
139
|
-
|
|
140
|
-
return NextResponse.json({ error: msg }, { status });
|
|
153
|
+
return NextResponse.json({ error: msg }, { status: httpStatus(err, 404) });
|
|
141
154
|
}
|
|
142
155
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
|
-
import { jwtVerify } from 'jose';
|
|
3
2
|
import { execSync } from 'child_process';
|
|
4
3
|
import { readFileSync } from 'fs';
|
|
5
4
|
import { join } from 'path';
|
|
@@ -33,7 +32,8 @@ const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
|
33
32
|
* GET /api/update-check
|
|
34
33
|
*/
|
|
35
34
|
export async function GET(request: Request): Promise<NextResponse> {
|
|
36
|
-
const denied = await requireAuthJWT(request);
|
|
35
|
+
const denied = await requireAuthJWT(request);
|
|
36
|
+
if (denied) return denied;
|
|
37
37
|
const url = new URL(request.url);
|
|
38
38
|
const check = url.searchParams.get('check') === '1';
|
|
39
39
|
|
|
@@ -71,23 +71,6 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|
|
71
71
|
return NextResponse.json({ installed, latest, updateAvailable, package: getPackageInfo().name });
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async function verifyAuth(request: Request): Promise<boolean> {
|
|
75
|
-
const raw = request.headers.get('cookie') || '';
|
|
76
|
-
const match = raw.split(';').map(c => c.trim()).find(c => c.startsWith('rev4a_token='));
|
|
77
|
-
if (!match) return false;
|
|
78
|
-
|
|
79
|
-
const cookieToken = decodeURIComponent(match.split('=')[1]);
|
|
80
|
-
try {
|
|
81
|
-
const secret = new TextEncoder().encode(
|
|
82
|
-
process.env.REV4A_JWT_SECRET || (() => { throw new Error('REV4A_JWT_SECRET not set'); })(),
|
|
83
|
-
);
|
|
84
|
-
await jwtVerify(cookieToken, secret);
|
|
85
|
-
return true;
|
|
86
|
-
} catch {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
74
|
/**
|
|
92
75
|
* POST /api/update-check
|
|
93
76
|
*
|
|
@@ -97,10 +80,8 @@ async function verifyAuth(request: Request): Promise<boolean> {
|
|
|
97
80
|
* with the newly installed binary. No systemd required.
|
|
98
81
|
*/
|
|
99
82
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
100
|
-
const denied = await requireAuthJWT(request);
|
|
101
|
-
if (
|
|
102
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
103
|
-
}
|
|
83
|
+
const denied = await requireAuthJWT(request);
|
|
84
|
+
if (denied) return denied;
|
|
104
85
|
|
|
105
86
|
// Spawn rev4a update in background, then write restart flag directly
|
|
106
87
|
// from this process so rev4a serve detects it and restarts.
|
|
@@ -4,16 +4,20 @@
|
|
|
4
4
|
* Sets wizard as complete in a persistent file (cross-browser).
|
|
5
5
|
* Also sets the httpOnly cookie for backwards compatibility.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
11
12
|
|
|
12
13
|
export const dynamic = 'force-dynamic';
|
|
13
14
|
|
|
14
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
16
|
+
|
|
17
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
18
|
+
const denied = await requireAuthIfConfigured(request);
|
|
19
|
+
if (denied) return denied;
|
|
15
20
|
|
|
16
|
-
export function POST(): NextResponse {
|
|
17
21
|
// Write persistent marker
|
|
18
22
|
const dir = path.dirname(WIZARD_FILE);
|
|
19
23
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
* Clears the wizard marker so the wizard runs again.
|
|
5
5
|
* Removes both the persistent file and the cookie.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
10
12
|
|
|
11
13
|
export const dynamic = 'force-dynamic';
|
|
12
14
|
|
|
13
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
16
|
+
|
|
17
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
18
|
+
const denied = await requireAuthIfConfigured(request);
|
|
19
|
+
if (denied) return denied;
|
|
14
20
|
|
|
15
|
-
export function POST(): NextResponse {
|
|
16
21
|
// Remove persistent marker
|
|
17
22
|
try { fs.unlinkSync(WIZARD_FILE); } catch { /* ignore */ }
|
|
18
23
|
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
* Returns whether the wizard is complete.
|
|
5
5
|
* Checks a persistent file so it survives browser changes and cache clears.
|
|
6
6
|
*/
|
|
7
|
-
import { NextResponse } from 'next/server';
|
|
7
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
8
8
|
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
9
10
|
import { REV4A_DATA } from '@/lib/rev4a-paths';
|
|
11
|
+
import { requireAuthIfConfigured } from '@/lib/rev4a-auth';
|
|
10
12
|
|
|
11
13
|
export const dynamic = 'force-dynamic';
|
|
12
14
|
|
|
13
|
-
const WIZARD_FILE = REV4A_DATA
|
|
15
|
+
const WIZARD_FILE = path.join(REV4A_DATA, 'data', 'wizard.json');
|
|
14
16
|
|
|
15
17
|
function readWizardFile(): boolean {
|
|
16
18
|
try {
|
|
@@ -22,7 +24,8 @@ function readWizardFile(): boolean {
|
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function GET(): NextResponse {
|
|
26
|
-
const
|
|
27
|
-
|
|
27
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
28
|
+
const denied = await requireAuthIfConfigured(request);
|
|
29
|
+
if (denied) return denied;
|
|
30
|
+
return NextResponse.json({ done: readWizardFile() });
|
|
28
31
|
}
|
|
@@ -7,17 +7,38 @@ import { useRouter, usePathname } from 'next/navigation';
|
|
|
7
7
|
const authCache = { valid: false, checked: false, ts: 0 };
|
|
8
8
|
const AUTH_CACHE_TTL = 30_000; // 30s before re-check
|
|
9
9
|
|
|
10
|
+
function isCacheValid(): boolean {
|
|
11
|
+
return authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL;
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
11
15
|
const router = useRouter();
|
|
12
16
|
const pathname = usePathname();
|
|
13
17
|
const [redirecting, setRedirecting] = useState(false);
|
|
14
18
|
const checkingRef = useRef(false);
|
|
15
19
|
|
|
20
|
+
// Which pathname the last successful check applies to. Kept per-path so a
|
|
21
|
+
// client-side navigation to a different protected route can't inherit the
|
|
22
|
+
// previous route's "verified" state and flash its content for a frame.
|
|
23
|
+
const [verifiedPath, setVerifiedPath] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
const isPublicRoute = pathname === '/setup' || pathname === '/login';
|
|
26
|
+
// Evaluated fresh on every render (not held in state), so an expired cache
|
|
27
|
+
// closes the gate immediately rather than one paint late.
|
|
28
|
+
const ready = isPublicRoute || isCacheValid() || verifiedPath === pathname;
|
|
29
|
+
|
|
16
30
|
useEffect(() => {
|
|
31
|
+
// A redirect that has landed is finished: clear the flag on every
|
|
32
|
+
// navigation, otherwise the render gate below stays closed forever and
|
|
33
|
+
// the destination page renders blank. (AppShell currently unmounts this
|
|
34
|
+
// component for /login, /setup and /wizard, which used to hide the bug —
|
|
35
|
+
// don't depend on that staying true.)
|
|
36
|
+
setRedirecting(false);
|
|
37
|
+
|
|
17
38
|
// /setup: always accessible
|
|
18
39
|
if (pathname === '/setup') return;
|
|
19
40
|
|
|
20
|
-
// /login: public,
|
|
41
|
+
// /login: public, but bounce to /setup if no password exists yet
|
|
21
42
|
if (pathname === '/login') {
|
|
22
43
|
fetch('/api/auth/status')
|
|
23
44
|
.then((r) => r.json())
|
|
@@ -31,18 +52,16 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
31
52
|
// Already checking — prevent duplicate
|
|
32
53
|
if (checkingRef.current) return;
|
|
33
54
|
|
|
34
|
-
// Cache hit —
|
|
35
|
-
if (
|
|
36
|
-
return; // Still valid, no re-check
|
|
37
|
-
}
|
|
55
|
+
// Cache hit — content is already known-good, `ready` above covers it
|
|
56
|
+
if (isCacheValid()) return;
|
|
38
57
|
|
|
39
58
|
checkingRef.current = true;
|
|
40
59
|
|
|
41
|
-
// Protected
|
|
60
|
+
// Protected route with no valid cache — verify before revealing content
|
|
42
61
|
fetch('/api/auth/check')
|
|
43
62
|
.then(async (r) => {
|
|
44
63
|
if (!r.ok) {
|
|
45
|
-
// Not authenticated — redirect
|
|
64
|
+
// Not authenticated — redirect, keep content hidden
|
|
46
65
|
authCache.valid = false;
|
|
47
66
|
authCache.checked = true;
|
|
48
67
|
const status = await fetch('/api/auth/status').then((r2) => r2.json());
|
|
@@ -55,6 +74,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
55
74
|
authCache.valid = true;
|
|
56
75
|
authCache.checked = true;
|
|
57
76
|
authCache.ts = Date.now();
|
|
77
|
+
setVerifiedPath(pathname);
|
|
58
78
|
|
|
59
79
|
if (pathname === '/wizard') return;
|
|
60
80
|
|
|
@@ -86,6 +106,7 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|
|
86
106
|
});
|
|
87
107
|
}, [pathname, router]);
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
if (redirecting) return null;
|
|
110
|
+
if (!ready) return null; // hold content until this route's check resolves
|
|
90
111
|
return <>{children}</>;
|
|
91
112
|
}
|