@jant/core 0.3.36 → 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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -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
  });
@@ -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 {