@edspencer/paddock 0.0.1 → 0.57.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 (296) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +538 -0
  33. package/packages/server/dist/config.js +625 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/environment-prompt.d.ts +52 -0
  37. package/packages/server/dist/environment-prompt.js +61 -0
  38. package/packages/server/dist/event-bus.d.ts +87 -0
  39. package/packages/server/dist/event-bus.js +48 -0
  40. package/packages/server/dist/git.d.ts +128 -0
  41. package/packages/server/dist/git.js +446 -0
  42. package/packages/server/dist/github-auth.d.ts +44 -0
  43. package/packages/server/dist/github-auth.js +169 -0
  44. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  45. package/packages/server/dist/herdctl-agent-config.js +331 -0
  46. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  47. package/packages/server/dist/herdctl-agent-names.js +228 -0
  48. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  49. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  50. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  51. package/packages/server/dist/herdctl-jobs.js +225 -0
  52. package/packages/server/dist/herdctl.d.ts +623 -0
  53. package/packages/server/dist/herdctl.js +1279 -0
  54. package/packages/server/dist/hook-config.d.ts +217 -0
  55. package/packages/server/dist/hook-config.js +224 -0
  56. package/packages/server/dist/http-bytes.d.ts +24 -0
  57. package/packages/server/dist/http-bytes.js +52 -0
  58. package/packages/server/dist/index.d.ts +1 -0
  59. package/packages/server/dist/index.js +14 -0
  60. package/packages/server/dist/instance-config.d.ts +143 -0
  61. package/packages/server/dist/instance-config.js +385 -0
  62. package/packages/server/dist/localcommand.d.ts +23 -0
  63. package/packages/server/dist/localcommand.js +140 -0
  64. package/packages/server/dist/management-auth.d.ts +99 -0
  65. package/packages/server/dist/management-auth.js +165 -0
  66. package/packages/server/dist/management-config.d.ts +164 -0
  67. package/packages/server/dist/management-config.js +286 -0
  68. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  69. package/packages/server/dist/management-mcp-server.js +95 -0
  70. package/packages/server/dist/management-metadata.d.ts +67 -0
  71. package/packages/server/dist/management-metadata.js +56 -0
  72. package/packages/server/dist/management-ops.d.ts +110 -0
  73. package/packages/server/dist/management-ops.js +449 -0
  74. package/packages/server/dist/management-policy.d.ts +172 -0
  75. package/packages/server/dist/management-policy.js +259 -0
  76. package/packages/server/dist/message-provenance.d.ts +112 -0
  77. package/packages/server/dist/message-provenance.js +260 -0
  78. package/packages/server/dist/models.d.ts +164 -0
  79. package/packages/server/dist/models.js +208 -0
  80. package/packages/server/dist/openapi.d.ts +66 -0
  81. package/packages/server/dist/openapi.js +183 -0
  82. package/packages/server/dist/parent-detach.d.ts +30 -0
  83. package/packages/server/dist/parent-detach.js +109 -0
  84. package/packages/server/dist/preload.d.ts +43 -0
  85. package/packages/server/dist/preload.js +55 -0
  86. package/packages/server/dist/project-files.d.ts +76 -0
  87. package/packages/server/dist/project-files.js +191 -0
  88. package/packages/server/dist/project-mime.d.ts +35 -0
  89. package/packages/server/dist/project-mime.js +55 -0
  90. package/packages/server/dist/project-paths.d.ts +88 -0
  91. package/packages/server/dist/project-paths.js +126 -0
  92. package/packages/server/dist/project-types.d.ts +316 -0
  93. package/packages/server/dist/project-types.js +48 -0
  94. package/packages/server/dist/projects.d.ts +202 -0
  95. package/packages/server/dist/projects.js +894 -0
  96. package/packages/server/dist/queued-message.d.ts +35 -0
  97. package/packages/server/dist/queued-message.js +116 -0
  98. package/packages/server/dist/read-state.d.ts +46 -0
  99. package/packages/server/dist/read-state.js +139 -0
  100. package/packages/server/dist/recovery-config.d.ts +111 -0
  101. package/packages/server/dist/recovery-config.js +105 -0
  102. package/packages/server/dist/recovery.d.ts +262 -0
  103. package/packages/server/dist/recovery.js +490 -0
  104. package/packages/server/dist/resume-drain.d.ts +122 -0
  105. package/packages/server/dist/resume-drain.js +158 -0
  106. package/packages/server/dist/route-context.d.ts +168 -0
  107. package/packages/server/dist/route-context.js +39 -0
  108. package/packages/server/dist/route-errors.d.ts +8 -0
  109. package/packages/server/dist/route-errors.js +13 -0
  110. package/packages/server/dist/routes/chats.d.ts +12 -0
  111. package/packages/server/dist/routes/chats.js +1406 -0
  112. package/packages/server/dist/routes/git.d.ts +13 -0
  113. package/packages/server/dist/routes/git.js +236 -0
  114. package/packages/server/dist/routes/mcp.d.ts +83 -0
  115. package/packages/server/dist/routes/mcp.js +250 -0
  116. package/packages/server/dist/routes/meta.d.ts +15 -0
  117. package/packages/server/dist/routes/meta.js +447 -0
  118. package/packages/server/dist/routes/projects.d.ts +25 -0
  119. package/packages/server/dist/routes/projects.js +736 -0
  120. package/packages/server/dist/routes/triggers.d.ts +21 -0
  121. package/packages/server/dist/routes/triggers.js +324 -0
  122. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  123. package/packages/server/dist/routes/workspace-mount.js +32 -0
  124. package/packages/server/dist/routes.d.ts +26 -0
  125. package/packages/server/dist/routes.js +29 -0
  126. package/packages/server/dist/run-provenance.d.ts +109 -0
  127. package/packages/server/dist/run-provenance.js +206 -0
  128. package/packages/server/dist/runs.d.ts +92 -0
  129. package/packages/server/dist/runs.js +72 -0
  130. package/packages/server/dist/schedule-config.d.ts +68 -0
  131. package/packages/server/dist/schedule-config.js +75 -0
  132. package/packages/server/dist/schedule-session.d.ts +34 -0
  133. package/packages/server/dist/schedule-session.js +120 -0
  134. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  135. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  136. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  137. package/packages/server/dist/self-mcp-read.js +97 -0
  138. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  139. package/packages/server/dist/self-mcp-triggers.js +144 -0
  140. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  141. package/packages/server/dist/self-mcp-types.js +10 -0
  142. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  143. package/packages/server/dist/self-mcp-util.js +157 -0
  144. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  145. package/packages/server/dist/self-mcp-write.js +215 -0
  146. package/packages/server/dist/self-mcp.d.ts +95 -0
  147. package/packages/server/dist/self-mcp.js +448 -0
  148. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  149. package/packages/server/dist/send-file-mcp.js +281 -0
  150. package/packages/server/dist/session-hub.d.ts +186 -0
  151. package/packages/server/dist/session-hub.js +291 -0
  152. package/packages/server/dist/spawn-capability.d.ts +99 -0
  153. package/packages/server/dist/spawn-capability.js +106 -0
  154. package/packages/server/dist/star.d.ts +26 -0
  155. package/packages/server/dist/star.js +94 -0
  156. package/packages/server/dist/start.d.ts +1 -0
  157. package/packages/server/dist/start.js +21 -0
  158. package/packages/server/dist/subagents.d.ts +337 -0
  159. package/packages/server/dist/subagents.js +626 -0
  160. package/packages/server/dist/sweep.d.ts +122 -0
  161. package/packages/server/dist/sweep.js +696 -0
  162. package/packages/server/dist/tooldetails.d.ts +33 -0
  163. package/packages/server/dist/tooldetails.js +466 -0
  164. package/packages/server/dist/transcribe.d.ts +69 -0
  165. package/packages/server/dist/transcribe.js +119 -0
  166. package/packages/server/dist/transcripts.d.ts +42 -0
  167. package/packages/server/dist/transcripts.js +164 -0
  168. package/packages/server/dist/trigger-config.d.ts +278 -0
  169. package/packages/server/dist/trigger-config.js +417 -0
  170. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  171. package/packages/server/dist/trigger-runtime.js +82 -0
  172. package/packages/server/dist/trigger-session.d.ts +34 -0
  173. package/packages/server/dist/trigger-session.js +122 -0
  174. package/packages/server/dist/triggers.d.ts +83 -0
  175. package/packages/server/dist/triggers.js +124 -0
  176. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  177. package/packages/server/dist/trusted-proxy.js +157 -0
  178. package/packages/server/dist/turn-notice.d.ts +154 -0
  179. package/packages/server/dist/turn-notice.js +271 -0
  180. package/packages/server/dist/unread.d.ts +36 -0
  181. package/packages/server/dist/unread.js +123 -0
  182. package/packages/server/dist/usage.d.ts +66 -0
  183. package/packages/server/dist/usage.js +243 -0
  184. package/packages/server/dist/wake-injection.d.ts +156 -0
  185. package/packages/server/dist/wake-injection.js +136 -0
  186. package/packages/server/dist/whisper-local.d.ts +9 -0
  187. package/packages/server/dist/whisper-local.js +122 -0
  188. package/packages/server/dist/ws-context.d.ts +158 -0
  189. package/packages/server/dist/ws-context.js +1 -0
  190. package/packages/server/dist/ws-protocol.d.ts +423 -0
  191. package/packages/server/dist/ws-protocol.js +251 -0
  192. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  193. package/packages/server/dist/ws-self-mcp.js +28 -0
  194. package/packages/server/dist/ws-triggers.d.ts +29 -0
  195. package/packages/server/dist/ws-triggers.js +261 -0
  196. package/packages/server/dist/ws-turn.d.ts +86 -0
  197. package/packages/server/dist/ws-turn.js +751 -0
  198. package/packages/server/dist/ws.d.ts +62 -0
  199. package/packages/server/dist/ws.js +1024 -0
  200. package/packages/server/package.json +6 -0
  201. package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
  202. package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
  203. package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
  204. package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
  205. package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
  206. package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
  207. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
  208. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  209. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
  210. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  211. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
  212. package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
  213. package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
  214. package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
  215. package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
  216. package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
  217. package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
  218. package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
  219. package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
  220. package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
  221. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
  222. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
  223. package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
  224. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
  225. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  226. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  227. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  228. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  229. package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
  230. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  231. package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
  232. package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
  233. package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
  234. package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
  235. package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
  236. package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
  237. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
  238. package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
  239. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
  240. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
  241. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  242. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  243. package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
  244. package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
  245. package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
  246. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  247. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  248. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
  249. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  250. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  251. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
  252. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  253. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
  254. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  255. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  256. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  257. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  258. package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
  259. package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
  260. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
  261. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  262. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  263. package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
  264. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  265. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
  266. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  267. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
  268. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  269. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  270. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
  271. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  272. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
  273. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  274. package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
  275. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
  276. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  277. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
  278. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  279. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
  280. package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
  281. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
  282. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  283. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
  284. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  285. package/packages/web/dist/favicon.ico +0 -0
  286. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  287. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  288. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  289. package/packages/web/dist/icons/favicon-16.png +0 -0
  290. package/packages/web/dist/icons/favicon-32.png +0 -0
  291. package/packages/web/dist/icons/icon-192.png +0 -0
  292. package/packages/web/dist/icons/icon-512.png +0 -0
  293. package/packages/web/dist/icons/maskable-512.png +0 -0
  294. package/packages/web/dist/index.html +56 -0
  295. package/packages/web/dist/manifest.webmanifest +32 -0
  296. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Inbound attachment config — the config foundation for issue #328 Phase 1
3
+ * (ChatGPT-style file/image upload in the composer, "Approach A").
4
+ *
5
+ * ── What this governs ────────────────────────────────────────────────────────
6
+ * A user can pick / drag / paste files into the composer; each is copied into the
7
+ * per-instance attachment store (reusing the `send_file` store, #112) and the
8
+ * keeper is pointed at the absolute paths so its `Read` tool can view them
9
+ * (native vision on images/PDFs). This module resolves the four knobs that gate
10
+ * that flow: whether it's enabled at all, a per-file size cap, a per-message file
11
+ * count cap, and an allow-list of file types.
12
+ *
13
+ * ── Config discipline (the `recovery`/`driveMode` pattern) ───────────────────
14
+ * Every field is an instance default (`PADDOCK_ATTACHMENTS_*` env, YAML instance
15
+ * file beneath it) with an optional PER-PROJECT override (`project.yaml` →
16
+ * {@link import("./projects.js").ProjectYaml.attachments}). An absent/invalid
17
+ * override field inherits the instance default, resolved at request time by
18
+ * {@link resolveAttachmentsConfig} — never baked into the DTO (mirrors how
19
+ * `recovery` resolves via {@link import("./recovery-config.js").resolveRecoveryConfig}).
20
+ * A malformed override is ignored rather than fatal so a hand-edited `project.yaml`
21
+ * can't wedge an upload.
22
+ *
23
+ * ── allowedTypes semantics ───────────────────────────────────────────────────
24
+ * Each entry is either a MIME pattern (contains `/`, `*` wildcards a segment —
25
+ * `image/*`, `*​/*`) or an extension (starts with `.` — `.csv`, `.ipynb`). A file
26
+ * is allowed if its MIME matches any MIME pattern OR its extension matches any
27
+ * extension entry. The sentinel `"*"` (or `"*​/*"`) allows everything (the
28
+ * default). The extension fallback matters because browsers report empty/generic
29
+ * MIME for many real attachments (`.md`, `.ts`, `.heic` → `""`/`text/plain`).
30
+ *
31
+ * NOTE: `File.type` / multipart content-type are client-provided and spoofable,
32
+ * so allowedTypes is a hygiene/UX guardrail, NOT a security boundary (no
33
+ * magic-byte sniffing in v1) — appropriate for a personal/LAN tool.
34
+ */
35
+ /**
36
+ * The built-in attachment defaults (beneath env + YAML + per-project override):
37
+ * enabled, 25 MB/file, 10 files/message, allow-all.
38
+ */
39
+ export const DEFAULT_ATTACHMENTS = Object.freeze({
40
+ enabled: true,
41
+ maxFileSizeMb: 25,
42
+ maxFilesPerMessage: 10,
43
+ allowedTypes: Object.freeze(["*"]),
44
+ });
45
+ /** 1 MB in bytes — the unit `maxFileSizeMb` is expressed in. */
46
+ export const BYTES_PER_MB = 1024 * 1024;
47
+ /** The per-file size cap in BYTES for a resolved config. */
48
+ export function maxFileBytes(cfg) {
49
+ return cfg.maxFileSizeMb * BYTES_PER_MB;
50
+ }
51
+ /** True when `n` is a valid positive integer knob (size/count must be ≥ 1). */
52
+ function isPositiveInt(n) {
53
+ return typeof n === "number" && Number.isInteger(n) && n >= 1 && Number.isFinite(n);
54
+ }
55
+ /**
56
+ * Normalise an allowedTypes list: keep only non-blank strings, trimmed and
57
+ * lower-cased (MIME + extensions are case-insensitive). Returns `undefined` when
58
+ * nothing valid remains (so an empty list never silently means "deny all").
59
+ */
60
+ export function sanitizeAllowedTypes(value) {
61
+ if (!Array.isArray(value))
62
+ return undefined;
63
+ const out = value
64
+ .filter((e) => typeof e === "string")
65
+ .map((e) => e.trim().toLowerCase())
66
+ .filter((e) => e.length > 0);
67
+ return out.length > 0 ? out : undefined;
68
+ }
69
+ /**
70
+ * Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
71
+ * into an {@link AttachmentsOverride}, dropping any field that is missing or
72
+ * invalid, and returning `undefined` when nothing valid remains (so an empty
73
+ * override is never persisted). `enabled` must be a real boolean; the numeric
74
+ * knobs must be positive integers; `allowedTypes` must be a non-empty string
75
+ * array. Defensive by design — a malformed hand-edit degrades to "inherit the
76
+ * instance default", never a crash.
77
+ */
78
+ export function sanitizeAttachmentsOverride(value) {
79
+ if (!value || typeof value !== "object" || Array.isArray(value))
80
+ return undefined;
81
+ const o = value;
82
+ const out = {};
83
+ if (typeof o.enabled === "boolean")
84
+ out.enabled = o.enabled;
85
+ if (isPositiveInt(o.maxFileSizeMb))
86
+ out.maxFileSizeMb = o.maxFileSizeMb;
87
+ if (isPositiveInt(o.maxFilesPerMessage))
88
+ out.maxFilesPerMessage = o.maxFilesPerMessage;
89
+ const types = sanitizeAllowedTypes(o.allowedTypes);
90
+ if (types)
91
+ out.allowedTypes = types;
92
+ return Object.keys(out).length > 0 ? out : undefined;
93
+ }
94
+ /**
95
+ * Resolve the effective attachment config for a request: a valid per-project
96
+ * override wins field-by-field; every absent field inherits the instance default.
97
+ * Mirrors how `recovery`/`driveMode` resolve — the override is carried on disk
98
+ * only for the fields explicitly set, so an absent value transparently inherits
99
+ * the instance (env/YAML) default. The override is re-sanitised so a corrupt
100
+ * on-disk value can't leak through.
101
+ */
102
+ export function resolveAttachmentsConfig(override, instanceDefault) {
103
+ const clean = sanitizeAttachmentsOverride(override) ?? {};
104
+ return {
105
+ enabled: clean.enabled ?? instanceDefault.enabled,
106
+ maxFileSizeMb: clean.maxFileSizeMb ?? instanceDefault.maxFileSizeMb,
107
+ maxFilesPerMessage: clean.maxFilesPerMessage ?? instanceDefault.maxFilesPerMessage,
108
+ allowedTypes: clean.allowedTypes ?? instanceDefault.allowedTypes,
109
+ };
110
+ }
111
+ /** The extension of a filename, lower-cased, WITH the leading dot (`.csv`), or "". */
112
+ function extensionOf(filename) {
113
+ const base = filename.slice(filename.lastIndexOf("/") + 1);
114
+ const dot = base.lastIndexOf(".");
115
+ // No dot, or a dotfile with no extension (`.gitignore` → treat as no ext).
116
+ return dot > 0 ? base.slice(dot).toLowerCase() : "";
117
+ }
118
+ /**
119
+ * Does a concrete `mime` (e.g. `image/png`) match a MIME PATTERN entry (e.g.
120
+ * `image/*`, `*​/*`, `text/csv`)? `*` wildcards a whole segment. Both are
121
+ * lower-cased by the caller / sanitiser. A blank mime never matches a pattern
122
+ * (fall back to the extension test instead).
123
+ */
124
+ function mimeMatches(pattern, mime) {
125
+ if (!mime)
126
+ return false;
127
+ const [pType, pSub] = pattern.split("/", 2);
128
+ const [mType, mSub] = mime.split("/", 2);
129
+ const seg = (p, m) => p === "*" || p === m;
130
+ return seg(pType, mType) && seg(pSub, mSub);
131
+ }
132
+ /**
133
+ * True when a file (identified by its browser-reported `mime` and its
134
+ * `filename`) is allowed by `allowedTypes`. Allowed if any entry matches: a
135
+ * MIME-pattern entry (contains `/`) is tested against `mime`; an extension entry
136
+ * (starts with `.`) against the filename's extension. The sentinel `"*"` /
137
+ * `"*​/*"` allows everything. See the module doc for why the extension fallback
138
+ * exists. `mime`/`filename` may be empty (browsers often omit one).
139
+ */
140
+ export function isTypeAllowed(allowedTypes, mime, filename) {
141
+ const m = (mime ?? "").trim().toLowerCase();
142
+ const ext = extensionOf(filename ?? "");
143
+ for (const raw of allowedTypes) {
144
+ const entry = raw.trim().toLowerCase();
145
+ if (!entry)
146
+ continue;
147
+ // Allow-all sentinels.
148
+ if (entry === "*" || entry === "*/*")
149
+ return true;
150
+ if (entry.includes("/")) {
151
+ if (mimeMatches(entry, m))
152
+ return true;
153
+ }
154
+ else if (entry.startsWith(".")) {
155
+ if (ext && ext === entry)
156
+ return true;
157
+ }
158
+ else {
159
+ // A bare token (e.g. "png") — tolerate it as an extension match.
160
+ if (ext && ext === `.${entry}`)
161
+ return true;
162
+ }
163
+ }
164
+ return false;
165
+ }
@@ -0,0 +1,53 @@
1
+ /** The renderer hint carried per attachment (a subset of send_file's kinds). */
2
+ export type AttachmentKind = "image" | "video" | "pdf" | "markdown" | "code" | "text" | "html" | "file";
3
+ export declare const ATTACHMENTS_OPEN = "<paddock-attachments>";
4
+ export declare const ATTACHMENTS_CLOSE = "</paddock-attachments>";
5
+ /** The literal boundary between the attachment block and the user's real request. */
6
+ export declare const ATTACHMENTS_REQUEST_MARKER = "</paddock-attachments>\n\n";
7
+ /** One attachment as the server knows it when building the prompt block. */
8
+ export interface PromptAttachment {
9
+ /** Opaque attachment-store id (`<uuid><ext>`), also the basename of `path`. */
10
+ id: string;
11
+ /** Original display filename (as uploaded). */
12
+ filename: string;
13
+ /** Renderer hint. */
14
+ kind: AttachmentKind;
15
+ /** Absolute path to the stored bytes, so the keeper's Read tool can open it. */
16
+ path: string;
17
+ }
18
+ /** A parsed attachment ref for the web (no absolute path — it fetches by id). */
19
+ export interface ParsedAttachment {
20
+ id: string;
21
+ filename: string;
22
+ kind: AttachmentKind;
23
+ }
24
+ /**
25
+ * Infer a renderer {@link AttachmentKind} from a filename. Images/videos/PDFs get
26
+ * rich rendering; source/markup/text get a typed chip; everything else is a
27
+ * generic `file`. IMAGE is tested before VIDEO so `.webp` (image) never collides
28
+ * with `.webm` (video).
29
+ */
30
+ export declare function inferAttachmentKind(filename: string, mime?: string): AttachmentKind;
31
+ /**
32
+ * Build the attachment-wrapped prompt. Returns `message` unchanged when there are
33
+ * no attachments, so the caller can always wrap unconditionally.
34
+ */
35
+ export declare function wrapAttachments(attachments: PromptAttachment[], message: string): string;
36
+ /**
37
+ * Parse a (possibly) attachment-wrapped message into its attachment refs + the
38
+ * user's real request. If `text` isn't wrapped it's returned unchanged with an
39
+ * empty list. The block may be nested inside a preload wrapper, so we locate it
40
+ * anywhere in `text` rather than only at the very start.
41
+ */
42
+ export declare function parseAttachmentsWrapper(text: string): {
43
+ attachments: ParsedAttachment[];
44
+ message: string;
45
+ };
46
+ /** Just the attachment ids referenced by a wrapped message (cleanup-on-delete). */
47
+ export declare function parseAttachmentIds(text: string): string[];
48
+ /**
49
+ * Recover the user's real request from a (possibly) attachment-wrapped message —
50
+ * the clean text with the `<paddock-attachments>` block removed. Used for chat
51
+ * display-name derivation (mirrors {@link import("./preload.js").stripPreloadWrapper}).
52
+ */
53
+ export declare function stripAttachmentsWrapper(text: string): string;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The composer-attachment "hint" wrapper for issue #328 Phase 1.
3
+ *
4
+ * On a turn that carries uploaded files, the server prepends a delimited block to
5
+ * the prompt string — exactly like the `<project-context>` preload wrapper
6
+ * (preload.ts) — telling the keeper which files were attached and pointing its
7
+ * `Read` tool at their absolute paths (native vision on images/PDFs). The block
8
+ * is single-sourced HERE so the WS layer that BUILDS it and the web that STRIPS +
9
+ * PARSES it (for the transcript chips) can't drift.
10
+ *
11
+ * <paddock-attachments>
12
+ * The user attached these files. Use the Read tool to view images and PDFs;
13
+ * each line is: id \t kind \t filename \t absolute-path.
14
+ * <id>\t<kind>\t<filename>\t<abs path>
15
+ * …
16
+ * </paddock-attachments>
17
+ *
18
+ * <the user's actual message>
19
+ *
20
+ * Both audiences read the same block: the keeper reads the human header + the
21
+ * visible absolute path in each line; the web parses the tab-delimited lines to
22
+ * rebuild image thumbnails / file chips from the attachment store on reload. The
23
+ * cleanup-on-delete path also parses it (via {@link parseAttachmentIds}) so an
24
+ * uploaded file's bytes are removed with its chat.
25
+ *
26
+ * The wrapper nests INSIDE the preload wrapper when both apply (attachments wrap
27
+ * the user's message first, then preload wraps the whole thing), so stripping the
28
+ * attachment block yields the same clean request the preload strip already
29
+ * expects.
30
+ */
31
+ import { extname } from "node:path";
32
+ export const ATTACHMENTS_OPEN = "<paddock-attachments>";
33
+ export const ATTACHMENTS_CLOSE = "</paddock-attachments>";
34
+ /** The literal boundary between the attachment block and the user's real request. */
35
+ export const ATTACHMENTS_REQUEST_MARKER = `${ATTACHMENTS_CLOSE}\n\n`;
36
+ const HEADER = "The user attached these files. Use the Read tool to view images and PDFs; " +
37
+ "each line is: id\\tkind\\tfilename\\tabsolute-path.";
38
+ const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".heic"]);
39
+ const VIDEO_EXT = new Set([".mp4", ".webm", ".mov", ".m4v"]);
40
+ const PDF_EXT = new Set([".pdf"]);
41
+ const MARKDOWN_EXT = new Set([".md", ".mdx", ".markdown"]);
42
+ const HTML_EXT = new Set([".html", ".htm"]);
43
+ const CODE_EXT = new Set([
44
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs",
45
+ ".java", ".c", ".h", ".cpp", ".cc", ".cs", ".php", ".swift", ".kt", ".sh",
46
+ ".bash", ".zsh", ".sql", ".json", ".yaml", ".yml", ".toml", ".css", ".scss",
47
+ ".xml",
48
+ ]);
49
+ const TEXT_EXT = new Set([".txt", ".text", ".log", ".csv", ".tsv", ".env", ".ini"]);
50
+ /**
51
+ * Infer a renderer {@link AttachmentKind} from a filename. Images/videos/PDFs get
52
+ * rich rendering; source/markup/text get a typed chip; everything else is a
53
+ * generic `file`. IMAGE is tested before VIDEO so `.webp` (image) never collides
54
+ * with `.webm` (video).
55
+ */
56
+ export function inferAttachmentKind(filename, mime) {
57
+ const ext = extname(filename).toLowerCase();
58
+ if (IMAGE_EXT.has(ext) || (mime ?? "").startsWith("image/"))
59
+ return "image";
60
+ if (VIDEO_EXT.has(ext) || (mime ?? "").startsWith("video/"))
61
+ return "video";
62
+ if (PDF_EXT.has(ext) || mime === "application/pdf")
63
+ return "pdf";
64
+ if (MARKDOWN_EXT.has(ext))
65
+ return "markdown";
66
+ if (HTML_EXT.has(ext))
67
+ return "html";
68
+ if (CODE_EXT.has(ext))
69
+ return "code";
70
+ if (TEXT_EXT.has(ext) || (mime ?? "").startsWith("text/"))
71
+ return "text";
72
+ return "file";
73
+ }
74
+ /** A tab or newline in a field would corrupt the block; collapse to a space. */
75
+ function sanitizeField(s) {
76
+ return s.replace(/[\t\r\n]+/g, " ").trim();
77
+ }
78
+ /**
79
+ * Build the attachment-wrapped prompt. Returns `message` unchanged when there are
80
+ * no attachments, so the caller can always wrap unconditionally.
81
+ */
82
+ export function wrapAttachments(attachments, message) {
83
+ if (attachments.length === 0)
84
+ return message;
85
+ const lines = attachments.map((a) => `${a.id}\t${a.kind}\t${sanitizeField(a.filename)}\t${a.path}`);
86
+ return `${ATTACHMENTS_OPEN}\n${HEADER}\n${lines.join("\n")}\n${ATTACHMENTS_REQUEST_MARKER}${message}`;
87
+ }
88
+ /**
89
+ * Parse a (possibly) attachment-wrapped message into its attachment refs + the
90
+ * user's real request. If `text` isn't wrapped it's returned unchanged with an
91
+ * empty list. The block may be nested inside a preload wrapper, so we locate it
92
+ * anywhere in `text` rather than only at the very start.
93
+ */
94
+ export function parseAttachmentsWrapper(text) {
95
+ const open = text.indexOf(ATTACHMENTS_OPEN);
96
+ if (open === -1)
97
+ return { attachments: [], message: text };
98
+ const bodyStart = open + ATTACHMENTS_OPEN.length;
99
+ const close = text.indexOf(ATTACHMENTS_CLOSE, bodyStart);
100
+ if (close === -1)
101
+ return { attachments: [], message: text };
102
+ const body = text.slice(bodyStart, close);
103
+ const attachments = [];
104
+ for (const line of body.split("\n")) {
105
+ const parts = line.split("\t");
106
+ // A data line is `id \t kind \t filename [\t path]`. The header line has no
107
+ // tabs, so it's naturally skipped.
108
+ if (parts.length < 3)
109
+ continue;
110
+ const [id, kind, filename] = parts;
111
+ if (!id || !filename)
112
+ continue;
113
+ attachments.push({
114
+ id: id.trim(),
115
+ filename: filename.trim(),
116
+ kind: (kind.trim() || "file"),
117
+ });
118
+ }
119
+ // Strip the whole block to recover the user's clean request: everything before
120
+ // the open tag + everything after the close marker. (Anything before the open
121
+ // tag is a preload wrapper we deliberately leave intact — existing behavior.)
122
+ const before = text.slice(0, open);
123
+ const afterClose = close + ATTACHMENTS_CLOSE.length;
124
+ const after = text.startsWith(ATTACHMENTS_REQUEST_MARKER, close)
125
+ ? text.slice(close + ATTACHMENTS_REQUEST_MARKER.length)
126
+ : text.slice(afterClose).replace(/^\n+/, "");
127
+ return { attachments, message: before + after };
128
+ }
129
+ /** Just the attachment ids referenced by a wrapped message (cleanup-on-delete). */
130
+ export function parseAttachmentIds(text) {
131
+ return parseAttachmentsWrapper(text).attachments.map((a) => a.id);
132
+ }
133
+ /**
134
+ * Recover the user's real request from a (possibly) attachment-wrapped message —
135
+ * the clean text with the `<paddock-attachments>` block removed. Used for chat
136
+ * display-name derivation (mirrors {@link import("./preload.js").stripPreloadWrapper}).
137
+ */
138
+ export function stripAttachmentsWrapper(text) {
139
+ return parseAttachmentsWrapper(text).message;
140
+ }
@@ -0,0 +1,33 @@
1
+ import type { ChatMessage } from "@herdctl/core";
2
+ export declare class AttachmentStore {
3
+ private readonly root;
4
+ constructor(root: string);
5
+ init(): Promise<void>;
6
+ /** Persist bytes, returning an opaque id (`<uuid>` + the source's extension). */
7
+ save(bytes: Buffer, filenameForExt: string): Promise<string>;
8
+ /**
9
+ * The absolute on-disk path for a (validated) attachment id, or `null` if the
10
+ * id is malformed. Used to point the keeper's `Read` tool at an uploaded file
11
+ * (issue #328) — the store file IS the on-disk copy the agent reads. Does not
12
+ * check existence; pair with {@link exists} when that matters.
13
+ */
14
+ absolutePath(id: string): string | null;
15
+ /** True when a (validated) attachment id currently has bytes on disk. */
16
+ exists(id: string): Promise<boolean>;
17
+ /** Read an attachment by id (validated), with a content-type for serving. */
18
+ read(id: string): Promise<{
19
+ bytes: Buffer;
20
+ mime: string;
21
+ } | null>;
22
+ /** Best-effort removal of a set of attachments (used on chat delete). */
23
+ remove(ids: string[]): Promise<void>;
24
+ }
25
+ /**
26
+ * Extract the attachment ids a chat's transcript references, used to clean up a
27
+ * chat's attachments when it's deleted. Two sources:
28
+ * - agent `send_file` tool outputs (#112) — the JSON envelope's `attachmentId`;
29
+ * - user upload wrappers (#328) — the `<paddock-attachments>` block a user turn
30
+ * carries, parsed by {@link parseAttachmentIds}.
31
+ * Inline `send_file` sends carry no id, so they're naturally skipped.
32
+ */
33
+ export declare function collectAttachmentIds(messages: ChatMessage[]): string[];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Attachment store for files shared via `mcp__paddock__send_file` (issue #112).
3
+ *
4
+ * When an agent sends a REAL file, we copy its bytes here AT SEND TIME and record
5
+ * only an opaque attachment id in the chat transcript (via the tool's output
6
+ * envelope). This makes a shared file an immutable snapshot — it renders
7
+ * identically forever, even if the original is later edited, moved, or deleted —
8
+ * and means the render endpoint only ever serves files that were explicitly
9
+ * sent, never an arbitrary path on the box. (Inline/virtual content is NOT stored
10
+ * here; it rides in the transcript envelope so it stays in the agent's context.)
11
+ *
12
+ * Layout: a flat directory of `<uuid><ext>` files. Ids are self-locating, so the
13
+ * serving endpoint needs only the id. Cleanup on chat delete parses the chat's
14
+ * transcript for the ids it referenced (see `collectAttachmentIds`).
15
+ */
16
+ import { promises as fs } from "node:fs";
17
+ import { randomUUID } from "node:crypto";
18
+ import path from "node:path";
19
+ import { contentTypeFor } from "./projects.js";
20
+ import { SEND_FILE_TOOL_NAME } from "./send-file-mcp.js";
21
+ import { ATTACHMENTS_OPEN, parseAttachmentIds } from "./attachments-hint.js";
22
+ /** `<uuid>` optionally followed by a short `.ext` — the only shape we serve. */
23
+ const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\.[A-Za-z0-9]{1,8})?$/;
24
+ export class AttachmentStore {
25
+ root;
26
+ constructor(root) {
27
+ this.root = root;
28
+ }
29
+ async init() {
30
+ await fs.mkdir(this.root, { recursive: true });
31
+ }
32
+ /** Persist bytes, returning an opaque id (`<uuid>` + the source's extension). */
33
+ async save(bytes, filenameForExt) {
34
+ const ext = path.extname(filenameForExt).toLowerCase().replace(/[^.a-z0-9]/g, "");
35
+ const id = `${randomUUID()}${ext}`;
36
+ await fs.mkdir(this.root, { recursive: true });
37
+ await fs.writeFile(path.join(this.root, id), bytes);
38
+ return id;
39
+ }
40
+ /**
41
+ * The absolute on-disk path for a (validated) attachment id, or `null` if the
42
+ * id is malformed. Used to point the keeper's `Read` tool at an uploaded file
43
+ * (issue #328) — the store file IS the on-disk copy the agent reads. Does not
44
+ * check existence; pair with {@link exists} when that matters.
45
+ */
46
+ absolutePath(id) {
47
+ if (!ID_RE.test(id))
48
+ return null;
49
+ return path.join(this.root, id);
50
+ }
51
+ /** True when a (validated) attachment id currently has bytes on disk. */
52
+ async exists(id) {
53
+ if (!ID_RE.test(id))
54
+ return false;
55
+ try {
56
+ const info = await fs.stat(path.join(this.root, id));
57
+ return info.isFile();
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /** Read an attachment by id (validated), with a content-type for serving. */
64
+ async read(id) {
65
+ if (!ID_RE.test(id))
66
+ return null;
67
+ try {
68
+ const bytes = await fs.readFile(path.join(this.root, id));
69
+ const mime = contentTypeFor(id);
70
+ return {
71
+ bytes,
72
+ // Non-image kinds (markdown/code/text/html) are fetched as text.
73
+ mime: mime === "application/octet-stream" ? "text/plain; charset=utf-8" : mime,
74
+ };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /** Best-effort removal of a set of attachments (used on chat delete). */
81
+ async remove(ids) {
82
+ await Promise.all(ids
83
+ .filter((id) => ID_RE.test(id))
84
+ .map((id) => fs.rm(path.join(this.root, id), { force: true }).catch(() => undefined)));
85
+ }
86
+ }
87
+ /**
88
+ * Extract the attachment ids a chat's transcript references, used to clean up a
89
+ * chat's attachments when it's deleted. Two sources:
90
+ * - agent `send_file` tool outputs (#112) — the JSON envelope's `attachmentId`;
91
+ * - user upload wrappers (#328) — the `<paddock-attachments>` block a user turn
92
+ * carries, parsed by {@link parseAttachmentIds}.
93
+ * Inline `send_file` sends carry no id, so they're naturally skipped.
94
+ */
95
+ export function collectAttachmentIds(messages) {
96
+ const ids = [];
97
+ for (const m of messages) {
98
+ // Agent-sent files (send_file tool output envelope).
99
+ if (m.toolCall?.toolName === SEND_FILE_TOOL_NAME && m.toolCall.output) {
100
+ try {
101
+ const env = JSON.parse(m.toolCall.output);
102
+ if (env?.paddockSendFile === 1 && env.source === "file" && env.attachmentId) {
103
+ ids.push(env.attachmentId);
104
+ }
105
+ }
106
+ catch {
107
+ // Not our envelope / not JSON — skip.
108
+ }
109
+ }
110
+ // User-uploaded files (composer attachment wrapper). A user turn's content is
111
+ // the raw prompt string, which may carry the `<paddock-attachments>` block.
112
+ if (typeof m.content === "string" && m.content.includes(ATTACHMENTS_OPEN)) {
113
+ ids.push(...parseAttachmentIds(m.content));
114
+ }
115
+ }
116
+ return ids;
117
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Provider-agnostic user authentication.
3
+ *
4
+ * Paddock has no login of its own — it is meant to sit behind a reverse proxy
5
+ * that has already authenticated the user against an OIDC IdP (Authentik,
6
+ * oauth2-proxy, Authelia, Cloudflare Access, Keycloak, …). This plugin turns
7
+ * that upstream identity into a `req.user` that the rest of the app can read,
8
+ * WITHOUT hardcoding any single provider. Behaviour is driven entirely by the
9
+ * `PADDOCK_AUTH_*` env vars resolved into `cfg.auth` (see config.ts + AUTH.md).
10
+ *
11
+ * Three modes:
12
+ * - `none` — no-op; every request is anonymous. Fully open (default).
13
+ * - `trusted-header` — read identity from proxy-set header(s). 401 if absent.
14
+ * Trust is network-level: only safe when the proxy is the
15
+ * sole path to paddock (it can forge headers otherwise).
16
+ * - `jwt` — verify a signed JWT (from a configured header) against a
17
+ * remote JWKS using `jose`. Zero-trust / spoof-proof:
18
+ * paddock holds no key material, only the JWKS URL, and
19
+ * rejects missing/invalid/expired tokens with 401.
20
+ *
21
+ * Paddock's health route (`/api/health`) is always exempt so the proxy and
22
+ * monitoring can poll a server that is otherwise locked down. Only registered
23
+ * routes may be exempted — see HEALTH_PATHS below for why (issue #569).
24
+ *
25
+ * Registered in app.ts BEFORE the routes so the `onRequest` hook guards every
26
+ * REST + WS request. The decorator + hook are added directly to the root app
27
+ * instance (no fastify-plugin wrapper needed) so they apply app-wide. The hook
28
+ * never throws past Fastify — auth failures are sent as a clean 401 JSON body
29
+ * and logged via `req.log`.
30
+ */
31
+ import type { FastifyInstance } from "fastify";
32
+ import type { AuthConfig } from "./config.js";
33
+ /** The authenticated (or anonymous) principal attached to every request. */
34
+ export interface AuthUser {
35
+ /** Stable identifier for the user (username / sub / email, per config). */
36
+ username: string;
37
+ /** Email, when the provider supplies one. */
38
+ email?: string;
39
+ /** Group / role memberships, when supplied. */
40
+ groups?: string[];
41
+ /** True for the synthetic principal used in `none` mode (no real identity). */
42
+ anonymous?: boolean;
43
+ }
44
+ declare module "fastify" {
45
+ interface FastifyRequest {
46
+ /**
47
+ * The authenticated principal. Always present once the auth plugin has run:
48
+ * a real user in trusted-header/jwt modes, or an anonymous principal in
49
+ * `none` mode.
50
+ */
51
+ user: AuthUser;
52
+ }
53
+ }
54
+ /**
55
+ * Paths that must never require auth (proxy / monitoring health probes).
56
+ *
57
+ * **Every member must be a REGISTERED route** (see `routes/meta.ts`). Exempting a
58
+ * path that no route serves does not make it 404 — it makes the SPA not-found
59
+ * handler (`app.ts`) reachable *without a credential*, so the probe receives the
60
+ * HTML app shell with a 200 and reads a locked-down or half-broken instance as
61
+ * healthy. In the authenticated modes that inverts the truthful answer: an
62
+ * unregistered path 401s honestly, but an exempted unregistered one reports 200.
63
+ *
64
+ * This set previously carried five conventional aliases (`/healthz`, `/-/health`,
65
+ * `/health`, `/readyz`, `/livez`) that were never registered — issue #569.
66
+ * `test/integration/auth-health-paths.test.ts` now asserts every member resolves
67
+ * to a real route returning JSON, so the set cannot drift from the router again.
68
+ */
69
+ export declare const HEALTH_PATHS: ReadonlySet<string>;
70
+ /**
71
+ * Register the auth layer on `app`. Adds a `user` request decorator and an
72
+ * `onRequest` hook that populates `req.user` (or replies 401). The decorator +
73
+ * hook are attached to the root app instance directly, so they guard every
74
+ * REST + WS request app-wide.
75
+ *
76
+ * In `jwt` mode the remote JWKS is created once and reused —
77
+ * `createRemoteJWKSet` fetches+caches keys and handles rotation, so we hold only
78
+ * the URL, never key material.
79
+ *
80
+ * Throws at registration time on a fatal misconfiguration (jwt mode without a
81
+ * JWKS URL) so the operator gets a clear startup failure instead of a server
82
+ * that rejects every request — failing closed but loudly.
83
+ */
84
+ export declare function registerAuth(app: FastifyInstance, auth: AuthConfig): void;