dsh-offpeak-guard 0.3.1 → 0.3.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/CHANGELOG.md +14 -0
- package/README.en.md +9 -0
- package/README.md +9 -0
- package/client.js +14 -2
- package/index.js +56 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 — 2026-11-14
|
|
4
|
+
|
|
5
|
+
- 设置接口 `/plugins/dsh-offpeak-guard/action` 补上准入:路由只在 `webServer` 与 `connection` 两个服务都在位时注册,每个请求先过 `connection.requestRejection(req)`(Host/Origin 校验 + 浏览器令牌鉴权),返回 401/403 直接拒绝。此前本机任意网页都能改会话豁免、峰谷规则和暂停方式。
|
|
6
|
+
- 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不退回到只检查 Host/Origin/回环地址。
|
|
7
|
+
- 请求体加 64 KiB 上限,超出返回 413;其余错误仍返回 400。
|
|
8
|
+
- 新增 `test/admission.test.mjs`:覆盖缺服务、读取抛错、`requestRejection` 抛错、放行与 401/403 透传五条路径。
|
|
9
|
+
- 适配 DSH 0.2.2-rc.2 的 `@deepseek-ai/dsh-client-connection` 服务约定(`requestRejection` 接收 `{ headers }`)。
|
|
10
|
+
|
|
11
|
+
## 0.3.2 — 2026-11-06
|
|
12
|
+
|
|
13
|
+
- 修复设置面板切换峰时策略时报 `Unexpected end of JSON input`:Host 启动顺序可能让 `webServer` 在插件 apply 时尚未就绪,导致设置路由根本没注册;改用 `ctx.inject(['webServer'], ...)` 后再挂载路由。
|
|
14
|
+
- 客户端对非 JSON/空响应改为显示 `HTTP <status>`,不再把浏览器 JSON 解析异常直接抛到面板底部。
|
|
15
|
+
- 已验证设置面板「立即暂停 / 完成当前轮后暂停」切换成功,路由返回 200。
|
|
16
|
+
|
|
3
17
|
## 0.3.1 — 2026-11-06
|
|
4
18
|
|
|
5
19
|
- 左侧会话行悬停操作新增盾牌按钮:直接在对话索引里豁免/取消豁免,豁免中的会话行保留盾牌标记。
|
package/README.en.md
CHANGED
|
@@ -110,6 +110,15 @@ Click the sidebar clock to open it:
|
|
|
110
110
|
- **Session exemptions**: searchable list of live root sessions; hover a session row in the left sidebar for a shield button, or use the **Exempt this session** button in the composer strip to toggle the current conversation directly;
|
|
111
111
|
- **Holidays**: show the dynamic sync state.
|
|
112
112
|
|
|
113
|
+
### Route admission
|
|
114
|
+
|
|
115
|
+
The settings route `/plugins/dsh-offpeak-guard/action` goes through host authentication:
|
|
116
|
+
|
|
117
|
+
- the route is only registered once both `webServer` and `connection` are available;
|
|
118
|
+
- every request passes `connection.requestRejection(req)` first — its Host/Origin fence stops DNS rebinding and cross-site calls, and its browser authentication stops pages that never logged in; a 401/403 is answered before any business logic runs;
|
|
119
|
+
- a missing `connection`, or one that throws, rejects the request (fail closed); there is no fallback to checking only Host, Origin or the loopback address;
|
|
120
|
+
- request bodies are capped at 64 KiB and answer 413 above that.
|
|
121
|
+
|
|
113
122
|
## Subagents and waiting
|
|
114
123
|
|
|
115
124
|
- Running subagents are allowed to finish; the parent is paused after the last child ends and resumed off-peak;
|
package/README.md
CHANGED
|
@@ -112,6 +112,15 @@
|
|
|
112
112
|
|
|
113
113
|
面板里的修改立即生效并持久化。
|
|
114
114
|
|
|
115
|
+
### 接口准入
|
|
116
|
+
|
|
117
|
+
设置接口 `/plugins/dsh-offpeak-guard/action` 走宿主认证,不对外开放:
|
|
118
|
+
|
|
119
|
+
- 路由在 `webServer` 与 `connection` 两个服务都在位时才注册;
|
|
120
|
+
- 每个请求先过 `connection.requestRejection(req)`——Host/Origin 校验挡住 DNS rebinding 与跨站调用,浏览器令牌鉴权挡住没登录的本机网页;返回 401/403 时直接拒绝,不进入业务逻辑;
|
|
121
|
+
- 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不会退回只检查 Host、Origin 或回环地址;
|
|
122
|
+
- 请求体上限 64 KiB,超出返回 413。
|
|
123
|
+
|
|
115
124
|
## 子代理与等待
|
|
116
125
|
|
|
117
126
|
- 峰时开始时若会话有**正在运行的子代理**,先放行父会话与子代理;
|
package/client.js
CHANGED
|
@@ -231,7 +231,13 @@ window.__ModuleLoader__.load({
|
|
|
231
231
|
headers: { 'content-type': 'application/json' },
|
|
232
232
|
body: JSON.stringify(body),
|
|
233
233
|
});
|
|
234
|
-
const
|
|
234
|
+
const text = await response.text();
|
|
235
|
+
let json = {};
|
|
236
|
+
try {
|
|
237
|
+
json = text === '' ? {} : JSON.parse(text);
|
|
238
|
+
} catch {
|
|
239
|
+
json = {};
|
|
240
|
+
}
|
|
235
241
|
if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
|
|
236
242
|
if (sharedStatus.ctx !== null) await loadSharedStatus(sharedStatus.ctx);
|
|
237
243
|
return json;
|
|
@@ -480,7 +486,13 @@ window.__ModuleLoader__.load({
|
|
|
480
486
|
headers: { 'content-type': 'application/json' },
|
|
481
487
|
body: JSON.stringify(body),
|
|
482
488
|
});
|
|
483
|
-
const
|
|
489
|
+
const text = await response.text();
|
|
490
|
+
let json = {};
|
|
491
|
+
try {
|
|
492
|
+
json = text === '' ? {} : JSON.parse(text);
|
|
493
|
+
} catch {
|
|
494
|
+
json = {};
|
|
495
|
+
}
|
|
484
496
|
if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
|
|
485
497
|
await refresh();
|
|
486
498
|
} catch (caught) {
|
package/index.js
CHANGED
|
@@ -149,11 +149,25 @@ export function envConfig(env = process.env) {
|
|
|
149
149
|
return out;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** 设置接口只收几个字段的小 JSON,超过这个体积按可疑请求拒绝。 */
|
|
153
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
154
|
+
|
|
152
155
|
async function readJsonBody(req) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
const chunks = [];
|
|
157
|
+
let size = 0;
|
|
158
|
+
for await (const chunk of req) {
|
|
159
|
+
size += chunk?.byteLength ?? Buffer.byteLength(String(chunk));
|
|
160
|
+
if (size > MAX_BODY_BYTES) {
|
|
161
|
+
// 先排空再拒绝,让客户端读到响应而不是被一刀切断连接。
|
|
162
|
+
req.resume();
|
|
163
|
+
const error = new Error('request body too large');
|
|
164
|
+
error.statusCode = 413;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
chunks.push(chunk);
|
|
168
|
+
}
|
|
169
|
+
if (size === 0) return {};
|
|
170
|
+
return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
function sendJson(res, status, payload) {
|
|
@@ -164,6 +178,33 @@ function sendJson(res, status, payload) {
|
|
|
164
178
|
res.end(body);
|
|
165
179
|
}
|
|
166
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 设置路由的准入栅栏。
|
|
183
|
+
*
|
|
184
|
+
* `connection.requestRejection` 是宿主唯一的本机接口准入点:它同时做 Host/Origin
|
|
185
|
+
* 校验(挡 DNS rebinding 和跨站请求)与浏览器令牌鉴权(挡没登录的本机网页)。
|
|
186
|
+
* 没有它,本机任意网页都能改会话豁免、峰谷规则和暂停方式。
|
|
187
|
+
*
|
|
188
|
+
* 拿不到 `connection`、或它自己抛错时一律拒绝(失败关闭),不退回到只检查
|
|
189
|
+
* Host/Origin/回环地址——那条路挡不住别的网页。
|
|
190
|
+
*/
|
|
191
|
+
export function admissionRejection(ctx, req) {
|
|
192
|
+
let connection;
|
|
193
|
+
try {
|
|
194
|
+
connection = Reflect.get(ctx, 'connection');
|
|
195
|
+
} catch {
|
|
196
|
+
return 403;
|
|
197
|
+
}
|
|
198
|
+
if (connection === undefined || connection === null || typeof connection.requestRejection !== 'function') {
|
|
199
|
+
return 403;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return connection.requestRejection({ headers: req.headers }) ?? undefined;
|
|
203
|
+
} catch {
|
|
204
|
+
return 403;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
167
208
|
export function apply(ctx, rawConfig = {}) {
|
|
168
209
|
const config = { ...(rawConfig ?? {}), ...envConfig() };
|
|
169
210
|
const guard = createGuard(ctx, config);
|
|
@@ -197,15 +238,19 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
197
238
|
};
|
|
198
239
|
}, 'offpeak-guard: tools');
|
|
199
240
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
ctx.effect(
|
|
241
|
+
ctx.inject(['webServer', 'connection'], (webCtx) => {
|
|
242
|
+
webCtx.effect(
|
|
203
243
|
() =>
|
|
204
|
-
webServer.register({
|
|
244
|
+
webCtx.webServer.register({
|
|
205
245
|
kind: 'prefix',
|
|
206
246
|
path: '/plugins/dsh-offpeak-guard',
|
|
207
247
|
async handler(req, res) {
|
|
208
248
|
try {
|
|
249
|
+
const rejection = admissionRejection(webCtx, req);
|
|
250
|
+
if (rejection !== undefined) {
|
|
251
|
+
sendJson(res, rejection, { ok: false, error: 'unauthorized' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
209
254
|
const url = new URL(req.url || '/', 'http://x');
|
|
210
255
|
const rest = url.pathname.replace(/^\/plugins\/dsh-offpeak-guard/, '') || '/';
|
|
211
256
|
if (req.method !== 'POST' || rest !== '/action') {
|
|
@@ -235,11 +280,12 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
235
280
|
}
|
|
236
281
|
sendJson(res, 400, { ok: false, error: 'unknown action' });
|
|
237
282
|
} catch (error) {
|
|
238
|
-
|
|
283
|
+
const status = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
|
|
284
|
+
sendJson(res, status, { ok: false, error: String(error?.message ?? error) });
|
|
239
285
|
}
|
|
240
286
|
},
|
|
241
287
|
}),
|
|
242
288
|
'offpeak-guard: settings route',
|
|
243
289
|
);
|
|
244
|
-
}
|
|
290
|
+
});
|
|
245
291
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-offpeak-guard",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "DeepSeek peak/off-peak pricing guard for DSH: detects the price window and current model, pauses running tasks at peak, resumes them automatically when off-peak begins, and schedules work for the next off-peak window.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|