@nkzw/codiff-service 1.9.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.
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './api.ts';
2
+ export * from './db.ts';
3
+ export * from './fate.ts';
4
+ export * from './schema.ts';
5
+ export * from './views.ts';
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@nkzw/codiff-service",
3
+ "version": "1.9.0",
4
+ "description": "Provider-neutral Codiff sharing service.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Christoph Nakazawa",
8
+ "email": "christoph.pojer@gmail.com"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/nkzw-tech/codiff.git",
13
+ "directory": "service"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "*.ts",
18
+ "*.tsx"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.mjs",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./index.ts",
26
+ "@nkzw/codiff-source": "./index.ts",
27
+ "default": "./dist/index.mjs"
28
+ },
29
+ "./api": {
30
+ "types": "./api.ts",
31
+ "@nkzw/codiff-source": "./api.ts",
32
+ "default": "./dist/api.mjs"
33
+ },
34
+ "./db": {
35
+ "types": "./db.ts",
36
+ "@nkzw/codiff-source": "./db.ts",
37
+ "default": "./dist/db.mjs"
38
+ },
39
+ "./fate": {
40
+ "types": "./fate.ts",
41
+ "@nkzw/codiff-source": "./fate.ts",
42
+ "default": "./dist/fate.mjs"
43
+ },
44
+ "./react": {
45
+ "types": "./react.tsx",
46
+ "@nkzw/codiff-source": "./react.tsx",
47
+ "default": "./dist/react.mjs"
48
+ },
49
+ "./schema": {
50
+ "types": "./schema.ts",
51
+ "@nkzw/codiff-source": "./schema.ts",
52
+ "default": "./dist/schema.mjs"
53
+ },
54
+ "./upload-intent": {
55
+ "types": "./upload-intent.ts",
56
+ "@nkzw/codiff-source": "./upload-intent.ts",
57
+ "default": "./dist/upload-intent.mjs"
58
+ },
59
+ "./views": {
60
+ "types": "./views.ts",
61
+ "@nkzw/codiff-source": "./views.ts",
62
+ "default": "./dist/views.mjs"
63
+ }
64
+ },
65
+ "dependencies": {
66
+ "@nkzw/fate": "^1.3.2",
67
+ "drizzle-orm": "^0.45.2",
68
+ "zod": "^4.4.3",
69
+ "@nkzw/codiff-core": "1.9.0"
70
+ },
71
+ "peerDependencies": {
72
+ "react": "^19.2.0",
73
+ "react-dom": "^19.2.0"
74
+ },
75
+ "scripts": {
76
+ "build": "rm -rf dist && vp pack -d dist --target=node24 index.ts api.ts db.ts fate.ts react.tsx schema.ts upload-intent.ts views.ts",
77
+ "typecheck": "vp exec tsc --noEmit -p ../tsconfig.json"
78
+ }
79
+ }
package/react.test.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { expect, test } from 'vite-plus/test';
2
+ import { resolveSubmittedShareReply, resolveSubmittedShareThread } from './react.tsx';
3
+
4
+ const comment = {
5
+ body: 'Keep this visible while it saves.',
6
+ filePath: 'src/review.ts',
7
+ lineNumber: 12,
8
+ sectionId: 'src/review.ts:unstaged',
9
+ side: 'additions',
10
+ } as const;
11
+
12
+ const message = {
13
+ authorImage: 'https://github.com/ada.png',
14
+ authorName: 'Ada Lovelace',
15
+ authorUsername: 'ada',
16
+ body: comment.body,
17
+ canEdit: true,
18
+ createdAt: '2026-07-16T12:00:00.000Z',
19
+ id: 'message-1',
20
+ threadId: 'thread-1',
21
+ updatedAt: '2026-07-16T12:00:00.000Z',
22
+ };
23
+
24
+ test('resolves submitted share comments to their persisted server identity', () => {
25
+ expect(
26
+ resolveSubmittedShareReply({
27
+ canResolveThread: true,
28
+ comment: { ...comment, threadId: 'thread-1' },
29
+ result: message,
30
+ }),
31
+ ).toMatchObject({
32
+ author: {
33
+ avatarUrl: 'https://github.com/ada.png',
34
+ login: 'ada',
35
+ name: 'Ada Lovelace',
36
+ },
37
+ body: comment.body,
38
+ canDelete: true,
39
+ canEdit: true,
40
+ canResolveThread: true,
41
+ id: 'message-1',
42
+ submittedAt: '2026-07-16T12:00:00.000Z',
43
+ threadId: 'thread-1',
44
+ });
45
+
46
+ expect(
47
+ resolveSubmittedShareThread({
48
+ canResolveThread: true,
49
+ comment,
50
+ result: {
51
+ id: 'thread-1',
52
+ messages: { items: [{ node: message }] },
53
+ },
54
+ }),
55
+ ).toMatchObject({
56
+ body: comment.body,
57
+ id: 'message-1',
58
+ threadId: 'thread-1',
59
+ });
60
+ });
61
+
62
+ test('rejects incomplete mutation results instead of inventing a submitted comment', () => {
63
+ expect(() =>
64
+ resolveSubmittedShareThread({
65
+ canResolveThread: false,
66
+ comment,
67
+ result: { id: 'thread-1', messages: { items: [] } },
68
+ }),
69
+ ).toThrow('Unable to load the submitted walkthrough comment.');
70
+ });
package/react.tsx ADDED
@@ -0,0 +1,151 @@
1
+ import type {
2
+ GitIdentity,
3
+ PullRequestExistingReviewComment,
4
+ PullRequestReviewComment,
5
+ SharedWalkthroughSnapshot,
6
+ } from '@nkzw/codiff-core';
7
+ import {
8
+ PlanReviewSurface,
9
+ ReviewSurface,
10
+ type PlanReviewCommenting,
11
+ type ReviewCommenting,
12
+ } from '@nkzw/codiff-core/react';
13
+ import type { ComponentProps, ReactNode } from 'react';
14
+
15
+ export type SharedPlanCommenting = PlanReviewCommenting;
16
+ export type SharedWalkthroughCommenting = ReviewCommenting;
17
+
18
+ type SubmittedShareCommentMessage = {
19
+ authorImage: null | string;
20
+ authorName: string;
21
+ authorUsername: null | string;
22
+ body: string;
23
+ canEdit: boolean;
24
+ createdAt: string;
25
+ id: string;
26
+ threadId: string;
27
+ };
28
+
29
+ const asRecord = (value: unknown): Record<string, unknown> | null =>
30
+ value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
31
+
32
+ const submittedShareCommentMessage = (value: unknown): SubmittedShareCommentMessage => {
33
+ const message = asRecord(value);
34
+ if (
35
+ !message ||
36
+ typeof message.authorName !== 'string' ||
37
+ typeof message.body !== 'string' ||
38
+ typeof message.canEdit !== 'boolean' ||
39
+ typeof message.createdAt !== 'string' ||
40
+ typeof message.id !== 'string' ||
41
+ typeof message.threadId !== 'string'
42
+ ) {
43
+ throw new Error('Unable to load the submitted walkthrough comment.');
44
+ }
45
+ return {
46
+ authorImage: typeof message.authorImage === 'string' ? message.authorImage : null,
47
+ authorName: message.authorName,
48
+ authorUsername: typeof message.authorUsername === 'string' ? message.authorUsername : null,
49
+ body: message.body,
50
+ canEdit: message.canEdit,
51
+ createdAt: message.createdAt,
52
+ id: message.id,
53
+ threadId: message.threadId,
54
+ };
55
+ };
56
+
57
+ const toSubmittedReviewComment = (
58
+ comment: PullRequestReviewComment,
59
+ message: SubmittedShareCommentMessage,
60
+ threadId: string,
61
+ canResolveThread: boolean,
62
+ ): PullRequestExistingReviewComment => ({
63
+ ...comment,
64
+ author: {
65
+ ...(message.authorImage ? { avatarUrl: message.authorImage } : {}),
66
+ login: message.authorUsername ?? message.authorName,
67
+ name: message.authorName,
68
+ },
69
+ body: message.body,
70
+ ...(message.canEdit ? { canDelete: true, canEdit: true } : {}),
71
+ ...(canResolveThread ? { canResolveThread: true } : {}),
72
+ id: message.id,
73
+ submittedAt: message.createdAt,
74
+ threadId,
75
+ });
76
+
77
+ export const resolveSubmittedShareReply = ({
78
+ canResolveThread,
79
+ comment,
80
+ result,
81
+ }: {
82
+ canResolveThread: boolean;
83
+ comment: PullRequestReviewComment & { threadId: string };
84
+ result: unknown;
85
+ }) =>
86
+ toSubmittedReviewComment(
87
+ comment,
88
+ submittedShareCommentMessage(result),
89
+ comment.threadId,
90
+ canResolveThread,
91
+ );
92
+
93
+ export const resolveSubmittedShareThread = ({
94
+ canResolveThread,
95
+ comment,
96
+ result,
97
+ }: {
98
+ canResolveThread: boolean;
99
+ comment: PullRequestReviewComment;
100
+ result: unknown;
101
+ }) => {
102
+ const thread = asRecord(result);
103
+ const messages = thread ? asRecord(thread.messages) : null;
104
+ const firstItem = messages && Array.isArray(messages.items) ? asRecord(messages.items[0]) : null;
105
+ if (!thread || typeof thread.id !== 'string' || !firstItem) {
106
+ throw new Error('Unable to load the submitted walkthrough comment.');
107
+ }
108
+ return toSubmittedReviewComment(
109
+ comment,
110
+ submittedShareCommentMessage(firstItem.node),
111
+ thread.id,
112
+ canResolveThread,
113
+ );
114
+ };
115
+
116
+ export function SharedPlanApp({
117
+ providerLabel,
118
+ ...props
119
+ }: Omit<ComponentProps<typeof PlanReviewSurface>, 'signInLabel'> & {
120
+ providerLabel: string;
121
+ }) {
122
+ return <PlanReviewSurface {...props} signInLabel={`Sign in with ${providerLabel} to comment`} />;
123
+ }
124
+
125
+ export function SharedWalkthroughApp({
126
+ commenting,
127
+ gitIdentity,
128
+ onDeleteShare,
129
+ providerLabel,
130
+ settingsBar,
131
+ snapshot,
132
+ }: {
133
+ commenting?: SharedWalkthroughCommenting;
134
+ gitIdentity?: GitIdentity | null;
135
+ onDeleteShare?: () => Promise<void> | void;
136
+ providerLabel: string;
137
+ settingsBar?: ReactNode;
138
+ snapshot: SharedWalkthroughSnapshot;
139
+ }) {
140
+ return (
141
+ <ReviewSurface
142
+ commenting={commenting}
143
+ gitIdentity={gitIdentity}
144
+ onDeleteShare={onDeleteShare}
145
+ providerLabel={providerLabel}
146
+ settingsBar={settingsBar}
147
+ signInLabel={`Sign in with ${providerLabel} to comment`}
148
+ snapshot={snapshot}
149
+ />
150
+ );
151
+ }
package/schema.ts ADDED
@@ -0,0 +1,320 @@
1
+ import { relations, sql } from 'drizzle-orm';
2
+ import {
3
+ check,
4
+ index,
5
+ integer,
6
+ primaryKey,
7
+ sqliteTable,
8
+ text,
9
+ uniqueIndex,
10
+ } from 'drizzle-orm/sqlite-core';
11
+
12
+ const idColumn = () =>
13
+ text()
14
+ .notNull()
15
+ .$defaultFn(() => crypto.randomUUID());
16
+
17
+ const timestampColumn = () =>
18
+ integer({ mode: 'timestamp' })
19
+ .notNull()
20
+ .default(sql`(unixepoch())`);
21
+ const nullableTimestampColumn = () => integer({ mode: 'timestamp' });
22
+
23
+ export const shareCommentThreadKind = ['plan', 'walkthrough-diff', 'walkthrough-general'] as const;
24
+ export const shareCommentThreadStatus = ['open', 'resolved'] as const;
25
+ export const shareKind = ['plan', 'walkthrough'] as const;
26
+
27
+ export const user = sqliteTable(
28
+ 'user',
29
+ {
30
+ banExpires: nullableTimestampColumn(),
31
+ banned: integer({ mode: 'boolean' }),
32
+ banReason: text(),
33
+ createdAt: timestampColumn(),
34
+ displayUsername: text(),
35
+ email: text().notNull(),
36
+ emailVerified: integer({ mode: 'boolean' }).notNull().default(false),
37
+ id: idColumn().primaryKey(),
38
+ image: text(),
39
+ name: text().notNull(),
40
+ password: text(),
41
+ role: text().notNull().default('user'),
42
+ updatedAt: timestampColumn(),
43
+ username: text(),
44
+ },
45
+ (table) => [
46
+ uniqueIndex('user_email_key').on(table.email),
47
+ uniqueIndex('user_username_key').on(table.username),
48
+ index('user_id_idx').on(table.id),
49
+ ],
50
+ );
51
+
52
+ export const walkthrough = sqliteTable(
53
+ 'Walkthrough',
54
+ {
55
+ branch: text(),
56
+ byteSize: integer().notNull(),
57
+ codiffVersion: text().notNull(),
58
+ commentsImportedAt: nullableTimestampColumn(),
59
+ createdAt: timestampColumn(),
60
+ description: text(),
61
+ filesIndexedAt: nullableTimestampColumn(),
62
+ id: idColumn().primaryKey(),
63
+ objectKey: text().notNull(),
64
+ pullRequestNumber: integer(),
65
+ pullRequestTitle: text(),
66
+ pullRequestUrl: text(),
67
+ repositoryHost: text(),
68
+ repositoryName: text(),
69
+ repositoryOwner: text(),
70
+ repositoryUrl: text(),
71
+ schemaVersion: integer().notNull(),
72
+ sha256: text().notNull(),
73
+ sharedByEmail: text(),
74
+ sharedByName: text(),
75
+ sharedByUserId: text().references(() => user.id, { onDelete: 'set null' }),
76
+ slug: text().notNull(),
77
+ sourceType: text().notNull(),
78
+ title: text().notNull(),
79
+ updatedAt: timestampColumn(),
80
+ },
81
+ (table) => [
82
+ uniqueIndex('Walkthrough_slug_key').on(table.slug),
83
+ index('Walkthrough_sharedByEmail_idx').on(table.sharedByEmail),
84
+ index('Walkthrough_sharedByUserId_idx').on(table.sharedByUserId),
85
+ index('Walkthrough_repository_idx').on(table.repositoryOwner, table.repositoryName),
86
+ ],
87
+ );
88
+
89
+ export const walkthroughFile = sqliteTable(
90
+ 'WalkthroughFile',
91
+ {
92
+ path: text().notNull(),
93
+ walkthroughId: text()
94
+ .notNull()
95
+ .references(() => walkthrough.id, { onDelete: 'cascade' }),
96
+ },
97
+ (table) => [
98
+ primaryKey({
99
+ columns: [table.walkthroughId, table.path],
100
+ name: 'WalkthroughFile_walkthroughId_path_pkey',
101
+ }),
102
+ ],
103
+ );
104
+
105
+ export const plan = sqliteTable(
106
+ 'Plan',
107
+ {
108
+ agent: text(),
109
+ byteSize: integer().notNull(),
110
+ codiffVersion: text().notNull(),
111
+ commentsImportedAt: nullableTimestampColumn(),
112
+ createdAt: timestampColumn(),
113
+ id: idColumn().primaryKey(),
114
+ objectKey: text().notNull(),
115
+ schemaVersion: integer().notNull(),
116
+ sessionId: text(),
117
+ sha256: text().notNull(),
118
+ sharedByEmail: text(),
119
+ sharedByName: text(),
120
+ sharedByUserId: text().references(() => user.id, { onDelete: 'set null' }),
121
+ slug: text().notNull(),
122
+ sourceFileName: text().notNull(),
123
+ title: text().notNull(),
124
+ updatedAt: timestampColumn(),
125
+ },
126
+ (table) => [
127
+ uniqueIndex('Plan_slug_key').on(table.slug),
128
+ index('Plan_sharedByEmail_idx').on(table.sharedByEmail),
129
+ index('Plan_sharedByUserId_idx').on(table.sharedByUserId),
130
+ ],
131
+ );
132
+
133
+ export const uploadIntent = sqliteTable(
134
+ 'UploadIntent',
135
+ {
136
+ claimedAt: nullableTimestampColumn(),
137
+ code: text().notNull(),
138
+ createdAt: timestampColumn(),
139
+ expiresAt: integer({ mode: 'timestamp' }).notNull(),
140
+ id: idColumn().primaryKey(),
141
+ secretHash: text().notNull(),
142
+ sharedByUserId: text().references(() => user.id, { onDelete: 'set null' }),
143
+ shareKind: text({ enum: shareKind }).notNull().default('walkthrough'),
144
+ status: text({ enum: ['pending', 'claimed', 'uploaded'] })
145
+ .notNull()
146
+ .default('pending'),
147
+ updatedAt: timestampColumn(),
148
+ uploadTokenHash: text(),
149
+ walkthroughSlug: text(),
150
+ },
151
+ (table) => [
152
+ uniqueIndex('UploadIntent_code_key').on(table.code),
153
+ index('UploadIntent_sharedByUserId_idx').on(table.sharedByUserId),
154
+ ],
155
+ );
156
+
157
+ export const shareCommentThread = sqliteTable(
158
+ 'ShareCommentThread',
159
+ {
160
+ anchorJson: text(),
161
+ createdAt: timestampColumn(),
162
+ filePath: text(),
163
+ id: idColumn().primaryKey(),
164
+ kind: text({ enum: shareCommentThreadKind }).notNull(),
165
+ lineNumber: integer(),
166
+ planId: text().references(() => plan.id, { onDelete: 'cascade' }),
167
+ resolvedAt: nullableTimestampColumn(),
168
+ sectionId: text(),
169
+ side: text({ enum: ['additions', 'deletions'] }),
170
+ sourceThreadId: text(),
171
+ startLineNumber: integer(),
172
+ startSide: text({ enum: ['additions', 'deletions'] }),
173
+ status: text({ enum: shareCommentThreadStatus }).notNull().default('open'),
174
+ updatedAt: timestampColumn(),
175
+ walkthroughId: text().references(() => walkthrough.id, { onDelete: 'cascade' }),
176
+ },
177
+ (table) => [
178
+ check(
179
+ 'ShareCommentThread_parent_check',
180
+ sql`(${table.planId} is not null and ${table.walkthroughId} is null) or (${table.planId} is null and ${table.walkthroughId} is not null)`,
181
+ ),
182
+ index('ShareCommentThread_planId_createdAt_idx').on(table.planId, table.createdAt),
183
+ index('ShareCommentThread_walkthroughId_createdAt_idx').on(
184
+ table.walkthroughId,
185
+ table.createdAt,
186
+ ),
187
+ uniqueIndex('ShareCommentThread_planId_sourceThreadId_key').on(
188
+ table.planId,
189
+ table.sourceThreadId,
190
+ ),
191
+ uniqueIndex('ShareCommentThread_walkthroughId_sourceThreadId_key').on(
192
+ table.walkthroughId,
193
+ table.sourceThreadId,
194
+ ),
195
+ ],
196
+ );
197
+
198
+ export const shareCommentMessage = sqliteTable(
199
+ 'ShareCommentMessage',
200
+ {
201
+ authorImage: text(),
202
+ authorName: text().notNull(),
203
+ authorUserId: text().references(() => user.id, { onDelete: 'set null' }),
204
+ authorUsername: text(),
205
+ body: text().notNull(),
206
+ createdAt: timestampColumn(),
207
+ id: idColumn().primaryKey(),
208
+ sourceMessageId: text(),
209
+ threadId: text()
210
+ .notNull()
211
+ .references(() => shareCommentThread.id, { onDelete: 'cascade' }),
212
+ updatedAt: timestampColumn(),
213
+ },
214
+ (table) => [
215
+ index('ShareCommentMessage_authorUserId_idx').on(table.authorUserId),
216
+ index('ShareCommentMessage_threadId_createdAt_idx').on(table.threadId, table.createdAt),
217
+ uniqueIndex('ShareCommentMessage_threadId_sourceMessageId_key').on(
218
+ table.threadId,
219
+ table.sourceMessageId,
220
+ ),
221
+ ],
222
+ );
223
+
224
+ export const shareDailyUsage = sqliteTable(
225
+ 'ShareDailyUsage',
226
+ {
227
+ date: text().notNull(),
228
+ planCount: integer().notNull().default(0),
229
+ userId: text()
230
+ .notNull()
231
+ .references(() => user.id, { onDelete: 'cascade' }),
232
+ walkthroughCount: integer().notNull().default(0),
233
+ },
234
+ (table) => [
235
+ primaryKey({
236
+ columns: [table.userId, table.date],
237
+ name: 'ShareDailyUsage_userId_date_pkey',
238
+ }),
239
+ ],
240
+ );
241
+
242
+ export const userRelations = relations(user, ({ many }) => ({
243
+ shareCommentMessages: many(shareCommentMessage),
244
+ shareDailyUsage: many(shareDailyUsage),
245
+ }));
246
+
247
+ export const walkthroughRelations = relations(walkthrough, ({ many }) => ({
248
+ commentThreads: many(shareCommentThread),
249
+ files: many(walkthroughFile),
250
+ }));
251
+
252
+ export const walkthroughFileRelations = relations(walkthroughFile, ({ one }) => ({
253
+ walkthrough: one(walkthrough, {
254
+ fields: [walkthroughFile.walkthroughId],
255
+ references: [walkthrough.id],
256
+ }),
257
+ }));
258
+
259
+ export const planRelations = relations(plan, ({ many }) => ({
260
+ commentThreads: many(shareCommentThread),
261
+ }));
262
+
263
+ export const shareCommentThreadRelations = relations(shareCommentThread, ({ many, one }) => ({
264
+ messages: many(shareCommentMessage),
265
+ plan: one(plan, {
266
+ fields: [shareCommentThread.planId],
267
+ references: [plan.id],
268
+ }),
269
+ walkthrough: one(walkthrough, {
270
+ fields: [shareCommentThread.walkthroughId],
271
+ references: [walkthrough.id],
272
+ }),
273
+ }));
274
+
275
+ export const shareCommentMessageRelations = relations(shareCommentMessage, ({ one }) => ({
276
+ author: one(user, {
277
+ fields: [shareCommentMessage.authorUserId],
278
+ references: [user.id],
279
+ }),
280
+ thread: one(shareCommentThread, {
281
+ fields: [shareCommentMessage.threadId],
282
+ references: [shareCommentThread.id],
283
+ }),
284
+ }));
285
+
286
+ export const shareDailyUsageRelations = relations(shareDailyUsage, ({ one }) => ({
287
+ user: one(user, {
288
+ fields: [shareDailyUsage.userId],
289
+ references: [user.id],
290
+ }),
291
+ }));
292
+
293
+ export type UserRow = typeof user.$inferSelect;
294
+ export type UploadIntentRow = typeof uploadIntent.$inferSelect;
295
+ export type PlanRow = typeof plan.$inferSelect;
296
+ export type WalkthroughRow = typeof walkthrough.$inferSelect;
297
+ export type ShareCommentThreadRow = typeof shareCommentThread.$inferSelect;
298
+ export type ShareCommentMessageRow = typeof shareCommentMessage.$inferSelect;
299
+ export type ShareCommentThreadKind = (typeof shareCommentThreadKind)[number];
300
+ export type ShareCommentThreadStatus = (typeof shareCommentThreadStatus)[number];
301
+
302
+ const schema = {
303
+ plan,
304
+ planRelations,
305
+ shareCommentMessage,
306
+ shareCommentMessageRelations,
307
+ shareCommentThread,
308
+ shareCommentThreadRelations,
309
+ shareDailyUsage,
310
+ shareDailyUsageRelations,
311
+ uploadIntent,
312
+ user,
313
+ userRelations,
314
+ walkthrough,
315
+ walkthroughFile,
316
+ walkthroughFileRelations,
317
+ walkthroughRelations,
318
+ } as const;
319
+
320
+ export default schema;
@@ -0,0 +1,24 @@
1
+ import { expect, test } from 'vite-plus/test';
2
+ import {
3
+ createUploadIntentSecret,
4
+ hashUploadIntentSecret,
5
+ verifyUploadIntentSecret,
6
+ } from './upload-intent.ts';
7
+
8
+ const env = { BETTER_AUTH_SECRET: 'test-secret-with-at-least-thirty-two-characters' };
9
+
10
+ test('creates upload intent capabilities bound to their code and expiration', async () => {
11
+ const expiresAt = new Date('2026-07-16T12:00:00.000Z');
12
+ const secret = await createUploadIntentSecret(env, 'UPLOADCODE', expiresAt);
13
+
14
+ expect(await verifyUploadIntentSecret(env, 'UPLOADCODE', secret)).toEqual({ expiresAt });
15
+ expect(await verifyUploadIntentSecret(env, 'OTHER-CODE', secret)).toBeNull();
16
+ expect(await verifyUploadIntentSecret(env, 'UPLOADCODE', `${secret}tampered`)).toBeNull();
17
+ expect(await hashUploadIntentSecret(secret)).toMatch(/^[a-f0-9]{64}$/);
18
+ });
19
+
20
+ test('requires a configured signing secret', async () => {
21
+ await expect(
22
+ createUploadIntentSecret({}, 'UPLOADCODE', new Date('2026-07-16T12:00:00.000Z')),
23
+ ).rejects.toThrow('BETTER_AUTH_SECRET');
24
+ });