@jant/core 0.6.16 → 0.7.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 (508) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +60 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  51. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  52. package/src/app.tsx +71 -16
  53. package/src/auth.ts +18 -1
  54. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  55. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  56. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  57. package/src/client/__tests__/compose-bridge.test.ts +272 -0
  58. package/src/client/__tests__/compose-launch.test.ts +62 -1
  59. package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
  60. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  61. package/src/client/__tests__/copy-field.test.ts +162 -0
  62. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  63. package/src/client/__tests__/image-processor.test.ts +324 -2
  64. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  65. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  66. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  67. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  68. package/src/client/__tests__/toast.test.ts +23 -0
  69. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  70. package/src/client/__tests__/video-processor.test.ts +181 -0
  71. package/src/client/audio-processor.ts +24 -15
  72. package/src/client/audio-waveform.ts +49 -0
  73. package/src/client/collection-dialog-host.ts +72 -0
  74. package/src/client/collection-navigation.ts +51 -16
  75. package/src/client/collection-page-actions.ts +26 -0
  76. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  77. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  78. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  79. package/src/client/components/__tests__/jant-command-palette.test.ts +28 -0
  80. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  81. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1250 -53
  82. package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
  83. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
  84. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  85. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  86. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  87. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  88. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  89. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  90. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  91. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  92. package/src/client/components/collection-dialog-types.ts +49 -0
  93. package/src/client/components/collection-manager-types.ts +32 -1
  94. package/src/client/components/collection-types.ts +1 -15
  95. package/src/client/components/compose-types.ts +75 -1
  96. package/src/client/components/jant-collection-dialog.ts +722 -0
  97. package/src/client/components/jant-collection-directory.ts +567 -182
  98. package/src/client/components/jant-collection-form.ts +53 -232
  99. package/src/client/components/jant-command-palette.ts +100 -49
  100. package/src/client/components/jant-compose-dialog.ts +1486 -707
  101. package/src/client/components/jant-compose-editor.ts +673 -336
  102. package/src/client/components/jant-compose-fullscreen.ts +51 -43
  103. package/src/client/components/jant-config-editor.ts +65 -53
  104. package/src/client/components/jant-confirm-dialog.ts +13 -11
  105. package/src/client/components/jant-locale-picker.ts +418 -0
  106. package/src/client/components/jant-media-lightbox.ts +146 -136
  107. package/src/client/components/jant-nav-manager.ts +690 -366
  108. package/src/client/components/jant-post-menu.ts +1086 -282
  109. package/src/client/components/jant-post-picker.ts +324 -0
  110. package/src/client/components/jant-repo-picker.ts +138 -106
  111. package/src/client/components/jant-settings-avatar.ts +31 -27
  112. package/src/client/components/jant-settings-general.ts +617 -543
  113. package/src/client/components/jant-settings-language.ts +1087 -0
  114. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  115. package/src/client/components/jant-text-preview.ts +94 -86
  116. package/src/client/components/nav-manager-types.ts +62 -1
  117. package/src/client/components/settings-types.ts +14 -29
  118. package/src/client/components/smart-collection-conditions.ts +285 -0
  119. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  120. package/src/client/compose-bridge.ts +214 -141
  121. package/src/client/compose-launch.ts +76 -2
  122. package/src/client/compose-shortcuts.ts +6 -8
  123. package/src/client/compose-triggers.ts +50 -0
  124. package/src/client/copy-field.ts +108 -0
  125. package/src/client/custom-url-menu.ts +60 -4
  126. package/src/client/icons.ts +14 -0
  127. package/src/client/image-plan.ts +165 -0
  128. package/src/client/image-processor.ts +195 -117
  129. package/src/client/image-worker-client.ts +45 -0
  130. package/src/client/image-worker.ts +135 -0
  131. package/src/client/lazy-entries.ts +95 -0
  132. package/src/client/lazy-slugify.ts +2 -2
  133. package/src/client/media-scroll-hint.ts +43 -2
  134. package/src/client/mediabunny.ts +29 -0
  135. package/src/client/post-picker.ts +41 -0
  136. package/src/client/runtime-paths.ts +74 -1
  137. package/src/client/settings-bridge.ts +4 -10
  138. package/src/client/site-header-nav.js +18 -6
  139. package/src/client/smart-collection-dialog-host.ts +116 -0
  140. package/src/client/smart-collection-page-actions.ts +208 -0
  141. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  142. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  143. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  144. package/src/client/tiptap/bubble-menu.ts +47 -16
  145. package/src/client/tiptap/floating-position.ts +216 -2
  146. package/src/client/tiptap/inline-image-upload.ts +7 -3
  147. package/src/client/tiptap/link-toolbar.ts +22 -9
  148. package/src/client/toast.ts +13 -0
  149. package/src/client/types/vite-worker.d.ts +12 -0
  150. package/src/client/upload-with-metadata.ts +3 -13
  151. package/src/client/video-processor.ts +351 -107
  152. package/src/client-auth.ts +14 -31
  153. package/src/client-compose.ts +19 -0
  154. package/src/client-manage.ts +13 -0
  155. package/src/client-settings.ts +14 -0
  156. package/src/client-site.ts +10 -3
  157. package/src/client.ts +4 -0
  158. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  159. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  160. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  161. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  162. package/src/db/migrations/0032_married_tomas.sql +3 -0
  163. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  164. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  165. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  166. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  167. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  168. package/src/db/migrations/meta/_journal.json +21 -0
  169. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  170. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  171. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  172. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  173. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  174. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  175. package/src/db/migrations/pg/meta/_journal.json +21 -0
  176. package/src/db/pg/schema.ts +195 -27
  177. package/src/db/post-visibility.ts +117 -0
  178. package/src/db/schema.ts +222 -27
  179. package/src/i18n/__tests__/detect.test.ts +26 -81
  180. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  181. package/src/i18n/context.tsx +12 -2
  182. package/src/i18n/detect.ts +7 -66
  183. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  184. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  185. package/src/i18n/locales/public/en.po +490 -158
  186. package/src/i18n/locales/public/en.ts +1 -1
  187. package/src/i18n/locales/public/zh-Hans.po +487 -155
  188. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  189. package/src/i18n/locales/public/zh-Hant.po +487 -155
  190. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  191. package/src/i18n/locales/settings/en.po +551 -69
  192. package/src/i18n/locales/settings/en.ts +1 -1
  193. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  194. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  195. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  196. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  197. package/src/i18n/locales.ts +94 -0
  198. package/src/i18n/middleware.ts +12 -2
  199. package/src/i18n/supported-locales.ts +80 -0
  200. package/src/lib/__tests__/api-settings.test.ts +11 -2
  201. package/src/lib/__tests__/asset-path.test.ts +92 -0
  202. package/src/lib/__tests__/deferred.test.ts +90 -0
  203. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  204. package/src/lib/__tests__/discover.test.ts +410 -0
  205. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  206. package/src/lib/__tests__/feed.test.ts +1526 -15
  207. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  208. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  209. package/src/lib/__tests__/http-cache.test.ts +162 -0
  210. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  211. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  212. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  213. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  214. package/src/lib/__tests__/navigation.test.ts +135 -1
  215. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  216. package/src/lib/__tests__/schemas.test.ts +42 -4
  217. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  218. package/src/lib/__tests__/slug.test.ts +70 -0
  219. package/src/lib/__tests__/slugify.test.ts +85 -0
  220. package/src/lib/__tests__/storage.test.ts +32 -0
  221. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  222. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  223. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  224. package/src/lib/__tests__/url.test.ts +78 -72
  225. package/src/lib/__tests__/view-language.test.ts +413 -0
  226. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  227. package/src/lib/address.ts +57 -0
  228. package/src/lib/api-posts.ts +12 -0
  229. package/src/lib/asset-path.ts +98 -1
  230. package/src/lib/build-env.ts +16 -0
  231. package/src/lib/collection-groups.ts +5 -1
  232. package/src/lib/collection-paths.ts +3 -10
  233. package/src/lib/constants.ts +28 -3
  234. package/src/lib/copy-field.ts +36 -0
  235. package/src/lib/crypto.ts +40 -0
  236. package/src/lib/csp-builder.ts +2 -1
  237. package/src/lib/deferred.ts +79 -0
  238. package/src/lib/discover-ping.ts +141 -0
  239. package/src/lib/discover.ts +437 -0
  240. package/src/lib/env.ts +134 -0
  241. package/src/lib/errors.ts +33 -0
  242. package/src/lib/feed-policy.ts +236 -2
  243. package/src/lib/feed.ts +926 -73
  244. package/src/lib/filter-dimensions.ts +1349 -0
  245. package/src/lib/github-sync-site-config.ts +3 -0
  246. package/src/lib/hosted-signin.ts +1 -1
  247. package/src/lib/http-cache.ts +148 -0
  248. package/src/lib/hugo-markdown.ts +16 -0
  249. package/src/lib/icons.ts +9 -2
  250. package/src/lib/ids.ts +3 -0
  251. package/src/lib/jant-docs.ts +42 -0
  252. package/src/lib/lang-detect.ts +308 -0
  253. package/src/lib/media-helpers.ts +13 -1
  254. package/src/lib/navigation.ts +78 -11
  255. package/src/lib/pagination.ts +28 -24
  256. package/src/lib/per-language-surfaces.ts +67 -0
  257. package/src/lib/post-meta.ts +19 -0
  258. package/src/lib/render.tsx +55 -7
  259. package/src/lib/resolve-config.ts +53 -5
  260. package/src/lib/schemas.ts +201 -10
  261. package/src/lib/site-header-fragment.tsx +2 -1
  262. package/src/lib/site-unavailable-page.ts +47 -0
  263. package/src/lib/slug.ts +51 -7
  264. package/src/lib/slugify.ts +113 -0
  265. package/src/lib/startup-config.ts +4 -6
  266. package/src/lib/storage.ts +7 -0
  267. package/src/lib/summary.ts +50 -0
  268. package/src/lib/thread-fold.ts +161 -0
  269. package/src/lib/time.ts +32 -0
  270. package/src/lib/timeline.ts +42 -1
  271. package/src/lib/tiptap-render.ts +1 -2
  272. package/src/lib/upload.ts +16 -13
  273. package/src/lib/url.ts +139 -86
  274. package/src/lib/version.ts +10 -8
  275. package/src/lib/view-language.ts +436 -0
  276. package/src/lib/view.ts +51 -23
  277. package/src/lib/viewer-context.ts +62 -0
  278. package/src/middleware/__tests__/auth.test.ts +70 -2
  279. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  280. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  281. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  282. package/src/middleware/__tests__/session.test.ts +181 -33
  283. package/src/middleware/auth.ts +50 -31
  284. package/src/middleware/cache-control.ts +75 -10
  285. package/src/middleware/config.ts +6 -6
  286. package/src/middleware/error-handler.ts +12 -0
  287. package/src/middleware/onboarding.ts +32 -10
  288. package/src/middleware/secure-headers.ts +1 -1
  289. package/src/middleware/session.ts +96 -8
  290. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  291. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  292. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  293. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  294. package/src/node/__tests__/runtime.test.ts +37 -0
  295. package/src/node/index.ts +1 -0
  296. package/src/preset.css +76 -22
  297. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  298. package/src/routes/api/__tests__/collections.test.ts +114 -0
  299. package/src/routes/api/__tests__/discover.test.ts +222 -0
  300. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  301. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  302. package/src/routes/api/__tests__/settings.test.ts +24 -3
  303. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  304. package/src/routes/api/collections.ts +49 -6
  305. package/src/routes/api/custom-urls.ts +0 -1
  306. package/src/routes/api/discover.ts +95 -0
  307. package/src/routes/api/export.ts +3 -0
  308. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  309. package/src/routes/api/nav-items.ts +55 -8
  310. package/src/routes/api/posts.ts +159 -0
  311. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  312. package/src/routes/api/public/archive.ts +77 -81
  313. package/src/routes/api/public/posts.ts +23 -6
  314. package/src/routes/api/smart-collections.ts +132 -0
  315. package/src/routes/api/telegram.ts +2 -40
  316. package/src/routes/api/uploads.ts +1 -2
  317. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  318. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  319. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  320. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  321. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  322. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  323. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  324. package/src/routes/auth/setup.tsx +934 -186
  325. package/src/routes/auth/signin.tsx +10 -8
  326. package/src/routes/compose.tsx +5 -0
  327. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  328. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  329. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  330. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  331. package/src/routes/dash/custom-urls.tsx +1 -37
  332. package/src/routes/dash/settings.tsx +502 -41
  333. package/src/routes/discover-announce.ts +96 -0
  334. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  335. package/src/routes/feed/feed.ts +69 -40
  336. package/src/routes/feed/sitemap.ts +95 -11
  337. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  338. package/src/routes/pages/__tests__/archive-params.test.ts +370 -6
  339. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  340. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  341. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  342. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  343. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  344. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  345. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  346. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  347. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  348. package/src/routes/pages/archive.tsx +471 -369
  349. package/src/routes/pages/collection.tsx +94 -96
  350. package/src/routes/pages/collections.tsx +38 -50
  351. package/src/routes/pages/featured.tsx +32 -13
  352. package/src/routes/pages/home.tsx +25 -6
  353. package/src/routes/pages/language.tsx +113 -0
  354. package/src/routes/pages/latest.tsx +26 -16
  355. package/src/routes/pages/new.tsx +3 -0
  356. package/src/routes/pages/page.tsx +178 -17
  357. package/src/routes/pages/partials.tsx +41 -0
  358. package/src/routes/pages/search.tsx +18 -3
  359. package/src/routes/pages/smart-collection.tsx +340 -0
  360. package/src/routes/pages/subscribe.tsx +182 -0
  361. package/src/runtime/__tests__/index.test.ts +19 -0
  362. package/src/runtime/__tests__/node.test.ts +129 -4
  363. package/src/runtime/cloudflare.ts +21 -2
  364. package/src/runtime/index.ts +27 -1
  365. package/src/runtime/node.ts +18 -1
  366. package/src/runtime/site.ts +95 -24
  367. package/src/services/__tests__/auth.test.ts +116 -7
  368. package/src/services/__tests__/collection.test.ts +108 -4
  369. package/src/services/__tests__/language.test.ts +495 -0
  370. package/src/services/__tests__/navigation.test.ts +213 -10
  371. package/src/services/__tests__/path.test.ts +180 -0
  372. package/src/services/__tests__/post-language.test.ts +830 -0
  373. package/src/services/__tests__/post.test.ts +46 -9
  374. package/src/services/__tests__/settings.test.ts +228 -17
  375. package/src/services/__tests__/site-admin.test.ts +5 -5
  376. package/src/services/__tests__/smart-collection.test.ts +651 -0
  377. package/src/services/about-page.ts +22 -1
  378. package/src/services/auth.ts +51 -49
  379. package/src/services/bootstrap.ts +188 -38
  380. package/src/services/collection-directory-position.ts +76 -0
  381. package/src/services/collection.ts +331 -46
  382. package/src/services/custom-url.ts +27 -22
  383. package/src/services/export-theme/assets/client-site.css +1 -1
  384. package/src/services/export-theme/assets/client-site.js +123 -123
  385. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  386. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  387. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  388. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  389. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  390. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  391. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  392. package/src/services/export-theme/styles/main.css +13 -1
  393. package/src/services/export.ts +278 -19
  394. package/src/services/index.ts +34 -3
  395. package/src/services/language.ts +412 -0
  396. package/src/services/mcp.ts +3 -1
  397. package/src/services/media.ts +14 -6
  398. package/src/services/navigation.ts +429 -67
  399. package/src/services/path.ts +221 -6
  400. package/src/services/post.ts +1021 -152
  401. package/src/services/search.ts +39 -2
  402. package/src/services/settings.ts +238 -43
  403. package/src/services/site-admin.ts +24 -7
  404. package/src/services/site.ts +77 -1
  405. package/src/services/smart-collection.ts +636 -0
  406. package/src/services/telegram.ts +20 -22
  407. package/src/services/upload-session.ts +1 -3
  408. package/src/style-author.css +27 -0
  409. package/src/styles/components-author.css +1622 -0
  410. package/src/styles/components.css +24 -1571
  411. package/src/styles/site-media.css +16 -2
  412. package/src/styles/tokens.css +82 -12
  413. package/src/styles/ui-author.css +7602 -0
  414. package/src/styles/ui.css +1087 -7219
  415. package/src/types/app-context.ts +7 -0
  416. package/src/types/bindings.ts +3 -0
  417. package/src/types/config.ts +94 -15
  418. package/src/types/constants.ts +188 -16
  419. package/src/types/entities.ts +87 -1
  420. package/src/types/operations.ts +64 -2
  421. package/src/types/props.ts +157 -31
  422. package/src/types/views.ts +55 -1
  423. package/src/ui/__tests__/font-themes.test.ts +52 -12
  424. package/src/ui/compose/ComposeDialog.tsx +201 -7
  425. package/src/ui/compose/ComposePrompt.tsx +1 -5
  426. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  427. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  428. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  429. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  430. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  431. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  432. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  433. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  434. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  435. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  436. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  437. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  438. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  439. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  440. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  441. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  442. package/src/ui/dash/settings/discover-copy.ts +116 -0
  443. package/src/ui/feed/LinkCard.tsx +55 -45
  444. package/src/ui/feed/NoteCard.tsx +9 -4
  445. package/src/ui/feed/PostStatusBadges.tsx +90 -43
  446. package/src/ui/feed/QuoteCard.tsx +55 -30
  447. package/src/ui/feed/ThreadPreview.tsx +6 -2
  448. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  449. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  450. package/src/ui/feed/thread-preview-state.ts +20 -6
  451. package/src/ui/font-themes.ts +39 -29
  452. package/src/ui/layouts/BaseLayout.tsx +155 -10
  453. package/src/ui/layouts/SiteLayout.tsx +192 -34
  454. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  455. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  456. package/src/ui/pages/ArchivePage.tsx +282 -289
  457. package/src/ui/pages/CollectionPage.tsx +86 -76
  458. package/src/ui/pages/CollectionsPage.tsx +5 -0
  459. package/src/ui/pages/ComposePage.tsx +8 -0
  460. package/src/ui/pages/PostPage.tsx +47 -0
  461. package/src/ui/pages/SearchPage.tsx +3 -3
  462. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  463. package/src/ui/pages/SubscribePage.tsx +236 -0
  464. package/src/ui/pages/__tests__/ArchivePage.test.tsx +65 -9
  465. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  466. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  467. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  468. package/src/ui/shared/CollectionsManager.tsx +70 -17
  469. package/src/ui/shared/CopyField.tsx +127 -0
  470. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  471. package/src/ui/shared/MediaGallery.tsx +125 -46
  472. package/src/ui/shared/Pagination.tsx +20 -5
  473. package/src/ui/shared/PostFooter.tsx +20 -11
  474. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  475. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  476. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  477. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  478. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  479. package/src/ui/shared/archive-labels.ts +98 -0
  480. package/src/ui/shared/collection-management-labels.ts +94 -16
  481. package/src/ui/shared/navigation-labels.ts +45 -71
  482. package/src/ui/shared/post-article-attributes.ts +2 -0
  483. package/src/ui/shared/smart-collection-labels.ts +343 -0
  484. package/dist/app-C75Zc8dD.js +0 -6
  485. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  486. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  487. package/dist/client/_assets/chunks/url-CU9pEbk5.js +0 -1
  488. package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
  489. package/dist/client/_assets/client-D3VZyG4L.css +0 -2
  490. package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
  491. package/dist/env-BPYViDJJ.js +0 -281
  492. package/dist/github-api-BNF35Lkx.js +0 -176
  493. package/dist/github-app-DdiD-bC4.js +0 -275
  494. package/dist/github-sync-Bqg62IvV.js +0 -4
  495. package/dist/github-sync-HdK2EgvJ.js +0 -431
  496. package/dist/url-CbLAtlZe.js +0 -900
  497. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  498. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  499. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  500. package/src/client/collection-created-notice.ts +0 -66
  501. package/src/client/collection-form-bridge.ts +0 -191
  502. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  503. package/src/client/components/jant-post-form.ts +0 -422
  504. package/src/client/components/post-form-template.ts +0 -352
  505. package/src/client/components/post-form-types.ts +0 -96
  506. package/src/client/media-metadata.ts +0 -247
  507. package/src/client/post-form-bridge.ts +0 -193
  508. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -36,13 +36,18 @@ function createTestHonoApp() {
36
36
  function createMockAuth(authenticated: boolean) {
37
37
  return {
38
38
  api: {
39
- getSession: async () =>
40
- authenticated
39
+ // `attachSession` reads with `returnHeaders: true` so it can write
40
+ // better-auth's refreshed cookies back onto the response, which puts the
41
+ // result in better-call's `{ headers, response }` shape.
42
+ getSession: async () => ({
43
+ headers: new Headers(),
44
+ response: authenticated
41
45
  ? {
42
46
  user: { id: "user-1", email: "test@test.com", name: "Test" },
43
47
  session: { id: "session-1" },
44
48
  }
45
49
  : null,
50
+ }),
46
51
  },
47
52
  } as AppVariables["auth"];
48
53
  }
@@ -200,9 +205,72 @@ describe("requireAuth", () => {
200
205
  expect(res.status).toBe(302);
201
206
  expect(res.headers.get("Location")).toBe("/login");
202
207
  });
208
+
209
+ it("surfaces a route handler's error instead of bouncing to signin", async () => {
210
+ const app = createTestHonoApp();
211
+ app.use("*", async (c, next) => {
212
+ c.set("auth", createMockAuth(true));
213
+ c.set("services", {
214
+ siteMembers: createMockSiteMembers(),
215
+ } as AppVariables["services"]);
216
+ await next();
217
+ });
218
+ app.use("*", attachSession());
219
+ app.get("/settings", requireAuth(), () => {
220
+ throw new Error("saving the post blew up");
221
+ });
222
+
223
+ // Rewriting this into a redirect reads to the author as being randomly
224
+ // signed out, and throws away whatever they were editing.
225
+ const res = await app.request("/settings", { redirect: "manual" });
226
+ expect(res.status).toBe(500);
227
+ });
228
+
229
+ it("surfaces a failed membership lookup instead of bouncing to signin", async () => {
230
+ const app = createTestHonoApp();
231
+ app.use("*", async (c, next) => {
232
+ c.set("auth", createMockAuth(true));
233
+ c.set("services", {
234
+ siteMembers: {
235
+ ...createMockSiteMembers(),
236
+ get: vi.fn(async () => {
237
+ throw new Error("database unavailable");
238
+ }),
239
+ },
240
+ } as unknown as AppVariables["services"]);
241
+ await next();
242
+ });
243
+ app.use("*", attachSession());
244
+ app.get("/settings", requireAuth(), (c) => c.text("Settings"));
245
+
246
+ // `siteMembers.get` resolves to `null` for "not a member", so a throw is
247
+ // always infrastructure — never a reason to claim the user is signed out.
248
+ const res = await app.request("/settings", { redirect: "manual" });
249
+ expect(res.status).toBe(500);
250
+ });
203
251
  });
204
252
 
205
253
  describe("requireAuthApi", () => {
254
+ it("surfaces a route handler's error instead of answering 401", async () => {
255
+ const app = createTestHonoApp();
256
+ app.use("*", async (c, next) => {
257
+ c.set("auth", createMockAuth(true));
258
+ c.set("services", {
259
+ siteMembers: createMockSiteMembers(),
260
+ } as AppVariables["services"]);
261
+ await next();
262
+ });
263
+ app.use("*", attachSession());
264
+ app.get("/api/data", requireAuthApi(), () => {
265
+ throw new Error("upstream failed");
266
+ });
267
+
268
+ // A 401 here would tell the client the login expired and send the author
269
+ // back to sign-in over what is really a server-side failure.
270
+ const res = await app.request("/api/data");
271
+ expect(res.status).toBe(500);
272
+ });
273
+
206
274
  it("allows authenticated requests via session", async () => {
207
275
  const app = createTestHonoApp();
208
276
  app.onError(errorHandler);
@@ -2,12 +2,18 @@ import { describe, expect, it } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import type { Bindings } from "../../types.js";
4
4
  import type { AppVariables } from "../../types/app-context.js";
5
- import { defaultCacheControl } from "../cache-control.js";
5
+ import { defaultCacheControl, noStore } from "../cache-control.js";
6
6
 
7
7
  type Env = { Bindings: Bindings; Variables: AppVariables };
8
8
 
9
- function buildApp(): Hono<Env> {
9
+ function buildApp(isAuthenticated = false): Hono<Env> {
10
10
  const app = new Hono<Env>();
11
+ // Stands in for `attachSession()`, which runs before this middleware and is
12
+ // what puts `isAuthenticated` on the context.
13
+ app.use("*", async (c, next) => {
14
+ c.set("isAuthenticated", isAuthenticated);
15
+ await next();
16
+ });
11
17
  app.use("*", defaultCacheControl());
12
18
 
13
19
  // Un-annotated dynamic page — the common case.
@@ -27,11 +33,31 @@ function buildApp(): Hono<Env> {
27
33
  }
28
34
 
29
35
  describe("defaultCacheControl", () => {
30
- it("defaults un-annotated responses to private, no-store", async () => {
36
+ it("lets an anonymous page stay in the browser's back/forward cache", async () => {
37
+ // `no-store` would disable it, making the back button re-request and
38
+ // re-render a page that is the same for everyone anyway. `private` is
39
+ // what keeps shared caches out, and it is still here.
31
40
  const response = await buildApp().request("/");
41
+ expect(response.headers.get("Cache-Control")).toBe("private, no-cache");
42
+ });
43
+
44
+ it("keeps a signed-in page out of every cache", async () => {
45
+ // The back/forward cache restores a snapshot without asking the server,
46
+ // so an authenticated view would survive signing out.
47
+ const response = await buildApp(true).request("/");
32
48
  expect(response.headers.get("Cache-Control")).toBe("private, no-store");
33
49
  });
34
50
 
51
+ it("never allows a shared cache to store a dynamic page", async () => {
52
+ for (const authed of [false, true]) {
53
+ const value =
54
+ (await buildApp(authed).request("/")).headers.get("Cache-Control") ??
55
+ "";
56
+ expect(value).toContain("private");
57
+ expect(value).not.toContain("public");
58
+ }
59
+ });
60
+
35
61
  it("leaves an explicit public cache policy untouched", async () => {
36
62
  const response = await buildApp().request("/feed");
37
63
  expect(response.headers.get("Cache-Control")).toBe("public, max-age=180");
@@ -45,6 +71,48 @@ describe("defaultCacheControl", () => {
45
71
  it("defaults not-found responses too", async () => {
46
72
  const response = await buildApp().request("/missing");
47
73
  expect(response.status).toBe(404);
74
+ expect(response.headers.get("Cache-Control")).toBe("private, no-cache");
75
+ });
76
+ });
77
+
78
+ describe("noStore", () => {
79
+ function buildCredentialApp(): Hono<Env> {
80
+ const app = new Hono<Env>();
81
+ app.use("*", async (c, next) => {
82
+ c.set("isAuthenticated", false);
83
+ await next();
84
+ });
85
+ app.use("*", defaultCacheControl());
86
+ // Mounted exactly as `app.tsx` does: after the default, before the route.
87
+ app.use("/reset", noStore());
88
+ app.use("/api/auth/*", noStore());
89
+
90
+ app.get("/reset", (c) => c.html("<form/>"));
91
+ // A terminal handler that never calls next(), like better-auth's.
92
+ app.all("/api/auth/*", (c) => c.json({ ok: true }));
93
+ app.get("/", (c) => c.html("<h1>home</h1>"));
94
+
95
+ return app;
96
+ }
97
+
98
+ it("keeps an anonymous credential page out of the browser's disk cache", async () => {
99
+ // The reset URL carries a one-time token, so the anonymous `no-cache`
100
+ // default — which lets the browser keep the page — is the wrong answer.
101
+ const response = await buildCredentialApp().request("/reset");
102
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
103
+ });
104
+
105
+ it("still reaches a terminal handler that never calls next()", async () => {
106
+ // better-auth's `/api/auth/*` handler returns without yielding, so the
107
+ // middleware only runs if it was registered first.
108
+ const response = await buildCredentialApp().request(
109
+ "/api/auth/get-session",
110
+ );
48
111
  expect(response.headers.get("Cache-Control")).toBe("private, no-store");
49
112
  });
113
+
114
+ it("leaves every other anonymous page on the default", async () => {
115
+ const response = await buildCredentialApp().request("/");
116
+ expect(response.headers.get("Cache-Control")).toBe("private, no-cache");
117
+ });
50
118
  });
@@ -4,28 +4,42 @@ import { requireOnboarding, resetOnboardingCache } from "../onboarding.js";
4
4
  import type { Bindings } from "../../types.js";
5
5
  import type { AppVariables } from "../../types/app-context.js";
6
6
  import { DEFAULT_TEST_SITE_ID } from "../../__tests__/helpers/db.js";
7
+ import {
8
+ ONBOARDING_STATUS,
9
+ type OnboardingStatus,
10
+ } from "../../lib/constants.js";
7
11
 
8
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
9
13
 
10
- function createMockServices(complete: boolean) {
14
+ function createMockServices(status: OnboardingStatus) {
11
15
  let callCount = 0;
12
16
  const services = {
13
17
  settings: {
14
- isOnboardingComplete: async () => {
18
+ getOnboardingStatus: async () => {
15
19
  callCount++;
16
- return complete;
20
+ return status;
17
21
  },
18
22
  },
19
23
  } as AppVariables["services"];
20
24
  return { services, getCallCount: () => callCount };
21
25
  }
22
26
 
23
- function createApp(complete: boolean) {
24
- const mock = createMockServices(complete);
27
+ function createApp(
28
+ status: OnboardingStatus | boolean,
29
+ options?: { authenticated?: boolean },
30
+ ) {
31
+ const resolved: OnboardingStatus =
32
+ typeof status === "boolean"
33
+ ? status
34
+ ? ONBOARDING_STATUS.COMPLETED
35
+ : ONBOARDING_STATUS.PENDING
36
+ : status;
37
+ const mock = createMockServices(resolved);
25
38
  const app = new Hono<Env>();
26
39
 
27
40
  app.use("*", async (c, next) => {
28
41
  c.set("services", mock.services);
42
+ c.set("isAuthenticated", options?.authenticated ?? false);
29
43
  c.set("currentSite", {
30
44
  id: DEFAULT_TEST_SITE_ID,
31
45
  key: "default",
@@ -55,6 +69,7 @@ function createApp(complete: boolean) {
55
69
  app.get("/robots.txt", (c) => c.text("robots"));
56
70
  app.get("/sitemap.xml", (c) => c.text("sitemap"));
57
71
  app.get("/media/abc.jpg", (c) => c.text("image"));
72
+ app.get("/compose", (c) => c.text("Compose"));
58
73
 
59
74
  return { app, getCallCount: mock.getCallCount };
60
75
  }
@@ -130,6 +145,62 @@ describe("requireOnboarding", () => {
130
145
  expect(getCallCount()).toBe(2); // queried again
131
146
  });
132
147
 
148
+ // A control plane already built the site and its owner, so readers must be
149
+ // served normally; only the author still owes setup an answer.
150
+ describe("provisioned sites", () => {
151
+ const provisioned = ONBOARDING_STATUS.PROVISIONED;
152
+
153
+ it("serves the public root to a signed-out visitor", async () => {
154
+ const { app } = createApp(provisioned);
155
+ const res = await app.request("/", { redirect: "manual" });
156
+ expect(res.status).toBe(200);
157
+ expect(await res.text()).toBe("Home");
158
+ });
159
+
160
+ it("serves /signin to a signed-out visitor", async () => {
161
+ const { app } = createApp(provisioned);
162
+ const res = await app.request("/signin", { redirect: "manual" });
163
+ expect(res.status).toBe(200);
164
+ });
165
+
166
+ it("redirects the signed-in author from / to /setup", async () => {
167
+ const { app } = createApp(provisioned, { authenticated: true });
168
+ const res = await app.request("/", { redirect: "manual" });
169
+ expect(res.status).toBe(302);
170
+ expect(res.headers.get("Location")).toBe("/setup");
171
+ });
172
+
173
+ it("redirects the signed-in author from /settings to /setup", async () => {
174
+ const { app } = createApp(provisioned, { authenticated: true });
175
+ const res = await app.request("/settings", { redirect: "manual" });
176
+ expect(res.status).toBe(302);
177
+ expect(res.headers.get("Location")).toBe("/setup");
178
+ });
179
+
180
+ it("redirects the signed-in author from /compose to /setup", async () => {
181
+ const { app } = createApp(provisioned, { authenticated: true });
182
+ const res = await app.request("/compose", { redirect: "manual" });
183
+ expect(res.status).toBe(302);
184
+ expect(res.headers.get("Location")).toBe("/setup");
185
+ });
186
+
187
+ it("leaves an archive page alone even for the author", async () => {
188
+ const { app } = createApp(provisioned, { authenticated: true });
189
+ const res = await app.request("/archive", { redirect: "manual" });
190
+ expect(res.status).toBe(200);
191
+ });
192
+
193
+ it("is not cached as complete", async () => {
194
+ const { app, getCallCount } = createApp(provisioned, {
195
+ authenticated: true,
196
+ });
197
+
198
+ await app.request("/", { redirect: "manual" });
199
+ await app.request("/settings", { redirect: "manual" });
200
+ expect(getCallCount()).toBe(2);
201
+ });
202
+ });
203
+
133
204
  describe("non-redirected paths (pass through without DB check)", () => {
134
205
  it("allows /setup", async () => {
135
206
  const { app, getCallCount } = createApp(false);
@@ -3,7 +3,7 @@ import { Hono } from "hono";
3
3
  import type { Bindings } from "../../types.js";
4
4
  import type { AppVariables } from "../../types/app-context.js";
5
5
 
6
- vi.mock("../../lib/version.js", () => ({
6
+ vi.mock("../../lib/build-env.js", () => ({
7
7
  IS_VITE_DEV: false,
8
8
  }));
9
9
 
@@ -1,16 +1,16 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { eq } from "drizzle-orm";
2
3
  import { Hono } from "hono";
3
4
  import { attachSession } from "../session.js";
5
+ import { createAuth } from "../../auth.js";
6
+ import { createTestDatabase } from "../../__tests__/helpers/db.js";
7
+ import { session as sessionTable } from "../../db/schema.js";
4
8
  import type { Bindings } from "../../types.js";
5
- import type { AppVariables } from "../../types/app-context.js";
9
+ import type { AppSession, AppVariables } from "../../types/app-context.js";
6
10
 
7
11
  type Env = { Bindings: Bindings; Variables: AppVariables };
8
12
 
9
- function createAppWithAuth(mockAuth: AppVariables["auth"]): Hono<Env> & {
10
- // ensures tests see session/isAuthenticated via c.var
11
- _lastSession?: AppVariables["session"];
12
- _lastIsAuthenticated?: boolean;
13
- } {
13
+ function createAppWithAuth(mockAuth: AppVariables["auth"]): Hono<Env> {
14
14
  const app = new Hono<Env>();
15
15
  app.use("*", async (c, next) => {
16
16
  c.set("auth", mockAuth);
@@ -20,26 +20,40 @@ function createAppWithAuth(mockAuth: AppVariables["auth"]): Hono<Env> & {
20
20
  return app;
21
21
  }
22
22
 
23
+ /**
24
+ * `attachSession` calls `getSession` with `returnHeaders: true`, so the mock
25
+ * has to answer in better-call's `{ headers, response }` shape.
26
+ */
23
27
  function buildSessionMock(
24
- impl: () => Promise<AppVariables["session"]>,
28
+ impl: () => Promise<{ headers: Headers; response: AppSession }>,
25
29
  ): AppVariables["auth"] {
26
30
  return {
27
- api: {
28
- getSession: impl,
29
- },
31
+ api: { getSession: impl },
30
32
  } as unknown as AppVariables["auth"];
31
33
  }
32
34
 
35
+ const VALID_SESSION = {
36
+ user: { id: "user-1", email: "x@y.z", name: "X" },
37
+ session: { id: "sess-1" },
38
+ } as unknown as AppSession;
39
+
40
+ function respondWith(
41
+ response: AppSession,
42
+ setCookies: string[] = [],
43
+ ): () => Promise<{ headers: Headers; response: AppSession }> {
44
+ return async () => {
45
+ const headers = new Headers();
46
+ for (const cookie of setCookies) headers.append("Set-Cookie", cookie);
47
+ return { headers, response };
48
+ };
49
+ }
50
+
51
+ const REFRESHED_SESSION_COOKIE =
52
+ "__Secure-better-auth.session_token=fresh; Path=/; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax";
53
+
33
54
  describe("attachSession", () => {
34
55
  it("populates c.var.session and isAuthenticated on a valid session", async () => {
35
- const mockAuth = buildSessionMock(
36
- async () =>
37
- ({
38
- user: { id: "user-1", email: "x@y.z", name: "X" },
39
- session: { id: "sess-1" },
40
- }) as unknown as AppVariables["session"],
41
- );
42
- const app = createAppWithAuth(mockAuth);
56
+ const app = createAppWithAuth(buildSessionMock(respondWith(VALID_SESSION)));
43
57
  app.get("/", (c) =>
44
58
  c.json({
45
59
  authed: c.var.isAuthenticated,
@@ -53,33 +67,167 @@ describe("attachSession", () => {
53
67
  });
54
68
 
55
69
  it("sets isAuthenticated=false and session=null when no session is present", async () => {
56
- const mockAuth = buildSessionMock(async () => null);
57
- const app = createAppWithAuth(mockAuth);
70
+ const app = createAppWithAuth(buildSessionMock(respondWith(null)));
58
71
  app.get("/", (c) =>
59
- c.json({
60
- authed: c.var.isAuthenticated,
61
- session: c.var.session,
62
- }),
72
+ c.json({ authed: c.var.isAuthenticated, session: c.var.session }),
63
73
  );
64
74
 
65
75
  const res = await app.request("/");
66
76
  expect(await res.json()).toEqual({ authed: false, session: null });
67
77
  });
68
78
 
69
- it("swallows errors from getSession and treats the request as unauthenticated", async () => {
70
- const mockAuth = buildSessionMock(async () => {
71
- throw new Error("session lookup failed");
72
- });
73
- const app = createAppWithAuth(mockAuth);
74
- app.get("/", (c) =>
75
- c.json({
76
- authed: c.var.isAuthenticated,
77
- session: c.var.session,
79
+ it("retries once so a blipping lookup does not present as a logout", async () => {
80
+ let calls = 0;
81
+ const app = createAppWithAuth(
82
+ buildSessionMock(async () => {
83
+ calls += 1;
84
+ if (calls === 1) throw new Error("transient database error");
85
+ return { headers: new Headers(), response: VALID_SESSION };
86
+ }),
87
+ );
88
+ app.get("/", (c) => c.json({ authed: c.var.isAuthenticated }));
89
+
90
+ const res = await app.request("/");
91
+ expect(calls).toBe(2);
92
+ expect(await res.json()).toEqual({ authed: true });
93
+ });
94
+
95
+ it("swallows a persistent lookup failure and treats the request as unauthenticated", async () => {
96
+ let calls = 0;
97
+ const app = createAppWithAuth(
98
+ buildSessionMock(async () => {
99
+ calls += 1;
100
+ throw new Error("session lookup failed");
78
101
  }),
79
102
  );
103
+ app.get("/", (c) =>
104
+ c.json({ authed: c.var.isAuthenticated, session: c.var.session }),
105
+ );
80
106
 
81
107
  const res = await app.request("/");
82
108
  expect(res.status).toBe(200);
109
+ expect(calls).toBe(2);
83
110
  expect(await res.json()).toEqual({ authed: false, session: null });
84
111
  });
112
+
113
+ it("writes better-auth's refreshed cookies onto the response", async () => {
114
+ const app = createAppWithAuth(
115
+ buildSessionMock(respondWith(VALID_SESSION, [REFRESHED_SESSION_COOKIE])),
116
+ );
117
+ app.get("/", (c) => c.json({ ok: true }));
118
+
119
+ const res = await app.request("/");
120
+ expect(res.headers.getSetCookie()).toEqual([REFRESHED_SESSION_COOKIE]);
121
+ });
122
+
123
+ it("never attaches cookies to a shared-cacheable response", async () => {
124
+ const app = createAppWithAuth(
125
+ buildSessionMock(respondWith(VALID_SESSION, [REFRESHED_SESSION_COOKIE])),
126
+ );
127
+ // Mirrors the media routes, which answer with a year-long public cache
128
+ // directive. A session cookie stored alongside that response is handed to
129
+ // whoever the CDN serves next.
130
+ app.get("/media/x.png", (c) =>
131
+ c.body("bytes", 200, {
132
+ "Cache-Control": "public, max-age=31536000, immutable",
133
+ }),
134
+ );
135
+
136
+ const res = await app.request("/media/x.png");
137
+ expect(res.headers.getSetCookie()).toEqual([]);
138
+ });
139
+
140
+ it("attaches cookies to an explicitly private response", async () => {
141
+ const app = createAppWithAuth(
142
+ buildSessionMock(respondWith(VALID_SESSION, [REFRESHED_SESSION_COOKIE])),
143
+ );
144
+ app.get("/", (c) =>
145
+ c.json({ ok: true }, 200, { "Cache-Control": "private, no-store" }),
146
+ );
147
+
148
+ const res = await app.request("/");
149
+ expect(res.headers.getSetCookie()).toEqual([REFRESHED_SESSION_COOKIE]);
150
+ });
151
+
152
+ it("lets a route's own cookie win, so sign-out is not undone by the refresh", async () => {
153
+ const cleared =
154
+ "__Secure-better-auth.session_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax";
155
+ const app = createAppWithAuth(
156
+ buildSessionMock(respondWith(VALID_SESSION, [REFRESHED_SESSION_COOKIE])),
157
+ );
158
+ app.post("/signout", (c) => c.body(null, 302, { "Set-Cookie": cleared }));
159
+
160
+ const res = await app.request("/signout", { method: "POST" });
161
+ expect(res.headers.getSetCookie()).toEqual([cleared]);
162
+ });
163
+ });
164
+
165
+ describe("attachSession with a real better-auth instance", () => {
166
+ it("re-issues the session cookie so its browser lifetime rolls forward", async () => {
167
+ const { db } = createTestDatabase();
168
+ const auth = createAuth(db, {
169
+ secret: "test-secret-with-enough-entropy-for-session-cookies",
170
+ baseURL: "https://example.com",
171
+ useSecureCookies: true,
172
+ });
173
+
174
+ const signUp = await auth.api.signUpEmail({
175
+ body: {
176
+ name: "Owner",
177
+ email: "owner@example.com",
178
+ password: "correct-horse-battery",
179
+ },
180
+ returnHeaders: true,
181
+ });
182
+ const signUpCookies = signUp.headers.getSetCookie();
183
+ const sessionCookie = signUpCookies.find((cookie) =>
184
+ cookie.includes(".session_token="),
185
+ );
186
+ expect(sessionCookie).toBeDefined();
187
+ // Send only the session token, not the `session_data` cache cookie: that
188
+ // one is written with `Max-Age=300`, so any request more than five minutes
189
+ // after the last one arrives without it and takes the database path. A
190
+ // cache hit deliberately short-circuits before any refresh.
191
+ const cookieHeader = sessionCookie!.split(";")[0]!;
192
+
193
+ // better-auth only re-issues the cookie once per `updateAge` (1 day). Age
194
+ // the stored session past that threshold so this request is the one that
195
+ // refreshes: with `expiresIn` at 90 days, any expiry inside the next 89
196
+ // days is due.
197
+ const rows = await db.select().from(sessionTable);
198
+ expect(rows).toHaveLength(1);
199
+ const due = new Date(Date.now() + 28 * 24 * 60 * 60 * 1000);
200
+ await db
201
+ .update(sessionTable)
202
+ .set({ expiresAt: due })
203
+ .where(eq(sessionTable.id, rows[0]!.id));
204
+
205
+ const app = new Hono<Env>();
206
+ app.use("*", async (c, next) => {
207
+ c.set("auth", auth);
208
+ await next();
209
+ });
210
+ app.use("*", attachSession());
211
+ app.get("/", (c) => c.json({ authed: c.var.isAuthenticated }));
212
+
213
+ const res = await app.request("/", { headers: { cookie: cookieHeader } });
214
+ expect(await res.json()).toEqual({ authed: true });
215
+
216
+ // The regression this guards: without `returnHeaders` the refreshed cookie
217
+ // is dropped, so the browser copy keeps the expiry it was born with and
218
+ // dies a fixed `expiresIn` after sign-in however active the user is.
219
+ // Asserted against the aged-down 28 days rather than the configured window,
220
+ // so this stays a test of "it rolls forward" and not of the current policy.
221
+ const refreshed = res.headers
222
+ .getSetCookie()
223
+ .find((cookie) => cookie.includes(".session_token="));
224
+ expect(refreshed).toBeDefined();
225
+ expect(refreshed).toMatch(/Max-Age=\d+/);
226
+ const maxAge = Number(/Max-Age=(\d+)/.exec(refreshed!)?.[1]);
227
+ expect(maxAge).toBeGreaterThan(29 * 24 * 60 * 60);
228
+
229
+ // And the database row rolled forward with it.
230
+ const [after] = await db.select().from(sessionTable);
231
+ expect(after!.expiresAt.getTime()).toBeGreaterThan(due.getTime());
232
+ });
85
233
  });