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 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/ # 122 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
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(122 个用例)
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 的 Bearer token |
262
- | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
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 # 17 个反馈用例
291
- npm test # 全量 122 个用例
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': 'Shown as the sender (From) of the mail.',
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 (≤256KB) is read and parsed: the settings
81
- * page asks for the last N rows on every open, and a full up-to-5MB parse
82
- * on each request scaled with the whole trail, not with `limit`. Malformed
83
- * lines outside the window are not counted (the metric describes the
84
- * displayed window).
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 = 256 * 1024
89
- let size = 0
91
+ const CHUNK = Math.max(1, Math.floor(tailWindowBytes))
92
+ let handle
90
93
  try {
91
- size = (await fsApi.stat(filePath)).size
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
- buffer = start > 0
100
- ? await fsApi.readFile(filePath, { start, end: size })
101
- : await fsApi.readFile(filePath, 'utf8')
102
- } catch (error) {
103
- if (error?.code === 'ENOENT') return { records: [], malformed: 0 }
104
- throw error
105
- }
106
- const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer)
107
- const rawLines = text.split('\n')
108
- // A chunk starts mid-line; drop the partial first line instead of
109
- // parsing a torn fragment as malformed.
110
- const lines = (start > 0 ? rawLines.slice(1) : rawLines)
111
- .filter((line) => line.trim() !== '')
112
- const records = []
113
- let malformed = 0
114
- for (const line of lines) {
115
- try {
116
- records.push(JSON.parse(line))
117
- } catch {
118
- malformed += 1
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 instead.
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
- break
656
+ continue
651
657
  }
652
658
  if (!validated.record.used) markConfirmationUsed(validated)
653
659
  try {
@@ -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 !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${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
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.3'
8
+ export const PLUGIN_VERSION = '0.3.4'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.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",