corporateai 0.0.1

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 (704) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +3 -0
  3. package/.github/workflows/publish-cli.yml +49 -0
  4. package/.mailmap +1 -0
  5. package/AGENTS.md +148 -0
  6. package/CONTRIBUTING.md +75 -0
  7. package/Dockerfile +59 -0
  8. package/Dockerfile.onboard-smoke +42 -0
  9. package/LICENSE +21 -0
  10. package/README.md +93 -0
  11. package/cli/esbuild.config.mjs +11 -0
  12. package/cli/package.json +24 -0
  13. package/cli/scripts/build-cli.mjs +5 -0
  14. package/cli/src/index.ts +27 -0
  15. package/docker-compose.quickstart.yml +18 -0
  16. package/docker-compose.untrusted-review.yml +33 -0
  17. package/docker-compose.yml +38 -0
  18. package/package.json +56 -0
  19. package/patches/embedded-postgres@18.1.0-beta.16.patch +0 -0
  20. package/pnpm-workspace.yaml +4 -0
  21. package/releases/.gitkeep +0 -0
  22. package/releases/v0.0.1.md +36 -0
  23. package/releases/v0.2.7.md +15 -0
  24. package/releases/v0.3.0.md +54 -0
  25. package/releases/v0.3.1.md +55 -0
  26. package/releases/v2026.318.0.md +66 -0
  27. package/releases/v2026.325.0.md +78 -0
  28. package/report/2026-03-13-08-46-token-optimization-implementation.md +48 -0
  29. package/scripts/backup-db.sh +17 -0
  30. package/scripts/build-npm.sh +80 -0
  31. package/scripts/check-forbidden-tokens.mjs +115 -0
  32. package/scripts/clean-onboard-git.sh +14 -0
  33. package/scripts/clean-onboard-npm.sh +13 -0
  34. package/scripts/clean-onboard-ref.sh +86 -0
  35. package/scripts/create-github-release.sh +99 -0
  36. package/scripts/dev-runner-paths.mjs +38 -0
  37. package/scripts/dev-runner.mjs +606 -0
  38. package/scripts/docker-onboard-smoke.sh +306 -0
  39. package/scripts/ensure-plugin-build-deps.mjs +47 -0
  40. package/scripts/generate-company-assets.ts +365 -0
  41. package/scripts/generate-npm-package-json.mjs +113 -0
  42. package/scripts/generate-org-chart-images.ts +694 -0
  43. package/scripts/generate-org-chart-satori-comparison.ts +225 -0
  44. package/scripts/generate-ui-package-json.mjs +31 -0
  45. package/scripts/kill-dev.sh +71 -0
  46. package/scripts/migrate-inline-env-secrets.ts +126 -0
  47. package/scripts/prepare-server-ui-dist.sh +22 -0
  48. package/scripts/provision-worktree.sh +333 -0
  49. package/scripts/release-lib.sh +306 -0
  50. package/scripts/release-package-map.mjs +169 -0
  51. package/scripts/release.sh +312 -0
  52. package/scripts/rollback-latest.sh +111 -0
  53. package/scripts/smoke/openclaw-docker-ui.sh +329 -0
  54. package/scripts/smoke/openclaw-gateway-e2e.sh +954 -0
  55. package/scripts/smoke/openclaw-join.sh +295 -0
  56. package/scripts/smoke/openclaw-sse-standalone.sh +146 -0
  57. package/scripts/workspace-compat.mjs +60 -0
  58. package/server/CHANGELOG.md +130 -0
  59. package/server/package.json +96 -0
  60. package/server/scripts/copy-onboarding-assets.mjs +10 -0
  61. package/server/scripts/dev-watch.ts +33 -0
  62. package/server/src/__tests__/activity-routes.test.ts +70 -0
  63. package/server/src/__tests__/adapter-models.test.ts +105 -0
  64. package/server/src/__tests__/adapter-session-codecs.test.ts +194 -0
  65. package/server/src/__tests__/agent-auth-jwt.test.ts +79 -0
  66. package/server/src/__tests__/agent-instructions-routes.test.ts +318 -0
  67. package/server/src/__tests__/agent-instructions-service.test.ts +361 -0
  68. package/server/src/__tests__/agent-permissions-routes.test.ts +275 -0
  69. package/server/src/__tests__/agent-shortname-collision.test.ts +69 -0
  70. package/server/src/__tests__/agent-skill-contract.test.ts +50 -0
  71. package/server/src/__tests__/agent-skills-routes.test.ts +462 -0
  72. package/server/src/__tests__/app-hmr-port.test.ts +19 -0
  73. package/server/src/__tests__/approval-routes-idempotency.test.ts +110 -0
  74. package/server/src/__tests__/approvals-service.test.ts +107 -0
  75. package/server/src/__tests__/assets.test.ts +250 -0
  76. package/server/src/__tests__/attachment-types.test.ts +97 -0
  77. package/server/src/__tests__/board-mutation-guard.test.ts +105 -0
  78. package/server/src/__tests__/budgets-service.test.ts +311 -0
  79. package/server/src/__tests__/claude-local-adapter-environment.test.ts +92 -0
  80. package/server/src/__tests__/claude-local-adapter.test.ts +31 -0
  81. package/server/src/__tests__/claude-local-skill-sync.test.ts +111 -0
  82. package/server/src/__tests__/cli-auth-routes.test.ts +230 -0
  83. package/server/src/__tests__/codex-local-adapter-environment.test.ts +143 -0
  84. package/server/src/__tests__/codex-local-adapter.test.ts +253 -0
  85. package/server/src/__tests__/codex-local-execute.test.ts +391 -0
  86. package/server/src/__tests__/codex-local-skill-injection.test.ts +175 -0
  87. package/server/src/__tests__/codex-local-skill-sync.test.ts +123 -0
  88. package/server/src/__tests__/companies-route-path-guard.test.ts +56 -0
  89. package/server/src/__tests__/company-branding-route.test.ts +196 -0
  90. package/server/src/__tests__/company-portability-routes.test.ts +175 -0
  91. package/server/src/__tests__/company-portability.test.ts +2186 -0
  92. package/server/src/__tests__/company-skills-routes.test.ts +113 -0
  93. package/server/src/__tests__/company-skills.test.ts +229 -0
  94. package/server/src/__tests__/costs-service.test.ts +226 -0
  95. package/server/src/__tests__/cursor-local-adapter-environment.test.ts +196 -0
  96. package/server/src/__tests__/cursor-local-adapter.test.ts +406 -0
  97. package/server/src/__tests__/cursor-local-execute.test.ts +263 -0
  98. package/server/src/__tests__/cursor-local-skill-injection.test.ts +104 -0
  99. package/server/src/__tests__/cursor-local-skill-sync.test.ts +145 -0
  100. package/server/src/__tests__/dev-runner-paths.test.ts +25 -0
  101. package/server/src/__tests__/dev-server-status.test.ts +66 -0
  102. package/server/src/__tests__/dev-watch-ignore.test.ts +42 -0
  103. package/server/src/__tests__/documents.test.ts +29 -0
  104. package/server/src/__tests__/error-handler.test.ts +53 -0
  105. package/server/src/__tests__/execution-workspace-policy.test.ts +170 -0
  106. package/server/src/__tests__/forbidden-tokens.test.ts +77 -0
  107. package/server/src/__tests__/gemini-local-adapter-environment.test.ts +135 -0
  108. package/server/src/__tests__/gemini-local-adapter.test.ts +190 -0
  109. package/server/src/__tests__/gemini-local-execute.test.ts +172 -0
  110. package/server/src/__tests__/gemini-local-skill-sync.test.ts +90 -0
  111. package/server/src/__tests__/health.test.ts +16 -0
  112. package/server/src/__tests__/heartbeat-process-recovery.test.ts +256 -0
  113. package/server/src/__tests__/heartbeat-run-summary.test.ts +33 -0
  114. package/server/src/__tests__/heartbeat-workspace-session.test.ts +334 -0
  115. package/server/src/__tests__/helpers/embedded-postgres.ts +7 -0
  116. package/server/src/__tests__/hire-hook.test.ts +181 -0
  117. package/server/src/__tests__/instance-settings-routes.test.ts +156 -0
  118. package/server/src/__tests__/invite-accept-gateway-defaults.test.ts +119 -0
  119. package/server/src/__tests__/invite-accept-replay.test.ts +92 -0
  120. package/server/src/__tests__/invite-expiry.test.ts +10 -0
  121. package/server/src/__tests__/invite-join-grants.test.ts +57 -0
  122. package/server/src/__tests__/invite-join-manager.test.ts +33 -0
  123. package/server/src/__tests__/invite-onboarding-text.test.ts +116 -0
  124. package/server/src/__tests__/issue-comment-reopen-routes.test.ts +146 -0
  125. package/server/src/__tests__/issue-goal-fallback.test.ts +99 -0
  126. package/server/src/__tests__/issues-checkout-wakeup.test.ts +48 -0
  127. package/server/src/__tests__/issues-goal-context-routes.test.ts +187 -0
  128. package/server/src/__tests__/issues-service.test.ts +317 -0
  129. package/server/src/__tests__/issues-user-context.test.ts +113 -0
  130. package/server/src/__tests__/log-redaction.test.ts +74 -0
  131. package/server/src/__tests__/monthly-spend-service.test.ts +90 -0
  132. package/server/src/__tests__/normalize-agent-mention-token.test.ts +41 -0
  133. package/server/src/__tests__/openclaw-gateway-adapter.test.ts +626 -0
  134. package/server/src/__tests__/openclaw-invite-prompt-route.test.ts +192 -0
  135. package/server/src/__tests__/opencode-local-adapter-environment.test.ts +97 -0
  136. package/server/src/__tests__/opencode-local-adapter.test.ts +226 -0
  137. package/server/src/__tests__/opencode-local-skill-sync.test.ts +91 -0
  138. package/server/src/__tests__/paperclip-env.test.ts +58 -0
  139. package/server/src/__tests__/paperclip-skill-utils.test.ts +63 -0
  140. package/server/src/__tests__/pi-local-adapter-environment.test.ts +102 -0
  141. package/server/src/__tests__/pi-local-skill-sync.test.ts +95 -0
  142. package/server/src/__tests__/plugin-dev-watcher.test.ts +68 -0
  143. package/server/src/__tests__/plugin-worker-manager.test.ts +43 -0
  144. package/server/src/__tests__/private-hostname-guard.test.ts +56 -0
  145. package/server/src/__tests__/project-shortname-resolution.test.ts +45 -0
  146. package/server/src/__tests__/quota-windows-service.test.ts +56 -0
  147. package/server/src/__tests__/quota-windows.test.ts +1109 -0
  148. package/server/src/__tests__/redaction.test.ts +66 -0
  149. package/server/src/__tests__/routines-e2e.test.ts +276 -0
  150. package/server/src/__tests__/routines-routes.test.ts +271 -0
  151. package/server/src/__tests__/routines-service.test.ts +424 -0
  152. package/server/src/__tests__/storage-local-provider.test.ts +78 -0
  153. package/server/src/__tests__/ui-branding.test.ts +82 -0
  154. package/server/src/__tests__/work-products.test.ts +95 -0
  155. package/server/src/__tests__/workspace-runtime.test.ts +1131 -0
  156. package/server/src/__tests__/worktree-config.test.ts +426 -0
  157. package/server/src/adapters/codex-models.ts +105 -0
  158. package/server/src/adapters/cursor-models.ts +171 -0
  159. package/server/src/adapters/http/execute.ts +42 -0
  160. package/server/src/adapters/http/index.ts +21 -0
  161. package/server/src/adapters/http/test.ts +116 -0
  162. package/server/src/adapters/index.ts +18 -0
  163. package/server/src/adapters/process/execute.ts +77 -0
  164. package/server/src/adapters/process/index.ts +24 -0
  165. package/server/src/adapters/process/test.ts +89 -0
  166. package/server/src/adapters/registry.ts +225 -0
  167. package/server/src/adapters/server-utils-compat.ts +57 -0
  168. package/server/src/adapters/types.ts +30 -0
  169. package/server/src/adapters/utils.ts +48 -0
  170. package/server/src/agent-auth-jwt.ts +141 -0
  171. package/server/src/app.ts +321 -0
  172. package/server/src/attachment-types.ts +74 -0
  173. package/server/src/auth/better-auth.ts +148 -0
  174. package/server/src/board-claim.ts +150 -0
  175. package/server/src/config-file.ts +17 -0
  176. package/server/src/config.ts +260 -0
  177. package/server/src/dev-server-status.ts +103 -0
  178. package/server/src/dev-watch-ignore.ts +36 -0
  179. package/server/src/errors.ts +34 -0
  180. package/server/src/home-paths.ts +95 -0
  181. package/server/src/index.ts +799 -0
  182. package/server/src/log-redaction.ts +146 -0
  183. package/server/src/middleware/auth.ts +178 -0
  184. package/server/src/middleware/board-mutation-guard.ts +66 -0
  185. package/server/src/middleware/error-handler.ts +71 -0
  186. package/server/src/middleware/index.ts +3 -0
  187. package/server/src/middleware/logger.ts +90 -0
  188. package/server/src/middleware/private-hostname-guard.ts +92 -0
  189. package/server/src/middleware/validate.ts +9 -0
  190. package/server/src/onboarding-assets/ceo/AGENTS.md +54 -0
  191. package/server/src/onboarding-assets/ceo/HEARTBEAT.md +72 -0
  192. package/server/src/onboarding-assets/ceo/SOUL.md +33 -0
  193. package/server/src/onboarding-assets/ceo/TOOLS.md +3 -0
  194. package/server/src/onboarding-assets/default/AGENTS.md +3 -0
  195. package/server/src/paths.ts +34 -0
  196. package/server/src/realtime/live-events-ws.ts +274 -0
  197. package/server/src/redaction.ts +59 -0
  198. package/server/src/routes/access.ts +2888 -0
  199. package/server/src/routes/activity.ts +89 -0
  200. package/server/src/routes/agents.ts +2313 -0
  201. package/server/src/routes/approvals.ts +346 -0
  202. package/server/src/routes/assets.ts +341 -0
  203. package/server/src/routes/authz.ts +52 -0
  204. package/server/src/routes/companies.ts +343 -0
  205. package/server/src/routes/company-skills.ts +300 -0
  206. package/server/src/routes/costs.ts +335 -0
  207. package/server/src/routes/dashboard.ts +19 -0
  208. package/server/src/routes/execution-workspaces.ts +182 -0
  209. package/server/src/routes/goals.ts +107 -0
  210. package/server/src/routes/health.ts +94 -0
  211. package/server/src/routes/index.ts +17 -0
  212. package/server/src/routes/instance-settings.ts +95 -0
  213. package/server/src/routes/issues-checkout-wakeup.ts +14 -0
  214. package/server/src/routes/issues.ts +1680 -0
  215. package/server/src/routes/llms.ts +86 -0
  216. package/server/src/routes/org-chart-svg.ts +777 -0
  217. package/server/src/routes/plugin-ui-static.ts +497 -0
  218. package/server/src/routes/plugins.ts +2220 -0
  219. package/server/src/routes/projects.ts +295 -0
  220. package/server/src/routes/routines.ts +300 -0
  221. package/server/src/routes/secrets.ts +166 -0
  222. package/server/src/routes/sidebar-badges.ts +52 -0
  223. package/server/src/secrets/external-stub-providers.ts +32 -0
  224. package/server/src/secrets/local-encrypted-provider.ts +135 -0
  225. package/server/src/secrets/provider-registry.ts +31 -0
  226. package/server/src/secrets/types.ts +23 -0
  227. package/server/src/services/access.ts +381 -0
  228. package/server/src/services/activity-log.ts +95 -0
  229. package/server/src/services/activity.ts +164 -0
  230. package/server/src/services/agent-instructions.ts +735 -0
  231. package/server/src/services/agent-permissions.ts +27 -0
  232. package/server/src/services/agents.ts +694 -0
  233. package/server/src/services/approvals.ts +273 -0
  234. package/server/src/services/assets.ts +23 -0
  235. package/server/src/services/board-auth.ts +355 -0
  236. package/server/src/services/budgets.ts +959 -0
  237. package/server/src/services/companies.ts +313 -0
  238. package/server/src/services/company-export-readme.ts +173 -0
  239. package/server/src/services/company-portability.ts +4263 -0
  240. package/server/src/services/company-skills.ts +2356 -0
  241. package/server/src/services/costs.ts +365 -0
  242. package/server/src/services/cron.ts +373 -0
  243. package/server/src/services/dashboard.ts +110 -0
  244. package/server/src/services/default-agent-instructions.ts +27 -0
  245. package/server/src/services/documents.ts +434 -0
  246. package/server/src/services/execution-workspace-policy.ts +210 -0
  247. package/server/src/services/execution-workspaces.ts +100 -0
  248. package/server/src/services/finance.ts +135 -0
  249. package/server/src/services/goals.ts +81 -0
  250. package/server/src/services/heartbeat-run-summary.ts +35 -0
  251. package/server/src/services/heartbeat.ts +3863 -0
  252. package/server/src/services/hire-hook.ts +114 -0
  253. package/server/src/services/index.ts +32 -0
  254. package/server/src/services/instance-settings.ts +138 -0
  255. package/server/src/services/issue-approvals.ts +175 -0
  256. package/server/src/services/issue-assignment-wakeup.ts +48 -0
  257. package/server/src/services/issue-goal-fallback.ts +56 -0
  258. package/server/src/services/issues.ts +1828 -0
  259. package/server/src/services/live-events.ts +55 -0
  260. package/server/src/services/plugin-capability-validator.ts +450 -0
  261. package/server/src/services/plugin-config-validator.ts +55 -0
  262. package/server/src/services/plugin-dev-watcher.ts +339 -0
  263. package/server/src/services/plugin-event-bus.ts +413 -0
  264. package/server/src/services/plugin-host-service-cleanup.ts +59 -0
  265. package/server/src/services/plugin-host-services.ts +1132 -0
  266. package/server/src/services/plugin-job-coordinator.ts +261 -0
  267. package/server/src/services/plugin-job-scheduler.ts +753 -0
  268. package/server/src/services/plugin-job-store.ts +466 -0
  269. package/server/src/services/plugin-lifecycle.ts +822 -0
  270. package/server/src/services/plugin-loader.ts +1955 -0
  271. package/server/src/services/plugin-log-retention.ts +87 -0
  272. package/server/src/services/plugin-manifest-validator.ts +164 -0
  273. package/server/src/services/plugin-registry.ts +683 -0
  274. package/server/src/services/plugin-runtime-sandbox.ts +222 -0
  275. package/server/src/services/plugin-secrets-handler.ts +355 -0
  276. package/server/src/services/plugin-state-store.ts +238 -0
  277. package/server/src/services/plugin-stream-bus.ts +81 -0
  278. package/server/src/services/plugin-tool-dispatcher.ts +449 -0
  279. package/server/src/services/plugin-tool-registry.ts +450 -0
  280. package/server/src/services/plugin-worker-manager.ts +1343 -0
  281. package/server/src/services/projects.ts +860 -0
  282. package/server/src/services/quota-windows.ts +65 -0
  283. package/server/src/services/routines.ts +1269 -0
  284. package/server/src/services/run-log-store.ts +156 -0
  285. package/server/src/services/secrets.ts +370 -0
  286. package/server/src/services/sidebar-badges.ts +56 -0
  287. package/server/src/services/work-products.ts +124 -0
  288. package/server/src/services/workspace-operation-log-store.ts +156 -0
  289. package/server/src/services/workspace-operations.ts +262 -0
  290. package/server/src/services/workspace-runtime.ts +1565 -0
  291. package/server/src/startup-banner.ts +176 -0
  292. package/server/src/storage/index.ts +35 -0
  293. package/server/src/storage/local-disk-provider.ts +89 -0
  294. package/server/src/storage/provider-registry.ts +18 -0
  295. package/server/src/storage/s3-provider.ts +153 -0
  296. package/server/src/storage/service.ts +131 -0
  297. package/server/src/storage/types.ts +63 -0
  298. package/server/src/ui-branding.ts +217 -0
  299. package/server/src/version.ts +10 -0
  300. package/server/src/worktree-config.ts +468 -0
  301. package/server/tsconfig.json +9 -0
  302. package/server/vitest.config.ts +7 -0
  303. package/skills/paperclip/SKILL.md +365 -0
  304. package/skills/paperclip/references/api-reference.md +647 -0
  305. package/skills/paperclip/references/company-skills.md +193 -0
  306. package/skills/paperclip-create-agent/SKILL.md +142 -0
  307. package/skills/paperclip-create-agent/references/api-reference.md +105 -0
  308. package/skills/paperclip-create-plugin/SKILL.md +102 -0
  309. package/skills/para-memory-files/SKILL.md +104 -0
  310. package/skills/para-memory-files/references/schemas.md +35 -0
  311. package/tests/e2e/onboarding.spec.ts +142 -0
  312. package/tests/e2e/playwright.config.ts +35 -0
  313. package/tests/release-smoke/docker-auth-onboarding.spec.ts +141 -0
  314. package/tests/release-smoke/playwright.config.ts +28 -0
  315. package/tsconfig.base.json +18 -0
  316. package/tsconfig.json +18 -0
  317. package/ui/README.md +12 -0
  318. package/ui/components.json +21 -0
  319. package/ui/index.html +47 -0
  320. package/ui/package.json +73 -0
  321. package/ui/public/android-chrome-192x192.png +0 -0
  322. package/ui/public/android-chrome-512x512.png +0 -0
  323. package/ui/public/apple-touch-icon.png +0 -0
  324. package/ui/public/brands/opencode-logo-dark-square.svg +18 -0
  325. package/ui/public/brands/opencode-logo-light-square.svg +18 -0
  326. package/ui/public/favicon-16x16.png +0 -0
  327. package/ui/public/favicon-32x32.png +0 -0
  328. package/ui/public/favicon.ico +0 -0
  329. package/ui/public/favicon.svg +9 -0
  330. package/ui/public/site.webmanifest +30 -0
  331. package/ui/public/sprites/1-D-1.png +0 -0
  332. package/ui/public/sprites/1-D-2.png +0 -0
  333. package/ui/public/sprites/1-D-3.png +0 -0
  334. package/ui/public/sprites/1-L-1.png +0 -0
  335. package/ui/public/sprites/1-R-1.png +0 -0
  336. package/ui/public/sprites/10-D-1.png +0 -0
  337. package/ui/public/sprites/10-D-2.png +0 -0
  338. package/ui/public/sprites/10-D-3.png +0 -0
  339. package/ui/public/sprites/10-L-1.png +0 -0
  340. package/ui/public/sprites/10-R-1.png +0 -0
  341. package/ui/public/sprites/11-D-1.png +0 -0
  342. package/ui/public/sprites/11-D-2.png +0 -0
  343. package/ui/public/sprites/11-D-3.png +0 -0
  344. package/ui/public/sprites/11-L-1.png +0 -0
  345. package/ui/public/sprites/11-R-1.png +0 -0
  346. package/ui/public/sprites/12-D-1.png +0 -0
  347. package/ui/public/sprites/12-D-2.png +0 -0
  348. package/ui/public/sprites/12-D-3.png +0 -0
  349. package/ui/public/sprites/12-L-1.png +0 -0
  350. package/ui/public/sprites/12-R-1.png +0 -0
  351. package/ui/public/sprites/13-D-1.png +0 -0
  352. package/ui/public/sprites/13-D-2.png +0 -0
  353. package/ui/public/sprites/13-D-3.png +0 -0
  354. package/ui/public/sprites/13-L-1.png +0 -0
  355. package/ui/public/sprites/13-R-1.png +0 -0
  356. package/ui/public/sprites/14-D-1.png +0 -0
  357. package/ui/public/sprites/14-D-2.png +0 -0
  358. package/ui/public/sprites/14-D-3.png +0 -0
  359. package/ui/public/sprites/14-L-1.png +0 -0
  360. package/ui/public/sprites/14-R-1.png +0 -0
  361. package/ui/public/sprites/2-D-1.png +0 -0
  362. package/ui/public/sprites/2-D-2.png +0 -0
  363. package/ui/public/sprites/2-D-3.png +0 -0
  364. package/ui/public/sprites/2-L-1.png +0 -0
  365. package/ui/public/sprites/2-R-1.png +0 -0
  366. package/ui/public/sprites/3-D-1.png +0 -0
  367. package/ui/public/sprites/3-D-2.png +0 -0
  368. package/ui/public/sprites/3-D-3.png +0 -0
  369. package/ui/public/sprites/3-L-1.png +0 -0
  370. package/ui/public/sprites/3-R-1.png +0 -0
  371. package/ui/public/sprites/4-D-1.png +0 -0
  372. package/ui/public/sprites/4-D-2.png +0 -0
  373. package/ui/public/sprites/4-D-3.png +0 -0
  374. package/ui/public/sprites/4-L-1.png +0 -0
  375. package/ui/public/sprites/4-R-1.png +0 -0
  376. package/ui/public/sprites/5-D-1.png +0 -0
  377. package/ui/public/sprites/5-D-2.png +0 -0
  378. package/ui/public/sprites/5-D-3.png +0 -0
  379. package/ui/public/sprites/5-L-1.png +0 -0
  380. package/ui/public/sprites/5-R-1.png +0 -0
  381. package/ui/public/sprites/6-D-1.png +0 -0
  382. package/ui/public/sprites/6-D-2.png +0 -0
  383. package/ui/public/sprites/6-D-3.png +0 -0
  384. package/ui/public/sprites/6-L-1.png +0 -0
  385. package/ui/public/sprites/6-R-1.png +0 -0
  386. package/ui/public/sprites/7-D-1.png +0 -0
  387. package/ui/public/sprites/7-D-2.png +0 -0
  388. package/ui/public/sprites/7-D-3.png +0 -0
  389. package/ui/public/sprites/7-L-1.png +0 -0
  390. package/ui/public/sprites/7-R-1.png +0 -0
  391. package/ui/public/sprites/8-D-1.png +0 -0
  392. package/ui/public/sprites/8-D-2.png +0 -0
  393. package/ui/public/sprites/8-D-3.png +0 -0
  394. package/ui/public/sprites/8-L-1.png +0 -0
  395. package/ui/public/sprites/8-R-1.png +0 -0
  396. package/ui/public/sprites/9-D-1.png +0 -0
  397. package/ui/public/sprites/9-D-2.png +0 -0
  398. package/ui/public/sprites/9-D-3.png +0 -0
  399. package/ui/public/sprites/9-L-1.png +0 -0
  400. package/ui/public/sprites/9-R-1.png +0 -0
  401. package/ui/public/sprites/ceo-lobster.png +0 -0
  402. package/ui/public/sw.js +42 -0
  403. package/ui/public/worktree-favicon-16x16.png +0 -0
  404. package/ui/public/worktree-favicon-32x32.png +0 -0
  405. package/ui/public/worktree-favicon.ico +0 -0
  406. package/ui/public/worktree-favicon.svg +9 -0
  407. package/ui/src/App.tsx +354 -0
  408. package/ui/src/adapters/claude-local/config-fields.tsx +138 -0
  409. package/ui/src/adapters/claude-local/index.ts +13 -0
  410. package/ui/src/adapters/codex-local/config-fields.tsx +104 -0
  411. package/ui/src/adapters/codex-local/index.ts +13 -0
  412. package/ui/src/adapters/cursor/config-fields.tsx +49 -0
  413. package/ui/src/adapters/cursor/index.ts +13 -0
  414. package/ui/src/adapters/gemini-local/config-fields.tsx +51 -0
  415. package/ui/src/adapters/gemini-local/index.ts +13 -0
  416. package/ui/src/adapters/http/build-config.ts +9 -0
  417. package/ui/src/adapters/http/config-fields.tsx +38 -0
  418. package/ui/src/adapters/http/index.ts +12 -0
  419. package/ui/src/adapters/http/parse-stdout.ts +5 -0
  420. package/ui/src/adapters/index.ts +9 -0
  421. package/ui/src/adapters/local-workspace-runtime-fields.tsx +5 -0
  422. package/ui/src/adapters/openclaw-gateway/config-fields.tsx +237 -0
  423. package/ui/src/adapters/openclaw-gateway/index.ts +13 -0
  424. package/ui/src/adapters/opencode-local/config-fields.tsx +72 -0
  425. package/ui/src/adapters/opencode-local/index.ts +13 -0
  426. package/ui/src/adapters/pi-local/config-fields.tsx +49 -0
  427. package/ui/src/adapters/pi-local/index.ts +13 -0
  428. package/ui/src/adapters/process/build-config.ts +18 -0
  429. package/ui/src/adapters/process/config-fields.tsx +77 -0
  430. package/ui/src/adapters/process/index.ts +12 -0
  431. package/ui/src/adapters/process/parse-stdout.ts +5 -0
  432. package/ui/src/adapters/registry.ts +34 -0
  433. package/ui/src/adapters/runtime-json-fields.tsx +122 -0
  434. package/ui/src/adapters/transcript.test.ts +30 -0
  435. package/ui/src/adapters/transcript.ts +62 -0
  436. package/ui/src/adapters/types.ts +34 -0
  437. package/ui/src/api/access.ts +160 -0
  438. package/ui/src/api/activity.ts +37 -0
  439. package/ui/src/api/agents.ts +194 -0
  440. package/ui/src/api/approvals.ts +25 -0
  441. package/ui/src/api/assets.ts +30 -0
  442. package/ui/src/api/auth.ts +74 -0
  443. package/ui/src/api/budgets.ts +21 -0
  444. package/ui/src/api/client.ts +50 -0
  445. package/ui/src/api/companies.ts +59 -0
  446. package/ui/src/api/companySkills.ts +55 -0
  447. package/ui/src/api/costs.ts +60 -0
  448. package/ui/src/api/dashboard.ts +7 -0
  449. package/ui/src/api/execution-workspaces.ts +27 -0
  450. package/ui/src/api/goals.ts +12 -0
  451. package/ui/src/api/health.ts +41 -0
  452. package/ui/src/api/heartbeats.ts +62 -0
  453. package/ui/src/api/index.ts +18 -0
  454. package/ui/src/api/instanceSettings.ts +19 -0
  455. package/ui/src/api/issues.ts +115 -0
  456. package/ui/src/api/plugins.ts +424 -0
  457. package/ui/src/api/projects.ts +34 -0
  458. package/ui/src/api/routines.ts +59 -0
  459. package/ui/src/api/secrets.ts +26 -0
  460. package/ui/src/api/sidebarBadges.ts +7 -0
  461. package/ui/src/components/AccountingModelCard.tsx +69 -0
  462. package/ui/src/components/ActiveAgentsPanel.tsx +157 -0
  463. package/ui/src/components/ActivityCharts.tsx +264 -0
  464. package/ui/src/components/ActivityRow.tsx +147 -0
  465. package/ui/src/components/AgentActionButtons.tsx +51 -0
  466. package/ui/src/components/AgentConfigForm.tsx +1468 -0
  467. package/ui/src/components/AgentIconPicker.tsx +81 -0
  468. package/ui/src/components/AgentProperties.tsx +107 -0
  469. package/ui/src/components/ApprovalCard.tsx +107 -0
  470. package/ui/src/components/ApprovalPayload.tsx +134 -0
  471. package/ui/src/components/AsciiArtAnimation.tsx +355 -0
  472. package/ui/src/components/BillerSpendCard.tsx +146 -0
  473. package/ui/src/components/BreadcrumbBar.tsx +113 -0
  474. package/ui/src/components/BudgetIncidentCard.tsx +101 -0
  475. package/ui/src/components/BudgetPolicyCard.tsx +220 -0
  476. package/ui/src/components/BudgetSidebarMarker.tsx +13 -0
  477. package/ui/src/components/ClaudeSubscriptionPanel.tsx +141 -0
  478. package/ui/src/components/CodexSubscriptionPanel.tsx +158 -0
  479. package/ui/src/components/CommandPalette.tsx +239 -0
  480. package/ui/src/components/CommentThread.tsx +503 -0
  481. package/ui/src/components/CompanyPatternIcon.tsx +212 -0
  482. package/ui/src/components/CompanyRail.tsx +329 -0
  483. package/ui/src/components/CompanySwitcher.tsx +81 -0
  484. package/ui/src/components/CopyText.tsx +56 -0
  485. package/ui/src/components/DevRestartBanner.tsx +89 -0
  486. package/ui/src/components/EmptyState.tsx +27 -0
  487. package/ui/src/components/EntityRow.tsx +69 -0
  488. package/ui/src/components/FilterBar.tsx +39 -0
  489. package/ui/src/components/FinanceBillerCard.tsx +45 -0
  490. package/ui/src/components/FinanceKindCard.tsx +44 -0
  491. package/ui/src/components/FinanceTimelineCard.tsx +72 -0
  492. package/ui/src/components/GoalProperties.tsx +165 -0
  493. package/ui/src/components/GoalTree.tsx +118 -0
  494. package/ui/src/components/Identity.tsx +39 -0
  495. package/ui/src/components/InlineEditor.tsx +248 -0
  496. package/ui/src/components/InlineEntitySelector.tsx +206 -0
  497. package/ui/src/components/InstanceSidebar.tsx +53 -0
  498. package/ui/src/components/IssueDocumentsSection.tsx +892 -0
  499. package/ui/src/components/IssueProperties.tsx +621 -0
  500. package/ui/src/components/IssueRow.tsx +149 -0
  501. package/ui/src/components/IssueWorkspaceCard.tsx +404 -0
  502. package/ui/src/components/IssuesList.tsx +889 -0
  503. package/ui/src/components/JsonSchemaForm.tsx +1048 -0
  504. package/ui/src/components/KanbanBoard.tsx +275 -0
  505. package/ui/src/components/Layout.tsx +441 -0
  506. package/ui/src/components/LiveRunWidget.tsx +160 -0
  507. package/ui/src/components/MarkdownBody.test.tsx +50 -0
  508. package/ui/src/components/MarkdownBody.tsx +152 -0
  509. package/ui/src/components/MarkdownEditor.tsx +622 -0
  510. package/ui/src/components/MetricCard.tsx +53 -0
  511. package/ui/src/components/MobileBottomNav.tsx +123 -0
  512. package/ui/src/components/NewAgentDialog.tsx +223 -0
  513. package/ui/src/components/NewGoalDialog.tsx +283 -0
  514. package/ui/src/components/NewIssueDialog.tsx +1473 -0
  515. package/ui/src/components/NewProjectDialog.tsx +451 -0
  516. package/ui/src/components/OnboardingWizard.tsx +1392 -0
  517. package/ui/src/components/OpenCodeLogoIcon.tsx +22 -0
  518. package/ui/src/components/PackageFileTree.tsx +318 -0
  519. package/ui/src/components/PageSkeleton.tsx +180 -0
  520. package/ui/src/components/PageTabBar.tsx +45 -0
  521. package/ui/src/components/PathInstructionsModal.tsx +143 -0
  522. package/ui/src/components/PriorityIcon.tsx +77 -0
  523. package/ui/src/components/ProjectProperties.tsx +1127 -0
  524. package/ui/src/components/PropertiesPanel.tsx +29 -0
  525. package/ui/src/components/ProviderQuotaCard.tsx +417 -0
  526. package/ui/src/components/QuotaBar.tsx +65 -0
  527. package/ui/src/components/ReportsToPicker.tsx +127 -0
  528. package/ui/src/components/ScheduleEditor.tsx +344 -0
  529. package/ui/src/components/ScrollToBottom.tsx +79 -0
  530. package/ui/src/components/Sidebar.tsx +130 -0
  531. package/ui/src/components/SidebarAgents.tsx +146 -0
  532. package/ui/src/components/SidebarNavItem.tsx +92 -0
  533. package/ui/src/components/SidebarProjects.tsx +234 -0
  534. package/ui/src/components/SidebarSection.tsx +17 -0
  535. package/ui/src/components/StatusBadge.tsx +15 -0
  536. package/ui/src/components/StatusIcon.tsx +71 -0
  537. package/ui/src/components/SwipeToArchive.tsx +152 -0
  538. package/ui/src/components/ToastViewport.tsx +99 -0
  539. package/ui/src/components/WorktreeBanner.tsx +25 -0
  540. package/ui/src/components/agent-config-defaults.ts +31 -0
  541. package/ui/src/components/agent-config-primitives.tsx +476 -0
  542. package/ui/src/components/transcript/RunTranscriptView.test.tsx +84 -0
  543. package/ui/src/components/transcript/RunTranscriptView.tsx +1015 -0
  544. package/ui/src/components/transcript/useLiveRunTranscripts.ts +297 -0
  545. package/ui/src/components/ui/avatar.tsx +107 -0
  546. package/ui/src/components/ui/badge.tsx +48 -0
  547. package/ui/src/components/ui/breadcrumb.tsx +109 -0
  548. package/ui/src/components/ui/button.tsx +64 -0
  549. package/ui/src/components/ui/card.tsx +92 -0
  550. package/ui/src/components/ui/checkbox.tsx +32 -0
  551. package/ui/src/components/ui/collapsible.tsx +33 -0
  552. package/ui/src/components/ui/command.tsx +194 -0
  553. package/ui/src/components/ui/dialog.tsx +156 -0
  554. package/ui/src/components/ui/dropdown-menu.tsx +257 -0
  555. package/ui/src/components/ui/input.tsx +21 -0
  556. package/ui/src/components/ui/label.tsx +22 -0
  557. package/ui/src/components/ui/popover.tsx +88 -0
  558. package/ui/src/components/ui/scroll-area.tsx +56 -0
  559. package/ui/src/components/ui/select.tsx +188 -0
  560. package/ui/src/components/ui/separator.tsx +28 -0
  561. package/ui/src/components/ui/sheet.tsx +143 -0
  562. package/ui/src/components/ui/skeleton.tsx +13 -0
  563. package/ui/src/components/ui/tabs.tsx +89 -0
  564. package/ui/src/components/ui/textarea.tsx +18 -0
  565. package/ui/src/components/ui/tooltip.tsx +57 -0
  566. package/ui/src/components/visual-office/AgentAvatar.tsx +99 -0
  567. package/ui/src/components/visual-office/OfficeViewExact.tsx +417 -0
  568. package/ui/src/components/visual-office/i18n.ts +52 -0
  569. package/ui/src/components/visual-office/office-view/CliUsagePanel.tsx +240 -0
  570. package/ui/src/components/visual-office/office-view/VirtualPadOverlay.tsx +104 -0
  571. package/ui/src/components/visual-office/office-view/buildScene-break-room.ts +248 -0
  572. package/ui/src/components/visual-office/office-view/buildScene-ceo-hallway.ts +345 -0
  573. package/ui/src/components/visual-office/office-view/buildScene-department-agent.ts +242 -0
  574. package/ui/src/components/visual-office/office-view/buildScene-departments.ts +360 -0
  575. package/ui/src/components/visual-office/office-view/buildScene-final-layers.ts +113 -0
  576. package/ui/src/components/visual-office/office-view/buildScene-types.ts +91 -0
  577. package/ui/src/components/visual-office/office-view/buildScene.ts +232 -0
  578. package/ui/src/components/visual-office/office-view/drawing-core.ts +374 -0
  579. package/ui/src/components/visual-office/office-view/drawing-furniture-a.ts +338 -0
  580. package/ui/src/components/visual-office/office-view/drawing-furniture-b.ts +241 -0
  581. package/ui/src/components/visual-office/office-view/model.ts +301 -0
  582. package/ui/src/components/visual-office/office-view/officeTicker.ts +455 -0
  583. package/ui/src/components/visual-office/office-view/officeTickerRoomAndDelivery.ts +133 -0
  584. package/ui/src/components/visual-office/office-view/themes-locale.ts +460 -0
  585. package/ui/src/components/visual-office/office-view/useCliUsage.ts +37 -0
  586. package/ui/src/components/visual-office/office-view/useOfficeDeliveryEffects.ts +465 -0
  587. package/ui/src/components/visual-office/office-view/useOfficePixiRuntime.ts +282 -0
  588. package/ui/src/components/visual-office/types.ts +123 -0
  589. package/ui/src/context/BreadcrumbContext.tsx +44 -0
  590. package/ui/src/context/CompanyContext.tsx +151 -0
  591. package/ui/src/context/DialogContext.tsx +135 -0
  592. package/ui/src/context/LiveUpdatesProvider.test.ts +119 -0
  593. package/ui/src/context/LiveUpdatesProvider.tsx +760 -0
  594. package/ui/src/context/PanelContext.tsx +73 -0
  595. package/ui/src/context/SidebarContext.tsx +43 -0
  596. package/ui/src/context/ThemeContext.tsx +83 -0
  597. package/ui/src/context/ToastContext.tsx +172 -0
  598. package/ui/src/fixtures/runTranscriptFixtures.ts +226 -0
  599. package/ui/src/hooks/useAgentOrder.ts +105 -0
  600. package/ui/src/hooks/useAutosaveIndicator.ts +72 -0
  601. package/ui/src/hooks/useCompanyPageMemory.test.ts +90 -0
  602. package/ui/src/hooks/useCompanyPageMemory.ts +79 -0
  603. package/ui/src/hooks/useDateRange.ts +120 -0
  604. package/ui/src/hooks/useInboxBadge.ts +132 -0
  605. package/ui/src/hooks/useKeyboardShortcuts.ts +40 -0
  606. package/ui/src/hooks/useProjectOrder.ts +106 -0
  607. package/ui/src/index.css +770 -0
  608. package/ui/src/lib/agent-icons.ts +99 -0
  609. package/ui/src/lib/agent-order.ts +107 -0
  610. package/ui/src/lib/agent-skills-state.test.ts +90 -0
  611. package/ui/src/lib/agent-skills-state.ts +41 -0
  612. package/ui/src/lib/assignees.test.ts +92 -0
  613. package/ui/src/lib/assignees.ts +82 -0
  614. package/ui/src/lib/color-contrast.ts +107 -0
  615. package/ui/src/lib/company-export-selection.test.ts +41 -0
  616. package/ui/src/lib/company-export-selection.ts +57 -0
  617. package/ui/src/lib/company-page-memory.ts +65 -0
  618. package/ui/src/lib/company-portability-sidebar.test.ts +101 -0
  619. package/ui/src/lib/company-portability-sidebar.ts +62 -0
  620. package/ui/src/lib/company-routes.ts +88 -0
  621. package/ui/src/lib/company-selection.test.ts +34 -0
  622. package/ui/src/lib/company-selection.ts +18 -0
  623. package/ui/src/lib/groupBy.ts +11 -0
  624. package/ui/src/lib/inbox.test.ts +404 -0
  625. package/ui/src/lib/inbox.ts +292 -0
  626. package/ui/src/lib/instance-settings.test.ts +26 -0
  627. package/ui/src/lib/instance-settings.ts +25 -0
  628. package/ui/src/lib/issueDetailBreadcrumb.ts +24 -0
  629. package/ui/src/lib/legacy-agent-config.test.ts +40 -0
  630. package/ui/src/lib/legacy-agent-config.ts +17 -0
  631. package/ui/src/lib/mention-aware-link-node.test.ts +50 -0
  632. package/ui/src/lib/mention-aware-link-node.ts +67 -0
  633. package/ui/src/lib/mention-chips.ts +168 -0
  634. package/ui/src/lib/mention-deletion.test.ts +87 -0
  635. package/ui/src/lib/mention-deletion.ts +143 -0
  636. package/ui/src/lib/model-utils.ts +16 -0
  637. package/ui/src/lib/onboarding-goal.test.ts +22 -0
  638. package/ui/src/lib/onboarding-goal.ts +18 -0
  639. package/ui/src/lib/onboarding-launch.test.ts +131 -0
  640. package/ui/src/lib/onboarding-launch.ts +54 -0
  641. package/ui/src/lib/onboarding-route.test.ts +80 -0
  642. package/ui/src/lib/onboarding-route.ts +51 -0
  643. package/ui/src/lib/portable-files.ts +42 -0
  644. package/ui/src/lib/project-order.ts +71 -0
  645. package/ui/src/lib/queryKeys.ts +140 -0
  646. package/ui/src/lib/recent-assignees.ts +36 -0
  647. package/ui/src/lib/router.tsx +76 -0
  648. package/ui/src/lib/routine-trigger-patch.test.ts +72 -0
  649. package/ui/src/lib/routine-trigger-patch.ts +31 -0
  650. package/ui/src/lib/status-colors.ts +108 -0
  651. package/ui/src/lib/timeAgo.ts +31 -0
  652. package/ui/src/lib/utils.ts +168 -0
  653. package/ui/src/lib/worktree-branding.ts +65 -0
  654. package/ui/src/lib/zip.test.ts +289 -0
  655. package/ui/src/lib/zip.ts +284 -0
  656. package/ui/src/main.tsx +67 -0
  657. package/ui/src/pages/Activity.tsx +141 -0
  658. package/ui/src/pages/AgentDetail.tsx +4053 -0
  659. package/ui/src/pages/Agents.tsx +415 -0
  660. package/ui/src/pages/ApprovalDetail.tsx +369 -0
  661. package/ui/src/pages/Approvals.tsx +132 -0
  662. package/ui/src/pages/Auth.tsx +180 -0
  663. package/ui/src/pages/BoardClaim.tsx +125 -0
  664. package/ui/src/pages/CliAuth.tsx +184 -0
  665. package/ui/src/pages/Companies.tsx +297 -0
  666. package/ui/src/pages/CompanyExport.tsx +1019 -0
  667. package/ui/src/pages/CompanyImport.tsx +1355 -0
  668. package/ui/src/pages/CompanySettings.tsx +661 -0
  669. package/ui/src/pages/CompanySkills.tsx +1171 -0
  670. package/ui/src/pages/Costs.tsx +1103 -0
  671. package/ui/src/pages/Dashboard.tsx +388 -0
  672. package/ui/src/pages/DesignGuide.tsx +1330 -0
  673. package/ui/src/pages/ExecutionWorkspaceDetail.tsx +82 -0
  674. package/ui/src/pages/GoalDetail.tsx +197 -0
  675. package/ui/src/pages/Goals.tsx +63 -0
  676. package/ui/src/pages/Inbox.tsx +1291 -0
  677. package/ui/src/pages/InstanceExperimentalSettings.tsx +139 -0
  678. package/ui/src/pages/InstanceGeneralSettings.tsx +104 -0
  679. package/ui/src/pages/InstanceSettings.tsx +284 -0
  680. package/ui/src/pages/InviteLanding.tsx +320 -0
  681. package/ui/src/pages/IssueDetail.tsx +1201 -0
  682. package/ui/src/pages/Issues.tsx +116 -0
  683. package/ui/src/pages/MyIssues.tsx +72 -0
  684. package/ui/src/pages/NewAgent.tsx +353 -0
  685. package/ui/src/pages/NotFound.tsx +66 -0
  686. package/ui/src/pages/Org.tsx +132 -0
  687. package/ui/src/pages/OrgChart.tsx +447 -0
  688. package/ui/src/pages/PluginManager.tsx +510 -0
  689. package/ui/src/pages/PluginPage.tsx +156 -0
  690. package/ui/src/pages/PluginSettings.tsx +836 -0
  691. package/ui/src/pages/ProjectDetail.tsx +633 -0
  692. package/ui/src/pages/Projects.tsx +87 -0
  693. package/ui/src/pages/RoutineDetail.tsx +1022 -0
  694. package/ui/src/pages/Routines.tsx +661 -0
  695. package/ui/src/pages/RunTranscriptUxLab.tsx +334 -0
  696. package/ui/src/pages/VisualOffice.tsx +243 -0
  697. package/ui/src/plugins/bridge-init.ts +69 -0
  698. package/ui/src/plugins/bridge.ts +476 -0
  699. package/ui/src/plugins/launchers.tsx +834 -0
  700. package/ui/src/plugins/slots.tsx +855 -0
  701. package/ui/tsconfig.json +21 -0
  702. package/ui/vite.config.ts +23 -0
  703. package/ui/vitest.config.ts +14 -0
  704. package/vitest.config.ts +11 -0
@@ -0,0 +1,2220 @@
1
+ /**
2
+ * @fileoverview Plugin management REST API routes
3
+ *
4
+ * This module provides Express routes for managing the complete plugin lifecycle:
5
+ * - Listing and filtering plugins by status
6
+ * - Installing plugins from npm or local paths
7
+ * - Uninstalling plugins (soft delete or hard purge)
8
+ * - Enabling/disabling plugins
9
+ * - Running health diagnostics
10
+ * - Upgrading plugins
11
+ * - Retrieving UI slot contributions for frontend rendering
12
+ * - Discovering and executing plugin-contributed agent tools
13
+ *
14
+ * All routes require board-level authentication (assertBoard middleware).
15
+ *
16
+ * @module server/routes/plugins
17
+ * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { randomUUID } from "node:crypto";
23
+ import { fileURLToPath } from "node:url";
24
+ import { Router } from "express";
25
+ import type { Request } from "express";
26
+ import { and, desc, eq, gte } from "drizzle-orm";
27
+ import type { Db } from "@corporateai/db";
28
+ import { companies, pluginLogs, pluginWebhookDeliveries } from "@corporateai/db";
29
+ import type {
30
+ PluginStatus,
31
+ PaperclipPluginManifestV1,
32
+ PluginBridgeErrorCode,
33
+ PluginLauncherRenderContextSnapshot,
34
+ } from "@corporateai/shared";
35
+ import {
36
+ PLUGIN_STATUSES,
37
+ } from "@corporateai/shared";
38
+ import { pluginRegistryService } from "../services/plugin-registry.js";
39
+ import { pluginLifecycleManager } from "../services/plugin-lifecycle.js";
40
+ import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js";
41
+ import { logActivity } from "../services/activity-log.js";
42
+ import { publishGlobalLiveEvent } from "../services/live-events.js";
43
+ import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js";
44
+ import type { PluginJobStore } from "../services/plugin-job-store.js";
45
+ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
46
+ import type { PluginStreamBus } from "../services/plugin-stream-bus.js";
47
+ import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js";
48
+ import type { ToolRunContext } from "@corporateai/plugin-sdk";
49
+ import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@corporateai/plugin-sdk";
50
+ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
51
+ import { validateInstanceConfig } from "../services/plugin-config-validator.js";
52
+
53
+ /** UI slot declaration extracted from plugin manifest */
54
+ type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
55
+ /** Launcher declaration extracted from plugin manifest */
56
+ type PluginLauncherDeclaration = NonNullable<PaperclipPluginManifestV1["launchers"]>[number];
57
+
58
+ /**
59
+ * Normalized UI contribution for frontend slot host consumption.
60
+ * Only includes plugins in 'ready' state with non-empty slot declarations.
61
+ */
62
+ type PluginUiContribution = {
63
+ pluginId: string;
64
+ pluginKey: string;
65
+ displayName: string;
66
+ version: string;
67
+ updatedAt: string;
68
+ /**
69
+ * Relative path within the plugin's UI directory to the entry module
70
+ * (e.g. `"index.js"`). The frontend constructs the full import URL as
71
+ * `/_plugins/${pluginId}/ui/${uiEntryFile}`.
72
+ */
73
+ uiEntryFile: string;
74
+ slots: PluginUiSlotDeclaration[];
75
+ launchers: PluginLauncherDeclaration[];
76
+ };
77
+
78
+ /** Request body for POST /api/plugins/install */
79
+ interface PluginInstallRequest {
80
+ /** npm package name (e.g., @paperclip/plugin-linear) or local path */
81
+ packageName: string;
82
+ /** Target version for npm packages (optional, defaults to latest) */
83
+ version?: string;
84
+ /** True if packageName is a local filesystem path */
85
+ isLocalPath?: boolean;
86
+ }
87
+
88
+ interface AvailablePluginExample {
89
+ packageName: string;
90
+ pluginKey: string;
91
+ displayName: string;
92
+ description: string;
93
+ localPath: string;
94
+ tag: "example";
95
+ }
96
+
97
+ /** Response body for GET /api/plugins/:pluginId/health */
98
+ interface PluginHealthCheckResult {
99
+ pluginId: string;
100
+ status: string;
101
+ healthy: boolean;
102
+ checks: Array<{
103
+ name: string;
104
+ passed: boolean;
105
+ message?: string;
106
+ }>;
107
+ lastError?: string;
108
+ }
109
+
110
+ /** UUID v4 regex used for plugin ID route resolution. */
111
+ const UUID_REGEX =
112
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
113
+
114
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
115
+ const REPO_ROOT = path.resolve(__dirname, "../../..");
116
+
117
+ const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
118
+ {
119
+ packageName: "@corporateai/plugin-hello-world-example",
120
+ pluginKey: "paperclip.hello-world-example",
121
+ displayName: "Hello World Widget (Example)",
122
+ description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
123
+ localPath: "packages/plugins/examples/plugin-hello-world-example",
124
+ tag: "example",
125
+ },
126
+ {
127
+ packageName: "@corporateai/plugin-file-browser-example",
128
+ pluginKey: "paperclip-file-browser-example",
129
+ displayName: "File Browser (Example)",
130
+ description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.",
131
+ localPath: "packages/plugins/examples/plugin-file-browser-example",
132
+ tag: "example",
133
+ },
134
+ {
135
+ packageName: "@corporateai/plugin-kitchen-sink-example",
136
+ pluginKey: "paperclip-kitchen-sink-example",
137
+ displayName: "Kitchen Sink (Example)",
138
+ description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.",
139
+ localPath: "packages/plugins/examples/plugin-kitchen-sink-example",
140
+ tag: "example",
141
+ },
142
+ ];
143
+
144
+ function listBundledPluginExamples(): AvailablePluginExample[] {
145
+ return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => {
146
+ const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath);
147
+ if (!existsSync(absoluteLocalPath)) return [];
148
+ return [{ ...plugin, localPath: absoluteLocalPath }];
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Resolve a plugin by either database ID or plugin key.
154
+ *
155
+ * Lookup order:
156
+ * - UUID-like IDs: getById first, then getByKey.
157
+ * - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById.
158
+ * - Other non-UUID IDs: try getById first (test/memory registries may allow this),
159
+ * then fallback to getByKey. Any UUID parse error from getById is ignored.
160
+ *
161
+ * @param registry - The plugin registry service instance
162
+ * @param pluginId - Either a database UUID or plugin key (manifest id)
163
+ * @returns Plugin record or null if not found
164
+ */
165
+ async function resolvePlugin(
166
+ registry: ReturnType<typeof pluginRegistryService>,
167
+ pluginId: string,
168
+ ) {
169
+ const isUuid = UUID_REGEX.test(pluginId);
170
+ const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/");
171
+
172
+ // Scoped package IDs are valid plugin keys but invalid UUIDs.
173
+ // Skip getById() entirely to avoid Postgres uuid parse errors.
174
+ if (isScopedPackageKey && !isUuid) {
175
+ return registry.getByKey(pluginId);
176
+ }
177
+
178
+ try {
179
+ const byId = await registry.getById(pluginId);
180
+ if (byId) return byId;
181
+ } catch (error) {
182
+ const maybeCode =
183
+ typeof error === "object" && error !== null && "code" in error
184
+ ? (error as { code?: unknown }).code
185
+ : undefined;
186
+ // Ignore invalid UUID cast errors and continue with key lookup.
187
+ if (maybeCode !== "22P02") {
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ return registry.getByKey(pluginId);
193
+ }
194
+
195
+ /**
196
+ * Optional dependencies for plugin job scheduling routes.
197
+ *
198
+ * When provided, job-related routes (list jobs, list runs, trigger job) are
199
+ * mounted. When omitted, the routes return 501 Not Implemented.
200
+ */
201
+ export interface PluginRouteJobDeps {
202
+ /** The job scheduler instance. */
203
+ scheduler: PluginJobScheduler;
204
+ /** The job persistence store. */
205
+ jobStore: PluginJobStore;
206
+ }
207
+
208
+ /**
209
+ * Optional dependencies for plugin webhook routes.
210
+ *
211
+ * When provided, the webhook ingestion route is enabled. When omitted,
212
+ * webhook POST requests return 501 Not Implemented.
213
+ */
214
+ export interface PluginRouteWebhookDeps {
215
+ /** The worker manager for dispatching handleWebhook RPC calls. */
216
+ workerManager: PluginWorkerManager;
217
+ }
218
+
219
+ /**
220
+ * Optional dependencies for plugin tool routes.
221
+ *
222
+ * When provided, tool discovery and execution routes are enabled.
223
+ * When omitted, the tool routes return 501 Not Implemented.
224
+ */
225
+ export interface PluginRouteToolDeps {
226
+ /** The tool dispatcher for listing and executing plugin tools. */
227
+ toolDispatcher: PluginToolDispatcher;
228
+ }
229
+
230
+ /**
231
+ * Optional dependencies for plugin UI bridge routes.
232
+ *
233
+ * When provided, the getData and performAction bridge proxy routes are enabled,
234
+ * allowing plugin UI components to communicate with their worker backend via
235
+ * `usePluginData()` and `usePluginAction()` hooks.
236
+ *
237
+ * @see PLUGIN_SPEC.md §13.8 — `getData`
238
+ * @see PLUGIN_SPEC.md §13.9 — `performAction`
239
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
240
+ */
241
+ export interface PluginRouteBridgeDeps {
242
+ /** The worker manager for dispatching getData/performAction RPC calls. */
243
+ workerManager: PluginWorkerManager;
244
+ /** Optional stream bus for SSE push from worker to UI. */
245
+ streamBus?: PluginStreamBus;
246
+ }
247
+
248
+ /** Request body for POST /api/plugins/tools/execute */
249
+ interface PluginToolExecuteRequest {
250
+ /** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */
251
+ tool: string;
252
+ /** Parameters matching the tool's declared JSON Schema. */
253
+ parameters?: unknown;
254
+ /** Agent run context. */
255
+ runContext: ToolRunContext;
256
+ }
257
+
258
+ /**
259
+ * Create Express router for plugin management API.
260
+ *
261
+ * Routes provided:
262
+ *
263
+ * | Method | Path | Description |
264
+ * |--------|------|-------------|
265
+ * | GET | /plugins | List all plugins (optional ?status= filter) |
266
+ * | GET | /plugins/ui-contributions | Get UI slots from ready plugins |
267
+ * | GET | /plugins/:pluginId | Get single plugin by ID or key |
268
+ * | POST | /plugins/install | Install from npm or local path |
269
+ * | DELETE | /plugins/:pluginId | Uninstall (optional ?purge=true) |
270
+ * | POST | /plugins/:pluginId/enable | Enable a plugin |
271
+ * | POST | /plugins/:pluginId/disable | Disable a plugin |
272
+ * | GET | /plugins/:pluginId/health | Run health diagnostics |
273
+ * | POST | /plugins/:pluginId/upgrade | Upgrade to newer version |
274
+ * | GET | /plugins/:pluginId/jobs | List jobs for a plugin |
275
+ * | GET | /plugins/:pluginId/jobs/:jobId/runs | List runs for a job |
276
+ * | POST | /plugins/:pluginId/jobs/:jobId/trigger | Manually trigger a job |
277
+ * | POST | /plugins/:pluginId/webhooks/:endpointKey | Receive inbound webhook |
278
+ * | GET | /plugins/tools | List all available plugin tools |
279
+ * | GET | /plugins/tools?pluginId=... | List tools for a specific plugin |
280
+ * | POST | /plugins/tools/execute | Execute a plugin tool |
281
+ * | GET | /plugins/:pluginId/config | Get current plugin config |
282
+ * | POST | /plugins/:pluginId/config | Save (upsert) plugin config |
283
+ * | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC |
284
+ * | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker |
285
+ * | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker |
286
+ * | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) |
287
+ * | POST | /plugins/:pluginId/actions/:key | Proxy performAction to plugin worker (key in URL) |
288
+ * | GET | /plugins/:pluginId/bridge/stream/:channel | SSE stream from worker to UI |
289
+ * | GET | /plugins/:pluginId/dashboard | Aggregated health dashboard data |
290
+ *
291
+ * **Route Ordering Note:** Static routes (like /ui-contributions, /tools) must be
292
+ * registered before parameterized routes (like /:pluginId) to prevent Express from
293
+ * matching them as a plugin ID.
294
+ *
295
+ * @param db - Database connection instance
296
+ * @param jobDeps - Optional job scheduling dependencies
297
+ * @param webhookDeps - Optional webhook ingestion dependencies
298
+ * @param toolDeps - Optional tool dispatcher dependencies
299
+ * @param bridgeDeps - Optional bridge proxy dependencies for getData/performAction
300
+ * @returns Express router with plugin routes mounted
301
+ */
302
+ export function pluginRoutes(
303
+ db: Db,
304
+ loader: ReturnType<typeof pluginLoader>,
305
+ jobDeps?: PluginRouteJobDeps,
306
+ webhookDeps?: PluginRouteWebhookDeps,
307
+ toolDeps?: PluginRouteToolDeps,
308
+ bridgeDeps?: PluginRouteBridgeDeps,
309
+ ) {
310
+ const router = Router();
311
+ const registry = pluginRegistryService(db);
312
+ const lifecycle = pluginLifecycleManager(db, {
313
+ loader,
314
+ workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager,
315
+ });
316
+
317
+ async function resolvePluginAuditCompanyIds(req: Request): Promise<string[]> {
318
+ if (typeof (db as { select?: unknown }).select === "function") {
319
+ const rows = await db
320
+ .select({ id: companies.id })
321
+ .from(companies);
322
+ return rows.map((row) => row.id);
323
+ }
324
+
325
+ if (req.actor.type === "agent" && req.actor.companyId) {
326
+ return [req.actor.companyId];
327
+ }
328
+
329
+ if (req.actor.type === "board") {
330
+ return req.actor.companyIds ?? [];
331
+ }
332
+
333
+ return [];
334
+ }
335
+
336
+ async function logPluginMutationActivity(
337
+ req: Request,
338
+ action: string,
339
+ entityId: string,
340
+ details: Record<string, unknown>,
341
+ ): Promise<void> {
342
+ const companyIds = await resolvePluginAuditCompanyIds(req);
343
+ if (companyIds.length === 0) return;
344
+
345
+ const actor = getActorInfo(req);
346
+ await Promise.all(companyIds.map((companyId) =>
347
+ logActivity(db, {
348
+ companyId,
349
+ actorType: actor.actorType,
350
+ actorId: actor.actorId,
351
+ agentId: actor.agentId,
352
+ runId: actor.runId,
353
+ action,
354
+ entityType: "plugin",
355
+ entityId,
356
+ details,
357
+ })));
358
+ }
359
+
360
+ /**
361
+ * GET /api/plugins
362
+ *
363
+ * List all installed plugins, optionally filtered by lifecycle status.
364
+ *
365
+ * Query params:
366
+ * - `status` (optional): Filter by lifecycle status. Must be one of the
367
+ * values in `PLUGIN_STATUSES` (`installed`, `ready`, `error`,
368
+ * `upgrade_pending`, `uninstalled`). Returns HTTP 400 if the value is
369
+ * not a recognised status string.
370
+ *
371
+ * Response: `PluginRecord[]`
372
+ */
373
+ router.get("/plugins", async (req, res) => {
374
+ assertBoard(req);
375
+ const rawStatus = req.query.status;
376
+ if (rawStatus !== undefined) {
377
+ if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) {
378
+ res.status(400).json({
379
+ error: `Invalid status '${String(rawStatus)}'. Must be one of: ${PLUGIN_STATUSES.join(", ")}`,
380
+ });
381
+ return;
382
+ }
383
+ }
384
+ const status = rawStatus as PluginStatus | undefined;
385
+ const plugins = status
386
+ ? await registry.listByStatus(status)
387
+ : await registry.listInstalled();
388
+ res.json(plugins);
389
+ });
390
+
391
+ /**
392
+ * GET /api/plugins/examples
393
+ *
394
+ * Return first-party example plugins bundled in this repo, if present.
395
+ * These can be installed through the normal local-path install flow.
396
+ */
397
+ router.get("/plugins/examples", async (req, res) => {
398
+ assertBoard(req);
399
+ res.json(listBundledPluginExamples());
400
+ });
401
+
402
+ // IMPORTANT: Static routes must come before parameterized routes
403
+ // to avoid Express matching "ui-contributions" as a :pluginId
404
+
405
+ /**
406
+ * GET /api/plugins/ui-contributions
407
+ *
408
+ * Return UI contributions from all plugins in 'ready' state.
409
+ * Used by the frontend to discover plugin UI slots and launcher metadata.
410
+ *
411
+ * The response is normalized for the frontend slot host:
412
+ * - Only includes plugins with at least one declared UI slot or launcher
413
+ * - Excludes plugins with null/missing manifestJson (defensive)
414
+ * - Slots are extracted from manifest.ui.slots
415
+ * - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers
416
+ *
417
+ * Example response:
418
+ * ```json
419
+ * [
420
+ * {
421
+ * "pluginId": "plg_123",
422
+ * "pluginKey": "paperclip.claude-usage",
423
+ * "displayName": "Claude Usage",
424
+ * "version": "1.0.0",
425
+ * "uiEntryFile": "index.js",
426
+ * "slots": [],
427
+ * "launchers": [
428
+ * {
429
+ * "id": "claude-usage-toolbar",
430
+ * "displayName": "Claude Usage",
431
+ * "placementZone": "toolbarButton",
432
+ * "action": { "type": "openModal", "target": "ClaudeUsageView" },
433
+ * "render": { "environment": "hostOverlay", "bounds": "wide" }
434
+ * }
435
+ * ]
436
+ * }
437
+ * ]
438
+ * ```
439
+ *
440
+ * Response: PluginUiContribution[]
441
+ */
442
+ router.get("/plugins/ui-contributions", async (req, res) => {
443
+ assertBoard(req);
444
+ const plugins = await registry.listByStatus("ready");
445
+
446
+ const contributions: PluginUiContribution[] = plugins
447
+ .map((plugin) => {
448
+ // Safety check: manifestJson should always exist for ready plugins, but guard against null
449
+ const manifest = plugin.manifestJson;
450
+ if (!manifest) return null;
451
+
452
+ const uiMetadata = getPluginUiContributionMetadata(manifest);
453
+ if (!uiMetadata) return null;
454
+
455
+ return {
456
+ pluginId: plugin.id,
457
+ pluginKey: plugin.pluginKey,
458
+ displayName: manifest.displayName,
459
+ version: plugin.version,
460
+ updatedAt: plugin.updatedAt.toISOString(),
461
+ uiEntryFile: uiMetadata.uiEntryFile,
462
+ slots: uiMetadata.slots,
463
+ launchers: uiMetadata.launchers,
464
+ };
465
+ })
466
+ .filter((item): item is PluginUiContribution => item !== null);
467
+ res.json(contributions);
468
+ });
469
+
470
+ // ===========================================================================
471
+ // Tool discovery and execution routes
472
+ // ===========================================================================
473
+
474
+ /**
475
+ * GET /api/plugins/tools
476
+ *
477
+ * List all available plugin-contributed tools in an agent-friendly format.
478
+ *
479
+ * Query params:
480
+ * - `pluginId` (optional): Filter to tools from a specific plugin
481
+ *
482
+ * Response: `AgentToolDescriptor[]`
483
+ * Errors: 501 if tool dispatcher is not configured
484
+ */
485
+ router.get("/plugins/tools", async (req, res) => {
486
+ assertBoard(req);
487
+
488
+ if (!toolDeps) {
489
+ res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
490
+ return;
491
+ }
492
+
493
+ const pluginId = req.query.pluginId as string | undefined;
494
+ const filter = pluginId ? { pluginId } : undefined;
495
+ const tools = toolDeps.toolDispatcher.listToolsForAgent(filter);
496
+ res.json(tools);
497
+ });
498
+
499
+ /**
500
+ * POST /api/plugins/tools/execute
501
+ *
502
+ * Execute a plugin-contributed tool by its namespaced name.
503
+ *
504
+ * This is the primary endpoint used by the agent service to invoke
505
+ * plugin tools during an agent run.
506
+ *
507
+ * Request body:
508
+ * - `tool`: Fully namespaced tool name (e.g., "acme.linear:search-issues")
509
+ * - `parameters`: Parameters matching the tool's declared JSON Schema
510
+ * - `runContext`: Agent run context with agentId, runId, companyId, projectId
511
+ *
512
+ * Response: `ToolExecutionResult`
513
+ * Errors:
514
+ * - 400 if request validation fails
515
+ * - 404 if tool is not found
516
+ * - 501 if tool dispatcher is not configured
517
+ * - 502 if the plugin worker is unavailable or the RPC call fails
518
+ */
519
+ router.post("/plugins/tools/execute", async (req, res) => {
520
+ assertBoard(req);
521
+
522
+ if (!toolDeps) {
523
+ res.status(501).json({ error: "Plugin tool dispatch is not enabled" });
524
+ return;
525
+ }
526
+
527
+ const body = (req.body as PluginToolExecuteRequest | undefined);
528
+ if (!body) {
529
+ res.status(400).json({ error: "Request body is required" });
530
+ return;
531
+ }
532
+
533
+ const { tool, parameters, runContext } = body;
534
+
535
+ // Validate required fields
536
+ if (!tool || typeof tool !== "string") {
537
+ res.status(400).json({ error: '"tool" is required and must be a string' });
538
+ return;
539
+ }
540
+
541
+ if (!runContext || typeof runContext !== "object") {
542
+ res.status(400).json({ error: '"runContext" is required and must be an object' });
543
+ return;
544
+ }
545
+
546
+ if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) {
547
+ res.status(400).json({
548
+ error: '"runContext" must include agentId, runId, companyId, and projectId',
549
+ });
550
+ return;
551
+ }
552
+
553
+ assertCompanyAccess(req, runContext.companyId);
554
+
555
+ // Verify the tool exists
556
+ const registeredTool = toolDeps.toolDispatcher.getTool(tool);
557
+ if (!registeredTool) {
558
+ res.status(404).json({ error: `Tool "${tool}" not found` });
559
+ return;
560
+ }
561
+
562
+ try {
563
+ const result = await toolDeps.toolDispatcher.executeTool(
564
+ tool,
565
+ parameters ?? {},
566
+ runContext,
567
+ );
568
+ res.json(result);
569
+ } catch (err) {
570
+ const message = err instanceof Error ? err.message : String(err);
571
+
572
+ // Distinguish between "worker not running" (502) and other errors (500)
573
+ if (message.includes("not running") || message.includes("worker")) {
574
+ res.status(502).json({ error: message });
575
+ } else {
576
+ res.status(500).json({ error: message });
577
+ }
578
+ }
579
+ });
580
+
581
+ /**
582
+ * POST /api/plugins/install
583
+ *
584
+ * Install a plugin from npm or a local filesystem path.
585
+ *
586
+ * Request body:
587
+ * - packageName: npm package name or local path (required)
588
+ * - version: Target version for npm packages (optional)
589
+ * - isLocalPath: Set true if packageName is a local path
590
+ *
591
+ * The installer:
592
+ * 1. Downloads from npm or loads from local path
593
+ * 2. Validates the manifest (schema + capability consistency)
594
+ * 3. Registers in the database
595
+ * 4. Transitions to `ready` state if no new capability approval is needed
596
+ *
597
+ * Response: `PluginRecord`
598
+ *
599
+ * Errors:
600
+ * - `400` — validation failure or install error (package not found, bad manifest, etc.)
601
+ * - `500` — installation succeeded but manifest is missing (indicates a loader bug)
602
+ */
603
+ router.post("/plugins/install", async (req, res) => {
604
+ assertBoard(req);
605
+ const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
606
+
607
+ // Input validation
608
+ if (!packageName || typeof packageName !== "string") {
609
+ res.status(400).json({ error: "packageName is required and must be a string" });
610
+ return;
611
+ }
612
+
613
+ if (version !== undefined && typeof version !== "string") {
614
+ res.status(400).json({ error: "version must be a string if provided" });
615
+ return;
616
+ }
617
+
618
+ if (isLocalPath !== undefined && typeof isLocalPath !== "boolean") {
619
+ res.status(400).json({ error: "isLocalPath must be a boolean if provided" });
620
+ return;
621
+ }
622
+
623
+ // Validate package name format
624
+ const trimmedPackage = packageName.trim();
625
+ if (trimmedPackage.length === 0) {
626
+ res.status(400).json({ error: "packageName cannot be empty" });
627
+ return;
628
+ }
629
+
630
+ // Basic security check for package name (prevent injection)
631
+ if (!isLocalPath && /[<>:"|?*]/.test(trimmedPackage)) {
632
+ res.status(400).json({ error: "packageName contains invalid characters" });
633
+ return;
634
+ }
635
+
636
+ try {
637
+ const installOptions = isLocalPath
638
+ ? { localPath: trimmedPackage }
639
+ : { packageName: trimmedPackage, version: version?.trim() };
640
+
641
+ const discovered = await loader.installPlugin(installOptions);
642
+
643
+ if (!discovered.manifest) {
644
+ res.status(500).json({ error: "Plugin installed but manifest is missing" });
645
+ return;
646
+ }
647
+
648
+ // Transition to ready state
649
+ const existingPlugin = await registry.getByKey(discovered.manifest.id);
650
+ if (existingPlugin) {
651
+ await lifecycle.load(existingPlugin.id);
652
+ const updated = await registry.getById(existingPlugin.id);
653
+ await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, {
654
+ pluginId: existingPlugin.id,
655
+ pluginKey: existingPlugin.pluginKey,
656
+ packageName: updated?.packageName ?? existingPlugin.packageName,
657
+ version: updated?.version ?? existingPlugin.version,
658
+ source: isLocalPath ? "local_path" : "npm",
659
+ });
660
+ publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: existingPlugin.id, action: "installed" } });
661
+ res.json(updated);
662
+ } else {
663
+ // This shouldn't happen since installPlugin already registers in the DB
664
+ res.status(500).json({ error: "Plugin installed but not found in registry" });
665
+ }
666
+ } catch (err) {
667
+ const message = err instanceof Error ? err.message : String(err);
668
+ res.status(400).json({ error: message });
669
+ }
670
+ });
671
+
672
+ // ===========================================================================
673
+ // UI Bridge proxy routes (getData / performAction)
674
+ // ===========================================================================
675
+
676
+ /** Request body for POST /api/plugins/:pluginId/bridge/data */
677
+ interface PluginBridgeDataRequest {
678
+ /** Plugin-defined data key (e.g. `"sync-health"`). */
679
+ key: string;
680
+ /** Optional company scope for authorizing company-context bridge calls. */
681
+ companyId?: string;
682
+ /** Optional context and query parameters from the UI. */
683
+ params?: Record<string, unknown>;
684
+ /** Optional host launcher/render metadata for the worker bridge call. */
685
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
686
+ }
687
+
688
+ /** Request body for POST /api/plugins/:pluginId/bridge/action */
689
+ interface PluginBridgeActionRequest {
690
+ /** Plugin-defined action key (e.g. `"resync"`). */
691
+ key: string;
692
+ /** Optional company scope for authorizing company-context bridge calls. */
693
+ companyId?: string;
694
+ /** Optional parameters from the UI. */
695
+ params?: Record<string, unknown>;
696
+ /** Optional host launcher/render metadata for the worker bridge call. */
697
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
698
+ }
699
+
700
+ /** Response envelope for bridge errors. */
701
+ interface PluginBridgeErrorResponse {
702
+ code: PluginBridgeErrorCode;
703
+ message: string;
704
+ details?: unknown;
705
+ }
706
+
707
+ /**
708
+ * Map a worker RPC error to a bridge-level error code.
709
+ *
710
+ * JsonRpcCallError carries numeric codes from the plugin RPC error code space.
711
+ * This helper maps them to the string error codes defined in PluginBridgeErrorCode.
712
+ *
713
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
714
+ */
715
+ function mapRpcErrorToBridgeError(err: unknown): PluginBridgeErrorResponse {
716
+ if (err instanceof JsonRpcCallError) {
717
+ switch (err.code) {
718
+ case PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE:
719
+ return {
720
+ code: "WORKER_UNAVAILABLE",
721
+ message: err.message,
722
+ details: err.data,
723
+ };
724
+ case PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED:
725
+ return {
726
+ code: "CAPABILITY_DENIED",
727
+ message: err.message,
728
+ details: err.data,
729
+ };
730
+ case PLUGIN_RPC_ERROR_CODES.TIMEOUT:
731
+ return {
732
+ code: "TIMEOUT",
733
+ message: err.message,
734
+ details: err.data,
735
+ };
736
+ case PLUGIN_RPC_ERROR_CODES.WORKER_ERROR:
737
+ return {
738
+ code: "WORKER_ERROR",
739
+ message: err.message,
740
+ details: err.data,
741
+ };
742
+ default:
743
+ return {
744
+ code: "UNKNOWN",
745
+ message: err.message,
746
+ details: err.data,
747
+ };
748
+ }
749
+ }
750
+
751
+ const message = err instanceof Error ? err.message : String(err);
752
+
753
+ // Worker not running — surface as WORKER_UNAVAILABLE
754
+ if (message.includes("not running") || message.includes("not registered")) {
755
+ return {
756
+ code: "WORKER_UNAVAILABLE",
757
+ message,
758
+ };
759
+ }
760
+
761
+ return {
762
+ code: "UNKNOWN",
763
+ message,
764
+ };
765
+ }
766
+
767
+ /**
768
+ * POST /api/plugins/:pluginId/bridge/data
769
+ *
770
+ * Proxy a `getData` call from the plugin UI to the plugin worker.
771
+ *
772
+ * This is the server-side half of the `usePluginData(key, params)` bridge hook.
773
+ * The frontend sends a POST with the data key and optional params; the host
774
+ * forwards the call to the worker via the `getData` RPC method and returns
775
+ * the result.
776
+ *
777
+ * Request body:
778
+ * - `key`: Plugin-defined data key (e.g. `"sync-health"`)
779
+ * - `params`: Optional query parameters forwarded to the worker handler
780
+ *
781
+ * Response: The raw result from the worker's `getData` handler
782
+ *
783
+ * Error response body follows the `PluginBridgeError` shape:
784
+ * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`
785
+ *
786
+ * Errors:
787
+ * - 400 if request validation fails
788
+ * - 404 if plugin not found
789
+ * - 501 if bridge deps are not configured
790
+ * - 502 if the worker is unavailable or returns an error
791
+ *
792
+ * @see PLUGIN_SPEC.md §13.8 — `getData`
793
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
794
+ */
795
+ router.post("/plugins/:pluginId/bridge/data", async (req, res) => {
796
+ assertBoard(req);
797
+
798
+ if (!bridgeDeps) {
799
+ res.status(501).json({ error: "Plugin bridge is not enabled" });
800
+ return;
801
+ }
802
+
803
+ const { pluginId } = req.params;
804
+
805
+ // Resolve plugin
806
+ const plugin = await resolvePlugin(registry, pluginId);
807
+ if (!plugin) {
808
+ res.status(404).json({ error: "Plugin not found" });
809
+ return;
810
+ }
811
+
812
+ // Validate plugin is in ready state
813
+ if (plugin.status !== "ready") {
814
+ const bridgeError: PluginBridgeErrorResponse = {
815
+ code: "WORKER_UNAVAILABLE",
816
+ message: `Plugin is not ready (current status: ${plugin.status})`,
817
+ };
818
+ res.status(502).json(bridgeError);
819
+ return;
820
+ }
821
+
822
+ // Validate request body
823
+ const body = req.body as PluginBridgeDataRequest | undefined;
824
+ if (!body || !body.key || typeof body.key !== "string") {
825
+ res.status(400).json({ error: '"key" is required and must be a string' });
826
+ return;
827
+ }
828
+
829
+ if (body.companyId) {
830
+ assertCompanyAccess(req, body.companyId);
831
+ }
832
+
833
+ try {
834
+ const result = await bridgeDeps.workerManager.call(
835
+ plugin.id,
836
+ "getData",
837
+ {
838
+ key: body.key,
839
+ params: body.params ?? {},
840
+ renderEnvironment: body.renderEnvironment ?? null,
841
+ },
842
+ );
843
+ res.json({ data: result });
844
+ } catch (err) {
845
+ const bridgeError = mapRpcErrorToBridgeError(err);
846
+ res.status(502).json(bridgeError);
847
+ }
848
+ });
849
+
850
+ /**
851
+ * POST /api/plugins/:pluginId/bridge/action
852
+ *
853
+ * Proxy a `performAction` call from the plugin UI to the plugin worker.
854
+ *
855
+ * This is the server-side half of the `usePluginAction(key)` bridge hook.
856
+ * The frontend sends a POST with the action key and optional params; the host
857
+ * forwards the call to the worker via the `performAction` RPC method and
858
+ * returns the result.
859
+ *
860
+ * Request body:
861
+ * - `key`: Plugin-defined action key (e.g. `"resync"`)
862
+ * - `params`: Optional parameters forwarded to the worker handler
863
+ *
864
+ * Response: The raw result from the worker's `performAction` handler
865
+ *
866
+ * Error response body follows the `PluginBridgeError` shape:
867
+ * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`
868
+ *
869
+ * Errors:
870
+ * - 400 if request validation fails
871
+ * - 404 if plugin not found
872
+ * - 501 if bridge deps are not configured
873
+ * - 502 if the worker is unavailable or returns an error
874
+ *
875
+ * @see PLUGIN_SPEC.md §13.9 — `performAction`
876
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
877
+ */
878
+ router.post("/plugins/:pluginId/bridge/action", async (req, res) => {
879
+ assertBoard(req);
880
+
881
+ if (!bridgeDeps) {
882
+ res.status(501).json({ error: "Plugin bridge is not enabled" });
883
+ return;
884
+ }
885
+
886
+ const { pluginId } = req.params;
887
+
888
+ // Resolve plugin
889
+ const plugin = await resolvePlugin(registry, pluginId);
890
+ if (!plugin) {
891
+ res.status(404).json({ error: "Plugin not found" });
892
+ return;
893
+ }
894
+
895
+ // Validate plugin is in ready state
896
+ if (plugin.status !== "ready") {
897
+ const bridgeError: PluginBridgeErrorResponse = {
898
+ code: "WORKER_UNAVAILABLE",
899
+ message: `Plugin is not ready (current status: ${plugin.status})`,
900
+ };
901
+ res.status(502).json(bridgeError);
902
+ return;
903
+ }
904
+
905
+ // Validate request body
906
+ const body = req.body as PluginBridgeActionRequest | undefined;
907
+ if (!body || !body.key || typeof body.key !== "string") {
908
+ res.status(400).json({ error: '"key" is required and must be a string' });
909
+ return;
910
+ }
911
+
912
+ if (body.companyId) {
913
+ assertCompanyAccess(req, body.companyId);
914
+ }
915
+
916
+ try {
917
+ const result = await bridgeDeps.workerManager.call(
918
+ plugin.id,
919
+ "performAction",
920
+ {
921
+ key: body.key,
922
+ params: body.params ?? {},
923
+ renderEnvironment: body.renderEnvironment ?? null,
924
+ },
925
+ );
926
+ res.json({ data: result });
927
+ } catch (err) {
928
+ const bridgeError = mapRpcErrorToBridgeError(err);
929
+ res.status(502).json(bridgeError);
930
+ }
931
+ });
932
+
933
+ // ===========================================================================
934
+ // URL-keyed bridge routes (key as path parameter)
935
+ // ===========================================================================
936
+
937
+ /**
938
+ * POST /api/plugins/:pluginId/data/:key
939
+ *
940
+ * Proxy a `getData` call from the plugin UI to the plugin worker, with the
941
+ * data key specified as a URL path parameter instead of in the request body.
942
+ *
943
+ * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/data`.
944
+ * The frontend bridge hooks use this endpoint for cleaner URLs.
945
+ *
946
+ * Request body (optional):
947
+ * - `params`: Optional query parameters forwarded to the worker handler
948
+ *
949
+ * Response: The raw result from the worker's `getData` handler wrapped as `{ data: T }`
950
+ *
951
+ * Error response body follows the `PluginBridgeError` shape:
952
+ * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`
953
+ *
954
+ * Errors:
955
+ * - 404 if plugin not found
956
+ * - 501 if bridge deps are not configured
957
+ * - 502 if the worker is unavailable or returns an error
958
+ *
959
+ * @see PLUGIN_SPEC.md §13.8 — `getData`
960
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
961
+ */
962
+ router.post("/plugins/:pluginId/data/:key", async (req, res) => {
963
+ assertBoard(req);
964
+
965
+ if (!bridgeDeps) {
966
+ res.status(501).json({ error: "Plugin bridge is not enabled" });
967
+ return;
968
+ }
969
+
970
+ const { pluginId, key } = req.params;
971
+
972
+ // Resolve plugin
973
+ const plugin = await resolvePlugin(registry, pluginId);
974
+ if (!plugin) {
975
+ res.status(404).json({ error: "Plugin not found" });
976
+ return;
977
+ }
978
+
979
+ // Validate plugin is in ready state
980
+ if (plugin.status !== "ready") {
981
+ const bridgeError: PluginBridgeErrorResponse = {
982
+ code: "WORKER_UNAVAILABLE",
983
+ message: `Plugin is not ready (current status: ${plugin.status})`,
984
+ };
985
+ res.status(502).json(bridgeError);
986
+ return;
987
+ }
988
+
989
+ const body = req.body as {
990
+ companyId?: string;
991
+ params?: Record<string, unknown>;
992
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
993
+ } | undefined;
994
+
995
+ if (body?.companyId) {
996
+ assertCompanyAccess(req, body.companyId);
997
+ }
998
+
999
+ try {
1000
+ const result = await bridgeDeps.workerManager.call(
1001
+ plugin.id,
1002
+ "getData",
1003
+ {
1004
+ key,
1005
+ params: body?.params ?? {},
1006
+ renderEnvironment: body?.renderEnvironment ?? null,
1007
+ },
1008
+ );
1009
+ res.json({ data: result });
1010
+ } catch (err) {
1011
+ const bridgeError = mapRpcErrorToBridgeError(err);
1012
+ res.status(502).json(bridgeError);
1013
+ }
1014
+ });
1015
+
1016
+ /**
1017
+ * POST /api/plugins/:pluginId/actions/:key
1018
+ *
1019
+ * Proxy a `performAction` call from the plugin UI to the plugin worker, with
1020
+ * the action key specified as a URL path parameter instead of in the request body.
1021
+ *
1022
+ * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/action`.
1023
+ * The frontend bridge hooks use this endpoint for cleaner URLs.
1024
+ *
1025
+ * Request body (optional):
1026
+ * - `params`: Optional parameters forwarded to the worker handler
1027
+ *
1028
+ * Response: The raw result from the worker's `performAction` handler wrapped as `{ data: T }`
1029
+ *
1030
+ * Error response body follows the `PluginBridgeError` shape:
1031
+ * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }`
1032
+ *
1033
+ * Errors:
1034
+ * - 404 if plugin not found
1035
+ * - 501 if bridge deps are not configured
1036
+ * - 502 if the worker is unavailable or returns an error
1037
+ *
1038
+ * @see PLUGIN_SPEC.md §13.9 — `performAction`
1039
+ * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
1040
+ */
1041
+ router.post("/plugins/:pluginId/actions/:key", async (req, res) => {
1042
+ assertBoard(req);
1043
+
1044
+ if (!bridgeDeps) {
1045
+ res.status(501).json({ error: "Plugin bridge is not enabled" });
1046
+ return;
1047
+ }
1048
+
1049
+ const { pluginId, key } = req.params;
1050
+
1051
+ // Resolve plugin
1052
+ const plugin = await resolvePlugin(registry, pluginId);
1053
+ if (!plugin) {
1054
+ res.status(404).json({ error: "Plugin not found" });
1055
+ return;
1056
+ }
1057
+
1058
+ // Validate plugin is in ready state
1059
+ if (plugin.status !== "ready") {
1060
+ const bridgeError: PluginBridgeErrorResponse = {
1061
+ code: "WORKER_UNAVAILABLE",
1062
+ message: `Plugin is not ready (current status: ${plugin.status})`,
1063
+ };
1064
+ res.status(502).json(bridgeError);
1065
+ return;
1066
+ }
1067
+
1068
+ const body = req.body as {
1069
+ companyId?: string;
1070
+ params?: Record<string, unknown>;
1071
+ renderEnvironment?: PluginLauncherRenderContextSnapshot | null;
1072
+ } | undefined;
1073
+
1074
+ if (body?.companyId) {
1075
+ assertCompanyAccess(req, body.companyId);
1076
+ }
1077
+
1078
+ try {
1079
+ const result = await bridgeDeps.workerManager.call(
1080
+ plugin.id,
1081
+ "performAction",
1082
+ {
1083
+ key,
1084
+ params: body?.params ?? {},
1085
+ renderEnvironment: body?.renderEnvironment ?? null,
1086
+ },
1087
+ );
1088
+ res.json({ data: result });
1089
+ } catch (err) {
1090
+ const bridgeError = mapRpcErrorToBridgeError(err);
1091
+ res.status(502).json(bridgeError);
1092
+ }
1093
+ });
1094
+
1095
+ // ===========================================================================
1096
+ // SSE stream bridge route
1097
+ // ===========================================================================
1098
+
1099
+ /**
1100
+ * GET /api/plugins/:pluginId/bridge/stream/:channel
1101
+ *
1102
+ * Server-Sent Events endpoint for real-time streaming from plugin worker to UI.
1103
+ *
1104
+ * The worker pushes events via `ctx.streams.emit(channel, event)` which arrive
1105
+ * as JSON-RPC notifications to the host, get published on the PluginStreamBus,
1106
+ * and are fanned out to all connected SSE clients matching (pluginId, channel,
1107
+ * companyId).
1108
+ *
1109
+ * Query parameters:
1110
+ * - `companyId` (required): Scope events to a specific company
1111
+ *
1112
+ * SSE event types:
1113
+ * - `message`: A data event from the worker (default)
1114
+ * - `open`: The worker opened the stream channel
1115
+ * - `close`: The worker closed the stream channel — client should disconnect
1116
+ *
1117
+ * Errors:
1118
+ * - 400 if companyId is missing
1119
+ * - 404 if plugin not found
1120
+ * - 501 if bridge deps or stream bus are not configured
1121
+ */
1122
+ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => {
1123
+ assertBoard(req);
1124
+
1125
+ if (!bridgeDeps?.streamBus) {
1126
+ res.status(501).json({ error: "Plugin stream bridge is not enabled" });
1127
+ return;
1128
+ }
1129
+
1130
+ const { pluginId, channel } = req.params;
1131
+ const companyId = req.query.companyId as string | undefined;
1132
+
1133
+ if (!companyId) {
1134
+ res.status(400).json({ error: '"companyId" query parameter is required' });
1135
+ return;
1136
+ }
1137
+
1138
+ const plugin = await resolvePlugin(registry, pluginId);
1139
+ if (!plugin) {
1140
+ res.status(404).json({ error: "Plugin not found" });
1141
+ return;
1142
+ }
1143
+
1144
+ assertCompanyAccess(req, companyId);
1145
+
1146
+ // Set SSE headers
1147
+ res.writeHead(200, {
1148
+ "Content-Type": "text/event-stream",
1149
+ "Cache-Control": "no-cache",
1150
+ "Connection": "keep-alive",
1151
+ "X-Accel-Buffering": "no",
1152
+ });
1153
+ res.flushHeaders();
1154
+
1155
+ // Send initial comment to establish the connection
1156
+ res.write(":ok\n\n");
1157
+
1158
+ let unsubscribed = false;
1159
+ const safeUnsubscribe = () => {
1160
+ if (!unsubscribed) {
1161
+ unsubscribed = true;
1162
+ unsubscribe();
1163
+ }
1164
+ };
1165
+
1166
+ const unsubscribe = bridgeDeps.streamBus.subscribe(
1167
+ plugin.id,
1168
+ channel,
1169
+ companyId,
1170
+ (event, eventType) => {
1171
+ if (unsubscribed || !res.writable) return;
1172
+ try {
1173
+ if (eventType !== "message") {
1174
+ res.write(`event: ${eventType}\n`);
1175
+ }
1176
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
1177
+ } catch {
1178
+ // Connection closed or write error — stop delivering
1179
+ safeUnsubscribe();
1180
+ }
1181
+ },
1182
+ );
1183
+
1184
+ req.on("close", safeUnsubscribe);
1185
+ res.on("error", safeUnsubscribe);
1186
+ });
1187
+
1188
+ /**
1189
+ * GET /api/plugins/:pluginId
1190
+ *
1191
+ * Get detailed information about a single plugin.
1192
+ *
1193
+ * The :pluginId parameter accepts either:
1194
+ * - Database UUID (e.g., "abc123-def456")
1195
+ * - Plugin key (e.g., "acme.linear")
1196
+ *
1197
+ * Response: PluginRecord
1198
+ * Errors: 404 if plugin not found
1199
+ */
1200
+ router.get("/plugins/:pluginId", async (req, res) => {
1201
+ assertBoard(req);
1202
+ const { pluginId } = req.params;
1203
+ const plugin = await resolvePlugin(registry, pluginId);
1204
+ if (!plugin) {
1205
+ res.status(404).json({ error: "Plugin not found" });
1206
+ return;
1207
+ }
1208
+
1209
+ // Enrich with worker capabilities when available
1210
+ const worker = bridgeDeps?.workerManager.getWorker(plugin.id);
1211
+ const supportsConfigTest = worker
1212
+ ? worker.supportedMethods.includes("validateConfig")
1213
+ : false;
1214
+
1215
+ res.json({ ...plugin, supportsConfigTest });
1216
+ });
1217
+
1218
+ /**
1219
+ * DELETE /api/plugins/:pluginId
1220
+ *
1221
+ * Uninstall a plugin.
1222
+ *
1223
+ * Query params:
1224
+ * - purge: If "true", permanently delete all plugin data (hard delete)
1225
+ * Otherwise, soft-delete with 30-day data retention
1226
+ *
1227
+ * Response: PluginRecord (the deleted record)
1228
+ * Errors: 404 if plugin not found, 400 for lifecycle errors
1229
+ */
1230
+ router.delete("/plugins/:pluginId", async (req, res) => {
1231
+ assertBoard(req);
1232
+ const { pluginId } = req.params;
1233
+ const purge = req.query.purge === "true";
1234
+
1235
+ const plugin = await resolvePlugin(registry, pluginId);
1236
+ if (!plugin) {
1237
+ res.status(404).json({ error: "Plugin not found" });
1238
+ return;
1239
+ }
1240
+
1241
+ try {
1242
+ const result = await lifecycle.unload(plugin.id, purge);
1243
+ await logPluginMutationActivity(req, "plugin.uninstalled", plugin.id, {
1244
+ pluginId: plugin.id,
1245
+ pluginKey: plugin.pluginKey,
1246
+ purge,
1247
+ });
1248
+ publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "uninstalled" } });
1249
+ res.json(result);
1250
+ } catch (err) {
1251
+ const message = err instanceof Error ? err.message : String(err);
1252
+ res.status(400).json({ error: message });
1253
+ }
1254
+ });
1255
+
1256
+ /**
1257
+ * POST /api/plugins/:pluginId/enable
1258
+ *
1259
+ * Enable a plugin that is currently disabled or in error state.
1260
+ *
1261
+ * Transitions the plugin to 'ready' state after loading and validation.
1262
+ *
1263
+ * Response: PluginRecord
1264
+ * Errors: 404 if plugin not found, 400 for lifecycle errors
1265
+ */
1266
+ router.post("/plugins/:pluginId/enable", async (req, res) => {
1267
+ assertBoard(req);
1268
+ const { pluginId } = req.params;
1269
+
1270
+ const plugin = await resolvePlugin(registry, pluginId);
1271
+ if (!plugin) {
1272
+ res.status(404).json({ error: "Plugin not found" });
1273
+ return;
1274
+ }
1275
+
1276
+ try {
1277
+ const result = await lifecycle.enable(plugin.id);
1278
+ await logPluginMutationActivity(req, "plugin.enabled", plugin.id, {
1279
+ pluginId: plugin.id,
1280
+ pluginKey: plugin.pluginKey,
1281
+ version: result?.version ?? plugin.version,
1282
+ });
1283
+ publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "enabled" } });
1284
+ res.json(result);
1285
+ } catch (err) {
1286
+ const message = err instanceof Error ? err.message : String(err);
1287
+ res.status(400).json({ error: message });
1288
+ }
1289
+ });
1290
+
1291
+ /**
1292
+ * POST /api/plugins/:pluginId/disable
1293
+ *
1294
+ * Disable a running plugin.
1295
+ *
1296
+ * Request body (optional):
1297
+ * - reason: Human-readable reason for disabling
1298
+ *
1299
+ * The plugin transitions to 'installed' state and stops processing events.
1300
+ *
1301
+ * Response: PluginRecord
1302
+ * Errors: 404 if plugin not found, 400 for lifecycle errors
1303
+ */
1304
+ router.post("/plugins/:pluginId/disable", async (req, res) => {
1305
+ assertBoard(req);
1306
+ const { pluginId } = req.params;
1307
+ const body = req.body as { reason?: string } | undefined;
1308
+ const reason = body?.reason;
1309
+
1310
+ const plugin = await resolvePlugin(registry, pluginId);
1311
+ if (!plugin) {
1312
+ res.status(404).json({ error: "Plugin not found" });
1313
+ return;
1314
+ }
1315
+
1316
+ try {
1317
+ const result = await lifecycle.disable(plugin.id, reason);
1318
+ await logPluginMutationActivity(req, "plugin.disabled", plugin.id, {
1319
+ pluginId: plugin.id,
1320
+ pluginKey: plugin.pluginKey,
1321
+ reason: reason ?? null,
1322
+ });
1323
+ publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "disabled" } });
1324
+ res.json(result);
1325
+ } catch (err) {
1326
+ const message = err instanceof Error ? err.message : String(err);
1327
+ res.status(400).json({ error: message });
1328
+ }
1329
+ });
1330
+
1331
+ /**
1332
+ * GET /api/plugins/:pluginId/health
1333
+ *
1334
+ * Run health diagnostics on a plugin.
1335
+ *
1336
+ * Performs the following checks:
1337
+ * 1. Registry: Plugin is registered in the database
1338
+ * 2. Manifest: Manifest is valid and parseable
1339
+ * 3. Status: Plugin is in 'ready' state
1340
+ * 4. Error state: Plugin has no unhandled errors
1341
+ *
1342
+ * Response: PluginHealthCheckResult
1343
+ * Errors: 404 if plugin not found
1344
+ */
1345
+ router.get("/plugins/:pluginId/health", async (req, res) => {
1346
+ assertBoard(req);
1347
+ const { pluginId } = req.params;
1348
+
1349
+ const plugin = await resolvePlugin(registry, pluginId);
1350
+ if (!plugin) {
1351
+ res.status(404).json({ error: "Plugin not found" });
1352
+ return;
1353
+ }
1354
+
1355
+ const checks: PluginHealthCheckResult["checks"] = [];
1356
+
1357
+ // Check 1: Plugin is registered
1358
+ checks.push({
1359
+ name: "registry",
1360
+ passed: true,
1361
+ message: "Plugin found in registry",
1362
+ });
1363
+
1364
+ // Check 2: Manifest is valid
1365
+ const hasValidManifest = Boolean(plugin.manifestJson?.id);
1366
+ checks.push({
1367
+ name: "manifest",
1368
+ passed: hasValidManifest,
1369
+ message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing",
1370
+ });
1371
+
1372
+ // Check 3: Plugin status
1373
+ const isHealthy = plugin.status === "ready";
1374
+ checks.push({
1375
+ name: "status",
1376
+ passed: isHealthy,
1377
+ message: `Current status: ${plugin.status}`,
1378
+ });
1379
+
1380
+ // Check 4: No last error
1381
+ const hasNoError = !plugin.lastError;
1382
+ if (!hasNoError) {
1383
+ checks.push({
1384
+ name: "error_state",
1385
+ passed: false,
1386
+ message: plugin.lastError ?? undefined,
1387
+ });
1388
+ }
1389
+
1390
+ const result: PluginHealthCheckResult = {
1391
+ pluginId: plugin.id,
1392
+ status: plugin.status,
1393
+ healthy: isHealthy && hasValidManifest && hasNoError,
1394
+ checks,
1395
+ lastError: plugin.lastError ?? undefined,
1396
+ };
1397
+
1398
+ res.json(result);
1399
+ });
1400
+
1401
+ /**
1402
+ * GET /api/plugins/:pluginId/logs
1403
+ *
1404
+ * Query recent log entries for a plugin.
1405
+ *
1406
+ * Query params:
1407
+ * - limit: Maximum number of entries (default 25, max 500)
1408
+ * - level: Filter by log level (info, warn, error, debug)
1409
+ * - since: ISO timestamp to filter logs newer than this time
1410
+ *
1411
+ * Response: Array of log entries, newest first.
1412
+ */
1413
+ router.get("/plugins/:pluginId/logs", async (req, res) => {
1414
+ assertBoard(req);
1415
+ const { pluginId } = req.params;
1416
+
1417
+ const plugin = await resolvePlugin(registry, pluginId);
1418
+ if (!plugin) {
1419
+ res.status(404).json({ error: "Plugin not found" });
1420
+ return;
1421
+ }
1422
+
1423
+ const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500);
1424
+ const level = req.query.level as string | undefined;
1425
+ const since = req.query.since as string | undefined;
1426
+
1427
+ const conditions = [eq(pluginLogs.pluginId, plugin.id)];
1428
+ if (level) {
1429
+ conditions.push(eq(pluginLogs.level, level));
1430
+ }
1431
+ if (since) {
1432
+ const sinceDate = new Date(since);
1433
+ if (!isNaN(sinceDate.getTime())) {
1434
+ conditions.push(gte(pluginLogs.createdAt, sinceDate));
1435
+ }
1436
+ }
1437
+
1438
+ const rows = await db
1439
+ .select()
1440
+ .from(pluginLogs)
1441
+ .where(and(...conditions))
1442
+ .orderBy(desc(pluginLogs.createdAt))
1443
+ .limit(limit);
1444
+
1445
+ res.json(rows);
1446
+ });
1447
+
1448
+ /**
1449
+ * POST /api/plugins/:pluginId/upgrade
1450
+ *
1451
+ * Upgrade a plugin to a newer version.
1452
+ *
1453
+ * Request body (optional):
1454
+ * - version: Target version (defaults to latest)
1455
+ *
1456
+ * If the upgrade adds new capabilities, the plugin transitions to
1457
+ * 'upgrade_pending' state for board approval. Otherwise, it goes
1458
+ * directly to 'ready'.
1459
+ *
1460
+ * Response: PluginRecord
1461
+ * Errors: 404 if plugin not found, 400 for lifecycle errors
1462
+ */
1463
+ router.post("/plugins/:pluginId/upgrade", async (req, res) => {
1464
+ assertBoard(req);
1465
+ const { pluginId } = req.params;
1466
+ const body = req.body as { version?: string } | undefined;
1467
+ const version = body?.version;
1468
+
1469
+ const plugin = await resolvePlugin(registry, pluginId);
1470
+ if (!plugin) {
1471
+ res.status(404).json({ error: "Plugin not found" });
1472
+ return;
1473
+ }
1474
+
1475
+ try {
1476
+ // Upgrade the plugin - this would typically:
1477
+ // 1. Download the new version
1478
+ // 2. Compare capabilities
1479
+ // 3. If new capabilities, mark as upgrade_pending
1480
+ // 4. Otherwise, transition to ready
1481
+ const result = await lifecycle.upgrade(plugin.id, version);
1482
+ await logPluginMutationActivity(req, "plugin.upgraded", plugin.id, {
1483
+ pluginId: plugin.id,
1484
+ pluginKey: plugin.pluginKey,
1485
+ previousVersion: plugin.version,
1486
+ version: result?.version ?? plugin.version,
1487
+ targetVersion: version ?? null,
1488
+ });
1489
+ publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "upgraded" } });
1490
+ res.json(result);
1491
+ } catch (err) {
1492
+ const message = err instanceof Error ? err.message : String(err);
1493
+ res.status(400).json({ error: message });
1494
+ }
1495
+ });
1496
+
1497
+ // ===========================================================================
1498
+ // Plugin configuration routes
1499
+ // ===========================================================================
1500
+
1501
+ /**
1502
+ * GET /api/plugins/:pluginId/config
1503
+ *
1504
+ * Retrieve the current instance configuration for a plugin.
1505
+ *
1506
+ * Returns the `PluginConfig` record if one exists, or `null` if the plugin
1507
+ * has not yet been configured.
1508
+ *
1509
+ * Response: `PluginConfig | null`
1510
+ * Errors: 404 if plugin not found
1511
+ */
1512
+ router.get("/plugins/:pluginId/config", async (req, res) => {
1513
+ assertBoard(req);
1514
+ const { pluginId } = req.params;
1515
+
1516
+ const plugin = await resolvePlugin(registry, pluginId);
1517
+ if (!plugin) {
1518
+ res.status(404).json({ error: "Plugin not found" });
1519
+ return;
1520
+ }
1521
+
1522
+ const config = await registry.getConfig(plugin.id);
1523
+ res.json(config);
1524
+ });
1525
+
1526
+ /**
1527
+ * POST /api/plugins/:pluginId/config
1528
+ *
1529
+ * Save (create or replace) the instance configuration for a plugin.
1530
+ *
1531
+ * The caller provides the full `configJson` object. The server persists it
1532
+ * via `registry.upsertConfig()`.
1533
+ *
1534
+ * Request body:
1535
+ * - `configJson`: Configuration values matching the plugin's `instanceConfigSchema`
1536
+ *
1537
+ * Response: `PluginConfig`
1538
+ * Errors:
1539
+ * - 400 if request validation fails
1540
+ * - 404 if plugin not found
1541
+ */
1542
+ router.post("/plugins/:pluginId/config", async (req, res) => {
1543
+ assertBoard(req);
1544
+ const { pluginId } = req.params;
1545
+
1546
+ const plugin = await resolvePlugin(registry, pluginId);
1547
+ if (!plugin) {
1548
+ res.status(404).json({ error: "Plugin not found" });
1549
+ return;
1550
+ }
1551
+
1552
+ const body = req.body as { configJson?: Record<string, unknown> } | undefined;
1553
+ if (!body?.configJson || typeof body.configJson !== "object") {
1554
+ res.status(400).json({ error: '"configJson" is required and must be an object' });
1555
+ return;
1556
+ }
1557
+
1558
+ // Strip devUiUrl unless the caller is an instance admin. devUiUrl activates
1559
+ // a dev-proxy in the static file route that could be abused for SSRF if any
1560
+ // board-level user were allowed to set it.
1561
+ if (
1562
+ "devUiUrl" in body.configJson &&
1563
+ !(req.actor.type === "board" && req.actor.isInstanceAdmin)
1564
+ ) {
1565
+ delete body.configJson.devUiUrl;
1566
+ }
1567
+
1568
+ // Validate configJson against the plugin's instanceConfigSchema (if declared).
1569
+ // This ensures CLI/API callers get the same validation the UI performs client-side.
1570
+ const schema = plugin.manifestJson?.instanceConfigSchema;
1571
+ if (schema && Object.keys(schema).length > 0) {
1572
+ const validation = validateInstanceConfig(body.configJson, schema);
1573
+ if (!validation.valid) {
1574
+ res.status(400).json({
1575
+ error: "Configuration does not match the plugin's instanceConfigSchema",
1576
+ fieldErrors: validation.errors,
1577
+ });
1578
+ return;
1579
+ }
1580
+ }
1581
+
1582
+ try {
1583
+ const result = await registry.upsertConfig(plugin.id, {
1584
+ configJson: body.configJson,
1585
+ });
1586
+ await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, {
1587
+ pluginId: plugin.id,
1588
+ pluginKey: plugin.pluginKey,
1589
+ configKeyCount: Object.keys(body.configJson).length,
1590
+ });
1591
+
1592
+ // Notify the running worker about the config change (PLUGIN_SPEC §25.4.4).
1593
+ // If the worker implements onConfigChanged, send the new config via RPC.
1594
+ // If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks
1595
+ // up the new config on re-initialize. If no worker is running, skip.
1596
+ if (bridgeDeps?.workerManager.isRunning(plugin.id)) {
1597
+ try {
1598
+ await bridgeDeps.workerManager.call(
1599
+ plugin.id,
1600
+ "configChanged",
1601
+ { config: body.configJson },
1602
+ );
1603
+ } catch (rpcErr) {
1604
+ if (
1605
+ rpcErr instanceof JsonRpcCallError &&
1606
+ rpcErr.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
1607
+ ) {
1608
+ // Worker doesn't handle live config — restart it.
1609
+ try {
1610
+ await lifecycle.restartWorker(plugin.id);
1611
+ } catch {
1612
+ // Restart failure is non-fatal for the config save response.
1613
+ }
1614
+ }
1615
+ // Other RPC errors (timeout, unavailable) are non-fatal — config is
1616
+ // already persisted and will take effect on next worker restart.
1617
+ }
1618
+ }
1619
+
1620
+ res.json(result);
1621
+ } catch (err) {
1622
+ const message = err instanceof Error ? err.message : String(err);
1623
+ res.status(400).json({ error: message });
1624
+ }
1625
+ });
1626
+
1627
+ /**
1628
+ * POST /api/plugins/:pluginId/config/test
1629
+ *
1630
+ * Test a plugin configuration without persisting it by calling the plugin
1631
+ * worker's `validateConfig` RPC method.
1632
+ *
1633
+ * Only works when the plugin's worker implements `onValidateConfig`.
1634
+ * If the worker does not implement the method, returns
1635
+ * `{ valid: false, supported: false, message: "..." }` with HTTP 200.
1636
+ *
1637
+ * Request body:
1638
+ * - `configJson`: Configuration values to validate
1639
+ *
1640
+ * Response: `{ valid: boolean; message?: string; supported?: boolean }`
1641
+ * Errors:
1642
+ * - 400 if request validation fails
1643
+ * - 404 if plugin not found
1644
+ * - 501 if bridge deps (worker manager) are not configured
1645
+ * - 502 if the worker is unavailable
1646
+ */
1647
+ router.post("/plugins/:pluginId/config/test", async (req, res) => {
1648
+ assertBoard(req);
1649
+
1650
+ if (!bridgeDeps) {
1651
+ res.status(501).json({ error: "Plugin bridge is not enabled" });
1652
+ return;
1653
+ }
1654
+
1655
+ const { pluginId } = req.params;
1656
+
1657
+ const plugin = await resolvePlugin(registry, pluginId);
1658
+ if (!plugin) {
1659
+ res.status(404).json({ error: "Plugin not found" });
1660
+ return;
1661
+ }
1662
+
1663
+ if (plugin.status !== "ready") {
1664
+ res.status(400).json({
1665
+ error: `Plugin is not ready (current status: ${plugin.status})`,
1666
+ });
1667
+ return;
1668
+ }
1669
+
1670
+ const body = req.body as { configJson?: Record<string, unknown> } | undefined;
1671
+ if (!body?.configJson || typeof body.configJson !== "object") {
1672
+ res.status(400).json({ error: '"configJson" is required and must be an object' });
1673
+ return;
1674
+ }
1675
+
1676
+ // Fast schema-level rejection before hitting the worker RPC.
1677
+ const schema = plugin.manifestJson?.instanceConfigSchema;
1678
+ if (schema && Object.keys(schema).length > 0) {
1679
+ const validation = validateInstanceConfig(body.configJson, schema);
1680
+ if (!validation.valid) {
1681
+ res.status(400).json({
1682
+ error: "Configuration does not match the plugin's instanceConfigSchema",
1683
+ fieldErrors: validation.errors,
1684
+ });
1685
+ return;
1686
+ }
1687
+ }
1688
+
1689
+ try {
1690
+ const result = await bridgeDeps.workerManager.call(
1691
+ plugin.id,
1692
+ "validateConfig",
1693
+ { config: body.configJson },
1694
+ );
1695
+
1696
+ // The worker returns PluginConfigValidationResult { ok, warnings?, errors? }
1697
+ // Map to the frontend-expected shape { valid, message? }
1698
+ if (result.ok) {
1699
+ const warningText = result.warnings?.length
1700
+ ? `Warnings: ${result.warnings.join("; ")}`
1701
+ : undefined;
1702
+ res.json({ valid: true, message: warningText });
1703
+ } else {
1704
+ const errorText = result.errors?.length
1705
+ ? result.errors.join("; ")
1706
+ : "Configuration validation failed.";
1707
+ res.json({ valid: false, message: errorText });
1708
+ }
1709
+ } catch (err) {
1710
+ // If the worker does not implement validateConfig, return a structured response
1711
+ if (
1712
+ err instanceof JsonRpcCallError &&
1713
+ err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED
1714
+ ) {
1715
+ res.json({
1716
+ valid: false,
1717
+ supported: false,
1718
+ message: "This plugin does not support configuration testing.",
1719
+ });
1720
+ return;
1721
+ }
1722
+
1723
+ // Worker unavailable or other RPC errors
1724
+ const bridgeError = mapRpcErrorToBridgeError(err);
1725
+ res.status(502).json(bridgeError);
1726
+ }
1727
+ });
1728
+
1729
+ // ===========================================================================
1730
+ // Job scheduling routes
1731
+ // ===========================================================================
1732
+
1733
+ /**
1734
+ * GET /api/plugins/:pluginId/jobs
1735
+ *
1736
+ * List all scheduled jobs for a plugin.
1737
+ *
1738
+ * Query params:
1739
+ * - `status` (optional): Filter by job status (`active`, `paused`, `failed`)
1740
+ *
1741
+ * Response: PluginJobRecord[]
1742
+ * Errors: 404 if plugin not found
1743
+ */
1744
+ router.get("/plugins/:pluginId/jobs", async (req, res) => {
1745
+ assertBoard(req);
1746
+ if (!jobDeps) {
1747
+ res.status(501).json({ error: "Job scheduling is not enabled" });
1748
+ return;
1749
+ }
1750
+
1751
+ const { pluginId } = req.params;
1752
+ const plugin = await resolvePlugin(registry, pluginId);
1753
+ if (!plugin) {
1754
+ res.status(404).json({ error: "Plugin not found" });
1755
+ return;
1756
+ }
1757
+
1758
+ const rawStatus = req.query.status as string | undefined;
1759
+ const validStatuses = ["active", "paused", "failed"];
1760
+ if (rawStatus !== undefined && !validStatuses.includes(rawStatus)) {
1761
+ res.status(400).json({
1762
+ error: `Invalid status '${rawStatus}'. Must be one of: ${validStatuses.join(", ")}`,
1763
+ });
1764
+ return;
1765
+ }
1766
+
1767
+ try {
1768
+ const jobs = await jobDeps.jobStore.listJobs(
1769
+ plugin.id,
1770
+ rawStatus as "active" | "paused" | "failed" | undefined,
1771
+ );
1772
+ res.json(jobs);
1773
+ } catch (err) {
1774
+ const message = err instanceof Error ? err.message : String(err);
1775
+ res.status(500).json({ error: message });
1776
+ }
1777
+ });
1778
+
1779
+ /**
1780
+ * GET /api/plugins/:pluginId/jobs/:jobId/runs
1781
+ *
1782
+ * List execution history for a specific job.
1783
+ *
1784
+ * Query params:
1785
+ * - `limit` (optional): Maximum number of runs to return (default: 50)
1786
+ *
1787
+ * Response: PluginJobRunRecord[]
1788
+ * Errors: 404 if plugin not found
1789
+ */
1790
+ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => {
1791
+ assertBoard(req);
1792
+ if (!jobDeps) {
1793
+ res.status(501).json({ error: "Job scheduling is not enabled" });
1794
+ return;
1795
+ }
1796
+
1797
+ const { pluginId, jobId } = req.params;
1798
+ const plugin = await resolvePlugin(registry, pluginId);
1799
+ if (!plugin) {
1800
+ res.status(404).json({ error: "Plugin not found" });
1801
+ return;
1802
+ }
1803
+
1804
+ const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId);
1805
+ if (!job) {
1806
+ res.status(404).json({ error: "Job not found" });
1807
+ return;
1808
+ }
1809
+
1810
+ const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 25;
1811
+ if (isNaN(limit) || limit < 1 || limit > 500) {
1812
+ res.status(400).json({ error: "limit must be a number between 1 and 500" });
1813
+ return;
1814
+ }
1815
+
1816
+ try {
1817
+ const runs = await jobDeps.jobStore.listRunsByJob(jobId, limit);
1818
+ res.json(runs);
1819
+ } catch (err) {
1820
+ const message = err instanceof Error ? err.message : String(err);
1821
+ res.status(500).json({ error: message });
1822
+ }
1823
+ });
1824
+
1825
+ /**
1826
+ * POST /api/plugins/:pluginId/jobs/:jobId/trigger
1827
+ *
1828
+ * Manually trigger a job execution outside its cron schedule.
1829
+ *
1830
+ * Creates a run with `trigger: "manual"` and dispatches immediately.
1831
+ * The response returns before the job completes (non-blocking).
1832
+ *
1833
+ * Response: `{ runId: string, jobId: string }`
1834
+ * Errors:
1835
+ * - 404 if plugin not found
1836
+ * - 400 if job not found, not active, already running, or worker unavailable
1837
+ */
1838
+ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => {
1839
+ assertBoard(req);
1840
+ if (!jobDeps) {
1841
+ res.status(501).json({ error: "Job scheduling is not enabled" });
1842
+ return;
1843
+ }
1844
+
1845
+ const { pluginId, jobId } = req.params;
1846
+ const plugin = await resolvePlugin(registry, pluginId);
1847
+ if (!plugin) {
1848
+ res.status(404).json({ error: "Plugin not found" });
1849
+ return;
1850
+ }
1851
+
1852
+ const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId);
1853
+ if (!job) {
1854
+ res.status(404).json({ error: "Job not found" });
1855
+ return;
1856
+ }
1857
+
1858
+ try {
1859
+ const result = await jobDeps.scheduler.triggerJob(jobId, "manual");
1860
+ res.json(result);
1861
+ } catch (err) {
1862
+ const message = err instanceof Error ? err.message : String(err);
1863
+ res.status(400).json({ error: message });
1864
+ }
1865
+ });
1866
+
1867
+ // ===========================================================================
1868
+ // Webhook ingestion route
1869
+ // ===========================================================================
1870
+
1871
+ /**
1872
+ * POST /api/plugins/:pluginId/webhooks/:endpointKey
1873
+ *
1874
+ * Receive an inbound webhook delivery for a plugin.
1875
+ *
1876
+ * This route is called by external systems (e.g. GitHub, Linear, Stripe) to
1877
+ * deliver webhook payloads to a plugin. The host validates that:
1878
+ * 1. The plugin exists and is in 'ready' state
1879
+ * 2. The plugin declares the `webhooks.receive` capability
1880
+ * 3. The `endpointKey` matches a declared webhook in the manifest
1881
+ *
1882
+ * The delivery is recorded in the `plugin_webhook_deliveries` table and
1883
+ * dispatched to the worker via the `handleWebhook` RPC method.
1884
+ *
1885
+ * **Note:** This route does NOT require board authentication — webhook
1886
+ * endpoints must be publicly accessible for external callers. Signature
1887
+ * verification is the plugin's responsibility.
1888
+ *
1889
+ * Response: `{ deliveryId: string, status: string }`
1890
+ * Errors:
1891
+ * - 404 if plugin not found or endpointKey not declared
1892
+ * - 400 if plugin is not in ready state or lacks webhooks.receive capability
1893
+ * - 502 if the worker is unavailable or the RPC call fails
1894
+ */
1895
+ router.post("/plugins/:pluginId/webhooks/:endpointKey", async (req, res) => {
1896
+ if (!webhookDeps) {
1897
+ res.status(501).json({ error: "Webhook ingestion is not enabled" });
1898
+ return;
1899
+ }
1900
+
1901
+ const { pluginId, endpointKey } = req.params;
1902
+
1903
+ // Step 1: Resolve the plugin
1904
+ const plugin = await resolvePlugin(registry, pluginId);
1905
+ if (!plugin) {
1906
+ res.status(404).json({ error: "Plugin not found" });
1907
+ return;
1908
+ }
1909
+
1910
+ // Step 2: Validate the plugin is in 'ready' state
1911
+ if (plugin.status !== "ready") {
1912
+ res.status(400).json({
1913
+ error: `Plugin is not ready (current status: ${plugin.status})`,
1914
+ });
1915
+ return;
1916
+ }
1917
+
1918
+ // Step 3: Validate the plugin has webhooks.receive capability
1919
+ const manifest = plugin.manifestJson;
1920
+ if (!manifest) {
1921
+ res.status(400).json({ error: "Plugin manifest is missing" });
1922
+ return;
1923
+ }
1924
+
1925
+ const capabilities = manifest.capabilities ?? [];
1926
+ if (!capabilities.includes("webhooks.receive")) {
1927
+ res.status(400).json({
1928
+ error: "Plugin does not have the webhooks.receive capability",
1929
+ });
1930
+ return;
1931
+ }
1932
+
1933
+ // Step 4: Validate the endpointKey exists in the manifest's webhook declarations
1934
+ const declaredWebhooks = manifest.webhooks ?? [];
1935
+ const webhookDecl = declaredWebhooks.find(
1936
+ (w) => w.endpointKey === endpointKey,
1937
+ );
1938
+ if (!webhookDecl) {
1939
+ res.status(404).json({
1940
+ error: `Webhook endpoint '${endpointKey}' is not declared by this plugin`,
1941
+ });
1942
+ return;
1943
+ }
1944
+
1945
+ // Step 5: Extract request data
1946
+ const requestId = randomUUID();
1947
+ const rawHeaders: Record<string, string> = {};
1948
+ for (const [key, value] of Object.entries(req.headers)) {
1949
+ if (typeof value === "string") {
1950
+ rawHeaders[key] = value;
1951
+ } else if (Array.isArray(value)) {
1952
+ rawHeaders[key] = value.join(", ");
1953
+ }
1954
+ }
1955
+
1956
+ // Use the raw buffer stashed by the express.json() `verify` callback.
1957
+ // This preserves the exact bytes the provider signed, whereas
1958
+ // JSON.stringify(req.body) would re-serialize and break HMAC verification.
1959
+ const stashedRaw = (req as unknown as { rawBody?: Buffer }).rawBody;
1960
+ const rawBody = stashedRaw ? stashedRaw.toString("utf-8") : "";
1961
+ const parsedBody = req.body as unknown;
1962
+ const payload = (req.body as Record<string, unknown> | undefined) ?? {};
1963
+
1964
+ // Step 6: Record the delivery in the database
1965
+ const startedAt = new Date();
1966
+ const [delivery] = await db
1967
+ .insert(pluginWebhookDeliveries)
1968
+ .values({
1969
+ pluginId: plugin.id,
1970
+ webhookKey: endpointKey,
1971
+ status: "pending",
1972
+ payload,
1973
+ headers: rawHeaders,
1974
+ startedAt,
1975
+ })
1976
+ .returning({ id: pluginWebhookDeliveries.id });
1977
+
1978
+ // Step 7: Dispatch to the worker via handleWebhook RPC
1979
+ try {
1980
+ await webhookDeps.workerManager.call(plugin.id, "handleWebhook", {
1981
+ endpointKey,
1982
+ headers: req.headers as Record<string, string | string[]>,
1983
+ rawBody,
1984
+ parsedBody,
1985
+ requestId,
1986
+ });
1987
+
1988
+ // Step 8: Update delivery record to success
1989
+ const finishedAt = new Date();
1990
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
1991
+ await db
1992
+ .update(pluginWebhookDeliveries)
1993
+ .set({
1994
+ status: "success",
1995
+ durationMs,
1996
+ finishedAt,
1997
+ })
1998
+ .where(eq(pluginWebhookDeliveries.id, delivery.id));
1999
+
2000
+ res.status(200).json({
2001
+ deliveryId: delivery.id,
2002
+ status: "success",
2003
+ });
2004
+ } catch (err) {
2005
+ // Step 8 (error): Update delivery record to failed
2006
+ const finishedAt = new Date();
2007
+ const durationMs = finishedAt.getTime() - startedAt.getTime();
2008
+ const errorMessage = err instanceof Error ? err.message : String(err);
2009
+
2010
+ await db
2011
+ .update(pluginWebhookDeliveries)
2012
+ .set({
2013
+ status: "failed",
2014
+ durationMs,
2015
+ error: errorMessage,
2016
+ finishedAt,
2017
+ })
2018
+ .where(eq(pluginWebhookDeliveries.id, delivery.id));
2019
+
2020
+ res.status(502).json({
2021
+ deliveryId: delivery.id,
2022
+ status: "failed",
2023
+ error: errorMessage,
2024
+ });
2025
+ }
2026
+ });
2027
+
2028
+ // ===========================================================================
2029
+ // Plugin health dashboard — aggregated diagnostics for the settings page
2030
+ // ===========================================================================
2031
+
2032
+ /**
2033
+ * GET /api/plugins/:pluginId/dashboard
2034
+ *
2035
+ * Aggregated health dashboard data for a plugin's settings page.
2036
+ *
2037
+ * Returns worker diagnostics (status, uptime, crash history), recent job
2038
+ * runs, recent webhook deliveries, and the current health check result —
2039
+ * all in a single response to avoid multiple round-trips.
2040
+ *
2041
+ * Response: PluginDashboardData
2042
+ * Errors: 404 if plugin not found
2043
+ */
2044
+ router.get("/plugins/:pluginId/dashboard", async (req, res) => {
2045
+ assertBoard(req);
2046
+ const { pluginId } = req.params;
2047
+
2048
+ const plugin = await resolvePlugin(registry, pluginId);
2049
+ if (!plugin) {
2050
+ res.status(404).json({ error: "Plugin not found" });
2051
+ return;
2052
+ }
2053
+
2054
+ // --- Worker diagnostics ---
2055
+ let worker: {
2056
+ status: string;
2057
+ pid: number | null;
2058
+ uptime: number | null;
2059
+ consecutiveCrashes: number;
2060
+ totalCrashes: number;
2061
+ pendingRequests: number;
2062
+ lastCrashAt: number | null;
2063
+ nextRestartAt: number | null;
2064
+ } | null = null;
2065
+
2066
+ // Try bridgeDeps first (primary source for worker manager), fallback to webhookDeps
2067
+ const wm = bridgeDeps?.workerManager ?? webhookDeps?.workerManager ?? null;
2068
+ if (wm) {
2069
+ const handle = wm.getWorker(plugin.id);
2070
+ if (handle) {
2071
+ const diag = handle.diagnostics();
2072
+ worker = {
2073
+ status: diag.status,
2074
+ pid: diag.pid,
2075
+ uptime: diag.uptime,
2076
+ consecutiveCrashes: diag.consecutiveCrashes,
2077
+ totalCrashes: diag.totalCrashes,
2078
+ pendingRequests: diag.pendingRequests,
2079
+ lastCrashAt: diag.lastCrashAt,
2080
+ nextRestartAt: diag.nextRestartAt,
2081
+ };
2082
+ }
2083
+ }
2084
+
2085
+ // --- Recent job runs (last 10, newest first) ---
2086
+ let recentJobRuns: Array<{
2087
+ id: string;
2088
+ jobId: string;
2089
+ jobKey?: string;
2090
+ trigger: string;
2091
+ status: string;
2092
+ durationMs: number | null;
2093
+ error: string | null;
2094
+ startedAt: string | null;
2095
+ finishedAt: string | null;
2096
+ createdAt: string;
2097
+ }> = [];
2098
+
2099
+ if (jobDeps) {
2100
+ try {
2101
+ const runs = await jobDeps.jobStore.listRunsByPlugin(plugin.id, undefined, 10);
2102
+ // Also fetch job definitions so we can include jobKey
2103
+ const jobs = await jobDeps.jobStore.listJobs(plugin.id);
2104
+ const jobKeyMap = new Map(jobs.map((j) => [j.id, j.jobKey]));
2105
+
2106
+ recentJobRuns = runs
2107
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
2108
+ .map((r) => ({
2109
+ id: r.id,
2110
+ jobId: r.jobId,
2111
+ jobKey: jobKeyMap.get(r.jobId) ?? undefined,
2112
+ trigger: r.trigger,
2113
+ status: r.status,
2114
+ durationMs: r.durationMs,
2115
+ error: r.error,
2116
+ startedAt: r.startedAt ? new Date(r.startedAt).toISOString() : null,
2117
+ finishedAt: r.finishedAt ? new Date(r.finishedAt).toISOString() : null,
2118
+ createdAt: new Date(r.createdAt).toISOString(),
2119
+ }));
2120
+ } catch {
2121
+ // Job data unavailable — leave empty
2122
+ }
2123
+ }
2124
+
2125
+ // --- Recent webhook deliveries (last 10, newest first) ---
2126
+ let recentWebhookDeliveries: Array<{
2127
+ id: string;
2128
+ webhookKey: string;
2129
+ status: string;
2130
+ durationMs: number | null;
2131
+ error: string | null;
2132
+ startedAt: string | null;
2133
+ finishedAt: string | null;
2134
+ createdAt: string;
2135
+ }> = [];
2136
+
2137
+ try {
2138
+ const deliveries = await db
2139
+ .select({
2140
+ id: pluginWebhookDeliveries.id,
2141
+ webhookKey: pluginWebhookDeliveries.webhookKey,
2142
+ status: pluginWebhookDeliveries.status,
2143
+ durationMs: pluginWebhookDeliveries.durationMs,
2144
+ error: pluginWebhookDeliveries.error,
2145
+ startedAt: pluginWebhookDeliveries.startedAt,
2146
+ finishedAt: pluginWebhookDeliveries.finishedAt,
2147
+ createdAt: pluginWebhookDeliveries.createdAt,
2148
+ })
2149
+ .from(pluginWebhookDeliveries)
2150
+ .where(eq(pluginWebhookDeliveries.pluginId, plugin.id))
2151
+ .orderBy(desc(pluginWebhookDeliveries.createdAt))
2152
+ .limit(10);
2153
+
2154
+ recentWebhookDeliveries = deliveries.map((d) => ({
2155
+ id: d.id,
2156
+ webhookKey: d.webhookKey,
2157
+ status: d.status,
2158
+ durationMs: d.durationMs,
2159
+ error: d.error,
2160
+ startedAt: d.startedAt ? d.startedAt.toISOString() : null,
2161
+ finishedAt: d.finishedAt ? d.finishedAt.toISOString() : null,
2162
+ createdAt: d.createdAt.toISOString(),
2163
+ }));
2164
+ } catch {
2165
+ // Webhook data unavailable — leave empty
2166
+ }
2167
+
2168
+ // --- Health check (same logic as GET /health) ---
2169
+ const checks: PluginHealthCheckResult["checks"] = [];
2170
+
2171
+ checks.push({
2172
+ name: "registry",
2173
+ passed: true,
2174
+ message: "Plugin found in registry",
2175
+ });
2176
+
2177
+ const hasValidManifest = Boolean(plugin.manifestJson?.id);
2178
+ checks.push({
2179
+ name: "manifest",
2180
+ passed: hasValidManifest,
2181
+ message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing",
2182
+ });
2183
+
2184
+ const isHealthy = plugin.status === "ready";
2185
+ checks.push({
2186
+ name: "status",
2187
+ passed: isHealthy,
2188
+ message: `Current status: ${plugin.status}`,
2189
+ });
2190
+
2191
+ const hasNoError = !plugin.lastError;
2192
+ if (!hasNoError) {
2193
+ checks.push({
2194
+ name: "error_state",
2195
+ passed: false,
2196
+ message: plugin.lastError ?? undefined,
2197
+ });
2198
+ }
2199
+
2200
+ const health: PluginHealthCheckResult = {
2201
+ pluginId: plugin.id,
2202
+ status: plugin.status,
2203
+ healthy: isHealthy && hasValidManifest && hasNoError,
2204
+ checks,
2205
+ lastError: plugin.lastError ?? undefined,
2206
+ };
2207
+
2208
+ res.json({
2209
+ pluginId: plugin.id,
2210
+ worker,
2211
+ recentJobRuns,
2212
+ recentWebhookDeliveries,
2213
+ health,
2214
+ checkedAt: new Date().toISOString(),
2215
+ });
2216
+ });
2217
+
2218
+ return router;
2219
+ }
2220
+