@xlight-oss/visionary-dsh 0.7.1 → 0.7.3
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 +15 -6
- package/cordis.patch.yml +7 -6
- package/lib/client.js +814 -0
- package/lib/image-bridge/core.mjs +22 -6
- package/lib/image-bridge/index.mjs +91 -85
- package/lib/index.mjs +27 -10
- package/package.json +23 -16
- package/lib/image-bridge/trust-fence.mjs +0 -85
- package/lib/settings-card/client.js +0 -607
- package/lib/settings-card/index.mjs +0 -18
- package/lib/settings-card/package.json +0 -36
- package/lib/settings-route.mjs +0 -215
|
@@ -35,11 +35,11 @@ export function matchesRoute(provider, model, routes) {
|
|
|
35
35
|
* untouched.
|
|
36
36
|
*
|
|
37
37
|
* The ORIGINAL method reference is kept separate from the patch so capability
|
|
38
|
-
* sensing (`nativeImageCapable`)
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
38
|
+
* sensing (`nativeImageCapable`) is never poisoned by it — the patch is
|
|
39
|
+
* strictly an admission-release lever, and without this separation every
|
|
40
|
+
* bridge route would read as "natively image capable" and the rewrite would
|
|
41
|
+
* never run (images would hit the pi-ai second gate and fail with
|
|
42
|
+
* UNSUPPORTED_CONTENT).
|
|
43
43
|
*
|
|
44
44
|
* Lifecycle: `install()` registers the patch and returns a disposer restoring
|
|
45
45
|
* the original (re-assign, guarded so it never clobbers a later patch by
|
|
@@ -47,8 +47,23 @@ export function matchesRoute(provider, model, routes) {
|
|
|
47
47
|
* unload/HMR reload always restores the original — otherwise a leftover patch
|
|
48
48
|
* would be captured as the "original" by the next apply and poison capability
|
|
49
49
|
* sensing forever.
|
|
50
|
+
*
|
|
51
|
+
* Feature detection (design D7): a host whose llm service has no
|
|
52
|
+
* `resolveModelInfo` cannot be bridged at all. Instead of throwing inside
|
|
53
|
+
* `apply` (which would kill the whole plugin row), the patch reports
|
|
54
|
+
* `available: false` and the caller skips the bridge.
|
|
50
55
|
*/
|
|
51
|
-
export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute }) {
|
|
56
|
+
export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute, logger }) {
|
|
57
|
+
if (typeof llm?.resolveModelInfo !== "function") {
|
|
58
|
+
logger?.warn?.(
|
|
59
|
+
"[visionary-image-bridge] ctx.llm.resolveModelInfo is not a function on this host; image bridging stays disabled"
|
|
60
|
+
);
|
|
61
|
+
return {
|
|
62
|
+
available: false,
|
|
63
|
+
original: undefined,
|
|
64
|
+
install: () => () => {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
52
67
|
const original = llm.resolveModelInfo.bind(llm);
|
|
53
68
|
const patched = async (provider, model, signal) => {
|
|
54
69
|
const info = await original(provider, model, signal);
|
|
@@ -59,6 +74,7 @@ export function makeModelInfoPatch({ llm, isEnabled, routeMatch = matchesRoute }
|
|
|
59
74
|
return { ...info, inputModalities: [...info.inputModalities, "image"] };
|
|
60
75
|
};
|
|
61
76
|
return {
|
|
77
|
+
available: true,
|
|
62
78
|
/** The unpatched method, for capability sensing and consultations. */
|
|
63
79
|
original,
|
|
64
80
|
/** Install the patch; returns the disposer that restores the original. */
|
|
@@ -17,20 +17,18 @@
|
|
|
17
17
|
// fires and the model only ever receives text. The rewrite acts on the
|
|
18
18
|
// request snapshot only — session logs / UI transcript keep the original
|
|
19
19
|
// images.
|
|
20
|
-
// 3.
|
|
21
|
-
//
|
|
22
|
-
// when the host provides it natively, the resolveModelInfo patch is not
|
|
23
|
-
// installed and the native hook handles admission.
|
|
20
|
+
// 3. settings — the bridge config lives in its own namespace
|
|
21
|
+
// (`visionary-image-bridge`) via `installSection` + the native card.
|
|
24
22
|
//
|
|
25
23
|
// The bridge never changes the `deepseek_vision` tool contract, never touches
|
|
26
24
|
// session logs, and with `enabled: false` restores the host's original
|
|
27
25
|
// behavior (text-only models reject images again).
|
|
28
26
|
//
|
|
29
27
|
// Design source: openspec/changes/visionary-image-bridge (D2 admission patch,
|
|
30
|
-
// D3 persistence, D4 llm/stream rewrite,
|
|
28
|
+
// D3 persistence, D4 llm/stream rewrite, D6 settings, D7 TTL);
|
|
29
|
+
// openspec/changes/adapt-dsh-0-1-5 (D2 installSection, D7 bridge route).
|
|
31
30
|
|
|
32
31
|
import z from "@deepseek-ai/schemastery";
|
|
33
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
34
32
|
import { ImagePersistence } from "./persistence.mjs";
|
|
35
33
|
import { makeModelInfoPatch, makeStreamListener, matchesRoute } from "./core.mjs";
|
|
36
34
|
// Same-package internal reuse (exported from the tools plugin row): the
|
|
@@ -53,8 +51,10 @@ export const DEFAULT_PROMPT_TEMPLATE = [
|
|
|
53
51
|
/** Degradation placeholder when an image cannot be read or persisted. */
|
|
54
52
|
export const IMAGE_PLACEHOLDER = "用户粘贴的图片处理失败,无法分析。";
|
|
55
53
|
|
|
56
|
-
/** Settings namespace (panel
|
|
57
|
-
|
|
54
|
+
/** Settings namespace (panel card key + `$DSH_HOME/settings.yaml` section).
|
|
55
|
+
* Literal string: DSH validates namespaces as literal types since
|
|
56
|
+
* 0.1.2-alpha.2 and no longer ships the `settingsNamespace()` helper. */
|
|
57
|
+
export const SETTINGS_NAMESPACE = "visionary-image-bridge";
|
|
58
58
|
|
|
59
59
|
export const Config = z.object({
|
|
60
60
|
enabled: z.boolean().default(true).description(
|
|
@@ -124,40 +124,21 @@ export function apply(ctx, config) {
|
|
|
124
124
|
logger: ctx.logger,
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
// Admission release (design D2 of the original change; route decision in
|
|
128
|
+
// adapt-dsh-0-1-5 D7): the web host's gate hardcodes
|
|
129
|
+
// MODEL_DOES_NOT_SUPPORT_IMAGES and reads only `llm.resolveModelInfo`, with
|
|
130
|
+
// no waterfall to hook. The bridge therefore releases admission by wrapping
|
|
131
|
+
// that one read, then rewrites image blocks to text at `llm/stream`. The
|
|
132
|
+
// community alternative (registering a synthetic provider route that
|
|
133
|
+
// advertises `image`) cannot take over an existing provider id and forces
|
|
134
|
+
// the user to re-select a wrapped model, so it is not used here.
|
|
134
135
|
const patch = makeModelInfoPatch({
|
|
135
136
|
llm: ctx.llm,
|
|
136
137
|
isEnabled: () => runtime.enabled,
|
|
137
138
|
routeMatch: (provider, model) => matchesRoute(provider, model, runtime.routes),
|
|
139
|
+
logger: ctx.logger,
|
|
138
140
|
});
|
|
139
141
|
|
|
140
|
-
if (!hostProvidesImageRouting) {
|
|
141
|
-
// Admission release + restore-on-unload (design D2). The disposer restores
|
|
142
|
-
// the original method, so an HMR reload never captures the leftover patch
|
|
143
|
-
// as the "original" and poisons capability sensing.
|
|
144
|
-
ctx.effect(patch.install, "visionary-image-bridge: resolveModelInfo patch");
|
|
145
|
-
|
|
146
|
-
// Community-shaped consultation (design D5): the web host calls
|
|
147
|
-
// resolveFallback(agent, current) when the selected model rejects image
|
|
148
|
-
// input and routes the request through the returned selection. This bridge
|
|
149
|
-
// never switches models — returning `current` means "keep this route, the
|
|
150
|
-
// bridge admits and rewrites images at the stream boundary".
|
|
151
|
-
ctx.provide("imageRouting", {
|
|
152
|
-
resolveFallback: async (_agent, current) => {
|
|
153
|
-
if (!runtime.enabled) return undefined;
|
|
154
|
-
if (!current) return undefined;
|
|
155
|
-
if (!matchesRoute(current.provider, current.model, runtime.routes)) return undefined;
|
|
156
|
-
return current;
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
142
|
const rewrittenBatches = new WeakSet();
|
|
162
143
|
|
|
163
144
|
// Deterministic-mode analysis hook (design D6): image -> text via the same
|
|
@@ -185,58 +166,83 @@ export function apply(ctx, config) {
|
|
|
185
166
|
);
|
|
186
167
|
};
|
|
187
168
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
169
|
+
if (!patch.available) {
|
|
170
|
+
// Feature detection (task 2.6): a host without the readable method cannot
|
|
171
|
+
// be bridged. Keep the plugin row alive with the bridge disabled instead
|
|
172
|
+
// of throwing out of `apply`; the patch already logged the reason.
|
|
173
|
+
ctx.logger?.warn?.(
|
|
174
|
+
"[visionary-image-bridge] bridging disabled: the host llm service exposes no resolveModelInfo()"
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
// Restore-on-unload (design D2): the disposer restores the original method,
|
|
178
|
+
// so an HMR reload never captures the leftover patch as the "original" and
|
|
179
|
+
// poisons capability sensing.
|
|
180
|
+
ctx.effect(patch.install, "visionary-image-bridge: resolveModelInfo patch");
|
|
181
|
+
|
|
182
|
+
// The unified rewrite point (design D4): every model request passes this
|
|
183
|
+
// waterfall, so one listener covers user pastes, read_image tool results,
|
|
184
|
+
// any tool-result image, and replay. prepend: true puts it outside the host
|
|
185
|
+
// llm-invariant; global: true makes it fire for the root event scope like
|
|
186
|
+
// the official dsh-session-title listener.
|
|
187
|
+
ctx.on(
|
|
188
|
+
"llm/stream",
|
|
189
|
+
makeStreamListener({
|
|
190
|
+
llm: ctx.llm,
|
|
191
|
+
originalResolveModelInfo: patch.original,
|
|
192
|
+
getRuntime: () => runtime,
|
|
193
|
+
persistence,
|
|
194
|
+
rewrittenBatches,
|
|
195
|
+
logger: ctx.logger,
|
|
196
|
+
analyzeImage,
|
|
197
|
+
}),
|
|
198
|
+
{ global: true, prepend: true },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
206
201
|
|
|
207
202
|
// Settings section (design D6): settings panel + settings.yaml, hot reload.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
203
|
+
// The service is optional: inject waits for it, and installSection falls back
|
|
204
|
+
// to the composition entry when the provider detaches (adapt-dsh-0-1-5 D2).
|
|
205
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
206
|
+
const settings = settingsCtx.settings;
|
|
207
|
+
if (typeof settings?.installSection !== "function") {
|
|
208
|
+
ctx.logger?.warn?.(
|
|
209
|
+
"[visionary-image-bridge] the mounted settings service has no installSection(); keeping the composition entry config"
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
214
|
+
setSource: (thunk) => {
|
|
215
|
+
source = thunk;
|
|
216
|
+
},
|
|
217
|
+
onChange: () => {
|
|
218
|
+
const next = { ...source() };
|
|
219
|
+
// cleanPasted 是一次性触发器:切为 true 即触发清理,并把运行态复位为
|
|
220
|
+
// false(不再视为常态配置),再回写 settings 文档复位持久化值,避免
|
|
221
|
+
// 每次启动/切换都重复全量清理(design:打开一次触发一次)。
|
|
222
|
+
const triggered = next.cleanPasted === true;
|
|
223
|
+
if (triggered) next.cleanPasted = false;
|
|
224
|
+
runtime = next;
|
|
225
|
+
validateConfig(runtime); // belt-and-braces; validate hook already rejects bad writes
|
|
226
|
+
if (triggered) {
|
|
227
|
+
persistence
|
|
228
|
+
.cleanup({ all: true })
|
|
229
|
+
.then((removed) => {
|
|
230
|
+
ctx.logger.info(`[visionary-image-bridge] cleaned ${removed} pasted file(s)`);
|
|
231
|
+
})
|
|
232
|
+
.catch((err) => {
|
|
233
|
+
ctx.logger.warn(`[visionary-image-bridge] cleanPasted cleanup failed: ${err?.message ?? err}`);
|
|
234
|
+
});
|
|
235
|
+
const settingsService = ctx.get?.("settings");
|
|
236
|
+
if (settingsService && typeof settingsService.update === "function") {
|
|
237
|
+
settingsService.update(SETTINGS_NAMESPACE, { cleanPasted: false }).catch(() => {
|
|
238
|
+
// best-effort reset; runtime is already flipped, panel stays truthful
|
|
239
|
+
});
|
|
240
|
+
}
|
|
235
241
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
242
|
+
ctx.logger.info("[visionary-image-bridge] configuration updated");
|
|
243
|
+
},
|
|
244
|
+
validate: validateConfig,
|
|
245
|
+
});
|
|
240
246
|
});
|
|
241
247
|
|
|
242
248
|
// Lazy TTL cleanup at startup (design D7); later cleanups run after persists.
|
package/lib/index.mjs
CHANGED
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
|
|
15
15
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
16
16
|
import z from "@deepseek-ai/schemastery";
|
|
17
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
18
17
|
import { spawn } from "node:child_process";
|
|
19
18
|
import { statSync, readFileSync } from "node:fs";
|
|
20
19
|
import { promises as fs } from "node:fs";
|
|
@@ -24,8 +23,12 @@ import path from "node:path";
|
|
|
24
23
|
const name = "visionary-vision";
|
|
25
24
|
const inject = ["tools", "systemPrompt"];
|
|
26
25
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Settings namespace(面板 / settings.yaml 双入口,规范:dsh-plugin 设置面板上传路径配置)。
|
|
28
|
+
* 字面量字符串:DSH 自 0.1.2-alpha.2 起以编译期字面量类型校验命名空间,
|
|
29
|
+
* 旧的 `settingsNamespace()` 运行时 helper 已不存在(`/^[a-z][a-z0-9-]*$/`)。
|
|
30
|
+
*/
|
|
31
|
+
const SETTINGS_NAMESPACE = "visionary-vision";
|
|
29
32
|
|
|
30
33
|
// Keep in lockstep with the Rust binary's minor version: tools rely on the
|
|
31
34
|
// CLI's `--json` output shape. Bump when the binary's contract changes.
|
|
@@ -297,13 +300,27 @@ function apply(ctx, config) {
|
|
|
297
300
|
// 而不是冻结的 config —— 设置面板切换 modelType(vision|ocr)无需重启 DSH。
|
|
298
301
|
let runtime = { ...config };
|
|
299
302
|
let source = () => config;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
303
|
+
// settings 服务可选:inject(["settings"], …) 在 provider 就绪(或之后出现)时
|
|
304
|
+
// 才注册命名空间,provider 脱离时 installSection 会把 source 回退到 entry,
|
|
305
|
+
// 因此「无 settings 服务」表现为按组合 entry 配置运行,而不是插件行报错。
|
|
306
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
307
|
+
const settings = settingsCtx.settings;
|
|
308
|
+
if (typeof settings?.installSection !== "function") {
|
|
309
|
+
// 契约不符(早于 0.1.2-alpha.2 的宿主):降级为 entry 配置并明确提示,
|
|
310
|
+
// 不让整行插件在加载期死掉。
|
|
311
|
+
ctx.logger?.warn?.(
|
|
312
|
+
"[visionary-vision] the mounted settings service has no installSection(); keeping the composition entry config"
|
|
313
|
+
);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
317
|
+
setSource: (thunk) => {
|
|
318
|
+
source = thunk;
|
|
319
|
+
},
|
|
320
|
+
onChange: () => {
|
|
321
|
+
runtime = { ...source() };
|
|
322
|
+
},
|
|
323
|
+
});
|
|
307
324
|
});
|
|
308
325
|
|
|
309
326
|
const loginSeconds = (() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xlight-oss/visionary-dsh",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "DeepSeek Visionary native plugin for DeepSeek Harness: deepseek_vision / status / login / logout native tools plus the text-model image bridge, all backed by the visionary-server CLI (DeepSeek web vision model, no API key).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
"./image-bridge": {
|
|
12
12
|
"default": "./lib/image-bridge/index.mjs"
|
|
13
13
|
},
|
|
14
|
-
"./
|
|
15
|
-
"default": "./lib/
|
|
14
|
+
"./client": {
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
16
|
},
|
|
17
|
-
"./settings-card/package.json": "./lib/settings-card/package.json",
|
|
18
17
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
19
18
|
"./package.json": "./package.json"
|
|
20
19
|
},
|
|
@@ -26,26 +25,34 @@
|
|
|
26
25
|
"dsh": {
|
|
27
26
|
"bundle": {
|
|
28
27
|
"patch": "./cordis.patch.yml"
|
|
28
|
+
},
|
|
29
|
+
"client": {
|
|
30
|
+
"platform": "web",
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-locale",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-settings-plugins"
|
|
35
|
+
]
|
|
29
36
|
}
|
|
30
37
|
},
|
|
31
38
|
"publishConfig": {
|
|
32
39
|
"access": "public"
|
|
33
40
|
},
|
|
34
41
|
"peerDependencies": {
|
|
35
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
36
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
37
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
38
|
-
"@deepseek-ai/dsh-attachment": "^0.1.
|
|
39
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
40
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
42
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
43
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
|
|
44
|
+
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
45
|
+
"@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.1",
|
|
47
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
41
48
|
},
|
|
42
49
|
"devDependencies": {
|
|
43
|
-
"@deepseek-ai/cordis": "
|
|
44
|
-
"@deepseek-ai/dsh-tools": "
|
|
45
|
-
"@deepseek-ai/dsh-llm": "
|
|
46
|
-
"@deepseek-ai/dsh-attachment": "
|
|
47
|
-
"@deepseek-ai/dsh-settings": "
|
|
48
|
-
"@deepseek-ai/schemastery": "
|
|
50
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
51
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
52
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
|
55
|
+
"@deepseek-ai/schemastery": "3.18.2"
|
|
49
56
|
},
|
|
50
57
|
"repository": {
|
|
51
58
|
"type": "git",
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
// Browser-trust fence for the visionary settings HTTP routes.
|
|
2
|
-
//
|
|
3
|
-
// Behavioral mirror of the /api gateway's fence in @deepseek-ai/dsh-client-connection
|
|
4
|
-
// (api-request-trust.ts, BSD-3-Clause). The DSH settings RPC domain only serves
|
|
5
|
-
// allowlisted namespaces to configuration clients, so a third-party plugin's
|
|
6
|
-
// namespace is unreachable through `connection.api.settings.*`. The
|
|
7
|
-
// settings-card host mounts its own fenced JSON routes instead (see
|
|
8
|
-
// lib/settings-route.mjs `visionary/api`, serving both the vision-tools and
|
|
9
|
-
// image-bridge namespaces); the fence below is a DNS-rebinding / cross-site
|
|
10
|
-
// defense, NOT authentication — the web browser is already same-origin with
|
|
11
|
-
// the host.
|
|
12
|
-
//
|
|
13
|
-
// Dependency-free (pure functions over node:http headers) so unit tests run
|
|
14
|
-
// without a node_modules install.
|
|
15
|
-
|
|
16
|
-
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
|
17
|
-
function header(headers, name) {
|
|
18
|
-
const value = headers[name];
|
|
19
|
-
return typeof value === "string" ? value : undefined;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
23
|
-
function parseAuthority(authority) {
|
|
24
|
-
try {
|
|
25
|
-
return new URL(`http://${authority}`);
|
|
26
|
-
} catch {
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
32
|
-
export function isLoopbackHostname(hostname) {
|
|
33
|
-
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
34
|
-
const parts = hostname.split(".");
|
|
35
|
-
return (
|
|
36
|
-
parts.length === 4 &&
|
|
37
|
-
parts[0] === "127" &&
|
|
38
|
-
parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
43
|
-
function canonicalAuthority(entry, entryUrl) {
|
|
44
|
-
const port =
|
|
45
|
-
entryUrl.port !== ""
|
|
46
|
-
? entryUrl.port
|
|
47
|
-
: new URL(`https://${entry}`).port;
|
|
48
|
-
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
|
|
52
|
-
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
53
|
-
return trustedHosts.some((entry) => {
|
|
54
|
-
const entryUrl = parseAuthority(entry);
|
|
55
|
-
if (entryUrl === undefined) return false;
|
|
56
|
-
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
57
|
-
? entryUrl.hostname === hostUrl.hostname
|
|
58
|
-
: entryUrl.host === hostUrl.host;
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Decide whether one request may reach the plugin's routes.
|
|
64
|
-
* @param request - node HTTP request facts (headers).
|
|
65
|
-
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
66
|
-
* @returns true when the Host is ours (loopback or trusted) and browser
|
|
67
|
-
* markers are same-origin.
|
|
68
|
-
*/
|
|
69
|
-
export function isTrustedApiRequest(request, trustedHosts) {
|
|
70
|
-
const host = header(request.headers, "host");
|
|
71
|
-
if (host === undefined) return false;
|
|
72
|
-
const hostUrl = parseAuthority(host);
|
|
73
|
-
if (hostUrl === undefined) return false;
|
|
74
|
-
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
78
|
-
const origin = header(request.headers, "origin");
|
|
79
|
-
if (origin === undefined) return true;
|
|
80
|
-
try {
|
|
81
|
-
return new URL(origin).host === hostUrl.host;
|
|
82
|
-
} catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|