dsh-conversation-recycle-bin 0.2.2 → 0.3.0

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,21 @@
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.0] — 2026-10-18
7
+
8
+ ### Added
9
+
10
+ - **设置页「反馈」横幅(`feedback` 配置段 + `POST /conversation-recycle-bin/api/feedback`)**:设置页最上方新增一条「反馈」横幅(主色按钮,位于标题之上;`dc-feedback-bar`),表单含类型(问题 / 建议 / 其他)、标题、正文、可选联系方式,并自动附带插件版本、平台与语言(不含任何对话内容)。提交时按钮进入「正在发送…」禁用态,成功后弹 toast 并关闭对话框,失败按错误码给出可读原因。Host 侧 `lib/host/feedback.js` 负责校验、渲染与投递:标题 4–120 字、正文 10–4000 字、联系方式 ≤200 字(Host 复核,客户端只是提前禁用按钮);每条反馈每小时默认最多 5 条(`feedback.rateLimitPerHour`),全部从操作者自己的邮箱发出。
11
+ - **两种投递通道,均为环境变量注入凭证**:`http`(`DSH_CRB_MAIL_API_URL` + `DSH_CRB_MAIL_API_TOKEN`,POST JSON)与 `smtp`(`DSH_CRB_SMTP_URL`,按需 `import('nodemailer')`)。`nodemailer` 只声明为 `optionalDependencies`(`^10.1.2`),装不上不影响插件其余功能,缺失时明确返回 `reason: nodemailer-missing`。收件人/发件人由配置项 `feedback.to` / `feedback.from` 给出,**环境变量 `DSH_CRB_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_FROM` 优先于它们**(同一份包被多人/多机使用时不必改配置文件);`feedback.to` 默认即维护者收件箱,以 `b64:<base64>` 形式随包分发(避免公开 tarball 中出现可被正则抓取的 `name@domain`;属混淆而非加密,解码器在 `lib/host/feedback.js`,明文地址同样可用),`feedback.from` 默认留空并复用 SMTP 连接串里的用户名(服务商要求 From 与登录账号一致)。**任何凭证都不进配置文件、不进代码库**。
12
+ - **反馈通道与删除开关解耦**:`enabled:false` 只关闭删除;`/state.feedback` 单独报告 `enabled`、`transport`、`toConfigured`。通道未配置时设置页不渲染按钮,直接请求也返回 `503 E_FEEDBACK_DISABLED`(带 `reason: not-configured | disabled | off`),而不是在点击后才失败。
13
+ - 新增错误码 `E_FEEDBACK_DISABLED`(503) / `E_FEEDBACK_INVALID`(400) / `E_FEEDBACK_RATE_LIMITED`(429) / `E_FEEDBACK_TRANSPORT`(502);投递失败只记错误码与 HTTP 状态,**正文、标题、联系方式不进 Host 日志**。
14
+ - **收件地址以 `b64:<base64>` 打包**:默认收件箱不再以明文 `name@domain` 出现在 `cordis.patch.yml` / `config.schema.json` / README / CHANGELOG 中,避免公开 tarball 被正则抓取。这是**混淆而非加密**——解码器 `decodeAddress()` 就在 `lib/host/feedback.js` 里,拿到包的人总能还原;明文地址与环境变量照旧可用。
15
+
16
+ ### Changed
17
+
18
+ - `npm test` 增加 `test/feedback.test.mjs`(15 个用例:配置归一化、通道判定、校验/渲染、HTTP 通路、失败状态、限流窗口、未配置、SMTP 缺依赖、两条路由用例),用例总数 106 → 121;`npm run check` 增加 `lib/host/feedback.js` 语法检查。
19
+ - `cordis.patch.yml` / `config.schema.json` 增加 `feedback` 默认段(`to` 默认为维护者公开反馈邮箱、`from` 留空、`transport: auto`、`rateLimitPerHour: 5`)。
20
+
6
21
  ## [0.2.2] — 2026-10-18
7
22
 
8
23
  对 0.2.1 的双环境(桌面/网页)全面审计修复轮。问题编号对应 `AUDIT-2026-10-17-desktop-web.md`;每个问题在该文档中都有平台、复现条件、原因与修复方向的完整记录。
package/README.md CHANGED
@@ -188,8 +188,9 @@ dsh-conversation-recycle-bin/
188
188
  │ ├─ catalog.js # archive/detach/pin 与日志移动
189
189
  │ ├─ lifecycle.js # live 会话停机:cancel + archive + drain
190
190
  │ ├─ memory.js # 适配器装配
191
+ │ ├─ feedback.js # 反馈校验/渲染/投递(http 或 smtp,凭证只读环境变量)
191
192
  │ └─ api.js # 同源 HTTP API、确认 token、限流
192
- └─ test/ # 104 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + Client 半渲染冒烟
193
+ └─ test/ # 121 个用例,覆盖用户要求的 5 类场景 + live 删除 + 记忆锁 + 原地刷新 + 部分失败/跨进程/未完成条目 + 反馈通道 + Client 半渲染冒烟
193
194
  ```
194
195
 
195
196
  ---
@@ -198,7 +199,7 @@ dsh-conversation-recycle-bin/
198
199
 
199
200
  ```powershell
200
201
  cd D:\Users\34332\AI\dsh-conversation-recycle-bin
201
- npm test # = manifest && client-module && core && cascade && memory && trash-audit && api(104 个用例)
202
+ npm test # = manifest && client-module && core && cascade && memory && trash-audit && feedback && api(121 个用例)
202
203
  npm run check # 语法 + manifest
203
204
  ```
204
205
 
@@ -226,3 +227,87 @@ npm run check # 语法 + manifest
226
227
  5. UI 依赖当前版本的 slot 约定;宿主升级若改 slot props,入口会失效但不会破坏宿主。0.1.4 的原地刷新还会调用 `uiWorkspace.clearArchivedCurrent/clearMain`(宿主 `.d.ts` 中标为 private);两者都不存在时会退回整页刷新,不会留下过期视图,但属于对私有方法的依赖,宿主升级需复核。
227
228
  6. 回收站 manifest 的并发写由 `<dataDir>/mutation.lock` 跨进程锁保护(抢不到锁返回 `E_LOCK_BUSY`);但**会话日志内容**的跨进程写保护仍是 best-effort:`foreignWriterGraceMs` 只能发现"窗口内仍在增长"的日志,另一个 Host 的空闲 live 会话(两次写入之间)无法被本进程识别。桌面端与 `dsh web` 同时运行时,仍应先关闭另一端的会话再彻底删除。
228
229
  7. 未完成条目(failed/pending/restoring/purging)若仍持有已移动的会话日志或记忆文件,出于数据安全不允许 `discard`(会报 `E_TRASH_CONFLICT`);设置页对这类条目提供「恢复」按钮,可直接恢复回会话列表,不再出现"失败了却没有出口"的死条目。日志与记忆都已不在磁盘上的条目才可以 discard。
230
+
231
+ ---
232
+
233
+ ## 9. 反馈通道(设置页「反馈」按钮)
234
+
235
+ 设置页**最上方**新增一条「反馈」横幅(主色按钮,位于标题之上):把问题或建议写成一条消息,由**宿主进程**通过邮件发到维护者邮箱。凭证只在宿主侧读取,页面拿不到、也发不出任何邮件。
236
+
237
+ ### 9.1 为什么是「页面 → 宿主路由 → 邮件」三段
238
+
239
+ 浏览器既不能开 SMTP 连接,也不该持有邮件服务凭证。因此 `client.js` 只负责表单与校验提示,`POST /conversation-recycle-bin/api/feedback` 由 `lib/host/feedback.js` 校验、渲染并投递。表单里的「类型 / 标题 / 正文 / 联系方式」之外,只额外附带**对话框里已展示给用户**的上下文(插件版本、平台、语言);对话内容、会话 id、工作区路径都不会进入邮件。
240
+
241
+ ### 9.2 配置项(`feedback` 段,非机密)
242
+
243
+ | 键 | 默认 | 说明 |
244
+ |---|---|---|
245
+ | `feedback.enabled` | `true` | 是否允许该通道。通道本身没配好时按钮依旧不显示 |
246
+ | `feedback.to` | `b64:…`(维护者收件箱) | 收件人。默认值以 `b64:<base64>` 形式携带,公开 tarball 里没有可直接抓取的 `name@domain` 串(这是**混淆,不是加密**:解码器就在 `lib/host/feedback.js`,拿到包的人总能解出来)。也接受明文地址;`DSH_CRB_FEEDBACK_TO` 优先于它 |
247
+ | `feedback.from` | `''` | 发件人。留空时 SMTP 通道复用连接串里的用户名(服务商通常要求 From 与登录账号一致);`DSH_CRB_FEEDBACK_FROM` 优先于它 |
248
+ | `feedback.transport` | `auto` | `auto` = 有 `DSH_CRB_MAIL_API_URL` 走 http,否则有 `DSH_CRB_SMTP_URL` 走 smtp;`http`/`smtp` 强制单通道(配不全就报未配置,不会静默换道);`off` 关闭 |
249
+ | `feedback.rateLimitPerHour` | `5` | 每宿主每小时发送上限(1–100)。邮件从操作者自己的账号发出,默认值刻意压低 |
250
+
251
+ ### 9.3 环境变量(优先级高于配置项;凭证只走这里,不进配置文件)
252
+
253
+ > 冲突时**环境变量赢**:`DSH_CRB_FEEDBACK_TO` 覆盖 `feedback.to`,`DSH_CRB_FEEDBACK_FROM` 覆盖 `feedback.from`。这样多台机器/多人共用一个 package 时,各自改环境变量即可,不必改配置。
254
+
255
+ | 变量 | 用途 |
256
+ |---|---|
257
+ | `DSH_CRB_FEEDBACK_TO` | 收件人(优先于 `feedback.to`) |
258
+ | `DSH_CRB_FEEDBACK_FROM` | 发件人(优先于 `feedback.from`) |
259
+ | `DSH_CRB_MAIL_API_URL` | 邮件 HTTP API 地址(Resend 兼容形状,也可指向自建转发网关) |
260
+ | `DSH_CRB_MAIL_API_TOKEN` | 该 API 的 Bearer token |
261
+ | `DSH_CRB_MAIL_API_AUTH_HEADER` | 可选,鉴权头名,默认 `authorization`,值为 `Bearer <token>` |
262
+ | `DSH_CRB_SMTP_URL` | SMTP 连接串,如 `smtps://user:pass@smtp.example.com:465` |
263
+
264
+ 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`。
265
+
266
+ #### 9.3.1 QQ 邮箱开通示例
267
+
268
+ QQ 邮箱不能直接用登录密码发信,要先取**授权码**(设置 → 账号 → POP3/SMTP 服务 → 开启 → 生成授权码),再把两件事写进环境变量(`@` 在连接串里必须写成 `%40`;`<你的QQ号>` 换成实际号码):
269
+
270
+ ```powershell
271
+ setx DSH_CRB_SMTP_URL 'smtps://<你的QQ号>%40qq.com:你的授权码@smtp.qq.com:465'
272
+ # 可选:换收件人/发件人(不设就用包内默认的维护者收件箱)
273
+ setx DSH_CRB_FEEDBACK_TO 'your-feedback@example.com'
274
+ # 设置后重启 DSH 桌面端(setx 只对新进程生效)
275
+ ```
276
+
277
+ 需要自己生成一个 `b64:` 地址(放进 profile patch 而不是环境变量时):
278
+
279
+ ```powershell
280
+ node -e "console.log('b64:' + Buffer.from('your-feedback@example.com','utf8').toString('base64'))"
281
+ ```
282
+
283
+
284
+ ### 9.4 本地验证
285
+
286
+ ```powershell
287
+ cd D:\Users\34332\AI\dsh-conversation-recycle-bin
288
+ npm run check # 语法 + manifest(含 feedback.js)
289
+ node test/feedback.test.mjs # 15 个反馈用例
290
+ npm test # 全量 121 个用例
291
+
292
+ # 端到端:用本机收件桩观察真实投递路径,不外发邮件
293
+ $env:DSH_CRB_MAIL_API_URL = 'http://127.0.0.1:9099/emails'
294
+ $env:DSH_CRB_MAIL_API_TOKEN = 'dev-token'
295
+ $env:DSH_CRB_FEEDBACK_TO = 'feedback@example.test'
296
+ # 另一个终端起桩:
297
+ # node -e "require('http').createServer((q,s)=>{let b='';q.on('data',c=>b+=c);q.on('end',()=>{console.log(q.method,q.url,b);s.end('{}')})}).listen(9099)"
298
+ ```
299
+
300
+ 起桩后打开设置页点「反馈」,填表发送:桩终端应打印 `POST /emails {...}`,页面出现「反馈已发送,谢谢!」。测 SMTP 通路时改设 `DSH_CRB_SMTP_URL`(本仓库已按 `optionalDependencies` 声明 `nodemailer`,先在本仓库 `npm install` 一次;未安装时返回 `E_FEEDBACK_TRANSPORT` + `reason: nodemailer-missing`,这是预期行为)。
301
+
302
+ ### 9.5 失败排查
303
+
304
+ | 现象 | 原因 | 处理 |
305
+ |---|---|---|
306
+ | 设置页没有「反馈」按钮 | 通道未配置(`/state.feedback.enabled === false`) | 设好 `DSH_CRB_MAIL_API_URL` + token,或 `DSH_CRB_SMTP_URL`;确认 `feedback.transport` 不是 `off`、`enabled` 不是 `false` |
307
+ | 点击后 503 `E_FEEDBACK_DISABLED` | 同上一行(按钮来自旧页面渲染) | 刷新页面;看 `/state.feedback.reason`:`not-configured` / `disabled` / `off` |
308
+ | 400 `E_FEEDBACK_INVALID` | 标题 4–120 字、正文 10–4000 字、联系方式 ≤200 字不满足 | `details.field` 指明字段,`details.min`/`details.max` 给出边界 |
309
+ | 429 `E_FEEDBACK_RATE_LIMITED` | 超过 `feedback.rateLimitPerHour` | 等 `details.retryAfterMs` 后重试,或调高上限 |
310
+ | 502 `E_FEEDBACK_TRANSPORT` | 邮件服务返回非 2xx / 网络失败 / 未装 nodemailer / 暴露不出 `createTransport` | 看宿主日志 `[conversation-recycle-bin] feedback delivery failed (<transport>): <code> <message>`(只含错误码与通道,不含正文);核对 URL、token、SMTP 授权码 |
311
+ | 接口 200 但收件箱没有 | 服务端已受理,但被对方退信或进了垃圾箱 | 查发件账号退信;`feedback.from` 用与 SMTP 账号一致的地址,并检查收件人域名 |
312
+
313
+ > 隐私边界:邮件正文 = 用户输入 + 对话框里已展示的上下文;宿主日志只记错误码、通道与耗时。删除开关(`enabled`)与反馈通道互相独立——关掉删除不影响反馈,反之亦然。
package/client.js CHANGED
@@ -147,6 +147,36 @@ window.__ModuleLoader__.load({
147
147
  'state.unavailable': 'unavailable',
148
148
  'state.rate': '{used}/{limit} deletions this minute',
149
149
  'state.batch': 'Effective batch limit: {limit}',
150
+ 'feedback.button': 'Feedback',
151
+ 'feedback.bannerTitle': 'Ran into a problem or have an idea?',
152
+ 'feedback.bannerHint': 'Send one email to the maintainer. It carries your text plus the plugin version, platform and language — no conversation content.',
153
+ 'feedback.title': 'Send feedback',
154
+ 'feedback.intro': 'Tell the maintainer what went wrong or what you would like to see. The message is sent by email from this Host; nothing is uploaded anywhere else.',
155
+ 'feedback.categoryLabel': 'Type',
156
+ 'feedback.categoryBug': 'Problem / bug',
157
+ 'feedback.categorySuggestion': 'Suggestion',
158
+ 'feedback.categoryOther': 'Other',
159
+ 'feedback.titleLabel': 'Title',
160
+ 'feedback.titlePlaceholder': 'One line that summarises it',
161
+ 'feedback.bodyLabel': 'Message',
162
+ 'feedback.bodyPlaceholder': 'What happened, what you expected, and the steps that led to it.',
163
+ 'feedback.contactLabel': 'Contact (optional)',
164
+ 'feedback.contactPlaceholder': 'An email address or another way to reach you',
165
+ 'feedback.contextLabel': 'Attached automatically',
166
+ 'feedback.contextHint': 'Plugin version, platform and language. Conversation content is never attached.',
167
+ 'feedback.send': 'Send',
168
+ 'feedback.sending': 'Sending…',
169
+ 'feedback.sent': 'Feedback sent. Thank you!',
170
+ 'feedback.tooShortTitle': 'The title needs at least {min} characters.',
171
+ 'feedback.tooLongTitle': 'The title may be at most {max} characters.',
172
+ 'feedback.tooShortBody': 'The message needs at least {min} characters.',
173
+ 'feedback.tooLongBody': 'The message may be at most {max} characters.',
174
+ 'feedback.tooLongContact': 'The contact field may be at most {max} characters.',
175
+ 'feedback.unavailable': 'The feedback channel is not configured on this Host.',
176
+ 'error.E_FEEDBACK_DISABLED': 'The feedback channel is not configured on this Host.',
177
+ 'error.E_FEEDBACK_INVALID': 'The feedback form is incomplete or too long.',
178
+ 'error.E_FEEDBACK_RATE_LIMITED': 'Several messages were sent recently; try again a little later.',
179
+ 'error.E_FEEDBACK_TRANSPORT': 'The message could not be delivered. Check the Host mail configuration and retry.',
150
180
  'error.E_CONFIRM_PHRASE': 'The confirmation phrase does not match.',
151
181
  'error.E_CONFIRM_EXPIRED': 'The confirmation expired; the counts were reloaded.',
152
182
  'error.E_CONFIRM_INVALID': 'The confirmation is stale; the plan was reloaded.',
@@ -263,6 +293,36 @@ window.__ModuleLoader__.load({
263
293
  'state.unavailable': '不可用',
264
294
  'state.rate': '本分钟已删除 {used}/{limit}',
265
295
  'state.batch': '单次上限:{limit} 个(受每分钟额度约束)',
296
+ 'feedback.button': '反馈',
297
+ 'feedback.bannerTitle': '遇到问题,或有改进建议?',
298
+ 'feedback.bannerHint': '写一条消息发给维护者,正文之外只附带插件版本、平台与语言,不含任何对话内容。',
299
+ 'feedback.title': '发送反馈',
300
+ 'feedback.intro': '把遇到的问题或想要的功能告诉维护者。消息由本机宿主通过邮件发送,不会上传到其他位置。',
301
+ 'feedback.categoryLabel': '类型',
302
+ 'feedback.categoryBug': '问题 / Bug',
303
+ 'feedback.categorySuggestion': '建议',
304
+ 'feedback.categoryOther': '其他',
305
+ 'feedback.titleLabel': '标题',
306
+ 'feedback.titlePlaceholder': '用一句话概括',
307
+ 'feedback.bodyLabel': '正文',
308
+ 'feedback.bodyPlaceholder': '发生了什么、预期是什么、如何复现。',
309
+ 'feedback.contactLabel': '联系方式(选填)',
310
+ 'feedback.contactPlaceholder': '邮箱或其他能找到你的方式',
311
+ 'feedback.contextLabel': '自动附带',
312
+ 'feedback.contextHint': '插件版本、平台与语言;不会附带任何对话内容。',
313
+ 'feedback.send': '发送',
314
+ 'feedback.sending': '正在发送…',
315
+ 'feedback.sent': '反馈已发送,谢谢!',
316
+ 'feedback.tooShortTitle': '标题至少需要 {min} 个字符。',
317
+ 'feedback.tooLongTitle': '标题最多 {max} 个字符。',
318
+ 'feedback.tooShortBody': '正文至少需要 {min} 个字符。',
319
+ 'feedback.tooLongBody': '正文最多 {max} 个字符。',
320
+ 'feedback.tooLongContact': '联系方式最多 {max} 个字符。',
321
+ 'feedback.unavailable': '当前宿主未配置反馈通道。',
322
+ 'error.E_FEEDBACK_DISABLED': '当前宿主未配置反馈通道。',
323
+ 'error.E_FEEDBACK_INVALID': '反馈内容不完整或超出长度限制。',
324
+ 'error.E_FEEDBACK_RATE_LIMITED': '最近已发送多条反馈,请稍后再试。',
325
+ 'error.E_FEEDBACK_TRANSPORT': '邮件发送失败,请检查宿主的邮件配置后重试。',
266
326
  'error.E_CONFIRM_PHRASE': '确认短语不匹配。',
267
327
  'error.E_CONFIRM_EXPIRED': '确认已过期;计数已重新加载。',
268
328
  'error.E_CONFIRM_INVALID': '确认已失效;计划已重新加载。',
@@ -696,6 +756,16 @@ window.__ModuleLoader__.load({
696
756
  h('path', { d: 'M20 4v5h-5' }))
697
757
  }
698
758
 
759
+ function GlyphFeedback({ size = 14 }) {
760
+ return h('svg', {
761
+ width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
762
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
763
+ 'aria-hidden': 'true',
764
+ },
765
+ h('path', { d: 'M4 6h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z' }),
766
+ h('path', { d: 'm3.5 7 8.5 6 8.5-6' }))
767
+ }
768
+
699
769
  function Button({ children, onClick, variant = 'secondary', disabled = false, type = 'button', title }) {
700
770
  return h('button', {
701
771
  type,
@@ -746,6 +816,48 @@ window.__ModuleLoader__.load({
746
816
  }
747
817
  }
748
818
 
819
+ /* ------------------------------------------------------------------ *
820
+ * Feedback form (pure helpers, shared with the Host's validation)
821
+ * ------------------------------------------------------------------ */
822
+
823
+ // Mirrors FEEDBACK_LIMITS in `lib/host/feedback.js`. The Host re-validates
824
+ // every field; this copy only exists to disable the submit button early and
825
+ // to point at the offending input.
826
+ const FEEDBACK_LIMITS = Object.freeze({
827
+ titleMin: 4,
828
+ titleMax: 120,
829
+ bodyMin: 10,
830
+ bodyMax: 4_000,
831
+ contactMax: 200,
832
+ })
833
+
834
+ /**
835
+ * @param {{title?: string, body?: string, contact?: string}} input
836
+ * @returns {{ok: true} | {ok: false, key: string, vars: Record<string, number>}}
837
+ */
838
+ function validateFeedbackInput(input) {
839
+ const title = String(input?.title ?? '').trim()
840
+ const body = String(input?.body ?? '').trim()
841
+ const contact = String(input?.contact ?? '').trim()
842
+ if (title.length < FEEDBACK_LIMITS.titleMin) return { ok: false, key: 'feedback.tooShortTitle', vars: { min: FEEDBACK_LIMITS.titleMin } }
843
+ if (title.length > FEEDBACK_LIMITS.titleMax) return { ok: false, key: 'feedback.tooLongTitle', vars: { max: FEEDBACK_LIMITS.titleMax } }
844
+ if (body.length < FEEDBACK_LIMITS.bodyMin) return { ok: false, key: 'feedback.tooShortBody', vars: { min: FEEDBACK_LIMITS.bodyMin } }
845
+ if (body.length > FEEDBACK_LIMITS.bodyMax) return { ok: false, key: 'feedback.tooLongBody', vars: { max: FEEDBACK_LIMITS.bodyMax } }
846
+ if (contact.length > FEEDBACK_LIMITS.contactMax) return { ok: false, key: 'feedback.tooLongContact', vars: { max: FEEDBACK_LIMITS.contactMax } }
847
+ return { ok: true }
848
+ }
849
+
850
+ /** Context the dialog shows before sending; nothing here is user content. */
851
+ function collectFeedbackContext() {
852
+ const userAgent = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
853
+ ? navigator.userAgent
854
+ : ''
855
+ return {
856
+ platform: userAgent.slice(0, 160),
857
+ locale: currentLocaleId(),
858
+ }
859
+ }
860
+
749
861
  /* ------------------------------------------------------------------ *
750
862
  * Confirmation dialog
751
863
  * ------------------------------------------------------------------ */
@@ -994,6 +1106,155 @@ window.__ModuleLoader__.load({
994
1106
  return store === undefined ? 'not detected' : adapterReason(store.reason)
995
1107
  }
996
1108
 
1109
+ /* ------------------------------------------------------------------ *
1110
+ * Feedback dialog
1111
+ * ------------------------------------------------------------------ */
1112
+
1113
+ /**
1114
+ * Settings page "反馈" button.
1115
+ *
1116
+ * The Host performs the delivery, so the page never holds a mail
1117
+ * credential. The auto-attached context is rendered BEFORE the user
1118
+ * commits, so nothing leaves the machine that they did not see.
1119
+ */
1120
+ function FeedbackDialog({ payload }) {
1121
+ useLocaleTick()
1122
+ const feedback = payload?.feedback ?? null
1123
+ const version = typeof payload?.version === 'string' ? payload.version : ''
1124
+ const [category, setCategory] = useState('bug')
1125
+ const [title, setTitle] = useState('')
1126
+ const [body, setBody] = useState('')
1127
+ const [contact, setContact] = useState('')
1128
+ const [busy, setBusy] = useState(false)
1129
+ const [error, setError] = useState(null)
1130
+ const validation = validateFeedbackInput({ title, body, contact })
1131
+ const context = collectFeedbackContext()
1132
+ // A Host without a mail transport advertises `enabled: false`; the form is
1133
+ // still rendered read-only so the explanation is visible instead of a
1134
+ // button that fails on click.
1135
+ const unavailable = feedback?.enabled !== true
1136
+
1137
+ useEffect(() => {
1138
+ function onKey(event) {
1139
+ if (event.key === 'Escape' && busy !== true) dialogStore.close()
1140
+ }
1141
+ window.addEventListener('keydown', onKey)
1142
+ return () => window.removeEventListener('keydown', onKey)
1143
+ }, [busy])
1144
+
1145
+ async function submit() {
1146
+ if (busy || unavailable || validation.ok !== true) return
1147
+ setBusy(true)
1148
+ setError(null)
1149
+ try {
1150
+ await api('/feedback', {
1151
+ method: 'POST',
1152
+ timeoutMs: 30_000,
1153
+ body: {
1154
+ category,
1155
+ title: title.trim(),
1156
+ body: body.trim(),
1157
+ contact: contact.trim(),
1158
+ context: {
1159
+ pluginVersion: version,
1160
+ platform: context.platform,
1161
+ locale: context.locale,
1162
+ },
1163
+ },
1164
+ })
1165
+ flashStore.show(t('feedback.sent'))
1166
+ dialogStore.close()
1167
+ } catch (caught) {
1168
+ // The Host log already records the stable code; this keeps the form
1169
+ // path debuggable from the browser console without a network tab.
1170
+ console.error('[conversation-recycle-bin] feedback submission failed:', caught?.code ?? caught)
1171
+ setError(caught)
1172
+ } finally {
1173
+ setBusy(false)
1174
+ }
1175
+ }
1176
+
1177
+ return h('div', {
1178
+ className: 'dc-backdrop',
1179
+ onMouseDown: (event) => {
1180
+ if (event.target === event.currentTarget && busy !== true) dialogStore.close()
1181
+ },
1182
+ },
1183
+ h('div', { className: 'dc-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'dc-feedback-title' },
1184
+ h('h2', { id: 'dc-feedback-title', className: 'dc-dialog-title' }, t('feedback.title')),
1185
+ h('p', { className: 'dc-dialog-body' }, t('feedback.intro')),
1186
+ unavailable ? h('div', { className: 'dc-error', role: 'alert' }, t('feedback.unavailable')) : null,
1187
+ h(ErrorBanner, { error }),
1188
+
1189
+ h('label', { className: 'dc-field' },
1190
+ h('span', { className: 'dc-field-label' }, t('feedback.categoryLabel')),
1191
+ h('select', {
1192
+ className: 'dc-input',
1193
+ value: category,
1194
+ disabled: busy,
1195
+ onChange: (event) => setCategory(event.target.value),
1196
+ },
1197
+ h('option', { value: 'bug' }, t('feedback.categoryBug')),
1198
+ h('option', { value: 'suggestion' }, t('feedback.categorySuggestion')),
1199
+ h('option', { value: 'other' }, t('feedback.categoryOther')))),
1200
+
1201
+ h('label', { className: 'dc-field' },
1202
+ h('span', { className: 'dc-field-label' }, t('feedback.titleLabel')),
1203
+ h('input', {
1204
+ type: 'text',
1205
+ className: 'dc-input',
1206
+ value: title,
1207
+ maxLength: FEEDBACK_LIMITS.titleMax,
1208
+ placeholder: t('feedback.titlePlaceholder'),
1209
+ disabled: busy,
1210
+ autoFocus: true,
1211
+ onChange: (event) => setTitle(event.target.value),
1212
+ })),
1213
+
1214
+ h('label', { className: 'dc-field' },
1215
+ h('span', { className: 'dc-field-label' }, t('feedback.bodyLabel')),
1216
+ h('textarea', {
1217
+ className: 'dc-input dc-textarea',
1218
+ value: body,
1219
+ rows: 6,
1220
+ maxLength: FEEDBACK_LIMITS.bodyMax,
1221
+ placeholder: t('feedback.bodyPlaceholder'),
1222
+ disabled: busy,
1223
+ onChange: (event) => setBody(event.target.value),
1224
+ })),
1225
+
1226
+ h('label', { className: 'dc-field' },
1227
+ h('span', { className: 'dc-field-label' }, t('feedback.contactLabel')),
1228
+ h('input', {
1229
+ type: 'text',
1230
+ className: 'dc-input',
1231
+ value: contact,
1232
+ maxLength: FEEDBACK_LIMITS.contactMax,
1233
+ placeholder: t('feedback.contactPlaceholder'),
1234
+ disabled: busy,
1235
+ onChange: (event) => setContact(event.target.value),
1236
+ })),
1237
+
1238
+ h('div', { className: 'dc-context' },
1239
+ h('div', { className: 'dc-context-title' }, t('feedback.contextLabel')),
1240
+ h('div', { className: 'dc-hint' }, t('feedback.contextHint')),
1241
+ h('ul', { className: 'dc-context-list' },
1242
+ h('li', null, `plugin: ${version === '' ? '—' : version}`),
1243
+ h('li', null, `platform: ${context.platform === '' ? '—' : context.platform}`),
1244
+ h('li', null, `locale: ${context.locale}`))),
1245
+
1246
+ validation.ok !== true ? h('div', { className: 'dc-hint' }, t(validation.key, validation.vars)) : null,
1247
+ busy ? h(Spinner, { label: t('feedback.sending') }) : null,
1248
+
1249
+ h('div', { className: 'dc-actions' },
1250
+ h(Button, { onClick: () => dialogStore.close(), disabled: busy }, t('action.cancel')),
1251
+ h(Button, {
1252
+ variant: 'primary',
1253
+ onClick: () => { void submit() },
1254
+ disabled: busy || unavailable || validation.ok !== true,
1255
+ }, busy ? t('feedback.sending') : t('feedback.send')))))
1256
+ }
1257
+
997
1258
  /* ------------------------------------------------------------------ *
998
1259
  * Dialog host / overlay entry
999
1260
  * ------------------------------------------------------------------ */
@@ -1014,8 +1275,15 @@ window.__ModuleLoader__.load({
1014
1275
  function DialogHost() {
1015
1276
  const payload = useDialogState()
1016
1277
  const flash = useFlashMessage()
1278
+ // `kind` is optional: every pre-0.2.2 caller opens a delete dialog, so a
1279
+ // payload without a kind keeps its old meaning.
1280
+ const dialog = payload === null
1281
+ ? null
1282
+ : payload.kind === 'feedback'
1283
+ ? h(FeedbackDialog, { payload, key: payload.key ?? 'feedback' })
1284
+ : h(DeleteDialog, { payload, key: payload.key ?? (payload.sessionIds ?? []).join(',') })
1017
1285
  const content = h(React.Fragment, null,
1018
- payload === null ? null : h(DeleteDialog, { payload, key: payload.key ?? payload.sessionIds.join(',') }),
1286
+ dialog,
1019
1287
  flash === null ? null : h('div', { className: 'dc-toast', role: 'status' }, flash))
1020
1288
  return portalIntoBody(content)
1021
1289
  }
@@ -1294,13 +1562,34 @@ window.__ModuleLoader__.load({
1294
1562
  : (state?.limits?.maxBatchSessions ?? 0)
1295
1563
  const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
1296
1564
 
1565
+ const openFeedback = () => dialogStore.open({
1566
+ kind: 'feedback',
1567
+ key: 'feedback',
1568
+ version: typeof state?.version === 'string' ? state.version : '',
1569
+ feedback: state?.feedback ?? null,
1570
+ })
1571
+
1297
1572
  return h('div', { className: 'dc-root' },
1573
+ // Top-of-section call to action, above the title: it is the first thing
1574
+ // a user unsure about the plugin should see. Rendered only when the Host
1575
+ // reports a configured transport, so a profile without mail settings
1576
+ // never shows a button that cannot work (the dialog would otherwise
1577
+ // explain a failure to send).
1578
+ state?.feedback?.enabled === true
1579
+ ? h('div', { className: 'dc-feedback-bar' },
1580
+ h('div', { className: 'dc-feedback-bar-text' },
1581
+ h('div', { className: 'dc-feedback-bar-title' }, t('feedback.bannerTitle')),
1582
+ h('div', { className: 'dc-feedback-bar-hint' }, t('feedback.bannerHint'))),
1583
+ h(Button, { variant: 'primary', title: t('feedback.button'), onClick: openFeedback },
1584
+ h(GlyphFeedback, { size: 14 }), ' ', t('feedback.button')))
1585
+ : null,
1298
1586
  h('div', { className: 'dc-header' },
1299
1587
  h('div', null,
1300
1588
  h('h1', { className: 'dc-title' }, t('section.title')),
1301
1589
  h('p', { className: 'dc-hint' }, t('section.subtitle'))),
1302
- h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
1303
- h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh'))),
1590
+ h('div', { className: 'dc-header-actions' },
1591
+ h(Button, { onClick: () => { void refresh() }, disabled: loading, title: t('action.refresh') },
1592
+ h(GlyphRefresh, { size: 13 }), ' ', t('action.refresh')))),
1304
1593
 
1305
1594
  h(ErrorBanner, { error }),
1306
1595
 
@@ -1436,6 +1725,13 @@ window.__ModuleLoader__.load({
1436
1725
  .dc-phrase-label{display:block;font-size:12px;color:var(--dsw-alias-state-warn-primary,#d46b08);margin-bottom:5px;line-height:1.5}
1437
1726
  .dc-input{width:100%;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);border-radius:7px;background:var(--dsw-alias-bg-base,#fff);color:inherit;font:inherit;font-size:13px}
1438
1727
  .dc-input-small{width:90px;height:28px}
1728
+ .dc-header-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
1729
+ .dc-field{display:flex;flex-direction:column;gap:5px;margin:10px 0}
1730
+ .dc-field-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2329)}
1731
+ .dc-textarea{height:auto;min-height:96px;padding:8px 9px;line-height:1.55;resize:vertical}
1732
+ .dc-context{margin:12px 0 4px;padding:9px 10px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:8px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
1733
+ .dc-context-title{font-size:12px;font-weight:600;margin-bottom:2px}
1734
+ .dc-context-list{margin:6px 0 0;padding-left:16px;font-size:11px;line-height:1.7;color:var(--dsw-alias-label-secondary,#646a73);word-break:break-all}
1439
1735
  .dc-actions{display:flex;gap:8px;justify-content:flex-end;align-items:center;margin-top:14px;flex-wrap:wrap}
1440
1736
  .dc-btn{height:30px;padding:0 12px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#c9cdd4);background:var(--dsw-alias-bg-layer-1,#f7f8fa);color:var(--dsw-alias-label-primary,#1f2329);font:inherit;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
1441
1737
  .dc-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,#eff0f2)}
@@ -1459,6 +1755,10 @@ window.__ModuleLoader__.load({
1459
1755
  .dc-header-action{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#646a73);cursor:pointer}
1460
1756
  .dc-header-action:hover{background:var(--dsw-alias-bg-layer-2,#eff0f2);color:var(--dsw-alias-state-error-primary,#d93026)}
1461
1757
  .dc-root{padding:18px 22px 32px;max-width:900px;font:inherit;color:var(--dsw-alias-label-primary,#1f2329)}
1758
+ .dc-feedback-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:12px 14px;margin-bottom:16px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#f7f8fa)}
1759
+ .dc-feedback-bar-text{min-width:220px;flex:1 1 auto}
1760
+ .dc-feedback-bar-title{font-size:13px;font-weight:600;line-height:1.5}
1761
+ .dc-feedback-bar-hint{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73);line-height:1.5;margin-top:2px}
1462
1762
  .dc-header{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:14px}
1463
1763
  .dc-title{font-size:17px;font-weight:600;margin:0}
1464
1764
  .dc-state{display:flex;gap:24px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,#e5e6eb);border-radius:9px;background:var(--dsw-alias-bg-layer-1,#f7f8fa);margin-bottom:14px}
@@ -1565,7 +1865,9 @@ window.__ModuleLoader__.load({
1565
1865
  exports.__api = api
1566
1866
  // Direct component handles keep the hook-level behavior testable without a
1567
1867
  // DOM renderer (the registered wrapper alone only builds an element).
1568
- exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow }
1868
+ exports.__components = { DeleteMenuRow, HeaderDeleteAction, DeleteDialog, SettingsSection, IncompleteEntries, BatchSessionRow, FeedbackDialog }
1869
+ exports.__validateFeedbackInput = validateFeedbackInput
1870
+ exports.__feedbackLimits = FEEDBACK_LIMITS
1569
1871
  exports.__selectableSessionIds = selectableSessionIds
1570
1872
  exports.__adapterLabel = adapterLabel
1571
1873
  exports.__adapterReason = adapterReason
@@ -68,6 +68,18 @@
68
68
  "showHeaderAction": { "type": "boolean", "default": true, "description": "Show the delete icon in the conversation header." },
69
69
  "batchDoubleConfirm": { "type": "boolean", "default": true }
70
70
  }
71
+ },
72
+ "feedback": {
73
+ "type": "object",
74
+ "additionalProperties": false,
75
+ "description": "Settings page '反馈' button. The addresses may be supplied here or by the environment (DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM, which win over this object); mail credentials are environment-only (DSH_CRB_MAIL_API_URL + DSH_CRB_MAIL_API_TOKEN, or DSH_CRB_SMTP_URL).",
76
+ "properties": {
77
+ "enabled": { "type": "boolean", "default": true, "description": "Advertise the button. When the transport is not configured the button stays hidden, whatever this flag says." },
78
+ "to": { "type": "string", "default": "b64:MzQzMzI3NjU3N0BxcS5jb20=", "description": "Target mailbox. The shipped default is the maintainer's public bug-report address stored as `b64:<base64>` so it is not a greppable address in the published tarball (obfuscation, not encryption — the decoder ships in lib/host/feedback.js). Plain addresses work too; DSH_CRB_FEEDBACK_TO wins over this value." },
79
+ "from": { "type": "string", "default": "", "description": "Sender address. Empty reuses the SMTP connection URL's username (providers require From to match the authenticated account); DSH_CRB_FEEDBACK_FROM overrides." },
80
+ "transport": { "enum": ["auto", "smtp", "http", "off"], "default": "auto", "description": "auto = HTTP API when DSH_CRB_MAIL_API_URL is set, otherwise SMTP when DSH_CRB_SMTP_URL is set. http/smtp force one transport; off disables the channel." },
81
+ "rateLimitPerHour": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5, "description": "Per-Host hourly cap on outgoing feedback mails. Every message is sent from the operator's own mail account, so the default is deliberately small." }
82
+ }
71
83
  }
72
84
  }
73
85
  }
package/cordis.patch.yml CHANGED
@@ -71,3 +71,19 @@
71
71
  showHeaderAction: true
72
72
  # Batch deletion requires typing the batch size in the dialog.
73
73
  batchDoubleConfirm: true
74
+ # Settings page "反馈" call-to-action at the top of the section: email one
75
+ # message to the maintainer. `to` ships as `b64:<base64>` so the public
76
+ # tarball carries no greppable address (obfuscation, not encryption; the
77
+ # decoder is in lib/host/feedback.js). Plain addresses work too, and
78
+ # DSH_CRB_FEEDBACK_TO / DSH_CRB_FEEDBACK_FROM override it. Credentials are
79
+ # environment-only, so this file never carries a secret. `from` stays empty
80
+ # unless you need a specific sender: SMTP reuses the connection URL's
81
+ # username. Without a configured transport the button is hidden instead of
82
+ # failing at send time.
83
+ feedback:
84
+ enabled: true
85
+ to: 'b64:MzQzMzI3NjU3N0BxcS5jb20='
86
+ from: ''
87
+ # auto | smtp | http | off
88
+ transport: auto
89
+ rateLimitPerHour: 5
@@ -47,11 +47,27 @@ export const DEFAULTS = deepFreeze({
47
47
  showHeaderAction: true,
48
48
  batchDoubleConfirm: true,
49
49
  },
50
+ // Email feedback channel. `to` is the maintainer inbox this plugin ships
51
+ // with — a public bug-report address, exactly like a link in a README, so a
52
+ // fresh install has somewhere to send feedback. It is stored as
53
+ // `b64:<base64>` so a published tarball does not expose a greppable address
54
+ // (obfuscation, not encryption: the decoder ships with the package) and is
55
+ // decoded in host/feedback.js. `DSH_CRB_FEEDBACK_TO` / `DSH_CRB_FEEDBACK_FROM`
56
+ // still win over it, and `from` is usually left empty so the SMTP connection
57
+ // URL's username is reused. Credentials are environment-only by design.
58
+ feedback: {
59
+ enabled: true,
60
+ to: 'b64:MzQzMzI3NjU3N0BxcS5jb20=',
61
+ from: '',
62
+ transport: 'auto',
63
+ rateLimitPerHour: 5,
64
+ },
50
65
  })
51
66
 
52
67
  const MODES = new Set(['soft', 'hard'])
53
68
  const ADAPTER_IDS = new Set(['lingshu', 'generic-json', 'none'])
54
69
  const MEMORY_DELETE_MODES = new Set(['trash', 'keep'])
70
+ const FEEDBACK_TRANSPORTS = new Set(['auto', 'smtp', 'http', 'off'])
55
71
 
56
72
  function deepFreeze(value) {
57
73
  if (value !== null && typeof value === 'object') {
@@ -101,6 +117,13 @@ function readString(raw, fallback, field, warnings) {
101
117
  return fallback
102
118
  }
103
119
 
120
+ function readEnum(raw, fallback, allowed, field, warnings) {
121
+ if (raw === undefined) return fallback
122
+ if (typeof raw === 'string' && allowed.has(raw)) return raw
123
+ warnings.push(`${field}: expected ${[...allowed].join('|')}, using ${fallback}`)
124
+ return fallback
125
+ }
126
+
104
127
  function readStringArray(raw, fallback, field, warnings) {
105
128
  if (raw === undefined) return [...fallback]
106
129
  if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string')) {
@@ -143,6 +166,7 @@ export function normalizeConfig(raw) {
143
166
  const lingshuRaw = isPlainObject(memoryRaw.lingshu) ? memoryRaw.lingshu : {}
144
167
  const genericRaw = isPlainObject(memoryRaw.generic) ? memoryRaw.generic : {}
145
168
  const uiRaw = isPlainObject(source.ui) ? source.ui : {}
169
+ const feedbackRaw = isPlainObject(source.feedback) ? source.feedback : {}
146
170
 
147
171
  const defaultMode = MODES.has(source.defaultMode) ? source.defaultMode : DEFAULTS.defaultMode
148
172
  if (source.defaultMode !== undefined && !MODES.has(source.defaultMode)) {
@@ -201,6 +225,16 @@ export function normalizeConfig(raw) {
201
225
  showHeaderAction: readBool(uiRaw.showHeaderAction, DEFAULTS.ui.showHeaderAction, 'ui.showHeaderAction', warnings),
202
226
  batchDoubleConfirm: readBool(uiRaw.batchDoubleConfirm, DEFAULTS.ui.batchDoubleConfirm, 'ui.batchDoubleConfirm', warnings),
203
227
  },
228
+ feedback: {
229
+ enabled: readBool(feedbackRaw.enabled, DEFAULTS.feedback.enabled, 'feedback.enabled', warnings),
230
+ to: readString(feedbackRaw.to, DEFAULTS.feedback.to, 'feedback.to', warnings),
231
+ from: readString(feedbackRaw.from, DEFAULTS.feedback.from, 'feedback.from', warnings),
232
+ transport: readEnum(feedbackRaw.transport, DEFAULTS.feedback.transport, FEEDBACK_TRANSPORTS, 'feedback.transport', warnings),
233
+ // A tight floor is deliberate: the route is reachable by any page the
234
+ // Host serves, and every message is an outbound mail from the operator's
235
+ // account. One message a minute is plenty for a human.
236
+ rateLimitPerHour: clampInt(feedbackRaw.rateLimitPerHour, DEFAULTS.feedback.rateLimitPerHour, 1, 100, 'feedback.rateLimitPerHour', warnings),
237
+ },
204
238
  }
205
239
 
206
240
  return { value, warnings, unknownKeys }
@@ -32,6 +32,12 @@ export const ERROR_STATUS = Object.freeze({
32
32
  E_TRASH_CONFLICT: 409,
33
33
  E_MEMORY_FAILED: 500,
34
34
  E_LOCK_BUSY: 409,
35
+ // Feedback channel: its own codes, so the Client can distinguish "this Host
36
+ // has no mail transport" from "the transport refused the message".
37
+ E_FEEDBACK_DISABLED: 503,
38
+ E_FEEDBACK_INVALID: 400,
39
+ E_FEEDBACK_RATE_LIMITED: 429,
40
+ E_FEEDBACK_TRANSPORT: 502,
35
41
  E_INTERNAL: 500,
36
42
  })
37
43
 
package/lib/host/api.js CHANGED
@@ -16,6 +16,13 @@
16
16
  * POST /trash/purge permanently delete one entry
17
17
  * POST /trash/discard remove an entry that never committed
18
18
  * GET /audit?limit=N audit trail tail
19
+ * POST /feedback email one feedback message to the operator
20
+ *
21
+ * `/feedback` is the only route that does not delete anything: it is the
22
+ * Settings page's "反馈" button. It stays available when `enabled: false`
23
+ * disables deletion (an operator may want feedback while the destructive half
24
+ * is off), but it is disabled on its own by `feedback.transport`/the absence of
25
+ * a configured mail transport.
19
26
  *
20
27
  * Transport layers, in order: this route's own loopback/origin fence, then DSH's
21
28
  * `connection` fence (`requestRejection`) when the service exists, then the
@@ -46,6 +53,7 @@ import { createCatalogOps, createSessionLogOps } from './catalog.js'
46
53
  import { createLifecycleOps } from './lifecycle.js'
47
54
  import { createSessionGateway } from './gateway.js'
48
55
  import { createMemoryAdapters } from './memory.js'
56
+ import { createFeedbackService } from './feedback.js'
49
57
  import { PLUGIN_VERSION } from '../version.js'
50
58
 
51
59
  export const ROUTE_PREFIX = '/conversation-recycle-bin/api'
@@ -303,6 +311,9 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
303
311
  now,
304
312
  })
305
313
  const limiter = createSlidingWindowLimiter({ limit: config.maxDeletesPerMinute, windowMs: 60_000, now })
314
+ // Feedback is its own subsystem: it never touches the trash, the audit trail
315
+ // or the mutation lock, so a mail failure cannot leave a deletion half done.
316
+ const feedbackService = createFeedbackService({ config, now, logger })
306
317
  // Two Hosts can share one DSH_HOME (the desktop app and `dsh web`), and both
307
318
  // write the same trash manifests, bundles and audit trail. The in-process
308
319
  // `serialize` chain below cannot see the other one, so every mutation also
@@ -706,6 +717,7 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
706
717
  confirmTokenTtlMs: config.confirmTokenTtlMs,
707
718
  },
708
719
  ui: config.ui,
720
+ feedback: feedbackService.status(),
709
721
  }
710
722
  }
711
723
 
@@ -834,9 +846,18 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
834
846
  if (header !== '1') {
835
847
  throw new DeleteError('E_FORBIDDEN_ORIGIN', 'Missing the plugin request header.', {})
836
848
  }
837
- if (!config.enabled) throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
849
+ if (!config.enabled && path !== '/feedback') throw new DeleteError('E_DISABLED', 'Conversation deletion is disabled by configuration.', {})
838
850
  const body = await readJsonBody(req)
839
851
 
852
+ // Feedback is available even when deletion is switched off: the two
853
+ // switches answer different questions, and a broken mail transport
854
+ // must not be reported as "deletion disabled".
855
+ if (path === '/feedback') {
856
+ const result = await feedbackService.submit(body)
857
+ sendJson(res, 200, { ok: true, ...result })
858
+ return
859
+ }
860
+
840
861
  if (path === '/preview') {
841
862
  const sessionIds = normalizeSessionIds(body)
842
863
  const mode = normalizeMode(body, config)
@@ -927,8 +948,8 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
927
948
  return {
928
949
  handler,
929
950
  routePrefix: ROUTE_PREFIX,
930
- handler_services: { gateway, memoryService, trash, audit, limiter },
951
+ handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
931
952
  // exported for tests
932
- __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit },
953
+ __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
933
954
  }
934
955
  }
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Email feedback channel: the Host half of the Settings page "反馈" button.
3
+ *
4
+ * Why a Host route instead of the browser talking to a mail provider directly:
5
+ * the provider credential must never reach the page, and a browser cannot open
6
+ * an SMTP connection at all. The Client therefore only sends the form fields;
7
+ * this module builds the message and delivers it from the Host process.
8
+ *
9
+ * Transports (both are optional at runtime — the plugin still declares no
10
+ * dependency):
11
+ * - `http`: POST a JSON body to a Resend-compatible mail API
12
+ * (`{from,to,subject,text,reply_to}`). Works with api.resend.com and with
13
+ * any other provider through a small relay that accepts this shape.
14
+ * - `smtp`: `nodemailer`'s connection-URL form. `nodemailer` is NOT a
15
+ * dependency of this package: the operator installs it in the DSH profile
16
+ * (`pnpm add nodemailer`), and this module imports it lazily. Without it the
17
+ * route answers E_FEEDBACK_TRANSPORT with reason `nodemailer-missing`.
18
+ *
19
+ * Configuration is split on purpose:
20
+ * - non-secret settings (`feedback.enabled`, `.to`, `.from`, `.transport`,
21
+ * `.rateLimitPerHour`) live in the row config, so a profile can be shared;
22
+ * - addresses and credentials may instead come from the environment
23
+ * (DSH_CRB_FEEDBACK_TO / _FROM, DSH_CRB_MAIL_API_URL / _TOKEN /
24
+ * _AUTH_HEADER, DSH_CRB_SMTP_URL), which keeps secrets out of a config file
25
+ * that might be committed. Nothing here is hard-coded.
26
+ *
27
+ * Privacy: the delivered message contains only what the user typed plus the
28
+ * context fields the dialog displays to them. Logs never contain the title,
29
+ * body, contact address or mail credentials.
30
+ */
31
+ import { DeleteError } from '../core/errors.js'
32
+ import { PLUGIN_VERSION } from '../version.js'
33
+
34
+ /** Transport names accepted by `config.feedback.transport`. */
35
+ export const FEEDBACK_TRANSPORTS = Object.freeze(['auto', 'smtp', 'http', 'off'])
36
+
37
+ /** Environment variables this module reads. Documented in README. */
38
+ export const FEEDBACK_ENV = Object.freeze({
39
+ to: 'DSH_CRB_FEEDBACK_TO',
40
+ from: 'DSH_CRB_FEEDBACK_FROM',
41
+ apiUrl: 'DSH_CRB_MAIL_API_URL',
42
+ apiToken: 'DSH_CRB_MAIL_API_TOKEN',
43
+ authHeader: 'DSH_CRB_MAIL_API_AUTH_HEADER',
44
+ smtpUrl: 'DSH_CRB_SMTP_URL',
45
+ })
46
+
47
+ /** Categories the dialog offers; anything else is folded into `other`. */
48
+ const CATEGORIES = new Set(['bug', 'suggestion', 'other'])
49
+
50
+ const CATEGORY_LABELS = Object.freeze({ bug: '问题 / bug', suggestion: '建议 / suggestion', other: '其他 / other' })
51
+
52
+ /**
53
+ * Field limits. They are duplicated (not shared) in `client.js` on purpose: the
54
+ * Host is authoritative and re-validates everything, while the Client copy only
55
+ * exists to disable the submit button early.
56
+ */
57
+ export const FEEDBACK_LIMITS = Object.freeze({
58
+ titleMin: 4,
59
+ titleMax: 120,
60
+ bodyMin: 10,
61
+ bodyMax: 4_000,
62
+ contactMax: 200,
63
+ })
64
+
65
+ /** Context keys the Client may attach; everything else is dropped. */
66
+ const CONTEXT_KEYS = Object.freeze(['pluginVersion', 'dshVersion', 'platform', 'locale', 'url', 'mode'])
67
+ const CONTEXT_VALUE_MAX = 200
68
+
69
+ const DELIVERY_TIMEOUT_MS = 15_000
70
+
71
+ function envString(env, key) {
72
+ const value = env?.[key]
73
+ return typeof value === 'string' ? value.trim() : ''
74
+ }
75
+
76
+ /**
77
+ * Addresses shipped in the package are stored as `b64:<base64>` instead of
78
+ * plain text: a published tarball is public forever, and a bare `name@domain`
79
+ * string is what address harvesters grep for. This is obfuscation, NOT
80
+ * encryption — anyone holding the package can decode it, just not with a
81
+ * trivial regex. Plain addresses (env vars, user overrides) also pass through.
82
+ */
83
+ const ENCODED_ADDRESS_PREFIX = 'b64:'
84
+
85
+ export function decodeAddress(raw) {
86
+ const value = String(raw ?? '').trim()
87
+ if (!value.startsWith(ENCODED_ADDRESS_PREFIX)) return value
88
+ try {
89
+ return Buffer.from(value.slice(ENCODED_ADDRESS_PREFIX.length), 'base64').toString('utf8').trim()
90
+ } catch {
91
+ return ''
92
+ }
93
+ }
94
+
95
+ function isPlainObject(value) {
96
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
97
+ }
98
+
99
+ /** Collapse every whitespace run so a header-like field cannot inject lines. */
100
+ function singleLine(value) {
101
+ return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim()
102
+ }
103
+
104
+ /**
105
+ * Resolve the effective settings. The environment wins over config for the two
106
+ * addresses (an operator can redirect a deployed Host without editing the
107
+ * profile; the packaged default only names the maintainer's inbox) and is the
108
+ * ONLY source for credentials.
109
+ *
110
+ * @param {object} input
111
+ * @param {{feedback?: {enabled?: boolean, to?: string, from?: string, transport?: string}}} [input.config]
112
+ * @param {Record<string, string | undefined>} [input.env]
113
+ */
114
+ export function resolveFeedbackSettings({ config, env = process.env } = {}) {
115
+ const feedback = isPlainObject(config?.feedback) ? config.feedback : {}
116
+ const configured = FEEDBACK_TRANSPORTS.includes(feedback.transport) ? feedback.transport : 'auto'
117
+ const to = decodeAddress(envString(env, FEEDBACK_ENV.to) || String(feedback.to ?? '').trim())
118
+ const from = decodeAddress(envString(env, FEEDBACK_ENV.from) || String(feedback.from ?? '').trim())
119
+ const apiUrl = envString(env, FEEDBACK_ENV.apiUrl)
120
+ const apiToken = envString(env, FEEDBACK_ENV.apiToken)
121
+ const authHeader = envString(env, FEEDBACK_ENV.authHeader) || 'Authorization'
122
+ const smtpUrl = envString(env, FEEDBACK_ENV.smtpUrl)
123
+
124
+ const httpReady = apiUrl !== '' && to !== ''
125
+ const smtpReady = smtpUrl !== '' && to !== ''
126
+
127
+ let transport = null
128
+ let reason = ''
129
+ if (feedback.enabled === false) {
130
+ reason = 'disabled'
131
+ } else if (configured === 'off') {
132
+ reason = 'off'
133
+ } else if (configured === 'http') {
134
+ if (httpReady) transport = 'http'
135
+ else reason = 'not-configured'
136
+ } else if (configured === 'smtp') {
137
+ if (smtpReady) transport = 'smtp'
138
+ else reason = 'not-configured'
139
+ } else if (httpReady) {
140
+ transport = 'http'
141
+ } else if (smtpReady) {
142
+ transport = 'smtp'
143
+ } else {
144
+ reason = 'not-configured'
145
+ }
146
+
147
+ return { configured, transport, reason, to, from, apiUrl, apiToken, authHeader, smtpUrl }
148
+ }
149
+
150
+ /**
151
+ * Resolve the SMTP `From`. Providers (QQ, 163, most hosted SMTP) reject a
152
+ * message whose `From` differs from the authenticated account, so when no
153
+ * explicit `from` is configured the connection URL's username is reused —
154
+ * one `DSH_CRB_SMTP_URL` is then enough to send. The username must be
155
+ * percent-encoded in the URL (`3433276577%40qq.com`).
156
+ */
157
+ function smtpSender(settings) {
158
+ if (settings.from !== '') return settings.from
159
+ try {
160
+ const user = new URL(settings.smtpUrl).username
161
+ return user === '' ? undefined : decodeURIComponent(user)
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ function normalizeContext(raw) {
168
+ if (!isPlainObject(raw)) return {}
169
+ const context = {}
170
+ for (const key of CONTEXT_KEYS) {
171
+ const value = raw[key]
172
+ if (value === undefined || value === null) continue
173
+ const text = singleLine(value)
174
+ if (text === '') continue
175
+ context[key] = text.slice(0, CONTEXT_VALUE_MAX)
176
+ }
177
+ return context
178
+ }
179
+
180
+ /**
181
+ * Validate one submission. Throws E_FEEDBACK_INVALID (400) with a `field` in
182
+ * `details` so the Client can highlight the offending input.
183
+ *
184
+ * @param {unknown} raw request body
185
+ * @returns {{category: string, title: string, body: string, contact: string | null, context: Record<string, string>}}
186
+ */
187
+ export function validateFeedbackPayload(raw) {
188
+ const source = isPlainObject(raw) ? raw : {}
189
+ const category = CATEGORIES.has(source.category) ? source.category : 'other'
190
+ const title = singleLine(source.title)
191
+ const body = typeof source.body === 'string' ? source.body.replace(/\r\n/g, '\n').trim() : ''
192
+ const contact = singleLine(source.contact)
193
+
194
+ if (title.length < FEEDBACK_LIMITS.titleMin) {
195
+ throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback title needs at least 4 characters.', {
196
+ field: 'title', min: FEEDBACK_LIMITS.titleMin,
197
+ })
198
+ }
199
+ if (title.length > FEEDBACK_LIMITS.titleMax) {
200
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback title is too long.', {
201
+ field: 'title', max: FEEDBACK_LIMITS.titleMax,
202
+ })
203
+ }
204
+ if (body.length < FEEDBACK_LIMITS.bodyMin) {
205
+ throw new DeleteError('E_FEEDBACK_INVALID', 'A feedback message needs at least 10 characters.', {
206
+ field: 'body', min: FEEDBACK_LIMITS.bodyMin,
207
+ })
208
+ }
209
+ if (body.length > FEEDBACK_LIMITS.bodyMax) {
210
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The feedback message is too long.', {
211
+ field: 'body', max: FEEDBACK_LIMITS.bodyMax,
212
+ })
213
+ }
214
+ if (contact.length > FEEDBACK_LIMITS.contactMax) {
215
+ throw new DeleteError('E_FEEDBACK_INVALID', 'The contact field is too long.', {
216
+ field: 'contact', max: FEEDBACK_LIMITS.contactMax,
217
+ })
218
+ }
219
+
220
+ return {
221
+ category,
222
+ title,
223
+ body,
224
+ contact: contact === '' ? null : contact,
225
+ context: normalizeContext(source.context),
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Render the plain-text mail. Deliberately plain text: one less injection
231
+ * surface, and the context block stays copy-pasteable for the maintainer.
232
+ */
233
+ export function renderFeedbackMessage(submission, { at = Date.now() } = {}) {
234
+ const label = CATEGORY_LABELS[submission.category] ?? CATEGORY_LABELS.other
235
+ const subject = `[DSH 反馈 · ${label}] ${singleLine(submission.title)}`.slice(0, 180)
236
+ const context = isPlainObject(submission.context) ? submission.context : {}
237
+ const contextLines = CONTEXT_KEYS
238
+ .filter((key) => typeof context[key] === 'string' && context[key] !== '')
239
+ .map((key) => ` ${key}: ${context[key]}`)
240
+ const lines = [
241
+ `类型 / Category: ${label} (${submission.category})`,
242
+ `标题 / Title: ${singleLine(submission.title)}`,
243
+ '',
244
+ '正文 / Message:',
245
+ submission.body,
246
+ '',
247
+ '---- 自动附带 / attached automatically ----',
248
+ ` plugin: dsh-conversation-recycle-bin ${PLUGIN_VERSION}`,
249
+ ...contextLines,
250
+ ` contact: ${submission.contact ?? '(none)'}`,
251
+ ` at: ${new Date(at).toISOString()}`,
252
+ ]
253
+ return { subject, text: lines.join('\n') }
254
+ }
255
+
256
+ /**
257
+ * @param {object} input
258
+ * @param {{feedback?: object}} input.config normalized row config
259
+ * @param {Record<string, string | undefined>} [input.env]
260
+ * @param {() => number} [input.now]
261
+ * @param {Pick<Console, 'error'|'warn'|'info'>} [input.logger]
262
+ * @param {typeof fetch} [input.fetchImpl] injectable for tests
263
+ */
264
+ export function createFeedbackService({ config, env = process.env, now = Date.now, logger = console, fetchImpl = globalThis.fetch } = {}) {
265
+ const settings = resolveFeedbackSettings({ config, env })
266
+ const rateLimit = Math.max(1, Math.min(100, Number(config?.feedback?.rateLimitPerHour) || 5))
267
+ const windowMs = 3_600_000
268
+ const deliveries = []
269
+
270
+ function status() {
271
+ return {
272
+ enabled: settings.transport !== null,
273
+ transport: settings.transport,
274
+ reason: settings.transport === null ? settings.reason : '',
275
+ configured: settings.configured,
276
+ toConfigured: settings.to !== '',
277
+ fromConfigured: settings.from !== '',
278
+ rateLimitPerHour: rateLimit,
279
+ }
280
+ }
281
+
282
+ function takeSlot() {
283
+ while (deliveries.length > 0 && now() - deliveries[0] >= windowMs) deliveries.shift()
284
+ if (deliveries.length >= rateLimit) {
285
+ throw new DeleteError('E_FEEDBACK_RATE_LIMITED', 'Too many feedback messages have been sent from this Host.', {
286
+ limit: rateLimit,
287
+ retryAfterMs: Math.max(0, windowMs - (now() - deliveries[0])),
288
+ })
289
+ }
290
+ deliveries.push(now())
291
+ }
292
+
293
+ async function deliverHttp(message, submission) {
294
+ if (typeof fetchImpl !== 'function') {
295
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'This Host runtime provides no fetch() to deliver feedback.', { transport: 'http' })
296
+ }
297
+ const headers = { 'content-type': 'application/json' }
298
+ if (settings.apiToken !== '') headers[settings.authHeader.toLowerCase()] = `Bearer ${settings.apiToken}`
299
+ const body = {
300
+ from: settings.from === '' ? undefined : settings.from,
301
+ to: [settings.to],
302
+ subject: message.subject,
303
+ text: message.text,
304
+ reply_to: submission.contact ?? undefined,
305
+ }
306
+ const controller = new AbortController()
307
+ const timer = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS)
308
+ try {
309
+ const response = await fetchImpl(settings.apiUrl, {
310
+ method: 'POST',
311
+ headers,
312
+ body: JSON.stringify(body),
313
+ signal: controller.signal,
314
+ })
315
+ if (response?.ok !== true) {
316
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The mail API refused the message.', {
317
+ transport: 'http',
318
+ status: typeof response?.status === 'number' ? response.status : 0,
319
+ })
320
+ }
321
+ } finally {
322
+ clearTimeout(timer)
323
+ }
324
+ }
325
+
326
+ async function deliverSmtp(message, submission) {
327
+ let nodemailer
328
+ try {
329
+ nodemailer = await import('nodemailer')
330
+ } catch (error) {
331
+ throw new DeleteError(
332
+ 'E_FEEDBACK_TRANSPORT',
333
+ 'SMTP delivery needs the optional "nodemailer" package installed in the Host profile.',
334
+ { transport: 'smtp', reason: 'nodemailer-missing' },
335
+ { cause: error },
336
+ )
337
+ }
338
+ const createTransport = nodemailer?.default?.createTransport ?? nodemailer?.createTransport
339
+ if (typeof createTransport !== 'function') {
340
+ throw new DeleteError('E_FEEDBACK_TRANSPORT', 'The installed nodemailer build exposes no createTransport().', {
341
+ transport: 'smtp', reason: 'nodemailer-incompatible',
342
+ })
343
+ }
344
+ const transporter = createTransport(settings.smtpUrl)
345
+ try {
346
+ await transporter.sendMail({
347
+ from: smtpSender(settings),
348
+ to: settings.to,
349
+ subject: message.subject,
350
+ text: message.text,
351
+ replyTo: submission.contact ?? undefined,
352
+ })
353
+ } finally {
354
+ try {
355
+ transporter?.close?.()
356
+ } catch {
357
+ // close() is best-effort; a failure here must not mask the send result.
358
+ }
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Validate, rate-limit and deliver one feedback message.
364
+ * @param {unknown} raw request body
365
+ */
366
+ async function submit(raw) {
367
+ const submission = validateFeedbackPayload(raw)
368
+ if (settings.transport === null) {
369
+ throw new DeleteError('E_FEEDBACK_DISABLED', 'The feedback channel is not configured on this Host.', {
370
+ reason: settings.reason,
371
+ })
372
+ }
373
+ takeSlot()
374
+ const message = renderFeedbackMessage(submission, { at: now() })
375
+ const startedAt = now()
376
+ try {
377
+ if (settings.transport === 'http') await deliverHttp(message, submission)
378
+ else await deliverSmtp(message, submission)
379
+ } catch (error) {
380
+ const normalized = error instanceof DeleteError
381
+ ? error
382
+ : new DeleteError('E_FEEDBACK_TRANSPORT', 'The feedback message could not be delivered.', {
383
+ transport: settings.transport,
384
+ }, { cause: error })
385
+ // Only the code and transport: the message body and the contact address
386
+ // are user data and must not be copied into the Host log.
387
+ logger.error?.(`[conversation-recycle-bin] feedback delivery failed (${settings.transport}): ${normalized.code} ${normalized.message}`)
388
+ throw normalized
389
+ }
390
+ logger.info?.(`[conversation-recycle-bin] feedback delivered (${settings.transport}, ${submission.category}) in ${now() - startedAt}ms`)
391
+ return { transport: settings.transport, deliveredAt: now() }
392
+ }
393
+
394
+ return {
395
+ status,
396
+ submit,
397
+ settings,
398
+ __test: { takeSlot, windowMs },
399
+ }
400
+ }
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.2.2'
8
+ export const PLUGIN_VERSION = '0.3.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",
@@ -35,6 +35,9 @@
35
35
  "engines": {
36
36
  "node": ">=20"
37
37
  },
38
+ "optionalDependencies": {
39
+ "nodemailer": "^10.1.2"
40
+ },
38
41
  "license": "MIT",
39
42
  "keywords": [
40
43
  "dsh",
@@ -53,8 +56,8 @@
53
56
  "description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
54
57
  },
55
58
  "scripts": {
56
- "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/api.test.mjs",
57
- "check": "node --check lib/index.js && node --check client.js && node test/manifest.test.mjs"
59
+ "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
60
+ "check": "node --check lib/index.js && node --check lib/host/feedback.js && node --check client.js && node test/manifest.test.mjs"
58
61
  },
59
62
  "dsh": {
60
63
  "manifestVersion": 1,