dsh-conversation-recycle-bin 0.3.3 → 0.3.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/CHANGELOG.md +16 -0
- package/README.md +6 -6
- package/client.js +2 -2
- package/lib/core/audit.js +46 -34
- package/lib/host/api.js +8 -2
- package/lib/host/feedback.js +8 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
All notable changes to `dsh-conversation-recycle-bin` are documented here.
|
|
4
4
|
The format follows Keep a Changelog; the project uses Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.3.4] — 2026-10-20
|
|
7
|
+
|
|
8
|
+
对应 `AUDIT-2026-10-20-last3-versions.md` 的四条非阻塞隐患 N1–N4,以及测试驱动下新发现的 P3 回归 N5。
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **N1:批量删除中途限流会静默丢尾项**:`deletePlan` 循环里 `!reservation.ok` 分支的 `break` 改为 `continue`。0.3.3 之前当第二 Host 在 preflight 与 take 之间抢走额度、或 `sessions.length > limiter.remaining`(跨进程 / 时间窗滑动)时,第一条被拒之后的所有 session 既不进 `results` 也不进 `failures`——一次 50 项批量 + 20/min 上限下,第 22–50 项从 UI 反馈里凭空消失。`continue` 让每一项各得一条 `E_RATE_LIMITED` 记录;首个项目预算不足直接抛 429 的既有契约不变。新增 `test/api.test.mjs` 用例:monkey-patch limiter 让 take 从第二次起即失败,断言 3 项批量 → 1 结果 + 2 失败。
|
|
13
|
+
- **N2:审计 `tail()` 字节窗口对齐到 `\n` 时丢一条完整记录**:`start > 0` 分支不再无条件 `rawLines.slice(1)`——先读 `start-1` 一字节,`\n` 意味着窗口从行首开始,`rawLines[0]` 是完整 JSON 保留;否则才丢。同时 `CHUNK` 抽为 `createAuditLog({ tailWindowBytes })` 便于回归。新增 `test/trash-audit.test.mjs` 用例:对齐窗口断言三条完整记录全在,偏移 1 字节窗口断言撕裂片段被正确丢弃。
|
|
14
|
+
- **N5(连带发现):`tail()` 从未真正做窗口读**:Node `fs.readFile(path, {encoding,flag,signal})` 不接受 `start/end`,0.3.3 P3「只读尾部 256KB」的实现其实每次仍全量读盘。改用 `fsApi.open(filePath, 'r')` + `FileHandle.read(buffer, offset, length, position)` 的位置化读,`stat` 也走 handle;`fsApi` 类型签名加 `open`。修复后 5MB 审计文件 tail 只吃 256KB 内存,与 P3 原意一致。
|
|
15
|
+
- **N3:HTTP 反馈通道对自定义鉴权头也强加 `Bearer ` 前缀**:`deliverHttp` 现只在 `authHeader === 'Authorization'` 时前缀 `Bearer `(RFC 7235 语法的合法要求),其它如 `X-Api-Key` / `x-goog-api-key` 直接发送裸 token——多数非 `Authorization` 中转拒绝 Bearer 前缀,此前配任何自定义头都会 401。默认 `Authorization` 通道逐字节不变。新增 `test/feedback.test.mjs` 双 case 用例(Bearer vs 裸 token)。
|
|
16
|
+
- **N4:`visitorAsFrom: true` 的 UI 提示未说明与常见发信方冲突**:`feedback.emailHintAsFrom` 词条中英各扩写为完整句子,说明「QQ / 163 / Gmail 一类要求 From 与登录账号一致会拒收,仅允许任意 From 的中继才生效」。行为不变、配置不变。
|
|
17
|
+
|
|
18
|
+
### Changed / Maintenance
|
|
19
|
+
|
|
20
|
+
- 回归测试:`api` 38 → 39、`feedback` 18 → 19、`trash-audit` 3 → 4;总 128 → **131 全绿**。
|
|
21
|
+
|
|
6
22
|
## [0.3.3] — 2026-10-19
|
|
7
23
|
|
|
8
24
|
本次修复对应 `AUDIT-2026-10-19-review.md` 的 S1–S3、P1–P5、R1–R4 与 M 项;每条均带回归测试。
|
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ dsh-conversation-recycle-bin/
|
|
|
190
190
|
│ ├─ memory.js # 适配器装配
|
|
191
191
|
│ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
|
|
192
192
|
│ └─ api.js # 同源 HTTP API、确认 token、限流
|
|
193
|
-
└─ test/ #
|
|
193
|
+
└─ test/ # 131 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
---
|
|
@@ -199,7 +199,7 @@ dsh-conversation-recycle-bin/
|
|
|
199
199
|
|
|
200
200
|
```powershell
|
|
201
201
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
202
|
-
npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(
|
|
202
|
+
npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(131 个用例)
|
|
203
203
|
npm run check # 语法 + manifest
|
|
204
204
|
```
|
|
205
205
|
|
|
@@ -258,8 +258,8 @@ npm run check # 语法 + manifest
|
|
|
258
258
|
| `DSH_CRB_FEEDBACK_TO` | 收件人(优先于 `feedback.to`) |
|
|
259
259
|
| `DSH_CRB_FEEDBACK_FROM` | 发件人(优先于 `feedback.from`) |
|
|
260
260
|
| `DSH_CRB_MAIL_API_URL` | 邮件 HTTP API 地址(Resend 兼容形状,也可指向自建转发网关) |
|
|
261
|
-
| `DSH_CRB_MAIL_API_TOKEN` | 该 API
|
|
262
|
-
| `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization
|
|
261
|
+
| `DSH_CRB_MAIL_API_TOKEN` | 该 API 的鉴权 token |
|
|
262
|
+
| `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`;`authorization` 时值形如 `Bearer <token>`(RFC 7235),其它自定义头(如 `x-api-key`)直接发送裸 token |
|
|
263
263
|
| `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
|
|
264
264
|
|
|
265
265
|
HTTP 通道发出的 JSON 固定为 `{from, to: [收件人], subject, text, reply_to}`,`text` 是纯文本(不生成 HTML);`reply_to` 取自表单里访客填写的邮箱(留空则不填)。SMTP 通道按需 `import('nodemailer')`:它已声明为 `optionalDependencies`(`^10.1.2`),正常安装会随包就位;以 `link:` 方式开发安装时插件从自身真实目录解析,需在**本仓库**跑一次 `npm install`。仍未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`。
|
|
@@ -287,8 +287,8 @@ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').to
|
|
|
287
287
|
```powershell
|
|
288
288
|
cd D:\Users\34332\AI\dsh-conversation-recycle-bin
|
|
289
289
|
npm run check # 语法 + manifest(含 feedback.js)
|
|
290
|
-
node test/feedback.test.mjs #
|
|
291
|
-
npm test # 全量
|
|
290
|
+
node test/feedback.test.mjs # 19 个反馈用例
|
|
291
|
+
npm test # 全量 131 个用例
|
|
292
292
|
|
|
293
293
|
# 端到端:用本机收件桩观察真实投递路径,不外发邮件
|
|
294
294
|
$env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
|
package/client.js
CHANGED
|
@@ -165,7 +165,7 @@ window.__ModuleLoader__.load({
|
|
|
165
165
|
'feedback.emailLabel': 'Your email (optional)',
|
|
166
166
|
'feedback.emailPlaceholder': 'you@example.com',
|
|
167
167
|
'feedback.emailHint': 'Used as Reply-To so the maintainer can answer you. Leave it empty to stay anonymous.',
|
|
168
|
-
'feedback.emailHintAsFrom': '
|
|
168
|
+
'feedback.emailHintAsFrom': 'Sent as the mail\'s From address. Most relays (QQ, 163, Gmail) reject this unless the mailbox matches the login account — enable only when the relay accepts arbitrary From.',
|
|
169
169
|
'feedback.contextLabel': 'Attached automatically',
|
|
170
170
|
'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
|
|
171
171
|
'feedback.contextVersion': 'Plugin version',
|
|
@@ -319,7 +319,7 @@ window.__ModuleLoader__.load({
|
|
|
319
319
|
'feedback.emailLabel': '你的邮箱(选填)',
|
|
320
320
|
'feedback.emailPlaceholder': 'you@example.com',
|
|
321
321
|
'feedback.emailHint': '作为 Reply-To,便于维护者直接回复你;留空即匿名。',
|
|
322
|
-
'feedback.emailHintAsFrom': '
|
|
322
|
+
'feedback.emailHintAsFrom': '会直接作为这封邮件的发件人(From)。QQ / 163 / Gmail 一类会拒收非登录账号的 From,只有允许任意 From 的中继才生效。',
|
|
323
323
|
'feedback.contextLabel': '自动附带',
|
|
324
324
|
'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
|
|
325
325
|
'feedback.contextVersion': '插件版本',
|
package/lib/core/audit.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* actor (the local Web UI), session ids, mode, the memory checkbox, per-adapter
|
|
12
12
|
* counts, and whether a failed transaction was rolled back.
|
|
13
13
|
*/
|
|
14
|
-
import { appendFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
15
15
|
import { dirname } from 'node:path'
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -19,10 +19,11 @@ import { dirname } from 'node:path'
|
|
|
19
19
|
* @param {string} options.filePath absolute path of the JSONL file
|
|
20
20
|
* @param {number} [options.maxBytes] compact threshold
|
|
21
21
|
* @param {number} [options.retentionLines] lines kept after compaction
|
|
22
|
+
* @param {number} [options.tailWindowBytes] byte window tail() reads from the end (default 256KB)
|
|
22
23
|
* @param {() => number} [options.now]
|
|
23
|
-
* @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'>} [options.fsApi]
|
|
24
|
+
* @param {Pick<typeof import('node:fs/promises'), 'readFile'|'writeFile'|'mkdir'|'appendFile'|'rename'|'stat'|'open'>} [options.fsApi]
|
|
24
25
|
*/
|
|
25
|
-
export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat } }) {
|
|
26
|
+
export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retentionLines = 5_000, tailWindowBytes = 256 * 1024, now = Date.now, fsApi = { readFile, writeFile, mkdir, appendFile, rename, stat, open } }) {
|
|
26
27
|
let pending = Promise.resolve()
|
|
27
28
|
|
|
28
29
|
function serialize(operation) {
|
|
@@ -77,48 +78,59 @@ export function createAuditLog({ filePath, maxBytes = 5 * 1024 * 1024, retention
|
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
80
|
* Read the most recent `limit` records; malformed lines are counted, not
|
|
80
|
-
* thrown. Only the tail window (
|
|
81
|
-
* page asks for the last N rows on every open, and a full
|
|
82
|
-
* on each request scaled with the whole trail, not with
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* thrown. Only the tail window (default 256KB) is read and parsed: the
|
|
82
|
+
* settings page asks for the last N rows on every open, and a full
|
|
83
|
+
* up-to-5MB parse on each request scaled with the whole trail, not with
|
|
84
|
+
* `limit`. `fs.readFile` does not honour `{start,end}`, so this uses a
|
|
85
|
+
* positional `FileHandle.read`; earlier revisions silently read the whole
|
|
86
|
+
* file while appearing to window. Malformed lines outside the window are
|
|
87
|
+
* not counted (the metric describes the displayed window).
|
|
85
88
|
*/
|
|
86
89
|
async tail(limit = 50) {
|
|
87
90
|
const maxRecords = Math.max(1, Math.floor(limit))
|
|
88
|
-
const CHUNK =
|
|
89
|
-
let
|
|
91
|
+
const CHUNK = Math.max(1, Math.floor(tailWindowBytes))
|
|
92
|
+
let handle
|
|
90
93
|
try {
|
|
91
|
-
|
|
94
|
+
handle = await fsApi.open(filePath, 'r')
|
|
92
95
|
} catch (error) {
|
|
93
96
|
if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
|
|
94
97
|
throw error
|
|
95
98
|
}
|
|
96
|
-
const start = Math.max(0, size - CHUNK)
|
|
97
|
-
let buffer
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
const statInfo = await handle.stat()
|
|
101
|
+
const size = statInfo.size
|
|
102
|
+
const start = Math.max(0, size - CHUNK)
|
|
103
|
+
const windowLength = size - start
|
|
104
|
+
const buffer = Buffer.alloc(windowLength)
|
|
105
|
+
const windowRead = await handle.read(buffer, 0, windowLength, start)
|
|
106
|
+
const text = buffer.subarray(0, windowRead.bytesRead).toString('utf8')
|
|
107
|
+
// `rawLines[0]` is only a torn fragment when the window starts
|
|
108
|
+
// *inside* a line. Read the byte at `start-1`: `\n` means the window
|
|
109
|
+
// begins on a line boundary and rawLines[0] is a complete record;
|
|
110
|
+
// unconditionally dropping it (pre-0.3.4) silently lost one valid
|
|
111
|
+
// record whenever the byte window happened to align with a newline.
|
|
112
|
+
let dropFirst = false
|
|
113
|
+
if (start > 0) {
|
|
114
|
+
const peek = Buffer.alloc(1)
|
|
115
|
+
const peekRead = await handle.read(peek, 0, 1, start - 1)
|
|
116
|
+
dropFirst = !(peekRead.bytesRead === 1 && peek[0] === 0x0a)
|
|
117
|
+
}
|
|
118
|
+
const rawLines = text.split('\n')
|
|
119
|
+
const lines = (dropFirst ? rawLines.slice(1) : rawLines)
|
|
120
|
+
.filter((line) => line.trim() !== '')
|
|
121
|
+
const records = []
|
|
122
|
+
let malformed = 0
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
try {
|
|
125
|
+
records.push(JSON.parse(line))
|
|
126
|
+
} catch {
|
|
127
|
+
malformed += 1
|
|
128
|
+
}
|
|
119
129
|
}
|
|
130
|
+
return { records: records.slice(-maxRecords), malformed }
|
|
131
|
+
} finally {
|
|
132
|
+
await handle.close()
|
|
120
133
|
}
|
|
121
|
-
return { records: records.slice(-maxRecords), malformed }
|
|
122
134
|
},
|
|
123
135
|
}
|
|
124
136
|
}
|
package/lib/host/api.js
CHANGED
|
@@ -634,7 +634,13 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
634
634
|
// Budget is consumed per actually-attempted deletion: a failed item must
|
|
635
635
|
// not silently spend the minute quota it never used. The very first item
|
|
636
636
|
// still refuses outright with 429 (the existing contract); once a plan
|
|
637
|
-
// has started, later exhausted items are recorded as failures
|
|
637
|
+
// has started, later exhausted items are recorded as failures.
|
|
638
|
+
//
|
|
639
|
+
// A refusal inside the loop uses `continue`, never `break`: `break` used
|
|
640
|
+
// to drop every session after the exhausted one from both `results` and
|
|
641
|
+
// `failures`, so a batch of 50 with a 20/min budget left items 22-50
|
|
642
|
+
// looking like the plan never mentioned them. Under the per-plan
|
|
643
|
+
// serialize this is still visible via `maxBatchSessions` > budget.
|
|
638
644
|
const request = requestId()
|
|
639
645
|
const results = []
|
|
640
646
|
const failures = []
|
|
@@ -647,7 +653,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
|
|
|
647
653
|
})
|
|
648
654
|
if (results.length === 0 && failures.length === 0) throw rateError
|
|
649
655
|
failures.push({ sessionId: session.id, error: rateError.toJSON() })
|
|
650
|
-
|
|
656
|
+
continue
|
|
651
657
|
}
|
|
652
658
|
if (!validated.record.used) markConfirmationUsed(validated)
|
|
653
659
|
try {
|
package/lib/host/feedback.js
CHANGED
|
@@ -338,7 +338,14 @@ export function createFeedbackService({ config, env = process.env, now = Date.no
|
|
|
338
338
|
throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
|
|
339
339
|
}
|
|
340
340
|
const headers = { 'content-type': 'application/json' }
|
|
341
|
-
if (settings.apiToken !== '')
|
|
341
|
+
if (settings.apiToken !== '') {
|
|
342
|
+
// `Authorization` is the one header whose RFC 7235 grammar mandates an
|
|
343
|
+
// auth-scheme token; a custom header (X-Api-Key, x-goog-api-key, …)
|
|
344
|
+
// expects the bare secret. Prefixing `Bearer` unconditionally 401'd
|
|
345
|
+
// every non-Authorization relay the user pointed us at.
|
|
346
|
+
const scheme = settings.authHeader.toLowerCase() === 'authorization' ? `Bearer ${settings.apiToken}` : settings.apiToken
|
|
347
|
+
headers[settings.authHeader.toLowerCase()] = scheme
|
|
348
|
+
}
|
|
342
349
|
const body = {
|
|
343
350
|
from: (settings.visitorAsFrom === true && submission.email) ? submission.email : (settings.from === '' ? undefined : settings.from),
|
|
344
351
|
to: [settings.to],
|
package/lib/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-conversation-recycle-bin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|