@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,325 @@
1
+ /**
2
+ * App factory — builds a fully-wired Fastify instance (REST + WS + optional
3
+ * static SPA) around a ProjectStore + HerdctlService + GitService + GithubAuth.
4
+ *
5
+ * Extracted from index.ts so tests can boot the REAL app in-process against a
6
+ * temp data dir (with a fake `claude` on PATH) without binding a port or
7
+ * registering signal handlers. `index.ts` calls `buildApp()` then `app.listen`.
8
+ *
9
+ * This is a testability seam only — the wiring is identical to the prior inline
10
+ * bootstrap; no behavior changed.
11
+ */
12
+ import Fastify from "fastify";
13
+ import websocket from "@fastify/websocket";
14
+ import fastifyStatic from "@fastify/static";
15
+ import fastifyMultipart from "@fastify/multipart";
16
+ import fastifySwagger from "@fastify/swagger";
17
+ import fastifySwaggerUi from "@fastify/swagger-ui";
18
+ import { createRequire } from "node:module";
19
+ import { promises as fs } from "node:fs";
20
+ import path from "node:path";
21
+ import { loadPaddockConfig } from "./config.js";
22
+ import { ProjectStore, ROOT_KEY } from "./projects.js";
23
+ import { AttachmentStore } from "./attachments.js";
24
+ import { HerdctlService } from "./herdctl.js";
25
+ import { GitService } from "./git.js";
26
+ import { GithubAuth } from "./github-auth.js";
27
+ import { makeTranscriber } from "./transcribe.js";
28
+ import { registerRoutes } from "./routes.js";
29
+ import { registerAuth } from "./auth.js";
30
+ import { evaluateBindSafety } from "./bind-safety.js";
31
+ import { renderIndexHtml } from "./brand.js";
32
+ import { makeChatHandler } from "./ws.js";
33
+ import { SweepService } from "./sweep.js";
34
+ import { ArchiveStore } from "./archive.js";
35
+ import { StarStore } from "./star.js";
36
+ import { ReadStateStore } from "./read-state.js";
37
+ import { UnreadStore } from "./unread.js";
38
+ import { ParentDetachStore } from "./parent-detach.js";
39
+ import { QueuedMessageStore } from "./queued-message.js";
40
+ import { RunProvenanceStore } from "./run-provenance.js";
41
+ import { MessageProvenanceStore } from "./message-provenance.js";
42
+ import { ScheduleSessionStore } from "./schedule-session.js";
43
+ import { TriggerSessionStore } from "./trigger-session.js";
44
+ import { PaddockEventBus } from "./event-bus.js";
45
+ import { TriggerService } from "./triggers.js";
46
+ import { buildSwaggerOptions, buildSwaggerUiOptions } from "./openapi.js";
47
+ // Resolve the package version at runtime (dist/app.js → ../package.json) so the
48
+ // generated OpenAPI document's info.version tracks the release without a build step.
49
+ const pkgVersion = (() => {
50
+ try {
51
+ const require = createRequire(import.meta.url);
52
+ return require("../package.json").version ?? "0.0.0";
53
+ }
54
+ catch {
55
+ return "0.0.0";
56
+ }
57
+ })();
58
+ /**
59
+ * True when the (query-stripped) path's last segment carries a file extension —
60
+ * e.g. `/assets/index-ABC.js`, `/favicon.ico`, `/sw.js`. Used by the SPA
61
+ * not-found handler (issue #220) to distinguish a missing static asset (→ 404)
62
+ * from a client-side route (→ index.html shell).
63
+ */
64
+ function hasFileExtension(pathname) {
65
+ const last = pathname.slice(pathname.lastIndexOf("/") + 1);
66
+ return /\.[^./]+$/.test(last);
67
+ }
68
+ /**
69
+ * Construct and fully register the paddock app. Does NOT listen on a port and
70
+ * installs NO signal handlers — the caller (index.ts in prod, tests in CI)
71
+ * owns the lifecycle. The herdctl fleet is initialized + started; a failure
72
+ * there is logged and swallowed (project CRUD still works), matching prod.
73
+ */
74
+ export async function buildApp(opts = {}) {
75
+ const cfg = opts.config ?? loadPaddockConfig();
76
+ const app = Fastify({
77
+ logger: { level: cfg.logLevel },
78
+ });
79
+ // --- safe-by-default bind guard (#435) --------------------------------
80
+ // Fail closed if the resolved bind host is non-loopback AND auth is `none`:
81
+ // that would expose an unauthenticated Paddock (which runs code + spends
82
+ // tokens) on a routable interface. Mirrors the jwt-without-JWKS fail-closed
83
+ // check below. A dangerously-worded env opt-in downgrades the refusal to a
84
+ // loud one-line boot warning.
85
+ {
86
+ const decision = evaluateBindSafety({
87
+ host: cfg.host,
88
+ authMode: cfg.auth.mode,
89
+ dangerouslyAllowOpen: cfg.dangerouslyAllowOpen,
90
+ });
91
+ if (decision.action === "refuse")
92
+ throw new Error(decision.message);
93
+ if (decision.action === "warn")
94
+ app.log.warn(decision.message);
95
+ }
96
+ // --- auth (provider-agnostic) -----------------------------------------
97
+ // Registered first so its onRequest hook guards every REST + WS request
98
+ // (health probes are exempted inside). Default mode `none` is a no-op. Throws
99
+ // on a fatal misconfig (e.g. jwt mode without a JWKS URL) — fail closed.
100
+ registerAuth(app, cfg.auth);
101
+ // --- project layer + herdctl ------------------------------------------
102
+ const projects = new ProjectStore(cfg.projectsRoot);
103
+ await projects.init();
104
+ const herdctl = new HerdctlService(cfg);
105
+ const git = new GitService(cfg.projectsRoot, cfg.gitAuthor);
106
+ const githubAuth = new GithubAuth(path.join(cfg.dataDir, "github-auth.json"), cfg.githubClientId);
107
+ const archive = new ArchiveStore(cfg.dataDir);
108
+ // Per-chat starred/pinned-flag sidecar (#373). Orthogonal to `archive`; the
109
+ // client floats starred chats to the top of both the active and Archived lists.
110
+ const star = new StarStore(cfg.dataDir);
111
+ // Per-user (or shared, in `none` mode) chat read-state sidecar (#189).
112
+ const readState = new ReadStateStore(cfg.dataDir);
113
+ // Per-user manual "unread" override sidecar (#458) — layered on read-state so a
114
+ // chat can be re-flagged unread after its last turn was seen ("look at it again
115
+ // in the morning"). Cleared whenever the chat is marked seen.
116
+ const unread = new UnreadStore(cfg.dataDir);
117
+ // Explicit "this chat was detached from its parent" sidecar (#508). Checked
118
+ // ahead of both parent-resolution tiers, so a detach survives the inference
119
+ // that would otherwise re-derive the old parent on the next list load.
120
+ const parentDetach = new ParentDetachStore(cfg.dataDir);
121
+ // Per-chat queued message sidecar (#197) for server-side auto-send.
122
+ const queuedMessage = new QueuedMessageStore(cfg.dataDir);
123
+ // Per-chat provenance sidecar (issue #261): records how each chat was created
124
+ // (origin human/scheduled/spawned + spawn depth) so #262 can depth-gate
125
+ // spawning and #267 can badge provenance. A1 only carries/persists the marker.
126
+ const runProvenance = new RunProvenanceStore(cfg.dataDir);
127
+ // Per-MESSAGE provenance sidecar (issue #290): records WHO injected each
128
+ // machine-added turn (send_message / schedule / spawn kickoff) so the chat
129
+ // history can attribute it. The per-message analog of runProvenance.
130
+ const messageProvenance = new MessageProvenanceStore(cfg.dataDir);
131
+ // Owned-session sidecar for accreting schedules (issue #265 / DD-2): maps a
132
+ // `resume_session: true` schedule to the one chat it accretes into across fires.
133
+ const scheduleSessions = new ScheduleSessionStore(cfg.dataDir);
134
+ // In-process lifecycle event bus (Epic T). The archive commit sites (REST route +
135
+ // self-MCP archive tool) `emit` onto the bus; the chat handler subscribes and fires
136
+ // the project's enabled event triggers via startAgentTurn.
137
+ const events = new PaddockEventBus();
138
+ // Unified trigger registry + owned-session sidecar (Epic T / T1). TriggerService is
139
+ // the single CRUD surface over both fire paths (event bus + schedule handler); the
140
+ // sidecar rebinds a `run.session: "resume"` trigger's owned chat after a restart.
141
+ const triggers = new TriggerService(projects, herdctl);
142
+ const triggerSessions = new TriggerSessionStore(cfg.dataDir);
143
+ // Store for files shared via mcp__paddock__send_file (issue #112). Copies live
144
+ // outside any project working dir so they never show up as untracked repo files.
145
+ const attachments = new AttachmentStore(path.join(cfg.dataDir, "attachments"));
146
+ await attachments.init();
147
+ const transcriber = makeTranscriber(cfg.transcription);
148
+ app.log.info({ mode: cfg.transcription.mode, available: transcriber.available }, "voice dictation capability");
149
+ // `list()` enumerates the root's CHILDREN (it walks subdirectories), so the
150
+ // root workspace itself is never in it. It always exists — the instance's own
151
+ // directory — so resolve it explicitly and register its keeper alongside every
152
+ // child's. The root keeper is an ordinary keeper in every respect.
153
+ const rootWorkspace = await projects.get(ROOT_KEY);
154
+ const initialProjects = [...(await projects.list()), rootWorkspace];
155
+ try {
156
+ await herdctl.init(initialProjects);
157
+ await herdctl.start();
158
+ app.log.info("herdctl FleetManager initialized");
159
+ }
160
+ catch (err) {
161
+ app.log.error({ err }, "FleetManager init/start failed — chat will be unavailable");
162
+ }
163
+ // --- post-turn curation sweep (overview + changelog) -------------------
164
+ const sweep = new SweepService({
165
+ herdctl,
166
+ projects,
167
+ dataDir: cfg.dataDir,
168
+ minIntervalMs: cfg.sweepMinIntervalMs,
169
+ budget: cfg.curation,
170
+ logger: app.log,
171
+ });
172
+ // --- transport ---------------------------------------------------------
173
+ await app.register(websocket);
174
+ // Multipart parses the mic-recording upload on POST /api/transcribe. The size
175
+ // cap mirrors the transcription config so an oversized blob is rejected before
176
+ // it's buffered in full.
177
+ await app.register(fastifyMultipart, {
178
+ limits: { fileSize: cfg.transcription.maxUploadBytes, files: 1 },
179
+ });
180
+ // --- OpenAPI (derived from route schemas) -----------------------------
181
+ // @fastify/swagger MUST register before the routes: it hooks `onRoute` to
182
+ // collect each route's schema into a live OpenAPI document. Swagger UI mounts
183
+ // at /docs and reads that document (raw spec at /docs/json). Both sit behind
184
+ // whatever auth mode is configured (no special exemption) — the docs are
185
+ // gated exactly like the API they describe. The security schemes advertised in
186
+ // the spec reflect this instance's auth mode (see openapi.ts / authDoc).
187
+ //
188
+ // The topbar logo + favicon are the shipped Paddock icons from the built web
189
+ // bundle (icons/*.png), so the docs are branded like the app; missing assets
190
+ // (API-only mode with no web dist) just fall back to the stock look.
191
+ if (cfg.openapi.enabled) {
192
+ const readImage = async (rel, type) => {
193
+ try {
194
+ return { type, content: await fs.readFile(path.join(cfg.webDist, rel)) };
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ };
200
+ const [swaggerLogo, swaggerFavicon] = await Promise.all([
201
+ readImage(path.join("icons", "icon-192.png"), "image/png"),
202
+ readImage(path.join("icons", "favicon-32.png"), "image/png"),
203
+ ]);
204
+ await app.register(fastifySwagger, buildSwaggerOptions(pkgVersion, cfg.auth));
205
+ await app.register(fastifySwaggerUi, buildSwaggerUiOptions({
206
+ routePrefix: cfg.openapi.path,
207
+ logo: swaggerLogo,
208
+ favicon: swaggerFavicon,
209
+ accent: cfg.brand.accent,
210
+ }));
211
+ // Stable, tool-friendly alias for the raw spec (e.g. `/open-api.json`), next
212
+ // to the UI's own `<path>/json`. Hidden from the spec itself.
213
+ app.get(`${cfg.openapi.path}.json`, { schema: { hide: true } }, () => app.swagger());
214
+ app.log.info({ path: cfg.openapi.path }, "OpenAPI reference mounted");
215
+ }
216
+ else {
217
+ app.log.info("OpenAPI reference off (set PADDOCK_OPENAPI_ENABLED=1 to mount /open-api)");
218
+ }
219
+ const chatHandler = makeChatHandler({ herdctl, projects, sweep, attachments, queuedMessage, runProvenance, messageProvenance, archive, scheduleSessions, events, triggers, triggerSessions, cfg });
220
+ // --- external Management API (#312 M1) ---------------------------------
221
+ // Surface how the `managementApi` block resolved. A malformed client is an
222
+ // ERROR (the operator wrote something meaningless); a dropped one is a WARNING
223
+ // (its credential wouldn't resolve, so it failed closed). Logged here because
224
+ // config loading has no logger of its own.
225
+ for (const message of cfg.managementApiDiagnostics.errors)
226
+ app.log.error(message);
227
+ for (const message of cfg.managementApiDiagnostics.warnings)
228
+ app.log.warn(message);
229
+ if (cfg.managementApi.clients.length > 0) {
230
+ app.log.info({
231
+ clients: cfg.managementApi.clients.map((c) => c.clientId),
232
+ instanceId: cfg.managementApi.instanceId,
233
+ }, "management API: /mcp enabled (self-authenticated — independent of PADDOCK_AUTH_MODE and of any proxy)");
234
+ }
235
+ else {
236
+ app.log.info("management API: /mcp disabled (no managementApi.clients configured) — the endpoint 404s");
237
+ }
238
+ await registerRoutes(app, { projects, herdctl, git, githubAuth, transcriber, archive, star, readState, unread, parentDetach, runProvenance, messageProvenance, attachments, fireTrigger: chatHandler.fireTrigger, managementOpsContext: chatHandler.managementOpsContext, events, triggers, cfg });
239
+ await app.register(async (scoped) => {
240
+ // `hide: true` keeps the WS upgrade out of the OpenAPI doc — it's not a REST
241
+ // endpoint (a Swagger "Try it out" against it would just fail); the WS frame
242
+ // protocol is documented in the spec description + docs/API.md instead.
243
+ scoped.get("/ws", { websocket: true, schema: { hide: true } }, (socket) => {
244
+ void chatHandler.handle(socket);
245
+ });
246
+ });
247
+ // --- static SPA (production) ------------------------------------------
248
+ const serveStatic = opts.serveStatic ?? true;
249
+ if (serveStatic) {
250
+ const hasWebDist = await fs
251
+ .stat(cfg.webDist)
252
+ .then((s) => s.isDirectory())
253
+ .catch(() => false);
254
+ if (hasWebDist) {
255
+ // Inject per-instance branding (issue #34) into index.html ONCE at
256
+ // startup, then serve that string for the app root + every client-side
257
+ // route.
258
+ const rawIndex = await fs.readFile(path.join(cfg.webDist, "index.html"), "utf8");
259
+ const indexHtml = renderIndexHtml(rawIndex, cfg.brand, {
260
+ enabled: cfg.openapi.enabled,
261
+ path: cfg.openapi.path,
262
+ });
263
+ const sendIndex = (reply) => reply.type("text/html; charset=utf-8").send(indexHtml);
264
+ // Short-circuit the app root before fastifyStatic can serve the RAW
265
+ // index.html (a per-file route with `wildcard: false`). An onRequest hook
266
+ // that replies stops routing, so we own the branded document without
267
+ // registering a route that would collide with the static plugin.
268
+ app.addHook("onRequest", async (req, reply) => {
269
+ if (req.method !== "GET")
270
+ return;
271
+ const p = req.url.split("?")[0];
272
+ if (p === "/" || p === "/index.html")
273
+ return sendIndex(reply);
274
+ });
275
+ await app.register(fastifyStatic, { root: cfg.webDist, wildcard: false });
276
+ app.setNotFoundHandler((req, reply) => {
277
+ if (req.method === "GET" &&
278
+ !req.url.startsWith("/api") &&
279
+ !req.url.startsWith("/ws") &&
280
+ // #312: `/.well-known/` and `/mcp` are MACHINE surfaces and must 404
281
+ // honestly when absent. Both are extension-less, so without this they
282
+ // would fall into the client-side-route branch below and be answered
283
+ // with the SPA shell + 200 — which (a) holes the fail-closed guarantee
284
+ // (an unconfigured management API would "exist" at the discovery URL)
285
+ // and (b) breaks MCP OAuth discovery: a client fetching the
286
+ // protected-resource metadata receives HTML, fails to parse it, and
287
+ // silently falls back to treating Paddock as its own authorization
288
+ // server, with no error naming the real cause.
289
+ !req.url.startsWith("/.well-known/") &&
290
+ !req.url.startsWith("/mcp")) {
291
+ // Serve the SPA shell for client-side routes — but NEVER for a request
292
+ // that is clearly a missing *static asset* (a stale hashed chunk after a
293
+ // deploy, or any missing file). Those must 404 (issue #220): otherwise the
294
+ // browser receives index.html (text/html) for a JS/CSS module and throws
295
+ // "Failed to load module script…" ("Unexpected application error: a module
296
+ // script failed"), and the service worker would cache that HTML under the
297
+ // asset URL and serve it indefinitely.
298
+ //
299
+ // A request is treated as a client-side route (→ shell) when it is a real
300
+ // browser navigation (Accept: text/html, or Sec-Fetch-Mode: navigate) OR
301
+ // its path has no file extension. Everything else with a file extension
302
+ // (e.g. /assets/index-DEADBEEF.js, /favicon.ico) 404s. Dotted client routes
303
+ // such as /projects/x/files/README.md still resolve to the shell because a
304
+ // reload/deep-link of them is a navigation carrying Accept: text/html.
305
+ const p = req.url.split("?")[0];
306
+ const accept = String(req.headers["accept"] ?? "");
307
+ const isNavigation = req.headers["sec-fetch-mode"] === "navigate" || accept.includes("text/html");
308
+ if (isNavigation || !hasFileExtension(p))
309
+ return sendIndex(reply);
310
+ }
311
+ return reply.code(404).send({ error: "not found" });
312
+ });
313
+ app.log.info({ webDist: cfg.webDist, brand: cfg.brand.name }, "serving built web SPA");
314
+ }
315
+ else {
316
+ app.log.warn({ webDist: cfg.webDist }, "web dist not found — API-only mode");
317
+ }
318
+ }
319
+ const close = async () => {
320
+ sweep.stop();
321
+ await herdctl.stop().catch(() => undefined);
322
+ await app.close().catch(() => undefined);
323
+ };
324
+ return { app, cfg, projects, herdctl, git, githubAuth, sweep, archive, star, readState, unread, parentDetach, runProvenance, queuedMessage, transcriber, events, triggers, close };
325
+ }
@@ -0,0 +1,42 @@
1
+ export declare class ArchiveStore {
2
+ private readonly stateFile;
3
+ /** In-memory set of archived keys (loaded once, written through on change). */
4
+ private archived;
5
+ /**
6
+ * The in-flight load, cached so concurrent first-callers share ONE read.
7
+ * Caching only the resolved `archived` set would let several toggles that
8
+ * begin before the first `fs.readFile` resolves each build their own set and
9
+ * race on the assignment — a lost update (last writer wins, dropping the
10
+ * others' keys).
11
+ */
12
+ private loadPromise;
13
+ /** Serialises concurrent writes so the file never interleaves. */
14
+ private writing;
15
+ constructor(dataDir: string);
16
+ /** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
17
+ private ensureLoaded;
18
+ /** Whether a chat is archived. Non-throwing: any load error reads as false. */
19
+ isArchived(agent: string, sessionId: string): Promise<boolean>;
20
+ /**
21
+ * Set (or clear) a chat's archived flag, persisting the change. Idempotent.
22
+ * Returns whether the flag actually CHANGED — so a caller can fire an "archived"
23
+ * lifecycle event (Epic G / G1) exactly once per real transition, not on a
24
+ * redundant re-archive of an already-archived chat.
25
+ */
26
+ setArchived(agent: string, sessionId: string, archived: boolean): Promise<boolean>;
27
+ /**
28
+ * Set (or clear) the archived flag for MANY chats in ONE write (#508).
29
+ *
30
+ * The subtree actions apply an archive to a parent and every descendant. Looped
31
+ * one-at-a-time that's N round trips, and a failure partway leaves a torn family
32
+ * (parent archived, half the children not) that the user has to unpick by hand.
33
+ * Here the whole set lands in the in-memory set first and persists once, so the
34
+ * sidecar only ever holds the before or the after state.
35
+ *
36
+ * Returns the ids whose flag actually CHANGED, so the caller can fire one
37
+ * `onArchive` per real transition — the same contract as {@link setArchived}.
38
+ */
39
+ setManyArchived(agent: string, sessionIds: readonly string[], archived: boolean): Promise<string[]>;
40
+ /** Write-through, serialised so overlapping toggles can't corrupt the file. */
41
+ private persist;
42
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * ArchiveStore — a Paddock-side sidecar for the per-chat "archived" flag (#95).
3
+ *
4
+ * Archiving files a finished chat away into a collapsible "Archived" section
5
+ * without touching its transcript, so it stays fully openable/resumable/forkable
6
+ * — the flag is purely presentational metadata.
7
+ *
8
+ * The natural home for this is @herdctl/core's SessionMetadataStore (the same
9
+ * layer that persists custom chat names), whose schema already reserves room for
10
+ * a future `archived` field. Until that ships upstream, Paddock keeps its own
11
+ * tiny JSON sidecar in the data dir — the exact same pattern SweepService uses
12
+ * for its watermark state. Keyed by `<agent>\0<sessionId>` (NUL-separated) so
13
+ * chats under different agents can never collide — a NUL can occur in neither an
14
+ * agent name (`keeper-<slug>`) nor a UUID. Only archived entries
15
+ * are stored (an unarchive deletes the key), keeping the file compact.
16
+ */
17
+ import { promises as fs } from "node:fs";
18
+ import path from "node:path";
19
+ const STATE_FILE = "archive-state.json";
20
+ /** Separator in a storage key; a NUL can't occur in an agent name or a UUID. */
21
+ const KEY_SEP = "\u0000";
22
+ /** Compose the storage key for an (agent, session) pair. */
23
+ function keyOf(agent, sessionId) {
24
+ return `${agent}${KEY_SEP}${sessionId}`;
25
+ }
26
+ export class ArchiveStore {
27
+ stateFile;
28
+ /** In-memory set of archived keys (loaded once, written through on change). */
29
+ archived = null;
30
+ /**
31
+ * The in-flight load, cached so concurrent first-callers share ONE read.
32
+ * Caching only the resolved `archived` set would let several toggles that
33
+ * begin before the first `fs.readFile` resolves each build their own set and
34
+ * race on the assignment — a lost update (last writer wins, dropping the
35
+ * others' keys).
36
+ */
37
+ loadPromise = null;
38
+ /** Serialises concurrent writes so the file never interleaves. */
39
+ writing = Promise.resolve();
40
+ constructor(dataDir) {
41
+ this.stateFile = path.join(dataDir, STATE_FILE);
42
+ }
43
+ /** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
44
+ ensureLoaded() {
45
+ if (this.archived)
46
+ return Promise.resolve(this.archived);
47
+ if (!this.loadPromise) {
48
+ this.loadPromise = (async () => {
49
+ let keys = [];
50
+ try {
51
+ const raw = await fs.readFile(this.stateFile, "utf8");
52
+ const parsed = JSON.parse(raw);
53
+ if (Array.isArray(parsed))
54
+ keys = parsed.filter((k) => typeof k === "string");
55
+ }
56
+ catch {
57
+ /* missing or unreadable — start empty */
58
+ }
59
+ this.archived = new Set(keys);
60
+ return this.archived;
61
+ })();
62
+ }
63
+ return this.loadPromise;
64
+ }
65
+ /** Whether a chat is archived. Non-throwing: any load error reads as false. */
66
+ async isArchived(agent, sessionId) {
67
+ const set = await this.ensureLoaded().catch(() => new Set());
68
+ return set.has(keyOf(agent, sessionId));
69
+ }
70
+ /**
71
+ * Set (or clear) a chat's archived flag, persisting the change. Idempotent.
72
+ * Returns whether the flag actually CHANGED — so a caller can fire an "archived"
73
+ * lifecycle event (Epic G / G1) exactly once per real transition, not on a
74
+ * redundant re-archive of an already-archived chat.
75
+ */
76
+ async setArchived(agent, sessionId, archived) {
77
+ const set = await this.ensureLoaded();
78
+ const key = keyOf(agent, sessionId);
79
+ if (archived === set.has(key))
80
+ return false; // no-op — avoid a needless write
81
+ if (archived)
82
+ set.add(key);
83
+ else
84
+ set.delete(key);
85
+ await this.persist(set);
86
+ return true;
87
+ }
88
+ /**
89
+ * Set (or clear) the archived flag for MANY chats in ONE write (#508).
90
+ *
91
+ * The subtree actions apply an archive to a parent and every descendant. Looped
92
+ * one-at-a-time that's N round trips, and a failure partway leaves a torn family
93
+ * (parent archived, half the children not) that the user has to unpick by hand.
94
+ * Here the whole set lands in the in-memory set first and persists once, so the
95
+ * sidecar only ever holds the before or the after state.
96
+ *
97
+ * Returns the ids whose flag actually CHANGED, so the caller can fire one
98
+ * `onArchive` per real transition — the same contract as {@link setArchived}.
99
+ */
100
+ async setManyArchived(agent, sessionIds, archived) {
101
+ const set = await this.ensureLoaded();
102
+ const changed = [];
103
+ for (const sessionId of sessionIds) {
104
+ const key = keyOf(agent, sessionId);
105
+ if (archived === set.has(key))
106
+ continue; // already in the target state
107
+ if (archived)
108
+ set.add(key);
109
+ else
110
+ set.delete(key);
111
+ changed.push(sessionId);
112
+ }
113
+ if (changed.length)
114
+ await this.persist(set);
115
+ return changed;
116
+ }
117
+ /** Write-through, serialised so overlapping toggles can't corrupt the file. */
118
+ persist(set) {
119
+ this.writing = this.writing.then(async () => {
120
+ const json = JSON.stringify([...set], null, 2);
121
+ await fs.writeFile(this.stateFile, json, "utf8").catch(() => undefined);
122
+ });
123
+ return this.writing;
124
+ }
125
+ }
@@ -0,0 +1,114 @@
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
+ * Resolved attachment config — all fields concrete. Held on {@link
37
+ * import("./config.js").PaddockConfig.attachments} (instance defaults) and
38
+ * produced per-request by {@link resolveAttachmentsConfig} (project override else
39
+ * instance).
40
+ */
41
+ export interface AttachmentsConfig {
42
+ /**
43
+ * Master switch for inbound composer uploads. Default ON. When off, the upload
44
+ * endpoint 403s and the composer hides its picker/drop/paste affordances. Env
45
+ * `PADDOCK_ATTACHMENTS_ENABLED`.
46
+ */
47
+ enabled: boolean;
48
+ /**
49
+ * Per-file size cap in megabytes (1 MB = 1024*1024 bytes). A larger file is
50
+ * rejected before it's written. Default 25. Env
51
+ * `PADDOCK_ATTACHMENTS_MAX_FILE_SIZE_MB`.
52
+ */
53
+ maxFileSizeMb: number;
54
+ /**
55
+ * How many files a single message may carry. Enforced client-side (tray cap)
56
+ * and server-side (per upload request + at send). Default 10. Env
57
+ * `PADDOCK_ATTACHMENTS_MAX_FILES_PER_MESSAGE`.
58
+ */
59
+ maxFilesPerMessage: number;
60
+ /**
61
+ * Allow-list of MIME patterns / extensions (see the module doc). Default
62
+ * `["*"]` (allow everything). Env `PADDOCK_ATTACHMENTS_ALLOWED_TYPES`
63
+ * (comma-separated); YAML/project take a real array.
64
+ */
65
+ allowedTypes: string[];
66
+ }
67
+ /**
68
+ * A per-project attachment override as stored in `project.yaml` — every field
69
+ * optional (an absent field inherits the instance default at request time).
70
+ */
71
+ export type AttachmentsOverride = Partial<AttachmentsConfig>;
72
+ /**
73
+ * The built-in attachment defaults (beneath env + YAML + per-project override):
74
+ * enabled, 25 MB/file, 10 files/message, allow-all.
75
+ */
76
+ export declare const DEFAULT_ATTACHMENTS: AttachmentsConfig;
77
+ /** 1 MB in bytes — the unit `maxFileSizeMb` is expressed in. */
78
+ export declare const BYTES_PER_MB: number;
79
+ /** The per-file size cap in BYTES for a resolved config. */
80
+ export declare function maxFileBytes(cfg: AttachmentsConfig): number;
81
+ /**
82
+ * Normalise an allowedTypes list: keep only non-blank strings, trimmed and
83
+ * lower-cased (MIME + extensions are case-insensitive). Returns `undefined` when
84
+ * nothing valid remains (so an empty list never silently means "deny all").
85
+ */
86
+ export declare function sanitizeAllowedTypes(value: unknown): string[] | undefined;
87
+ /**
88
+ * Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
89
+ * into an {@link AttachmentsOverride}, dropping any field that is missing or
90
+ * invalid, and returning `undefined` when nothing valid remains (so an empty
91
+ * override is never persisted). `enabled` must be a real boolean; the numeric
92
+ * knobs must be positive integers; `allowedTypes` must be a non-empty string
93
+ * array. Defensive by design — a malformed hand-edit degrades to "inherit the
94
+ * instance default", never a crash.
95
+ */
96
+ export declare function sanitizeAttachmentsOverride(value: unknown): AttachmentsOverride | undefined;
97
+ /**
98
+ * Resolve the effective attachment config for a request: a valid per-project
99
+ * override wins field-by-field; every absent field inherits the instance default.
100
+ * Mirrors how `recovery`/`driveMode` resolve — the override is carried on disk
101
+ * only for the fields explicitly set, so an absent value transparently inherits
102
+ * the instance (env/YAML) default. The override is re-sanitised so a corrupt
103
+ * on-disk value can't leak through.
104
+ */
105
+ export declare function resolveAttachmentsConfig(override: AttachmentsOverride | undefined, instanceDefault: AttachmentsConfig): AttachmentsConfig;
106
+ /**
107
+ * True when a file (identified by its browser-reported `mime` and its
108
+ * `filename`) is allowed by `allowedTypes`. Allowed if any entry matches: a
109
+ * MIME-pattern entry (contains `/`) is tested against `mime`; an extension entry
110
+ * (starts with `.`) against the filename's extension. The sentinel `"*"` /
111
+ * `"*​/*"` allows everything. See the module doc for why the extension fallback
112
+ * exists. `mime`/`filename` may be empty (browsers often omit one).
113
+ */
114
+ export declare function isTypeAllowed(allowedTypes: string[], mime: string | undefined, filename: string | undefined): boolean;