@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
@@ -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 allModels = loadModelsConfig();
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 p = getPricing(m.id);
149
- if (p) entry.pricing = p;
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); if (denied) return denied as any;
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); if (denied) return denied as any;
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;
@@ -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
 
@@ -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
- // Check both env and .env file (same fallback pattern as login/status routes)
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
- // Ensure password line
76
- if (content.includes('REV4A_PASSWORD=')) {
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
- // Generate JWT secret if missing
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
@@ -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
- const status = err.status || 500;
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
- const status = err.status || 404;
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); if (denied) return denied as any;
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); if (denied) return denied as any;
101
- if (!(await verifyAuth(request))) {
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 + '/data/wizard.json';
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 + '/data/wizard.json';
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 + '/data/wizard.json';
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 done = readWizardFile();
27
- return NextResponse.json({ done });
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, check if already authenticated
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 — skip check
35
- if (authCache.checked && authCache.valid && (Date.now() - authCache.ts) < AUTH_CACHE_TTL) {
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 routes — check auth in background
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
- // NEVER hide children — always render, auth check happens in background
109
+ if (redirecting) return null;
110
+ if (!ready) return null; // hold content until this route's check resolves
90
111
  return <>{children}</>;
91
112
  }