@240xu/dsh-websearch 2.8.3 → 2.8.4
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/lib/backends/anthropic-like.js +2 -1
- package/lib/backends/brave.js +2 -1
- package/lib/backends/ddg.js +2 -1
- package/lib/backends/mojeek.js +2 -1
- package/lib/backends/openai.js +2 -1
- package/lib/backends/searxng.js +2 -1
- package/lib/backends/serper.js +2 -1
- package/lib/backends/tavily.js +2 -1
- package/lib/client.js +139 -20
- package/lib/health.js +7 -0
- package/lib/history.js +7 -5
- package/lib/index.js +25 -4
- package/lib/settings-api.js +277 -0
- package/lib/trust.js +179 -0
- package/lib/util/egress-fetch.js +338 -0
- package/lib/util/mcp-client.js +5 -4
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
19
19
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
20
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
20
21
|
|
|
21
22
|
/** Attribution header; bumped with the package version. */
|
|
22
23
|
const UA = "dsh-websearch/2.3";
|
|
@@ -111,7 +112,7 @@ export function createAnthropicLikeBackend(spec) {
|
|
|
111
112
|
|
|
112
113
|
let response;
|
|
113
114
|
try {
|
|
114
|
-
response = await
|
|
115
|
+
response = await egressFetch(endpoint, {
|
|
115
116
|
method: "POST",
|
|
116
117
|
redirect: "error",
|
|
117
118
|
headers: {
|
package/lib/backends/brave.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applyBraveFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
|
13
14
|
|
|
@@ -46,7 +47,7 @@ export const braveBackend = {
|
|
|
46
47
|
|
|
47
48
|
let response;
|
|
48
49
|
try {
|
|
49
|
-
response = await
|
|
50
|
+
response = await egressFetch(url, {
|
|
50
51
|
method: "GET",
|
|
51
52
|
headers: {
|
|
52
53
|
"X-Subscription-Token": apiKey,
|
package/lib/backends/ddg.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { searchAborted, isAbortError, throwIfSearchAborted } from "../util/abort.js";
|
|
9
9
|
import { applyDdgFilters, normalizeFilters } from "../util/filters.js";
|
|
10
10
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
export const DDG_ENDPOINT = "https://html.duckduckgo.com/html/";
|
|
13
14
|
|
|
@@ -34,7 +35,7 @@ export const ddgBackend = {
|
|
|
34
35
|
|
|
35
36
|
let text;
|
|
36
37
|
try {
|
|
37
|
-
const res = await
|
|
38
|
+
const res = await egressFetch(DDG_ENDPOINT, {
|
|
38
39
|
method: "POST",
|
|
39
40
|
redirect: "error",
|
|
40
41
|
headers: {
|
package/lib/backends/mojeek.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
10
11
|
|
|
11
12
|
const MOJEEK_ENDPOINT = "https://api.mojeek.com/v1/search";
|
|
12
13
|
|
|
@@ -52,7 +53,7 @@ export const mojeekBackend = {
|
|
|
52
53
|
|
|
53
54
|
let response;
|
|
54
55
|
try {
|
|
55
|
-
response = await
|
|
56
|
+
response = await egressFetch(url, {
|
|
56
57
|
method: "GET",
|
|
57
58
|
headers,
|
|
58
59
|
redirect: "error",
|
package/lib/backends/openai.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
14
14
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
15
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
15
16
|
|
|
16
17
|
const UA = "dsh-websearch/2.3";
|
|
17
18
|
|
|
@@ -67,7 +68,7 @@ export const openaiBackend = {
|
|
|
67
68
|
|
|
68
69
|
let response;
|
|
69
70
|
try {
|
|
70
|
-
response = await
|
|
71
|
+
response = await egressFetch(endpoint, {
|
|
71
72
|
method: "POST",
|
|
72
73
|
redirect: "error",
|
|
73
74
|
headers: {
|
package/lib/backends/searxng.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
10
10
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
11
11
|
import { applySearxngFilters, normalizeFilters, safeSearchParams } from "../util/filters.js";
|
|
12
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
12
13
|
|
|
13
14
|
const DEFAULT_SEARXNG_URL = "https://searx.be";
|
|
14
15
|
|
|
@@ -61,7 +62,7 @@ export const searxngBackend = {
|
|
|
61
62
|
|
|
62
63
|
let response;
|
|
63
64
|
try {
|
|
64
|
-
response = await
|
|
65
|
+
response = await egressFetch(url, {
|
|
65
66
|
method: "GET",
|
|
66
67
|
headers,
|
|
67
68
|
redirect: "error",
|
package/lib/backends/serper.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applySerperFilters, normalizeFilters } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const SERPER_ENDPOINT = "https://google.serper.dev/search";
|
|
13
14
|
|
|
@@ -46,7 +47,7 @@ export const serperBackend = {
|
|
|
46
47
|
|
|
47
48
|
let response;
|
|
48
49
|
try {
|
|
49
|
-
response = await
|
|
50
|
+
response = await egressFetch(baseURL, {
|
|
50
51
|
method: "POST",
|
|
51
52
|
headers: {
|
|
52
53
|
"X-API-KEY": apiKey,
|
package/lib/backends/tavily.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
9
|
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
10
|
import { applyTavilyFilters, normalizeFilters } from "../util/filters.js";
|
|
11
|
+
import { egressFetch } from "../util/egress-fetch.js";
|
|
11
12
|
|
|
12
13
|
const TAVILY_ENDPOINT = "https://api.tavily.com/search";
|
|
13
14
|
|
|
@@ -55,7 +56,7 @@ export const tavilyBackend = {
|
|
|
55
56
|
|
|
56
57
|
let response;
|
|
57
58
|
try {
|
|
58
|
-
response = await
|
|
59
|
+
response = await egressFetch(baseURL, {
|
|
59
60
|
method: "POST",
|
|
60
61
|
headers: {
|
|
61
62
|
"Authorization": `Bearer ${apiKey}`,
|
package/lib/client.js
CHANGED
|
@@ -171,6 +171,78 @@ window.__ModuleLoader__.load({
|
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Settings bridge for 0.2.x builds where the legacy `settingsScope`
|
|
176
|
+
* client service is gone: the same snapshot/subscribe/set/unset surface
|
|
177
|
+
* the card controller expects, backed by this plugin's fenced settings
|
|
178
|
+
* route. Writes land in the volatile profile entry the host provider
|
|
179
|
+
* resolves from on the next call -- no second settings store.
|
|
180
|
+
*/
|
|
181
|
+
class SettingsRoutesBridge {
|
|
182
|
+
constructor() {
|
|
183
|
+
this.store = createStore({
|
|
184
|
+
status: "loading",
|
|
185
|
+
writable: false,
|
|
186
|
+
value: undefined,
|
|
187
|
+
base: undefined,
|
|
188
|
+
user: undefined,
|
|
189
|
+
revision: undefined,
|
|
190
|
+
mode: "routes",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getSnapshot() { return this.store.getSnapshot(); }
|
|
195
|
+
subscribe(listener) { return this.store.subscribe(listener); }
|
|
196
|
+
|
|
197
|
+
accept(payload) {
|
|
198
|
+
if (payload === null || typeof payload !== "object" || payload.ok !== true || payload.writable === false) {
|
|
199
|
+
// Keep the card rendered (status ready) but read-only, so the section
|
|
200
|
+
// explains itself instead of showing an endless loading ellipsis.
|
|
201
|
+
this.store.set({ status: "ready", writable: false, value: {}, base: undefined, user: undefined, revision: undefined, mode: "routes" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
this.store.set({
|
|
205
|
+
status: "ready",
|
|
206
|
+
writable: true,
|
|
207
|
+
value: payload.value,
|
|
208
|
+
base: payload.base,
|
|
209
|
+
user: payload.user,
|
|
210
|
+
revision: payload.revision,
|
|
211
|
+
mode: "routes",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async load() {
|
|
216
|
+
try {
|
|
217
|
+
const response = await fetch("/api/unified-search/settings", { credentials: "same-origin", headers: { accept: "application/json" } });
|
|
218
|
+
const payload = await response.json().catch(() => undefined);
|
|
219
|
+
this.accept(response.status === 404 ? undefined : payload);
|
|
220
|
+
} catch (_noRoutes) {
|
|
221
|
+
this.accept(undefined);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async write(ops) {
|
|
226
|
+
const expectedRevision = this.store.getSnapshot().revision;
|
|
227
|
+
const response = await fetch("/api/unified-search/settings", {
|
|
228
|
+
method: "POST",
|
|
229
|
+
credentials: "same-origin",
|
|
230
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
231
|
+
// eslint-disable-next-line object-shorthand
|
|
232
|
+
body: JSON.stringify(expectedRevision === undefined ? { ops } : { ops, expectedRevision }),
|
|
233
|
+
});
|
|
234
|
+
const payload = await response.json().catch(() => undefined);
|
|
235
|
+
if (response.status === 409 || (payload !== undefined && payload.code === "SETTINGS_CONFLICT")) throw new Error("SETTINGS_CONFLICT");
|
|
236
|
+
if (!response.ok || payload === undefined || payload.ok !== true) {
|
|
237
|
+
throw new Error(payload !== undefined && payload.error !== undefined ? String(payload.error) : "settings request failed (" + response.status + ")");
|
|
238
|
+
}
|
|
239
|
+
this.accept(payload);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
set(field, value) { return this.write([{ op: "set", path: [field], value }]); }
|
|
243
|
+
unset(field) { return this.write([{ op: "unset", path: [field] }]); }
|
|
244
|
+
}
|
|
245
|
+
|
|
174
246
|
/**
|
|
175
247
|
* Card state machine: reads the bound settings scope snapshot, stages edits,
|
|
176
248
|
* writes section fields through scope.set/unset and secrets via credentials.
|
|
@@ -247,14 +319,37 @@ window.__ModuleLoader__.load({
|
|
|
247
319
|
|
|
248
320
|
async readCredential(ref) {
|
|
249
321
|
try {
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
322
|
+
let view;
|
|
323
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.describe === "function") {
|
|
324
|
+
// 0.2.x remote face: Record<ref, {configured, writable, source}> behind {ok, value}.
|
|
325
|
+
const res = await this.remote.credentials.describe([ref]);
|
|
326
|
+
const record = res !== null && typeof res === "object" && res.value !== undefined ? res.value : res;
|
|
327
|
+
const byRef = record !== null && typeof record === "object" && record.credentials !== undefined ? record.credentials : record;
|
|
328
|
+
view = byRef !== null && typeof byRef === "object" ? byRef[ref] : undefined;
|
|
329
|
+
}
|
|
330
|
+
if (view === undefined && this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined) {
|
|
331
|
+
// Older line: the connection controller's credentials API.
|
|
332
|
+
const res = await this.connection.api.credentials.describe({ refs: [ref] });
|
|
333
|
+
if (res?.result?.ok) view = res.result.value.credentials[ref];
|
|
334
|
+
}
|
|
335
|
+
if (view === undefined) return;
|
|
336
|
+
this.keyState.set(ref, { configured: view.configured === true, writable: view.writable !== false });
|
|
254
337
|
} catch { /* ignore */ }
|
|
255
338
|
this.republish();
|
|
256
339
|
}
|
|
257
340
|
|
|
341
|
+
async writeCredential(ref, value) {
|
|
342
|
+
if (this.remote !== undefined && this.remote.credentials !== undefined && typeof this.remote.credentials.set === "function") {
|
|
343
|
+
await this.remote.credentials.set(ref, value);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (this.connection !== undefined && this.connection.api !== undefined && this.connection.api.credentials !== undefined && typeof this.connection.api.credentials.set === "function") {
|
|
347
|
+
await this.connection.api.credentials.set({ ref, value });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
throw new Error("credentials service unavailable");
|
|
351
|
+
}
|
|
352
|
+
|
|
258
353
|
refreshAllCredentials() {
|
|
259
354
|
for (const b of BACKENDS) if (b.keyEnv) this.readCredential(b.keyEnv);
|
|
260
355
|
}
|
|
@@ -291,7 +386,7 @@ window.__ModuleLoader__.load({
|
|
|
291
386
|
backendTimeoutMs: [1000, 120000],
|
|
292
387
|
};
|
|
293
388
|
const [lo, hi] = BOUNDS[field] ?? [-Infinity, Infinity];
|
|
294
|
-
{ this.failed = true; this.failedFields.add(field); continue; }
|
|
389
|
+
if (!Number.isFinite(num) || num < lo || num > hi) { this.failed = true; this.failedFields.add(field); continue; }
|
|
295
390
|
await this.scope.set(field, num);
|
|
296
391
|
doneFields.push(field);
|
|
297
392
|
} else {
|
|
@@ -303,7 +398,7 @@ window.__ModuleLoader__.load({
|
|
|
303
398
|
const value = staged.text.trim();
|
|
304
399
|
if (value !== "") {
|
|
305
400
|
try {
|
|
306
|
-
await this.
|
|
401
|
+
await this.writeCredential(ref, value);
|
|
307
402
|
this.keyState.set(ref, { configured: true, writable: true });
|
|
308
403
|
doneKeys.push(ref);
|
|
309
404
|
this.failedKeys.delete(ref);
|
|
@@ -424,6 +519,7 @@ window.__ModuleLoader__.load({
|
|
|
424
519
|
backend: b,
|
|
425
520
|
value: state.value[toggleField(b.id)],
|
|
426
521
|
keyConfigured: true,
|
|
522
|
+
t: props.t,
|
|
427
523
|
onChange: (checked) => card.setToggle(b.id, checked),
|
|
428
524
|
}, b.id)
|
|
429
525
|
) }),
|
|
@@ -433,6 +529,7 @@ window.__ModuleLoader__.load({
|
|
|
433
529
|
backend: b,
|
|
434
530
|
value: state.value[toggleField(b.id)],
|
|
435
531
|
keyConfigured: !!state.keyState[b.keyEnv]?.configured,
|
|
532
|
+
t: props.t,
|
|
436
533
|
onChange: (checked) => card.setToggle(b.id, checked),
|
|
437
534
|
}, b.id)
|
|
438
535
|
) }),
|
|
@@ -545,27 +642,49 @@ window.__ModuleLoader__.load({
|
|
|
545
642
|
|
|
546
643
|
/**
|
|
547
644
|
* Plugin entry: register the card into the settings plugins page.
|
|
548
|
-
* Compatibility
|
|
549
|
-
*
|
|
550
|
-
*
|
|
645
|
+
* Compatibility: the legacy `settingsScope` client service was removed on
|
|
646
|
+
* the 0.2.x line; the card binds whichever data source the build offers
|
|
647
|
+
* (see selectBridge) and registers the section unconditionally.
|
|
551
648
|
*/
|
|
552
|
-
const inject = ["slots", "locale", "connection", "remote"
|
|
649
|
+
const inject = ["slots", "locale", "connection", "remote"];
|
|
650
|
+
/**
|
|
651
|
+
* Resolve the settings data source for this build, in order:
|
|
652
|
+
* 1. the legacy `settingsScope` service (DSH <= 0.1.6 line, or a shim);
|
|
653
|
+
* 2. this plugin's own fenced settings routes (0.2.x line, where the
|
|
654
|
+
* client service was removed and the settings shell renders client
|
|
655
|
+
* slot sections only);
|
|
656
|
+
* 3. an unavailable bridge -- the section still registers and the card
|
|
657
|
+
* explains itself through its read-only banner.
|
|
658
|
+
*/
|
|
659
|
+
function selectBridge(ctx) {
|
|
660
|
+
try {
|
|
661
|
+
const scope = ctx.get("settingsScope");
|
|
662
|
+
if (scope !== undefined && typeof scope.bind === "function") return scope.bind({ namespace: PLUGIN_NS });
|
|
663
|
+
} catch (_noSettingsScope) { /* fall through to the routes bridge */ }
|
|
664
|
+
return new SettingsRoutesBridge();
|
|
665
|
+
}
|
|
553
666
|
function apply(ctx) {
|
|
554
667
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "websearch: copy dictionaries");
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
// DSH >= 0.1.7 without dsh-settings-scope-shim: the settings card
|
|
558
|
-
// cannot bind its scope. Stop wiring the card instead of hard-
|
|
559
|
-
// crashing the client fiber; the breadcrumb aids diagnosis.
|
|
560
|
-
console.warn("[dsh-websearch] settingsScope service unavailable (DSH >= 0.1.7); unified-search settings card disabled. Configure via server settings/cordis config or install dsh-settings-scope-shim.");
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
668
|
+
const bridge = selectBridge(ctx);
|
|
669
|
+
if (typeof bridge.load === "function") void bridge.load();
|
|
563
670
|
const controller = new UnifiedSearchCardController(
|
|
564
|
-
|
|
671
|
+
bridge,
|
|
565
672
|
ctx.get("connection"),
|
|
566
673
|
ctx.get("remote"),
|
|
567
674
|
);
|
|
568
|
-
|
|
675
|
+
// Credential invalidations: 0.2.x forwards `credentials/reference-updated`;
|
|
676
|
+
// older lines forwarded `credentials/updated`. Subscribe to whichever
|
|
677
|
+
// event face this build exposes; a missing face must not abort apply().
|
|
678
|
+
for (const event of ["credentials/reference-updated", "credentials/updated"]) {
|
|
679
|
+
try {
|
|
680
|
+
ctx.effect(() => ctx.remote.$on(event, () => controller.refreshAllCredentials()), "websearch: credential invalidations");
|
|
681
|
+
} catch (_noEventFace) { /* this build does not forward that event */ }
|
|
682
|
+
}
|
|
683
|
+
// Re-read when another client saves this namespace (settings document
|
|
684
|
+
// revisions travel through the remote event face on 0.2.x).
|
|
685
|
+
try {
|
|
686
|
+
ctx.effect(() => ctx.remote.$on("settings/document-updated", () => { if (typeof bridge.load === "function") void bridge.load(); }), "websearch: settings document invalidations");
|
|
687
|
+
} catch (_noSettingsEvent) { /* settings events unavailable */ }
|
|
569
688
|
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
570
689
|
name: "settings.section",
|
|
571
690
|
id: "websearch",
|
package/lib/health.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// provider's own telemetry truncation) and run through a defensive redaction
|
|
8
8
|
// pass for token-like fragments.
|
|
9
9
|
|
|
10
|
+
import { rejectionFor } from "./trust.js";
|
|
11
|
+
|
|
10
12
|
const MAX_ERROR_CHARS = 120;
|
|
11
13
|
|
|
12
14
|
/** Token-like fragments that must never appear in a health payload. */
|
|
@@ -89,6 +91,11 @@ export function registerHealthRoute(ws, deps) {
|
|
|
89
91
|
res.writeHead(code, { "content-type": "application/json" });
|
|
90
92
|
res.end(JSON.stringify(body));
|
|
91
93
|
};
|
|
94
|
+
const rejection = rejectionFor(req, typeof deps.getConnection === "function" ? deps.getConnection() : undefined);
|
|
95
|
+
if (rejection !== undefined) {
|
|
96
|
+
send(rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
92
99
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
93
100
|
send(405, { ok: false, error: "method not allowed (GET only)" });
|
|
94
101
|
return;
|
package/lib/history.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { rejectionFor } from "./trust.js";
|
|
11
12
|
|
|
12
13
|
/** Ring-buffer cap (spec: 50). */
|
|
13
14
|
export const HISTORY_MAX_ENTRIES = 50;
|
|
@@ -143,18 +144,19 @@ export function toBackendObservability(healthMap, nowMs = Date.now()) {
|
|
|
143
144
|
* backends: per-backend last real outcome/latency/breaker state (v2.8.0),
|
|
144
145
|
* so fallback redundancy is observable without a separate endpoint.
|
|
145
146
|
* POST /api/websearch/history/clear -> { ok, cleared: true }
|
|
146
|
-
* Both routes sit behind
|
|
147
|
+
* Both routes sit behind the host connection admission fence (the same
|
|
148
|
+
* decision the kernel applies to its own /api routes).
|
|
147
149
|
*/
|
|
148
|
-
export function registerHistoryRoutes(ws, { getStore, getBackendHealth }) {
|
|
150
|
+
export function registerHistoryRoutes(ws, { getStore, getBackendHealth, getConnection }) {
|
|
149
151
|
if (!ws || typeof ws.register !== "function") return;
|
|
150
152
|
const send = (res, code, body) => {
|
|
151
153
|
res.writeHead(code, { "content-type": "application/json" });
|
|
152
154
|
res.end(JSON.stringify(body));
|
|
153
155
|
};
|
|
154
156
|
const guarded = (handler) => (req, res) => {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
157
|
-
send(res,
|
|
157
|
+
const rejection = rejectionFor(req, typeof getConnection === "function" ? getConnection() : undefined);
|
|
158
|
+
if (rejection !== undefined) {
|
|
159
|
+
send(res, rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
158
160
|
return;
|
|
159
161
|
}
|
|
160
162
|
handler(req, res);
|
package/lib/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import { createUnifiedSearchProvider, getLatestBackendHealth } from "./provider.
|
|
|
37
37
|
import { registerHealthRoute } from "./health.js";
|
|
38
38
|
import { createSearchCache, CACHE_DEFAULTS } from "./cache.js";
|
|
39
39
|
import { createHistoryStore, registerHistoryRoutes, HISTORY_MAX_ENTRIES } from "./history.js";
|
|
40
|
+
import { registerSettingsRoutes } from "./settings-api.js";
|
|
40
41
|
import { BREAKER_DEFAULTS } from "./breaker.js";
|
|
41
42
|
import os from "node:os";
|
|
42
43
|
import path from "node:path";
|
|
@@ -391,6 +392,8 @@ export function resolveStoreDir(env = process.env, fallbackHome = os.homedir())
|
|
|
391
392
|
*/
|
|
392
393
|
export function apply(ctx, config) {
|
|
393
394
|
let current = () => config;
|
|
395
|
+
/** 0.2.x SettingsForms instance, captured for the browser settings bridge. */
|
|
396
|
+
let settingsForms;
|
|
394
397
|
|
|
395
398
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
396
399
|
const settings = settingsCtx.settings;
|
|
@@ -404,10 +407,11 @@ export function apply(ctx, config) {
|
|
|
404
407
|
});
|
|
405
408
|
return;
|
|
406
409
|
}
|
|
407
|
-
// 0.2.
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
410
|
+
// 0.2.x SettingsForms line: no installSection. Keep the service so the
|
|
411
|
+
// browser settings card can read/write this profile entry through the
|
|
412
|
+
// fenced bridge in ./settings-api.js; values keep resolving via cordis
|
|
413
|
+
// config (the same volatile entry), so there is no second settings store.
|
|
414
|
+
if (typeof settings?.describe === "function" && typeof settings?.mutate === "function") settingsForms = settings;
|
|
411
415
|
});
|
|
412
416
|
|
|
413
417
|
// v2.7.0 stores: disk cache + append-only search history, both under
|
|
@@ -449,16 +453,33 @@ export function apply(ctx, config) {
|
|
|
449
453
|
// only reads the settings snapshot + last per-search telemetry — it never
|
|
450
454
|
// probes backends and never touches credential values.
|
|
451
455
|
ctx.inject(["webServer"], (webCtx) => {
|
|
456
|
+
const getConnection = () => {
|
|
457
|
+
try {
|
|
458
|
+
return ctx.get("connection");
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
452
463
|
registerHealthRoute(webCtx.webServer, {
|
|
453
464
|
resolveOptions: () => resolveOptions(ctx, current()),
|
|
454
465
|
backends: BACKENDS,
|
|
455
466
|
getBackendHealth: getLatestBackendHealth,
|
|
467
|
+
getConnection,
|
|
456
468
|
});
|
|
457
469
|
// v2.7.0 search history: read-only GET + explicit clear POST.
|
|
458
470
|
// v2.8.0: GET also carries per-backend last-outcome observability.
|
|
459
471
|
registerHistoryRoutes(webCtx.webServer, {
|
|
460
472
|
getStore: () => historyStore,
|
|
461
473
|
getBackendHealth: getLatestBackendHealth,
|
|
474
|
+
getConnection,
|
|
475
|
+
});
|
|
476
|
+
// Settings bridge for the browser card on 0.2.x builds (no legacy
|
|
477
|
+
// settingsScope service). Fenced by the host connection admission; writes
|
|
478
|
+
// land in the volatile config entry above.
|
|
479
|
+
registerSettingsRoutes(webCtx.webServer, {
|
|
480
|
+
getSettings: () => settingsForms,
|
|
481
|
+
getConfig: () => Config,
|
|
482
|
+
getConnection,
|
|
462
483
|
});
|
|
463
484
|
});
|
|
464
485
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenced settings bridge for the browser settings card.
|
|
3
|
+
*
|
|
4
|
+
* The card needs three things from the host: the resolved value of every
|
|
5
|
+
* field, the revision that guards concurrent saves, and a way to write path
|
|
6
|
+
* ops back into the same profile entry the provider reads. On the 0.2.x
|
|
7
|
+
* settings line there is no `settingsScope` client service and the settings
|
|
8
|
+
* shell renders client slot sections only — so this module servers that data
|
|
9
|
+
* over the plugin's own HTTP surface instead.
|
|
10
|
+
*
|
|
11
|
+
* Data flow: the host keeps plugin config in its `settings` service
|
|
12
|
+
* (`SettingsForms.describe` / `mutate`, keyed by the profile entry id). This
|
|
13
|
+
* route is a thin bridge to it: writes land in the volatile config entry the
|
|
14
|
+
* provider resolves from on the next call, so there is no second store and no
|
|
15
|
+
* out-of-band state. Mutations are validated against the plugin's own schema
|
|
16
|
+
* here because the host only checks the volatile path, not the value shape.
|
|
17
|
+
*
|
|
18
|
+
* Both routes sit behind the host connection admission fence (the same
|
|
19
|
+
* decision the kernel applies to its own `/api` routes, including the browser
|
|
20
|
+
* auth cookie), so a LAN or tunnel page holding a valid session can save
|
|
21
|
+
* settings, and nothing weaker than the app can.
|
|
22
|
+
*
|
|
23
|
+
* GET /api/unified-search/settings -> { ok, namespace, writable, revision,
|
|
24
|
+
* value, base, user, reason? }
|
|
25
|
+
* POST /api/unified-search/settings -> { ok } + fresh payload on success,
|
|
26
|
+
* 409 { code: 'SETTINGS_CONFLICT' },
|
|
27
|
+
* 400 invalid op value/field,
|
|
28
|
+
* 503 settings service unavailable.
|
|
29
|
+
*
|
|
30
|
+
* Headless / CI profiles never mount `webServer`, so registration is skipped
|
|
31
|
+
* and nothing here runs.
|
|
32
|
+
*
|
|
33
|
+
* @module lib/settings-api.js
|
|
34
|
+
*/
|
|
35
|
+
import { rejectionFor } from "./trust.js";
|
|
36
|
+
|
|
37
|
+
const SETTINGS_PATH = "/api/unified-search/settings";
|
|
38
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
39
|
+
const MAX_OPS = 64;
|
|
40
|
+
/**
|
|
41
|
+
* Field names the card owns. Used only to find the profile entry in
|
|
42
|
+
* `describe()` output — never as a whitelist; the whitelist is the live
|
|
43
|
+
* `Config.dict`, so schema growth is picked up without touching this file.
|
|
44
|
+
*/
|
|
45
|
+
const DISCOVERY_FIELDS = ["numResults", "backendTimeoutMs", "enabledBackends", "enableExa"];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Register the (exact) settings route on the late-mounted webServer.
|
|
49
|
+
* @param {{ register?: (route: { kind: string, path: string, handler: Function }) => void }} ws
|
|
50
|
+
* @param {{ getSettings: () => unknown, getConfig: () => unknown, getConnection?: () => unknown }} deps
|
|
51
|
+
*/
|
|
52
|
+
export function registerSettingsRoutes(ws, deps) {
|
|
53
|
+
if (!ws || typeof ws.register !== "function") return;
|
|
54
|
+
ws.register({
|
|
55
|
+
kind: "exact",
|
|
56
|
+
path: SETTINGS_PATH,
|
|
57
|
+
handler: guarded(deps, (req, res) => {
|
|
58
|
+
const url = new URL(String(req.url ?? "/"), "http://localhost");
|
|
59
|
+
if (url.pathname !== SETTINGS_PATH) {
|
|
60
|
+
sendJson(res, 404, { ok: false, error: "not found" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
64
|
+
const payload = describe(deps);
|
|
65
|
+
sendJson(res, payload.ok ? 200 : 503, payload);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (req.method === "POST") {
|
|
69
|
+
void write(req, res, deps);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
res.setHeader("allow", "GET, POST");
|
|
73
|
+
sendJson(res, 405, { ok: false, error: "method not allowed (GET/POST)" });
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
/* handlers */
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
|
|
82
|
+
function guarded(deps, handler) {
|
|
83
|
+
return (req, res) => {
|
|
84
|
+
const rejection = rejectionFor(req, typeof deps.getConnection === "function" ? deps.getConnection() : undefined);
|
|
85
|
+
if (rejection !== undefined) {
|
|
86
|
+
sendJson(res, rejection, { ok: false, error: rejection === 503 ? "admission unavailable" : "untrusted request" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
Promise.resolve()
|
|
90
|
+
.then(() => handler(req, res))
|
|
91
|
+
.catch((err) => {
|
|
92
|
+
const status = err && Number.isInteger(err.status) ? err.status : 500;
|
|
93
|
+
sendJson(res, status, { ok: false, error: sanitize(err) });
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Read the plugin's volatile form snapshot from the host settings service. */
|
|
99
|
+
function describe(deps) {
|
|
100
|
+
const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
|
|
101
|
+
if (settings === undefined || settings === null || typeof settings.describe !== "function") {
|
|
102
|
+
return { ok: false, writable: false, reason: "settings-unavailable" };
|
|
103
|
+
}
|
|
104
|
+
let descriptors;
|
|
105
|
+
try {
|
|
106
|
+
descriptors = settings.describe();
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, writable: false, reason: "settings-unavailable" };
|
|
109
|
+
}
|
|
110
|
+
const view = findNamespace(descriptors);
|
|
111
|
+
if (view === undefined) return { ok: false, writable: false, reason: "namespace-not-found" };
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
namespace: view.ns,
|
|
115
|
+
writable: true,
|
|
116
|
+
applies: view.applies,
|
|
117
|
+
revision: view.revision,
|
|
118
|
+
value: view.value,
|
|
119
|
+
base: view.base,
|
|
120
|
+
user: view.user,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Locate this plugin's descriptor: first by shape (the card's own fields
|
|
126
|
+
* present in the resolved value), then by the profile entry id this package's
|
|
127
|
+
* bundle patch inserts. Shape-first survives a renamed insert id; the literal
|
|
128
|
+
* survives a host that reports an empty value.
|
|
129
|
+
*/
|
|
130
|
+
function findNamespace(descriptors) {
|
|
131
|
+
if (!Array.isArray(descriptors)) return undefined;
|
|
132
|
+
const rows = descriptors.filter((row) => row !== null && typeof row === "object" && typeof row.ns === "string");
|
|
133
|
+
return rows.find((row) => row.value !== undefined && row.value !== null
|
|
134
|
+
&& typeof row.value === "object" && DISCOVERY_FIELDS.some((key) => Object.hasOwn(row.value, key)))
|
|
135
|
+
?? rows.find((row) => row.ns === "dsh-websearch");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function write(req, res, deps) {
|
|
139
|
+
const type = String(req.headers["content-type"] ?? "").toLowerCase();
|
|
140
|
+
if (!type.includes("application/json")) {
|
|
141
|
+
sendJson(res, 400, { ok: false, error: "content-type must be application/json" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let body;
|
|
145
|
+
try {
|
|
146
|
+
body = JSON.parse(await readBody(req));
|
|
147
|
+
} catch (err) {
|
|
148
|
+
sendJson(res, err && Number.isInteger(err.status) ? err.status : 400, { ok: false, error: sanitize(err) || "request body is not valid JSON" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
152
|
+
sendJson(res, 400, { ok: false, error: "request body must be a JSON object" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const wanted = Array.isArray(body.ops) ? body.ops : undefined;
|
|
156
|
+
if (wanted === undefined || wanted.length === 0 || wanted.length > MAX_OPS) {
|
|
157
|
+
sendJson(res, 400, { ok: false, error: `ops must be a non-empty array of at most ${MAX_OPS} entries` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const settings = typeof deps.getSettings === "function" ? deps.getSettings() : undefined;
|
|
161
|
+
if (settings === undefined || settings === null || typeof settings.mutate !== "function") {
|
|
162
|
+
sendJson(res, 503, { ok: false, error: "settings service unavailable" });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const config = typeof deps.getConfig === "function" ? deps.getConfig() : undefined;
|
|
166
|
+
const fields = config !== undefined && config !== null && config.dict !== undefined && config.dict !== null
|
|
167
|
+
? config.dict
|
|
168
|
+
: {};
|
|
169
|
+
const ops = [];
|
|
170
|
+
for (const raw of wanted) {
|
|
171
|
+
const parsed = validateOp(raw, fields);
|
|
172
|
+
if (typeof parsed === "string") {
|
|
173
|
+
sendJson(res, 400, { ok: false, error: parsed });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
ops.push(parsed);
|
|
177
|
+
}
|
|
178
|
+
const view = findNamespace(safeDescribe(settings));
|
|
179
|
+
if (view === undefined) {
|
|
180
|
+
sendJson(res, 503, { ok: false, error: "settings namespace unavailable" });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const expected = Number.isInteger(body.expectedRevision) ? body.expectedRevision : undefined;
|
|
184
|
+
try {
|
|
185
|
+
await settings.mutate(view.ns, ops, expected);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (isConflict(err)) {
|
|
188
|
+
sendJson(res, 409, { ok: false, code: "SETTINGS_CONFLICT", error: "settings changed elsewhere; reload and retry" });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
sendJson(res, 400, { ok: false, error: sanitize(err) || "settings write failed" });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const payload = describe(deps);
|
|
195
|
+
sendJson(res, payload.ok ? 200 : 503, payload);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Shape-check one path op against the live schema. The host validates only
|
|
200
|
+
* that the path is inside the volatile subtree; a wrong-typed value would be
|
|
201
|
+
* written into the profile patch and break the entry's next reload, so the
|
|
202
|
+
* value must be validated here.
|
|
203
|
+
* @returns {object|string} the op to forward, or a fixed reason string
|
|
204
|
+
*/
|
|
205
|
+
function validateOp(raw, fields) {
|
|
206
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return "ops entries must be objects";
|
|
207
|
+
const op = raw.op === "set" || raw.op === "unset" ? raw.op : undefined;
|
|
208
|
+
if (op === undefined) return "unsupported op (expected set|unset)";
|
|
209
|
+
const path = Array.isArray(raw.path) ? raw.path : undefined;
|
|
210
|
+
if (path === undefined || path.length !== 1 || typeof path[0] !== "string" || path[0] === "") {
|
|
211
|
+
return "path must be a one-element array holding a field name";
|
|
212
|
+
}
|
|
213
|
+
const field = path[0];
|
|
214
|
+
if (!Object.hasOwn(fields, field)) return `unknown settings field "${field}"`;
|
|
215
|
+
if (op === "unset") return { op, path: [field] };
|
|
216
|
+
if (!Object.hasOwn(raw, "value")) return `op "set" on "${field}" requires a value`;
|
|
217
|
+
const schema = fields[field];
|
|
218
|
+
if (typeof schema !== "function") return `field "${field}" is not writable`;
|
|
219
|
+
let checked;
|
|
220
|
+
try {
|
|
221
|
+
checked = schema(raw.value);
|
|
222
|
+
} catch {
|
|
223
|
+
return `invalid value for "${field}"`;
|
|
224
|
+
}
|
|
225
|
+
return { op, path: [field], value: checked };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isConflict(err) {
|
|
229
|
+
if (err === null || err === undefined) return false;
|
|
230
|
+
if (typeof err === "object" && typeof err.code === "string" && err.code === "SETTINGS_CONFLICT") return true;
|
|
231
|
+
const message = typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
|
232
|
+
return /SETTINGS_CONFLICT/.test(message);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function safeDescribe(settings) {
|
|
236
|
+
try {
|
|
237
|
+
return settings.describe();
|
|
238
|
+
} catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ------------------------------------------------------------------ */
|
|
244
|
+
/* plumbing */
|
|
245
|
+
/* ------------------------------------------------------------------ */
|
|
246
|
+
|
|
247
|
+
function readBody(req) {
|
|
248
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let size = 0;
|
|
251
|
+
req.on("data", (chunk) => {
|
|
252
|
+
size += chunk.length;
|
|
253
|
+
if (size > MAX_BODY_BYTES) {
|
|
254
|
+
const oversize = new Error("request body too large");
|
|
255
|
+
oversize.status = 413;
|
|
256
|
+
rejectBody(oversize);
|
|
257
|
+
req.removeAllListeners("data");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
chunks.push(chunk);
|
|
261
|
+
});
|
|
262
|
+
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
|
|
263
|
+
req.on("error", rejectBody);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sendJson(res, status, payload) {
|
|
268
|
+
if (res.writableEnded === true) return;
|
|
269
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
270
|
+
res.end(JSON.stringify(payload));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Fixed-shape diagnostics only: never echo header text or raw error details. */
|
|
274
|
+
function sanitize(err) {
|
|
275
|
+
const raw = err === null || err === undefined ? "" : String(err && err.message ? err.message : err);
|
|
276
|
+
return raw.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
277
|
+
}
|
package/lib/trust.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request trust fence for the plugin's HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* The plugin registers a prefix *longer* than the kernel's `/api`, and webServer
|
|
5
|
+
* dispatch is longest-prefix-wins — so these routes run before the connection
|
|
6
|
+
* service's own admission check and would otherwise answer any loopback caller.
|
|
7
|
+
* That was measured and documented as an open boundary; this module closes it.
|
|
8
|
+
*
|
|
9
|
+
* Two layers, tried in order:
|
|
10
|
+
*
|
|
11
|
+
* 1. the composition's own `connection` service when present — the exact
|
|
12
|
+
* admission decision the kernel applies to its `/api` routes (trust fence
|
|
13
|
+
* plus browser-auth cookie), so the plugin is never weaker than the app;
|
|
14
|
+
* 2. a structural replica of that fence for compositions without the service:
|
|
15
|
+
* loopback host only, no cross-site fetches, and an `Origin`/`Referer` that
|
|
16
|
+
* matches the `Host` authority whenever the client supplies one, with a
|
|
17
|
+
* narrow exception for the desktop's dsh-app://app relay Referer.
|
|
18
|
+
*
|
|
19
|
+
* @module src/trust.js
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Hostnames a same-machine caller can legitimately use. */
|
|
23
|
+
const LOOPBACK_NAMES = new Set(['127.0.0.1', '[::1]', '::1', 'localhost'])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Is this address one a same-machine caller can reach — i.e. may the forward
|
|
27
|
+
* listener bind it? The lanes are keyed to this machine's egress and priced
|
|
28
|
+
* against whoever shares it, so binding a routable interface would hand the
|
|
29
|
+
* quota to the whole subnet on the strength of a string in a settings file.
|
|
30
|
+
*/
|
|
31
|
+
export function isLoopbackHost(value) {
|
|
32
|
+
const authority = authorityOf(String(value ?? ''), 'http')
|
|
33
|
+
return authority !== null && LOOPBACK_NAMES.has(authority.hostname)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The fence's view of one connection service, keyed under `admit` (issue #89).
|
|
38
|
+
*
|
|
39
|
+
* The composition may publish `connection` long after plugins have loaded, so
|
|
40
|
+
* index.js reads it per request through this late-binding view. Hosts before
|
|
41
|
+
* dsh 0.1.7 carry the same decision as `requestRejection`: the view maps that
|
|
42
|
+
* method onto `admit` instead of wrapping a property that does not exist —
|
|
43
|
+
* the wrapping call used to throw inside the fence and read as 503 admission
|
|
44
|
+
* unavailable on every request. A bare status travels as the `{rejection}`
|
|
45
|
+
* envelope `rejectionFor` unwraps: handed through bare, an object-shaped
|
|
46
|
+
* return reads as "no rejection" and switches the host's authentication off.
|
|
47
|
+
*
|
|
48
|
+
* Exported so the tests exercise the real view rather than a replica that can
|
|
49
|
+
* drift from it.
|
|
50
|
+
*
|
|
51
|
+
* @param {object|(() => object|undefined)} service - the harness `connection`
|
|
52
|
+
* service, or a thunk resolving it per request (the composition may publish
|
|
53
|
+
* it long after plugins have loaded)
|
|
54
|
+
* @returns {{admit: (req: object) => ({rejection: number}|undefined)|undefined}|{admit: undefined}}
|
|
55
|
+
*/
|
|
56
|
+
export function connectionAdmissionView(service) {
|
|
57
|
+
return {
|
|
58
|
+
get admit() {
|
|
59
|
+
const current = typeof service === 'function' ? service() : service
|
|
60
|
+
if (current === undefined) return undefined
|
|
61
|
+
if (typeof current.admit !== 'function' && typeof current.requestRejection === 'function') {
|
|
62
|
+
return req => {
|
|
63
|
+
const status = current.requestRejection(req)
|
|
64
|
+
return status === undefined ? undefined : { rejection: status }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return req => current.admit(req)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decide one request. Returns an HTTP status to reject with, or `undefined` to
|
|
74
|
+
* let the handler run.
|
|
75
|
+
*
|
|
76
|
+
* @param {import('node:http').IncomingMessage} req
|
|
77
|
+
* @param {object|undefined} connection - the harness `connection` service, when the composition mounts one
|
|
78
|
+
* @param {(decision: {status: number, source: string, reason: string}) => void} [onRejection]
|
|
79
|
+
* fixed diagnostic fields only; never request headers or error text
|
|
80
|
+
* @returns {number|undefined}
|
|
81
|
+
*/
|
|
82
|
+
export function rejectionFor(req, connection, onRejection) {
|
|
83
|
+
let decision
|
|
84
|
+
try {
|
|
85
|
+
// Hosts before dsh 0.1.7 publish the same decision under the older name
|
|
86
|
+
// `requestRejection` (issue #89): `admit` is undefined there, and the
|
|
87
|
+
// service *exists*, so an empty read must fall through to that method —
|
|
88
|
+
// treating it as a fault turned every request into 503 admission
|
|
89
|
+
// unavailable. The return value is a bare status on that spelling and an
|
|
90
|
+
// `{rejection}` envelope on `admit`.
|
|
91
|
+
const admit = connection?.admit
|
|
92
|
+
if (typeof admit === 'function') {
|
|
93
|
+
const admission = admit.call(connection, req)
|
|
94
|
+
const status = admission && typeof admission === 'object' ? admission.rejection : undefined
|
|
95
|
+
if (status === undefined) return undefined
|
|
96
|
+
decision = { status, source: 'connection', reason: 'host-rejected' }
|
|
97
|
+
} else {
|
|
98
|
+
const reject = connection?.requestRejection
|
|
99
|
+
if (typeof reject === 'function') {
|
|
100
|
+
const status = reject.call(connection, req)
|
|
101
|
+
if (status === undefined) return undefined
|
|
102
|
+
decision = { status, source: 'connection', reason: 'host-rejected' }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Never bypass browser authentication when a mounted Host service fails.
|
|
107
|
+
decision = { status: 503, source: 'connection', reason: 'admission-error' }
|
|
108
|
+
}
|
|
109
|
+
if (decision === undefined) {
|
|
110
|
+
const reason = structuralReason(req)
|
|
111
|
+
if (reason === undefined) return undefined
|
|
112
|
+
decision = { status: 403, source: 'structural', reason }
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
onRejection?.(decision)
|
|
116
|
+
} catch {
|
|
117
|
+
// Diagnostics are observational; a logger cannot change the HTTP decision.
|
|
118
|
+
}
|
|
119
|
+
return decision.status
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The replica fence. Same shape as the kernel's `isTrustedApiRequest`: DNS
|
|
124
|
+
* rebinding defence via the Host header, cross-site fetch refusal, and an
|
|
125
|
+
* Origin/Referer authority match.
|
|
126
|
+
*/
|
|
127
|
+
export function structuralRejection(req) {
|
|
128
|
+
return structuralReason(req) === undefined ? undefined : 403
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Return a fixed reason without exposing caller-supplied strings. */
|
|
132
|
+
function structuralReason(req) {
|
|
133
|
+
const host = authorityOf(req.headers.host, 'http')
|
|
134
|
+
if (host === null || !LOOPBACK_NAMES.has(host.hostname)) return 'host-not-loopback'
|
|
135
|
+
const site = String(req.headers['sec-fetch-site'] ?? '').toLowerCase()
|
|
136
|
+
if (site === 'cross-site') return 'cross-site'
|
|
137
|
+
for (const header of ['origin', 'referer']) {
|
|
138
|
+
const raw = req.headers[header]
|
|
139
|
+
if (typeof raw !== 'string' || raw.trim() === '') continue
|
|
140
|
+
if (header === 'referer' && isDesktopRelayReferer(raw)) {
|
|
141
|
+
// Desktop forwardWebRequest strips both browser markers before relaying
|
|
142
|
+
// to HTTP loopback, but retains this Referer. It is not an Origin grant
|
|
143
|
+
// and never overrides a mounted Host's authentication decision.
|
|
144
|
+
if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) return 'desktop-relay-markers'
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const authority = authorityOf(raw.trim())
|
|
148
|
+
if (authority === null) return `${header}-invalid`
|
|
149
|
+
// The web server speaks plain http on a loopback bind, so an Origin that
|
|
150
|
+
// claims https — or any other scheme — is not this page.
|
|
151
|
+
if (authority.scheme !== host.scheme || authority.hostname !== host.hostname || authority.port !== host.port) return `${header}-mismatch`
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Only the owned application page, never arbitrary custom-protocol pages. */
|
|
157
|
+
function isDesktopRelayReferer(value) {
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(value)
|
|
160
|
+
return url.protocol === 'dsh-app:' && url.hostname === 'app' && url.port === ''
|
|
161
|
+
&& url.username === '' && url.password === ''
|
|
162
|
+
} catch {
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Split a Host/Origin/Referer value into {scheme, hostname, port}, defaulting the port. */
|
|
168
|
+
function authorityOf(value, defaultScheme) {
|
|
169
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
170
|
+
let url
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(value.includes('://') ? value.trim() : `${defaultScheme ?? 'http'}://${value.trim()}`)
|
|
173
|
+
} catch {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
177
|
+
const port = url.port === '' ? (url.protocol === 'https:' ? '443' : '80') : url.port
|
|
178
|
+
return { scheme: url.protocol.replace(':', ''), hostname: url.hostname.toLowerCase(), port }
|
|
179
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// util/egress-fetch.js — proxy-aware fetch for kill-switched egress environments.
|
|
2
|
+
//
|
|
3
|
+
// Node's global fetch ignores HTTP_PROXY / HTTPS_PROXY. Containerized
|
|
4
|
+
// deployments commonly rely on those env vars AND block direct connections
|
|
5
|
+
// (egress kill-switch), so every outbound backend request must tunnel through
|
|
6
|
+
// the proxy. This module provides the fetch subset the plugin uses — ok,
|
|
7
|
+
// status, headers.get(), text(), json(), body.cancel(), abort signals,
|
|
8
|
+
// redirect:"error" plus bounded redirect:"follow" — backed by node:http(s)
|
|
9
|
+
// with a proxy CONNECT tunnel and zero dependencies.
|
|
10
|
+
//
|
|
11
|
+
// When no matching proxy env is set, or the target host sits in NO_PROXY, it
|
|
12
|
+
// defers to the native fetch, so behavior everywhere else is unchanged.
|
|
13
|
+
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import https from "node:https";
|
|
16
|
+
|
|
17
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
|
|
18
|
+
const MAX_REDIRECTS = 5;
|
|
19
|
+
|
|
20
|
+
/** fetch-compatible entry: tunnels via HTTPS_PROXY/HTTP_PROXY when configured. */
|
|
21
|
+
export function egressFetch(url, init = {}) {
|
|
22
|
+
const proxy = proxyFor(url);
|
|
23
|
+
if (proxy === undefined) return fetch(url, init);
|
|
24
|
+
return tunneledFetch(String(url), init, proxy, 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve the proxy URL for a request target (NO_PROXY aware). */
|
|
28
|
+
function proxyFor(url) {
|
|
29
|
+
let target;
|
|
30
|
+
try {
|
|
31
|
+
target = new URL(String(url));
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (target.protocol !== "https:" && target.protocol !== "http:") return undefined;
|
|
36
|
+
const env = process.env;
|
|
37
|
+
const proxy = target.protocol === "https:"
|
|
38
|
+
? env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy
|
|
39
|
+
: env.HTTP_PROXY || env.http_proxy;
|
|
40
|
+
if (proxy === undefined || proxy === "") return undefined;
|
|
41
|
+
if (inNoProxy(target, env.NO_PROXY || env.no_proxy)) return undefined;
|
|
42
|
+
return proxy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** NO_PROXY matching: "*", bare hosts, leading-dot suffixes, optional :port. */
|
|
46
|
+
function inNoProxy(target, raw) {
|
|
47
|
+
if (raw === undefined || raw === "") return false;
|
|
48
|
+
const host = target.hostname.toLowerCase();
|
|
49
|
+
const port = target.port !== "" ? target.port : target.protocol === "https:" ? "443" : "80";
|
|
50
|
+
for (const piece of String(raw).split(",")) {
|
|
51
|
+
const item = piece.trim().toLowerCase();
|
|
52
|
+
if (item === "") continue;
|
|
53
|
+
if (item === "*") return true;
|
|
54
|
+
const [name, itemPort] = splitHostPort(item);
|
|
55
|
+
if (itemPort !== "" && itemPort !== port) continue;
|
|
56
|
+
const bare = name.replace(/^\./, "");
|
|
57
|
+
if (bare === "" ) continue;
|
|
58
|
+
if (host === bare || host.endsWith("." + bare)) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitHostPort(item) {
|
|
64
|
+
if (item.startsWith("[")) {
|
|
65
|
+
const close = item.indexOf("]");
|
|
66
|
+
if (close === -1) return [item.replace(/^\[/, ""), ""];
|
|
67
|
+
const rest = item.slice(close + 1);
|
|
68
|
+
return [item.slice(1, close), rest.startsWith(":") ? rest.slice(1) : ""];
|
|
69
|
+
}
|
|
70
|
+
const colon = item.lastIndexOf(":");
|
|
71
|
+
if (colon === -1) return [item, ""];
|
|
72
|
+
return [item.slice(0, colon), item.slice(colon + 1)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function tunneledFetch(url, init, proxy, depth) {
|
|
76
|
+
const target = new URL(url);
|
|
77
|
+
const secure = target.protocol === "https:";
|
|
78
|
+
const method = String(init.method ?? "GET").toUpperCase();
|
|
79
|
+
const headers = normalizeHeaders(init.headers);
|
|
80
|
+
const body = normalizeBody(init.body);
|
|
81
|
+
if (body !== undefined && headers["content-length"] === undefined && headers["transfer-encoding"] === undefined) {
|
|
82
|
+
headers["content-length"] = String(body.length);
|
|
83
|
+
}
|
|
84
|
+
const signal = init.signal;
|
|
85
|
+
throwIfAborted(signal);
|
|
86
|
+
|
|
87
|
+
const response = secure
|
|
88
|
+
? await httpsViaConnect(target, proxy, method, headers, body, signal)
|
|
89
|
+
: await httpViaProxy(target, proxy, method, headers, body, signal);
|
|
90
|
+
|
|
91
|
+
if (REDIRECT_STATUS.has(response.status)) {
|
|
92
|
+
const location = response.headers.get("location");
|
|
93
|
+
if (location !== null && location !== "") {
|
|
94
|
+
if (init.redirect === "error") {
|
|
95
|
+
await drain(response);
|
|
96
|
+
throw new TypeError(`fetch failed: redirect mode is set to error (${response.status})`);
|
|
97
|
+
}
|
|
98
|
+
if (init.redirect !== "manual" && depth < MAX_REDIRECTS) {
|
|
99
|
+
const next = new URL(location, target);
|
|
100
|
+
const dropBody = response.status === 303
|
|
101
|
+
|| ((response.status === 301 || response.status === 302) && method === "POST");
|
|
102
|
+
const nextInit = dropBody
|
|
103
|
+
? { ...init, method: "GET", body: undefined, headers: withoutBodyHeaders(init.headers) }
|
|
104
|
+
: { ...init };
|
|
105
|
+
await drain(response);
|
|
106
|
+
return egressFetch(next.href, nextInit);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return response;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
/* transports */
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
function httpsViaConnect(target, proxyUrl, method, headers, body, signal) {
|
|
118
|
+
const proxy = new URL(proxyUrl);
|
|
119
|
+
const port = target.port !== "" ? Number(target.port) : 443;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const open = [];
|
|
122
|
+
let settled = false;
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
if (signal !== undefined && typeof signal.removeEventListener === "function") {
|
|
125
|
+
signal.removeEventListener("abort", onAbort);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const onAbort = () => {
|
|
129
|
+
for (const handle of open) handle.destroy?.();
|
|
130
|
+
};
|
|
131
|
+
const fail = (error) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
cleanup();
|
|
135
|
+
reject(signal?.aborted === true ? abortReason(signal) : error);
|
|
136
|
+
};
|
|
137
|
+
const done = (response) => {
|
|
138
|
+
if (settled) return;
|
|
139
|
+
settled = true;
|
|
140
|
+
cleanup();
|
|
141
|
+
resolve(response);
|
|
142
|
+
};
|
|
143
|
+
if (signal !== undefined && typeof signal.addEventListener === "function") {
|
|
144
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const connectHeaders = { host: `${target.hostname}:${port}` };
|
|
148
|
+
const proxyAuth = basicProxyAuth(proxy);
|
|
149
|
+
if (proxyAuth !== undefined) connectHeaders["proxy-authorization"] = proxyAuth;
|
|
150
|
+
const connectReq = http.request({
|
|
151
|
+
host: proxy.hostname,
|
|
152
|
+
port: proxy.port !== "" ? Number(proxy.port) : 80,
|
|
153
|
+
method: "CONNECT",
|
|
154
|
+
path: `${target.hostname}:${port}`,
|
|
155
|
+
headers: connectHeaders,
|
|
156
|
+
signal,
|
|
157
|
+
});
|
|
158
|
+
open.push(connectReq);
|
|
159
|
+
connectReq.on("error", (error) => fail(new TypeError(`fetch failed: proxy CONNECT ${sanitize(error)}`, { cause: error })));
|
|
160
|
+
connectReq.on("response", (res) => {
|
|
161
|
+
res.resume();
|
|
162
|
+
fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${res.statusCode}`));
|
|
163
|
+
});
|
|
164
|
+
connectReq.on("connect", (connectRes, socket) => {
|
|
165
|
+
if (connectRes.statusCode !== 200) {
|
|
166
|
+
socket.destroy();
|
|
167
|
+
fail(new TypeError(`fetch failed: proxy CONNECT rejected with ${connectRes.statusCode}`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const request = https.request({
|
|
171
|
+
socket,
|
|
172
|
+
agent: false,
|
|
173
|
+
servername: target.hostname,
|
|
174
|
+
method,
|
|
175
|
+
path: pathWithQuery(target),
|
|
176
|
+
headers,
|
|
177
|
+
});
|
|
178
|
+
open.push(request);
|
|
179
|
+
request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
|
|
180
|
+
request.on("response", (res) => {
|
|
181
|
+
collect(res).then(done, (error) => fail(error));
|
|
182
|
+
});
|
|
183
|
+
if (signal?.aborted === true) {
|
|
184
|
+
request.destroy();
|
|
185
|
+
fail(abortReason(signal));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
request.end(body);
|
|
189
|
+
});
|
|
190
|
+
connectReq.end();
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function httpViaProxy(target, proxyUrl, method, headers, body, signal) {
|
|
195
|
+
const proxy = new URL(proxyUrl);
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const request = http.request({
|
|
198
|
+
host: proxy.hostname,
|
|
199
|
+
port: proxy.port !== "" ? Number(proxy.port) : 80,
|
|
200
|
+
method,
|
|
201
|
+
path: target.href,
|
|
202
|
+
headers: { ...headers, host: headers.host ?? target.host },
|
|
203
|
+
signal,
|
|
204
|
+
});
|
|
205
|
+
const fail = (error) => {
|
|
206
|
+
if (settled) return;
|
|
207
|
+
settled = true;
|
|
208
|
+
reject(signal?.aborted === true ? abortReason(signal) : error);
|
|
209
|
+
};
|
|
210
|
+
let settled = false;
|
|
211
|
+
const proxyAuth = basicProxyAuth(proxy);
|
|
212
|
+
if (proxyAuth !== undefined) request.setHeader("proxy-authorization", proxyAuth);
|
|
213
|
+
request.on("error", (error) => fail(new TypeError(`fetch failed: ${sanitize(error)}`, { cause: error })));
|
|
214
|
+
request.on("response", (res) => {
|
|
215
|
+
collect(res).then((response) => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
resolve(response);
|
|
219
|
+
}, fail);
|
|
220
|
+
});
|
|
221
|
+
if (signal?.aborted === true) {
|
|
222
|
+
request.destroy();
|
|
223
|
+
fail(abortReason(signal));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
request.end(body);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ------------------------------------------------------------------ */
|
|
231
|
+
/* plumbing */
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
|
|
234
|
+
function collect(res) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const chunks = [];
|
|
237
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
238
|
+
res.on("end", () => {
|
|
239
|
+
// Null-body statuses (204/304) and empty streams must construct a
|
|
240
|
+
// bodiless Response; an empty Buffer is not null and would throw.
|
|
241
|
+
const payload = chunks.length === 0 ? null : Buffer.concat(chunks);
|
|
242
|
+
resolve(new Response(payload, {
|
|
243
|
+
status: res.statusCode ?? 502,
|
|
244
|
+
headers: headersFromRaw(res),
|
|
245
|
+
}));
|
|
246
|
+
});
|
|
247
|
+
res.on("error", reject);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function headersFromRaw(res) {
|
|
252
|
+
const pairs = [];
|
|
253
|
+
const raw = res.rawHeaders ?? [];
|
|
254
|
+
for (let i = 0; i + 1 < raw.length; i += 2) pairs.push([raw[i], raw[i + 1]]);
|
|
255
|
+
return new Headers(pairs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function pathWithQuery(target) {
|
|
259
|
+
return target.pathname + target.search;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeHeaders(raw) {
|
|
263
|
+
const headers = {};
|
|
264
|
+
if (raw === undefined || raw === null) return headers;
|
|
265
|
+
if (typeof raw.forEach === "function" && typeof raw.get === "function") {
|
|
266
|
+
raw.forEach((value, key) => { headers[String(key).toLowerCase()] = String(value); });
|
|
267
|
+
} else if (Array.isArray(raw)) {
|
|
268
|
+
for (const entry of raw) {
|
|
269
|
+
if (Array.isArray(entry) && entry.length >= 2) headers[String(entry[0]).toLowerCase()] = String(entry[1]);
|
|
270
|
+
}
|
|
271
|
+
} else if (typeof raw === "object") {
|
|
272
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
273
|
+
if (value !== undefined) headers[String(key).toLowerCase()] = String(value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
delete headers["host"];
|
|
277
|
+
return headers;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function withoutBodyHeaders(raw) {
|
|
281
|
+
if (raw === undefined || raw === null) return raw;
|
|
282
|
+
const drop = new Set(["content-type", "content-length", "content-encoding"]);
|
|
283
|
+
if (typeof raw.forEach === "function" && typeof raw.get === "function") {
|
|
284
|
+
const copy = new Headers();
|
|
285
|
+
raw.forEach((value, key) => {
|
|
286
|
+
if (!drop.has(String(key).toLowerCase())) copy.append(key, value);
|
|
287
|
+
});
|
|
288
|
+
return copy;
|
|
289
|
+
}
|
|
290
|
+
const copy = {};
|
|
291
|
+
if (Array.isArray(raw)) {
|
|
292
|
+
for (const entry of raw) {
|
|
293
|
+
if (Array.isArray(entry) && entry.length >= 2 && !drop.has(String(entry[0]).toLowerCase())) copy[entry[0]] = entry[1];
|
|
294
|
+
}
|
|
295
|
+
} else if (typeof raw === "object") {
|
|
296
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
297
|
+
if (!drop.has(String(key).toLowerCase())) copy[key] = value;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return copy;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeBody(body) {
|
|
304
|
+
if (body === undefined || body === null) return undefined;
|
|
305
|
+
if (typeof body === "string") return Buffer.from(body);
|
|
306
|
+
if (Buffer.isBuffer(body)) return body;
|
|
307
|
+
if (body instanceof Uint8Array) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
308
|
+
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return Buffer.from(body.toString());
|
|
309
|
+
return Buffer.from(String(body));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function basicProxyAuth(proxy) {
|
|
313
|
+
if (proxy.username === "" && proxy.password === "") return undefined;
|
|
314
|
+
const user = decodeURIComponent(proxy.username);
|
|
315
|
+
const pass = decodeURIComponent(proxy.password);
|
|
316
|
+
return "Basic " + Buffer.from(`${user}:${pass}`).toString("base64");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function throwIfAborted(signal) {
|
|
320
|
+
if (signal?.aborted === true) throw abortReason(signal);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function abortReason(signal) {
|
|
324
|
+
return signal?.reason !== undefined ? signal.reason : new DOMException("This operation was aborted", "AbortError");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function sanitize(error) {
|
|
328
|
+
const raw = error && error.message ? String(error.message) : String(error);
|
|
329
|
+
return raw.replace(/\s+/g, " ").slice(0, 140);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function drain(response) {
|
|
333
|
+
try {
|
|
334
|
+
await response.body?.cancel?.();
|
|
335
|
+
} catch {
|
|
336
|
+
/* already consumed */
|
|
337
|
+
}
|
|
338
|
+
}
|
package/lib/util/mcp-client.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
|
+
import { egressFetch } from "./egress-fetch.js";
|
|
9
10
|
|
|
10
11
|
const UA = "dsh-websearch/2.3";
|
|
11
12
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -41,7 +42,7 @@ async function ensureSession(url, signal) {
|
|
|
41
42
|
|
|
42
43
|
let initResponse;
|
|
43
44
|
try {
|
|
44
|
-
initResponse = await
|
|
45
|
+
initResponse = await egressFetch(url, {
|
|
45
46
|
method: "POST",
|
|
46
47
|
headers: {
|
|
47
48
|
"Content-Type": "application/json",
|
|
@@ -75,7 +76,7 @@ async function ensureSession(url, signal) {
|
|
|
75
76
|
};
|
|
76
77
|
|
|
77
78
|
try {
|
|
78
|
-
const notifyResponse = await
|
|
79
|
+
const notifyResponse = await egressFetch(url, {
|
|
79
80
|
method: "POST",
|
|
80
81
|
headers: {
|
|
81
82
|
"Content-Type": "application/json",
|
|
@@ -161,7 +162,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
|
|
|
161
162
|
|
|
162
163
|
let response;
|
|
163
164
|
try {
|
|
164
|
-
response = await
|
|
165
|
+
response = await egressFetch(url, {
|
|
165
166
|
method: "POST",
|
|
166
167
|
headers: {
|
|
167
168
|
"Content-Type": "application/json",
|
|
@@ -186,7 +187,7 @@ export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
|
|
|
186
187
|
// Retry with fresh session
|
|
187
188
|
const newSessionId = await ensureSession(url, signal);
|
|
188
189
|
try {
|
|
189
|
-
response = await
|
|
190
|
+
response = await egressFetch(url, {
|
|
190
191
|
method: "POST",
|
|
191
192
|
headers: {
|
|
192
193
|
"Content-Type": "application/json",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@240xu/dsh-websearch",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.4",
|
|
4
4
|
"description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search. 统一网页搜索聚合:11 后端并发扇出、去重合并,零依赖。See CHANGELOG.md for version history.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|