@oar-sh/oar 0.9.10 → 0.9.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/CHANGELOG.md +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +787 -22
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -1238,7 +1238,21 @@ export function createRemoteRelayDispatcher({
|
|
|
1238
1238
|
}
|
|
1239
1239
|
}
|
|
1240
1240
|
|
|
1241
|
-
|
|
1241
|
+
/** The session group of a listed row: id, display name and the row's role. */
|
|
1242
|
+
function sessionGroupField(row, groups) {
|
|
1243
|
+
const groupId = toText(row?.groupId);
|
|
1244
|
+
if (!groupId) return {};
|
|
1245
|
+
const info = isPlainObject(groups?.[groupId]) ? groups[groupId] : null;
|
|
1246
|
+
return {
|
|
1247
|
+
group: {
|
|
1248
|
+
id: groupId,
|
|
1249
|
+
name: toText(info?.displayName) || toText(info?.name) || null,
|
|
1250
|
+
role: toText(row?.groupRole) === 'head' ? 'head' : 'member',
|
|
1251
|
+
},
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function mapSession(row, workerIds, ctx = null, groups = null) {
|
|
1242
1256
|
const id = toText(row?.id);
|
|
1243
1257
|
const via = toText(row?.origin?.relayName);
|
|
1244
1258
|
// A session an agent of that relay created there, and from which session.
|
|
@@ -1256,6 +1270,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1256
1270
|
...(via ? { via } : {}),
|
|
1257
1271
|
...(createdBy ? { createdBy } : {}),
|
|
1258
1272
|
...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
|
|
1273
|
+
// The session group it sits in (sessions an agent started here are
|
|
1274
|
+
// grouped under that agent's session; the owner can group by hand).
|
|
1275
|
+
...sessionGroupField(row, groups),
|
|
1259
1276
|
};
|
|
1260
1277
|
}
|
|
1261
1278
|
|
|
@@ -1296,7 +1313,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1296
1313
|
// pages, all = the whole list page by page (only it honours the cursor).
|
|
1297
1314
|
let cursor = null;
|
|
1298
1315
|
if (scope === 'all' && args.cursor) {
|
|
1299
|
-
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
|
|
1316
|
+
cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
|
|
1300
1317
|
if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
|
|
1301
1318
|
}
|
|
1302
1319
|
const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
|
|
@@ -1305,6 +1322,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1305
1322
|
&& (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
|
|
1306
1323
|
|
|
1307
1324
|
const sessions = [];
|
|
1325
|
+
const seen = new Set();
|
|
1308
1326
|
let hasMore = false;
|
|
1309
1327
|
let scanned = 0;
|
|
1310
1328
|
for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
|
|
@@ -1313,18 +1331,32 @@ export function createRemoteRelayDispatcher({
|
|
|
1313
1331
|
limit: filtering ? LIST_PAGE_MAX : limit,
|
|
1314
1332
|
beforeConversationId: cursor?.beforeConversationId,
|
|
1315
1333
|
beforeUpdatedAt: cursor?.beforeUpdatedAt,
|
|
1334
|
+
beforeGroupId: cursor?.beforeGroupId,
|
|
1316
1335
|
},
|
|
1317
1336
|
});
|
|
1318
1337
|
const rows = Array.isArray(response?.conversations) ? response.conversations : [];
|
|
1338
|
+
const groups = isPlainObject(response?.groups) ? response.groups : null;
|
|
1319
1339
|
let consumed = 0;
|
|
1320
1340
|
for (const row of rows) {
|
|
1321
1341
|
consumed += 1;
|
|
1322
|
-
|
|
1342
|
+
// A group served again whole (its cursor row vanished) repeats rows.
|
|
1343
|
+
if (seen.has(toText(row?.id))) continue;
|
|
1344
|
+
seen.add(toText(row?.id));
|
|
1345
|
+
if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
|
|
1323
1346
|
if (sessions.length >= limit) break;
|
|
1324
1347
|
}
|
|
1325
1348
|
scanned += consumed;
|
|
1326
1349
|
const last = rows[consumed - 1];
|
|
1327
|
-
|
|
1350
|
+
// The row's sort time (its group's newest row, `activityAt`) and its
|
|
1351
|
+
// group, so the next page continues from the right place even if the
|
|
1352
|
+
// row is archived or deleted before then.
|
|
1353
|
+
if (last) {
|
|
1354
|
+
cursor = {
|
|
1355
|
+
beforeConversationId: toText(last.id),
|
|
1356
|
+
beforeUpdatedAt: toText(last.activityAt) || toText(last.updatedAt),
|
|
1357
|
+
...(toText(last.groupId) ? { beforeGroupId: toText(last.groupId) } : {}),
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1328
1360
|
hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
|
|
1329
1361
|
if (sessions.length >= limit || !hasMore || !rows.length) break;
|
|
1330
1362
|
}
|
|
@@ -1405,7 +1437,9 @@ export function createRemoteRelayDispatcher({
|
|
|
1405
1437
|
if (!before) throw invalidInput('before is not an olderCursor from read_session');
|
|
1406
1438
|
}
|
|
1407
1439
|
const conversation = await call(ctx, 'GET', sessionPath(args.session), {
|
|
1408
|
-
|
|
1440
|
+
// groupMembers: the session group's live members; the relay gives them
|
|
1441
|
+
// to its own sessions only.
|
|
1442
|
+
query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
|
|
1409
1443
|
});
|
|
1410
1444
|
const conversationId = toText(conversation?.id) || args.session;
|
|
1411
1445
|
const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
|
|
@@ -1425,6 +1459,23 @@ export function createRemoteRelayDispatcher({
|
|
|
1425
1459
|
const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
|
|
1426
1460
|
const via = toText(conversation?.origin?.relayName);
|
|
1427
1461
|
const inFlight = progressSnapshot(conversation?.inFlight);
|
|
1462
|
+
// Its session group, with the other members and what each is doing.
|
|
1463
|
+
const groupInfo = isPlainObject(conversation?.group) ? conversation.group : null;
|
|
1464
|
+
const group = groupInfo && toText(groupInfo.id) ? {
|
|
1465
|
+
id: toText(groupInfo.id),
|
|
1466
|
+
name: toText(groupInfo.displayName) || toText(groupInfo.name) || null,
|
|
1467
|
+
role: toText(groupInfo.role) === 'head' ? 'head' : 'member',
|
|
1468
|
+
members: (Array.isArray(groupInfo.members) ? groupInfo.members : [])
|
|
1469
|
+
.filter(isPlainObject)
|
|
1470
|
+
.slice(0, 50)
|
|
1471
|
+
.map((member) => ({
|
|
1472
|
+
id: toText(member.id),
|
|
1473
|
+
title: toText(member.title),
|
|
1474
|
+
role: toText(member.role) === 'head' ? 'head' : 'member',
|
|
1475
|
+
state: SESSION_STATES.has(toText(member.state)) ? toText(member.state) : 'idle',
|
|
1476
|
+
...(member.archived === true ? { archived: true } : {}),
|
|
1477
|
+
})),
|
|
1478
|
+
} : null;
|
|
1428
1479
|
return {
|
|
1429
1480
|
summary: `read_session → ${ctx.relay.name} session ${shortId(conversationId)}: ${messages.length} message${messages.length === 1 ? '' : 's'}${inFlight ? ', turn running' : ''}${pendingQuestions.length ? `, ${pendingQuestions.length} question${pendingQuestions.length === 1 ? '' : 's'} pending` : ''}`,
|
|
1430
1481
|
session: {
|
|
@@ -1438,6 +1489,7 @@ export function createRemoteRelayDispatcher({
|
|
|
1438
1489
|
active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
|
|
1439
1490
|
...sessionActivityFields(conversation),
|
|
1440
1491
|
...(via ? { via } : {}),
|
|
1492
|
+
...(group ? { group } : {}),
|
|
1441
1493
|
openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
|
|
1442
1494
|
},
|
|
1443
1495
|
messages,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Where a request came from, as far as the relay can tell.
|
|
4
|
+
//
|
|
5
|
+
// "Truly local" is the boundary for the few things only a process on the
|
|
6
|
+
// relay's own machine may do (run an image operation, open a session-worker
|
|
7
|
+
// socket): the TCP peer is loopback AND nothing in front of the relay touched
|
|
8
|
+
// the request. A same-host reverse proxy (Caddy, nginx), a hand-run
|
|
9
|
+
// cloudflared and the built-in tunnel all connect from 127.0.0.1 too, so the
|
|
10
|
+
// peer alone proves nothing; what tells them apart is what they add: an edge
|
|
11
|
+
// marker (`cf-ray`, or a configured one) or a forwarding header. Local workers
|
|
12
|
+
// and agent tools call the relay over 127.0.0.1 and send none of these.
|
|
13
|
+
//
|
|
14
|
+
// A forwarder that adds no header at all (an SSH reverse tunnel, a bare TCP
|
|
15
|
+
// proxy) cannot be told apart from a local process; the access token is then
|
|
16
|
+
// the only boundary, as it is for every other route.
|
|
17
|
+
|
|
18
|
+
// Cloudflare's edge stamps `cf-ray` on every proxied request and a remote
|
|
19
|
+
// client cannot strip it.
|
|
20
|
+
export const DEFAULT_TUNNEL_MARKER_HEADERS = Object.freeze(['cf-ray']);
|
|
21
|
+
|
|
22
|
+
// Headers a proxy adds to say who it forwards for. Read here only as a trace
|
|
23
|
+
// that a proxy was involved, never as an address.
|
|
24
|
+
export const FORWARDING_HEADERS = Object.freeze(['x-forwarded-for', 'forwarded', 'x-real-ip']);
|
|
25
|
+
|
|
26
|
+
function normalizeHeaderName(value) {
|
|
27
|
+
return String(value || '').trim().toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function headerNamesFrom(source) {
|
|
31
|
+
if (Array.isArray(source)) return source;
|
|
32
|
+
if (source === undefined || source === null) return [];
|
|
33
|
+
return String(source).split(',');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The marker headers to look for: `cf-ray` always, plus every name from every
|
|
38
|
+
* source given (an array or a comma-separated string). Sources are merged,
|
|
39
|
+
* none replaces another, so `tunnelMarkerHeaders: []` in the config does not
|
|
40
|
+
* switch off COPILOT_TUNNEL_MARKER_HEADERS.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveTunnelMarkerHeaders(...sources) {
|
|
43
|
+
const names = [...DEFAULT_TUNNEL_MARKER_HEADERS];
|
|
44
|
+
for (const source of sources) {
|
|
45
|
+
for (const entry of headerNamesFrom(source)) {
|
|
46
|
+
const name = normalizeHeaderName(entry);
|
|
47
|
+
if (name && !names.includes(name)) names.push(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return names;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 127.0.0.0/8 and ::1, plain or IPv4-mapped. */
|
|
54
|
+
export function isLoopbackAddress(value) {
|
|
55
|
+
let text = String(value || '').trim().toLowerCase();
|
|
56
|
+
if (!text) return false;
|
|
57
|
+
if (text === '::1' || text === '0:0:0:0:0:0:0:1') return true;
|
|
58
|
+
if (text.startsWith('::ffff:')) text = text.slice(7);
|
|
59
|
+
if (text === '7f00:1') return true;
|
|
60
|
+
return /^127(?:\.\d{1,3}){3}$/.test(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function headerPresent(headers, name) {
|
|
64
|
+
const value = headers?.[name];
|
|
65
|
+
if (Array.isArray(value)) return value.some((entry) => String(entry || '').trim());
|
|
66
|
+
return typeof value === 'string' ? !!value.trim() : !!value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function peerAddressOf(req) {
|
|
70
|
+
return req?.socket?.remoteAddress || req?.connection?.remoteAddress || '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `markerHeaders`: names as resolveTunnelMarkerHeaders returns them (the
|
|
75
|
+
* default `cf-ray` is added if missing).
|
|
76
|
+
*/
|
|
77
|
+
export function createLocalRequestCheck({ markerHeaders = DEFAULT_TUNNEL_MARKER_HEADERS } = {}) {
|
|
78
|
+
const markers = resolveTunnelMarkerHeaders(markerHeaders);
|
|
79
|
+
|
|
80
|
+
function matchedMarkerHeader(headers) {
|
|
81
|
+
for (const name of markers) {
|
|
82
|
+
if (headerPresent(headers, name)) return name;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchedForwardingHeader(headers) {
|
|
88
|
+
for (const name of FORWARDING_HEADERS) {
|
|
89
|
+
if (headerPresent(headers, name)) return name;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Why a request is not truly local: `{ reason: 'peer' | 'marker' |
|
|
96
|
+
* 'forwarded', header }`, or null when it is.
|
|
97
|
+
*/
|
|
98
|
+
function explain(req) {
|
|
99
|
+
if (!isLoopbackAddress(peerAddressOf(req))) return { reason: 'peer', header: null };
|
|
100
|
+
const marker = matchedMarkerHeader(req?.headers);
|
|
101
|
+
if (marker) return { reason: 'marker', header: marker };
|
|
102
|
+
const forwarded = matchedForwardingHeader(req?.headers);
|
|
103
|
+
if (forwarded) return { reason: 'forwarded', header: forwarded };
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isTrulyLocal(req) {
|
|
108
|
+
return explain(req) === null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
markerHeaders: markers,
|
|
113
|
+
matchedMarkerHeader,
|
|
114
|
+
explain,
|
|
115
|
+
isTrulyLocal,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function trustsPeer(req) {
|
|
120
|
+
const trust = typeof req?.app?.get === 'function' ? req.app.get('trust proxy fn') : null;
|
|
121
|
+
return typeof trust === 'function' && trust(peerAddressOf(req), 0) === true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function firstListEntry(value) {
|
|
125
|
+
return String(Array.isArray(value) ? value[0] : (value || '')).split(',')[0].trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The host (with port) the client addressed: X-Forwarded-Host when the
|
|
130
|
+
* immediate peer is a trusted proxy (Express `trust proxy`), else Host. A
|
|
131
|
+
* direct client cannot forge it. Express's own `req.hostname` drops the port,
|
|
132
|
+
* which a LAN address needs.
|
|
133
|
+
*/
|
|
134
|
+
export function requestHostOf(req) {
|
|
135
|
+
if (trustsPeer(req)) {
|
|
136
|
+
const forwardedHost = firstListEntry(req?.headers?.['x-forwarded-host']);
|
|
137
|
+
if (forwardedHost) return forwardedHost;
|
|
138
|
+
}
|
|
139
|
+
return firstListEntry(req?.headers?.host);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** An Express-style view of a raw request (a socket.io handshake), so `ip`, `protocol` and the trust checks follow `trust proxy`. */
|
|
143
|
+
export function expressRequestView(app, rawReq) {
|
|
144
|
+
const view = Object.create(app.request);
|
|
145
|
+
view.headers = rawReq?.headers || {};
|
|
146
|
+
view.socket = rawReq?.socket || rawReq?.connection || null;
|
|
147
|
+
view.connection = view.socket;
|
|
148
|
+
view.method = rawReq?.method;
|
|
149
|
+
view.url = rawReq?.url;
|
|
150
|
+
return view;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizedHost(protocol, host) {
|
|
154
|
+
try {
|
|
155
|
+
return new URL(`${protocol}//${host}`).host.toLowerCase();
|
|
156
|
+
} catch {
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Same-origin check for a browser handshake. No Origin header (a non-browser
|
|
163
|
+
* client) passes; otherwise the Origin's host must be the host the request
|
|
164
|
+
* was addressed to (see requestHostOf), or be listed in `allowedOrigins`.
|
|
165
|
+
*/
|
|
166
|
+
export function isSameOriginRequest(req, { allowedOrigins = [] } = {}) {
|
|
167
|
+
const origin = firstListEntry(req?.headers?.origin);
|
|
168
|
+
if (!origin) return true;
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = new URL(origin);
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (parsed.origin === 'null') return false;
|
|
176
|
+
const allowed = (Array.isArray(allowedOrigins) ? allowedOrigins : headerNamesFrom(allowedOrigins))
|
|
177
|
+
.map((entry) => String(entry || '').trim().replace(/\/+$/, '').toLowerCase())
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
if (allowed.includes(parsed.origin.toLowerCase())) return true;
|
|
180
|
+
const host = requestHostOf(req);
|
|
181
|
+
if (!host) return false;
|
|
182
|
+
return normalizedHost(parsed.protocol, host) === parsed.host.toLowerCase();
|
|
183
|
+
}
|