@abstractframework/observer 0.1.9 → 0.1.11
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 +16 -3
- package/bin/cli.js +326 -2
- package/dist/assets/index-B15Wt8-t.js +166 -0
- package/dist/assets/index-B15Wt8-t.js.map +1 -0
- package/dist/assets/index-DXWoQByS.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/dist/assets/index-BY-zbj0z.js +0 -143
- package/dist/assets/index-BY-zbj0z.js.map +0 -1
- package/dist/assets/index-X_4ChdXq.css +0 -1
package/README.md
CHANGED
|
@@ -5,7 +5,8 @@ Gateway-only observability UI (Web/PWA) for AbstractFramework runs.
|
|
|
5
5
|
What it does (implemented in `src/ui/app.tsx` + `src/lib/gateway_client.ts`):
|
|
6
6
|
- **Discover** workflows/bundles exposed by an AbstractGateway
|
|
7
7
|
- **Launch** or **schedule** runs (durable)
|
|
8
|
-
- **Observe** runs by replaying + streaming the durable **ledger** (replay-first + SSE)
|
|
8
|
+
- **Observe** runs and subruns by replaying + streaming the durable **ledger** (replay-first + SSE)
|
|
9
|
+
- **Inspect runtime state** across active runs, generated artifacts, provider calls, and gateway audit logs
|
|
9
10
|
- **Control** runs via durable commands (`pause`, `resume`, `cancel`)
|
|
10
11
|
- (Optional) **Voice** in run chat: gateway-based TTS + push-to-talk transcription (`src/ui/use_gateway_voice.ts`)
|
|
11
12
|
|
|
@@ -38,7 +39,18 @@ Note: the npm package is `@abstractframework/observer`, and the CLI binary is `a
|
|
|
38
39
|
Open `http://localhost:3001`, then go to **Settings** and configure:
|
|
39
40
|
- **Gateway URL** (usually your gateway base URL, e.g. `http://localhost:8081`)
|
|
40
41
|
- Leave it blank only for same-origin deployments (reverse proxy routes `/api`) or when using `npm run dev` (Vite `/api` proxy).
|
|
41
|
-
-
|
|
42
|
+
- **Gateway user** and that user's **Gateway token** in hosted user-auth mode,
|
|
43
|
+
then click **Connect**
|
|
44
|
+
|
|
45
|
+
In hosted mode, Observer exchanges the user token for an app-scoped Gateway
|
|
46
|
+
browser session and does not persist the token in browser settings. Direct
|
|
47
|
+
bearer-token mode remains available for local development. When Observer is
|
|
48
|
+
served from a non-local hostname, the server-configured Gateway URL is
|
|
49
|
+
authoritative; browser-supplied Gateway URL changes are rejected unless
|
|
50
|
+
`ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1` is set behind your own
|
|
51
|
+
access control. If a reverse proxy rewrites `Host`, set
|
|
52
|
+
`ABSTRACTOBSERVER_TRUST_PROXY_HEADERS=1` only when the proxy strips
|
|
53
|
+
client-supplied forwarded headers.
|
|
42
54
|
|
|
43
55
|
## Install options
|
|
44
56
|
### Global install
|
|
@@ -61,7 +73,8 @@ The CLI is a static file server implemented in `bin/cli.js`.
|
|
|
61
73
|
## Features (UI pages)
|
|
62
74
|
All pages share the same gateway connection settings.
|
|
63
75
|
|
|
64
|
-
- **Observe**: ledger, graph, digest, attachments, chat (optional voice: PTT + TTS)
|
|
76
|
+
- **Observe**: workflow/subworkflow navigator, overview, human timeline, raw ledger, provider calls, graph, digest, attachments, chat (optional voice: PTT + TTS)
|
|
77
|
+
- **Runtime**: Activity, Artifacts, and Logs modes for platform-level monitoring. The Artifact Explorer uses Gateway artifact envelopes and exact stats, separates Voice/Music/Sound/unclassified audio from render kinds such as Markdown/HTML/JSON, previews media inline, and links artifacts back to producing runs, ledgers, and trace/audit actions when metadata is available
|
|
65
78
|
- **Launch**: start runs, schedule runs, bundle upload/reload
|
|
66
79
|
- **Mindmap**: knowledge-graph query UI (requires `POST /api/gateway/kg/query`)
|
|
67
80
|
- **Backlog / Inbox (reports + email) / Processes**: maintainer tooling (high trust; requires additional gateway endpoints)
|
package/bin/cli.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Serves the built web application on a configurable port
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import * as http from 'http';
|
|
9
|
+
import * as https from 'https';
|
|
9
10
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
10
11
|
import { join, extname, dirname } from 'path';
|
|
11
12
|
import { fileURLToPath } from 'url';
|
|
@@ -14,6 +15,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
14
15
|
const DIST_DIR = join(__dirname, '..', 'dist');
|
|
15
16
|
const PORT = process.env.PORT || 3001;
|
|
16
17
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
18
|
+
const DEFAULT_GATEWAY_URL = String(process.env.ABSTRACTOBSERVER_GATEWAY_URL || process.env.ABSTRACTGATEWAY_URL || 'http://127.0.0.1:8080').trim().replace(/\/+$/, '') || 'http://127.0.0.1:8080';
|
|
19
|
+
const GATEWAY_SESSION_URL_COOKIE = 'abstractobserver_gateway_url';
|
|
20
|
+
const GATEWAY_SESSION_ID_COOKIE = 'abstractobserver_gateway_session';
|
|
21
|
+
const GATEWAY_SESSION_CSRF_COOKIE = 'abstractobserver_gateway_csrf';
|
|
22
|
+
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'y', 'on']);
|
|
17
23
|
const ARGV = process.argv.slice(2);
|
|
18
24
|
function parse_bool_env(name) {
|
|
19
25
|
const raw = process.env[name];
|
|
@@ -95,10 +101,328 @@ function serveFile(res, filePath) {
|
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
|
|
104
|
+
function sendJson(res, status, payload) {
|
|
105
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
106
|
+
res.end(JSON.stringify(payload));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function envBool(name) {
|
|
110
|
+
const raw = process.env[name];
|
|
111
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
112
|
+
return TRUE_VALUES.has(raw.trim().toLowerCase());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function requestHostname(req) {
|
|
116
|
+
const trustProxyHeaders = envBool('ABSTRACTOBSERVER_TRUST_PROXY_HEADERS') || envBool('ABSTRACTGATEWAY_TRUST_PROXY_HEADERS');
|
|
117
|
+
const headerValue = trustProxyHeaders ? (req?.headers?.['x-forwarded-host'] || req?.headers?.host) : req?.headers?.host;
|
|
118
|
+
const raw = String(headerValue || '').split(',', 1)[0].trim();
|
|
119
|
+
if (!raw) return '';
|
|
120
|
+
if (raw.startsWith('[')) return raw.slice(1).split(']', 1)[0].trim().toLowerCase();
|
|
121
|
+
if ((raw.match(/:/g) || []).length === 1) return raw.split(':')[0].trim().toLowerCase();
|
|
122
|
+
return raw.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isLoopbackHostname(hostname) {
|
|
126
|
+
const h = String(hostname || '').trim().toLowerCase();
|
|
127
|
+
return h === 'localhost' || h === 'localhost.localdomain' || h === '::1' || h.startsWith('127.');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function browserGatewayConnectionConfigAllowed(req) {
|
|
131
|
+
if (envBool('ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
|
|
132
|
+
if (envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
|
|
133
|
+
return isLoopbackHostname(requestHostname(req));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function browserGatewayConnectionConfigDenial(req) {
|
|
137
|
+
const host = requestHostname(req) || 'unknown host';
|
|
138
|
+
return (
|
|
139
|
+
`Browser-supplied Gateway URL changes are disabled for this non-local Observer host (${host}). ` +
|
|
140
|
+
'Use the server-configured Gateway URL, or set ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control.'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseCookies(req) {
|
|
145
|
+
const out = {};
|
|
146
|
+
for (const part of String(req?.headers?.cookie || '').split(';')) {
|
|
147
|
+
const idx = part.indexOf('=');
|
|
148
|
+
if (idx < 0) continue;
|
|
149
|
+
const key = part.slice(0, idx).trim();
|
|
150
|
+
const value = part.slice(idx + 1).trim();
|
|
151
|
+
if (!key) continue;
|
|
152
|
+
try {
|
|
153
|
+
out[key] = decodeURIComponent(value);
|
|
154
|
+
} catch {
|
|
155
|
+
out[key] = value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cookieSecure(req) {
|
|
162
|
+
return String(req?.headers?.['x-forwarded-proto'] || '').trim().toLowerCase() === 'https' ? '; Secure' : '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, persist) {
|
|
166
|
+
const secure = cookieSecure(req);
|
|
167
|
+
const maxAge = persist ? '; Max-Age=2592000' : '';
|
|
168
|
+
const attrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
|
|
169
|
+
const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
|
|
170
|
+
res.setHeader('Set-Cookie', [
|
|
171
|
+
`${GATEWAY_SESSION_URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${attrs}`,
|
|
172
|
+
`${GATEWAY_SESSION_ID_COOKIE}=${encodeURIComponent(sessionId)}${attrs}`,
|
|
173
|
+
`${GATEWAY_SESSION_CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clearSessionCookies(res, req) {
|
|
178
|
+
const secure = cookieSecure(req);
|
|
179
|
+
res.setHeader('Set-Cookie', [
|
|
180
|
+
`${GATEWAY_SESSION_URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
181
|
+
`${GATEWAY_SESSION_ID_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
182
|
+
`${GATEWAY_SESSION_CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function browserSession(req) {
|
|
187
|
+
const cookies = parseCookies(req);
|
|
188
|
+
const cookieUrl = String(cookies[GATEWAY_SESSION_URL_COOKIE] || '').trim().replace(/\/+$/, '');
|
|
189
|
+
const allowCookieUrl =
|
|
190
|
+
envBool('ABSTRACTOBSERVER_ALLOW_BROWSER_GATEWAY_URL_COOKIE') ||
|
|
191
|
+
envBool('ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
|
|
192
|
+
envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
|
|
193
|
+
browserGatewayConnectionConfigAllowed(req);
|
|
194
|
+
return {
|
|
195
|
+
gatewayUrl: cookieUrl && (allowCookieUrl || cookieUrl === DEFAULT_GATEWAY_URL) ? cookieUrl : DEFAULT_GATEWAY_URL,
|
|
196
|
+
sessionId: String(cookies[GATEWAY_SESSION_ID_COOKIE] || '').trim(),
|
|
197
|
+
csrfToken: String(cookies[GATEWAY_SESSION_CSRF_COOKIE] || '').trim(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveBackend(gatewayUrl) {
|
|
202
|
+
const backend = new URL(String(gatewayUrl || DEFAULT_GATEWAY_URL).trim());
|
|
203
|
+
if (!backend.port) backend.port = backend.protocol === 'https:' ? '443' : '80';
|
|
204
|
+
return {
|
|
205
|
+
url: backend,
|
|
206
|
+
origin: `${backend.protocol}//${backend.host}`,
|
|
207
|
+
client: backend.protocol === 'https:' ? https : http,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function mutatingMethod(method) {
|
|
212
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || 'GET').toUpperCase());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readRequestJson(req) {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
const chunks = [];
|
|
218
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
219
|
+
req.on('end', () => {
|
|
220
|
+
try {
|
|
221
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
222
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
223
|
+
} catch {
|
|
224
|
+
resolve({});
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
req.on('error', () => resolve({}));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function gatewayRequest(gatewayUrl, options, body) {
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let backend;
|
|
234
|
+
try {
|
|
235
|
+
backend = resolveBackend(gatewayUrl);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
resolve({ ok: false, status: 0, payload: { detail: `Invalid gateway URL: ${String(err?.message || err)}` } });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const req = backend.client.request(
|
|
241
|
+
{
|
|
242
|
+
protocol: backend.url.protocol,
|
|
243
|
+
hostname: backend.url.hostname,
|
|
244
|
+
port: backend.url.port,
|
|
245
|
+
timeout: options.timeout || 4000,
|
|
246
|
+
...options,
|
|
247
|
+
},
|
|
248
|
+
(resp) => {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
resp.on('data', (chunk) => chunks.push(chunk));
|
|
251
|
+
resp.on('end', () => {
|
|
252
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
253
|
+
let payload = {};
|
|
254
|
+
try {
|
|
255
|
+
payload = text ? JSON.parse(text) : {};
|
|
256
|
+
} catch {
|
|
257
|
+
payload = { detail: text };
|
|
258
|
+
}
|
|
259
|
+
const status = resp.statusCode || 0;
|
|
260
|
+
resolve({ ok: status >= 200 && status < 300, status, payload, headers: resp.headers, origin: backend.origin });
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
);
|
|
264
|
+
req.on('timeout', () => {
|
|
265
|
+
req.destroy();
|
|
266
|
+
resolve({ ok: false, status: 0, payload: { detail: 'Gateway request timed out' }, origin: backend?.origin });
|
|
267
|
+
});
|
|
268
|
+
req.on('error', (err) => resolve({ ok: false, status: 0, payload: { detail: String(err?.message || err) }, origin: backend?.origin }));
|
|
269
|
+
if (body) req.write(body);
|
|
270
|
+
req.end();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function cookieValueFromSetCookie(rawHeaders, name) {
|
|
275
|
+
const headers = Array.isArray(rawHeaders) ? rawHeaders : rawHeaders ? [rawHeaders] : [];
|
|
276
|
+
for (const header of headers) {
|
|
277
|
+
for (const candidate of String(header || '').split(/,(?=\s*[^;,=]+=)/)) {
|
|
278
|
+
const first = candidate.split(';', 1)[0];
|
|
279
|
+
const idx = first.indexOf('=');
|
|
280
|
+
if (idx < 0) continue;
|
|
281
|
+
if (first.slice(0, idx).trim() !== name) continue;
|
|
282
|
+
const raw = first.slice(idx + 1).trim();
|
|
283
|
+
try {
|
|
284
|
+
return decodeURIComponent(raw);
|
|
285
|
+
} catch {
|
|
286
|
+
return raw;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function handleConnectionApi(req, res) {
|
|
294
|
+
if (req.method === 'GET') {
|
|
295
|
+
const session = browserSession(req);
|
|
296
|
+
if (!session.sessionId) {
|
|
297
|
+
sendJson(res, 200, { ok: false, gateway_url: session.gatewayUrl, has_session: false, gateway: { ok: false, error: 'Gateway sign-in required' } });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const checked = await gatewayRequest(session.gatewayUrl, {
|
|
301
|
+
method: 'GET',
|
|
302
|
+
path: '/api/gateway/me',
|
|
303
|
+
headers: { Accept: 'application/json', 'X-AbstractGateway-Session': session.sessionId },
|
|
304
|
+
});
|
|
305
|
+
sendJson(res, 200, { ok: checked.ok, gateway_url: session.gatewayUrl, has_session: Boolean(session.sessionId), gateway: checked.payload });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (req.method === 'POST') {
|
|
309
|
+
const payload = await readRequestJson(req);
|
|
310
|
+
const gatewayUrl = String(payload.gateway_url || DEFAULT_GATEWAY_URL).trim().replace(/\/+$/, '') || DEFAULT_GATEWAY_URL;
|
|
311
|
+
if (!browserGatewayConnectionConfigAllowed(req) && gatewayUrl !== DEFAULT_GATEWAY_URL) {
|
|
312
|
+
sendJson(res, 403, { detail: browserGatewayConnectionConfigDenial(req) });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const body = Buffer.from(JSON.stringify({
|
|
316
|
+
user_id: String(payload.gateway_user_id || '').trim(),
|
|
317
|
+
token: String(payload.gateway_token || '').trim(),
|
|
318
|
+
remember: payload.persist === true,
|
|
319
|
+
}));
|
|
320
|
+
const login = await gatewayRequest(gatewayUrl, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
path: '/api/gateway/session/login',
|
|
323
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
|
|
324
|
+
}, body);
|
|
325
|
+
const session = login.payload && typeof login.payload.session === 'object' ? login.payload.session : {};
|
|
326
|
+
const setCookie = login.headers?.['set-cookie'];
|
|
327
|
+
const sessionId = cookieValueFromSetCookie(setCookie, 'abstractgateway_session') || String(session.session_id || '').trim();
|
|
328
|
+
const csrfToken = cookieValueFromSetCookie(setCookie, 'abstractgateway_csrf') || String(session.csrf_token || '').trim();
|
|
329
|
+
if (!login.ok || !sessionId || !csrfToken) {
|
|
330
|
+
sendJson(res, login.status || 401, { ok: false, detail: login.payload?.detail || 'Gateway browser session failed', gateway: login.payload });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, payload.persist === true);
|
|
334
|
+
sendJson(res, 200, { ok: true, gateway_url: gatewayUrl, has_session: true, gateway: login.payload });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (req.method === 'DELETE') {
|
|
338
|
+
const session = browserSession(req);
|
|
339
|
+
if (session.sessionId) {
|
|
340
|
+
const body = Buffer.from('{}');
|
|
341
|
+
await gatewayRequest(session.gatewayUrl, {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
path: '/api/gateway/session/logout',
|
|
344
|
+
headers: {
|
|
345
|
+
Accept: 'application/json',
|
|
346
|
+
'Content-Type': 'application/json',
|
|
347
|
+
'Content-Length': String(body.length),
|
|
348
|
+
'X-AbstractGateway-Session': session.sessionId,
|
|
349
|
+
'X-AbstractGateway-CSRF': session.csrfToken,
|
|
350
|
+
},
|
|
351
|
+
timeout: 2000,
|
|
352
|
+
}, body);
|
|
353
|
+
}
|
|
354
|
+
clearSessionCookies(res, req);
|
|
355
|
+
sendJson(res, 200, { ok: true });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
sendJson(res, 405, { detail: 'Method not allowed' });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
|
|
362
|
+
|
|
363
|
+
function proxyHeaders(headers) {
|
|
364
|
+
const out = {};
|
|
365
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
366
|
+
const k = String(key).toLowerCase();
|
|
367
|
+
if (HOP_BY_HOP_HEADERS.has(k) || k === 'content-length') continue;
|
|
368
|
+
out[key] = value;
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function proxyApiRequest(req, res) {
|
|
374
|
+
const session = browserSession(req);
|
|
375
|
+
if (!session.sessionId) {
|
|
376
|
+
sendJson(res, 401, { detail: 'Gateway sign-in required' });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (mutatingMethod(req.method)) {
|
|
380
|
+
const presented = String(req.headers['x-abstractobserver-csrf'] || '').trim();
|
|
381
|
+
if (!session.csrfToken || presented !== session.csrfToken) {
|
|
382
|
+
sendJson(res, 403, { detail: 'Gateway browser session CSRF token missing or invalid', reason_code: 'csrf_required' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
let backend;
|
|
387
|
+
try {
|
|
388
|
+
backend = resolveBackend(session.gatewayUrl);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
sendJson(res, 500, { detail: `Invalid gateway URL: ${String(err?.message || err)}` });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const headers = { ...req.headers, host: backend.url.host };
|
|
394
|
+
delete headers.cookie;
|
|
395
|
+
delete headers.authorization;
|
|
396
|
+
delete headers['x-forwarded-for'];
|
|
397
|
+
delete headers['x-forwarded-host'];
|
|
398
|
+
delete headers['x-forwarded-proto'];
|
|
399
|
+
headers['x-abstractgateway-session'] = session.sessionId;
|
|
400
|
+
if (mutatingMethod(req.method)) headers['x-abstractgateway-csrf'] = session.csrfToken;
|
|
401
|
+
const proxyReq = backend.client.request(
|
|
402
|
+
{ protocol: backend.url.protocol, hostname: backend.url.hostname, port: backend.url.port, method: req.method, path: req.url, headers },
|
|
403
|
+
(proxyRes) => {
|
|
404
|
+
res.writeHead(proxyRes.statusCode || 502, proxyHeaders(proxyRes.headers));
|
|
405
|
+
proxyRes.pipe(res);
|
|
406
|
+
}
|
|
407
|
+
);
|
|
408
|
+
proxyReq.on('error', (err) => sendJson(res, 502, { detail: `Backend not reachable at ${backend.origin} (${String(err?.message || err)})` }));
|
|
409
|
+
req.pipe(proxyReq);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const server = http.createServer((req, res) => {
|
|
99
413
|
// Remove query strings and normalize path
|
|
100
414
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
101
415
|
let pathname = url.pathname;
|
|
416
|
+
|
|
417
|
+
if (pathname === '/api/connection/gateway') {
|
|
418
|
+
void handleConnectionApi(req, res);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (pathname.startsWith('/api/')) {
|
|
423
|
+
proxyApiRequest(req, res);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
102
426
|
|
|
103
427
|
// Security: prevent directory traversal
|
|
104
428
|
if (pathname.includes('..')) {
|