@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,208 @@
1
+ import type { NextRequest } from 'next/server'
2
+
3
+ import {
4
+ bearerFrom,
5
+ consumeAnonymousRateLimit,
6
+ consumeRateLimit,
7
+ hasScope,
8
+ type Method,
9
+ matchRoute,
10
+ type RateLimitOutcome,
11
+ type RouteSpec,
12
+ rateLimitHeaders,
13
+ routeKey,
14
+ } from '@meith/api'
15
+ import type { Actor } from '@meith/authorization'
16
+ import { isAppError, metrics, statusForError, toPublicError, withSpan } from '@meith/core'
17
+ import { currentRequestId } from '@meith/core/logger'
18
+
19
+ import { JSON_MEDIA_TYPE } from '@/server/api/http'
20
+ import { handlerFor } from '@/server/api/registry'
21
+ import {
22
+ type AuthenticatedToken,
23
+ anonymousLimits,
24
+ anonymousSubject,
25
+ apiActor,
26
+ apiGuest,
27
+ apiToken,
28
+ } from '@/server/api-auth'
29
+ import { boardOffline } from '@/server/board-offline'
30
+ import { getMessageResolver } from '@/server/i18n'
31
+
32
+ export const dynamic = 'force-dynamic'
33
+
34
+ function json(body: unknown, status: number, headers: Record<string, string> = {}): Response {
35
+ return new Response(JSON.stringify(body), {
36
+ status,
37
+ headers: { 'content-type': JSON_MEDIA_TYPE, ...headers },
38
+ })
39
+ }
40
+
41
+ function fail(status: number, code: string, message: string, headers = {}): Response {
42
+ return json({ error: { code, message, requestId: currentRequestId() ?? null } }, status, headers)
43
+ }
44
+
45
+ interface Caller {
46
+ readonly actor: Actor
47
+ readonly authenticated: AuthenticatedToken | null
48
+ readonly limit: RateLimitOutcome | null
49
+ }
50
+
51
+ async function resolveCaller(
52
+ route: RouteSpec,
53
+ presented: string | null,
54
+ ): Promise<Caller | Response> {
55
+ if (presented !== null) {
56
+ const authenticated = await apiToken(presented)
57
+ if (authenticated === null) return fail(401, 'unauthenticated', 'That token is not valid.')
58
+
59
+ if (!hasScope(authenticated.token, route.scope)) {
60
+ return fail(403, 'missing_scope', `This token does not carry the "${route.scope}" scope.`)
61
+ }
62
+
63
+ const limit = await consumeRateLimit(
64
+ authenticated.limits,
65
+ authenticated.token.id,
66
+ route.cost,
67
+ new Date(),
68
+ )
69
+ if (!limit.allowed) return refused(limit)
70
+
71
+ const actor = await apiActor(authenticated.token.userId)
72
+ if (actor === null) {
73
+ return fail(403, 'owner_unavailable', 'The account this token belongs to cannot act.', {
74
+ ...rateLimitHeaders(limit),
75
+ })
76
+ }
77
+
78
+ return { actor, authenticated, limit }
79
+ }
80
+
81
+ if (route.authenticated) {
82
+ return fail(401, 'unauthenticated', 'Send a bearer token in the Authorization header.')
83
+ }
84
+
85
+ const store = anonymousLimits()
86
+ const limit =
87
+ store === null
88
+ ? null
89
+ : await consumeAnonymousRateLimit(store, await anonymousSubject(), route.cost, new Date())
90
+
91
+ if (limit !== null && !limit.allowed) return refused(limit)
92
+
93
+ return { actor: await apiGuest(), authenticated: null, limit }
94
+ }
95
+
96
+ function refused(limit: RateLimitOutcome): Response {
97
+ return fail(429, 'rate_limited', 'Too many requests. Slow down and try again.', {
98
+ ...rateLimitHeaders(limit),
99
+ 'retry-after': String(limit.resetSeconds),
100
+ })
101
+ }
102
+
103
+ const httpRequestSeconds = metrics.histogram(
104
+ 'meith_http_request_duration_seconds',
105
+ 'REST API v1 request duration in seconds, labelled by method, route and status.',
106
+ )
107
+
108
+ async function handle(request: NextRequest, method: Method): Promise<Response> {
109
+ const startedAt = Date.now()
110
+ const url = new URL(request.url)
111
+ const path = url.pathname.replace(/^\/api\/v1/, '')
112
+ const matched = matchRoute(method, path)
113
+ const routeLabel = matched === null ? 'unmatched' : matched.route.path
114
+
115
+ const response = await withSpan(
116
+ 'http.request',
117
+ { 'http.method': method, 'http.route': routeLabel },
118
+ async (span) => {
119
+ const result = await respondTo(request, url, matched)
120
+ span.setAttribute('http.status_code', result.status)
121
+ return result
122
+ },
123
+ )
124
+
125
+ httpRequestSeconds.observe((Date.now() - startedAt) / 1000, {
126
+ method,
127
+ route: routeLabel,
128
+ status: String(response.status),
129
+ })
130
+
131
+ return response
132
+ }
133
+
134
+ async function respondTo(
135
+ request: NextRequest,
136
+ url: URL,
137
+ matched: ReturnType<typeof matchRoute>,
138
+ ): Promise<Response> {
139
+ if (matched === null) {
140
+ return fail(404, 'no_such_route', 'No such endpoint. See /api/v1/openapi.json for the routes.')
141
+ }
142
+
143
+ const caller = await resolveCaller(
144
+ matched.route,
145
+ bearerFrom(request.headers.get('authorization')),
146
+ )
147
+ if (caller instanceof Response) return caller
148
+
149
+ const headers = caller.limit === null ? {} : rateLimitHeaders(caller.limit)
150
+
151
+ const offline = await boardOffline(caller.actor)
152
+ if (offline !== null) return fail(503, 'board_offline', offline.message, headers)
153
+
154
+ const handler = handlerFor(routeKey(matched.route))
155
+ if (handler === null) {
156
+ return fail(
157
+ 501,
158
+ 'not_implemented',
159
+ `${matched.route.method} ${matched.route.path} is declared but not yet implemented.`,
160
+ headers,
161
+ )
162
+ }
163
+
164
+ try {
165
+ const result = await handler({
166
+ actor: caller.actor,
167
+ token: caller.authenticated?.token ?? null,
168
+ params: matched.params,
169
+ url,
170
+ body: matched.route.request === undefined ? null : await readJsonBody(request),
171
+ })
172
+
173
+ return json(result.body, result.status, headers)
174
+ } catch (err) {
175
+ if (isAppError(err)) {
176
+ const { error } = toPublicError(err, await getMessageResolver())
177
+ return fail(statusForError(err), error.code, error.message, headers)
178
+ }
179
+ throw err
180
+ }
181
+ }
182
+
183
+ async function readJsonBody(request: NextRequest): Promise<Record<string, unknown> | null> {
184
+ try {
185
+ const parsed: unknown = await request.json()
186
+ return typeof parsed === 'object' && parsed !== null
187
+ ? (parsed as Record<string, unknown>)
188
+ : null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
194
+ export async function GET(request: NextRequest): Promise<Response> {
195
+ return handle(request, 'GET')
196
+ }
197
+
198
+ export async function POST(request: NextRequest): Promise<Response> {
199
+ return handle(request, 'POST')
200
+ }
201
+
202
+ export async function PATCH(request: NextRequest): Promise<Response> {
203
+ return handle(request, 'PATCH')
204
+ }
205
+
206
+ export async function DELETE(request: NextRequest): Promise<Response> {
207
+ return handle(request, 'DELETE')
208
+ }
@@ -0,0 +1,12 @@
1
+ import { openApiDocument } from '@meith/api'
2
+
3
+ import { JSON_MEDIA_TYPE, SPEC_CACHE } from '@/server/api/http'
4
+ import { CODE_VERSION } from '@/server/upgrade-notice'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(): Promise<Response> {
9
+ return new Response(JSON.stringify(openApiDocument(CODE_VERSION)), {
10
+ headers: { 'content-type': JSON_MEDIA_TYPE, 'cache-control': SPEC_CACHE },
11
+ })
12
+ }
@@ -0,0 +1,11 @@
1
+ import { renderBrandMark } from '@/server/brand-mark'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export const size = { width: 180, height: 180 }
6
+
7
+ export const contentType = 'image/png'
8
+
9
+ export default function AppleIcon(): Promise<Response> {
10
+ return renderBrandMark(size.width)
11
+ }
@@ -0,0 +1,7 @@
1
+ import { boardFeed } from '@/server/feed-routes'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(): Promise<Response> {
6
+ return boardFeed('atom', '/atom.xml')
7
+ }
@@ -0,0 +1,10 @@
1
+ import { serveAttachment } from '@/server/attachment-download'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(
6
+ _request: Request,
7
+ context: { params: Promise<{ id: string }> },
8
+ ): Promise<Response> {
9
+ return serveAttachment((await context.params).id, 'file')
10
+ }
@@ -0,0 +1,10 @@
1
+ import { serveAttachment } from '@/server/attachment-download'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(
6
+ _request: Request,
7
+ context: { params: Promise<{ id: string }> },
8
+ ): Promise<Response> {
9
+ return serveAttachment((await context.params).id, 'thumb')
10
+ }
@@ -0,0 +1,145 @@
1
+ import type { NextRequest } from 'next/server'
2
+
3
+ import { newChallenge } from '@meith/accounts'
4
+ import { logger, statusForError, toPublicError } from '@meith/core'
5
+
6
+ import { configuredIdentity, getContainer } from '@/server/container'
7
+ import { getActor } from '@/server/context'
8
+ import { currentCredentialProof } from '@/server/credential-proof'
9
+ import {
10
+ memberManagedSignIns,
11
+ passkeyRelyingPartyName,
12
+ passkeyService,
13
+ passkeysEnabled,
14
+ relyingParty,
15
+ } from '@/server/federation'
16
+ import { tr } from '@/server/i18n'
17
+ import { type PasskeyPurpose, packChallenge } from '@/server/passkey-challenge'
18
+ import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
19
+ import { readSecondFactorToken, setPasskeyChallengeCookie } from '@/server/session-cookies'
20
+
21
+ export const runtime = 'nodejs'
22
+ export const dynamic = 'force-dynamic'
23
+
24
+ function json(body: unknown, status = 200): Response {
25
+ return new Response(JSON.stringify(body), {
26
+ status,
27
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
28
+ })
29
+ }
30
+
31
+ function problem(message: string, status: number): Response {
32
+ return json({ error: { code: 'FORBIDDEN', message } }, status)
33
+ }
34
+
35
+ export async function POST(request: NextRequest): Promise<Response> {
36
+ if (!isSameOrigin(request)) return crossOriginRefusal()
37
+
38
+ if (!(await passkeysEnabled())) {
39
+ return problem(await tr('authRoute.passkey.disabled'), 404)
40
+ }
41
+
42
+ const asked = request.nextUrl.searchParams.get('for')
43
+ const purpose: PasskeyPurpose =
44
+ asked === 'register' || asked === 'second-factor' || asked === 'credential-proof'
45
+ ? asked
46
+ : 'authenticate'
47
+
48
+ const challenge = newChallenge()
49
+
50
+ try {
51
+ if (purpose === 'second-factor') {
52
+ const held = await readSecondFactorToken()
53
+ const pending =
54
+ held === undefined || held === ''
55
+ ? null
56
+ : await (await configuredIdentity()).pendingSecondFactor(held)
57
+
58
+ if (pending === null) {
59
+ return problem(await tr('authRoute.passkey.signInExpired'), 403)
60
+ }
61
+
62
+ const heldPasskeys = await getContainer().accountStore.passkeys.listForUser(pending.userId)
63
+ if (heldPasskeys.length === 0) {
64
+ return problem(await tr('authRoute.passkey.noneForAccount'), 403)
65
+ }
66
+
67
+ const options = (await passkeyService()).assertionOptions({
68
+ challenge,
69
+ relyingParty: await relyingParty(),
70
+ allowCredentials: heldPasskeys.map((passkey) => passkey.credentialId),
71
+ })
72
+
73
+ await setPasskeyChallengeCookie(packChallenge(purpose, challenge))
74
+ return json(options)
75
+ }
76
+
77
+ if (purpose === 'credential-proof') {
78
+ const actor = await getActor()
79
+ if (actor.userId === null) {
80
+ return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
81
+ }
82
+ const token = await import('@/server/session-cookies').then((module) =>
83
+ module.readSessionToken(),
84
+ )
85
+ const located = token ? await (await configuredIdentity()).locateSession(token) : null
86
+ if (located === null || located.userId !== actor.userId) {
87
+ return problem(await tr('authRoute.passkey.signInExpired'), 403)
88
+ }
89
+ const heldPasskeys = await getContainer().accountStore.passkeys.listForUser(actor.userId)
90
+ if (heldPasskeys.length === 0) {
91
+ return problem(await tr('authRoute.passkey.noneForAccount'), 403)
92
+ }
93
+ const options = (await passkeyService()).assertionOptions({
94
+ challenge,
95
+ relyingParty: await relyingParty(),
96
+ allowCredentials: heldPasskeys.map((passkey) => passkey.credentialId),
97
+ })
98
+ await setPasskeyChallengeCookie(
99
+ packChallenge(purpose, challenge, { userId: actor.userId, sessionId: located.sessionId }),
100
+ )
101
+ return json(options)
102
+ }
103
+
104
+ if (purpose === 'authenticate') {
105
+ const options = (await passkeyService()).assertionOptions({
106
+ challenge,
107
+ relyingParty: await relyingParty(),
108
+ })
109
+ await setPasskeyChallengeCookie(packChallenge(purpose, challenge))
110
+ return json(options)
111
+ }
112
+
113
+ const actor = await getActor()
114
+ if (actor.userId === null) {
115
+ return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
116
+ }
117
+ if (!(await memberManagedSignIns())) {
118
+ return problem(await tr('authRoute.passkey.managedSignIns'), 403)
119
+ }
120
+
121
+ const proof = await currentCredentialProof(actor.userId)
122
+ if (proof === null) {
123
+ return problem(await tr('authRoute.passkey.recentProofRequired'), 403)
124
+ }
125
+
126
+ const options = await (await passkeyService()).registrationOptions({
127
+ userId: actor.userId,
128
+ challenge,
129
+ relyingParty: await relyingParty(),
130
+ boardName: await passkeyRelyingPartyName(),
131
+ })
132
+
133
+ await setPasskeyChallengeCookie(
134
+ packChallenge(purpose, challenge, {
135
+ userId: actor.userId,
136
+ sessionId: proof.session.id,
137
+ provedAt: proof.provedAt.getTime(),
138
+ }),
139
+ )
140
+ return json(options)
141
+ } catch (err) {
142
+ logger({ module: 'passkeys' }).warn({ err, purpose }, 'could not issue passkey options')
143
+ return json(toPublicError(err), statusForError(err))
144
+ }
145
+ }
@@ -0,0 +1,241 @@
1
+ import type { NextRequest } from 'next/server'
2
+
3
+ import { logger, statusForError, toPublicError } from '@meith/core'
4
+
5
+ import { recordAuthEvent } from '@/server/auth-events'
6
+ import { configuredIdentity, configuredSessions } from '@/server/container'
7
+ import { getActor } from '@/server/context'
8
+ import {
9
+ currentCredentialProof,
10
+ markCurrentSessionCredentialProved,
11
+ } from '@/server/credential-proof'
12
+ import {
13
+ memberManagedSignIns,
14
+ passkeyService,
15
+ passkeysEnabled,
16
+ relyingParty,
17
+ } from '@/server/federation'
18
+ import { tr } from '@/server/i18n'
19
+ import { type PasskeyPurpose, unpackChallenge } from '@/server/passkey-challenge'
20
+ import { retainedIpPrefix } from '@/server/request-fingerprint'
21
+ import { isSafeLocalPath } from '@/server/safe-path'
22
+ import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
23
+ import {
24
+ clearPasskeyChallengeCookie,
25
+ clearSecondFactorCookie,
26
+ readPasskeyChallengeCookie,
27
+ readSecondFactorToken,
28
+ readSessionToken,
29
+ setRememberCookie,
30
+ setSessionCookie,
31
+ } from '@/server/session-cookies'
32
+
33
+ export const runtime = 'nodejs'
34
+ export const dynamic = 'force-dynamic'
35
+
36
+ interface Submission {
37
+ readonly id?: unknown
38
+ readonly label?: unknown
39
+ readonly transports?: unknown
40
+ readonly clientDataJSON?: unknown
41
+ readonly attestationObject?: unknown
42
+ readonly authenticatorData?: unknown
43
+ readonly signature?: unknown
44
+ readonly next?: unknown
45
+ }
46
+
47
+ function json(body: unknown, status = 200): Response {
48
+ return new Response(JSON.stringify(body), {
49
+ status,
50
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
51
+ })
52
+ }
53
+
54
+ function problem(message: string, status: number): Response {
55
+ return json({ error: { code: 'FORBIDDEN', message } }, status)
56
+ }
57
+
58
+ function text(value: unknown): string | null {
59
+ return typeof value === 'string' && value !== '' ? value : null
60
+ }
61
+
62
+ export async function POST(request: NextRequest): Promise<Response> {
63
+ if (!isSameOrigin(request)) return crossOriginRefusal()
64
+
65
+ if (!(await passkeysEnabled())) {
66
+ return problem(await tr('authRoute.passkey.disabled'), 404)
67
+ }
68
+
69
+ const asked = request.nextUrl.searchParams.get('for')
70
+ const purpose: PasskeyPurpose =
71
+ asked === 'register' || asked === 'second-factor' || asked === 'credential-proof'
72
+ ? asked
73
+ : 'authenticate'
74
+
75
+ let body: Submission
76
+ try {
77
+ body = (await request.json()) as Submission
78
+ } catch {
79
+ return problem(await tr('authRoute.passkey.responseUnreadable'), 400)
80
+ }
81
+
82
+ const challengeState = unpackChallenge(await readPasskeyChallengeCookie(), purpose)
83
+ await clearPasskeyChallengeCookie()
84
+
85
+ if (challengeState === null) {
86
+ return problem(await tr('authRoute.passkey.attemptExpired'), 400)
87
+ }
88
+
89
+ const clientDataJSON = text(body.clientDataJSON)
90
+ if (clientDataJSON === null) {
91
+ return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
92
+ }
93
+
94
+ const log = logger({ module: 'passkeys' })
95
+
96
+ try {
97
+ const service = await passkeyService()
98
+ const party = await relyingParty()
99
+
100
+ if (purpose === 'register') {
101
+ const actor = await getActor()
102
+ if (actor.userId === null)
103
+ return problem(await tr('authRoute.passkey.signInBeforeAdding'), 403)
104
+ if (!(await memberManagedSignIns())) {
105
+ return problem(await tr('authRoute.passkey.managedSignIns'), 403)
106
+ }
107
+
108
+ const proof = await currentCredentialProof(actor.userId)
109
+ if (
110
+ proof === null ||
111
+ challengeState.userId !== actor.userId ||
112
+ challengeState.sessionId !== proof.session.id ||
113
+ challengeState.provedAt !== proof.provedAt.getTime()
114
+ ) {
115
+ return problem(await tr('authRoute.passkey.recentProofRequired'), 403)
116
+ }
117
+
118
+ const attestationObject = text(body.attestationObject)
119
+ if (attestationObject === null) {
120
+ return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
121
+ }
122
+
123
+ const passkey = await service.enrol({
124
+ userId: actor.userId,
125
+ label: text(body.label) ?? '',
126
+ expectedChallenge: challengeState.challenge,
127
+ relyingParty: party,
128
+ response: {
129
+ clientDataJSON,
130
+ attestationObject,
131
+ ...(Array.isArray(body.transports)
132
+ ? { transports: body.transports.filter((entry) => typeof entry === 'string') }
133
+ : {}),
134
+ },
135
+ })
136
+
137
+ await recordAuthEvent({ userId: actor.userId, kind: 'passkey_added' })
138
+ return json({ ok: true, label: passkey.label })
139
+ }
140
+
141
+ const credentialId = text(body.id)
142
+ const authenticatorData = text(body.authenticatorData)
143
+ const signature = text(body.signature)
144
+
145
+ if (credentialId === null || authenticatorData === null || signature === null) {
146
+ return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
147
+ }
148
+
149
+ if (purpose === 'credential-proof') {
150
+ const actor = await getActor()
151
+ const token = await readSessionToken()
152
+ const located = token ? await (await configuredIdentity()).locateSession(token) : null
153
+ if (
154
+ actor.userId === null ||
155
+ located === null ||
156
+ located.userId !== actor.userId ||
157
+ challengeState.userId !== actor.userId ||
158
+ challengeState.sessionId !== located.sessionId
159
+ ) {
160
+ return problem(await tr('authRoute.passkey.signInExpired'), 403)
161
+ }
162
+
163
+ const proved = await service.proveOwnership({
164
+ userId: actor.userId,
165
+ credentialId,
166
+ expectedChallenge: challengeState.challenge,
167
+ relyingParty: party,
168
+ response: { clientDataJSON, authenticatorData, signature },
169
+ })
170
+ if (!proved || !(await markCurrentSessionCredentialProved(actor.userId))) {
171
+ return problem(await tr('authRoute.passkey.notForAccount'), 403)
172
+ }
173
+ return json({ ok: true, next: '/usercp/security?verified=1' })
174
+ }
175
+
176
+ if (purpose === 'second-factor') {
177
+ const held = await readSecondFactorToken()
178
+ const identity = await configuredIdentity()
179
+ const pending =
180
+ held === undefined || held === '' ? null : await identity.pendingSecondFactor(held)
181
+
182
+ if (pending === null || held === undefined) {
183
+ return problem(await tr('authRoute.passkey.signInExpired'), 403)
184
+ }
185
+
186
+ await identity.assertSecondFactorAttemptsLeft(pending.userId)
187
+
188
+ const proved = await service.proveOwnership({
189
+ userId: pending.userId,
190
+ credentialId,
191
+ expectedChallenge: challengeState.challenge,
192
+ relyingParty: party,
193
+ response: { clientDataJSON, authenticatorData, signature },
194
+ })
195
+
196
+ if (!proved) {
197
+ await identity.recordSecondFactorFailure(pending.userId)
198
+ await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
199
+ return problem(await tr('authRoute.passkey.notForAccount'), 403)
200
+ }
201
+
202
+ await identity.clearSecondFactorFailures(pending.userId)
203
+
204
+ const login = await identity.redeemSecondFactor(held, {
205
+ ipPrefix: await retainedIpPrefix(),
206
+ })
207
+
208
+ await clearSecondFactorCookie()
209
+ await setSessionCookie(login.sessionToken, login.expiresAt)
210
+
211
+ if (pending.remember) {
212
+ const remembered = await (await configuredSessions()).startRemembered(pending.userId)
213
+ await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
214
+ }
215
+
216
+ await recordAuthEvent({ userId: pending.userId, kind: 'login' })
217
+
218
+ const target = text(body.next)
219
+ return json({
220
+ ok: true,
221
+ next: target !== null && isSafeLocalPath(target) ? target : '/',
222
+ })
223
+ }
224
+
225
+ const outcome = await service.authenticate({
226
+ credentialId,
227
+ expectedChallenge: challengeState.challenge,
228
+ relyingParty: party,
229
+ response: { clientDataJSON, authenticatorData, signature },
230
+ context: { ipPrefix: await retainedIpPrefix() },
231
+ })
232
+
233
+ await setSessionCookie(outcome.login.sessionToken, outcome.login.expiresAt)
234
+
235
+ const next = text(body.next)
236
+ return json({ ok: true, next: next !== null && isSafeLocalPath(next) ? next : '/' })
237
+ } catch (err) {
238
+ log.warn({ err, purpose }, 'a passkey exchange was refused')
239
+ return json(toPublicError(err), statusForError(err))
240
+ }
241
+ }