@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -2,10 +2,22 @@ import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../__tests__/helpers/db.js";
3
3
  import { createSearchService } from "../search.js";
4
4
  import { createPostService } from "../post.js";
5
- import { createPathRegistryService } from "../path-registry.js";
6
5
  import type { Database } from "../../db/index.js";
7
6
  import type BetterSqlite3 from "better-sqlite3";
8
7
 
8
+ /** Wraps plain text in a minimal valid TipTap JSON document. */
9
+ function tiptapDoc(text: string): string {
10
+ return JSON.stringify({
11
+ type: "doc",
12
+ content: [
13
+ {
14
+ type: "paragraph",
15
+ content: [{ type: "text", text }],
16
+ },
17
+ ],
18
+ });
19
+ }
20
+
9
21
  describe("SearchService", () => {
10
22
  let db: Database;
11
23
  let sqlite: BetterSqlite3.Database;
@@ -34,7 +46,7 @@ describe("SearchService", () => {
34
46
  const testDb = createTestDatabase({ fts: true });
35
47
  db = testDb.db as unknown as Database;
36
48
  sqlite = testDb.sqlite;
37
- postService = createPostService(db, createPathRegistryService(db));
49
+ postService = createPostService(db, { slugIdLength: 5 });
38
50
  });
39
51
 
40
52
  it("returns empty results for empty query", async () => {
@@ -56,11 +68,11 @@ describe("SearchService", () => {
56
68
  it("finds posts by content", async () => {
57
69
  await postService.create({
58
70
  format: "note",
59
- body: "Hello world from jant",
71
+ body: tiptapDoc("Hello world from jant"),
60
72
  });
61
73
  await postService.create({
62
74
  format: "note",
63
- body: "Another post entirely",
75
+ body: tiptapDoc("Another post entirely"),
64
76
  });
65
77
 
66
78
  const d1 = createMockD1(sqlite);
@@ -68,14 +80,14 @@ describe("SearchService", () => {
68
80
 
69
81
  const results = await searchService.search("jant");
70
82
  expect(results.length).toBeGreaterThanOrEqual(1);
71
- expect(results[0]?.post.body).toContain("jant");
83
+ expect(results[0]?.post.bodyText).toContain("jant");
72
84
  });
73
85
 
74
86
  it("finds posts by title", async () => {
75
87
  await postService.create({
76
88
  format: "note",
77
89
  title: "Introduction to TypeScript",
78
- body: "Some article body",
90
+ body: tiptapDoc("Some article body"),
79
91
  });
80
92
 
81
93
  const d1 = createMockD1(sqlite);
@@ -89,11 +101,11 @@ describe("SearchService", () => {
89
101
  it("respects status filter", async () => {
90
102
  await postService.create({
91
103
  format: "note",
92
- body: "published post about testing",
104
+ body: tiptapDoc("published post about testing"),
93
105
  });
94
106
  await postService.create({
95
107
  format: "note",
96
- body: "draft post about testing",
108
+ body: tiptapDoc("draft post about testing"),
97
109
  status: "draft",
98
110
  });
99
111
 
@@ -110,7 +122,7 @@ describe("SearchService", () => {
110
122
  it("excludes deleted posts", async () => {
111
123
  const post = await postService.create({
112
124
  format: "note",
113
- body: "deleted post with unique search term xyzzy",
125
+ body: tiptapDoc("deleted post with unique search term xyzzy"),
114
126
  });
115
127
  await postService.delete(post.id);
116
128
 
@@ -125,7 +137,7 @@ describe("SearchService", () => {
125
137
  for (let i = 0; i < 5; i++) {
126
138
  await postService.create({
127
139
  format: "note",
128
- body: `searchable post number ${i}`,
140
+ body: tiptapDoc(`searchable post number ${i}`),
129
141
  });
130
142
  }
131
143
 
@@ -135,4 +147,49 @@ describe("SearchService", () => {
135
147
  const limited = await searchService.search("searchable", { limit: 2 });
136
148
  expect(limited.length).toBeLessThanOrEqual(2);
137
149
  });
150
+
151
+ it("finds link posts by URL", async () => {
152
+ await postService.create({
153
+ format: "link",
154
+ title: "Example Site",
155
+ url: "https://example.com/article",
156
+ });
157
+
158
+ const d1 = createMockD1(sqlite);
159
+ const searchService = createSearchService(d1);
160
+
161
+ const results = await searchService.search("example.com");
162
+ expect(results.length).toBeGreaterThanOrEqual(1);
163
+ expect(results[0]?.post.url).toContain("example.com");
164
+ });
165
+
166
+ it("finds posts with short queries (< 3 chars) via LIKE fallback", async () => {
167
+ await postService.create({
168
+ format: "note",
169
+ body: tiptapDoc("自由软件"),
170
+ });
171
+
172
+ const d1 = createMockD1(sqlite);
173
+ const searchService = createSearchService(d1);
174
+
175
+ // "自由" is 2 Chinese characters — below trigram minimum, uses LIKE
176
+ const results = await searchService.search("自由");
177
+ expect(results.length).toBeGreaterThanOrEqual(1);
178
+ // LIKE fallback returns no snippet
179
+ expect(results[0]?.snippet).toBeUndefined();
180
+ });
181
+
182
+ it("does not match TipTap JSON structural tokens", async () => {
183
+ await postService.create({
184
+ format: "note",
185
+ body: tiptapDoc("Hello world"),
186
+ });
187
+
188
+ const d1 = createMockD1(sqlite);
189
+ const searchService = createSearchService(d1);
190
+
191
+ // "paragraph" is a JSON key in TipTap but not user content
192
+ const results = await searchService.search("paragraph");
193
+ expect(results).toHaveLength(0);
194
+ });
138
195
  });
@@ -102,7 +102,7 @@ describe("SettingsService", () => {
102
102
  siteFooter: "",
103
103
  siteLanguage: "en",
104
104
  homeDefaultView: "latest",
105
- headerNavMaxVisible: "3",
105
+ headerNavMaxVisible: "2",
106
106
  timeZone: "UTC",
107
107
  };
108
108
 
@@ -154,10 +154,10 @@ describe("SettingsService", () => {
154
154
  expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
155
155
  });
156
156
 
157
- it("removes HEADER_NAV_MAX_VISIBLE when set to default (3)", async () => {
157
+ it("removes HEADER_NAV_MAX_VISIBLE when set to default (2)", async () => {
158
158
  await settingsService.set("HEADER_NAV_MAX_VISIBLE", "5");
159
159
  await settingsService.updateGeneral(
160
- { ...defaults, headerNavMaxVisible: "3" },
160
+ { ...defaults, headerNavMaxVisible: "2" },
161
161
  { oldLanguage: "en", fallbackSiteName: "Jant" },
162
162
  );
163
163
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * API Token Service
3
+ *
4
+ * Manages long-lived Bearer tokens for programmatic API access.
5
+ * Tokens are stored as SHA-256 hashes — the plaintext is shown only once at creation.
6
+ */
7
+
8
+ import { eq } from "drizzle-orm";
9
+ import { uuidv7 } from "uuidv7";
10
+ import type { Database } from "../db/index.js";
11
+ import { apiTokens } from "../db/schema.js";
12
+ import { now } from "../lib/time.js";
13
+ import type { ApiToken } from "../types/entities.js";
14
+
15
+ const TOKEN_PREFIX = "jnt_";
16
+
17
+ export interface ApiTokenService {
18
+ /**
19
+ * Creates a new API token.
20
+ *
21
+ * @param name - User-assigned label for the token
22
+ * @returns The created token metadata and the plaintext (shown only once)
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const { token, plaintext } = await apiTokens.create("iOS Shortcuts");
27
+ * // plaintext: "jnt_a1b2c3d4..." — display once, never stored
28
+ * ```
29
+ */
30
+ create(name: string): Promise<{ token: ApiToken; plaintext: string }>;
31
+
32
+ /**
33
+ * Lists all active API tokens (without hashes).
34
+ *
35
+ * @returns Array of tokens sorted by creation date (newest first)
36
+ */
37
+ list(): Promise<ApiToken[]>;
38
+
39
+ /**
40
+ * Deletes an API token by ID.
41
+ *
42
+ * @param id - Token ID (UUIDv7)
43
+ * @returns `true` if a token was deleted, `false` if not found
44
+ */
45
+ delete(id: string): Promise<boolean>;
46
+
47
+ /**
48
+ * Verifies a raw Bearer token against stored hashes.
49
+ *
50
+ * @param rawToken - The full token string (e.g. "jnt_a1b2c3d4...")
51
+ * @returns The token ID if valid, `null` if invalid or not found
52
+ */
53
+ verify(rawToken: string): Promise<string | null>;
54
+
55
+ /**
56
+ * Updates the last-used timestamp for a token.
57
+ * Intended to be called fire-and-forget after successful verification.
58
+ *
59
+ * @param id - Token ID (UUIDv7)
60
+ */
61
+ updateLastUsed(id: string): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Hashes a raw token string using SHA-256.
66
+ *
67
+ * @param raw - The raw token bytes as a hex string (without prefix)
68
+ * @returns Hex-encoded SHA-256 hash
69
+ */
70
+ async function hashToken(raw: string): Promise<string> {
71
+ const encoded = new TextEncoder().encode(raw);
72
+ const digest = await crypto.subtle.digest("SHA-256", encoded);
73
+ return Array.from(new Uint8Array(digest))
74
+ .map((b) => b.toString(16).padStart(2, "0"))
75
+ .join("");
76
+ }
77
+
78
+ /**
79
+ * Generates cryptographically random hex bytes.
80
+ *
81
+ * @param byteCount - Number of random bytes
82
+ * @returns Hex string of the random bytes
83
+ */
84
+ function randomHex(byteCount: number): string {
85
+ const bytes = new Uint8Array(byteCount);
86
+ crypto.getRandomValues(bytes);
87
+ return Array.from(bytes)
88
+ .map((b) => b.toString(16).padStart(2, "0"))
89
+ .join("");
90
+ }
91
+
92
+ function toApiToken(row: typeof apiTokens.$inferSelect): ApiToken {
93
+ return {
94
+ id: row.id,
95
+ name: row.name,
96
+ prefix: row.prefix,
97
+ lastUsedAt: row.lastUsedAt,
98
+ createdAt: row.createdAt,
99
+ updatedAt: row.updatedAt,
100
+ };
101
+ }
102
+
103
+ export function createApiTokenService(db: Database): ApiTokenService {
104
+ return {
105
+ async create(name: string) {
106
+ const id = uuidv7();
107
+ const timestamp = now();
108
+ const hex = randomHex(32); // 64 hex chars
109
+ const plaintext = `${TOKEN_PREFIX}${hex}`;
110
+ const tokenHash = await hashToken(plaintext);
111
+ const prefix = hex.slice(0, 8);
112
+
113
+ const result = await db
114
+ .insert(apiTokens)
115
+ .values({
116
+ id,
117
+ name,
118
+ tokenHash,
119
+ prefix,
120
+ lastUsedAt: null,
121
+ createdAt: timestamp,
122
+ updatedAt: timestamp,
123
+ })
124
+ .returning();
125
+
126
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns
127
+ return { token: toApiToken(result[0]!), plaintext };
128
+ },
129
+
130
+ async list() {
131
+ const rows = await db
132
+ .select()
133
+ .from(apiTokens)
134
+ .orderBy(apiTokens.createdAt);
135
+ return rows.map(toApiToken);
136
+ },
137
+
138
+ async delete(id: string) {
139
+ const result = await db
140
+ .delete(apiTokens)
141
+ .where(eq(apiTokens.id, id))
142
+ .returning();
143
+ return result.length > 0;
144
+ },
145
+
146
+ async verify(rawToken: string) {
147
+ if (!rawToken.startsWith(TOKEN_PREFIX)) return null;
148
+
149
+ const tokenHash = await hashToken(rawToken);
150
+ const rows = await db
151
+ .select({ id: apiTokens.id })
152
+ .from(apiTokens)
153
+ .where(eq(apiTokens.tokenHash, tokenHash))
154
+ .limit(1);
155
+
156
+ return rows[0]?.id ?? null;
157
+ },
158
+
159
+ async updateLastUsed(id: string) {
160
+ await db
161
+ .update(apiTokens)
162
+ .set({ lastUsedAt: now(), updatedAt: now() })
163
+ .where(eq(apiTokens.id, id));
164
+ },
165
+ };
166
+ }
@@ -45,11 +45,26 @@ export function createAuthService(
45
45
  if (!stored) return false;
46
46
 
47
47
  const separatorIndex = stored.lastIndexOf(":");
48
- const storedToken = stored.substring(0, separatorIndex);
48
+ const storedHash = stored.substring(0, separatorIndex);
49
49
  const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
50
50
  const now = Math.floor(Date.now() / 1000);
51
51
 
52
- return token === storedToken && now <= expiry;
52
+ if (now > expiry) return false;
53
+
54
+ const hashBuffer = await crypto.subtle.digest(
55
+ "SHA-256",
56
+ new TextEncoder().encode(token),
57
+ );
58
+ const tokenHash = Array.from(new Uint8Array(hashBuffer))
59
+ .map((b) => b.toString(16).padStart(2, "0"))
60
+ .join("");
61
+
62
+ const encoder = new TextEncoder();
63
+ const a = encoder.encode(tokenHash);
64
+ const b = encoder.encode(storedHash);
65
+ if (a.byteLength !== b.byteLength) return false;
66
+
67
+ return crypto.subtle.timingSafeEqual(a, b);
53
68
  }
54
69
 
55
70
  return {