@jant/core 0.6.16 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (592) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/export.js +3 -1
  4. package/bin/commands/import-site.js +749 -220
  5. package/bin/commands/reset-password.js +21 -8
  6. package/bin/commands/setup.js +136 -0
  7. package/bin/commands/site/export.js +71 -34
  8. package/bin/commands/site/pull-media.js +2 -6
  9. package/bin/commands/site/snapshot/export.js +19 -61
  10. package/bin/commands/site/snapshot/import.js +23 -24
  11. package/bin/lib/d1-query.js +92 -18
  12. package/bin/lib/hugo-markdown.js +4 -0
  13. package/bin/lib/node-env.js +40 -4
  14. package/bin/lib/site-pull-media.js +21 -28
  15. package/bin/lib/site-selection.js +94 -29
  16. package/bin/lib/site-snapshot.js +338 -3
  17. package/bin/lib/sql-export.js +68 -5
  18. package/bin/lib/wrangler-cli.js +37 -2
  19. package/bin/lib/wrangler-json.js +35 -0
  20. package/bin/lib/zip-archive.js +187 -0
  21. package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
  22. package/dist/client/.vite/manifest.json +255 -90
  23. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  24. package/dist/client/_assets/chunks/create-editor-B-m7X7S5.js +261 -0
  25. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  26. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  27. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  28. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  29. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  30. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  31. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  32. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  33. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  34. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  35. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  36. package/dist/client/_assets/chunks/sortable-list-BJyd-LXE.js +2 -0
  37. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  38. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  39. package/dist/client/_assets/chunks/unsafe-svg-BkscJx69.js +2 -0
  40. package/dist/client/_assets/client-DAhoqPdr.css +2 -0
  41. package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
  42. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  43. package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
  44. package/dist/client/_assets/client-knSEyUJO.js +295 -0
  45. package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
  46. package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
  47. package/dist/github-sync-Gw4orAHk.js +8398 -0
  48. package/dist/index.js +2 -5
  49. package/dist/node.js +11 -9
  50. package/package.json +15 -5
  51. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  52. package/src/__tests__/basecoat-variants.test.ts +121 -0
  53. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  54. package/src/__tests__/dev-scripts.test.ts +203 -0
  55. package/src/__tests__/export-collection-order.test.ts +276 -0
  56. package/src/__tests__/export-feed-ids.test.ts +184 -0
  57. package/src/__tests__/export-feed-order.test.ts +294 -0
  58. package/src/__tests__/export-hugo-build.test.ts +390 -0
  59. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  60. package/src/__tests__/export-service.test.ts +842 -28
  61. package/src/__tests__/export-smart-collection.test.ts +329 -0
  62. package/src/__tests__/helpers/component-css.ts +91 -0
  63. package/src/__tests__/helpers/db.ts +33 -0
  64. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  65. package/src/__tests__/helpers/hugo-site.ts +146 -0
  66. package/src/__tests__/import-site-command.test.ts +487 -1
  67. package/src/__tests__/mise-config.test.ts +75 -4
  68. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  69. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  70. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  71. package/src/__tests__/snapshot-settings.test.ts +97 -0
  72. package/src/__tests__/snapshot-tables.test.ts +219 -0
  73. package/src/__tests__/sql-export.test.ts +173 -0
  74. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  75. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  76. package/src/__tests__/zip-archive.test.ts +106 -0
  77. package/src/app.tsx +86 -22
  78. package/src/auth.ts +18 -1
  79. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  80. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  81. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  82. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  83. package/src/client/__tests__/compose-launch.test.ts +62 -1
  84. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  85. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  86. package/src/client/__tests__/copy-field.test.ts +162 -0
  87. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  88. package/src/client/__tests__/image-processor.test.ts +324 -2
  89. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  90. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  91. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  92. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  93. package/src/client/__tests__/toast.test.ts +23 -0
  94. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  95. package/src/client/__tests__/video-processor.test.ts +181 -0
  96. package/src/client/audio-processor.ts +24 -15
  97. package/src/client/audio-waveform.ts +49 -0
  98. package/src/client/collection-dialog-host.ts +72 -0
  99. package/src/client/collection-navigation.ts +51 -16
  100. package/src/client/collection-page-actions.ts +26 -0
  101. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  102. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  103. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  104. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  105. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  106. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  107. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  108. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  109. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  110. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  111. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  112. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  113. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  114. package/src/client/components/__tests__/jant-settings-general.test.ts +561 -172
  115. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  116. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  117. package/src/client/components/collection-dialog-types.ts +49 -0
  118. package/src/client/components/collection-manager-types.ts +32 -1
  119. package/src/client/components/collection-types.ts +1 -15
  120. package/src/client/components/compose-types.ts +75 -1
  121. package/src/client/components/jant-collection-dialog.ts +722 -0
  122. package/src/client/components/jant-collection-directory.ts +567 -182
  123. package/src/client/components/jant-collection-form.ts +53 -232
  124. package/src/client/components/jant-command-palette.ts +100 -49
  125. package/src/client/components/jant-compose-dialog.ts +1486 -707
  126. package/src/client/components/jant-compose-editor.ts +673 -336
  127. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  128. package/src/client/components/jant-config-editor.ts +65 -53
  129. package/src/client/components/jant-confirm-dialog.ts +13 -11
  130. package/src/client/components/jant-locale-picker.ts +418 -0
  131. package/src/client/components/jant-media-lightbox.ts +146 -136
  132. package/src/client/components/jant-nav-manager.ts +690 -366
  133. package/src/client/components/jant-post-menu.ts +1086 -282
  134. package/src/client/components/jant-post-picker.ts +324 -0
  135. package/src/client/components/jant-repo-picker-types.ts +6 -1
  136. package/src/client/components/jant-repo-picker.ts +137 -108
  137. package/src/client/components/jant-settings-avatar.ts +31 -27
  138. package/src/client/components/jant-settings-general.ts +622 -543
  139. package/src/client/components/jant-settings-language.ts +1087 -0
  140. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  141. package/src/client/components/jant-text-preview.ts +94 -86
  142. package/src/client/components/nav-manager-types.ts +62 -1
  143. package/src/client/components/settings-types.ts +14 -29
  144. package/src/client/components/smart-collection-conditions.ts +285 -0
  145. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  146. package/src/client/compose-bridge.ts +214 -141
  147. package/src/client/compose-launch.ts +76 -2
  148. package/src/client/compose-shortcuts.ts +6 -8
  149. package/src/client/compose-triggers.ts +50 -0
  150. package/src/client/copy-field.ts +108 -0
  151. package/src/client/custom-url-menu.ts +60 -4
  152. package/src/client/icons.ts +14 -0
  153. package/src/client/image-plan.ts +165 -0
  154. package/src/client/image-processor.ts +195 -117
  155. package/src/client/image-worker-client.ts +45 -0
  156. package/src/client/image-worker.ts +135 -0
  157. package/src/client/lazy-entries.ts +95 -0
  158. package/src/client/lazy-slugify.ts +2 -2
  159. package/src/client/media-scroll-hint.ts +43 -2
  160. package/src/client/mediabunny.ts +29 -0
  161. package/src/client/post-picker.ts +41 -0
  162. package/src/client/runtime-paths.ts +74 -1
  163. package/src/client/settings-bridge.ts +4 -10
  164. package/src/client/site-header-nav.js +18 -6
  165. package/src/client/smart-collection-dialog-host.ts +116 -0
  166. package/src/client/smart-collection-page-actions.ts +208 -0
  167. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  168. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  169. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  170. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  171. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  172. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  173. package/src/client/tiptap/bubble-menu.ts +47 -16
  174. package/src/client/tiptap/extensions.ts +0 -3
  175. package/src/client/tiptap/floating-position.ts +216 -2
  176. package/src/client/tiptap/inline-image-upload.ts +7 -3
  177. package/src/client/tiptap/link-toolbar.ts +22 -9
  178. package/src/client/tiptap/structural-keymap.ts +158 -47
  179. package/src/client/toast.ts +13 -0
  180. package/src/client/types/vite-worker.d.ts +12 -0
  181. package/src/client/upload-with-metadata.ts +3 -13
  182. package/src/client/video-processor.ts +351 -107
  183. package/src/client-auth.ts +14 -31
  184. package/src/client-compose.ts +19 -0
  185. package/src/client-manage.ts +13 -0
  186. package/src/client-settings.ts +14 -0
  187. package/src/client-site.ts +10 -3
  188. package/src/client.ts +4 -0
  189. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  190. package/src/db/__tests__/d1-query.test.ts +56 -1
  191. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  192. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  193. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  194. package/src/db/migrations/0032_married_tomas.sql +3 -0
  195. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  196. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  197. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  198. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  199. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  200. package/src/db/migrations/meta/_journal.json +21 -0
  201. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  202. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  203. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  204. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  205. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  206. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  207. package/src/db/migrations/pg/meta/_journal.json +21 -0
  208. package/src/db/pg/schema.ts +195 -27
  209. package/src/db/post-visibility.ts +117 -0
  210. package/src/db/schema.ts +222 -27
  211. package/src/i18n/__tests__/detect.test.ts +26 -81
  212. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  213. package/src/i18n/context.tsx +12 -2
  214. package/src/i18n/detect.ts +7 -66
  215. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  216. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  217. package/src/i18n/locales/public/en.po +490 -158
  218. package/src/i18n/locales/public/en.ts +1 -1
  219. package/src/i18n/locales/public/zh-Hans.po +487 -155
  220. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  221. package/src/i18n/locales/public/zh-Hant.po +487 -155
  222. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  223. package/src/i18n/locales/settings/en.po +555 -73
  224. package/src/i18n/locales/settings/en.ts +1 -1
  225. package/src/i18n/locales/settings/zh-Hans.po +601 -119
  226. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  227. package/src/i18n/locales/settings/zh-Hant.po +614 -132
  228. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  229. package/src/i18n/locales.ts +94 -0
  230. package/src/i18n/middleware.ts +12 -2
  231. package/src/i18n/supported-locales.ts +80 -0
  232. package/src/lib/__tests__/api-settings.test.ts +11 -2
  233. package/src/lib/__tests__/asset-path.test.ts +92 -0
  234. package/src/lib/__tests__/deferred.test.ts +90 -0
  235. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  236. package/src/lib/__tests__/discover.test.ts +410 -0
  237. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  238. package/src/lib/__tests__/feed.test.ts +1526 -15
  239. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  240. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  241. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  242. package/src/lib/__tests__/http-cache.test.ts +162 -0
  243. package/src/lib/__tests__/image.test.ts +27 -1
  244. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  245. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  246. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  247. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  248. package/src/lib/__tests__/markdown.test.ts +10 -0
  249. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  250. package/src/lib/__tests__/navigation.test.ts +135 -1
  251. package/src/lib/__tests__/resolve-config.test.ts +131 -3
  252. package/src/lib/__tests__/schemas.test.ts +69 -5
  253. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  254. package/src/lib/__tests__/slug.test.ts +70 -0
  255. package/src/lib/__tests__/slugify.test.ts +85 -0
  256. package/src/lib/__tests__/storage.test.ts +32 -0
  257. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  258. package/src/lib/__tests__/timeline.test.ts +87 -0
  259. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  260. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  261. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  262. package/src/lib/__tests__/url.test.ts +78 -72
  263. package/src/lib/__tests__/view-language.test.ts +413 -0
  264. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  265. package/src/lib/address.ts +57 -0
  266. package/src/lib/api-posts.ts +12 -0
  267. package/src/lib/asset-path.ts +98 -1
  268. package/src/lib/build-env.ts +16 -0
  269. package/src/lib/collection-groups.ts +5 -1
  270. package/src/lib/collection-paths.ts +3 -10
  271. package/src/lib/constants.ts +28 -3
  272. package/src/lib/copy-field.ts +36 -0
  273. package/src/lib/crypto.ts +40 -0
  274. package/src/lib/csp-builder.ts +2 -1
  275. package/src/lib/deferred.ts +79 -0
  276. package/src/lib/discover-ping.ts +141 -0
  277. package/src/lib/discover.ts +439 -0
  278. package/src/lib/env.ts +134 -0
  279. package/src/lib/errors.ts +33 -0
  280. package/src/lib/feed-policy.ts +236 -2
  281. package/src/lib/feed.ts +926 -73
  282. package/src/lib/filter-dimensions.ts +1349 -0
  283. package/src/lib/github-sync-repo-name.ts +45 -0
  284. package/src/lib/github-sync-site-config.ts +3 -0
  285. package/src/lib/hosted-signin.ts +1 -1
  286. package/src/lib/http-cache.ts +148 -0
  287. package/src/lib/hugo-markdown.ts +62 -0
  288. package/src/lib/icons.ts +9 -2
  289. package/src/lib/ids.ts +3 -0
  290. package/src/lib/image.ts +17 -4
  291. package/src/lib/jant-docs.ts +42 -0
  292. package/src/lib/lang-detect.ts +308 -0
  293. package/src/lib/markdown-manager.ts +392 -2
  294. package/src/lib/media-helpers.ts +13 -1
  295. package/src/lib/navigation.ts +78 -11
  296. package/src/lib/pagination.ts +28 -24
  297. package/src/lib/per-language-surfaces.ts +67 -0
  298. package/src/lib/post-body-html.ts +11 -4
  299. package/src/lib/post-meta.ts +19 -0
  300. package/src/lib/render.tsx +55 -7
  301. package/src/lib/resolve-config.ts +111 -6
  302. package/src/lib/schemas.ts +251 -15
  303. package/src/lib/site-header-fragment.tsx +2 -1
  304. package/src/lib/site-unavailable-page.ts +47 -0
  305. package/src/lib/slug.ts +51 -7
  306. package/src/lib/slugify.ts +113 -0
  307. package/src/lib/startup-config.ts +4 -6
  308. package/src/lib/storage.ts +7 -0
  309. package/src/lib/summary.ts +50 -0
  310. package/src/lib/thread-fold.ts +161 -0
  311. package/src/lib/time.ts +32 -0
  312. package/src/lib/timeline.ts +43 -2
  313. package/src/lib/tiptap-render.ts +1 -2
  314. package/src/lib/tiptap-to-markdown.ts +13 -7
  315. package/src/lib/upload.ts +16 -13
  316. package/src/lib/url.ts +159 -86
  317. package/src/lib/version.ts +10 -8
  318. package/src/lib/view-language.ts +436 -0
  319. package/src/lib/view.ts +53 -25
  320. package/src/lib/viewer-context.ts +62 -0
  321. package/src/middleware/__tests__/auth.test.ts +70 -2
  322. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  323. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  324. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  325. package/src/middleware/__tests__/session.test.ts +181 -33
  326. package/src/middleware/auth.ts +50 -31
  327. package/src/middleware/cache-control.ts +75 -10
  328. package/src/middleware/config.ts +6 -6
  329. package/src/middleware/error-handler.ts +12 -0
  330. package/src/middleware/onboarding.ts +32 -10
  331. package/src/middleware/secure-headers.ts +1 -1
  332. package/src/middleware/session.ts +96 -8
  333. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  334. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  335. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  336. package/src/node/__tests__/cli-setup.test.ts +163 -0
  337. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  338. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  339. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  340. package/src/node/__tests__/runtime.test.ts +75 -0
  341. package/src/node/index.ts +3 -0
  342. package/src/node/request-handler.ts +3 -1
  343. package/src/preset.css +76 -22
  344. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  345. package/src/routes/api/__tests__/collections.test.ts +114 -0
  346. package/src/routes/api/__tests__/discover.test.ts +222 -0
  347. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  348. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  349. package/src/routes/api/__tests__/posts.test.ts +24 -0
  350. package/src/routes/api/__tests__/settings.test.ts +24 -3
  351. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  352. package/src/routes/api/__tests__/upload.test.ts +34 -0
  353. package/src/routes/api/collections.ts +49 -6
  354. package/src/routes/api/custom-urls.ts +0 -1
  355. package/src/routes/api/discover.ts +95 -0
  356. package/src/routes/api/export.ts +6 -3
  357. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  358. package/src/routes/api/internal/sites.ts +0 -1
  359. package/src/routes/api/nav-items.ts +55 -8
  360. package/src/routes/api/posts.ts +161 -0
  361. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  362. package/src/routes/api/public/archive.ts +77 -81
  363. package/src/routes/api/public/posts.ts +44 -7
  364. package/src/routes/api/smart-collections.ts +132 -0
  365. package/src/routes/api/telegram.ts +2 -40
  366. package/src/routes/api/upload.ts +10 -3
  367. package/src/routes/api/uploads.ts +1 -2
  368. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  369. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  370. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  371. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  372. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  373. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  374. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  375. package/src/routes/auth/setup.tsx +934 -186
  376. package/src/routes/auth/signin.tsx +10 -8
  377. package/src/routes/compose.tsx +9 -0
  378. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  379. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  380. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  381. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  382. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  383. package/src/routes/dash/custom-urls.tsx +1 -37
  384. package/src/routes/dash/settings.tsx +714 -111
  385. package/src/routes/discover-announce.ts +96 -0
  386. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  387. package/src/routes/feed/feed.ts +69 -40
  388. package/src/routes/feed/sitemap.ts +95 -11
  389. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  390. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  391. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  392. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  393. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  394. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  395. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  396. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  397. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  398. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  399. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  400. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  401. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  402. package/src/routes/pages/archive.tsx +487 -379
  403. package/src/routes/pages/collection.tsx +94 -96
  404. package/src/routes/pages/collections.tsx +38 -50
  405. package/src/routes/pages/featured.tsx +43 -18
  406. package/src/routes/pages/home.tsx +25 -6
  407. package/src/routes/pages/language.tsx +113 -0
  408. package/src/routes/pages/latest.tsx +26 -16
  409. package/src/routes/pages/new.tsx +3 -0
  410. package/src/routes/pages/page.tsx +228 -45
  411. package/src/routes/pages/partials.tsx +41 -0
  412. package/src/routes/pages/search.tsx +22 -5
  413. package/src/routes/pages/smart-collection.tsx +340 -0
  414. package/src/routes/pages/subscribe.tsx +182 -0
  415. package/src/runtime/__tests__/index.test.ts +19 -0
  416. package/src/runtime/__tests__/node.test.ts +129 -4
  417. package/src/runtime/__tests__/readiness.test.ts +23 -0
  418. package/src/runtime/cloudflare.ts +21 -2
  419. package/src/runtime/index.ts +27 -1
  420. package/src/runtime/node.ts +67 -1
  421. package/src/runtime/readiness.ts +15 -0
  422. package/src/runtime/site.ts +95 -24
  423. package/src/services/__tests__/auth.test.ts +116 -7
  424. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  425. package/src/services/__tests__/collection.test.ts +108 -4
  426. package/src/services/__tests__/custom-url.test.ts +34 -0
  427. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  428. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  429. package/src/services/__tests__/language.test.ts +495 -0
  430. package/src/services/__tests__/media.test.ts +31 -0
  431. package/src/services/__tests__/navigation.test.ts +213 -10
  432. package/src/services/__tests__/path.test.ts +236 -0
  433. package/src/services/__tests__/post-language.test.ts +830 -0
  434. package/src/services/__tests__/post-timeline.test.ts +127 -0
  435. package/src/services/__tests__/post.test.ts +120 -9
  436. package/src/services/__tests__/settings.test.ts +228 -17
  437. package/src/services/__tests__/site-admin.test.ts +5 -5
  438. package/src/services/__tests__/smart-collection.test.ts +651 -0
  439. package/src/services/about-page.ts +22 -1
  440. package/src/services/auth.ts +51 -49
  441. package/src/services/bootstrap.ts +408 -39
  442. package/src/services/collection-directory-position.ts +76 -0
  443. package/src/services/collection.ts +331 -46
  444. package/src/services/custom-url.ts +29 -24
  445. package/src/services/export-theme/assets/client-site.css +1 -1
  446. package/src/services/export-theme/assets/client-site.js +123 -123
  447. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  448. package/src/services/export-theme/layouts/_default/list.html +2 -63
  449. package/src/services/export-theme/layouts/_default/rss.xml +103 -38
  450. package/src/services/export-theme/layouts/collections/list.html +3 -3
  451. package/src/services/export-theme/layouts/featured/list.html +1 -4
  452. package/src/services/export-theme/layouts/index.html +40 -26
  453. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  454. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  455. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  456. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  457. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  458. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  459. package/src/services/export-theme/layouts/partials/head.html +1 -1
  460. package/src/services/export-theme/layouts/partials/header.html +5 -3
  461. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  462. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  463. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  464. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  465. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  466. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  467. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  468. package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
  469. package/src/services/export-theme/layouts/post/list.html +1 -1
  470. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  471. package/src/services/export-theme/styles/main.css +13 -2
  472. package/src/services/export-theme/theme.toml +1 -1
  473. package/src/services/export.ts +880 -72
  474. package/src/services/github-app-installations.ts +171 -4
  475. package/src/services/github-sync.ts +69 -15
  476. package/src/services/index.ts +34 -3
  477. package/src/services/language.ts +412 -0
  478. package/src/services/mcp.ts +22 -2
  479. package/src/services/media.ts +22 -7
  480. package/src/services/navigation.ts +429 -67
  481. package/src/services/path.ts +254 -6
  482. package/src/services/post.ts +1234 -190
  483. package/src/services/search.ts +39 -2
  484. package/src/services/settings.ts +238 -43
  485. package/src/services/site-admin.ts +27 -14
  486. package/src/services/site.ts +160 -32
  487. package/src/services/smart-collection.ts +636 -0
  488. package/src/services/telegram.ts +20 -22
  489. package/src/services/upload-session.ts +1 -3
  490. package/src/style-author.css +27 -0
  491. package/src/styles/components-author.css +1622 -0
  492. package/src/styles/components.css +24 -1571
  493. package/src/styles/site-media.css +16 -2
  494. package/src/styles/tokens.css +82 -12
  495. package/src/styles/ui-author.css +7602 -0
  496. package/src/styles/ui.css +1090 -7216
  497. package/src/types/app-context.ts +23 -0
  498. package/src/types/bindings.ts +8 -0
  499. package/src/types/config.ts +94 -15
  500. package/src/types/constants.ts +188 -16
  501. package/src/types/entities.ts +87 -1
  502. package/src/types/operations.ts +74 -2
  503. package/src/types/props.ts +157 -31
  504. package/src/types/views.ts +55 -1
  505. package/src/ui/__tests__/font-themes.test.ts +52 -12
  506. package/src/ui/compose/ComposeDialog.tsx +201 -7
  507. package/src/ui/compose/ComposePrompt.tsx +1 -5
  508. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  509. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  510. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  511. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  512. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  513. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  514. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  515. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  516. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  517. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  518. package/src/ui/dash/settings/GeneralContent.tsx +277 -113
  519. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  520. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  521. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  522. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  523. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  524. package/src/ui/dash/settings/discover-copy.ts +116 -0
  525. package/src/ui/feed/LinkCard.tsx +55 -45
  526. package/src/ui/feed/NoteCard.tsx +9 -4
  527. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  528. package/src/ui/feed/QuoteCard.tsx +55 -30
  529. package/src/ui/feed/ThreadPreview.tsx +6 -2
  530. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  531. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  532. package/src/ui/feed/thread-preview-state.ts +20 -6
  533. package/src/ui/font-themes.ts +39 -29
  534. package/src/ui/layouts/BaseLayout.tsx +155 -10
  535. package/src/ui/layouts/SiteLayout.tsx +192 -34
  536. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  537. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  538. package/src/ui/pages/ArchivePage.tsx +282 -289
  539. package/src/ui/pages/CollectionPage.tsx +86 -76
  540. package/src/ui/pages/CollectionsPage.tsx +5 -0
  541. package/src/ui/pages/ComposePage.tsx +8 -0
  542. package/src/ui/pages/PostPage.tsx +47 -0
  543. package/src/ui/pages/SearchPage.tsx +3 -3
  544. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  545. package/src/ui/pages/SubscribePage.tsx +236 -0
  546. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  547. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  548. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  549. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  550. package/src/ui/shared/CollectionsManager.tsx +70 -17
  551. package/src/ui/shared/CopyField.tsx +127 -0
  552. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  553. package/src/ui/shared/MediaGallery.tsx +125 -46
  554. package/src/ui/shared/Pagination.tsx +20 -5
  555. package/src/ui/shared/PostFooter.tsx +20 -11
  556. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  557. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  558. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  559. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  560. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  561. package/src/ui/shared/archive-labels.ts +98 -0
  562. package/src/ui/shared/collection-management-labels.ts +94 -16
  563. package/src/ui/shared/navigation-labels.ts +45 -71
  564. package/src/ui/shared/post-article-attributes.ts +2 -0
  565. package/src/ui/shared/smart-collection-labels.ts +343 -0
  566. package/dist/app-C75Zc8dD.js +0 -6
  567. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  568. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  569. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  570. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  571. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  572. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  573. package/dist/env-BPYViDJJ.js +0 -281
  574. package/dist/export-Dl5KCiEs.js +0 -5562
  575. package/dist/github-api-BNF35Lkx.js +0 -176
  576. package/dist/github-app-DdiD-bC4.js +0 -275
  577. package/dist/github-sync-Bqg62IvV.js +0 -4
  578. package/dist/github-sync-HdK2EgvJ.js +0 -431
  579. package/dist/url-CbLAtlZe.js +0 -900
  580. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  581. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  582. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  583. package/src/client/collection-created-notice.ts +0 -66
  584. package/src/client/collection-form-bridge.ts +0 -191
  585. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  586. package/src/client/components/jant-post-form.ts +0 -422
  587. package/src/client/components/post-form-template.ts +0 -352
  588. package/src/client/components/post-form-types.ts +0 -96
  589. package/src/client/media-metadata.ts +0 -247
  590. package/src/client/post-form-bridge.ts +0 -193
  591. package/src/client/tiptap/exitable-marks.ts +0 -73
  592. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -21,8 +21,12 @@ import {
21
21
  import type { SettingsService } from "./settings.js";
22
22
  import type { StorageDriver } from "../lib/storage.js";
23
23
  import { SETTINGS_KEYS } from "../lib/constants.js";
24
- import { timingSafeEqualText } from "../lib/crypto.js";
25
- import { ValidationError, NotFoundError } from "../lib/errors.js";
24
+ import { hmacHex, timingSafeEqualText } from "../lib/crypto.js";
25
+ import {
26
+ ConfigurationError,
27
+ ValidationError,
28
+ NotFoundError,
29
+ } from "../lib/errors.js";
26
30
  import { hashPassword } from "../lib/password.js";
27
31
 
28
32
  /** Dependencies for account deletion */
@@ -53,20 +57,27 @@ export interface AuthService {
53
57
  resetPassword(token: string, newPassword: string): Promise<void>;
54
58
 
55
59
  /**
56
- * Generate a short-lived CSRF token for account deletion.
57
- * Stores the hash + expiry in settings and returns the plaintext.
60
+ * Derive the account-deletion CSRF token for a session.
61
+ *
62
+ * Derived, not stored: rendering the delete page is a GET, and a GET that
63
+ * wrote a fresh token somewhere would invalidate every copy handed out
64
+ * before it — a second tab, a prefetch, or a browser extension re-fetching
65
+ * the page would silently break the button the author is looking at. Same
66
+ * session in, same token out, however many times it is asked for.
58
67
  *
59
- * @returns The plaintext CSRF token to embed in the page
68
+ * @param sessionId - The signed-in session this token is bound to
69
+ * @returns The token to embed in the page
60
70
  */
61
- generateDeleteCsrfToken(): Promise<string>;
71
+ generateDeleteCsrfToken(sessionId: string): Promise<string>;
62
72
 
63
73
  /**
64
- * Validate a CSRF token for account deletion.
74
+ * Validate an account-deletion CSRF token against the requesting session.
65
75
  *
66
- * @param token - The plaintext CSRF token from the form
67
- * @returns true if valid and not expired
76
+ * @param token - The token submitted with the request
77
+ * @param sessionId - The session the request arrived on
78
+ * @returns `true` when the token belongs to this session
68
79
  */
69
- validateDeleteCsrfToken(token: string): Promise<boolean>;
80
+ validateDeleteCsrfToken(token: string, sessionId: string): Promise<boolean>;
70
81
 
71
82
  /**
72
83
  * Delete all user data and reset the instance to a pristine state.
@@ -87,6 +98,8 @@ export function createAuthService(
87
98
  settings: SettingsService,
88
99
  config?: {
89
100
  databaseDialect?: DatabaseDialect;
101
+ /** Signing key for derived tokens. Absent only where no route needs one. */
102
+ authSecret?: string;
90
103
  },
91
104
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
92
105
  ): AuthService {
@@ -97,6 +110,7 @@ export function createAuthService(
97
110
  verification,
98
111
  media,
99
112
  collections,
113
+ smartCollections,
100
114
  threadCollections,
101
115
  pathRegistry,
102
116
  collectionDirectoryItems: directoryItemsTable,
@@ -108,6 +122,7 @@ export function createAuthService(
108
122
  apiTokens,
109
123
  } = databaseSchema;
110
124
  const databaseDialect = config?.databaseDialect ?? "sqlite";
125
+ const authSecret = config?.authSecret;
111
126
 
112
127
  async function deleteDataRows(targetDb: Database): Promise<void> {
113
128
  // Junction/dependent tables first
@@ -125,6 +140,12 @@ export function createAuthService(
125
140
  );
126
141
  await executeStatement(targetDb, sql`DELETE FROM post`);
127
142
 
143
+ // Both kinds of collection, and smart ones first — a smart collection holds
144
+ // a `collection_id`. There is no constraint forcing that order any more
145
+ // (see the note on the column in `db/schema.ts`), but a reset that walks
146
+ // every table by hand should not leave one of them to an implicit cascade
147
+ // from `site` at the very end.
148
+ await targetDb.delete(smartCollections);
128
149
  await targetDb.delete(collections);
129
150
  await targetDb.delete(apiTokens);
130
151
  await targetDb.delete(settingsTable);
@@ -174,14 +195,21 @@ export function createAuthService(
174
195
  return timingSafeEqualText(tokenHash, storedHash);
175
196
  }
176
197
 
177
- async function hashToken(token: string): Promise<string> {
178
- const hashBuffer = await crypto.subtle.digest(
179
- "SHA-256",
180
- new TextEncoder().encode(token),
181
- );
182
- return Array.from(new Uint8Array(hashBuffer))
183
- .map((b) => b.toString(16).padStart(2, "0"))
184
- .join("");
198
+ /**
199
+ * The delete-account token for a session — a pure function of the session and
200
+ * the server's signing key, so nothing has to be written down to check it
201
+ * later. It stops being valid when the session does, which is the only
202
+ * lifetime that means anything here: the token is a second factor on top of
203
+ * the session cookie, useless to anyone who does not already hold it.
204
+ */
205
+ async function deriveDeleteCsrfToken(sessionId: string): Promise<string> {
206
+ if (!authSecret) {
207
+ throw new ConfigurationError(
208
+ "AUTH_SECRET is required to protect account deletion.",
209
+ );
210
+ }
211
+
212
+ return hmacHex(authSecret, `delete-account:${sessionId}`);
185
213
  }
186
214
 
187
215
  return {
@@ -217,39 +245,13 @@ export function createAuthService(
217
245
  await settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
218
246
  },
219
247
 
220
- async generateDeleteCsrfToken() {
221
- const bytes = new Uint8Array(32);
222
- crypto.getRandomValues(bytes);
223
- const plaintext = Array.from(bytes)
224
- .map((b) => b.toString(16).padStart(2, "0"))
225
- .join("");
226
-
227
- const tokenHash = await hashToken(plaintext);
228
- // Token valid for 15 minutes
229
- const expiry = Math.floor(Date.now() / 1000) + 15 * 60;
230
-
231
- await settings.set(
232
- SETTINGS_KEYS.DELETE_CSRF_TOKEN,
233
- `${tokenHash}:${expiry}`,
234
- );
235
-
236
- return plaintext;
248
+ async generateDeleteCsrfToken(sessionId) {
249
+ return deriveDeleteCsrfToken(sessionId);
237
250
  },
238
251
 
239
- async validateDeleteCsrfToken(token) {
240
- const stored = await settings.get(SETTINGS_KEYS.DELETE_CSRF_TOKEN);
241
- if (!stored) return false;
242
-
243
- const separatorIndex = stored.lastIndexOf(":");
244
- const storedHash = stored.substring(0, separatorIndex);
245
- const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
246
- const now = Math.floor(Date.now() / 1000);
247
-
248
- if (now > expiry) return false;
249
-
250
- const tokenHash = await hashToken(token);
251
-
252
- return timingSafeEqualText(tokenHash, storedHash);
252
+ async validateDeleteCsrfToken(token, sessionId) {
253
+ const expected = await deriveDeleteCsrfToken(sessionId);
254
+ return timingSafeEqualText(token, expected);
253
255
  },
254
256
 
255
257
  async deleteAllData(deps) {
@@ -1,83 +1,452 @@
1
1
  /**
2
2
  * Bootstrap Service
3
3
  *
4
- * Owns first-run site shell setup after account creation.
4
+ * Owns first-run setup, which runs in two acts on a self-hosted site: the
5
+ * account is created first, and everything about the site itself is settled
6
+ * afterwards. The two are separate methods because the site is left standing
7
+ * between them — `markSiteProvisioned` closes the first act, which is the same
8
+ * state a control plane leaves a hosted site in, so the second act is one code
9
+ * path for both install kinds.
10
+ *
11
+ * The first act belongs to self-hosted installs alone. A hosted site gets its
12
+ * owner through the control plane's handoff, so on a host-based install only
13
+ * the second act runs, against the site the request resolved — the same site
14
+ * the route's membership check reads. On a hosted install the database holds
15
+ * every tenant, so "the only site" is not a question with an answer there.
16
+ *
17
+ * A self-hosted install that is deployed rather than clicked through runs both
18
+ * acts back to back without a browser — see `setUpInstance`.
5
19
  */
6
20
 
21
+ import { and, eq } from "drizzle-orm";
22
+ import type { DatabaseDialect } from "../db/dialect.js";
7
23
  import type { Database } from "../db/index.js";
8
24
  import {
9
25
  sqliteSchemaBundle,
10
26
  type DatabaseSchema,
11
27
  } from "../db/schema-bundle.js";
12
- import {
13
- baseLocale,
14
- isValidContentLanguage,
15
- normalizeContentLanguage,
16
- resolveCatalogLocale,
17
- } from "../i18n/locales.js";
28
+ import { AUTH_ID_PREFIX, createTypeId } from "../lib/ids.js";
29
+ import { hashPassword, verifyPassword } from "../lib/password.js";
30
+ import { deriveAccountName } from "../lib/schemas.js";
31
+ import { now } from "../lib/time.js";
18
32
  import { createNavItemService } from "./navigation.js";
19
33
  import { createSettingsService } from "./settings.js";
20
34
  import { createSiteMemberService } from "./site-member.js";
21
- import { createSiteService, type EnsureSingleSiteOptions } from "./site.js";
35
+ import {
36
+ createSiteService,
37
+ TRANSIENT_SINGLE_SITE_ID,
38
+ type EnsureSingleSiteOptions,
39
+ } from "./site.js";
22
40
 
23
- export interface CompleteInitialSetupData {
41
+ export interface ProvisionOwnerAccountData {
42
+ /** The account the first setup screen just created. */
24
43
  ownerUserId: string;
25
- siteName: string;
44
+ }
45
+
46
+ export interface CompleteSiteSetupData {
47
+ /**
48
+ * The site's name, on an install that does not have one yet.
49
+ *
50
+ * Omitted on a hosted site: the control plane named it at creation, so the
51
+ * second screen never asks and must not overwrite.
52
+ */
53
+ siteName?: string | null;
54
+ /** Language the site publishes in, chosen explicitly during setup. */
26
55
  siteLanguage?: string | null;
27
- cjkSerifFont?: string | null;
56
+ /**
57
+ * The browser's own language, used only to pin the dashboard UI locale.
58
+ *
59
+ * Kept separate from `siteLanguage` because the two genuinely differ for the
60
+ * people this matters to most: someone running an English-language blog from
61
+ * a Chinese browser wants an English site and a Chinese dashboard.
62
+ */
63
+ browserLanguage?: string | null;
64
+ /**
65
+ * The browser's time zone, already mapped to a supported one by the caller.
66
+ *
67
+ * Omitted where the shell already carries one, so a second screen loaded in
68
+ * a different place cannot move a hosted site's clock.
69
+ */
28
70
  timeZone?: string | null;
29
71
  }
30
72
 
73
+ export interface CompleteSiteSetupDeps {
74
+ /**
75
+ * Align the operator's better-auth account name with the site's display
76
+ * name, the way the general settings page does whenever the name changes.
77
+ *
78
+ * Injected rather than called here: the update needs the request's own
79
+ * headers to know whose account it is, and a service has none.
80
+ */
81
+ updateCurrentUserName?: ((displayName: string) => Promise<void>) | null;
82
+ }
83
+
84
+ export interface SetUpInstanceData {
85
+ /** The owner's sign-in address, normalized the way sign-in normalizes it. */
86
+ email: string;
87
+ /** The owner's password in plain text. Only its hash is stored. */
88
+ password: string;
89
+ /** The id to create the site with. See `EnsureSingleSiteOptions.id`. */
90
+ siteId?: string;
91
+ /** The site's name. Omitted leaves the built-in fallback name in place. */
92
+ siteName?: string;
93
+ /**
94
+ * Language the site publishes in. Omitted means the base locale, the same
95
+ * as an empty answer on the screen.
96
+ */
97
+ siteLanguage?: string;
98
+ /** The site's time zone. Omitted means UTC, as on the screen. */
99
+ timeZone?: string;
100
+ }
101
+
102
+ /**
103
+ * What `setUpInstance` found:
104
+ * - `created`: nothing existed, and now the account, the site and its answers do
105
+ * - `resumed`: an earlier run stopped after creating the account; this one
106
+ * finished the rest
107
+ * - `already-set-up`: setup had finished before, and nothing was changed
108
+ */
109
+ export type SetUpInstanceOutcome = "created" | "resumed" | "already-set-up";
110
+
111
+ export interface SetUpInstanceResult {
112
+ outcome: SetUpInstanceOutcome;
113
+ siteId: string;
114
+ }
115
+
31
116
  export interface BootstrapService {
32
117
  /**
33
- * Complete first-run setup for a newly created account.
34
- * Materializes the versioned navigation profile and marks onboarding complete
35
- * last so an interrupted setup can recover safely.
118
+ * Stand the site up around a newly created owner account.
119
+ *
120
+ * Everything here is what a site needs to exist at all — a row, an owner, a
121
+ * navigation profile. Nothing here is an answer the author gave, because at
122
+ * this point they have given none beyond their credentials.
123
+ *
124
+ * Ends by marking the site provisioned rather than complete. That is what
125
+ * makes the gap between the two screens survivable: at `provisioned` the
126
+ * onboarding middleware serves `/signin` normally, so an author who loses
127
+ * the session before finishing signs back in and lands on the second screen.
128
+ *
129
+ * @param data - The account the first screen created
130
+ * @throws {Error} On a host-based install, where a site's owner arrives
131
+ * through the control plane's handoff instead
132
+ */
133
+ provisionOwnerAccount(data: ProvisionOwnerAccountData): Promise<void>;
134
+
135
+ /**
136
+ * Close first-run setup by recording what the author said about their site.
137
+ *
138
+ * Shared by both install kinds: a self-hosted site arrives here from its
139
+ * second screen with a name to set, a hosted site from its only screen with
140
+ * the name already stored. Onboarding is marked complete last, so an
141
+ * interrupted run resumes rather than stranding a half-answered site.
142
+ *
143
+ * @param data - The answers the second screen collected
144
+ * @param opts - The language in effect before this answer
145
+ * @param deps - Cross-cutting work the route owns, such as the better-auth
146
+ * account rename that needs the request's headers
147
+ */
148
+ completeSiteSetup(
149
+ data: CompleteSiteSetupData,
150
+ opts: { oldLanguage: string },
151
+ deps?: CompleteSiteSetupDeps,
152
+ ): Promise<void>;
153
+
154
+ /**
155
+ * Set a self-hosted install up without a browser: the owner account, the
156
+ * site around it, and the answers the second screen would have collected,
157
+ * ending with onboarding complete.
36
158
  *
37
- * @param data - Initial site shell values captured during setup
159
+ * The site half is the screens' own code. The account half differs in one
160
+ * way: the first screen opens the account through better-auth so the
161
+ * browser leaves with a session, and there is no browser here to hand one
162
+ * to. The rows are written directly instead, with the same password hash
163
+ * better-auth is configured with, and named the way the first screen names
164
+ * them until the site's name replaces it.
165
+ *
166
+ * Safe to run on every start. An install that finished setup is left as it
167
+ * is, whatever this call was given — the owner may have changed their
168
+ * address or password since, and a restart must not undo that. A run that
169
+ * stopped after creating the account resumes, but only with that account's
170
+ * address and password, which is what the first screen asks for too.
171
+ *
172
+ * @param data - The owner's credentials and the site's answers, already
173
+ * validated
174
+ * @returns What was found, and the site
175
+ * @throws {Error} On a host-based install; when the site exists under an id
176
+ * other than `siteId`; when an account for another address exists; when
177
+ * the password does not match the account an earlier run left
178
+ * @example
179
+ * ```ts
180
+ * await bootstrap.setUpInstance({
181
+ * email: "owner@example.com",
182
+ * password: "correct horse battery",
183
+ * siteName: "Field Notes",
184
+ * });
185
+ * ```
38
186
  */
39
- completeInitialSetup(data: CompleteInitialSetupData): Promise<void>;
187
+ setUpInstance(data: SetUpInstanceData): Promise<SetUpInstanceResult>;
40
188
  }
41
189
 
190
+ /**
191
+ * Create the first-run setup service for one request.
192
+ *
193
+ * @param db - The database
194
+ * @param siteId - The site the request resolved. On a self-hosted install that
195
+ * has not finished the account step this is the transient placeholder, since
196
+ * no site row exists yet.
197
+ * @param options - The schema, dialect and resolution mode the runtime uses,
198
+ * and where a self-hosted site's row and domain come from
199
+ * @returns The bootstrap service bound to that site
200
+ */
42
201
  export function createBootstrapService(
43
202
  db: Database,
203
+ siteId: string,
44
204
  options?: {
45
205
  schema?: DatabaseSchema;
206
+ databaseDialect?: DatabaseDialect;
46
207
  bootstrapSite?: EnsureSingleSiteOptions;
208
+ siteResolutionMode?: "single-site" | "host-based";
47
209
  },
48
210
  ): BootstrapService {
49
211
  const databaseSchema = options?.schema ?? sqliteSchemaBundle;
212
+ const dialect = options?.databaseDialect ?? "sqlite";
213
+ const siteResolutionMode = options?.siteResolutionMode ?? "single-site";
214
+
215
+ /**
216
+ * The request's site, which must be a real row by the time anything is
217
+ * written to it. The placeholder only stands in for a self-hosted site the
218
+ * account step has yet to create.
219
+ */
220
+ function existingSiteId(step: string): string {
221
+ if (siteId === TRANSIENT_SINGLE_SITE_ID) {
222
+ throw new Error(
223
+ `${step} needs an existing site, but the request resolved none.`,
224
+ );
225
+ }
226
+ return siteId;
227
+ }
228
+
229
+ function settingsFor(targetSiteId: string) {
230
+ return createSettingsService(db, targetSiteId, databaseSchema, dialect);
231
+ }
232
+
233
+ /**
234
+ * A host-based install refuses before anything is written. The control
235
+ * plane creates each hosted site already provisioned, and its owner arrives
236
+ * through the hosted handoff, the only way anyone becomes a member of one.
237
+ * Attaching whoever just signed in to the site the host resolved would let
238
+ * any account in a database every tenant shares take a site that had lost
239
+ * its onboarding status.
240
+ */
241
+ function assertSelfHosted(step: string): void {
242
+ if (siteResolutionMode === "host-based") {
243
+ throw new Error(
244
+ `${step} runs on self-hosted installs only. A hosted site's owner arrives through the control plane's handoff.`,
245
+ );
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Stand the site up around its owner. A self-hosted install has no row until
251
+ * now, so this is the step that creates it — along with its domain, when one
252
+ * is configured.
253
+ *
254
+ * @returns The site's id
255
+ */
256
+ async function provisionSite(
257
+ step: string,
258
+ ownerUserId: string,
259
+ siteOptions: EnsureSingleSiteOptions | undefined,
260
+ ): Promise<string> {
261
+ assertSelfHosted(step);
262
+ const { site } = await createSiteService(
263
+ db,
264
+ databaseSchema,
265
+ ).ensureSingleSite(siteOptions);
266
+ const navItems = createNavItemService(db, site.id, databaseSchema);
267
+ const siteMembers = createSiteMemberService(db, databaseSchema);
268
+
269
+ await siteMembers.ensure(site.id, ownerUserId, "owner");
270
+ await navItems.materializeDefaultNavigation();
271
+ await settingsFor(site.id).markSiteProvisioned();
272
+ return site.id;
273
+ }
274
+
275
+ async function recordSiteAnswers(
276
+ targetSiteId: string,
277
+ data: CompleteSiteSetupData,
278
+ opts: { oldLanguage: string },
279
+ deps: CompleteSiteSetupDeps | undefined,
280
+ ): Promise<void> {
281
+ const settings = settingsFor(targetSiteId);
282
+
283
+ const siteName = data.siteName?.trim();
284
+ if (siteName) {
285
+ await settings.set("SITE_NAME", siteName);
286
+ // Before onboarding closes, deliberately: if the rename fails the site
287
+ // is still provisioned, and the author retries the screen they were
288
+ // already on rather than landing in a finished site under a stale name.
289
+ await deps?.updateCurrentUserName?.(siteName);
290
+ }
291
+
292
+ // `undefined` means the shell already has one. `null` is a browser that
293
+ // reported nothing, which is the same UTC default the site would fall
294
+ // back to anyway — written so a later reader sees a chosen value.
295
+ if (data.timeZone !== undefined) {
296
+ await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
297
+ }
298
+
299
+ await settings.confirmFirstRunLanguage(
300
+ {
301
+ siteLanguage: data.siteLanguage ?? "",
302
+ browserLanguage: data.browserLanguage,
303
+ },
304
+ opts,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * The account a browserless setup stands the site up around: a new one when
310
+ * the install has none, or the one an interrupted run left, claimed with its
311
+ * own password — the way the first screen resumes by signing in.
312
+ */
313
+ async function claimOwnerAccount(
314
+ email: string,
315
+ password: string,
316
+ ): Promise<{ userId: string; created: boolean }> {
317
+ const { account, user } = databaseSchema;
318
+ const [existing] = await db
319
+ .select({ id: user.id, passwordHash: account.password })
320
+ .from(user)
321
+ .leftJoin(
322
+ account,
323
+ and(eq(account.userId, user.id), eq(account.providerId, "credential")),
324
+ )
325
+ .where(eq(user.email, email))
326
+ .limit(1);
327
+
328
+ if (existing) {
329
+ const matches =
330
+ !!existing.passwordHash &&
331
+ (await verifyPassword({ hash: existing.passwordHash, password }));
332
+ if (!matches) {
333
+ throw new Error(
334
+ `An earlier setup created the account for ${email}, and the password given does not match it.`,
335
+ );
336
+ }
337
+ return { userId: existing.id, created: false };
338
+ }
339
+
340
+ const [other] = await db.select({ id: user.id }).from(user).limit(1);
341
+ if (other) {
342
+ throw new Error(
343
+ `This install already has an account under another address. Pass that account's address and password to finish setup.`,
344
+ );
345
+ }
346
+
347
+ const userId = createTypeId(AUTH_ID_PREFIX.user);
348
+ const timestamp = new Date(now() * 1000);
349
+ await db.insert(user).values({
350
+ id: userId,
351
+ email,
352
+ emailVerified: false,
353
+ image: null,
354
+ // The first screen's stand-in, replaced when the site is named.
355
+ name: deriveAccountName(email),
356
+ role: "admin",
357
+ createdAt: timestamp,
358
+ updatedAt: timestamp,
359
+ });
360
+ await db.insert(account).values({
361
+ id: createTypeId(AUTH_ID_PREFIX.account),
362
+ accountId: userId,
363
+ userId,
364
+ providerId: "credential",
365
+ password: await hashPassword(password),
366
+ accessToken: null,
367
+ accessTokenExpiresAt: null,
368
+ idToken: null,
369
+ refreshToken: null,
370
+ refreshTokenExpiresAt: null,
371
+ scope: null,
372
+ createdAt: timestamp,
373
+ updatedAt: timestamp,
374
+ });
375
+ return { userId, created: true };
376
+ }
50
377
 
51
378
  return {
52
- async completeInitialSetup(data) {
53
- const siteService = createSiteService(db, databaseSchema);
54
- const { site } = await siteService.ensureSingleSite(
379
+ async provisionOwnerAccount(data) {
380
+ await provisionSite(
381
+ "provisionOwnerAccount",
382
+ data.ownerUserId,
55
383
  options?.bootstrapSite,
56
384
  );
57
- const settings = createSettingsService(db, site.id, databaseSchema);
58
- const navItems = createNavItemService(db, site.id, databaseSchema);
59
- const siteMembers = createSiteMemberService(db, databaseSchema);
385
+ },
60
386
 
61
- await siteMembers.ensure(site.id, data.ownerUserId, "owner");
62
- await navItems.materializeDefaultNavigation();
63
- await settings.set("SITE_NAME", data.siteName.trim());
64
- await settings.set("TIME_ZONE", data.timeZone ?? "UTC");
65
- const siteLanguage =
66
- data.siteLanguage && isValidContentLanguage(data.siteLanguage)
67
- ? normalizeContentLanguage(data.siteLanguage)
68
- : baseLocale;
69
- await settings.set("SITE_LANGUAGE", siteLanguage);
70
- // Pin the dashboard UI locale to the detected catalog language so it stays
71
- // stable if the operator later changes the public content language.
72
- await settings.set(
73
- "DASHBOARD_LANGUAGE",
74
- resolveCatalogLocale(siteLanguage),
387
+ async completeSiteSetup(data, opts, deps) {
388
+ await recordSiteAnswers(
389
+ existingSiteId("completeSiteSetup"),
390
+ data,
391
+ opts,
392
+ deps,
75
393
  );
76
- if (data.cjkSerifFont && data.cjkSerifFont !== "off") {
77
- await settings.set("CJK_SERIF_FONT", data.cjkSerifFont);
394
+ },
395
+
396
+ async setUpInstance(data) {
397
+ assertSelfHosted("setUpInstance");
398
+
399
+ const existingSite = await createSiteService(
400
+ db,
401
+ databaseSchema,
402
+ ).getOnlySite();
403
+ if (
404
+ existingSite &&
405
+ data.siteId !== undefined &&
406
+ existingSite.id !== data.siteId
407
+ ) {
408
+ throw new Error(
409
+ `This install's site is ${existingSite.id}, not ${data.siteId}. Check that the database is the one you meant.`,
410
+ );
411
+ }
412
+ if (
413
+ existingSite &&
414
+ (await settingsFor(existingSite.id).isOnboardingComplete())
415
+ ) {
416
+ return { outcome: "already-set-up", siteId: existingSite.id };
78
417
  }
79
418
 
80
- await settings.completeOnboarding();
419
+ const owner = await claimOwnerAccount(data.email, data.password);
420
+ const setUpSiteId = await provisionSite("setUpInstance", owner.userId, {
421
+ ...options?.bootstrapSite,
422
+ id: data.siteId,
423
+ });
424
+ const { user } = databaseSchema;
425
+ await recordSiteAnswers(
426
+ setUpSiteId,
427
+ {
428
+ siteName: data.siteName,
429
+ siteLanguage: data.siteLanguage,
430
+ timeZone: data.timeZone ?? null,
431
+ },
432
+ {
433
+ oldLanguage:
434
+ (await settingsFor(setUpSiteId).get("SITE_LANGUAGE")) ?? "",
435
+ },
436
+ {
437
+ async updateCurrentUserName(displayName) {
438
+ await db
439
+ .update(user)
440
+ .set({ name: displayName, updatedAt: new Date(now() * 1000) })
441
+ .where(eq(user.id, owner.userId));
442
+ },
443
+ },
444
+ );
445
+
446
+ return {
447
+ outcome: owner.created ? "created" : "resumed",
448
+ siteId: setUpSiteId,
449
+ };
81
450
  },
82
451
  };
83
452
  }