discoclaw 0.1.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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ vi.mock('execa', () => ({
3
+ execa: vi.fn(),
4
+ }));
5
+ import { getGitHash } from './version.js';
6
+ describe('getGitHash', () => {
7
+ let mockExeca;
8
+ beforeEach(async () => {
9
+ const mod = await import('execa');
10
+ mockExeca = mod.execa;
11
+ mockExeca.mockReset();
12
+ });
13
+ it('returns the short hash from git rev-parse output', async () => {
14
+ mockExeca.mockResolvedValueOnce({ stdout: 'f52710d\n' });
15
+ const hash = await getGitHash();
16
+ expect(hash).toBe('f52710d');
17
+ expect(mockExeca).toHaveBeenCalledWith('git', ['rev-parse', '--short', 'HEAD']);
18
+ });
19
+ it('trims whitespace from git output', async () => {
20
+ mockExeca.mockResolvedValueOnce({ stdout: ' abc1234 \n' });
21
+ expect(await getGitHash()).toBe('abc1234');
22
+ });
23
+ it('returns null when git exits with an error', async () => {
24
+ mockExeca.mockRejectedValueOnce(new Error('not a git repository'));
25
+ expect(await getGitHash()).toBeNull();
26
+ });
27
+ it('returns null when git output is empty', async () => {
28
+ mockExeca.mockResolvedValueOnce({ stdout: ' ' });
29
+ expect(await getGitHash()).toBeNull();
30
+ });
31
+ });
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Webhook server — Phase 1
3
+ *
4
+ * Listens on POST /webhook/:source. Each source maps to an HMAC secret
5
+ * and a target Discord channel. Verified requests are dispatched through
6
+ * the existing cron execution pipeline (executeCronJob), giving webhooks
7
+ * runtime invocation, channel routing, model selection, and logging for free.
8
+ *
9
+ * Config file format (JSON):
10
+ * {
11
+ * "<source>": {
12
+ * "secret": "<hmac-sha256-secret>",
13
+ * "channel": "<discord-channel-name-or-id>",
14
+ * "prompt": "<optional instruction override>"
15
+ * }
16
+ * }
17
+ *
18
+ * HMAC verification: callers must send an `X-Hub-Signature-256` header
19
+ * with value `sha256=<hex-digest>` computed over the raw request body
20
+ * using the per-source secret (same convention as GitHub webhooks).
21
+ */
22
+ import http from 'node:http';
23
+ import crypto from 'node:crypto';
24
+ import fs from 'node:fs/promises';
25
+ import { executeCronJob } from '../cron/executor.js';
26
+ // ---------------------------------------------------------------------------
27
+ // HMAC helpers
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Constant-time comparison of two HMAC digests.
31
+ * Returns true when the signature header matches the expected value.
32
+ */
33
+ function verifySignature(body, secret, signatureHeader) {
34
+ if (!signatureHeader.startsWith('sha256='))
35
+ return false;
36
+ const supplied = signatureHeader.slice('sha256='.length);
37
+ const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
38
+ try {
39
+ return crypto.timingSafeEqual(Buffer.from(supplied, 'hex'), Buffer.from(expected, 'hex'));
40
+ }
41
+ catch {
42
+ // timingSafeEqual throws when buffers differ in length.
43
+ return false;
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Config loader
48
+ // ---------------------------------------------------------------------------
49
+ export async function loadWebhookConfig(configPath) {
50
+ const raw = await fs.readFile(configPath, 'utf8');
51
+ const parsed = JSON.parse(raw);
52
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
53
+ throw new Error('Webhook config must be a JSON object');
54
+ }
55
+ return parsed;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Synthetic CronJob factory
59
+ // ---------------------------------------------------------------------------
60
+ let webhookJobCounter = 0;
61
+ function buildWebhookJob(source, src, bodyText, guildId) {
62
+ webhookJobCounter += 1;
63
+ const id = `webhook-${source}-${webhookJobCounter}`;
64
+ const prompt = src.prompt !== undefined
65
+ ? src.prompt.replaceAll('{{body}}', bodyText).replaceAll('{{source}}', source)
66
+ : `A webhook event was received from source "${source}".\n\nPayload:\n${bodyText}`;
67
+ return {
68
+ id,
69
+ cronId: '',
70
+ threadId: '',
71
+ guildId,
72
+ name: `webhook:${source}`,
73
+ def: {
74
+ triggerType: 'webhook',
75
+ timezone: 'UTC',
76
+ channel: src.channel,
77
+ prompt,
78
+ },
79
+ cron: null,
80
+ running: false,
81
+ };
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Request body reader
85
+ // ---------------------------------------------------------------------------
86
+ const MAX_BODY_BYTES = 256 * 1024; // 256 KB
87
+ function readBody(req) {
88
+ return new Promise((resolve, reject) => {
89
+ const chunks = [];
90
+ let total = 0;
91
+ req.on('data', (chunk) => {
92
+ total += chunk.length;
93
+ if (total > MAX_BODY_BYTES) {
94
+ req.destroy();
95
+ reject(new Error('Request body too large'));
96
+ return;
97
+ }
98
+ chunks.push(chunk);
99
+ });
100
+ req.on('end', () => resolve(Buffer.concat(chunks)));
101
+ req.on('error', reject);
102
+ });
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // HTTP response helpers
106
+ // ---------------------------------------------------------------------------
107
+ function respond(res, status, body) {
108
+ res.writeHead(status, { 'Content-Type': 'application/json' });
109
+ res.end(JSON.stringify({ ok: status < 400, message: body }));
110
+ }
111
+ /**
112
+ * Start the webhook HTTP server.
113
+ *
114
+ * Returns a handle with the underlying `http.Server` and a `close()` method.
115
+ */
116
+ export async function startWebhookServer(opts) {
117
+ const { configPath, port = 8080, host = '127.0.0.1', guildId, executorCtx, log, } = opts;
118
+ // Load config eagerly so startup fails fast on bad JSON.
119
+ let config = await loadWebhookConfig(configPath);
120
+ log?.info({ configPath, sources: Object.keys(config) }, 'webhook:config loaded');
121
+ const server = http.createServer(async (req, res) => {
122
+ // Only handle POST /webhook/:source
123
+ const url = req.url ?? '';
124
+ const match = url.match(/^\/webhook\/([^/?#]+)$/);
125
+ if (!match) {
126
+ respond(res, 404, 'Not found');
127
+ return;
128
+ }
129
+ if (req.method !== 'POST') {
130
+ respond(res, 405, 'Method Not Allowed');
131
+ return;
132
+ }
133
+ let source;
134
+ try {
135
+ source = decodeURIComponent(match[1]);
136
+ }
137
+ catch {
138
+ respond(res, 400, 'Bad request');
139
+ return;
140
+ }
141
+ const src = config[source];
142
+ if (!src) {
143
+ log?.warn({ source }, 'webhook:unknown source');
144
+ // Return 404 to avoid leaking which sources exist.
145
+ respond(res, 404, 'Not found');
146
+ return;
147
+ }
148
+ // Read body before verifying signature.
149
+ let body;
150
+ try {
151
+ body = await readBody(req);
152
+ }
153
+ catch (err) {
154
+ const msg = err instanceof Error ? err.message : String(err);
155
+ if (msg === 'Request body too large') {
156
+ log?.warn({ source }, 'webhook:body too large');
157
+ respond(res, 413, 'Payload Too Large');
158
+ return;
159
+ }
160
+ log?.warn({ source, err }, 'webhook:body read error');
161
+ respond(res, 400, 'Bad request');
162
+ return;
163
+ }
164
+ // Verify HMAC signature.
165
+ const sigHeader = String(req.headers['x-hub-signature-256'] ?? '');
166
+ if (!verifySignature(body, src.secret, sigHeader)) {
167
+ log?.warn({ source }, 'webhook:signature verification failed');
168
+ respond(res, 401, 'Unauthorized');
169
+ return;
170
+ }
171
+ // Signature OK — ack immediately, then dispatch in the background.
172
+ respond(res, 202, 'Accepted');
173
+ const bodyText = body.toString('utf8');
174
+ const job = buildWebhookJob(source, src, bodyText, guildId);
175
+ log?.info({ source, jobId: job.id, channel: src.channel }, 'webhook:dispatching');
176
+ // Fire-and-forget — errors are handled inside executeCronJob.
177
+ void executeCronJob(job, executorCtx).catch((err) => {
178
+ log?.error({ source, jobId: job.id, err }, 'webhook:executor error');
179
+ });
180
+ });
181
+ await new Promise((resolve, reject) => {
182
+ server.once('error', reject);
183
+ server.listen(port, host, () => resolve());
184
+ });
185
+ log?.info({ port, host }, 'webhook:server listening');
186
+ return {
187
+ server,
188
+ close() {
189
+ return new Promise((resolve, reject) => {
190
+ server.close((err) => {
191
+ if (err)
192
+ reject(err);
193
+ else
194
+ resolve();
195
+ });
196
+ });
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,460 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import http from 'node:http';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { startWebhookServer, loadWebhookConfig } from './server.js';
8
+ import { executeCronJob } from '../cron/executor.js';
9
+ vi.mock('../cron/executor.js', () => ({
10
+ executeCronJob: vi.fn().mockResolvedValue(undefined),
11
+ }));
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ function signBody(body, secret) {
16
+ const hmac = crypto.createHmac('sha256', secret);
17
+ hmac.update(typeof body === 'string' ? Buffer.from(body, 'utf8') : body);
18
+ return 'sha256=' + hmac.digest('hex');
19
+ }
20
+ function makeRequest(port, opts = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const rawBody = opts.body ?? '';
23
+ const bodyBuf = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody;
24
+ const req = http.request({
25
+ hostname: '127.0.0.1',
26
+ port,
27
+ path: opts.path ?? '/',
28
+ method: opts.method ?? 'POST',
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'Content-Length': bodyBuf.length,
32
+ ...opts.headers,
33
+ },
34
+ }, (res) => {
35
+ const chunks = [];
36
+ res.on('data', (c) => chunks.push(c));
37
+ res.on('end', () => {
38
+ const text = Buffer.concat(chunks).toString('utf8');
39
+ try {
40
+ resolve({ status: res.statusCode ?? 0, body: JSON.parse(text) });
41
+ }
42
+ catch {
43
+ reject(new Error(`Failed to parse response body: ${text}`));
44
+ }
45
+ });
46
+ res.on('error', reject);
47
+ });
48
+ req.on('error', reject);
49
+ if (bodyBuf.length)
50
+ req.write(bodyBuf);
51
+ req.end();
52
+ });
53
+ }
54
+ function mockLog() {
55
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
56
+ }
57
+ // Give the fire-and-forget dispatch one event-loop tick to settle.
58
+ function tick() {
59
+ return new Promise((r) => setImmediate(r));
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // loadWebhookConfig
63
+ // ---------------------------------------------------------------------------
64
+ describe('loadWebhookConfig', () => {
65
+ let tmpDir;
66
+ beforeEach(async () => {
67
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-cfg-'));
68
+ });
69
+ afterEach(async () => {
70
+ await fs.rm(tmpDir, { recursive: true, force: true });
71
+ });
72
+ it('parses a valid config file', async () => {
73
+ const config = {
74
+ github: { secret: 'abc123', channel: 'deploys' },
75
+ alerts: { secret: 'xyz789', channel: 'ops', prompt: 'Handle alert' },
76
+ };
77
+ const cfgPath = path.join(tmpDir, 'webhooks.json');
78
+ await fs.writeFile(cfgPath, JSON.stringify(config), 'utf8');
79
+ const loaded = await loadWebhookConfig(cfgPath);
80
+ expect(loaded).toEqual(config);
81
+ });
82
+ it('throws when file does not exist', async () => {
83
+ await expect(loadWebhookConfig(path.join(tmpDir, 'missing.json'))).rejects.toThrow();
84
+ });
85
+ it('throws when JSON is invalid', async () => {
86
+ const cfgPath = path.join(tmpDir, 'bad.json');
87
+ await fs.writeFile(cfgPath, 'not json', 'utf8');
88
+ await expect(loadWebhookConfig(cfgPath)).rejects.toThrow();
89
+ });
90
+ it('throws when config is an array', async () => {
91
+ const cfgPath = path.join(tmpDir, 'array.json');
92
+ await fs.writeFile(cfgPath, '[]', 'utf8');
93
+ await expect(loadWebhookConfig(cfgPath)).rejects.toThrow('Webhook config must be a JSON object');
94
+ });
95
+ it('throws when config is a primitive', async () => {
96
+ const cfgPath = path.join(tmpDir, 'prim.json');
97
+ await fs.writeFile(cfgPath, '"hello"', 'utf8');
98
+ await expect(loadWebhookConfig(cfgPath)).rejects.toThrow('Webhook config must be a JSON object');
99
+ });
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // startWebhookServer — HTTP routing
103
+ // ---------------------------------------------------------------------------
104
+ describe('startWebhookServer HTTP routing', () => {
105
+ let tmpDir;
106
+ let port;
107
+ let handle;
108
+ const config = {
109
+ github: { secret: 'gh-secret', channel: 'deploys' },
110
+ alerts: { secret: 'alert-secret', channel: 'ops', prompt: 'Alert received.' },
111
+ };
112
+ const baseOpts = () => ({
113
+ host: '127.0.0.1',
114
+ guildId: 'guild-1',
115
+ executorCtx: {},
116
+ log: mockLog(),
117
+ });
118
+ beforeEach(async () => {
119
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-srv-'));
120
+ const configPath = path.join(tmpDir, 'webhooks.json');
121
+ await fs.writeFile(configPath, JSON.stringify(config), 'utf8');
122
+ handle = await startWebhookServer({ ...baseOpts(), configPath, port: 0 });
123
+ const addr = handle.server.address();
124
+ port = addr.port;
125
+ });
126
+ afterEach(async () => {
127
+ await handle.close();
128
+ await fs.rm(tmpDir, { recursive: true, force: true });
129
+ });
130
+ it('returns 404 for paths that do not match /webhook/:source', async () => {
131
+ const res = await makeRequest(port, { path: '/not-a-webhook' });
132
+ expect(res.status).toBe(404);
133
+ expect(res.body.ok).toBe(false);
134
+ });
135
+ it('returns 404 for root path', async () => {
136
+ const res = await makeRequest(port, { path: '/' });
137
+ expect(res.status).toBe(404);
138
+ });
139
+ it('returns 404 for /webhook/ with no source segment', async () => {
140
+ const res = await makeRequest(port, { path: '/webhook/' });
141
+ expect(res.status).toBe(404);
142
+ });
143
+ it('returns 405 for GET requests', async () => {
144
+ const res = await makeRequest(port, { path: '/webhook/github', method: 'GET' });
145
+ expect(res.status).toBe(405);
146
+ expect(res.body.ok).toBe(false);
147
+ });
148
+ it('returns 405 for PUT requests', async () => {
149
+ const res = await makeRequest(port, { path: '/webhook/github', method: 'PUT' });
150
+ expect(res.status).toBe(405);
151
+ });
152
+ it('returns 400 for a malformed percent-encoded source (%GG)', async () => {
153
+ const res = await makeRequest(port, { path: '/webhook/%GG' });
154
+ expect(res.status).toBe(400);
155
+ expect(res.body.ok).toBe(false);
156
+ });
157
+ it('returns 400 for another malformed percent-encoding (foo%ZZbar)', async () => {
158
+ const res = await makeRequest(port, { path: '/webhook/foo%ZZbar' });
159
+ expect(res.status).toBe(400);
160
+ });
161
+ it('returns 404 for an unknown source', async () => {
162
+ const body = '{}';
163
+ const res = await makeRequest(port, {
164
+ path: '/webhook/unknown-source',
165
+ body,
166
+ headers: { 'x-hub-signature-256': signBody(body, 'any') },
167
+ });
168
+ expect(res.status).toBe(404);
169
+ expect(res.body.ok).toBe(false);
170
+ });
171
+ it('returns 401 when the signature header is absent', async () => {
172
+ const res = await makeRequest(port, { path: '/webhook/github', body: '{"event":"push"}' });
173
+ expect(res.status).toBe(401);
174
+ expect(res.body.ok).toBe(false);
175
+ });
176
+ it('returns 401 for a malformed signature value', async () => {
177
+ const res = await makeRequest(port, {
178
+ path: '/webhook/github',
179
+ body: '{"event":"push"}',
180
+ headers: { 'x-hub-signature-256': 'sha256=deadbeef' },
181
+ });
182
+ expect(res.status).toBe(401);
183
+ });
184
+ it('returns 401 when the signature uses the wrong secret', async () => {
185
+ const body = '{"event":"push"}';
186
+ const res = await makeRequest(port, {
187
+ path: '/webhook/github',
188
+ body,
189
+ headers: { 'x-hub-signature-256': signBody(body, 'wrong-secret') },
190
+ });
191
+ expect(res.status).toBe(401);
192
+ });
193
+ it('returns 202 for a valid signed POST', async () => {
194
+ const body = '{"event":"push"}';
195
+ const res = await makeRequest(port, {
196
+ path: '/webhook/github',
197
+ body,
198
+ headers: { 'x-hub-signature-256': signBody(body, 'gh-secret') },
199
+ });
200
+ expect(res.status).toBe(202);
201
+ expect(res.body.ok).toBe(true);
202
+ });
203
+ it('accepts a valid request with an empty body', async () => {
204
+ const body = '';
205
+ const res = await makeRequest(port, {
206
+ path: '/webhook/github',
207
+ body,
208
+ headers: { 'x-hub-signature-256': signBody(body, 'gh-secret') },
209
+ });
210
+ expect(res.status).toBe(202);
211
+ });
212
+ });
213
+ // ---------------------------------------------------------------------------
214
+ // startWebhookServer — executeCronJob dispatch
215
+ // ---------------------------------------------------------------------------
216
+ describe('startWebhookServer dispatch', () => {
217
+ let tmpDir;
218
+ let port;
219
+ let handle;
220
+ const config = {
221
+ github: { secret: 'gh-secret', channel: 'deploys' },
222
+ alerts: { secret: 'alert-secret', channel: 'ops', prompt: 'Alert: {{body}}' },
223
+ notify: { secret: 'notify-secret', channel: 'general', prompt: 'Event from {{source}}!' },
224
+ combined: { secret: 'combined-secret', channel: 'all', prompt: '{{source}} says: {{body}}' },
225
+ static: { secret: 'static-secret', channel: 'static-ch', prompt: 'No placeholders here' },
226
+ multi: { secret: 'multi-secret', channel: 'multi-ch', prompt: '{{body}} then {{body}} again' },
227
+ };
228
+ beforeEach(async () => {
229
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-disp-'));
230
+ const configPath = path.join(tmpDir, 'webhooks.json');
231
+ await fs.writeFile(configPath, JSON.stringify(config), 'utf8');
232
+ handle = await startWebhookServer({
233
+ configPath,
234
+ port: 0,
235
+ host: '127.0.0.1',
236
+ guildId: 'guild-1',
237
+ executorCtx: {},
238
+ log: mockLog(),
239
+ });
240
+ const addr = handle.server.address();
241
+ port = addr.port;
242
+ });
243
+ afterEach(async () => {
244
+ await handle.close();
245
+ await fs.rm(tmpDir, { recursive: true, force: true });
246
+ });
247
+ const sourceSecrets = {
248
+ github: 'gh-secret',
249
+ alerts: 'alert-secret',
250
+ notify: 'notify-secret',
251
+ combined: 'combined-secret',
252
+ static: 'static-secret',
253
+ multi: 'multi-secret',
254
+ };
255
+ async function postValid(source, body = '{}') {
256
+ return makeRequest(port, {
257
+ path: `/webhook/${source}`,
258
+ body,
259
+ headers: { 'x-hub-signature-256': signBody(body, sourceSecrets[source]) },
260
+ });
261
+ }
262
+ it('calls executeCronJob once after a valid request', async () => {
263
+ await postValid('github');
264
+ await tick();
265
+ expect(vi.mocked(executeCronJob)).toHaveBeenCalledOnce();
266
+ });
267
+ it('does not call executeCronJob for an invalid signature', async () => {
268
+ await makeRequest(port, {
269
+ path: '/webhook/github',
270
+ body: '{}',
271
+ headers: { 'x-hub-signature-256': 'sha256=invalid' },
272
+ });
273
+ await tick();
274
+ expect(vi.mocked(executeCronJob)).not.toHaveBeenCalled();
275
+ });
276
+ it('dispatches job with correct guildId and channel', async () => {
277
+ await postValid('github');
278
+ await tick();
279
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
280
+ expect(job.guildId).toBe('guild-1');
281
+ expect(job.def.channel).toBe('deploys');
282
+ });
283
+ it('builds default prompt from source name and body when no custom prompt is set', async () => {
284
+ const body = 'payload data here';
285
+ await postValid('github', body);
286
+ await tick();
287
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
288
+ expect(job.def.prompt).toContain('github');
289
+ expect(job.def.prompt).toContain('payload data here');
290
+ });
291
+ it('uses the custom prompt when one is configured', async () => {
292
+ await postValid('alerts', '{"level":"warn"}');
293
+ await tick();
294
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
295
+ expect(job.def.prompt).toBe('Alert: {"level":"warn"}');
296
+ });
297
+ it('replaces {{source}} in a custom prompt with the source name', async () => {
298
+ await postValid('notify', '{}');
299
+ await tick();
300
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
301
+ expect(job.def.prompt).toBe('Event from notify!');
302
+ });
303
+ it('replaces both {{body}} and {{source}} in a custom prompt', async () => {
304
+ await postValid('combined', 'hello world');
305
+ await tick();
306
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
307
+ expect(job.def.prompt).toBe('combined says: hello world');
308
+ });
309
+ it('passes through a custom prompt with no placeholders unchanged', async () => {
310
+ await postValid('static', '{}');
311
+ await tick();
312
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
313
+ expect(job.def.prompt).toBe('No placeholders here');
314
+ });
315
+ it('replaces all occurrences of {{body}} when it appears multiple times', async () => {
316
+ await postValid('multi', 'ping');
317
+ await tick();
318
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
319
+ expect(job.def.prompt).toBe('ping then ping again');
320
+ });
321
+ it('names the job with the source', async () => {
322
+ await postValid('github');
323
+ await tick();
324
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
325
+ expect(job.name).toBe('webhook:github');
326
+ });
327
+ it('assigns a unique id to each dispatch', async () => {
328
+ await postValid('github');
329
+ await tick();
330
+ await postValid('github');
331
+ await tick();
332
+ const calls = vi.mocked(executeCronJob).mock.calls;
333
+ expect(calls).toHaveLength(2);
334
+ const id0 = calls[0][0].id;
335
+ const id1 = calls[1][0].id;
336
+ expect(id0).not.toBe(id1);
337
+ });
338
+ it('leaves schedule undefined and sets timezone to UTC on the synthetic job', async () => {
339
+ await postValid('github');
340
+ await tick();
341
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
342
+ expect(job.def.schedule).toBeUndefined();
343
+ expect(job.def.timezone).toBe('UTC');
344
+ });
345
+ it('sets triggerType to webhook on the synthetic job', async () => {
346
+ await postValid('github');
347
+ await tick();
348
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
349
+ expect(job.def.triggerType).toBe('webhook');
350
+ });
351
+ it('decodes a valid percent-encoded source name before looking it up', async () => {
352
+ // 'alerts' with the first character 'a' encoded as %61
353
+ const body = '{}';
354
+ const res = await makeRequest(port, {
355
+ path: '/webhook/%61lerts',
356
+ body,
357
+ headers: { 'x-hub-signature-256': signBody(body, 'alert-secret') },
358
+ });
359
+ expect(res.status).toBe(202);
360
+ await tick();
361
+ const [job] = vi.mocked(executeCronJob).mock.calls[0];
362
+ expect(job.name).toBe('webhook:alerts');
363
+ });
364
+ it('passes the executorCtx through to executeCronJob', async () => {
365
+ const executorCtx = { __marker: 'test-ctx' };
366
+ const cfgPath = path.join(tmpDir, 'webhooks2.json');
367
+ await fs.writeFile(cfgPath, JSON.stringify(config), 'utf8');
368
+ const h2 = await startWebhookServer({
369
+ configPath: cfgPath,
370
+ port: 0,
371
+ host: '127.0.0.1',
372
+ guildId: 'g2',
373
+ executorCtx,
374
+ log: mockLog(),
375
+ });
376
+ const p2 = h2.server.address().port;
377
+ const body = '{}';
378
+ await makeRequest(p2, {
379
+ path: '/webhook/github',
380
+ body,
381
+ headers: { 'x-hub-signature-256': signBody(body, 'gh-secret') },
382
+ });
383
+ await tick();
384
+ await h2.close();
385
+ const [, ctx] = vi.mocked(executeCronJob).mock.calls[0];
386
+ expect(ctx).toBe(executorCtx);
387
+ });
388
+ });
389
+ // ---------------------------------------------------------------------------
390
+ // startWebhookServer — body size limit
391
+ // ---------------------------------------------------------------------------
392
+ describe('startWebhookServer body size limit', () => {
393
+ let tmpDir;
394
+ let port;
395
+ let handle;
396
+ beforeEach(async () => {
397
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-body-'));
398
+ const configPath = path.join(tmpDir, 'webhooks.json');
399
+ await fs.writeFile(configPath, JSON.stringify({ src: { secret: 's', channel: 'c' } }), 'utf8');
400
+ handle = await startWebhookServer({
401
+ configPath,
402
+ port: 0,
403
+ host: '127.0.0.1',
404
+ guildId: 'guild-1',
405
+ executorCtx: {},
406
+ log: mockLog(),
407
+ });
408
+ const addr = handle.server.address();
409
+ port = addr.port;
410
+ });
411
+ afterEach(async () => {
412
+ await handle.close();
413
+ await fs.rm(tmpDir, { recursive: true, force: true });
414
+ });
415
+ it('returns 413 or closes connection for a 257 KB body, and never dispatches', async () => {
416
+ const bigBody = Buffer.alloc(257 * 1024, 0x78); // 257 KB of 'x'
417
+ let status;
418
+ try {
419
+ const res = await makeRequest(port, {
420
+ path: '/webhook/src',
421
+ body: bigBody,
422
+ headers: { 'x-hub-signature-256': signBody(bigBody, 's') },
423
+ });
424
+ status = res.status;
425
+ }
426
+ catch {
427
+ // req.destroy() on the server side may reset the connection before the
428
+ // 413 response can be flushed to the client.
429
+ }
430
+ // If a response was received it must be 413.
431
+ if (status !== undefined) {
432
+ expect(status).toBe(413);
433
+ }
434
+ // executeCronJob must never be called regardless of response fate.
435
+ await tick();
436
+ expect(vi.mocked(executeCronJob)).not.toHaveBeenCalled();
437
+ });
438
+ });
439
+ // ---------------------------------------------------------------------------
440
+ // startWebhookServer — close
441
+ // ---------------------------------------------------------------------------
442
+ describe('startWebhookServer close', () => {
443
+ it('close() resolves and the server stops accepting connections', async () => {
444
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'webhook-close-'));
445
+ const configPath = path.join(tmpDir, 'webhooks.json');
446
+ await fs.writeFile(configPath, JSON.stringify({}), 'utf8');
447
+ const handle = await startWebhookServer({
448
+ configPath,
449
+ port: 0,
450
+ host: '127.0.0.1',
451
+ guildId: 'guild-1',
452
+ executorCtx: {},
453
+ });
454
+ const { port } = handle.server.address();
455
+ await handle.close();
456
+ // After close, new connections should be refused.
457
+ await expect(makeRequest(port, { path: '/' })).rejects.toThrow();
458
+ await fs.rm(tmpDir, { recursive: true, force: true });
459
+ });
460
+ });