@xrn1997/dsh-session-delete 0.5.0 → 0.6.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/lib/index.js CHANGED
@@ -12,13 +12,18 @@ import { homedir } from "node:os";
12
12
  * 信封 {ok:true,value} / {ok:false,error:{code,message,details?}} 也是两半的共同契约。
13
13
  */
14
14
  /**
15
- * 插件专属前缀。
15
+ * 插件专属前缀(九段相对它命名)。
16
16
  *
17
- * 为什么必须带插件名:宿主 web server 的 `register({kind:'prefix', path})` 对**重复的 (kind, path)
18
- * 直接抛错**(`dsh-host-webserver` 原文 `webserver: duplicate ${route.kind} route "${route.path}"`),
19
- * 撞了就是整棵插件树 boot 失败——所以这条路径是与生态的硬边界,不是显示好不好看的问题。
17
+ * 为什么必须带插件名:宿主 `connection.fetch.register` 对**重复的精确路径**直接抛错
18
+ * (`connection: exact Fetch route "…" is already registered`),撞了就是整棵插件树 boot 失败——
19
+ * 所以这条路径是与生态的硬边界,不是显示好不好看的问题。
20
20
  */
21
21
  const SESSION_DELETE_API_PREFIX = "/dsh-session-delete-api";
22
+ /**
23
+ * 唯一的取数基础路径:宿主 `/api` + 插件前缀。九段 exact Fetch 路由、客户端探测与附件 URL
24
+ * 都从它起;`SESSION_DELETE_API_PREFIX` 只作为段名的相对前缀与错误原文。
25
+ */
26
+ const OFFICIAL_SESSION_DELETE_API_PREFIX = `/api${SESSION_DELETE_API_PREFIX}`;
22
27
  /** 九个段名(相对前缀)。**每项 `{ path, method }` 两键**:某段用什么方法也是线上形状的一部分
23
28
  * (ADR-0003),家就在这一张表——路由记录(`src/api/route-table.ts`)逐行投影(`ROUTES.x.method`),
24
29
  * 浏览器半的 GET 段同样投影、写段把 `ROUTES.x.method` 交给 `src/client/remote.ts` 的 `post()`,
@@ -91,7 +96,14 @@ const PARAMS = {
91
96
  ids: "ids",
92
97
  attachmentId: "id",
93
98
  attachmentKind: "kind",
94
- attachmentName: "name"
99
+ attachmentName: "name",
100
+ /** 图片引用的媒体类型 / 宽高:只给宿主 `attachments.readImage` 做引用校验,不参与路径拼接。 */
101
+ attachmentMediaType: "mediaType",
102
+ /** 字节数:图片支与文件支都带——它是宿主读面的引用一部分,并先过 `maxAttachmentBytes` 闸
103
+ * (别为一张已知超限的图让宿主整份读出来)。 */
104
+ attachmentBytes: "bytes",
105
+ attachmentWidth: "width",
106
+ attachmentHeight: "height"
95
107
  };
96
108
  /**
97
109
  * 进程内判别「这已经是一次失败」——**按标记位认,不按 `instanceof`**(跨 realm / 跨 bundle 都不成立)。
@@ -146,6 +158,50 @@ function verbCodeOf(error) {
146
158
  /** 回收站目录名(相对宿主数据根)。Node 半拼路径,浏览器半在页脚念它——同一个字面量。 */
147
159
  const TRASH_DIR_NAME = "session-trash";
148
160
  //#endregion
161
+ //#region src/verbs/errors.ts
162
+ /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
163
+ * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
164
+ function verbError(code, message, cause, details) {
165
+ const e = new Error(message);
166
+ e.code = SESSION_DELETE_CODES[code];
167
+ if (cause !== void 0) e.cause = cause;
168
+ if (details !== void 0) e.details = details;
169
+ return e;
170
+ }
171
+ /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
172
+ function trashEmpty(entryId) {
173
+ return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
174
+ }
175
+ /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
176
+ function routeError(code, httpStatus, message) {
177
+ const e = verbError("io", message);
178
+ e.code = SESSION_DELETE_CODES[code];
179
+ e.httpStatus = httpStatus;
180
+ return e;
181
+ }
182
+ /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
183
+ * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
184
+ * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
185
+ function rollbackReason(label, error) {
186
+ return `${label}:${error instanceof Error ? error.message : String(error)}`;
187
+ }
188
+ /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
189
+ function carriedRollbackFailures(error) {
190
+ const raw = error?.details?.rollbackFailures;
191
+ return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
192
+ }
193
+ /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
194
+ * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
195
+ * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
196
+ function withRollbackFailures(error, failures) {
197
+ const code = verbCodeOf(error) ?? "io";
198
+ const carried = carriedRollbackFailures(error);
199
+ const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
200
+ const base = error instanceof Error ? error.message : String(error);
201
+ const extra = failures.filter((failure) => !carried.includes(failure));
202
+ return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
203
+ }
204
+ //#endregion
149
205
  //#region src/remote/map-error.ts
150
206
  /** 宿主两条类型化错误 → 我们的码(本文件的两行映射)。
151
207
  * 类不可 import(跨插件值 import 是红线;且宿主包与运行中的宿主不同代)⇒ 按官方构造器写死的 `this.name` 认。
@@ -266,134 +322,6 @@ function toRemoteFailure(error) {
266
322
  };
267
323
  }
268
324
  //#endregion
269
- //#region src/verbs/errors.ts
270
- /** 失败的**结构与措辞**。码本身、码的前缀读法与动词词表都住在 `shared/wire.ts`(ADR-0003):
271
- * 浏览器半也要认它们,它们属于两半共同的契约,不属于 Node 半。这里只做动词抛错与回滚措辞。 */
272
- function verbError(code, message, cause, details) {
273
- const e = new Error(message);
274
- e.code = SESSION_DELETE_CODES[code];
275
- if (cause !== void 0) e.cause = cause;
276
- if (details !== void 0) e.details = details;
277
- return e;
278
- }
279
- /** `trash-empty` 的原文:有具体条目就点名那个 id,拿不到时只报通用那句。 */
280
- function trashEmpty(entryId) {
281
- return verbError("trash-empty", entryId === void 0 ? "回收站里没有这一条" : `回收站里没有这一条:${entryId}`);
282
- }
283
- /** 路由自检用的失败:带 HTTP 状态语义,**不**冒充五个动词码。 */
284
- function routeError(code, httpStatus, message) {
285
- const e = verbError("io", message);
286
- e.code = SESSION_DELETE_CODES[code];
287
- e.httpStatus = httpStatus;
288
- return e;
289
- }
290
- /** 一条回滚步骤失败的**标签句**:label 说的是"这一步在退什么",原文带出**那一步抛的东西**
291
- * (`Error` 取 message,其余一律 `String()`;对象带 `message` 的差异归 `map-error.messageOf` 管)。
292
- * 调用方不许另抄这份格式——那样 details 里的句子会随各处 catch 变形,界面上就变成"同一个失败三个说法"。 */
293
- function rollbackReason(label, error) {
294
- return `${label}:${error instanceof Error ? error.message : String(error)}`;
295
- }
296
- /** 内层错误已经挂着的回滚失败(成员自己的就地回滚失败时会有)。组级 catch 再包一次时不能丢。 */
297
- function carriedRollbackFailures(error) {
298
- const raw = error?.details?.rollbackFailures;
299
- return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
300
- }
301
- /** 原错误保留,回滚失败作为 `details.rollbackFailures` 上线;码仍按原错误的动词码,
302
- * 没有可继承的就落 io。**内层的回滚失败要并进来**:一个成员自己退失败、兄弟也退失败时,
303
- * 两份失败都得在 details 里,消息也不能出现第二个「回滚未完成」。 */
304
- function withRollbackFailures(error, failures) {
305
- const code = verbCodeOf(error) ?? "io";
306
- const carried = carriedRollbackFailures(error);
307
- const merged = [.../* @__PURE__ */ new Set([...carried, ...failures])];
308
- const base = error instanceof Error ? error.message : String(error);
309
- const extra = failures.filter((failure) => !carried.includes(failure));
310
- return verbError(code, base.includes(";回滚未完成:") ? extra.length > 0 ? `${base};${extra.join(";")}` : base : `${base};回滚未完成:${merged.join(";")}`, error, { rollbackFailures: merged });
311
- }
312
- //#endregion
313
- //#region src/api/request-trust.ts
314
- /** 桌面壳那条路(asar `/lib/main.js` 的 `forwardWebRequest`):渲染进程从 `dsh-app://app/` 加载,
315
- * 转发前把 `host` / `origin` / `cookie` / `sec-fetch-site` 删掉、换上一枚宿主自己的 cookie,
316
- * `Referer` 保留。判据按 `protocol` + `host` 比,**不能比 `URL.origin`**(`dsh-app:` 不是 WHATWG
317
- * special scheme,`new URL('dsh-app://app/').origin` 是字符串 `"null"`)。 */
318
- const DESKTOP_APP_SCHEME = "dsh-app:";
319
- const DESKTOP_APP_HOST = "app";
320
- /** 回环 socket 的三种写法(IPv4 / IPv6 / IPv4-mapped IPv6)。 */
321
- const LOOPBACK_SOCKETS = [
322
- "127.0.0.1",
323
- "::1",
324
- "::ffff:127.0.0.1"
325
- ];
326
- function isDesktopShellUrl(value) {
327
- try {
328
- const url = new URL(value);
329
- return url.protocol === DESKTOP_APP_SCHEME && url.host === DESKTOP_APP_HOST;
330
- } catch {
331
- return false;
332
- }
333
- }
334
- /** 裸 authority(`host` / `host:port`)按给定 scheme 解析;解析不了回 `undefined`。 */
335
- function parseAuthority(authority, scheme = "http:") {
336
- try {
337
- return new URL(`${scheme}//${authority}`);
338
- } catch {
339
- return;
340
- }
341
- }
342
- /** 与宿主 `isLoopbackHostname` 同口径:`localhost`、`[::1]`、任意 127/8。 */
343
- function isLoopbackHostname(hostname) {
344
- if (hostname === "localhost" || hostname === "[::1]") return true;
345
- const parts = hostname.split(".");
346
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
347
- }
348
- /** Host 是不是本部署的 bind 字面量:忽略端口与 `%zone`(zone 是接口选择器,不是地址文本)。 */
349
- function isBindAddress(hostUrl, bindHost) {
350
- if (bindHost === void 0) return false;
351
- const zoneAt = bindHost.indexOf("%");
352
- const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt);
353
- const bindUrl = parseAuthority(address.includes(":") ? `[${address}]` : address);
354
- return bindUrl !== void 0 && bindUrl.hostname === hostUrl.hostname;
355
- }
356
- /** 携带 authority 的头(Origin / Referer)是否与 Host 同 authority:按这个头自己的 scheme 归一
357
- * 默认端口(`http://x:80` 与 Host `x` 是同一个 authority)。 */
358
- function isSameAuthority(value, hostHeader) {
359
- let url;
360
- try {
361
- url = new URL(value);
362
- } catch {
363
- return false;
364
- }
365
- const scheme = url.protocol === "https:" ? "https:" : url.protocol === "http:" ? "http:" : void 0;
366
- if (scheme === void 0) return false;
367
- const hostUrl = parseAuthority(hostHeader, scheme);
368
- return hostUrl !== void 0 && url.host === hostUrl.host;
369
- }
370
- /** 头栅栏:`sec-fetch-site` / Origin / Referer 三条,任何客户端都要过。 */
371
- function passesHeaderFence(headers, host) {
372
- if (headers["sec-fetch-site"] === "cross-site") return false;
373
- const origin = headers.origin;
374
- if (origin !== void 0 && !isDesktopShellUrl(origin) && !isSameAuthority(origin, host)) return false;
375
- const referer = headers.referer;
376
- if (referer !== void 0 && !isDesktopShellUrl(referer) && !isSameAuthority(referer, host)) return false;
377
- return true;
378
- }
379
- /**
380
- * 这条请求归不归本插件办。`untrusted` = 归属不对(跨源 / 重绑 / 头不成形);`unauthenticated` =
381
- * 归属对但远程客户端没带宿主身份(原文会提示用 `dsh web` 打印的 URL 打开)。
382
- *
383
- * 远程支的两个失败裁决由端口翻译宿主的区分(401 = 缺身份、403 = 归属不对)。身份层缺席(端口报
384
- * `absent`)一律按归属不对处理:那时没有可换的身份,不能让原文提示用户去补一个不存在的东西。
385
- */
386
- function judgeRequest(req, facts) {
387
- const host = req.headers.host;
388
- if (host === void 0 || !passesHeaderFence(req.headers, host)) return "untrusted";
389
- const hostUrl = parseAuthority(host);
390
- if (hostUrl === void 0) return "untrusted";
391
- if ((isLoopbackHostname(hostUrl.hostname) || isBindAddress(hostUrl, facts.bindHost)) && LOOPBACK_SOCKETS.includes(req.socket.remoteAddress ?? "")) return "allowed";
392
- const verdict = facts.connection.verdictFor(req);
393
- if (verdict.kind === "allowed") return "allowed";
394
- return verdict.kind === "unauthenticated" ? "unauthenticated" : "untrusted";
395
- }
396
- //#endregion
397
325
  //#region src/domain/closure.ts
398
326
  /** 会话 id 去掉 `session-` 前缀后的形状:同一个会话在两代宿主里写法不同(v3 代 id 不带前缀、
399
327
  * 现行代带),归堆、成组、去重一律按这个形状比——两边都归一,不赌调用方传的是哪一代写法。 */
@@ -452,6 +380,9 @@ function firstByBare(members) {
452
380
  * 已被前一组收走的那条,连同它整支子树都不再出现在这一组里——后代在那一组已被全部认领,所以
453
381
  * 「丢掉已认领的」与「不走进已认领的那支」交回的是同一个集合。环上的代价是「少数一条」,
454
382
  * 不是把一次扫描挂死。组序与成员序都按语料原序。
383
+ *
384
+ * 后代也必须是 subagent 起源:普通 fork 的 `parentSession` 只是来源记录,它不进组、也不穿过它收孩子
385
+ * (否则从 subagent 分出去的分支会被当成孤儿永久删掉)。
455
386
  */
456
387
  function orphanRootsOf(corpus, known) {
457
388
  const knownBares = /* @__PURE__ */ new Set();
@@ -460,6 +391,7 @@ function orphanRootsOf(corpus, known) {
460
391
  for (const session of corpus) {
461
392
  const parent = session.parentSessionId;
462
393
  if (parent === void 0) continue;
394
+ if (!session.subagent) continue;
463
395
  const key = bareSessionId(parent);
464
396
  const list = children.get(key);
465
397
  if (list === void 0) children.set(key, [session]);
@@ -1415,8 +1347,6 @@ async function imageIdsOfDir(deps, dir) {
1415
1347
  };
1416
1348
  }
1417
1349
  }
1418
- /** 单个 JSON 引用源的扫描上限;超过就记成不可读(fail closed),**不许当作"没有引用"跳过**。 */
1419
- const MAX_JSON_REF_BYTES = 10485760;
1420
1350
  /** 读全部剩余引用源;任何一处读不出都记进 `unreadable`。 */
1421
1351
  async function collectRemainingImageRefs(deps, input) {
1422
1352
  const exclude = new Set(input.excludeDirs);
@@ -1439,6 +1369,7 @@ async function collectRemainingImageRefs(deps, input) {
1439
1369
  if (await deps.fs.exists(trashRoot)) for (const name of await deps.fs.listDirs(trashRoot)) await visit(`${trashRoot}/${name}`);
1440
1370
  const scanJson = async (root) => {
1441
1371
  if (!await deps.fs.exists(root)) return;
1372
+ const maxJsonRefBytes = deps.config.maxJsonRefBytes;
1442
1373
  const stack = [root];
1443
1374
  const seen = /* @__PURE__ */ new Set();
1444
1375
  while (stack.length > 0) {
@@ -1452,8 +1383,8 @@ async function collectRemainingImageRefs(deps, input) {
1452
1383
  if (!name.endsWith(".json")) continue;
1453
1384
  const file = `${dir}/${name}`;
1454
1385
  const stat = await deps.fs.statFile(file);
1455
- if (stat.size > MAX_JSON_REF_BYTES) {
1456
- unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${MAX_JSON_REF_BYTES} 字节,未扫描`);
1386
+ if (stat.size > maxJsonRefBytes) {
1387
+ unreadable.push(`${file}: JSON 引用源 ${stat.size} 字节超过扫描上限 ${maxJsonRefBytes} 字节,未扫描`);
1457
1388
  continue;
1458
1389
  }
1459
1390
  const text = (await deps.fs.readFile(file)).toString("utf8");
@@ -1975,8 +1906,9 @@ async function executeRemoval(deps, plan) {
1975
1906
  * 彻底删除(组语义见 `src/trash/groups.ts`),把它的根当孤儿删掉就是**替用户做了一个没人点过的决定**。孤儿只
1976
1907
  * 有一种来路:父被彻底删掉了(或被手工从盘上清掉了),清册里再也没有这一行。
1977
1908
  *
1978
- * 根按**传递闭包**收全部后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
1979
- * `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限),同一条记录只归一组
1909
+ * 根按**传递闭包**收全部 subagent 后代(与 `HostCorpusPort.subagentMembersOf` 同一实现:闭包核心住
1910
+ * `src/domain/closure.ts` 的 `orphanRootsOf`,孩子与孙代都算,深度不设上限;普通 fork 不是成员、
1911
+ * 也不穿过它收后代),同一条记录只归一组
1980
1912
  * (`claimed`)——数据里真出现环时结果是"少数一条",不是把一个会话删两遍。
1981
1913
  * 归堆与去重**两边都去 `session-` 前缀**:同一个父在两代宿主里写法不同(见 `bareSessionId` 的来历)。
1982
1914
  */
@@ -2147,7 +2079,10 @@ function liveSessionsOf(ctx) {
2147
2079
  if (typeof store.get !== "function" || typeof store.liveEntryFor !== "function") return void 0;
2148
2080
  return store;
2149
2081
  }
2150
- /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。 */
2082
+ /** 官方的「这些父的孩子」那一支:`kind:'parent'` 的判据逐字是 `values.includes(record.header.parentSession ?? null)`。
2083
+ * 它只按 `parentSession` 原样匹配,**不是**子会话分类器:旧宿主(≤0.2.1-rc.5)的普通 fork 也写
2084
+ * `parentSession`,0.2.2-alpha.2 起才不写。子会话的判据是 `origin` 的 subagent 标记(宿主归档闸、归档
2085
+ * 准入、持久化找子日志三处一致),所以调用方必须再按 origin 筛一遍。 */
2151
2086
  async function filterChildrenOf(sessionQuery, values) {
2152
2087
  return sessionQuery.filterSessions([{
2153
2088
  kind: "parent",
@@ -2174,10 +2109,11 @@ async function descendantsByParent(sessionQuery, roots) {
2174
2109
  const parent = record.header?.parentSession;
2175
2110
  if (typeof id !== "string" || id.length === 0) continue;
2176
2111
  if (typeof parent !== "string" || parent.length === 0) continue;
2112
+ if (record.header?.origin !== "subagent") continue;
2177
2113
  const bare = bareSessionId(id);
2178
2114
  if (seen.has(bare)) continue;
2179
2115
  seen.add(bare);
2180
- const cwd = record.header?.cwd;
2116
+ const cwd = record.header.cwd;
2181
2117
  const descendant = {
2182
2118
  sessionId: id,
2183
2119
  projectDir: cwd === void 0 ? NO_CWD_PROJECT_DIR : projectKey(cwd)
@@ -3138,8 +3074,42 @@ function readTrashDetail(deps, limits = DEFAULT_LIMITS) {
3138
3074
  };
3139
3075
  }
3140
3076
  //#endregion
3077
+ //#region src/ports/attachments-port.ts
3078
+ /** 服务在 `available()` 之后消失/形状变了:调用方可以退回直接 fs,而不是把这次读取判成 io。 */
3079
+ var AttachmentsUnavailableError = class extends Error {
3080
+ constructor(message = "attachments service absent") {
3081
+ super(message);
3082
+ this.name = "AttachmentsUnavailableError";
3083
+ }
3084
+ };
3085
+ /** 每次调用惰性解析服务:插件与 `attachments` 的激活顺序没有承诺。 */
3086
+ function createAttachmentsPort(ctx) {
3087
+ const serviceOf = () => {
3088
+ try {
3089
+ const service = ctx.get("attachments");
3090
+ if (service === void 0 || service === null) return void 0;
3091
+ if (typeof service.readImage !== "function" || typeof service.readFileStream !== "function") return void 0;
3092
+ return service;
3093
+ } catch {
3094
+ return;
3095
+ }
3096
+ };
3097
+ return {
3098
+ available: () => serviceOf() !== void 0,
3099
+ async readImage(ref) {
3100
+ const service = serviceOf();
3101
+ if (service === void 0) throw new AttachmentsUnavailableError();
3102
+ return await service.readImage(ref);
3103
+ },
3104
+ async *readFileStream(ref) {
3105
+ const service = serviceOf();
3106
+ if (service === void 0) throw new AttachmentsUnavailableError();
3107
+ yield* service.readFileStream(ref);
3108
+ }
3109
+ };
3110
+ }
3111
+ //#endregion
3141
3112
  //#region src/api/attachment.ts
3142
- const MAX_ATTACHMENT_BYTES = 20971520;
3143
3113
  const PNG_MAGIC = Buffer.from([
3144
3114
  137,
3145
3115
  80,
@@ -3150,6 +3120,21 @@ const PNG_MAGIC = Buffer.from([
3150
3120
  26,
3151
3121
  10
3152
3122
  ]);
3123
+ const IMAGE_MEDIA_TYPES = [
3124
+ "image/png",
3125
+ "image/jpeg",
3126
+ "image/webp",
3127
+ "image/gif"
3128
+ ];
3129
+ function isImageMediaType(value) {
3130
+ return value !== null && IMAGE_MEDIA_TYPES.includes(value);
3131
+ }
3132
+ /** 引用里的正整数字段(bytes / width / height);缺、空、非整数、非正数都当"元数据不够",退回直接 fs。 */
3133
+ function positiveInt(value) {
3134
+ if (value === null || value === "") return void 0;
3135
+ const parsed = Number(value);
3136
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : void 0;
3137
+ }
3153
3138
  /** 按魔数认图片类型;认不出回 `undefined`(调用方按未知字节处理,不猜)。 */
3154
3139
  function sniffImage(bytes) {
3155
3140
  if (bytes.length >= PNG_MAGIC.length && bytes.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return "image/png";
@@ -3164,63 +3149,185 @@ function sniffImage(bytes) {
3164
3149
  * 否则 `encodeURIComponent` 会在写头时抛)。
3165
3150
  */
3166
3151
  function safeFilename(name, fallback) {
3167
- const raw = name === null || name === "" ? fallback : name;
3152
+ const raw = name === null || name === void 0 || name === "" ? fallback : name;
3168
3153
  const base = Array.from(raw).slice(0, 120).join("").replace(/[\\/:*?"<>|\u0000-\u001f\uD800-\uDFFF]/gu, "_").replace(/\.\./gu, "_");
3169
3154
  return base === "" ? fallback : base;
3170
3155
  }
3171
- async function readAttachmentResponse(deps, query) {
3156
+ /** 引用里的短 id:错误文案与下载名兜底共用,别让 `sha256:` 前缀的切片散在各处。 */
3157
+ function shortId(id) {
3158
+ return id.slice(7, 19);
3159
+ }
3160
+ /** 普通文件的下载响应;图片认不出类型时也走这一支。 */
3161
+ function downloadResponse(body, filename) {
3162
+ return {
3163
+ body,
3164
+ contentType: "application/octet-stream",
3165
+ disposition: "attachment",
3166
+ filename
3167
+ };
3168
+ }
3169
+ /** 服务读取/校验失败按 io 上屏,不 fallback 直接 fs:宿主读 `files/.../<name>` 别名并做摘要/元数据
3170
+ * 校验,直接 fs 读 `file-objects/...` 对象;绕过会跳过宿主的校验(ADR-0035 边界)。 */
3171
+ function attachmentsIoError(id, error) {
3172
+ throw verbError("io", `读取附件失败:${id}(${String(error)})`, error);
3173
+ }
3174
+ /** 同一道上限:声明字节、磁盘字节、服务返回字节都从这里过。 */
3175
+ function assertWithinLimit(id, observed, maxBytes) {
3176
+ if (observed > maxBytes) throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(${observed} 字节)`);
3177
+ }
3178
+ /** 流式读超过上限:没有"实际总字节",原文用 `>上限`。 */
3179
+ function streamOverflow(id, maxBytes) {
3180
+ throw verbError("io", `附件超过 ${maxBytes} 字节上限:${id}(>${maxBytes} 字节)`);
3181
+ }
3182
+ /** 服务路径要的引用元数据;任一字段不够就回 `undefined`(调用方退回直接 fs,不拿半截 ref 去猜)。 */
3183
+ function imageServiceRef(id, query) {
3184
+ const mediaType = query.get(PARAMS.attachmentMediaType);
3185
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3186
+ const width = positiveInt(query.get(PARAMS.attachmentWidth));
3187
+ const height = positiveInt(query.get(PARAMS.attachmentHeight));
3188
+ if (!isImageMediaType(mediaType) || bytes === void 0 || width === void 0 || height === void 0) return void 0;
3189
+ const name = query.get(PARAMS.attachmentName);
3190
+ return {
3191
+ attachmentId: id,
3192
+ mediaType,
3193
+ bytes,
3194
+ width,
3195
+ height,
3196
+ ...name === null || name === "" ? {} : { name }
3197
+ };
3198
+ }
3199
+ function fileServiceRef(id, query) {
3200
+ const bytes = positiveInt(query.get(PARAMS.attachmentBytes));
3201
+ const name = query.get(PARAMS.attachmentName);
3202
+ if (bytes === void 0 || name === null || name === "") return void 0;
3203
+ return {
3204
+ attachmentId: id,
3205
+ name,
3206
+ bytes
3207
+ };
3208
+ }
3209
+ function parseServiceRef(id, kind, query) {
3210
+ if (kind === "image") {
3211
+ const ref = imageServiceRef(id, query);
3212
+ return ref === void 0 ? void 0 : {
3213
+ kind: "image",
3214
+ ref
3215
+ };
3216
+ }
3217
+ const ref = fileServiceRef(id, query);
3218
+ return ref === void 0 ? void 0 : {
3219
+ kind: "file",
3220
+ ref
3221
+ };
3222
+ }
3223
+ /** query 只解析一次:id/kind 的 400 闸与两套引用元数据都在这里收口。 */
3224
+ function parseAttachmentRequest(query) {
3172
3225
  const id = query.get(PARAMS.attachmentId);
3173
3226
  const kind = query.get(PARAMS.attachmentKind);
3174
3227
  if (!isAttachmentId(id)) throw routeError("bad-request", 400, `附件 id 需为 sha256:<64 位小写 hex>:${String(id)}`);
3175
3228
  if (kind !== "image" && kind !== "file") throw routeError("bad-request", 400, `附件 kind 只认 image / file:${String(kind)}`);
3176
- const path = attachmentPathOf(deps.home, kind, id);
3229
+ const name = query.get(PARAMS.attachmentName);
3230
+ return {
3231
+ id,
3232
+ kind,
3233
+ name: name === null || name === "" ? void 0 : name,
3234
+ serviceRef: parseServiceRef(id, kind, query)
3235
+ };
3236
+ }
3237
+ async function readImageViaAttachments(deps, ref, maxBytes) {
3238
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3239
+ let stored;
3240
+ try {
3241
+ stored = await deps.attachments.readImage(ref);
3242
+ } catch (error) {
3243
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3244
+ attachmentsIoError(ref.attachmentId, error);
3245
+ }
3246
+ const body = Buffer.from(stored.data);
3247
+ assertWithinLimit(ref.attachmentId, body.length, maxBytes);
3248
+ return {
3249
+ body,
3250
+ contentType: stored.ref.mediaType,
3251
+ disposition: "inline"
3252
+ };
3253
+ }
3254
+ /** 普通文件走宿主服务的分块读;超限在累计处停下,不把整份字节读进内存。 */
3255
+ async function readFileViaAttachments(deps, ref, maxBytes) {
3256
+ assertWithinLimit(ref.attachmentId, ref.bytes, maxBytes);
3257
+ const chunks = [];
3258
+ let total = 0;
3259
+ let tooLarge = false;
3260
+ try {
3261
+ for await (const chunk of deps.attachments.readFileStream(ref)) {
3262
+ const buffer = Buffer.from(chunk);
3263
+ total += buffer.length;
3264
+ if (total > maxBytes) {
3265
+ tooLarge = true;
3266
+ break;
3267
+ }
3268
+ chunks.push(buffer);
3269
+ }
3270
+ } catch (error) {
3271
+ if (error instanceof AttachmentsUnavailableError) return void 0;
3272
+ attachmentsIoError(ref.attachmentId, error);
3273
+ }
3274
+ if (tooLarge) streamOverflow(ref.attachmentId, maxBytes);
3275
+ return downloadResponse(Buffer.concat(chunks), safeFilename(ref.name, shortId(ref.attachmentId)));
3276
+ }
3277
+ /** 服务优先的唯一入口;`undefined` = 服务缺席 / 元数据不够 / 服务中途消失,调用方退回直接 fs。 */
3278
+ async function readViaAttachments(deps, request, maxBytes) {
3279
+ if (request.serviceRef === void 0) return void 0;
3280
+ if (!deps.attachments.available()) return void 0;
3281
+ const serviceRef = request.serviceRef;
3282
+ return serviceRef.kind === "image" ? readImageViaAttachments(deps, serviceRef.ref, maxBytes) : readFileViaAttachments(deps, serviceRef.ref, maxBytes);
3283
+ }
3284
+ /** 旧宿主 / 元数据不够:退回直接 fs(路径仍只由 id 与 kind 推导)。 */
3285
+ async function readViaFs(deps, request, maxBytes) {
3286
+ const path = attachmentPathOf(deps.home, request.kind, request.id);
3177
3287
  let size;
3178
3288
  try {
3179
3289
  size = (await deps.fs.statFile(path)).size;
3180
3290
  } catch (error) {
3181
- throw verbError("io", `附件不在:${id}(${String(error)})`, error);
3291
+ throw verbError("io", `附件不在:${request.id}(${String(error)})`, error);
3182
3292
  }
3183
- if (size > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${size} 字节)`);
3293
+ assertWithinLimit(request.id, size, maxBytes);
3184
3294
  const bytes = await deps.fs.readFile(path);
3185
- if (bytes.length > MAX_ATTACHMENT_BYTES) throw verbError("io", `附件超过 ${MAX_ATTACHMENT_BYTES} 字节上限:${id}(${bytes.length} 字节)`);
3186
- if (kind === "file") return {
3187
- body: bytes,
3188
- contentType: "application/octet-stream",
3189
- disposition: "attachment",
3190
- filename: safeFilename(query.get(PARAMS.attachmentName), id.slice(7, 19))
3191
- };
3295
+ assertWithinLimit(request.id, bytes.length, maxBytes);
3296
+ if (request.kind === "file") return downloadResponse(bytes, safeFilename(request.name, shortId(request.id)));
3192
3297
  const contentType = sniffImage(bytes);
3193
- if (contentType === void 0) return {
3194
- body: bytes,
3195
- contentType: "application/octet-stream",
3196
- disposition: "attachment",
3197
- filename: safeFilename(query.get(PARAMS.attachmentName), `${id.slice(7, 19)}.bin`)
3198
- };
3298
+ if (contentType === void 0) return downloadResponse(bytes, safeFilename(request.name, `${shortId(request.id)}.bin`));
3199
3299
  return {
3200
3300
  body: bytes,
3201
3301
  contentType,
3202
3302
  disposition: "inline"
3203
3303
  };
3204
3304
  }
3305
+ async function readAttachmentResponse(deps, query) {
3306
+ const request = parseAttachmentRequest(query);
3307
+ const maxBytes = deps.config.maxAttachmentBytes;
3308
+ const served = await readViaAttachments(deps, request, maxBytes);
3309
+ if (served !== void 0) return served;
3310
+ return readViaFs(deps, request, maxBytes);
3311
+ }
3205
3312
  //#endregion
3206
3313
  //#region src/api/attachment-orphans.ts
3207
3314
  /**
3208
3315
  * 孤儿附件的报告段:`GET /attachment-orphans`。
3209
3316
  *
3210
3317
  * 报告把扫描计划折成数量/字节 + 对象清单(`id`/`bytes`,供设置页预览缩略图;最多
3211
- * `ORPHAN_PREVIEW_LIMIT` 条,清理不受预览条数限制)。扫描跳过(引用源或对象列表读不出)时
3212
- * **报告必须失败**——回一份 0 会让界面说"没有孤儿",而这类失败恰恰是"不知道",不是"没有"。
3318
+ * `src/config.ts` 的 `orphanPreviewLimit`(默认 200,ADR-0037),清理不受预览条数限制)。
3319
+ * 扫描跳过(引用源或对象列表读不出)时**报告必须失败**——回一份 0 会让界面说"没有孤儿",
3320
+ * 而这类失败恰恰是"不知道",不是"没有"。
3213
3321
  * 清理是 `POST /attachment-clean`,由路由直接接到 `cleanOrphanImageAttachments`。
3214
3322
  */
3215
- /** 预览清单的上限:与详情附件的载荷闸同一量级。数量与字节仍按全量报告。 */
3216
- const ORPHAN_PREVIEW_LIMIT = 200;
3217
3323
  async function reportAttachmentOrphans(deps) {
3218
3324
  const plan = await scanOrphanImageAttachments(deps);
3219
3325
  if (plan.skipped !== void 0) throw verbError("io", plan.skipped);
3326
+ const limit = deps.config.orphanPreviewLimit;
3220
3327
  return {
3221
3328
  count: plan.entries.length,
3222
3329
  totalBytes: plan.bytes,
3223
- items: plan.entries.slice(0, ORPHAN_PREVIEW_LIMIT).map((entry) => ({
3330
+ items: plan.entries.slice(0, limit).map((entry) => ({
3224
3331
  attachmentId: entry.id,
3225
3332
  bytes: entry.bytes
3226
3333
  }))
@@ -3356,13 +3463,14 @@ const ROUTE_TABLE = {
3356
3463
  run: async () => "pong"
3357
3464
  },
3358
3465
  [ROUTES.attachment.path]: {
3359
- slice: "core",
3466
+ slice: "attachment",
3360
3467
  method: ROUTES.attachment.method,
3361
3468
  body: "none",
3362
3469
  purity: "pure",
3363
3470
  read: "none",
3364
3471
  response: "binary",
3365
- run: async (deps, _body, _read, query) => readAttachmentResponse(deps, query)
3472
+ fetchGate: "same-origin",
3473
+ run: async (deps, query) => readAttachmentResponse(deps, query)
3366
3474
  },
3367
3475
  [ROUTES.attachmentOrphans.path]: {
3368
3476
  slice: "core",
@@ -3434,95 +3542,37 @@ function routeOf(rest) {
3434
3542
  }
3435
3543
  //#endregion
3436
3544
  //#region src/api/dispatch.ts
3437
- /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3438
- const JSON_BODY_MAX_BYTES = 1048576;
3439
- function writeJson(res, status, body) {
3440
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
3441
- res.end(JSON.stringify(body));
3442
- }
3443
- function writeOk(res, value) {
3444
- writeJson(res, 200, {
3445
- ok: true,
3446
- value
3447
- });
3448
- }
3449
3545
  /**
3450
- * 二进制响应(`/attachment` 专用)。四道头都是判据不是口味:
3451
- * - `content-type` 由处理函数给(图片按魔数认,文件一律 octet-stream);
3452
- * - `content-length` 与字节数同源;
3453
- * - `cache-control: private, no-store`:附件可能来自已删除会话,不许被任何中间层留下;
3454
- * - `nosniff` + `attachment` 下载名:字节内容与类型不一致时浏览器不许改猜。
3546
+ * /dsh-session-delete-api 的分发核心:只负责路由记录的执行。
3547
+ *
3548
+ * 传输适配器只有 `src/api/connection-route.ts` 一个:九段注册到宿主 `connection.fetch.register`
3549
+ * 的 exact Fetch 路由(`Request` / `Response`),Host/Origin/浏览器认证由宿主 `/api` bridge 完成
3550
+ * (ADR-0033)。`Request` 的字节闸 / JSON 解析、`Response` 的头与失败信封都归适配器;
3551
+ * 这里只收已经解析好的请求,做查表 → 按段注入窄面 → 跑处理 → 回结果。
3552
+ *
3553
+ * 纯度、body 声明、读面与方法仍住 `src/api/route-table.ts` 与 `src/shared/wire.ts`;这里只按判据执行。
3455
3554
  *
3456
- * **失败不走这里**:处理函数抛出的错误仍由 `writeFailure` 写 JSON 信封(图片的 `onError` 会换成
3457
- * 占位;普通文件走 `<a download>`,没有页面内失败面)。
3555
+ * - 请求本身不对用 4xx(400 请求体 / 405 方法 / 404 未知路由);操作失败回 200 + {ok:false,error}。
3556
+ * 浏览器半先解信封,再看状态。宿主 exact route 已在 handler 之前把未声明方法 / 路径回 404,
3557
+ * 所以 405 / 未知路由 404 只在适配器直调时可达(`tests/connection-route.test.ts` 钉防御分支)。
3558
+ * - `purity` 为 chained 的请求串行执行,避免读清册/动文件/写清册交错;整个进程共用一条到达序链。
3458
3559
  */
3459
- function writeBinary(res, body) {
3460
- const headers = {
3461
- "content-type": body.contentType,
3462
- "content-length": String(body.body.length),
3463
- "cache-control": "private, no-store",
3464
- "x-content-type-options": "nosniff"
3465
- };
3466
- if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3467
- else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3468
- res.writeHead(200, headers);
3469
- res.end(body.body);
3470
- }
3471
- /** 失败信封:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3472
- * **状态语义**:**请求本身不对**不再伪装成 200——请求体不对 400、方法不对 405、
3473
- * 未知路由 404、栅栏拒 403(它们不是"一次办成的操作",是"这条请求不归我办/你没资格办")。
3474
- * **操作失败**仍回 200 + 信封。浏览器半先解信封再看状态,所以 4xx 上带的信封照样读得出原文;
3475
- * `code` 一律折成 `sessiondelete/io`,那三个路由标签(`bad-request` / `bad-method` / `not-found-route`)
3476
- * 是**抛出处给读代码的人看的站点名**,不许冒充 `src/shared/wire.ts` 的五个动词码上客户端。 */
3477
- function writeFailure(res, error, status) {
3478
- const carried = error?.httpStatus;
3479
- writeJson(res, status ?? (typeof carried === "number" ? carried : 200), {
3480
- ok: false,
3481
- error: toRemoteFailure(error)
3482
- });
3483
- }
3484
- /** 栅栏拒绝的两种原文:归属不对(跨源 / 重绑 / 头不成形)与远程客户端没带宿主身份。
3485
- * 两者都是 403,但客户端会上屏,原文要能告诉用户下一步做什么。 */
3486
- const UNTRUSTED_MESSAGE = "非受信来源";
3487
- const UNAUTHENTICATED_MESSAGE = "需要宿主的 Web 身份:请用 dsh web 打印的那一行 URL 打开页面后重试";
3488
- /** 流式读 body 并解析成 JSON 对象:空体回 `null`,超限 / 非法 JSON / 不是对象一律抛(→ 400 + 失败信封)。
3489
- * **请求本身不对**(400)与**那次操作读不到东西**(200 + 信封)是两件事:前者是调用方没组好请求,
3490
- * 后者是盘上没有;把前者也写成 200,代理与日志里就再也分不出这两类。 */
3491
- async function readJsonBody(req) {
3492
- const chunks = [];
3493
- let size = 0;
3494
- for await (const chunk of req) {
3495
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3496
- size += buf.length;
3497
- if (size > JSON_BODY_MAX_BYTES) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3498
- chunks.push(buf);
3499
- }
3500
- const text = Buffer.concat(chunks).toString("utf8").trim();
3501
- if (text === "") return null;
3502
- try {
3503
- const parsed = JSON.parse(text);
3504
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3505
- return parsed;
3506
- } catch (error) {
3507
- if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3508
- throw error;
3509
- }
3510
- }
3511
3560
  function requireMethod(method, want, route) {
3512
3561
  if (method !== want) throw routeError("bad-method", 405, `方法不允许:${method}(${SESSION_DELETE_API_PREFIX}${route} 需 ${want})`);
3513
3562
  }
3563
+ /** 段名的 404 与方法的 405 只在这里判一次;空段名按插件前缀显示,避免 404 原文出现一个空串。 */
3564
+ function resolveRoute(method, rest) {
3565
+ const spec = routeOf(rest);
3566
+ if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${method} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3567
+ requireMethod(method, spec.method, rest);
3568
+ return spec;
3569
+ }
3514
3570
  function isPure(purity, body) {
3515
3571
  return typeof purity === "function" ? purity(body) === "pure" : purity === "pure";
3516
3572
  }
3517
- function readBodyFor(kind, req) {
3518
- return kind === "json" ? readJsonBody(req) : null;
3519
- }
3520
3573
  /**
3521
3574
  * 全量面按行收窄(ADR-0032):组合根装出的 {@link DeleteDeps} 只交给 `/apply`;每一段只拿到
3522
- * 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影:`core` 拿到的是
3523
- * 新建的 `CoreFsPort` 面,`removeDir` / `removeFile` 根本不在对象上;`now` 也不在 `core` / `removal`
3524
- * 上。三格逐行 `satisfies` 是闸:全量面必须结构满足每一格;`coreOf` 的 fs 字面量又逼着
3525
- * `CoreFsPort` 新增方法时同步补进来——漏了编译不过,而不是等某个段的 `run` 静默拿到超面。
3575
+ * 自己声明的那一格(路由表的 `slice`)。收窄是**运行期事实**,不是类型投影。
3526
3576
  */
3527
3577
  function coreOf(full) {
3528
3578
  return {
@@ -3540,7 +3590,8 @@ function coreOf(full) {
3540
3590
  host: full.host,
3541
3591
  ledger: full.ledger,
3542
3592
  home: full.home,
3543
- presence: full.presence
3593
+ presence: full.presence,
3594
+ config: full.config
3544
3595
  };
3545
3596
  }
3546
3597
  /** `removal` 保留删除能力,但不带会话删除的时钟;`full` 只给 `/apply`。 */
@@ -3550,25 +3601,32 @@ function removalOf(full) {
3550
3601
  host: full.host,
3551
3602
  ledger: full.ledger,
3552
3603
  home: full.home,
3553
- presence: full.presence
3604
+ presence: full.presence,
3605
+ config: full.config
3554
3606
  };
3555
3607
  }
3556
- /** 三格只建一次:收窄对象在 handler 构造期固定,请求路径只做引用选择。 */
3608
+ /** 四格只建一次:收窄对象在 dispatcher 构造期固定,请求路径只做引用选择。 */
3557
3609
  function slicesOf(full) {
3610
+ const core = coreOf(full);
3558
3611
  return {
3559
- core: coreOf(full),
3612
+ core,
3560
3613
  removal: removalOf(full),
3561
- full
3614
+ full,
3615
+ attachment: {
3616
+ ...core,
3617
+ attachments: full.attachments
3618
+ }
3562
3619
  };
3563
3620
  }
3564
- /** 按行选格:`spec.slice` 与三格的键同域,查表结果的类型由 `S` 直接给出。 */
3621
+ /** 按行选格:`spec.slice` 与 {@link SliceDeps} 的键同域,查表结果的类型由 `S` 直接给出。 */
3565
3622
  function depsFor(spec, slices) {
3566
3623
  return slices[spec.slice];
3567
3624
  }
3568
- function createApiHandler(deps, trust) {
3625
+ /** 路由执行器:查段 → 方法闸 → 读面 → 处理;返回归一结果。组合根只建一次。 */
3626
+ function createDispatcher(deps) {
3569
3627
  const slices = slicesOf(deps);
3570
3628
  /**
3571
- * 到达序串行链(见文件头):路由表里 `purity === 'chained'` 的请求一个跑完再放下一个。
3629
+ * 到达序串行链:`purity === 'chained'` 的请求一个跑完再放下一个。
3572
3630
  * `tail` 由构造保证**永不 reject**(每个任务自己的失败留在交回给那次请求的那个 promise 上),
3573
3631
  * 所以一次失败不会卡死链。
3574
3632
  */
@@ -3578,53 +3636,272 @@ function createApiHandler(deps, trust) {
3578
3636
  tail = started.then(() => void 0, () => void 0);
3579
3637
  return started;
3580
3638
  };
3581
- return async function handle(req, res) {
3582
- const verdict = judgeRequest(req, trust);
3583
- if (verdict !== "allowed") {
3584
- writeFailure(res, /* @__PURE__ */ new Error(verdict === "untrusted" ? UNTRUSTED_MESSAGE : UNAUTHENTICATED_MESSAGE), 403);
3585
- return;
3639
+ return async function dispatch(input) {
3640
+ const spec = resolveRoute(input.method, input.rest);
3641
+ const kind = spec.read;
3642
+ let rowsPromise;
3643
+ const read = { rows() {
3644
+ if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3645
+ rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3646
+ return rowsPromise;
3647
+ } };
3648
+ const rowDeps = depsFor(spec, slices);
3649
+ const task = () => spec.response === "binary" ? spec.run(rowDeps, input.search) : spec.run(rowDeps, input.body, read);
3650
+ const result = await (isPure(spec.purity, input.body) ? task() : throughChain(task));
3651
+ if (spec.response === "binary") return {
3652
+ kind: "binary",
3653
+ body: result
3654
+ };
3655
+ return {
3656
+ kind: "json",
3657
+ status: 200,
3658
+ body: {
3659
+ ok: true,
3660
+ value: result
3661
+ }
3662
+ };
3663
+ };
3664
+ }
3665
+ //#endregion
3666
+ //#region src/api/connection-route.ts
3667
+ /**
3668
+ * 现代宿主的 exact Fetch 路由适配器:把九段注册到 `connection.fetch.register`,
3669
+ * 由宿主 `/api` bridge 做 Host/Origin/浏览器认证(ADR-0033)。
3670
+ *
3671
+ * 为什么路径必须带 `/api`:宿主 `assertFetchRoute` 用 `endpointFromPath('/api', path)` 校验,
3672
+ * 不带 `/api/` 前缀会在注册时直接抛错。`request.url` 会被 bridge 重写成 `http://dsh.internal/...`,
3673
+ * 这里只取 `pathname` / `searchParams`,不依赖外部 origin。
3674
+ *
3675
+ * 这条路由**不重复宿主的鉴权**:Host/Origin/浏览器认证在 handler 之前已经由宿主完成;只有路由记录
3676
+ * 声明了 `fetchGate` 的段(`/attachment`)再判一次同源取数(`isSameOriginFetch`)——那是字节 oracle
3677
+ * 的收口,不是身份。插件的 4xx(请求体 / 方法)与 200 操作失败信封在这里落地——`createDispatcher`
3678
+ * 只抛带 `httpStatus` 的请求错误。401/403 是宿主的纯文本响应,客户端负责映射。适配器拥有 wire 机制:
3679
+ * body 字节闸、JSON 解析、二进制头与失败信封。
3680
+ */
3681
+ /** 请求体字节上限:这条通道只收几个 id,1MiB 是"够用且不给自己开一条内存放大器"的量级。 */
3682
+ const JSON_BODY_MAX_BYTES = 1048576;
3683
+ /** 请求体文本 → JSON 对象;空体 `null`;非对象 / 非法 JSON 抛 400。 */
3684
+ function parseJsonBody(text) {
3685
+ const trimmed = text.trim();
3686
+ if (trimmed === "") return null;
3687
+ try {
3688
+ const parsed = JSON.parse(trimmed);
3689
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw routeError("bad-request", 400, "请求体需为 JSON 对象");
3690
+ return parsed;
3691
+ } catch (error) {
3692
+ if (error instanceof SyntaxError) throw routeError("bad-request", 400, `请求体不是合法 JSON:${error.message}`);
3693
+ throw error;
3694
+ }
3695
+ }
3696
+ /**
3697
+ * 二进制响应的四道头(`/attachment` 专用)。判据不是口味:类型由处理函数给、长度与字节同源、
3698
+ * 不许任何中间层留下已删除会话的附件、字节与声明类型不一致时浏览器不许改猜。
3699
+ */
3700
+ function binaryHeaders(body) {
3701
+ const headers = {
3702
+ "content-type": body.contentType,
3703
+ "content-length": String(body.body.length),
3704
+ "cache-control": "private, no-store",
3705
+ "x-content-type-options": "nosniff"
3706
+ };
3707
+ if (body.filename !== void 0) headers["content-disposition"] = `${body.disposition}; filename*=UTF-8''${encodeURIComponent(body.filename)}`;
3708
+ else if (body.disposition === "attachment") headers["content-disposition"] = "attachment";
3709
+ return headers;
3710
+ }
3711
+ /**
3712
+ * 失败的落地:码与原文都来自 `toRemoteFailure`(本插件唯一的错误映射表)。
3713
+ * 状态语义:**请求本身不对**不伪装成 200——请求体 400、方法 405、未知路由 404;
3714
+ * **操作失败**回 200 + 信封。`code` 一律折成 `sessiondelete/io`,路由站点名不冒充动词码。
3715
+ */
3716
+ function failureResult(error, status) {
3717
+ const carried = error?.httpStatus;
3718
+ return {
3719
+ kind: "json",
3720
+ status: status ?? (typeof carried === "number" ? carried : 200),
3721
+ body: {
3722
+ ok: false,
3723
+ error: toRemoteFailure(error)
3586
3724
  }
3725
+ };
3726
+ }
3727
+ /**
3728
+ * 同源取数闸(ADR-0033 边界收口):附件字节能被任意页面当 `<img>` 渲染,而宿主 cookie 的
3729
+ * `SameSite=Strict` 按 **site** 判——同 IP 不同端口仍会带上。宿主 `/api` 只拦 `cross-site` 与
3730
+ * authority 不符的 `Origin`,这一格补在插件这一侧:
3731
+ * - `Sec-Fetch-Site` 为 `same-site` / `cross-site` ⇒ 拒;
3732
+ * - http(s) 的 `Origin` / `Referer` 与 `Host` 不同 authority ⇒ 拒(覆盖不发送 Fetch Metadata 的代次);
3733
+ * - 桌面壳的 `dsh-app:` 来源与三头全缺的非浏览器客户端放行——桌面转发器已摘掉
3734
+ * `host` / `origin` / `sec-fetch-site`,「摘干净」不是不可信。
3735
+ *
3736
+ * `Host` 依赖的是宿主 bridge 保留原始请求头(它只改写 `request.url`);真机与
3737
+ * `tests/api.test.ts` 的 `node:http` 桥都钉住这一条:同源 `Referer` 能对上 `Host`。
3738
+ */
3739
+ function isSameOriginFetch(request) {
3740
+ const site = request.headers.get("sec-fetch-site");
3741
+ if (site === "same-origin") return true;
3742
+ if (site === "same-site" || site === "cross-site") return false;
3743
+ const host = request.headers.get("host");
3744
+ for (const name of ["origin", "referer"]) {
3745
+ const value = request.headers.get(name);
3746
+ if (value === null) continue;
3747
+ let source;
3587
3748
  try {
3588
- const url = new URL(req.url ?? "/", "http://dsh.internal");
3589
- if (!url.pathname.startsWith("/dsh-session-delete-api")) throw routeError("not-found-route", 404, `未知路由:${url.pathname}`);
3590
- const rest = url.pathname.slice(23);
3591
- const spec = routeOf(rest);
3592
- if (spec === void 0) throw routeError("not-found-route", 404, `未知路由:${req.method ?? "GET"} ${rest === "" ? SESSION_DELETE_API_PREFIX : rest}`);
3593
- requireMethod(req.method ?? "GET", spec.method, rest);
3594
- const body = await readBodyFor(spec.body, req);
3595
- const kind = spec.read;
3596
- let rowsPromise;
3597
- const read = { rows() {
3598
- if (kind === "none") return Promise.reject(/* @__PURE__ */ new Error("route read: none"));
3599
- rowsPromise ??= kind === "list" ? deps.ledger.list() : deps.ledger.raw();
3600
- return rowsPromise;
3601
- } };
3602
- const task = () => spec.run(depsFor(spec, slices), body, read, url.searchParams);
3603
- const result = await (isPure(spec.purity, body) ? task() : throughChain(task));
3604
- if (spec.response === "binary") writeBinary(res, result);
3605
- else writeOk(res, result);
3606
- } catch (error) {
3607
- writeFailure(res, error);
3749
+ source = new URL(value);
3750
+ } catch {
3751
+ return false;
3752
+ }
3753
+ if (source.protocol !== "http:" && source.protocol !== "https:" && source.protocol !== "dsh-app:") return false;
3754
+ if (source.protocol === "dsh-app:") continue;
3755
+ if (host === null || !isSameAuthority(source, host)) return false;
3756
+ }
3757
+ return true;
3758
+ }
3759
+ /** 按来源的协议解析 `Host`,两侧都过 WHATWG 归一(默认端口、大小写)后比 authority。 */
3760
+ function isSameAuthority(source, host) {
3761
+ try {
3762
+ return new URL(`${source.protocol}//${host}`).host === source.host;
3763
+ } catch {
3764
+ return false;
3765
+ }
3766
+ }
3767
+ /** 边读边数:宿主按 `requestBody: 'buffered'` 已预读整段,这里仍守插件侧的 1 MiB 逻辑闸;
3768
+ * 超限即停,不再把读到的分片拼成更大的字符串。 */
3769
+ async function readRequestJson(request) {
3770
+ const declared = Number(request.headers.get("content-length"));
3771
+ if (Number.isFinite(declared) && declared > 1048576) throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3772
+ const stream = request.body;
3773
+ if (stream === null) return null;
3774
+ const reader = stream.getReader();
3775
+ const chunks = [];
3776
+ let size = 0;
3777
+ try {
3778
+ for (;;) {
3779
+ const { done, value } = await reader.read();
3780
+ if (done) break;
3781
+ size += value.byteLength;
3782
+ if (size > 1048576) {
3783
+ await reader.cancel();
3784
+ throw routeError("bad-request", 400, `请求体超过 ${JSON_BODY_MAX_BYTES} 字节上限`);
3785
+ }
3786
+ chunks.push(value);
3608
3787
  }
3788
+ } finally {
3789
+ reader.releaseLock();
3790
+ }
3791
+ return parseJsonBody(Buffer.concat(chunks).toString("utf8"));
3792
+ }
3793
+ function toResponse(result) {
3794
+ if (result.kind === "binary") return new Response(new Uint8Array(result.body.body), {
3795
+ status: 200,
3796
+ headers: binaryHeaders(result.body)
3797
+ });
3798
+ return new Response(JSON.stringify(result.body), {
3799
+ status: result.status,
3800
+ headers: { "content-type": "application/json; charset=utf-8" }
3801
+ });
3802
+ }
3803
+ /**
3804
+ * 注册九段 exact Fetch 路由。返回一个组合 disposer;调用方把它挂在自己的 effect 上。
3805
+ * `connection` 是插件的硬依赖(web UI / 桌面端都装 `client-connection`);`fetch.register`
3806
+ * 缺席属于宿主形状漂移,直接抛——静默返回空 disposer 会让插件「在但没有入口」。
3807
+ * `dispatch` 与分发器同源。
3808
+ */
3809
+ function registerConnectionRoutes(service, dispatch) {
3810
+ const fetchService = service.fetch;
3811
+ if (fetchService === void 0 || typeof fetchService.register !== "function") throw new Error("session-delete: connection.fetch.register 不可用");
3812
+ const disposers = [];
3813
+ const disposeAll = async () => {
3814
+ for (const dispose of [...disposers].reverse()) try {
3815
+ await dispose();
3816
+ } catch {}
3609
3817
  };
3818
+ try {
3819
+ for (const [routePath, spec] of Object.entries(ROUTE_TABLE)) {
3820
+ const path = `${OFFICIAL_SESSION_DELETE_API_PREFIX}${routePath}`;
3821
+ disposers.push(fetchService.register({
3822
+ path,
3823
+ methods: [spec.method],
3824
+ requestBody: "buffered",
3825
+ fetch: async (request) => {
3826
+ if (spec.fetchGate === "same-origin" && !isSameOriginFetch(request)) return toResponse(failureResult(routeError("bad-request", 403, "附件只允许同源页面读取")));
3827
+ try {
3828
+ const body = spec.body === "json" ? await readRequestJson(request) : null;
3829
+ return toResponse(await dispatch({
3830
+ method: request.method,
3831
+ rest: routePath,
3832
+ search: new URL(request.url).searchParams,
3833
+ body
3834
+ }));
3835
+ } catch (error) {
3836
+ return toResponse(failureResult(error));
3837
+ }
3838
+ }
3839
+ }));
3840
+ }
3841
+ } catch (error) {
3842
+ for (const dispose of [...disposers].reverse()) dispose().catch(() => void 0);
3843
+ throw error;
3844
+ }
3845
+ return disposeAll;
3610
3846
  }
3611
3847
  //#endregion
3612
- //#region src/ports/connection-port.ts
3613
- const HOST_UNAUTHENTICATED = 401;
3614
- /** 成员抛错按「归属不对」拒绝(fail-closed)。 */
3615
- function createConnectionPort(ctx) {
3616
- return { verdictFor: (req) => {
3617
- const service = ctx.get("connection");
3618
- if (service === void 0 || typeof service.requestRejection !== "function") return { kind: "absent" };
3848
+ //#region src/config.ts
3849
+ const DEFAULT_CONFIG = Object.freeze({
3850
+ orphanPreviewLimit: 200,
3851
+ maxAttachmentBytes: 20971520,
3852
+ maxJsonRefBytes: 10485760
3853
+ });
3854
+ const LIMITS = {
3855
+ orphanPreviewLimit: {
3856
+ min: 1,
3857
+ max: 1e4
3858
+ },
3859
+ maxAttachmentBytes: {
3860
+ min: 1,
3861
+ max: 209715200
3862
+ },
3863
+ maxJsonRefBytes: {
3864
+ min: 1,
3865
+ max: 104857600
3866
+ }
3867
+ };
3868
+ function intField(input, key, range) {
3869
+ const value = input[key];
3870
+ if (value === void 0) return DEFAULT_CONFIG[key];
3871
+ if (typeof value !== "number" || !Number.isInteger(value) || value < range.min || value > range.max) throw new Error(`配置项 ${key} 需为 ${range.min}..${range.max} 的整数`);
3872
+ return value;
3873
+ }
3874
+ /**
3875
+ * 把 patch 里的 `config` 归一成完整配置:缺省取默认值,未知键 / 非整数 / 越界都抛。
3876
+ * 抛错会让插件激活失败(响亮),而不是悄悄用半套配置跑。
3877
+ */
3878
+ function normalizeConfig(value) {
3879
+ if (value === void 0 || value === null) return DEFAULT_CONFIG;
3880
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error("配置需为对象");
3881
+ const input = value;
3882
+ const known = new Set(Object.keys(LIMITS));
3883
+ for (const key of Object.keys(input)) if (!known.has(key)) throw new Error(`未知配置项:${key}`);
3884
+ return {
3885
+ orphanPreviewLimit: intField(input, "orphanPreviewLimit", LIMITS.orphanPreviewLimit),
3886
+ maxAttachmentBytes: intField(input, "maxAttachmentBytes", LIMITS.maxAttachmentBytes),
3887
+ maxJsonRefBytes: intField(input, "maxJsonRefBytes", LIMITS.maxJsonRefBytes)
3888
+ };
3889
+ }
3890
+ /**
3891
+ * 插件 Config:Cordis 接受 Standard Schema,`~standard.validate` 交回归一后的配置。
3892
+ * 宿主在激活前校验;`apply` 再归一一次(同一份事实,不会漂)。
3893
+ */
3894
+ const Config = { "~standard": {
3895
+ version: 1,
3896
+ vendor: "dsh-session-delete",
3897
+ validate(value) {
3619
3898
  try {
3620
- const status = service.requestRejection(req);
3621
- if (status === void 0) return { kind: "allowed" };
3622
- return { kind: status === HOST_UNAUTHENTICATED ? "unauthenticated" : "untrusted" };
3623
- } catch {
3624
- return { kind: "untrusted" };
3899
+ return { value: normalizeConfig(value) };
3900
+ } catch (error) {
3901
+ return { issues: [{ message: error instanceof Error ? error.message : String(error) }] };
3625
3902
  }
3626
- } };
3627
- }
3903
+ }
3904
+ } };
3628
3905
  //#endregion
3629
3906
  //#region src/ports/fs-port.ts
3630
3907
  /** 模块级递归:端口方法**解构出来单独调**也不能靠 `this`(`{ sizeOfDir } = port` 会让方法体的
@@ -3957,14 +4234,11 @@ const name = "@xrn1997/dsh-session-delete";
3957
4234
  * - `agents`:只给 handle 捕获用(`ctx.get('agents')`;缺席时 `ctx.inject(['agents'])` 等服务就绪再装)。
3958
4235
  * 它同样可能晚于本插件激活,写进插件 inject 只会让整个插件 pending。
3959
4236
  *
3960
- * `webServer` 是**浏览器半取数通道的承载者**(那条薄线):插件用它注册 `/dsh-session-delete-api`
3961
- * 前缀路由,浏览器半同源 fetch 那九个段名。它是宿主 web 组成里的硬依赖(`dsh-host-webserver` 的
3962
- * `super(ctx, "webServer")`),服务名与 `register({kind:'prefix', path, handler})` 的形状按
3963
- * `dsh-host-webserver/lib/index.js` 与 `README.md` 原文读实,不是猜的。
3964
- *
3965
- * `connection` 只喂请求栅栏的远程支(`src/api/request-trust.ts` ②),按 `ctx.get` 可选读:
3966
- * 服务缺席时插件照常激活,远程客户端 fail-closed。它不进 `inject`——写进去会让没有 web 组成的
3967
- * profile 整个停在 pending。
4237
+ * `connection` 是浏览器半取数通道的唯一承载者:九段注册到 `connection.fetch.register` 的
4238
+ * `/api/dsh-session-delete-api/*`,Host/Origin/浏览器认证由宿主 `/api` bridge 完成(ADR-0033)。
4239
+ * 支持面是 web UI 与桌面端,二者的标准组合都装 `client-connection`;服务缺席时插件停在 pending
4240
+ * ——可见的激活失败,而不是静默地没有入口。`webServer` 不再直接使用:exact route 仍由宿主的
4241
+ * `connection` 挂在其 `/api` 前缀上,插件不需要自己碰物理服务器。
3968
4242
  *
3969
4243
  * 清册的三层各有各的家:raw 绑定 `src/trash/storage-manifest.ts`、读面(对账 + 读)`src/trash/ledger.ts`、
3970
4244
  * 数据根 `src/ports/home.ts`。这里只把它们接起来,任何一层都不在组合根里长判据。
@@ -3972,16 +4246,18 @@ const name = "@xrn1997/dsh-session-delete";
3972
4246
  const inject = [
3973
4247
  "workspaceRegistry",
3974
4248
  "storageDomain",
3975
- "webServer"
4249
+ "connection"
3976
4250
  ];
3977
4251
  /**
3978
- * 装配顺序:**清单 → 端口 → 在场能力 → 动词依赖 → 前缀路由**(`DeleteDeps` 把这些依赖面合成一颗)。
4252
+ * 装配顺序:**配置归一 → 清单 → 端口 → 在场能力 → 动词依赖 → 分发器 → exact Fetch 路由**
4253
+ * (`DeleteDeps` 把这些依赖面合成一颗;`config` 是归一后的只读快照)。
3979
4254
  *
3980
4255
  * 路由用 `ctx.effect` 挂、disposer 归装载它的那根 fiber:插件卸载即摘路由(`register` 返回的
3981
- * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(kind, path)`
3982
- * 在 `register` 里直接抛(`webserver: duplicate prefix route "…"`),响亮失败而不是悄悄覆盖。
4256
+ * disposer 是官方契约)。**同一进程里只注册一次**这件事由宿主把住:重复的 `(path, method)`
4257
+ * 在 `connection.fetch.register` 里直接抛,响亮失败而不是悄悄覆盖。
3983
4258
  */
3984
- function apply(ctx) {
4259
+ function apply(ctx, rawConfig) {
4260
+ const config = normalizeConfig(rawConfig);
3985
4261
  const fs = createFsPort();
3986
4262
  const home = resolveHome();
3987
4263
  const rawManifest = createHostStorageManifest(ctx);
@@ -3992,23 +4268,17 @@ function apply(ctx) {
3992
4268
  ledger: createTrashLedger(rawManifest, fs, home),
3993
4269
  home,
3994
4270
  presence: createMemberPresence(host),
4271
+ config,
4272
+ attachments: createAttachmentsPort(ctx),
3995
4273
  now: () => Date.now()
3996
4274
  };
3997
- const webServer = ctx.webServer;
3998
- const connection = createConnectionPort(ctx);
4275
+ const connection = ctx.get("connection");
4276
+ if (connection === void 0) throw new Error("session-delete: connection 服务缺席");
4277
+ const dispatch = createDispatcher(deps);
3999
4278
  ctx.effect(() => {
4000
- const dispose = webServer.register({
4001
- kind: "prefix",
4002
- path: SESSION_DELETE_API_PREFIX,
4003
- handler: createApiHandler(deps, {
4004
- bindHost: webServer.host,
4005
- connection
4006
- })
4007
- });
4008
- return () => {
4009
- dispose();
4010
- };
4011
- }, "session-delete: /dsh-session-delete-api routes");
4279
+ const dispose = registerConnectionRoutes(connection, dispatch);
4280
+ return () => dispose();
4281
+ }, "session-delete: /api exact Fetch routes");
4012
4282
  }
4013
4283
  //#endregion
4014
- export { apply, inject, name };
4284
+ export { Config, apply, inject, name };