@jant/core 0.3.36 → 0.3.38
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.
- package/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- 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,
|
|
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.
|
|
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
|
+
}
|
package/src/services/auth.ts
CHANGED
|
@@ -45,11 +45,26 @@ export function createAuthService(
|
|
|
45
45
|
if (!stored) return false;
|
|
46
46
|
|
|
47
47
|
const separatorIndex = stored.lastIndexOf(":");
|
|
48
|
-
const
|
|
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
|
-
|
|
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 {
|