dsh-zen-remote 1.1.16 → 2.0.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.
Files changed (255) hide show
  1. package/AGENTS.md +54 -9
  2. package/README.md +249 -190
  3. package/README.zh-CN.md +247 -201
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +12 -11
  10. package/cordis.patch.yml.example +24 -9
  11. package/docs/interface.md +57 -2
  12. package/docs/remote-access.en.md +99 -58
  13. package/docs/remote-access.md +99 -42
  14. package/dsh-push.mjs +67 -54
  15. package/lan-gate.mjs +88 -20
  16. package/lib/activity.js +168 -0
  17. package/lib/activity.js.map +1 -0
  18. package/lib/admin-routes.js +526 -0
  19. package/lib/admin-routes.js.map +1 -0
  20. package/lib/client-pairing.js +224 -0
  21. package/lib/client-pairing.js.map +1 -0
  22. package/lib/client-routes.js +674 -0
  23. package/lib/client-routes.js.map +1 -0
  24. package/lib/client.js +10051 -4777
  25. package/lib/config.js +346 -0
  26. package/lib/config.js.map +1 -0
  27. package/lib/fetch-route-intercept.js +418 -0
  28. package/lib/fetch-route-intercept.js.map +1 -0
  29. package/lib/fingerprint.js +363 -0
  30. package/lib/fingerprint.js.map +1 -0
  31. package/lib/http.js +69 -0
  32. package/lib/http.js.map +1 -0
  33. package/lib/index.js +675 -54
  34. package/lib/index.js.map +1 -1
  35. package/lib/intercept-shape.js +81 -0
  36. package/lib/intercept-shape.js.map +1 -0
  37. package/lib/intercept.js +3040 -0
  38. package/lib/intercept.js.map +1 -0
  39. package/lib/lan-gate-server.cjs +532 -121
  40. package/lib/merge-streams.js +1165 -0
  41. package/lib/merge-streams.js.map +1 -0
  42. package/lib/relay-access.js +496 -0
  43. package/lib/relay-access.js.map +1 -0
  44. package/lib/relay-client.js +1303 -0
  45. package/lib/relay-client.js.map +1 -0
  46. package/lib/relay-filter.js +282 -0
  47. package/lib/relay-filter.js.map +1 -0
  48. package/lib/relay-server.js +1792 -0
  49. package/lib/relay-server.js.map +1 -0
  50. package/lib/restart-fields.js +34 -0
  51. package/lib/restart-fields.js.map +1 -0
  52. package/lib/restart-watcher.js +106 -0
  53. package/lib/restart-watcher.js.map +1 -0
  54. package/lib/session-reference.js +157 -0
  55. package/lib/session-reference.js.map +1 -0
  56. package/lib/share-export.js +16 -1
  57. package/lib/share-export.js.map +1 -1
  58. package/lib/share-ops.js +86 -0
  59. package/lib/share-ops.js.map +1 -0
  60. package/lib/share-store.js +294 -0
  61. package/lib/share-store.js.map +1 -0
  62. package/lib/types/activity.d.ts +99 -0
  63. package/lib/types/activity.d.ts.map +1 -0
  64. package/lib/types/admin-routes.d.ts +129 -0
  65. package/lib/types/admin-routes.d.ts.map +1 -0
  66. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
  67. package/lib/types/client/MobileHome.d.ts +1 -1
  68. package/lib/types/client/MobileHome.d.ts.map +1 -1
  69. package/lib/types/client/MobileHomeChips.d.ts +1 -1
  70. package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
  71. package/lib/types/client/MobileSessionHeader.d.ts +13 -2
  72. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  73. package/lib/types/client/MobileSessionInfo.d.ts +20 -3
  74. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
  75. package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
  76. package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
  77. package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
  78. package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
  79. package/lib/types/client/RemoteShareMenu.d.ts +28 -0
  80. package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
  81. package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
  82. package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
  83. package/lib/types/client/compat/desktop.d.ts +21 -0
  84. package/lib/types/client/compat/desktop.d.ts.map +1 -0
  85. package/lib/types/client/compat/types.d.ts +57 -0
  86. package/lib/types/client/compat/types.d.ts.map +1 -1
  87. package/lib/types/client/current-session-report.d.ts +37 -0
  88. package/lib/types/client/current-session-report.d.ts.map +1 -0
  89. package/lib/types/client/effects/header-status.d.ts.map +1 -1
  90. package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
  91. package/lib/types/client/effects/permission-sheet.d.ts +39 -0
  92. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
  93. package/lib/types/client/effects/remote-session.d.ts +25 -0
  94. package/lib/types/client/effects/remote-session.d.ts.map +1 -0
  95. package/lib/types/client/effects/turn-fold.d.ts +9 -0
  96. package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
  97. package/lib/types/client/index.d.ts.map +1 -1
  98. package/lib/types/client/locales.d.ts +187 -2
  99. package/lib/types/client/locales.d.ts.map +1 -1
  100. package/lib/types/client/remote-fetch.d.ts +51 -0
  101. package/lib/types/client/remote-fetch.d.ts.map +1 -0
  102. package/lib/types/client/remote-group-expansion.d.ts +41 -0
  103. package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
  104. package/lib/types/client/remote-share-css.d.ts +19 -0
  105. package/lib/types/client/remote-share-css.d.ts.map +1 -0
  106. package/lib/types/client/remote-share-register.d.ts +113 -0
  107. package/lib/types/client/remote-share-register.d.ts.map +1 -0
  108. package/lib/types/client/remote-status-css.d.ts +15 -0
  109. package/lib/types/client/remote-status-css.d.ts.map +1 -0
  110. package/lib/types/client/remote-status-register.d.ts +46 -0
  111. package/lib/types/client/remote-status-register.d.ts.map +1 -0
  112. package/lib/types/client/settings/SettingsSection.d.ts +46 -0
  113. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
  114. package/lib/types/client/settings/register-settings.d.ts +58 -0
  115. package/lib/types/client/settings/register-settings.d.ts.map +1 -0
  116. package/lib/types/client/settings/settings-css.d.ts +10 -0
  117. package/lib/types/client/settings/settings-css.d.ts.map +1 -0
  118. package/lib/types/client/share/share-preview.d.ts.map +1 -1
  119. package/lib/types/client/styles/composer.css.d.ts +1 -1
  120. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  121. package/lib/types/client/styles/header.css.d.ts +1 -1
  122. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  123. package/lib/types/client/styles/info.css.d.ts +1 -1
  124. package/lib/types/client/styles/info.css.d.ts.map +1 -1
  125. package/lib/types/client/styles/remote-session.css.d.ts +2 -0
  126. package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
  127. package/lib/types/client-data/current-session.d.ts +41 -0
  128. package/lib/types/client-data/current-session.d.ts.map +1 -0
  129. package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
  130. package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
  131. package/lib/types/client-data/remote-status.d.ts +148 -0
  132. package/lib/types/client-data/remote-status.d.ts.map +1 -0
  133. package/lib/types/client-data/settings-form.d.ts +774 -0
  134. package/lib/types/client-data/settings-form.d.ts.map +1 -0
  135. package/lib/types/client-data/shares.d.ts +211 -0
  136. package/lib/types/client-data/shares.d.ts.map +1 -0
  137. package/lib/types/client-pairing.d.ts +114 -0
  138. package/lib/types/client-pairing.d.ts.map +1 -0
  139. package/lib/types/client-routes.d.ts +112 -0
  140. package/lib/types/client-routes.d.ts.map +1 -0
  141. package/lib/types/config.d.ts +200 -0
  142. package/lib/types/config.d.ts.map +1 -0
  143. package/lib/types/fetch-route-intercept.d.ts +156 -0
  144. package/lib/types/fetch-route-intercept.d.ts.map +1 -0
  145. package/lib/types/fingerprint.d.ts +164 -0
  146. package/lib/types/fingerprint.d.ts.map +1 -0
  147. package/lib/types/http.d.ts +41 -0
  148. package/lib/types/http.d.ts.map +1 -0
  149. package/lib/types/index.d.ts +56 -24
  150. package/lib/types/index.d.ts.map +1 -1
  151. package/lib/types/intercept-shape.d.ts +32 -0
  152. package/lib/types/intercept-shape.d.ts.map +1 -0
  153. package/lib/types/intercept.d.ts +432 -0
  154. package/lib/types/intercept.d.ts.map +1 -0
  155. package/lib/types/merge-streams.d.ts +393 -0
  156. package/lib/types/merge-streams.d.ts.map +1 -0
  157. package/lib/types/relay-access.d.ts +310 -0
  158. package/lib/types/relay-access.d.ts.map +1 -0
  159. package/lib/types/relay-client.d.ts +274 -0
  160. package/lib/types/relay-client.d.ts.map +1 -0
  161. package/lib/types/relay-filter.d.ts +103 -0
  162. package/lib/types/relay-filter.d.ts.map +1 -0
  163. package/lib/types/relay-server.d.ts +208 -0
  164. package/lib/types/relay-server.d.ts.map +1 -0
  165. package/lib/types/restart-fields.d.ts +22 -0
  166. package/lib/types/restart-fields.d.ts.map +1 -0
  167. package/lib/types/restart-watcher.d.ts +83 -0
  168. package/lib/types/restart-watcher.d.ts.map +1 -0
  169. package/lib/types/session-reference.d.ts +66 -0
  170. package/lib/types/session-reference.d.ts.map +1 -0
  171. package/lib/types/share-export.d.ts +14 -1
  172. package/lib/types/share-export.d.ts.map +1 -1
  173. package/lib/types/share-ops.d.ts +79 -0
  174. package/lib/types/share-ops.d.ts.map +1 -0
  175. package/lib/types/share-store.d.ts +87 -0
  176. package/lib/types/share-store.d.ts.map +1 -0
  177. package/lib/types/virtual-id.d.ts +38 -0
  178. package/lib/types/virtual-id.d.ts.map +1 -0
  179. package/lib/virtual-id.js +52 -0
  180. package/lib/virtual-id.js.map +1 -0
  181. package/llms.txt +17 -14
  182. package/package.json +60 -50
  183. package/scripts/build-client.mjs +5 -3
  184. package/scripts/check-host-shadow.mjs +19 -2
  185. package/scripts/check-settings-form.mjs +933 -0
  186. package/scripts/check-shares.mjs +397 -0
  187. package/scripts/check-upload-endpoint.mjs +59 -3
  188. package/scripts/sync-doc-version.mjs +8 -15
  189. package/scripts/verify-fingerprint-live.mjs +111 -0
  190. package/src/activity.ts +218 -0
  191. package/src/admin-routes.ts +627 -0
  192. package/src/client/MobileAttachButton.tsx +2 -2
  193. package/src/client/MobileAttachChips.tsx +3 -3
  194. package/src/client/MobileDrawerFooter.tsx +7 -4
  195. package/src/client/MobileHome.tsx +20 -16
  196. package/src/client/MobileHomeChips.tsx +16 -16
  197. package/src/client/MobileNavOverlay.tsx +2 -2
  198. package/src/client/MobileNavToggle.tsx +3 -3
  199. package/src/client/MobileSessionHeader.tsx +63 -42
  200. package/src/client/MobileSessionInfo.tsx +118 -17
  201. package/src/client/RemoteComposerBanner.tsx +76 -0
  202. package/src/client/RemoteHeaderIcon.tsx +103 -0
  203. package/src/client/RemoteShareMenu.tsx +57 -0
  204. package/src/client/RemoteStatusIcon.tsx +91 -0
  205. package/src/client/compat/desktop.ts +22 -0
  206. package/src/client/compat/types.ts +62 -0
  207. package/src/client/current-session-report.ts +164 -0
  208. package/src/client/effects/header-status.ts +21 -17
  209. package/src/client/effects/keyboard-guard.ts +65 -1
  210. package/src/client/effects/permission-sheet.ts +149 -0
  211. package/src/client/effects/remote-session.ts +68 -0
  212. package/src/client/effects/turn-fold.ts +52 -0
  213. package/src/client/index.tsx +151 -36
  214. package/src/client/locales.ts +396 -2
  215. package/src/client/remote-fetch.ts +125 -0
  216. package/src/client/remote-group-expansion.ts +190 -0
  217. package/src/client/remote-share-css.ts +54 -0
  218. package/src/client/remote-share-register.ts +244 -0
  219. package/src/client/remote-status-css.ts +64 -0
  220. package/src/client/remote-status-register.ts +107 -0
  221. package/src/client/settings/SettingsSection.tsx +1229 -0
  222. package/src/client/settings/register-settings.ts +116 -0
  223. package/src/client/settings/settings-css.ts +236 -0
  224. package/src/client/share/share-preview.tsx +6 -3
  225. package/src/client/share/share-sheet.tsx +3 -3
  226. package/src/client/styles/composer.css.ts +119 -26
  227. package/src/client/styles/header.css.ts +7 -7
  228. package/src/client/styles/info.css.ts +74 -0
  229. package/src/client/styles/remote-session.css.ts +251 -0
  230. package/src/client-data/current-session.ts +57 -0
  231. package/src/client-data/remote-group-expansion.ts +145 -0
  232. package/src/client-data/remote-status.ts +374 -0
  233. package/src/client-data/settings-form.ts +1371 -0
  234. package/src/client-data/shares.ts +467 -0
  235. package/src/client-pairing.ts +244 -0
  236. package/src/client-routes.ts +732 -0
  237. package/src/config.ts +447 -0
  238. package/src/fetch-route-intercept.ts +510 -0
  239. package/src/fingerprint.ts +408 -0
  240. package/src/http.ts +68 -0
  241. package/src/index.ts +719 -62
  242. package/src/intercept-shape.ts +87 -0
  243. package/src/intercept.ts +3362 -0
  244. package/src/merge-streams.ts +1272 -0
  245. package/src/relay-access.ts +669 -0
  246. package/src/relay-client.ts +1514 -0
  247. package/src/relay-filter.ts +291 -0
  248. package/src/relay-server.ts +2025 -0
  249. package/src/restart-fields.ts +36 -0
  250. package/src/restart-watcher.ts +140 -0
  251. package/src/session-reference.ts +156 -0
  252. package/src/share-export.ts +26 -1
  253. package/src/share-ops.ts +114 -0
  254. package/src/share-store.ts +373 -0
  255. package/src/virtual-id.ts +59 -0
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Pure access control for the relay invoke and stream routes (2.0.0
3
+ * desktop-client).
4
+ *
5
+ * PER-METHOD ALLOWLIST, not a generic id scan. The first draft extracted
6
+ * whatever session ids it could find in `args.request` and checked those —
7
+ * which a hostile client defeats with a DECOY: DSH's gateway validates only
8
+ * the top-level argument names (`assertExactArguments`) and zod silently
9
+ * strips unknown fields inside `request`, so padding the arguments with an
10
+ * extra shared `sessionId` gets through the share-table check while the
11
+ * method's REAL ownership field (`agentId`, `request.parentSessionId`, …)
12
+ * goes unchecked. The fix: every invokable method is registered below with
13
+ * EXACTLY the fields that locate its session, authorization looks at those
14
+ * fields and nothing else, and any method not in the table is refused
15
+ * (`forbidden-method`) before an id is ever read. The stream route
16
+ * ({@link decideStream}) reads the SAME table: only `stream: true` entries
17
+ * may ride it, and the field rules are identical.
18
+ *
19
+ * Field verification (T22a-fix, against the 0.2.0-rc.1 wire inventory in the
20
+ * review's keys.txt and the local `typert.remote-client.js` — the spike
21
+ * proved those files byte-identical across 0.1.7/0.2.0, docs/spike-relay.md
22
+ * §2.3):
23
+ *
24
+ * - `request.address` exists only on `session/follow` and `session/page`;
25
+ * its zod schema is a union of EXACTLY two shapes —
26
+ * `{kind:'session', sessionId}` and `{kind:'subagent', parentSessionId,
27
+ * childSessionId, mode}` — and DSH's `validateAddress` re-checks that the
28
+ * child belongs to the parent, so judging a subagent call by the parent id
29
+ * is authoritative. Any other kind, or a missing id, is a refusal.
30
+ * - every other method registered before T31 carries `request.sessionId`.
31
+ *
32
+ * T31 additions, each verified against the 0.2.0 sources before registering:
33
+ *
34
+ * - `session/create` carries `request.workspaceId` — a WORKSPACE id, never
35
+ * share-checked (workspaces do not live in the share table): it must be
36
+ * present, and the relay route validates it against the server's live
37
+ * workspace list before forwarding (relay-server.ts). The new session is
38
+ * auto-shared there, which is what makes the entry safe at all.
39
+ * - `session/fork` carries `request.sessionId` (the SOURCE session); the
40
+ * forked child is auto-shared after the call succeeds.
41
+ * - `subagents/prompt` (`request.parentSessionId`) and
42
+ * `subagents/interruptByParent` (TOP-LEVEL `parentSessionId`) are judged
43
+ * by the parent: DSH re-validates the parent-child link itself
44
+ * (`authorizeLineage` on both delivery paths of prompt; the user-authority
45
+ * check inside `interrupt`), so an unshared child can no more be reached
46
+ * than an unshared parent — it is refused server-side by DSH.
47
+ * - `fileUploads/upload` and `fileReferences/list` carry a TOP-LEVEL
48
+ * `agentId`: the gateway's `agent` lookup resolves it through the agent
49
+ * registry keyed by SESSION id (dsh-agent registers wire `agentId`,
50
+ * wireTypeSymbol `SessionId`), so it IS the session id and shares its
51
+ * check. A shared `request.sessionId` padded next to it buys nothing —
52
+ * only the registered field is read.
53
+ *
54
+ * T41a additions (the panel long tail where the session id hides in another
55
+ * argument), each verified against the 0.2.0 sources before registering:
56
+ *
57
+ * - the `agentId` group grows: `goals/get|edit|pause|resume|clear`,
58
+ * `commands/list|execute`, `agentPresets/select`,
59
+ * `sessionReferenceResolver/candidates` and the whole `terminal/*` agent
60
+ * half — every one takes `agent` as its first parameter, wire `agentId`,
61
+ * source `lookup: 'agent'`, wireTypeSymbol `SessionId` (the same shape
62
+ * T31 verified for fileUploads/fileReferences).
63
+ * - `workspaceFiles/list|changes|read|readBytes|stat` carry a TOP-LEVEL
64
+ * `workspaceFileScopeId`: the host registers the `workspaceFileScope`
65
+ * lookup with wireTypeSymbol `SessionId` and resolves it by session —
66
+ * `sessions.get(sessionId).header.cwd` is only the BASE for relative
67
+ * paths (dsh-api-workspace-files lib/index.js, the lookup registration),
68
+ * not a containment: absolute paths anywhere the server process can read
69
+ * are served (see the registry note below). The field itself is
70
+ * share-checked like any session id.
71
+ * - `terminal/list` and `terminal/retain` take a plain TOP-LEVEL json
72
+ * `sessionId` (source `'json'`, not a lookup) — same check.
73
+ * - `sessionFeedback/record` carries `request.sessionId` — a plain B-type.
74
+ * - `sessionReferenceResolver/candidates` (@ mentions) answers with EVERY
75
+ * server session — title, cwd, and a ready-made `dsh-session:` mention
76
+ * — so its result travels only through the row filter that drops
77
+ * inaccessible sessions (relay-server.ts, the session/list discipline).
78
+ * - `subagents/prompt` (`request.parentSessionId` +
79
+ * `request.childSessionId`) and `subagents/interruptByParent` (TOP-LEVEL
80
+ * `parentSessionId` + `childSessionId`) now claim BOTH ids: the parent is
81
+ * share-checked directly, the child through the injected `parentOf`
82
+ * inheritance (`store.isAccessible(id, parentOf)` — a subagent session
83
+ * never enters the table, it borrows its ancestor's share). A child that
84
+ * does not descend from the claimed parent fails its own check, so the
85
+ * "shared parent + foreign child" decoy refuses here before DSH's own
86
+ * lineage validation is ever reached.
87
+ *
88
+ * PROMPT TEXT REFERENCES ARE CHECKED TOO: a prompt whose text carries a
89
+ * canonical `dsh-session:<base64url(id)>` address makes DSH inject that
90
+ * session's content (`prepareDirectMessages` → `readSurface`, with no
91
+ * access check of its own), so the relay refuses any address naming an
92
+ * inaccessible session wherever such a text can enter (relay-server.ts,
93
+ * scanning by the shared rule in session-reference.ts, T41a-fix2): the
94
+ * `session/prompt` / `subagents/prompt` content, a `session/updateQueue`
95
+ * EDIT's replacement content, and EVERY string of a `commands/execute`
96
+ * call (a command handler steers its raw input in as a user message —
97
+ * `/plan <text>` does exactly that). The client restores its virtual ids
98
+ * inside those same positions before the call travels (intercept.ts).
99
+ *
100
+ * TERMINALS ARE DELIBERATE (SPEC user story 45, explicit user request):
101
+ * remote terminals open SERVER-side, so a paired desktop client working a
102
+ * shared session gets a real PTY in the server's workspace — a shell
103
+ * running as the server user WITHOUT the agent sandbox or approval
104
+ * restrictions (RT dsh-api-terminal-controller create), the session cwd
105
+ * being only the starting directory. The authorization boundary is exactly
106
+ * the standing one — the session must be shared AND the caller must be a
107
+ * paired desktop application client (the gateway's device auth + relay
108
+ * secret), a TRUSTED DEVICE: the shared-only rule constrains session data,
109
+ * not the machine. No extra switch is added on top; closing the session's
110
+ * remote access closes its terminals with it (the stream route kills
111
+ * `terminal/follow` / `terminal/retain` with `unshared`, like every other
112
+ * session-scoped stream). The terminal ids and attachment ids inside these
113
+ * calls are CLIENT-generated (`WebTerminalId` / `TerminalAttachmentId` type
114
+ * symbols, distinct from `SessionId`) and pass through untouched.
115
+ *
116
+ * Pure functions: no I/O, no clock, the share-table lookup is injected.
117
+ */
118
+
119
+ /**
120
+ * The argument fields that can locate a session, as registered per method.
121
+ * A field named without a dot is TOP-LEVEL (`agentId`, `parentSessionId`,
122
+ * `childSessionId`, `sessionId`, `workspaceFileScopeId`); the `request.*`
123
+ * forms live inside the `request` object. Every field except
124
+ * `request.workspaceId` must yield an id that passes the share-table check
125
+ * (top-level `sessionId` / `workspaceFileScopeId` ARE session ids — the
126
+ * wire type symbols say so — and inherit ancestor shares like any other);
127
+ * `request.workspaceId` must merely be present (its validation is the relay
128
+ * route's workspace probe, not this table).
129
+ */
130
+ type SessionField =
131
+ | 'request.sessionId'
132
+ | 'request.address'
133
+ | 'request.parentSessionId'
134
+ | 'request.childSessionId'
135
+ | 'request.workspaceId'
136
+ | 'parentSessionId'
137
+ | 'childSessionId'
138
+ | 'sessionId'
139
+ | 'workspaceFileScopeId'
140
+ | 'agentId'
141
+
142
+ /** Which standing filter the caller must apply to a global stream's frames
143
+ * (`src/relay-filter.ts` owns both implementations; the `$zr/events`
144
+ * forwarding subscription is filtered by relay-server.ts itself — it needs
145
+ * the per-eventId registry, not a pure frame function). */
146
+ export type StreamFilter = 'workspace' | 'control'
147
+
148
+ /** Which standing filter the caller must apply to an invoke result before it
149
+ * travels: `session-list` narrows the unscoped `session/list` items
150
+ * (relay-filter.ts), `session-reference-candidates` drops the @-mention
151
+ * candidate rows whose session is not accessible (relay-server.ts — the host
152
+ * lists EVERY server session with title, cwd and a ready-made mention, so
153
+ * unshared rows must never leave the box), and `model-catalog` empties the
154
+ * `session/modelCatalog` `failures` array (T52-fix — relay-filter.ts: the
155
+ * host's per-group error texts may carry endpoint or credential details, and
156
+ * the client discards `failures` anyway). */
157
+ export type InvokeFilter = 'session-list' | 'session-reference-candidates' | 'model-catalog'
158
+
159
+ interface RelayMethod {
160
+ /** Every field that carries session ownership for this method. ALL of
161
+ * them must exist, be strings, and pass the share-table check. Global
162
+ * entries (`workspace/follow`, `session/control`, `session/list`,
163
+ * `session/modelCatalog`) own no field — reachability is enforced by
164
+ * filtering their output instead (for modelCatalog that filter only
165
+ * scrubs the `failures` array; the groups themselves carry no session
166
+ * data). */
167
+ fields: SessionField[]
168
+ /** Stream-delivered methods (`mode: 'stream'` in the wire inventory):
169
+ * callable ONLY through the streaming route — an invoke carrying one is
170
+ * refused, and so is a stream carrying a non-stream method. */
171
+ stream?: boolean
172
+ /** Set on the two GLOBAL streams: every frame of such a subscription must
173
+ * go through the named filter before it is written. */
174
+ streamFilter?: StreamFilter
175
+ /** Set on the invoke methods whose RESULT needs a standing filter. */
176
+ resultFilter?: InvokeFilter
177
+ /** The one EVENT-FORWARDING entry (T32, `$zr/events`): the stream route
178
+ * opens the gateway's `$events` wire stream (never `gw.stream`, spike §2.1
179
+ * 坑 1) and filters frames by the waterfall's `agentId`; the answer route
180
+ * matches `eventId`s against what was forwarded. Like the global reads it
181
+ * claims no session field — access is judged per frame / per answer. */
182
+ events?: true
183
+ }
184
+
185
+ /**
186
+ * The registry. Deliberately small: everything not listed here — plugin and
187
+ * account management, settings, credentials, the methods located by other
188
+ * ids (`schedule/update|delete|history`, `goals/create|complete`,
189
+ * `fileUploads/list|resolve`, `agentPresets/list|read`,
190
+ * `permissionPresets/*`, `officeToPdf/*`, `dynamicCordisRunner/*`) — is
191
+ * refused by default.
192
+ *
193
+ * T31 registered the five session-creating / agent-scoped calls after
194
+ * verifying their ownership story (see the field notes above): creations
195
+ * auto-share their result (relay-server.ts), the subagents calls are judged
196
+ * by the parent DSH itself re-validates, and the two `agentId` methods are
197
+ * located by the session id that name resolves to. T41a grew the agentId
198
+ * family and added the workspaceFiles / terminal / feedback / goals /
199
+ * commands / preset-switch groups on the same verified shape.
200
+ *
201
+ * The unscoped reads are registered as FILTERED controlled paths (T22b):
202
+ * `workspace/follow` and `session/control` stream globally but every frame
203
+ * passes `streamFilter` first; `session/list` invokes but its items pass
204
+ * `resultFilter` first. Nothing unlisted ever reaches the gateway.
205
+ */
206
+ const RELAY_METHODS: Record<string, RelayMethod> = {
207
+ // session/* — ownership via the address envelope
208
+ 'session/follow': { fields: ['request.address'], stream: true },
209
+ 'session/page': { fields: ['request.address'] },
210
+ // session/* — ownership via request.sessionId
211
+ 'session/projections': { fields: ['request.sessionId'] },
212
+ 'session/prompt': { fields: ['request.sessionId'] },
213
+ 'session/cancel': { fields: ['request.sessionId'] },
214
+ 'session/rename': { fields: ['request.sessionId'] },
215
+ 'session/selectModel': { fields: ['request.sessionId'] },
216
+ 'session/updateQueue': { fields: ['request.sessionId'] },
217
+ 'session/attachment': { fields: ['request.sessionId'] },
218
+ // session/* — creation and forking (T31): the route validates the workspace
219
+ // / source session and auto-shares the NEW session from the result
220
+ 'session/create': { fields: ['request.workspaceId'] },
221
+ 'session/fork': { fields: ['request.sessionId'] },
222
+ // subagents (T31, both ids since T41a) — the parent locates the call and
223
+ // DSH re-validates the parent-child link on every path; the child id is
224
+ // ALSO share-checked through the parentOf inheritance, so a foreign child
225
+ // refuses before DSH is ever asked
226
+ 'subagents/prompt': { fields: ['request.parentSessionId', 'request.childSessionId'] },
227
+ 'subagents/interruptByParent': { fields: ['parentSessionId', 'childSessionId'] },
228
+ // attachments and @ references (T31) — the top-level agentId resolves
229
+ // through the agent registry keyed by session id, so it IS the session id
230
+ 'fileUploads/upload': { fields: ['agentId'] },
231
+ 'fileReferences/list': { fields: ['agentId'] },
232
+ // goal panel (T41a) — top-level agentId, same lookup shape as fileUploads;
233
+ // goals/create|complete stay closed (the UI's goal bar uses only these five)
234
+ 'goals/get': { fields: ['agentId'] },
235
+ 'goals/edit': { fields: ['agentId'] },
236
+ 'goals/pause': { fields: ['agentId'] },
237
+ 'goals/resume': { fields: ['agentId'] },
238
+ 'goals/clear': { fields: ['agentId'] },
239
+ // slash commands + the preset switches they drive (T41a) — top-level agentId
240
+ 'commands/list': { fields: ['agentId'] },
241
+ 'commands/execute': { fields: ['agentId'] },
242
+ 'agentPresets/select': { fields: ['agentId'] },
243
+ // The @ resolver's answer lists EVERY server session with title, cwd and a
244
+ // ready-made mention — it travels only through the row filter that drops
245
+ // inaccessible sessions (relay-server.ts), like session/list.
246
+ 'sessionReferenceResolver/candidates': { fields: ['agentId'], resultFilter: 'session-reference-candidates' },
247
+ // session feedback (T41a) — plain request.sessionId
248
+ 'sessionFeedback/record': { fields: ['request.sessionId'] },
249
+ // file tree and previews (T41a) — the top-level workspaceFileScopeId IS the
250
+ // session id (host lookup wireTypeSymbol SessionId), but the session cwd is
251
+ // only the BASE for relative paths: an absolute path is served whenever the
252
+ // server process can read it, with no containment to the workspace (RT
253
+ // dsh-api-workspace-files lib/index.js ~168-170 "including paths outside the
254
+ // workspace … not a read-containment restriction", ~420 / ~479 "files
255
+ // outside it are allowed"). That exposure matches the terminal trust
256
+ // premise below (SPEC user story 45) and is deliberately accepted.
257
+ // changes is the tree's live stream.
258
+ 'workspaceFiles/list': { fields: ['workspaceFileScopeId'] },
259
+ 'workspaceFiles/changes': { fields: ['workspaceFileScopeId'], stream: true },
260
+ 'workspaceFiles/read': { fields: ['workspaceFileScopeId'] },
261
+ 'workspaceFiles/readBytes': { fields: ['workspaceFileScopeId'] },
262
+ 'workspaceFiles/stat': { fields: ['workspaceFileScopeId'] },
263
+ // terminal (T41a) — opens SERVER-side by explicit user request (SPEC story
264
+ // 45). What the client gets is a shell running AS THE SERVER USER, WITHOUT
265
+ // the agent sandbox or approval restrictions (RT dsh-api-terminal-controller
266
+ // create: "Allocate a user shell … without Agent sandbox or approval
267
+ // restrictions"); the session cwd is only the starting directory. "Only
268
+ // shared sessions are accessible" constrains SESSION data, not this machine
269
+ // — a paired desktop application client is treated as a trusted device, and
270
+ // no extra switch is added on top. follow/retain are streams; the terminal
271
+ // and attachment ids in these calls are client-generated and pass through.
272
+ 'terminal/environment': { fields: ['agentId'] },
273
+ 'terminal/shells': { fields: ['agentId'] },
274
+ 'terminal/create': { fields: ['agentId'] },
275
+ 'terminal/write': { fields: ['agentId'] },
276
+ 'terminal/resize': { fields: ['agentId'] },
277
+ 'terminal/rename': { fields: ['agentId'] },
278
+ 'terminal/close': { fields: ['agentId'] },
279
+ 'terminal/follow': { fields: ['agentId'], stream: true },
280
+ // the retain/list half locates by a plain top-level json sessionId
281
+ 'terminal/list': { fields: ['sessionId'] },
282
+ 'terminal/retain': { fields: ['sessionId'], stream: true },
283
+ // session/* — the global control stream and the unscoped list
284
+ 'session/control': { fields: [], stream: true, streamFilter: 'control' },
285
+ 'session/list': { fields: [], resultFilter: 'session-list' },
286
+ // T52: the global model catalog — NO parameters at all (RT
287
+ // dsh-api-session-controller lib/typert.remote-client.js:1014-1027,
288
+ // `parameters: []`), and its result is the server's provider directory
289
+ // `{default, routableProviders, groups, failures}` — group/model ids and
290
+ // display names only, nothing session-scoped (the schema is at
291
+ // lib/typert.remote-client.js:489-518). Field-less like session/list; the
292
+ // result filter (T52-fix) only EMPTIES `failures` — the host's per-group
293
+ // error texts may carry endpoint or credential details the client never
294
+ // shows (it keeps its own local failures instead).
295
+ 'session/modelCatalog': { fields: [], resultFilter: 'model-catalog' },
296
+ // job/*
297
+ 'job/list': { fields: ['request.sessionId'], stream: true },
298
+ 'job/follow': { fields: ['request.sessionId'], stream: true },
299
+ 'job/kill': { fields: ['request.sessionId'] },
300
+ // skills, message feedback
301
+ 'skills/list': { fields: ['request.sessionId'] },
302
+ 'messageFeedback/list': { fields: ['request.sessionId'] },
303
+ 'messageFeedback/put': { fields: ['request.sessionId'] },
304
+ 'messageFeedback/delete': { fields: ['request.sessionId'] },
305
+ // schedule (list only — update/delete/history wait for P4)
306
+ 'schedule/list': { fields: ['request.sessionId'] },
307
+ // workspace session-scoped mutations
308
+ 'workspace/pinSession': { fields: ['request.sessionId'] },
309
+ 'workspace/unpinSession': { fields: ['request.sessionId'] },
310
+ 'workspace/archiveSession': { fields: ['request.sessionId'] },
311
+ 'workspace/unarchiveSession': { fields: ['request.sessionId'] },
312
+ // workspace — the global follow stream
313
+ 'workspace/follow': { fields: [], stream: true, streamFilter: 'workspace' },
314
+ // the forwarded-event subscription (T32): special entry, no session fields —
315
+ // its frames are judged per `agentId`, its answers per forwarded `eventId`
316
+ '$zr/events': { fields: [], stream: true, events: true },
317
+ }
318
+
319
+ /** One invoke decision: allow (optionally through a standing result filter),
320
+ * or the reason that goes into the 403 body. */
321
+ export type InvokeDenyReason = 'no-session' | 'not-shared' | 'forbidden-method'
322
+
323
+ export type InvokeDecision = { allow: true; filter?: InvokeFilter } | { allow: false; reason: InvokeDenyReason }
324
+
325
+ /** One stream decision: allow (global streams carry a `streamFilter`, scoped
326
+ * streams list the session ids the subscription depends on — the relay kills
327
+ * the stream and counts viewers with them, and the event subscription sets
328
+ * `events`), or the 403 reason. */
329
+ export type StreamDecision =
330
+ | { allow: true; filter?: StreamFilter; sessionIds: string[]; events?: true }
331
+ | { allow: false; reason: InvokeDenyReason }
332
+
333
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
334
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
335
+ }
336
+
337
+ /** An OWNED session id: present and a non-empty string, anything else means
338
+ * "the field does not locate a session" (an empty id is garbage, not one). */
339
+ function ownedId(value: unknown): string | undefined {
340
+ return typeof value === 'string' && value !== '' ? value : undefined
341
+ }
342
+
343
+ /**
344
+ * The session ids one registered method's arguments claim, read STRICTLY
345
+ * along the registered fields (this replaces the old generic
346
+ * `extractSessionIds` scan — that scan is exactly the decoy hole).
347
+ *
348
+ * `request.address` contributes its session id by kind: `session` →
349
+ * `sessionId`, `subagent` → `parentSessionId` (the child never enters the
350
+ * share table; DSH validates the parent-child link server-side). The
351
+ * top-level fields (`agentId`, `parentSessionId`, `childSessionId`,
352
+ * `sessionId`, `workspaceFileScopeId`) read the argument object itself —
353
+ * all of them are session ids on the wire and share-check alike.
354
+ * `request.workspaceId` contributes NO id — it must be present, but its
355
+ * authorization is the route's workspace probe plus the auto-share of
356
+ * the created session, not this table. Any other kind, or a missing id,
357
+ * makes the method unauthorizable.
358
+ */
359
+ const TOP_LEVEL_FIELDS: ReadonlySet<string> = new Set([
360
+ 'agentId',
361
+ 'parentSessionId',
362
+ 'childSessionId',
363
+ 'sessionId',
364
+ 'workspaceFileScopeId',
365
+ ])
366
+
367
+ function claimedSessionIds(entry: RelayMethod, args: unknown): { ids: string[] } | { reason: InvokeDenyReason } {
368
+ // Field-less entries (the global reads) claim nothing by construction —
369
+ // their safety comes from output filtering, and their wire shape is their
370
+ // own (`session/list` takes `_request`, not `request`).
371
+ if (entry.fields.length === 0) return { ids: [] }
372
+ if (!isPlainObject(args)) return { reason: 'no-session' }
373
+ const ids: string[] = []
374
+ for (const field of entry.fields) {
375
+ // Top-level fields read the argument object itself.
376
+ if (TOP_LEVEL_FIELDS.has(field)) {
377
+ const id = ownedId(args[field])
378
+ if (id === undefined) return { reason: 'no-session' }
379
+ ids.push(id)
380
+ continue
381
+ }
382
+ const request = args.request
383
+ if (!isPlainObject(request)) return { reason: 'no-session' }
384
+ if (field === 'request.sessionId' || field === 'request.parentSessionId' || field === 'request.childSessionId') {
385
+ const id = ownedId(request[field.slice('request.'.length)])
386
+ if (id === undefined) return { reason: 'no-session' }
387
+ ids.push(id)
388
+ continue
389
+ }
390
+ if (field === 'request.workspaceId') {
391
+ // Present and a non-empty string, but never share-checked — see the
392
+ // SessionField note.
393
+ if (ownedId(request.workspaceId) === undefined) return { reason: 'no-session' }
394
+ continue
395
+ }
396
+ // 'request.address'
397
+ const address = request.address
398
+ if (!isPlainObject(address)) return { reason: 'no-session' }
399
+ if (address.kind === 'session') {
400
+ const id = ownedId(address.sessionId)
401
+ if (id === undefined) return { reason: 'no-session' }
402
+ ids.push(id)
403
+ } else if (address.kind === 'subagent') {
404
+ const id = ownedId(address.parentSessionId)
405
+ if (id === undefined) return { reason: 'no-session' }
406
+ ids.push(id)
407
+ } else {
408
+ // Unknown kind — not an address DSH would accept either.
409
+ return { reason: 'no-session' }
410
+ }
411
+ }
412
+ return { ids }
413
+ }
414
+
415
+ /**
416
+ * Decide one relayed invoke against the per-method allowlist:
417
+ *
418
+ * 1. the method must be registered AND invoke-delivered (a `stream: true`
419
+ * entry refuses the invoke route) — anything else is `forbidden-method`;
420
+ * 2. its registered fields must all yield an owned session id — else
421
+ * `no-session`;
422
+ * 3. every claimed id must pass `isAccessible` — one unreachable id refuses
423
+ * the whole call (`not-shared`): an unshared session must not become
424
+ * readable through a shared one riding in the same arguments.
425
+ *
426
+ * A method registered with `resultFilter` allows with that marker attached;
427
+ * the caller filters the result before it travels (never the reverse — the
428
+ * filter is an OUTPUT discipline, the access check above stays input-only).
429
+ */
430
+ export function decideInvoke(
431
+ namespace: string,
432
+ method: string,
433
+ args: unknown,
434
+ isAccessible: (sessionId: string) => boolean,
435
+ ): InvokeDecision {
436
+ const entry = RELAY_METHODS[`${namespace}/${method}`]
437
+ if (entry === undefined || entry.stream === true) return { allow: false, reason: 'forbidden-method' }
438
+ const claimed = claimedSessionIds(entry, args)
439
+ if ('reason' in claimed) return { allow: false, reason: claimed.reason }
440
+ for (const id of claimed.ids) {
441
+ if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
442
+ }
443
+ return entry.resultFilter !== undefined ? { allow: true, filter: entry.resultFilter } : { allow: true }
444
+ }
445
+
446
+ /**
447
+ * Decide one relayed STREAM subscription against the same table:
448
+ *
449
+ * 1. the method must be registered AND stream-delivered — an invoke-only
450
+ * method riding the stream route is `forbidden-method`, exactly like a
451
+ * stream method riding the invoke route;
452
+ * 2. a global entry (`streamFilter` set) allows unconditionally — its frames
453
+ * are filtered per frame, so there is nothing to check up front;
454
+ * 3. any other entry follows the {@link decideInvoke} field rules verbatim:
455
+ * all registered fields must yield owned ids and every id must be
456
+ * accessible, and the claimed ids ride back to the caller, which kills the
457
+ * subscription when one of them stops being shared.
458
+ */
459
+ export function decideStream(
460
+ namespace: string,
461
+ method: string,
462
+ args: unknown,
463
+ isAccessible: (sessionId: string) => boolean,
464
+ ): StreamDecision {
465
+ const entry = RELAY_METHODS[`${namespace}/${method}`]
466
+ if (entry === undefined || entry.stream !== true) return { allow: false, reason: 'forbidden-method' }
467
+ if (entry.streamFilter !== undefined) return { allow: true, filter: entry.streamFilter, sessionIds: [] }
468
+ if (entry.events === true) return { allow: true, sessionIds: [], events: true }
469
+ const claimed = claimedSessionIds(entry, args)
470
+ if ('reason' in claimed) return { allow: false, reason: claimed.reason }
471
+ for (const id of claimed.ids) {
472
+ if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
473
+ }
474
+ return { allow: true, sessionIds: claimed.ids }
475
+ }
476
+
477
+ /**
478
+ * The `$events/result` answer body (T32), as the client sends it:
479
+ * `{ eventId, result }`. `result` is the Remote event OUTCOME and travels
480
+ * VERBATIM — dsh-api-gateway's `parseRemoteEventResult` is the validator
481
+ * (exactly `{clientId,eventId,outcome}` up there; kinds `next` / `result`
482
+ * with optional JSON `value` / `rejected` with `{name,message,code?,details?}`),
483
+ * and a malformed one comes back as the gateway's own 200 error envelope, so
484
+ * re-validating here would only invent a second dialect for the same refusal.
485
+ * `eventId` ownership (forwarded on a live subscription, session still
486
+ * reachable) is the ROUTE's check — it needs the handler's registry.
487
+ */
488
+ export interface EventResultBody {
489
+ eventId: string
490
+ result: Record<string, unknown>
491
+ }
492
+
493
+ export function parseEventResultBody(body: unknown): EventResultBody | undefined {
494
+ if (!isPlainObject(body)) return undefined
495
+ const { eventId, result } = body
496
+ if (typeof eventId !== 'string' || eventId === '') return undefined
497
+ if (!isPlainObject(result)) return undefined
498
+ return { eventId, result }
499
+ }
500
+
501
+ /**
502
+ * The plain-HTTP panel long tail (T41b): the session-page surfaces that do
503
+ * NOT ride the typert gateway but GET `/api/...` routes registered as exact
504
+ * Fetch routes on the host's connection service (RT
505
+ * dsh-client-ui-deliverables/lib/index.js — `ctx.connection.fetch.register`
506
+ * at ~49-58 and ~84-90; the shared handler they dispatch through is
507
+ * `createSharedFetchHandler('/api')`, dsh-client-connection/lib/index.js
508
+ * ~608-637). Each entry names the query parameter that locates the session —
509
+ * the same per-route registration discipline as RELAY_METHODS, and the same
510
+ * refusal defaults: a route not in this table answers `unknown-route`
511
+ * (404) before any id is read, and a registered route is judged ONLY along
512
+ * its declared field.
513
+ *
514
+ * Deliberately ABSENT (the known limitations of the HTTP interception, kept
515
+ * beside the registry that makes the contrast true):
516
+ *
517
+ * - `/api/session.export` (session log download) — an anchor-click download,
518
+ * not a fetch call at all, and a full ZIP of the session's server-side log
519
+ * is not a body worth re-streaming through the relay. Remote sessions have
520
+ * export DISABLED instead (T51): the sub-client's own backend refuses
521
+ * `/api/session.export` for a virtual id with 403 `remote-unsupported`
522
+ * before the local route is ever reached (fetch-route-intercept.ts), and
523
+ * the UI entry is hidden on the same html attribute
524
+ * (remote-session.css.ts) — the host route's answer for a foreign id
525
+ * never happens.
526
+ * - `/api/present.host` — takes NO parameters and reports the machine the
527
+ * SERVER process runs on; forwarding it would read server-desktop facts
528
+ * into a panel whose actions stay hidden remotely, so it is not relayed.
529
+ * - `/api/changes.open` and `/api/present.open` — POST actions that open
530
+ * files ON THE SERVER MACHINE (Finder / applications); meaningless from a
531
+ * sub-client, so the UI entries are hidden remotely (remote-session.css)
532
+ * and the routes are not registered here.
533
+ *
534
+ * The one HTTP route that USED to be absent is no longer (T51):
535
+ * `/api/session/uploadFileBinary` (attachment upload, RT
536
+ * dsh-client-file-upload) — its Blob/stream branch runs inside a Web Worker
537
+ * over XHR / Worker-scoped fetch, which neither a `window.fetch` wrapper nor
538
+ * the GET passthrough this table serves can carry. The sub-client's own
539
+ * backend instead wraps that entry in the host's `fetchRoutes` map
540
+ * (fetch-route-intercept.ts) and forwards the raw byte stream through the
541
+ * DEDICATED binary channel `POST relay/v1/upload`, whose query is decided by
542
+ * {@link decideUploadQuery} below — the same parse-once normalization, share
543
+ * table and refusal codes as everything else here.
544
+ */
545
+ export interface RelayHttpRoute {
546
+ /** The query parameter carrying the owning session id. */
547
+ sessionField: 'sessionId'
548
+ }
549
+
550
+ export const RELAY_HTTP_ROUTES: Readonly<Record<string, RelayHttpRoute>> = {
551
+ 'changes.summary': { sessionField: 'sessionId' },
552
+ 'changes.diff': { sessionField: 'sessionId' },
553
+ }
554
+
555
+ /** One plain-HTTP decision: allow — carrying the NORMALIZED query string
556
+ * that is the only thing the caller may put on the synthetic Request URL —
557
+ * or the reason for the answer: `unknown-route` (not in
558
+ * {@link RELAY_HTTP_ROUTES}) answers 404, `no-session` (the field is missing
559
+ * or duplicated) and `bad-query` (a whitelisted coordinate repeats or is not
560
+ * a decimal non-negative integer string) answer 400, and `not-shared`
561
+ * answers 403 like every other session-scoped refusal. */
562
+ export type HttpDenyReason = 'unknown-route' | 'no-session' | 'bad-query' | 'not-shared'
563
+
564
+ export type HttpDecision = { allow: true; query: string } | { allow: false; reason: HttpDenyReason }
565
+
566
+ /** True only for OWN registry keys: a bare route string from the wire is
567
+ * indexed into a plain object, so `constructor` / `__proto__` must never
568
+ * resolve through the prototype chain the way a bare `table[route]` would. */
569
+ function isRegisteredHttpRoute(route: string): boolean {
570
+ return Object.prototype.hasOwnProperty.call(RELAY_HTTP_ROUTES, route)
571
+ }
572
+
573
+ /** A decimal non-negative integer string, exactly what the serving routes'
574
+ * own coordinate parser accepts (RT dsh-client-ui-deliverables
575
+ * lib/index.js `NUMERIC`). */
576
+ const DECIMAL = /^\d+$/
577
+
578
+ /**
579
+ * The one value of a whitelisted query parameter, or `null` when it repeats
580
+ * or is not a decimal non-negative integer string (a 400, not a pass-through
581
+ * of whichever duplicate the URL parser happens to keep). `undefined` = the
582
+ * parameter is legitimately absent.
583
+ */
584
+ function singleCoordinate(params: URLSearchParams, name: string): string | undefined | null {
585
+ const values = params.getAll(name)
586
+ if (values.length === 0) return undefined
587
+ const value = values.length === 1 ? values[0] : undefined
588
+ if (value === undefined || !DECIMAL.test(value)) return null
589
+ return value
590
+ }
591
+
592
+ /**
593
+ * Decide one relayed `/api` GET against the per-route allowlist, and
594
+ * NORMALIZE the query. The two halves of this route used to disagree about
595
+ * parsing: `URLSearchParams` keeps `\t` / `\r` / `\n` inside key names
596
+ * (`session\tId` is one parameter here) while the WHATWG URL parser strips
597
+ * control characters before the request URL is built — so a query whose
598
+ * checkable `sessionId` named a shared session could DISPATCH as two
599
+ * parameters, with the serving route's own `get('sessionId')` reading the
600
+ * first one, a secret (reviewer-confirmed 200). The fix is structural: this
601
+ * function parses ONCE, takes only the whitelisted parameters — `sessionId`
602
+ * exactly once (its id must pass the share table), `seq` / `index` at most
603
+ * once each and decimal non-negative integers — and returns the rebuilt,
604
+ * fixed-order query string; the caller composes the synthetic URL from THAT
605
+ * string alone, never from raw input. Unknown parameters are dropped.
606
+ */
607
+ export function decideHttpRoute(
608
+ route: unknown,
609
+ query: unknown,
610
+ isAccessible: (sessionId: string) => boolean,
611
+ ): HttpDecision {
612
+ if (typeof route !== 'string' || !isRegisteredHttpRoute(route)) return { allow: false, reason: 'unknown-route' }
613
+ if (typeof query !== 'string') return { allow: false, reason: 'no-session' }
614
+ const raw = new URLSearchParams(query)
615
+ const values = raw.getAll('sessionId')
616
+ const id = values.length === 1 ? values[0] : undefined
617
+ if (id === undefined || id === '') return { allow: false, reason: 'no-session' }
618
+ const seq = singleCoordinate(raw, 'seq')
619
+ const index = singleCoordinate(raw, 'index')
620
+ if (seq === null || index === null) return { allow: false, reason: 'bad-query' }
621
+ if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
622
+ const normalized = new URLSearchParams()
623
+ normalized.set('sessionId', id)
624
+ if (seq !== undefined) normalized.set('seq', seq)
625
+ if (index !== undefined) normalized.set('index', index)
626
+ return { allow: true, query: normalized.toString() }
627
+ }
628
+
629
+ /**
630
+ * The binary upload channel's query (T51), decided with the SAME
631
+ * parse-once discipline as {@link decideHttpRoute}: the wire string is
632
+ * parsed exactly once here, only `sessionId` and `name` are kept, and the
633
+ * caller composes the synthetic Request URL from the RETURNED string alone.
634
+ * `URLSearchParams` keeps `\t` / `\r` / `\n` inside key names while the
635
+ * WHATWG URL parser strips control characters — the differential that was
636
+ * T41b-fix's authorization bypass on the GET routes — so the check and the
637
+ * dispatch must see one parse.
638
+ *
639
+ * `sessionId` exactly once, a non-empty string, and share-checked like any
640
+ * other session id; a repeat is `no-session` (400). `name` (the file's
641
+ * display name — the host route passes it to its own leaf-name sanitizer,
642
+ * RT dsh-client-file-upload lib/index.js `handleFileUploadHttp`) at most
643
+ * once; a repeat is `bad-query` (400) and an empty value is dropped, the
644
+ * same "absent" the host route reads when the browser sent none. Everything
645
+ * else is discarded before an id is read.
646
+ */
647
+ export type UploadQueryDecision = { allow: true; sessionId: string; name: string | undefined; query: string } | {
648
+ allow: false
649
+ reason: 'no-session' | 'bad-query' | 'not-shared'
650
+ }
651
+
652
+ export function decideUploadQuery(
653
+ query: unknown,
654
+ isAccessible: (sessionId: string) => boolean,
655
+ ): UploadQueryDecision {
656
+ if (typeof query !== 'string') return { allow: false, reason: 'no-session' }
657
+ const raw = new URLSearchParams(query)
658
+ const values = raw.getAll('sessionId')
659
+ const id = values.length === 1 ? values[0] : undefined
660
+ if (id === undefined || id === '') return { allow: false, reason: 'no-session' }
661
+ const names = raw.getAll('name')
662
+ if (names.length > 1) return { allow: false, reason: 'bad-query' }
663
+ const name = names.length === 1 && names[0] !== '' ? names[0] : undefined
664
+ if (!isAccessible(id)) return { allow: false, reason: 'not-shared' }
665
+ const normalized = new URLSearchParams()
666
+ normalized.set('sessionId', id)
667
+ if (name !== undefined) normalized.set('name', name)
668
+ return { allow: true, sessionId: id, name, query: normalized.toString() }
669
+ }