@meith/web 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (511) hide show
  1. package/LICENSE.md +165 -0
  2. package/app/(auth)/layout.tsx +6 -0
  3. package/app/(auth)/login/page.tsx +106 -0
  4. package/app/(auth)/login/verify/page.tsx +55 -0
  5. package/app/(auth)/register/page.tsx +83 -0
  6. package/app/(auth)/reset/confirm/page.tsx +45 -0
  7. package/app/(auth)/reset/page.tsx +22 -0
  8. package/app/(auth)/verify/page.tsx +44 -0
  9. package/app/(auth)/verify/resend/page.tsx +51 -0
  10. package/app/(board)/[slug]/feed.xml/route.ts +48 -0
  11. package/app/(board)/[slug]/new/page.tsx +112 -0
  12. package/app/(board)/[slug]/page.tsx +403 -0
  13. package/app/(board)/discover/[view]/page.tsx +115 -0
  14. package/app/(board)/discover/page.tsx +5 -0
  15. package/app/(board)/layout.tsx +22 -0
  16. package/app/(board)/member/[id]/page.tsx +187 -0
  17. package/app/(board)/member/[id]/reputation/page.tsx +195 -0
  18. package/app/(board)/member/by-name/[name]/page.tsx +32 -0
  19. package/app/(board)/messages/[id]/page.tsx +103 -0
  20. package/app/(board)/messages/compose/page.tsx +60 -0
  21. package/app/(board)/messages/layout.tsx +5 -0
  22. package/app/(board)/messages/page.tsx +179 -0
  23. package/app/(board)/modcp/forums/page.tsx +59 -0
  24. package/app/(board)/modcp/ip/page.tsx +158 -0
  25. package/app/(board)/modcp/layout.tsx +5 -0
  26. package/app/(board)/modcp/log/page.tsx +98 -0
  27. package/app/(board)/modcp/page.tsx +112 -0
  28. package/app/(board)/moderation/layout.tsx +5 -0
  29. package/app/(board)/moderation/page.tsx +111 -0
  30. package/app/(board)/moderation/reports/page.tsx +193 -0
  31. package/app/(board)/moderation/warn/page.tsx +158 -0
  32. package/app/(board)/notifications/layout.tsx +5 -0
  33. package/app/(board)/notifications/page.tsx +144 -0
  34. package/app/(board)/notifications/preferences/page.tsx +65 -0
  35. package/app/(board)/online/page.tsx +119 -0
  36. package/app/(board)/page.tsx +195 -0
  37. package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
  38. package/app/(board)/privacy/page.tsx +12 -0
  39. package/app/(board)/report/page.tsx +71 -0
  40. package/app/(board)/rules/page.tsx +12 -0
  41. package/app/(board)/search/[token]/page.tsx +130 -0
  42. package/app/(board)/search/page.tsx +289 -0
  43. package/app/(board)/stats/page.tsx +202 -0
  44. package/app/(board)/subscriptions/layout.tsx +5 -0
  45. package/app/(board)/subscriptions/page.tsx +142 -0
  46. package/app/(board)/terms/page.tsx +12 -0
  47. package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
  48. package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
  49. package/app/(board)/thread/[slug]/page.tsx +707 -0
  50. package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
  51. package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
  52. package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
  53. package/app/(board)/unsubscribe/page.tsx +73 -0
  54. package/app/(board)/usercp/avatar/page.tsx +56 -0
  55. package/app/(board)/usercp/contacts/page.tsx +141 -0
  56. package/app/(board)/usercp/email/confirm/page.tsx +52 -0
  57. package/app/(board)/usercp/layout.tsx +23 -0
  58. package/app/(board)/usercp/options/page.tsx +56 -0
  59. package/app/(board)/usercp/page.tsx +71 -0
  60. package/app/(board)/usercp/profile/page.tsx +81 -0
  61. package/app/(board)/usercp/security/page.tsx +238 -0
  62. package/app/(board)/usercp/security/verify/page.tsx +92 -0
  63. package/app/(board)/usercp/signature/page.tsx +67 -0
  64. package/app/admin/antispam/page.tsx +155 -0
  65. package/app/admin/api/marketplace/screenshot/route.ts +68 -0
  66. package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
  67. package/app/admin/api-tokens/page.tsx +129 -0
  68. package/app/admin/content/announcements/page.tsx +110 -0
  69. package/app/admin/content/attachments/page.tsx +199 -0
  70. package/app/admin/content/navigation/page.tsx +123 -0
  71. package/app/admin/content/page.tsx +170 -0
  72. package/app/admin/forums/[id]/page.tsx +96 -0
  73. package/app/admin/forums/[id]/permissions/page.tsx +108 -0
  74. package/app/admin/forums/page.tsx +45 -0
  75. package/app/admin/groups/[id]/page.tsx +133 -0
  76. package/app/admin/groups/memberships/page.tsx +56 -0
  77. package/app/admin/groups/page.tsx +104 -0
  78. package/app/admin/groups/promotions/page.tsx +143 -0
  79. package/app/admin/layout.tsx +86 -0
  80. package/app/admin/log/page.tsx +116 -0
  81. package/app/admin/page.tsx +206 -0
  82. package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
  83. package/app/admin/plugins/browse/page.tsx +70 -0
  84. package/app/admin/plugins/page.tsx +165 -0
  85. package/app/admin/security/page.tsx +124 -0
  86. package/app/admin/settings/page.tsx +190 -0
  87. package/app/admin/system/page.tsx +303 -0
  88. package/app/admin/themes/[key]/page.tsx +110 -0
  89. package/app/admin/themes/browse/page.tsx +70 -0
  90. package/app/admin/themes/page.tsx +163 -0
  91. package/app/admin/users/[id]/merge/page.tsx +136 -0
  92. package/app/admin/users/[id]/page.tsx +208 -0
  93. package/app/admin/users/mail/page.tsx +68 -0
  94. package/app/admin/users/page.tsx +260 -0
  95. package/app/admin/users/prune/page.tsx +186 -0
  96. package/app/api/health/route.ts +11 -0
  97. package/app/api/metrics/route.ts +66 -0
  98. package/app/api/plugins/[key]/[...path]/route.ts +27 -0
  99. package/app/api/read/all/route.ts +15 -0
  100. package/app/api/read/forum/[id]/route.ts +29 -0
  101. package/app/api/read/thread/[id]/route.ts +40 -0
  102. package/app/api/ready/route.ts +63 -0
  103. package/app/api/system/tick/route.ts +77 -0
  104. package/app/api/v1/[...path]/route.ts +208 -0
  105. package/app/api/v1/openapi.json/route.ts +12 -0
  106. package/app/apple-icon.ts +11 -0
  107. package/app/atom.xml/route.ts +7 -0
  108. package/app/attachment/[id]/route.ts +10 -0
  109. package/app/attachment/[id]/thumb/route.ts +10 -0
  110. package/app/auth/passkey/options/route.ts +145 -0
  111. package/app/auth/passkey/verify/route.ts +241 -0
  112. package/app/auth/resume/route.ts +55 -0
  113. package/app/auth/sso/[provider]/callback/route.ts +173 -0
  114. package/app/auth/sso/[provider]/go/route.ts +57 -0
  115. package/app/auth/sso/[provider]/route.ts +105 -0
  116. package/app/avatar/[id]/route.ts +31 -0
  117. package/app/brand/icon-192.png/route.ts +7 -0
  118. package/app/brand/icon-512.png/route.ts +7 -0
  119. package/app/brand/icon-maskable.png/route.ts +7 -0
  120. package/app/demo/stripe/[[...path]]/route.ts +27 -0
  121. package/app/error.tsx +15 -0
  122. package/app/feed.xml/route.ts +7 -0
  123. package/app/forumdisplay.php/page.tsx +11 -0
  124. package/app/group/[id]/badge/[scheme]/route.ts +26 -0
  125. package/app/icon.ts +13 -0
  126. package/app/install/page.tsx +221 -0
  127. package/app/jump/page.tsx +28 -0
  128. package/app/layout.tsx +124 -0
  129. package/app/logo/[scheme]/route.ts +24 -0
  130. package/app/manifest.ts +43 -0
  131. package/app/member.php/page.tsx +11 -0
  132. package/app/memberlist.php/page.tsx +11 -0
  133. package/app/not-found.tsx +14 -0
  134. package/app/opengraph-image.ts +11 -0
  135. package/app/redirect/page.tsx +44 -0
  136. package/app/robots.txt/route.ts +35 -0
  137. package/app/showthread.php/page.tsx +11 -0
  138. package/app/sitemap/[page]/route.ts +64 -0
  139. package/app/sitemap.xml/route.ts +31 -0
  140. package/app/twitter-image.ts +11 -0
  141. package/app/viewforum.php/page.tsx +11 -0
  142. package/app/viewtopic.php/page.tsx +11 -0
  143. package/bin/forum-web.mjs +241 -0
  144. package/components.json +21 -0
  145. package/instrumentation.ts +18 -0
  146. package/next.config.mjs +182 -0
  147. package/package.json +99 -0
  148. package/postcss.config.mjs +7 -0
  149. package/proxy.ts +160 -0
  150. package/src/components/account/active-sessions.tsx +87 -0
  151. package/src/components/account/avatar-form.tsx +119 -0
  152. package/src/components/account/credential-proof-form.tsx +76 -0
  153. package/src/components/account/logout-form.tsx +14 -0
  154. package/src/components/account/notification-forms.tsx +157 -0
  155. package/src/components/account/passkey-enrol.tsx +81 -0
  156. package/src/components/account/passkey-proof-button.tsx +47 -0
  157. package/src/components/account/push-device-form.tsx +193 -0
  158. package/src/components/account/relation-forms.tsx +69 -0
  159. package/src/components/account/reputation-forms.tsx +124 -0
  160. package/src/components/account/security-activity.tsx +46 -0
  161. package/src/components/account/sign-in-methods.tsx +166 -0
  162. package/src/components/account/subscription-forms.tsx +151 -0
  163. package/src/components/account/two-factor-forms.tsx +226 -0
  164. package/src/components/account/usercp-forms.tsx +387 -0
  165. package/src/components/account/usercp-nav.tsx +14 -0
  166. package/src/components/account/usercp-shell.tsx +34 -0
  167. package/src/components/admin/admin-forms.tsx +92 -0
  168. package/src/components/admin/admin-nav.tsx +52 -0
  169. package/src/components/admin/admin-undo.tsx +32 -0
  170. package/src/components/admin/api-token-forms.tsx +89 -0
  171. package/src/components/admin/badge-forms.tsx +97 -0
  172. package/src/components/admin/branding-forms.tsx +102 -0
  173. package/src/components/admin/content-forms.tsx +712 -0
  174. package/src/components/admin/form-bits.tsx +11 -0
  175. package/src/components/admin/forum-forms.tsx +524 -0
  176. package/src/components/admin/forum-tree.tsx +454 -0
  177. package/src/components/admin/group-forms.tsx +646 -0
  178. package/src/components/admin/mail-test-card.tsx +90 -0
  179. package/src/components/admin/marketplace-forms.tsx +30 -0
  180. package/src/components/admin/marketplace-listing.tsx +123 -0
  181. package/src/components/admin/navigation-forms.tsx +229 -0
  182. package/src/components/admin/navigation-tree.tsx +442 -0
  183. package/src/components/admin/oklch-picker.tsx +180 -0
  184. package/src/components/admin/plugin-forms.tsx +225 -0
  185. package/src/components/admin/settings-form.tsx +148 -0
  186. package/src/components/admin/system-forms.tsx +169 -0
  187. package/src/components/admin/theme-changes.tsx +354 -0
  188. package/src/components/admin/theme-forms.tsx +636 -0
  189. package/src/components/admin/theme-palette.tsx +119 -0
  190. package/src/components/admin/theme-preview.tsx +461 -0
  191. package/src/components/admin/user-bulk-toolbar.tsx +99 -0
  192. package/src/components/admin/user-forms.tsx +488 -0
  193. package/src/components/auth/auth-page.tsx +45 -0
  194. package/src/components/auth/form-controls.tsx +117 -0
  195. package/src/components/auth/login-form.tsx +47 -0
  196. package/src/components/auth/passkey-client.ts +171 -0
  197. package/src/components/auth/passkey-second-factor.tsx +69 -0
  198. package/src/components/auth/passkey-sign-in.tsx +61 -0
  199. package/src/components/auth/register-form.tsx +136 -0
  200. package/src/components/auth/resend-verification-form.tsx +34 -0
  201. package/src/components/auth/reset-confirm-form.tsx +43 -0
  202. package/src/components/auth/reset-request-form.tsx +35 -0
  203. package/src/components/auth/second-factor-form.tsx +51 -0
  204. package/src/components/auth/sso-buttons.tsx +47 -0
  205. package/src/components/board/live-region.tsx +74 -0
  206. package/src/components/board/search-off-notice.tsx +18 -0
  207. package/src/components/board/section-page.tsx +114 -0
  208. package/src/components/content/attachment-field.tsx +51 -0
  209. package/src/components/content/composer-field.ts +31 -0
  210. package/src/components/content/composer-ids.ts +6 -0
  211. package/src/components/content/composer-intents.tsx +27 -0
  212. package/src/components/content/composer-recovery.tsx +215 -0
  213. package/src/components/content/edit-post-form.tsx +121 -0
  214. package/src/components/content/markdown-editor.tsx +590 -0
  215. package/src/components/content/markdown-syntax.ts +53 -0
  216. package/src/components/content/mention-token.ts +23 -0
  217. package/src/components/content/multiquote-button.tsx +32 -0
  218. package/src/components/content/multiquote-selection.tsx +105 -0
  219. package/src/components/content/multiquote.ts +125 -0
  220. package/src/components/content/new-thread-form.tsx +169 -0
  221. package/src/components/content/poll-edit-form.tsx +96 -0
  222. package/src/components/content/poll.tsx +89 -0
  223. package/src/components/content/quote-in-place.tsx +51 -0
  224. package/src/components/content/reply-form.tsx +113 -0
  225. package/src/components/content/thanks-button.tsx +65 -0
  226. package/src/components/content/thread-rating.tsx +143 -0
  227. package/src/components/install/install-form.tsx +497 -0
  228. package/src/components/legal/legal-document.tsx +22 -0
  229. package/src/components/messages/message-forms.tsx +152 -0
  230. package/src/components/moderation/inline-moderation-form.tsx +158 -0
  231. package/src/components/moderation/modcp-nav.tsx +15 -0
  232. package/src/components/moderation/modcp-shell.tsx +41 -0
  233. package/src/components/moderation/queue-form.tsx +96 -0
  234. package/src/components/moderation/report-forms.tsx +120 -0
  235. package/src/components/moderation/signature-lock-form.tsx +83 -0
  236. package/src/components/moderation/thread-surgery-form.tsx +96 -0
  237. package/src/components/moderation/thread-tools-form.tsx +137 -0
  238. package/src/components/moderation/warning-forms.tsx +125 -0
  239. package/src/components/profile/custom-field.tsx +79 -0
  240. package/src/components/shell/appearance-icons.tsx +35 -0
  241. package/src/components/shell/board-notice.tsx +21 -0
  242. package/src/components/shell/copy-record.ts +20 -0
  243. package/src/components/shell/copy.tsx +17 -0
  244. package/src/components/shell/crash-notice.tsx +86 -0
  245. package/src/components/shell/demo-banner.tsx +49 -0
  246. package/src/components/shell/group-name-style.tsx +6 -0
  247. package/src/components/shell/measure.ts +1 -0
  248. package/src/components/shell/notification-menu.tsx +361 -0
  249. package/src/components/shell/offline-notice.tsx +29 -0
  250. package/src/components/shell/onboarding-banner.tsx +39 -0
  251. package/src/components/shell/page-shell.tsx +167 -0
  252. package/src/components/shell/panel-list.tsx +95 -0
  253. package/src/components/shell/panel-nav.tsx +40 -0
  254. package/src/components/shell/panel-overview.tsx +81 -0
  255. package/src/components/shell/panel-page.tsx +96 -0
  256. package/src/components/shell/panel-pagination.tsx +28 -0
  257. package/src/components/shell/panel-shell.tsx +31 -0
  258. package/src/components/shell/theme-runtime-style.tsx +6 -0
  259. package/src/components/shell/theme-switcher.tsx +97 -0
  260. package/src/components/shell/timezone-probe.tsx +45 -0
  261. package/src/components/shell/view-tabs.tsx +60 -0
  262. package/src/config.ts +13 -0
  263. package/src/server/absent-services.ts +29 -0
  264. package/src/server/admin-actions.ts +171 -0
  265. package/src/server/admin-settings-actions.ts +83 -0
  266. package/src/server/admin-undo-actions.ts +70 -0
  267. package/src/server/admin-undo.ts +75 -0
  268. package/src/server/admin.ts +112 -0
  269. package/src/server/announcements.ts +82 -0
  270. package/src/server/antispam.ts +310 -0
  271. package/src/server/api/content.ts +486 -0
  272. package/src/server/api/http.ts +119 -0
  273. package/src/server/api/members.ts +109 -0
  274. package/src/server/api/messages.ts +113 -0
  275. package/src/server/api/registry.ts +30 -0
  276. package/src/server/api/search.ts +98 -0
  277. package/src/server/api/subscriptions.ts +106 -0
  278. package/src/server/api-auth.ts +66 -0
  279. package/src/server/api-token-actions.ts +85 -0
  280. package/src/server/api-tokens-admin.ts +95 -0
  281. package/src/server/appearance-actions.ts +39 -0
  282. package/src/server/attachment-download.ts +43 -0
  283. package/src/server/attachment-embed.ts +63 -0
  284. package/src/server/attachment-upload-actions.ts +92 -0
  285. package/src/server/attachments.ts +257 -0
  286. package/src/server/auth-actions.ts +479 -0
  287. package/src/server/auth-config.ts +61 -0
  288. package/src/server/auth-events.ts +92 -0
  289. package/src/server/auth-form-state.ts +14 -0
  290. package/src/server/auth-mail.ts +104 -0
  291. package/src/server/avatars.ts +83 -0
  292. package/src/server/board-latest-actions.ts +7 -0
  293. package/src/server/board-latest.tsx +103 -0
  294. package/src/server/board-offline.ts +23 -0
  295. package/src/server/board-url.ts +14 -0
  296. package/src/server/brand-assets.ts +151 -0
  297. package/src/server/brand-mark.tsx +110 -0
  298. package/src/server/branding-actions.ts +53 -0
  299. package/src/server/branding.ts +101 -0
  300. package/src/server/cache-targets.ts +13 -0
  301. package/src/server/code-highlighting.ts +33 -0
  302. package/src/server/container.ts +402 -0
  303. package/src/server/content-actions.ts +459 -0
  304. package/src/server/content-admin-actions.ts +492 -0
  305. package/src/server/content-admin.ts +95 -0
  306. package/src/server/content-security-policy.ts +35 -0
  307. package/src/server/context.ts +32 -0
  308. package/src/server/cookies.ts +139 -0
  309. package/src/server/credential-proof-actions.ts +42 -0
  310. package/src/server/credential-proof.ts +35 -0
  311. package/src/server/current-location.ts +19 -0
  312. package/src/server/demo-stripe.ts +104 -0
  313. package/src/server/demo-uploads.ts +28 -0
  314. package/src/server/demo.ts +80 -0
  315. package/src/server/discovery.ts +104 -0
  316. package/src/server/embeds.ts +113 -0
  317. package/src/server/error-notice.tsx +41 -0
  318. package/src/server/federation-actions.ts +113 -0
  319. package/src/server/federation.ts +138 -0
  320. package/src/server/feed-builder.ts +72 -0
  321. package/src/server/feed-routes.ts +122 -0
  322. package/src/server/fixture-actor-source.ts +62 -0
  323. package/src/server/fixture-forum-repo.ts +59 -0
  324. package/src/server/fixture-member-profile-repo.ts +30 -0
  325. package/src/server/fixture-post-repo.ts +108 -0
  326. package/src/server/fixture-thread-repo.ts +98 -0
  327. package/src/server/form-state-reporter.ts +34 -0
  328. package/src/server/form-values.ts +23 -0
  329. package/src/server/forum-admin-actions.ts +353 -0
  330. package/src/server/forum-admin.ts +98 -0
  331. package/src/server/group-admin-actions.ts +341 -0
  332. package/src/server/group-admin.ts +98 -0
  333. package/src/server/group-badge-actions.ts +61 -0
  334. package/src/server/group-badge.ts +62 -0
  335. package/src/server/group-identity.ts +104 -0
  336. package/src/server/i18n-catalogs.ts +42 -0
  337. package/src/server/i18n.ts +69 -0
  338. package/src/server/image-upload.ts +109 -0
  339. package/src/server/inline-moderation-actions.ts +129 -0
  340. package/src/server/install-actions.ts +124 -0
  341. package/src/server/install.ts +281 -0
  342. package/src/server/legacy-redirect.ts +83 -0
  343. package/src/server/legal.ts +43 -0
  344. package/src/server/location-header.ts +37 -0
  345. package/src/server/mail-brand.ts +43 -0
  346. package/src/server/mail-health.ts +41 -0
  347. package/src/server/mail-send.ts +44 -0
  348. package/src/server/mail-test-actions.ts +67 -0
  349. package/src/server/mail-test.ts +56 -0
  350. package/src/server/markdown-pipeline.ts +31 -0
  351. package/src/server/marketplace-actions.ts +47 -0
  352. package/src/server/marketplace-admin.ts +260 -0
  353. package/src/server/mention-actions.ts +21 -0
  354. package/src/server/mention-search.ts +35 -0
  355. package/src/server/message-actions.ts +110 -0
  356. package/src/server/messages.ts +141 -0
  357. package/src/server/modcp.ts +125 -0
  358. package/src/server/moderation-actions.ts +165 -0
  359. package/src/server/navigation-admin-actions.ts +217 -0
  360. package/src/server/navigation.ts +112 -0
  361. package/src/server/nonce.ts +13 -0
  362. package/src/server/notification-actions.ts +84 -0
  363. package/src/server/notification-audience.ts +10 -0
  364. package/src/server/notification-menu-actions.ts +82 -0
  365. package/src/server/notification-menu.ts +96 -0
  366. package/src/server/notifications.ts +93 -0
  367. package/src/server/onboarding-actions.ts +27 -0
  368. package/src/server/onboarding.ts +89 -0
  369. package/src/server/page-metadata.ts +14 -0
  370. package/src/server/passkey-challenge.ts +42 -0
  371. package/src/server/plugin-admin-actions.ts +232 -0
  372. package/src/server/plugin-admin.ts +276 -0
  373. package/src/server/plugin-host.ts +143 -0
  374. package/src/server/plugin-pages.ts +192 -0
  375. package/src/server/plugin-panel.ts +49 -0
  376. package/src/server/plugin-routes.ts +298 -0
  377. package/src/server/plugin-view.tsx +58 -0
  378. package/src/server/poll-actions.ts +104 -0
  379. package/src/server/poll-scope.ts +52 -0
  380. package/src/server/post-notifications.ts +71 -0
  381. package/src/server/post-scope.ts +86 -0
  382. package/src/server/presence.ts +177 -0
  383. package/src/server/profile-fields.ts +112 -0
  384. package/src/server/push-actions.ts +113 -0
  385. package/src/server/push.ts +40 -0
  386. package/src/server/redirect-back.ts +15 -0
  387. package/src/server/registration.ts +7 -0
  388. package/src/server/relation-actions.ts +89 -0
  389. package/src/server/relations.ts +45 -0
  390. package/src/server/reply-core.ts +173 -0
  391. package/src/server/report-actions.ts +165 -0
  392. package/src/server/report-scope.ts +21 -0
  393. package/src/server/reputation-actions.ts +146 -0
  394. package/src/server/reputation-target.ts +49 -0
  395. package/src/server/reputation.ts +70 -0
  396. package/src/server/request-fingerprint.ts +65 -0
  397. package/src/server/safe-path.ts +16 -0
  398. package/src/server/same-origin.ts +45 -0
  399. package/src/server/search-page.ts +313 -0
  400. package/src/server/search.ts +45 -0
  401. package/src/server/secret-auth.ts +20 -0
  402. package/src/server/see-other.ts +11 -0
  403. package/src/server/seed-board.ts +417 -0
  404. package/src/server/session-actions.ts +78 -0
  405. package/src/server/session-cookies.ts +124 -0
  406. package/src/server/session-key.ts +17 -0
  407. package/src/server/settings.ts +47 -0
  408. package/src/server/signatures.ts +62 -0
  409. package/src/server/sso-handshake.ts +79 -0
  410. package/src/server/stats.ts +63 -0
  411. package/src/server/subscription-actions.ts +161 -0
  412. package/src/server/surgery-actions.ts +180 -0
  413. package/src/server/syndication.ts +77 -0
  414. package/src/server/system-admin-actions.ts +134 -0
  415. package/src/server/system-admin.ts +90 -0
  416. package/src/server/test-container.ts +114 -0
  417. package/src/server/theme-admin-actions.ts +242 -0
  418. package/src/server/theme-admin.ts +203 -0
  419. package/src/server/theme-runtime.ts +89 -0
  420. package/src/server/theme-style.ts +279 -0
  421. package/src/server/theme.ts +52 -0
  422. package/src/server/thread-core.ts +207 -0
  423. package/src/server/thread-rating-actions.ts +55 -0
  424. package/src/server/thread-tool-actions.ts +154 -0
  425. package/src/server/two-factor-actions.ts +178 -0
  426. package/src/server/two-factor.ts +86 -0
  427. package/src/server/upgrade-notice.ts +41 -0
  428. package/src/server/user-admin-actions.ts +572 -0
  429. package/src/server/user-admin.ts +165 -0
  430. package/src/server/usercp-actions.ts +263 -0
  431. package/src/server/usercp-mail.ts +120 -0
  432. package/src/server/viewer-preferences.ts +64 -0
  433. package/src/server/warning-actions.ts +122 -0
  434. package/src/styles/globals.css +969 -0
  435. package/src/theme/contract.fixture.ts +793 -0
  436. package/src/view/account-copy.ts +351 -0
  437. package/src/view/admin-content-copy.ts +66 -0
  438. package/src/view/admin-copy.ts +106 -0
  439. package/src/view/admin-forum-tree-copy.ts +40 -0
  440. package/src/view/admin-group-copy.ts +80 -0
  441. package/src/view/admin-log.ts +58 -0
  442. package/src/view/admin-nav.ts +164 -0
  443. package/src/view/admin-navigation-copy.ts +58 -0
  444. package/src/view/admin-panel-copy.ts +194 -0
  445. package/src/view/admin-settings.ts +173 -0
  446. package/src/view/admin-theme-copy.ts +229 -0
  447. package/src/view/admin-user-copy.ts +96 -0
  448. package/src/view/attachments.ts +51 -0
  449. package/src/view/auth-copy.ts +147 -0
  450. package/src/view/board-index.ts +167 -0
  451. package/src/view/board-latest.ts +101 -0
  452. package/src/view/breadcrumb.ts +50 -0
  453. package/src/view/contacts.ts +85 -0
  454. package/src/view/content-copy.ts +118 -0
  455. package/src/view/contrast.ts +286 -0
  456. package/src/view/copy.ts +101 -0
  457. package/src/view/count.ts +8 -0
  458. package/src/view/discovery-view.ts +74 -0
  459. package/src/view/editor-toolbar.ts +84 -0
  460. package/src/view/error-notice.ts +52 -0
  461. package/src/view/feed.ts +130 -0
  462. package/src/view/forum-cursor.ts +54 -0
  463. package/src/view/forum-display.ts +152 -0
  464. package/src/view/forum-jump.ts +60 -0
  465. package/src/view/forum-notice.ts +24 -0
  466. package/src/view/inline-moderation.ts +87 -0
  467. package/src/view/install-copy.ts +95 -0
  468. package/src/view/legal.ts +58 -0
  469. package/src/view/locale.ts +40 -0
  470. package/src/view/marketplace-panel.ts +51 -0
  471. package/src/view/member-identity.ts +23 -0
  472. package/src/view/member-profile.ts +65 -0
  473. package/src/view/messages.ts +222 -0
  474. package/src/view/metadata.ts +75 -0
  475. package/src/view/modcp-nav.ts +76 -0
  476. package/src/view/moderation-copy.ts +84 -0
  477. package/src/view/moderation-queue.ts +67 -0
  478. package/src/view/navigation-arrange.ts +188 -0
  479. package/src/view/navigation.ts +173 -0
  480. package/src/view/notification-menu.ts +202 -0
  481. package/src/view/notifications.ts +84 -0
  482. package/src/view/onboarding.ts +26 -0
  483. package/src/view/pager.ts +191 -0
  484. package/src/view/panel-nav.ts +203 -0
  485. package/src/view/plugin-panel.ts +42 -0
  486. package/src/view/post-form.ts +61 -0
  487. package/src/view/post-link.ts +29 -0
  488. package/src/view/presence.ts +99 -0
  489. package/src/view/promotion-rules.ts +52 -0
  490. package/src/view/redirect-notice.ts +21 -0
  491. package/src/view/reputation.ts +107 -0
  492. package/src/view/search-controls.ts +103 -0
  493. package/src/view/search-results.ts +291 -0
  494. package/src/view/security-activity.ts +73 -0
  495. package/src/view/setting-groups.ts +43 -0
  496. package/src/view/shell.ts +164 -0
  497. package/src/view/slug-id.ts +12 -0
  498. package/src/view/sso-hand-off.ts +44 -0
  499. package/src/view/sso-notices.ts +48 -0
  500. package/src/view/subscriptions.ts +76 -0
  501. package/src/view/theme-draft.ts +167 -0
  502. package/src/view/theme-preference.ts +30 -0
  503. package/src/view/theme-tokens.ts +333 -0
  504. package/src/view/thread-view.ts +280 -0
  505. package/src/view/time.ts +31 -0
  506. package/src/view/timezone.ts +35 -0
  507. package/src/view/two-factor.ts +6 -0
  508. package/src/view/usercp-nav.ts +103 -0
  509. package/src/view/usercp.ts +196 -0
  510. package/src/view/warnings.ts +128 -0
  511. package/src/view/word-filter.ts +5 -0
@@ -0,0 +1,232 @@
1
+ 'use server'
2
+
3
+ import forumConfig from '@board/config'
4
+ import { revalidatePath } from 'next/cache'
5
+
6
+ import { CacheTags, env, isAppError, logger, readPluginEnv, ValidationError } from '@meith/core'
7
+ import { clearPluginHealth, getDb, PostgresSettingsRepository } from '@meith/db'
8
+ import { drivers } from '@meith/drivers'
9
+ import {
10
+ type PluginDefinition,
11
+ parsePluginSetting,
12
+ pluginEnabledKey,
13
+ pluginSettingType,
14
+ resolvePluginSettingDetails,
15
+ serialisePluginSetting,
16
+ } from '@meith/plugin-kit'
17
+ import { runPluginLifecycle } from '@meith/runtime'
18
+
19
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
20
+ import type { FormState } from './auth-form-state'
21
+ import { tr } from './i18n'
22
+ import { syncPluginNavigation } from './navigation'
23
+ import {
24
+ invalidatePluginHealth,
25
+ reconcilePluginHealth,
26
+ recordPluginFault,
27
+ syncPluginEnablement,
28
+ } from './plugin-host'
29
+ import { emitEvent } from './plugin-view'
30
+ import { getSettingOverrides } from './settings'
31
+
32
+ function requireDefinition(key: string): PluginDefinition {
33
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === key)
34
+ const definition = entry?.plugin as PluginDefinition | undefined
35
+
36
+ if (entry === undefined) {
37
+ throw new ValidationError(`No plugin named "${key}" is installed on this board.`)
38
+ }
39
+ if (definition === undefined) {
40
+ throw new ValidationError(
41
+ `"${key}" is registered without a definition, so there is nothing to configure.`,
42
+ )
43
+ }
44
+ return definition
45
+ }
46
+
47
+ async function invalidateSettings(): Promise<void> {
48
+ await drivers().cache.invalidateTags([CacheTags.settings()])
49
+ revalidatePath('/admin/plugins')
50
+ revalidatePath('/admin/plugins/[key]/[[...path]]', 'page')
51
+ }
52
+
53
+ export async function setPluginEnabledAction(_prev: FormState, form: FormData): Promise<FormState> {
54
+ try {
55
+ const key = String(form.get('key') ?? '')
56
+ const enabled = form.get('enabled') === '1'
57
+
58
+ if (enabled) {
59
+ await requireAdmin()
60
+ } else {
61
+ await requireFreshAdmin()
62
+ }
63
+
64
+ const definition = requireDefinition(key)
65
+
66
+ const repository = new PostgresSettingsRepository(getDb())
67
+ if (enabled) {
68
+ await repository.delete([pluginEnabledKey(key)])
69
+ } else {
70
+ await repository.save(new Map([[pluginEnabledKey(key), '0']]))
71
+ }
72
+
73
+ await invalidateSettings()
74
+ await syncPluginEnablement()
75
+ await syncPluginNavigation()
76
+
77
+ /*
78
+ * After the switch is written, so the callback sees the state it is being
79
+ * told about, and never for the host's own failure switch: a plugin that
80
+ * has just failed five times is not one to hand more work to.
81
+ */
82
+ await announceSwitch(definition, enabled)
83
+
84
+ await recordAdminAction({
85
+ action: enabled ? 'plugin.enabled' : 'plugin.disabled',
86
+ detail: { plugin: key },
87
+ })
88
+
89
+ return { notice: enabled ? 'enabled' : 'disabled' }
90
+ } catch (err) {
91
+ if (isAppError(err)) return { error: err.message }
92
+ logger({ module: 'plugin-admin' }).error({ err }, 'failed to change plugin enablement')
93
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * The switch has already been thrown by the time this runs, and it stays
99
+ * thrown: a callback that throws is the plugin's fault, so it is counted and
100
+ * reported like any other plugin failure rather than being reported as the
101
+ * operator's action having failed.
102
+ */
103
+ async function announceSwitch(definition: PluginDefinition, enabled: boolean): Promise<void> {
104
+ if (enabled) {
105
+ await emitEvent('plugin.enabled', { pluginKey: definition.key }, {})
106
+ } else {
107
+ await emitEvent('plugin.disabled', { pluginKey: definition.key, reason: 'operator' }, {})
108
+ }
109
+
110
+ if (env.DATA_SOURCE !== 'postgres') return
111
+
112
+ try {
113
+ await runPluginLifecycle({
114
+ db: getDb(),
115
+ plugin: definition,
116
+ phase: enabled ? 'enable' : 'disable',
117
+ })
118
+ } catch (err) {
119
+ logger({ module: 'plugin-admin' }).error(
120
+ { err, plugin: definition.key },
121
+ `plugin ${enabled ? 'onEnable' : 'onDisable'} failed`,
122
+ )
123
+ await recordPluginFault(definition.key, enabled ? 'onEnable' : 'onDisable', err)
124
+ }
125
+ }
126
+
127
+ export async function clearPluginHealthAction(
128
+ _prev: FormState,
129
+ form: FormData,
130
+ ): Promise<FormState> {
131
+ try {
132
+ await requireFreshAdmin()
133
+
134
+ const key = String(form.get('key') ?? '')
135
+ requireDefinition(key)
136
+
137
+ await clearPluginHealth(getDb(), key)
138
+ await invalidatePluginHealth()
139
+ await reconcilePluginHealth()
140
+
141
+ revalidatePath('/admin/plugins')
142
+ revalidatePath('/admin/plugins/[key]/[[...path]]', 'page')
143
+
144
+ await recordAdminAction({ action: 'plugin.health-cleared', detail: { plugin: key } })
145
+
146
+ return { notice: 'cleared' }
147
+ } catch (err) {
148
+ if (isAppError(err)) return { error: err.message }
149
+ logger({ module: 'plugin-admin' }).error({ err }, 'failed to clear plugin health')
150
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
151
+ }
152
+ }
153
+
154
+ export async function savePluginSettingsAction(
155
+ _prev: FormState,
156
+ form: FormData,
157
+ ): Promise<FormState> {
158
+ try {
159
+ await requireAdmin()
160
+
161
+ const key = String(form.get('key') ?? '')
162
+ const definition = requireDefinition(key)
163
+ const declared = definition.settings ?? []
164
+
165
+ if (declared.length === 0) {
166
+ throw new ValidationError(`"${key}" declares no settings.`)
167
+ }
168
+
169
+ const resolved = resolvePluginSettingDetails(
170
+ definition,
171
+ await getSettingOverrides(),
172
+ readPluginEnv,
173
+ )
174
+ const environmentOwned = new Set(
175
+ resolved
176
+ .filter((detail) => detail.source === 'environment')
177
+ .map((detail) => detail.setting.key),
178
+ )
179
+
180
+ const updates = new Map<string, string>()
181
+ for (const setting of declared) {
182
+ const field = `setting.${setting.key}`
183
+ const type = pluginSettingType(setting)
184
+ const raw = form.get(field)
185
+
186
+ if (environmentOwned.has(setting.key)) continue
187
+ if (raw === null && type !== 'boolean') continue
188
+
189
+ if (type === 'boolean') {
190
+ updates.set(`plugin.${key}.${setting.key}`, raw === '1' ? '1' : '0')
191
+ continue
192
+ }
193
+
194
+ if (type === 'secret') {
195
+ if (typeof raw !== 'string' || raw === '') continue
196
+ updates.set(`plugin.${key}.${setting.key}`, raw)
197
+ continue
198
+ }
199
+
200
+ if (type === 'select') {
201
+ const value = typeof raw === 'string' ? raw : ''
202
+ if (!(setting.options ?? []).some((option) => option.value === value)) {
203
+ throw new ValidationError(`“${setting.label}” must be one of its listed options.`)
204
+ }
205
+ updates.set(`plugin.${key}.${setting.key}`, value)
206
+ continue
207
+ }
208
+
209
+ const parsed = parsePluginSetting(setting, typeof raw === 'string' ? raw : '')
210
+ if (parsed === null) {
211
+ throw new ValidationError(
212
+ `“${setting.label}” needs ${typeof setting.default === 'number' ? 'a number' : 'a value'}.`,
213
+ )
214
+ }
215
+ updates.set(`plugin.${key}.${setting.key}`, serialisePluginSetting(parsed))
216
+ }
217
+
218
+ await new PostgresSettingsRepository(getDb()).save(updates)
219
+ await invalidateSettings()
220
+
221
+ await recordAdminAction({
222
+ action: 'plugin.configured',
223
+ detail: { plugin: key, keys: [...updates.keys()] },
224
+ })
225
+
226
+ return { notice: 'saved' }
227
+ } catch (err) {
228
+ if (isAppError(err)) return { error: err.message }
229
+ logger({ module: 'plugin-admin' }).error({ err }, 'failed to save plugin settings')
230
+ return { error: await tr('notice.app.something-went-wrong-please-try') }
231
+ }
232
+ }
@@ -0,0 +1,276 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+
5
+ import { env, logger, readPluginEnv } from '@meith/core'
6
+ import {
7
+ appliedPluginMigrations,
8
+ getDb,
9
+ type PluginHealthRecord,
10
+ readPluginHealth,
11
+ } from '@meith/db'
12
+ import type { Translator } from '@meith/i18n'
13
+ import {
14
+ type PluginDefinition,
15
+ type PluginHealth,
16
+ type PluginSettingSource,
17
+ type PluginSettingType,
18
+ type PluginSettingValue,
19
+ pluginAdminPath,
20
+ pluginSettingType,
21
+ pluginTaskId,
22
+ resolvePluginSettingDetails,
23
+ } from '@meith/plugin-kit'
24
+
25
+ import { configuredPlugins, pluginHost, syncPluginEnablement } from './plugin-host'
26
+ import { getSettingOverrides } from './settings'
27
+
28
+ export type PluginSettingKind = PluginSettingType
29
+
30
+ export interface PluginSettingRow {
31
+ readonly key: string
32
+ readonly label: string
33
+ readonly description: string | null
34
+ readonly advanced: boolean
35
+ readonly kind: PluginSettingKind
36
+ /** '' for a secret, whatever is stored or not: the value never leaves the server. */
37
+ readonly default: PluginSettingValue
38
+ readonly value: PluginSettingValue
39
+ readonly overridden: boolean
40
+ readonly source: PluginSettingSource
41
+ /** For secrets: whether a non-empty value is in force, without saying what it is. */
42
+ readonly set: boolean
43
+ readonly options: readonly { readonly value: string; readonly label: string }[]
44
+ readonly envName: string | null
45
+ readonly problem: string | null
46
+ }
47
+
48
+ export interface PluginDurableHealthRow {
49
+ readonly failures: number
50
+ readonly disabledAt: string | null
51
+ readonly reason: string | null
52
+ }
53
+
54
+ export interface PluginMigrationRow {
55
+ readonly id: string
56
+ readonly applied: boolean
57
+ }
58
+
59
+ export interface PluginTaskRow {
60
+ readonly id: string
61
+ readonly registeredId: string
62
+ readonly intervalSeconds: number
63
+ }
64
+
65
+ export interface PluginPageRow {
66
+ readonly path: string
67
+ readonly title: string
68
+ readonly href: string
69
+ }
70
+
71
+ export interface PluginRow {
72
+ readonly key: string
73
+ readonly name: string | null
74
+ readonly version: string | null
75
+ readonly description: string | null
76
+ readonly dependsOn: readonly string[]
77
+ readonly hasDefinition: boolean
78
+
79
+ readonly configuredEnabled: boolean
80
+ readonly operatorEnabled: boolean
81
+ readonly running: boolean
82
+
83
+ readonly health: PluginHealth | null
84
+ /** The record every instance reconciles against, not this process's tally. */
85
+ readonly durableHealth: PluginDurableHealthRow | null
86
+ readonly hooks: readonly string[]
87
+ readonly regions: readonly string[]
88
+ readonly settings: readonly PluginSettingRow[]
89
+ readonly migrations: readonly PluginMigrationRow[]
90
+ readonly tasks: readonly PluginTaskRow[]
91
+ readonly pages: readonly PluginPageRow[]
92
+ }
93
+
94
+ export interface PluginInventory {
95
+ readonly plugins: readonly PluginRow[]
96
+ readonly migrationsKnown: boolean
97
+ }
98
+
99
+ function translated(
100
+ t: Translator | undefined,
101
+ key: string | undefined,
102
+ fallback: string,
103
+ args?: Parameters<Translator['t']>[1],
104
+ ): string {
105
+ return t !== undefined && key !== undefined && t.has(key) ? t.t(key, args) : fallback
106
+ }
107
+
108
+ function definitionsByKey(): ReadonlyMap<string, PluginDefinition | undefined> {
109
+ return new Map(
110
+ (forumConfig.plugins ?? []).map((entry) => [
111
+ entry.key,
112
+ entry.plugin as PluginDefinition | undefined,
113
+ ]),
114
+ )
115
+ }
116
+
117
+ async function durableHealthByPlugin(): Promise<ReadonlyMap<string, PluginHealthRecord>> {
118
+ if (env.DATA_SOURCE !== 'postgres') return new Map()
119
+
120
+ try {
121
+ return new Map((await readPluginHealth(getDb())).map((row) => [row.pluginKey, row]))
122
+ } catch (error) {
123
+ logger().warn({ err: String(error) }, 'could not read plugin health')
124
+ return new Map()
125
+ }
126
+ }
127
+
128
+ async function appliedByPlugin(
129
+ keys: readonly string[],
130
+ ): Promise<ReadonlyMap<string, readonly string[]> | null> {
131
+ if (keys.length === 0) return new Map()
132
+ if (env.DATA_SOURCE !== 'postgres') return null
133
+
134
+ try {
135
+ const db = getDb()
136
+ const applied = new Map<string, readonly string[]>()
137
+ for (const key of keys) {
138
+ applied.set(key, await appliedPluginMigrations(db, key))
139
+ }
140
+ return applied
141
+ } catch (error) {
142
+ logger().warn({ err: String(error) }, 'could not read applied plugin migrations')
143
+ return null
144
+ }
145
+ }
146
+
147
+ export async function pluginInventory(t?: Translator): Promise<PluginInventory> {
148
+ const definitions = definitionsByKey()
149
+ const overrides = await getSettingOverrides()
150
+ await syncPluginEnablement()
151
+ const health = new Map(pluginHost.health().map((entry) => [entry.key, entry]))
152
+
153
+ const withMigrations = [...definitions]
154
+ .filter(([, definition]) => (definition?.migrations ?? []).length > 0)
155
+ .map(([key]) => key)
156
+ const applied = await appliedByPlugin(withMigrations)
157
+ const durable = await durableHealthByPlugin()
158
+
159
+ const plugins = configuredPlugins().map((entry): PluginRow => {
160
+ const definition = definitions.get(entry.key)
161
+ const configuredEnabled = entry.enabled
162
+ const operatorEnabled = overrides.get(`plugin.${entry.key}._enabled`) !== '0'
163
+ const entryHealth = health.get(entry.key) ?? null
164
+ const details =
165
+ definition === undefined
166
+ ? []
167
+ : resolvePluginSettingDetails(definition, overrides, readPluginEnv)
168
+
169
+ return {
170
+ key: entry.key,
171
+ name:
172
+ definition === undefined
173
+ ? entry.name
174
+ : translated(t, definition.nameKey, entry.name ?? definition.name),
175
+ version: entry.version,
176
+ description:
177
+ definition?.description === undefined
178
+ ? null
179
+ : translated(
180
+ t,
181
+ definition.descriptionKey,
182
+ definition.description,
183
+ definition.descriptionArgs,
184
+ ),
185
+ dependsOn: definition?.dependsOn ?? [],
186
+ hasDefinition: entry.hasDefinition,
187
+
188
+ configuredEnabled,
189
+ operatorEnabled,
190
+ running: configuredEnabled && operatorEnabled && (entryHealth?.enabled ?? false),
191
+
192
+ health: entryHealth,
193
+ durableHealth: toDurableHealth(durable.get(entry.key)),
194
+ hooks: Object.keys(definition?.hooks ?? {}).sort(),
195
+ regions: [...new Set((definition?.contributions ?? []).map((c) => c.region))].sort(),
196
+
197
+ settings: details.map(({ setting, value, source, problem }): PluginSettingRow => {
198
+ const kind = pluginSettingType(setting)
199
+ const secret = kind === 'secret'
200
+ return {
201
+ key: setting.key,
202
+ label: translated(t, setting.labelKey, setting.label),
203
+ description:
204
+ setting.description === undefined
205
+ ? null
206
+ : translated(t, setting.descriptionKey, setting.description),
207
+ advanced: setting.advanced === true,
208
+ kind,
209
+ default: secret ? '' : setting.default,
210
+ value: secret ? '' : value,
211
+ overridden: overrides.has(`plugin.${entry.key}.${setting.key}`),
212
+ source,
213
+ set: typeof value === 'string' ? value !== '' : true,
214
+ options: (setting.options ?? []).map((option) => ({
215
+ value: option.value,
216
+ label: translated(t, option.labelKey, option.label),
217
+ })),
218
+ envName: setting.env ?? null,
219
+ problem,
220
+ }
221
+ }),
222
+
223
+ migrations: (definition?.migrations ?? []).map(
224
+ (migration): PluginMigrationRow => ({
225
+ id: migration.id,
226
+ applied: (applied?.get(entry.key) ?? []).includes(migration.id),
227
+ }),
228
+ ),
229
+
230
+ tasks: (definition?.tasks ?? []).map(
231
+ (task): PluginTaskRow => ({
232
+ id: task.id,
233
+ registeredId: pluginTaskId(entry.key, task.id),
234
+ intervalSeconds: task.intervalSeconds,
235
+ }),
236
+ ),
237
+
238
+ pages: (definition?.adminPages ?? []).map(
239
+ (page): PluginPageRow => ({
240
+ path: page.path,
241
+ title: translated(t, page.titleKey, page.title, page.titleArgs),
242
+ href: pluginAdminPath(entry.key, page.path),
243
+ }),
244
+ ),
245
+ }
246
+ })
247
+
248
+ return { plugins, migrationsKnown: withMigrations.length === 0 || applied !== null }
249
+ }
250
+
251
+ function toDurableHealth(record: PluginHealthRecord | undefined): PluginDurableHealthRow | null {
252
+ if (record === undefined) return null
253
+
254
+ return {
255
+ failures: record.failures,
256
+ disabledAt: record.disabledAt === null ? null : record.disabledAt.toISOString(),
257
+ reason: record.reason,
258
+ }
259
+ }
260
+
261
+ export async function pluginRow(key: string, t?: Translator): Promise<PluginRow | null> {
262
+ const inventory = await pluginInventory(t)
263
+ return inventory.plugins.find((plugin) => plugin.key === key) ?? null
264
+ }
265
+
266
+ export interface HookListenerRow {
267
+ readonly hook: string
268
+ readonly plugins: readonly string[]
269
+ }
270
+
271
+ export function hookListeners(): readonly HookListenerRow[] {
272
+ return Object.entries(pluginHost.listeners())
273
+ .filter(([, plugins]) => plugins.length > 0)
274
+ .map(([hook, plugins]) => ({ hook, plugins }))
275
+ .sort((a, b) => (a.hook < b.hook ? -1 : a.hook > b.hook ? 1 : 0))
276
+ }
@@ -0,0 +1,143 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+ import { cache } from 'react'
5
+
6
+ import { CacheTags, cachedGlobal, env } from '@meith/core'
7
+ import { logger } from '@meith/core/logger'
8
+ import { getDb, readPluginHealth, recordPluginFailure } from '@meith/db'
9
+ import { drivers } from '@meith/drivers'
10
+ import {
11
+ type DurablyDisabledPlugin,
12
+ operatorDisabledPlugins,
13
+ type PluginDefinition,
14
+ PluginHost,
15
+ } from '@meith/plugin-kit'
16
+
17
+ import { getSettingOverrides } from './settings'
18
+
19
+ const HEALTH_TTL_SECONDS = 30
20
+
21
+ export const FAILURE_THRESHOLD = 5
22
+
23
+ export interface ConfiguredPlugin {
24
+ readonly key: string
25
+ readonly enabled: boolean
26
+ readonly hasDefinition: boolean
27
+ readonly name: string | null
28
+ readonly version: string | null
29
+ }
30
+
31
+ export function configuredPlugins(): readonly ConfiguredPlugin[] {
32
+ return (forumConfig.plugins ?? []).map((entry) => {
33
+ const definition = entry.plugin as PluginDefinition | undefined
34
+ return {
35
+ key: entry.key,
36
+ enabled: entry.enabled !== false,
37
+ hasDefinition: definition !== undefined,
38
+ name: definition?.name ?? null,
39
+ version: definition?.version ?? null,
40
+ }
41
+ })
42
+ }
43
+
44
+ export function activeDefinitions(): readonly PluginDefinition[] {
45
+ return (forumConfig.plugins ?? [])
46
+ .filter((entry) => entry.enabled !== false && entry.plugin !== undefined)
47
+ .map((entry) => entry.plugin as PluginDefinition)
48
+ }
49
+
50
+ export const pluginHost = new PluginHost({
51
+ plugins: activeDefinitions(),
52
+ failureThreshold: FAILURE_THRESHOLD,
53
+ logger: {
54
+ warn: (message, detail) => logger().warn(detail, message),
55
+ error: (message, detail) => logger().error(detail, message),
56
+ },
57
+ health: {
58
+ failed: (failure) => {
59
+ void persistFailure(failure.pluginKey, failure.threshold, failure.hook, failure.message)
60
+ },
61
+ },
62
+ })
63
+
64
+ /**
65
+ * A plugin failing somewhere the host does not itself dispatch — a lifecycle
66
+ * callback — counted and reported exactly as a failing hook is. There is one
67
+ * failure record per plugin, and an operator reading it should not have to know
68
+ * which door the plugin was behind when it broke.
69
+ */
70
+ export async function recordPluginFault(
71
+ pluginKey: string,
72
+ surface: string,
73
+ error: unknown,
74
+ ): Promise<void> {
75
+ await persistFailure(
76
+ pluginKey,
77
+ FAILURE_THRESHOLD,
78
+ surface,
79
+ error instanceof Error ? error.message : String(error),
80
+ )
81
+ }
82
+
83
+ async function persistFailure(
84
+ pluginKey: string,
85
+ threshold: number,
86
+ hook: string,
87
+ message: string,
88
+ ): Promise<void> {
89
+ if (env.DATA_SOURCE !== 'postgres') return
90
+
91
+ try {
92
+ const record = await recordPluginFailure(getDb(), {
93
+ pluginKey,
94
+ threshold,
95
+ reason: `${threshold} failures, most recently in "${hook}": ${message}`,
96
+ })
97
+ if (record.disabledAt !== null) {
98
+ await invalidatePluginHealth()
99
+ await pluginHost.emit('plugin.disabled', { pluginKey, reason: 'failures' }, {})
100
+ }
101
+ } catch (error) {
102
+ logger().warn({ err: String(error), plugin: pluginKey }, 'could not record plugin failure')
103
+ }
104
+ }
105
+
106
+ export async function invalidatePluginHealth(): Promise<void> {
107
+ await drivers().cache.invalidateTags([CacheTags.pluginHealth()])
108
+ }
109
+
110
+ export async function durablyDisabledPlugins(): Promise<readonly DurablyDisabledPlugin[]> {
111
+ if (env.DATA_SOURCE !== 'postgres') return []
112
+
113
+ return cachedGlobal<readonly DurablyDisabledPlugin[]>(
114
+ drivers().cache,
115
+ {
116
+ key: ['plugin', 'health', 'disabled'],
117
+ tags: [CacheTags.pluginHealth()],
118
+ revalidate: HEALTH_TTL_SECONDS,
119
+ },
120
+ async () =>
121
+ (await readPluginHealth(getDb()))
122
+ .filter((row) => row.disabledAt !== null)
123
+ .map((row) => ({ key: row.pluginKey, reason: row.reason ?? 'repeated failures' })),
124
+ )
125
+ }
126
+
127
+ export async function reconcilePluginHealth(): Promise<void> {
128
+ pluginHost.setDurablyDisabled(await durablyDisabledPlugins())
129
+ }
130
+
131
+ export const syncPluginEnablement = cache(async (): Promise<void> => {
132
+ try {
133
+ pluginHost.setOperatorDisabled(operatorDisabledPlugins(await getSettingOverrides()))
134
+ } catch (error) {
135
+ logger().warn({ err: String(error) }, 'could not read plugin enablement')
136
+ }
137
+
138
+ try {
139
+ pluginHost.setDurablyDisabled(await durablyDisabledPlugins())
140
+ } catch (error) {
141
+ logger().warn({ err: String(error) }, 'could not read plugin health')
142
+ }
143
+ })