@flame0510/project-aether 1.6.1 → 1.7.0
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/LICENSE +21 -0
- package/README.md +5 -2
- package/agent-templates/atlas/HEARTBEAT.md +1 -1
- package/app/agents/ImageDownloadBanner.tsx +171 -37
- package/app/api/agents/download-image/route.ts +29 -5
- package/app/api/agents/image-status/route.ts +17 -2
- package/app/api/assistant/route.ts +13 -4
- package/app/api/auth/login/route.ts +2 -2
- package/app/api/setup/agent-image/route.ts +6 -4
- package/app/api/system-health/route.ts +3 -3
- package/app/components/DashboardToolbar.tsx +2 -2
- package/app/components/LineageGraphPage.tsx +4 -4
- package/app/components/SessionDrawer.tsx +8 -8
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/globals.css +1 -1
- package/app/setup/PageClient.tsx +1 -1
- package/bin/postinstall.js +5 -1
- package/daemon.js +13 -34
- package/docs/ARCHITECTURE.md +44 -26
- package/docs/CONTAINER-TERMINAL.md +17 -8
- package/docs/DESIGN-SYSTEM.md +21 -12
- package/docs/FRONTEND-ARCHITECTURE.md +2 -2
- package/docs/REV4A.md +35 -85
- package/docs/dev/API-REFERENCE.md +73 -19
- package/docs/dev/DATABASE.md +18 -7
- package/docs/dev/GATEWAY.md +23 -0
- package/docs/dev/SESSION-MAINTENANCE-PLAN.md +6 -6
- package/docs/rag/DATA-FRESHNESS.md +20 -9
- package/docs/rag/GLOSSARY.md +2 -2
- package/docs/rag/REV4A-OVERVIEW.md +10 -6
- package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
- package/lib/agent-images.ts +43 -14
- package/lib/buildAgentImage.ts +142 -6
- package/lib/patterns/sessionPresentation.ts +4 -2
- package/lib/rev4a-auth.d.ts +1 -0
- package/lib/rev4a-auth.js +18 -2
- package/models.config.json +937 -45
- package/next.config.mjs +9 -1
- package/package.json +5 -3
- package/scripts/backup.sh +48 -54
- package/scripts/check-language.mjs +21 -3
- package/scripts/model-info-verify.mjs +194 -0
- package/scripts/restore.sh +77 -59
package/lib/agent-images.ts
CHANGED
|
@@ -226,6 +226,13 @@ export async function remoteSupportedVersions(): Promise<string[] | null> {
|
|
|
226
226
|
/**
|
|
227
227
|
* Runs `docker <args>`, streaming output lines; rejects with the last error line.
|
|
228
228
|
*
|
|
229
|
+
* A signal that is already aborted rejects before anything is spawned: an `abort`
|
|
230
|
+
* listener added to an aborted signal never fires, so the process would otherwise run
|
|
231
|
+
* to completion even though its caller has been cancelled.
|
|
232
|
+
*
|
|
233
|
+
* Lines are reassembled across output chunks — a chunk can end mid-line — and `\r` is
|
|
234
|
+
* treated as a line break too, since docker redraws progress with it.
|
|
235
|
+
*
|
|
229
236
|
* `env` is for container environment on `docker run`: each name is inserted as `-e KEY`
|
|
230
237
|
* right after the subcommand, and the value reaches docker through its own
|
|
231
238
|
* environment, so secrets stay out of the process table.
|
|
@@ -235,6 +242,10 @@ export function runDocker(
|
|
|
235
242
|
opts: { onLine?: (line: string) => void; signal?: AbortSignal; timeoutMs: number; env?: Record<string, string> },
|
|
236
243
|
): Promise<void> {
|
|
237
244
|
return new Promise((resolve, reject) => {
|
|
245
|
+
if (opts.signal?.aborted) {
|
|
246
|
+
reject(new Error(`docker ${args[0]} aborted`));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
238
249
|
const envNames = Object.keys(opts.env ?? {});
|
|
239
250
|
const argv = envNames.length ? [args[0], ...envNames.flatMap((k) => ['-e', k]), ...args.slice(1)] : args;
|
|
240
251
|
const child = spawn('docker', argv, {
|
|
@@ -243,17 +254,28 @@ export function runDocker(
|
|
|
243
254
|
});
|
|
244
255
|
let lastError = '';
|
|
245
256
|
let firstError = '';
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
lastError = line.trim();
|
|
254
|
-
}
|
|
255
|
-
opts.onLine?.(line);
|
|
257
|
+
const emit = (line: string, isErr: boolean) => {
|
|
258
|
+
if (!line.trim()) return;
|
|
259
|
+
if (isErr) {
|
|
260
|
+
// Docker prints the cause first and "Run 'docker run --help' …" last; keep
|
|
261
|
+
// both ends so the rejection can carry the cause, not the usage pointer.
|
|
262
|
+
firstError ||= line.trim();
|
|
263
|
+
lastError = line.trim();
|
|
256
264
|
}
|
|
265
|
+
opts.onLine?.(line);
|
|
266
|
+
};
|
|
267
|
+
// The unfinished tail of the last chunk, per stream, completed by the next one.
|
|
268
|
+
const pending = { out: '', err: '' };
|
|
269
|
+
const onData = (isErr: boolean) => (chunk: Buffer) => {
|
|
270
|
+
const key = isErr ? 'err' : 'out';
|
|
271
|
+
const parts = (pending[key] + chunk.toString()).split(/\r\n|\n|\r/);
|
|
272
|
+
pending[key] = parts.pop() ?? '';
|
|
273
|
+
for (const line of parts) emit(line, isErr);
|
|
274
|
+
};
|
|
275
|
+
const flush = () => {
|
|
276
|
+
emit(pending.out, false);
|
|
277
|
+
emit(pending.err, true);
|
|
278
|
+
pending.out = pending.err = '';
|
|
257
279
|
};
|
|
258
280
|
child.stdout.on('data', onData(false));
|
|
259
281
|
child.stderr.on('data', onData(true));
|
|
@@ -271,6 +293,7 @@ export function runDocker(
|
|
|
271
293
|
reject(err);
|
|
272
294
|
});
|
|
273
295
|
child.on('close', (code) => {
|
|
296
|
+
flush();
|
|
274
297
|
clearTimeout(timer);
|
|
275
298
|
opts.signal?.removeEventListener('abort', kill);
|
|
276
299
|
if (code === 0) resolve();
|
|
@@ -281,8 +304,9 @@ export function runDocker(
|
|
|
281
304
|
|
|
282
305
|
/**
|
|
283
306
|
* Pull `<registry>:<version>` and tag it `openclaw-agent-base:<version>`. The registry
|
|
284
|
-
* reference is removed afterwards
|
|
285
|
-
*
|
|
307
|
+
* reference is removed afterwards, so local images list under the version tag only: after
|
|
308
|
+
* a successful tag that is an untag (the layers stay); when the tag failed or was
|
|
309
|
+
* cancelled it is the image's only name, and the pulled image is deleted with it.
|
|
286
310
|
*/
|
|
287
311
|
export async function pullAgentImage(
|
|
288
312
|
version: string,
|
|
@@ -291,8 +315,13 @@ export async function pullAgentImage(
|
|
|
291
315
|
if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
|
|
292
316
|
const remote = `${registryRepository()}:${version}`;
|
|
293
317
|
await runDocker(['pull', remote], { ...opts, timeoutMs: PULL_TIMEOUT_MS });
|
|
294
|
-
|
|
295
|
-
|
|
318
|
+
try {
|
|
319
|
+
await runDocker(['tag', remote, localImageRef(version)], { ...opts, timeoutMs: 30_000 });
|
|
320
|
+
} finally {
|
|
321
|
+
// Also when the tag failed or was cancelled: left behind, the registry reference
|
|
322
|
+
// is a full image under a name neither the listing nor retention looks at.
|
|
323
|
+
await runDocker(['rmi', remote], { timeoutMs: 30_000 }).catch(() => {});
|
|
324
|
+
}
|
|
296
325
|
}
|
|
297
326
|
|
|
298
327
|
/**
|
package/lib/buildAgentImage.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* instead — development without registry access. Any other version cannot be built
|
|
12
12
|
* from here, so its download fails.
|
|
13
13
|
*
|
|
14
|
-
* One download at a time (in-process lock).
|
|
15
|
-
* /tmp/rev4a-download-<timestamp>.log
|
|
14
|
+
* One download at a time (in-process lock). Docker's output also goes to
|
|
15
|
+
* /tmp/rev4a-download-<timestamp>.log (mode 0600, kept 24 h) for diagnosis — nothing in the
|
|
16
|
+
* UI reads it; the banner gets its percent and status line from `getDownloadProgress()`.
|
|
16
17
|
*/
|
|
17
18
|
import * as fs from 'fs';
|
|
18
19
|
import * as path from 'path';
|
|
@@ -26,6 +27,36 @@ const BUILD_TIMEOUT_MS = 60 * 60 * 1000;
|
|
|
26
27
|
/** The version being downloaded; `pending` while the default version is being resolved. */
|
|
27
28
|
let downloadingVersion: string | null = null;
|
|
28
29
|
let downloadLogPath: string | null = null;
|
|
30
|
+
/** Owns the running pull/build process; `cancelDownload()` aborts it. One at a time, like the download itself. */
|
|
31
|
+
let downloadController: AbortController | null = null;
|
|
32
|
+
/** `docker pull` layer ids seen, by state — the source of the real percent below. */
|
|
33
|
+
const pullLayers = new Map<string, 'pending' | 'done'>();
|
|
34
|
+
/** Set once a layer starts transferring: from then on the layer list is complete. */
|
|
35
|
+
let pullTransferStarted = false;
|
|
36
|
+
let downloadPercent: number | null = null;
|
|
37
|
+
/** The latest line of docker's output, shown under the percent (or alone when there is none). */
|
|
38
|
+
let downloadMessage: string | null = null;
|
|
39
|
+
|
|
40
|
+
export interface DownloadOutcome {
|
|
41
|
+
version: string;
|
|
42
|
+
result: 'succeeded' | 'failed' | 'canceled';
|
|
43
|
+
source?: 'present' | 'registry' | 'build';
|
|
44
|
+
message: string;
|
|
45
|
+
at: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The last download's outcome, same idea as `finished` in lib/cold-backup.ts: a client
|
|
50
|
+
* that was not mounted for the live `downloading: true → false` transition (navigated
|
|
51
|
+
* away, reloaded, opened the page late) would otherwise see nothing at all once the
|
|
52
|
+
* download is done — this is what it reads instead. Overwritten by the next download,
|
|
53
|
+
* never cleared on its own; the client decides how long "recently" still means something.
|
|
54
|
+
*/
|
|
55
|
+
let lastDownloadResult: DownloadOutcome | null = null;
|
|
56
|
+
|
|
57
|
+
export function getLastDownloadResult(): DownloadOutcome | null {
|
|
58
|
+
return lastDownloadResult;
|
|
59
|
+
}
|
|
29
60
|
|
|
30
61
|
/** Checks if a download is already running (in-process flag only). */
|
|
31
62
|
export function getIsDownloading(): boolean {
|
|
@@ -36,6 +67,63 @@ export function getDownloadingVersion(): string | null {
|
|
|
36
67
|
return downloadingVersion === 'pending' ? null : downloadingVersion;
|
|
37
68
|
}
|
|
38
69
|
|
|
70
|
+
/** Real progress from `docker pull`'s own per-layer lines; `percent` is null until it can be computed (see `trackPullLayer`). */
|
|
71
|
+
export function getDownloadProgress(): { percent: number | null; message: string | null } {
|
|
72
|
+
return { percent: downloadPercent, message: downloadMessage };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cancel the running download, if any: the docker process is killed, or never started
|
|
77
|
+
* when the cancel arrives first (`runDocker` refuses an already-aborted signal). The
|
|
78
|
+
* download rejects with a distinct 'canceled' error so the caller can tell it apart from
|
|
79
|
+
* a real failure. Returns false when nothing is running.
|
|
80
|
+
*/
|
|
81
|
+
export function cancelDownload(): boolean {
|
|
82
|
+
if (!downloadController) return false;
|
|
83
|
+
downloadController.abort();
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the last finished download ended as cancelled. Derived from its recorded
|
|
89
|
+
* outcome, not from the request: a cancel that arrives after the image is already tagged
|
|
90
|
+
* changes nothing, and must not make a successful download read as cancelled.
|
|
91
|
+
*/
|
|
92
|
+
export function wasDownloadCanceled(): boolean {
|
|
93
|
+
return lastDownloadResult?.result === 'canceled';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Track `docker pull`'s per-layer lines to compute a real percent: the fraction of
|
|
98
|
+
* layers finished. A layer is finished at `Pull complete` (downloaded *and* extracted) or
|
|
99
|
+
* `Already exists` — not at `Download complete`, which still has the extraction ahead.
|
|
100
|
+
*
|
|
101
|
+
* Not byte-exact (layers differ in size and run in parallel), and deliberately withheld
|
|
102
|
+
* until the first layer starts transferring: docker lists every layer up front, and a
|
|
103
|
+
* percent computed before the list is complete jumps high on a cached first layer and
|
|
104
|
+
* then falls (99 → 50 → 33 → 25 was reproduced). It also never goes down, and stays
|
|
105
|
+
* below 100 — completion is reported by the download's outcome, not by a percent.
|
|
106
|
+
*/
|
|
107
|
+
function trackPullLayer(line: string): void {
|
|
108
|
+
const m = /^([0-9a-f]{12}):\s+(.+)$/.exec(line.trim());
|
|
109
|
+
if (!m) return;
|
|
110
|
+
const [, id, state] = m;
|
|
111
|
+
if (/^(Pulling fs layer|Waiting)/.test(state)) {
|
|
112
|
+
if (!pullLayers.has(id)) pullLayers.set(id, 'pending');
|
|
113
|
+
} else if (/^(Pull complete|Already exists)/.test(state)) {
|
|
114
|
+
pullLayers.set(id, 'done');
|
|
115
|
+
} else if (/^(Downloading|Verifying Checksum|Download complete|Extracting)/.test(state)) {
|
|
116
|
+
if (!pullLayers.has(id)) pullLayers.set(id, 'pending');
|
|
117
|
+
pullTransferStarted = true;
|
|
118
|
+
} else {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!pullTransferStarted || pullLayers.size === 0) return;
|
|
122
|
+
const done = [...pullLayers.values()].filter((v) => v === 'done').length;
|
|
123
|
+
const percent = Math.min(99, Math.round((done / pullLayers.size) * 100));
|
|
124
|
+
downloadPercent = Math.max(downloadPercent ?? 0, percent);
|
|
125
|
+
}
|
|
126
|
+
|
|
39
127
|
function appendLog(msg: string): void {
|
|
40
128
|
if (!downloadLogPath) return;
|
|
41
129
|
try {
|
|
@@ -96,53 +184,101 @@ export async function downloadAgentImage(opts: DownloadOptions = {}): Promise<Do
|
|
|
96
184
|
if (!dockerAvailable()) throw new Error('Docker is not available — please install Docker and try again');
|
|
97
185
|
if (downloadingVersion !== null) throw new Error('A download is already in progress');
|
|
98
186
|
downloadingVersion = opts.version ?? 'pending';
|
|
187
|
+
pullLayers.clear();
|
|
188
|
+
pullTransferStarted = false;
|
|
189
|
+
downloadPercent = null;
|
|
190
|
+
downloadMessage = null;
|
|
99
191
|
downloadLogPath = `/tmp/rev4a-download-${Date.now()}.log`;
|
|
192
|
+
try {
|
|
193
|
+
// Owner-only, and 'wx' refuses a path that already exists (a planted file or link).
|
|
194
|
+
fs.writeFileSync(downloadLogPath, '', { mode: 0o600, flag: 'wx' });
|
|
195
|
+
} catch {
|
|
196
|
+
downloadLogPath = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Owns the child process for this run. A caller's own signal (the setup wizard's SSE
|
|
200
|
+
// stream closing) aborts it too, same as cancelDownload() — both end up here, so
|
|
201
|
+
// `signal.aborted` below is the one place that tells "stopped" from "failed".
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
downloadController = controller;
|
|
204
|
+
const onCallerAbort = () => controller.abort();
|
|
205
|
+
if (opts.signal?.aborted) controller.abort();
|
|
206
|
+
else opts.signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
207
|
+
const { signal } = controller;
|
|
208
|
+
// Between awaits a cancel can arrive with no process to kill yet; stop at the next step.
|
|
209
|
+
const stopIfCanceled = () => { if (signal.aborted) throw new Error('canceled'); };
|
|
100
210
|
|
|
101
211
|
try {
|
|
102
212
|
const version = opts.version ?? (await defaultDownloadVersion());
|
|
213
|
+
stopIfCanceled();
|
|
103
214
|
if (!isSupportedVersion(version)) throw new Error(`OpenClaw ${version} is not supported by this Rev4a`);
|
|
104
215
|
downloadingVersion = version;
|
|
105
216
|
|
|
106
|
-
|
|
217
|
+
const present = await localVersionExists(version);
|
|
218
|
+
stopIfCanceled();
|
|
219
|
+
if (present) {
|
|
107
220
|
event('complete', `OpenClaw ${version} image already downloaded`);
|
|
221
|
+
lastDownloadResult = { version, result: 'succeeded', source: 'present', message: `OpenClaw ${version} image already downloaded`, at: Date.now() };
|
|
108
222
|
return { version, source: 'present' };
|
|
109
223
|
}
|
|
110
224
|
|
|
111
225
|
event('status', `Downloading OpenClaw ${version} image…`);
|
|
112
226
|
try {
|
|
113
227
|
await pullAgentImage(version, {
|
|
114
|
-
signal
|
|
228
|
+
signal,
|
|
115
229
|
onLine: (line) => {
|
|
116
230
|
appendLog(line);
|
|
231
|
+
downloadMessage = line;
|
|
232
|
+
trackPullLayer(line);
|
|
117
233
|
if (/Download|Extract|Pull/.test(line)) opts.onEvent?.(JSON.stringify({ type: 'progress', message: line }));
|
|
118
234
|
},
|
|
119
235
|
});
|
|
120
236
|
event('complete', `OpenClaw ${version} image downloaded`);
|
|
237
|
+
lastDownloadResult = { version, result: 'succeeded', source: 'registry', message: `OpenClaw ${version} image downloaded`, at: Date.now() };
|
|
121
238
|
return { version, source: 'registry' };
|
|
122
239
|
} catch (e) {
|
|
123
240
|
appendLog(`Registry download failed: ${(e as Error).message}`);
|
|
124
|
-
if (
|
|
241
|
+
if (signal.aborted) throw new Error('canceled');
|
|
242
|
+
if (dockerfileVersion() !== version) {
|
|
125
243
|
throw new Error(`Could not download OpenClaw ${version}: ${(e as Error).message}`);
|
|
126
244
|
}
|
|
127
245
|
}
|
|
128
246
|
|
|
129
247
|
event('log', 'Registry download failed, building the image locally…');
|
|
248
|
+
downloadPercent = null;
|
|
249
|
+
stopIfCanceled();
|
|
130
250
|
await runDocker(
|
|
131
251
|
['build', '--build-arg', `OPENCLAW_VERSION=${version}`, '-t', localImageRef(version), '--progress=plain', DOCKERFILE_DIR],
|
|
132
252
|
{
|
|
133
|
-
signal
|
|
253
|
+
signal,
|
|
134
254
|
timeoutMs: BUILD_TIMEOUT_MS,
|
|
135
255
|
onLine: (line) => {
|
|
136
256
|
appendLog(line);
|
|
257
|
+
downloadMessage = line;
|
|
137
258
|
if (line.startsWith('#')) opts.onEvent?.(JSON.stringify({ type: 'step', message: line }));
|
|
138
259
|
},
|
|
139
260
|
},
|
|
140
261
|
);
|
|
141
262
|
event('complete', `OpenClaw ${version} image built locally`);
|
|
263
|
+
lastDownloadResult = { version, result: 'succeeded', source: 'build', message: `OpenClaw ${version} image built locally`, at: Date.now() };
|
|
142
264
|
return { version, source: 'build' };
|
|
265
|
+
} catch (e) {
|
|
266
|
+
const version = downloadingVersion === 'pending' ? (opts.version ?? 'unknown') : downloadingVersion;
|
|
267
|
+
if (signal.aborted) {
|
|
268
|
+
lastDownloadResult = { version, result: 'canceled', message: `OpenClaw ${version} download canceled`, at: Date.now() };
|
|
269
|
+
throw new Error('canceled');
|
|
270
|
+
}
|
|
271
|
+
lastDownloadResult = { version, result: 'failed', message: (e as Error).message, at: Date.now() };
|
|
272
|
+
throw e;
|
|
143
273
|
} finally {
|
|
274
|
+
opts.signal?.removeEventListener('abort', onCallerAbort);
|
|
144
275
|
downloadingVersion = null;
|
|
145
276
|
downloadLogPath = null;
|
|
277
|
+
downloadController = null;
|
|
278
|
+
pullLayers.clear();
|
|
279
|
+
pullTransferStarted = false;
|
|
280
|
+
downloadPercent = null;
|
|
281
|
+
downloadMessage = null;
|
|
146
282
|
cleanupOldLogs();
|
|
147
283
|
}
|
|
148
284
|
}
|
|
@@ -27,11 +27,13 @@ export function shortLabel(sessionId: string): string {
|
|
|
27
27
|
return sessionId.slice(0, 14);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// The two Italian patterns match prompt prefixes stored in older sessions' data; they
|
|
31
|
+
// are matched, never shown, so they stay as they are.
|
|
30
32
|
function stripTaskBoilerplate(value: string): string {
|
|
31
33
|
return value
|
|
32
34
|
.replace(/^\[Subagent Task\]\s*/i, '')
|
|
33
|
-
.replace(/^Task richiesto da .*?:\s*/i, '')
|
|
34
|
-
.replace(/^Sei [^.\n]+\.\s*/i, '')
|
|
35
|
+
.replace(/^Task richiesto da .*?:\s*/i, '') // lang-ok: matches stored prompt text
|
|
36
|
+
.replace(/^Sei [^.\n]+\.\s*/i, '') // lang-ok: matches stored prompt text
|
|
35
37
|
.replace(/^Begin\.?\s*/i, '')
|
|
36
38
|
.trim();
|
|
37
39
|
}
|
package/lib/rev4a-auth.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export function requireAuthIfConfigured(request: Request): Promise<NextResponse
|
|
|
4
4
|
export function getPassword(): string;
|
|
5
5
|
export function getJwtSecret(): string;
|
|
6
6
|
export function isConfigured(): boolean;
|
|
7
|
+
export function secretEquals(supplied: unknown, expected: unknown): boolean;
|
package/lib/rev4a-auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { jwtVerify } from "jose";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
4
5
|
import { ENV_FILE } from "@/lib/rev4a-paths";
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -61,14 +62,17 @@ export async function requireAuthJWT(request) {
|
|
|
61
62
|
} catch { /* fall through to bearer */ }
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
// Only the server-side variable: anything named NEXT_PUBLIC_* is inlined into the
|
|
66
|
+
// browser bundle by Next.js, so accepting one here would hand the API token to every
|
|
67
|
+
// visitor of the dashboard the moment an operator set it.
|
|
68
|
+
const expectedToken = process.env.REV4A_TOKEN || "";
|
|
65
69
|
// Without this guard an unset token would make `Bearer ` match an empty
|
|
66
70
|
// expected value. HTTP header normalisation already strips the trailing
|
|
67
71
|
// space so it isn't reachable in practice — the guard makes that safety
|
|
68
72
|
// explicit instead of dependent on it.
|
|
69
73
|
if (expectedToken) {
|
|
70
74
|
const auth = request.headers.get("authorization");
|
|
71
|
-
if (auth
|
|
75
|
+
if (auth && secretEquals(auth, `Bearer ${expectedToken}`)) return null;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
return unauthorized();
|
|
@@ -82,3 +86,15 @@ export async function requireAuthIfConfigured(request) {
|
|
|
82
86
|
if (!isConfigured()) return null;
|
|
83
87
|
return requireAuthJWT(request);
|
|
84
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compare a supplied secret with the expected one in constant time. Both are hashed to
|
|
92
|
+
* a fixed length first, so neither the content nor the length of the expected value
|
|
93
|
+
* leaks through how long the comparison takes. (The provider gateway's own check in
|
|
94
|
+
* app/api/provider/auth.ts compares the raw bytes after a length guard instead.)
|
|
95
|
+
*/
|
|
96
|
+
export function secretEquals(supplied, expected) {
|
|
97
|
+
if (typeof supplied !== "string" || typeof expected !== "string" || !expected) return false;
|
|
98
|
+
const digest = (value) => createHash("sha256").update(value, "utf8").digest();
|
|
99
|
+
return timingSafeEqual(digest(supplied), digest(expected));
|
|
100
|
+
}
|