@nkzw/codiff-service 1.10.0 → 1.11.1

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/api.test.ts CHANGED
@@ -1,5 +1,41 @@
1
1
  import { expect, test } from 'vite-plus/test';
2
- import { handleSharingApiRequest, type SharingEnv } from './api.ts';
2
+ import { handleSharingApiRequest, readRequestText, type SharingEnv } from './api.ts';
3
+
4
+ test('reads request bodies within a byte limit', async () => {
5
+ const request = new Request('https://codiff.dev/api/uploads', {
6
+ body: new Uint8Array([0x61, 0xe2, 0x82, 0xac]),
7
+ method: 'POST',
8
+ });
9
+
10
+ expect(await readRequestText(request, 4)).toBe('a€');
11
+ });
12
+
13
+ test('rejects request bodies that exceed declared or streamed byte limits', async () => {
14
+ const declared = new Request('https://codiff.dev/api/uploads', {
15
+ body: 'small',
16
+ headers: { 'content-length': '100' },
17
+ method: 'POST',
18
+ });
19
+ expect(await readRequestText(declared, 5)).toBeNull();
20
+
21
+ let cancelled = false;
22
+ const streamed = new Request('https://codiff.dev/api/uploads', {
23
+ body: new ReadableStream({
24
+ cancel() {
25
+ cancelled = true;
26
+ throw new Error('Cancellation failed.');
27
+ },
28
+ start(controller) {
29
+ controller.enqueue(new Uint8Array([1, 2, 3]));
30
+ controller.enqueue(new Uint8Array([4, 5, 6]));
31
+ },
32
+ }),
33
+ duplex: 'half',
34
+ method: 'POST',
35
+ } as RequestInit);
36
+ expect(await readRequestText(streamed, 5)).toBeNull();
37
+ expect(cancelled).toBe(true);
38
+ });
3
39
 
4
40
  test('creates anonymous upload intents without touching persistent storage', async () => {
5
41
  let databaseAccesses = 0;
package/api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parsePlanShareUpload } from '@nkzw/codiff-core/share';
2
2
  import { z } from 'zod';
3
- import { db, eq, sql, withDatabase, type D1Binding } from './db.ts';
3
+ import { and, db, eq, sql, withDatabase, type D1Binding } from './db.ts';
4
4
  import { plan, shareDailyUsage, uploadIntent, user, walkthrough, type UserRow } from './schema.ts';
5
5
  import {
6
6
  createUploadIntentSecret,
@@ -185,6 +185,44 @@ const unauthorized = () => json({ error: 'authentication-required' }, { status:
185
185
  const maxUploadByteSize = 25 * 1024 * 1024;
186
186
  const textEncoder = new TextEncoder();
187
187
 
188
+ export const readRequestText = async (request: Request, maximumByteSize: number) => {
189
+ const contentLength = request.headers.get('content-length');
190
+ if (contentLength && Number(contentLength) > maximumByteSize) {
191
+ return null;
192
+ }
193
+
194
+ if (!request.body) {
195
+ return '';
196
+ }
197
+
198
+ const reader = request.body.getReader();
199
+ const decoder = new TextDecoder();
200
+ let byteSize = 0;
201
+ const chunks: Array<string> = [];
202
+ try {
203
+ while (true) {
204
+ const { done, value } = await reader.read();
205
+ if (done) {
206
+ chunks.push(decoder.decode());
207
+ return chunks.join('');
208
+ }
209
+
210
+ byteSize += value.byteLength;
211
+ if (byteSize > maximumByteSize) {
212
+ try {
213
+ await reader.cancel();
214
+ } catch {
215
+ // Cancellation is best-effort; the size limit still determines the response.
216
+ }
217
+ return null;
218
+ }
219
+ chunks.push(decoder.decode(value, { stream: true }));
220
+ }
221
+ } finally {
222
+ reader.releaseLock();
223
+ }
224
+ };
225
+
188
226
  const isRecord = (value: unknown): value is Record<string, unknown> =>
189
227
  value !== null && typeof value === 'object';
190
228
 
@@ -517,8 +555,8 @@ const uploadShare = async (request: Request, env: SharingEnv, options: SharingAp
517
555
  return unauthorized();
518
556
  }
519
557
 
520
- const body = await request.text();
521
- if (textEncoder.encode(body).byteLength > maxUploadByteSize) {
558
+ const body = await readRequestText(request, maxUploadByteSize);
559
+ if (body === null) {
522
560
  return json({ error: 'manifest-too-large' }, { status: 413 });
523
561
  }
524
562
 
@@ -547,11 +585,20 @@ const uploadShare = async (request: Request, env: SharingEnv, options: SharingAp
547
585
  }
548
586
  const now = new Date();
549
587
 
550
- await env.WALKTHROUGH_BUCKET.put(objectKey, canonical, {
551
- httpMetadata: { contentType: 'application/json; charset=utf-8' },
552
- });
588
+ const [ownedIntent] = await db
589
+ .update(uploadIntent)
590
+ .set({ status: 'uploading', updatedAt: now })
591
+ .where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, 'claimed')))
592
+ .returning({ id: uploadIntent.id });
593
+ if (!ownedIntent) {
594
+ return unauthorized();
595
+ }
553
596
 
554
597
  try {
598
+ await env.WALKTHROUGH_BUCKET.put(objectKey, canonical, {
599
+ httpMetadata: { contentType: 'application/json; charset=utf-8' },
600
+ });
601
+
555
602
  if (upload.kind === 'walkthrough') {
556
603
  const source = upload.snapshot.repository.source;
557
604
  const repository = getRepositoryMetadata(source);
@@ -591,7 +638,7 @@ const uploadShare = async (request: Request, env: SharingEnv, options: SharingAp
591
638
  updatedAt: now,
592
639
  walkthroughSlug: slug,
593
640
  })
594
- .where(eq(uploadIntent.id, intent.id)),
641
+ .where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, 'uploading'))),
595
642
  ]);
596
643
  } else {
597
644
  await db.batch([
@@ -619,11 +666,18 @@ const uploadShare = async (request: Request, env: SharingEnv, options: SharingAp
619
666
  updatedAt: now,
620
667
  walkthroughSlug: slug,
621
668
  })
622
- .where(eq(uploadIntent.id, intent.id)),
669
+ .where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, 'uploading'))),
623
670
  ]);
624
671
  }
625
672
  } catch (error) {
626
- await env.WALKTHROUGH_BUCKET.delete(objectKey);
673
+ try {
674
+ await env.WALKTHROUGH_BUCKET.delete(objectKey);
675
+ } finally {
676
+ await db
677
+ .update(uploadIntent)
678
+ .set({ status: 'claimed', updatedAt: new Date() })
679
+ .where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, 'uploading')));
680
+ }
627
681
  if (isQuotaError(error)) {
628
682
  return quotaResponse();
629
683
  }
package/dist/api.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { plan, shareDailyUsage, uploadIntent, user, walkthrough } from "./schema.mjs";
2
- import { db, eq, sql, withDatabase } from "./db.mjs";
2
+ import { and, db, eq, sql, withDatabase } from "./db.mjs";
3
3
  import { createUploadIntentSecret, hashUploadIntentSecret, verifyUploadIntentSecret } from "./upload-intent.mjs";
4
4
  import { parsePlanShareUpload } from "@nkzw/codiff-core/share";
5
5
  import { z } from "zod";
@@ -14,7 +14,7 @@ const reviewAuthorSchema = z.object({
14
14
  const reviewCommentSchema = z.object({
15
15
  anchor: z.enum(["file", "line"]).optional(),
16
16
  author: reviewAuthorSchema,
17
- body: boundedString(128 * 1024),
17
+ body: boundedString(131072),
18
18
  filePath: nonEmptyString(4096),
19
19
  id: nonEmptyString(256),
20
20
  isThreadResolved: z.boolean().optional(),
@@ -28,7 +28,7 @@ const reviewCommentSchema = z.object({
28
28
  }).passthrough().refine((comment) => comment.anchor === "file" || comment.lineNumber != null && comment.side != null && (comment.startLineNumber == null || comment.startSide != null), "Invalid walkthrough review comment.");
29
29
  const generalCommentSchema = z.object({
30
30
  author: reviewAuthorSchema,
31
- body: boundedString(128 * 1024),
31
+ body: boundedString(131072),
32
32
  id: nonEmptyString(256),
33
33
  submittedAt: boundedString(64).optional()
34
34
  }).passthrough();
@@ -60,7 +60,7 @@ const walkthroughManifestSchema = z.object({
60
60
  reviewComments: z.array(reviewCommentSchema).max(1e4).optional(),
61
61
  version: z.literal(1),
62
62
  walkthrough: z.object({
63
- summary: boundedString(128 * 1024).optional(),
63
+ summary: boundedString(131072).optional(),
64
64
  title: nonEmptyString(300)
65
65
  }).passthrough()
66
66
  }).passthrough();
@@ -82,8 +82,36 @@ const json = (value, init) => new Response(JSON.stringify(value), {
82
82
  const notFound = () => json({ error: "not-found" }, { status: 404 });
83
83
  const badRequest = (message) => json({ error: message }, { status: 400 });
84
84
  const unauthorized = () => json({ error: "authentication-required" }, { status: 401 });
85
- const maxUploadByteSize = 25 * 1024 * 1024;
85
+ const maxUploadByteSize = 26214400;
86
86
  const textEncoder = new TextEncoder();
87
+ const readRequestText = async (request, maximumByteSize) => {
88
+ const contentLength = request.headers.get("content-length");
89
+ if (contentLength && Number(contentLength) > maximumByteSize) return null;
90
+ if (!request.body) return "";
91
+ const reader = request.body.getReader();
92
+ const decoder = new TextDecoder();
93
+ let byteSize = 0;
94
+ const chunks = [];
95
+ try {
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done) {
99
+ chunks.push(decoder.decode());
100
+ return chunks.join("");
101
+ }
102
+ byteSize += value.byteLength;
103
+ if (byteSize > maximumByteSize) {
104
+ try {
105
+ await reader.cancel();
106
+ } catch {}
107
+ return null;
108
+ }
109
+ chunks.push(decoder.decode(value, { stream: true }));
110
+ }
111
+ } finally {
112
+ reader.releaseLock();
113
+ }
114
+ };
87
115
  const isRecord = (value) => value !== null && typeof value === "object";
88
116
  const isBetterAuthApi = (value) => isRecord(value) && isRecord(value.api) && typeof value.api.getSession === "function";
89
117
  const stringValue = (value) => typeof value === "string" && value ? value : null;
@@ -233,7 +261,7 @@ const hasAvailableQuota = async (userId, kind) => {
233
261
  const createUploadIntent = async (request, env, options) => {
234
262
  const code = randomCode();
235
263
  const now = /* @__PURE__ */ new Date();
236
- const expiresAt = new Date(now.getTime() + 600 * 1e3);
264
+ const expiresAt = new Date(now.getTime() + 6e5);
237
265
  const secret = await createUploadIntentSecret(env, code, expiresAt);
238
266
  const tokenHash = await hashUploadIntentSecret(secret);
239
267
  const shareUser = await getAuthenticatedShareUser(request, options.auth);
@@ -281,8 +309,8 @@ const uploadShare = async (request, env, options) => {
281
309
  if (!code || !token) return unauthorized();
282
310
  const [intent] = await db.select().from(uploadIntent).where(eq(uploadIntent.code, code)).limit(1);
283
311
  if (!intent || intent.status !== "claimed" || !intent.sharedByUserId || intent.expiresAt.getTime() < Date.now() || intent.uploadTokenHash !== await sha256(token)) return unauthorized();
284
- const body = await request.text();
285
- if (textEncoder.encode(body).byteLength > maxUploadByteSize) return json({ error: "manifest-too-large" }, { status: 413 });
312
+ const body = await readRequestText(request, maxUploadByteSize);
313
+ if (body === null) return json({ error: "manifest-too-large" }, { status: 413 });
286
314
  let upload;
287
315
  try {
288
316
  upload = parseShareUpload(body);
@@ -297,8 +325,13 @@ const uploadShare = async (request, env, options) => {
297
325
  const shareUser = await getShareUserById(intent.sharedByUserId);
298
326
  if (!shareUser) return unauthorized();
299
327
  const now = /* @__PURE__ */ new Date();
300
- await env.WALKTHROUGH_BUCKET.put(objectKey, canonical, { httpMetadata: { contentType: "application/json; charset=utf-8" } });
328
+ const [ownedIntent] = await db.update(uploadIntent).set({
329
+ status: "uploading",
330
+ updatedAt: now
331
+ }).where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, "claimed"))).returning({ id: uploadIntent.id });
332
+ if (!ownedIntent) return unauthorized();
301
333
  try {
334
+ await env.WALKTHROUGH_BUCKET.put(objectKey, canonical, { httpMetadata: { contentType: "application/json; charset=utf-8" } });
302
335
  if (upload.kind === "walkthrough") {
303
336
  const source = upload.snapshot.repository.source;
304
337
  const repository = getRepositoryMetadata(source);
@@ -332,7 +365,7 @@ const uploadShare = async (request, env, options) => {
332
365
  status: "uploaded",
333
366
  updatedAt: now,
334
367
  walkthroughSlug: slug
335
- }).where(eq(uploadIntent.id, intent.id))]);
368
+ }).where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, "uploading")))]);
336
369
  } else await db.batch([db.insert(plan).values({
337
370
  agent: cleanIdentityValue(upload.snapshot.source?.agent, 40),
338
371
  byteSize: textEncoder.encode(canonical).byteLength,
@@ -353,9 +386,16 @@ const uploadShare = async (request, env, options) => {
353
386
  status: "uploaded",
354
387
  updatedAt: now,
355
388
  walkthroughSlug: slug
356
- }).where(eq(uploadIntent.id, intent.id))]);
389
+ }).where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, "uploading")))]);
357
390
  } catch (error) {
358
- await env.WALKTHROUGH_BUCKET.delete(objectKey);
391
+ try {
392
+ await env.WALKTHROUGH_BUCKET.delete(objectKey);
393
+ } finally {
394
+ await db.update(uploadIntent).set({
395
+ status: "claimed",
396
+ updatedAt: /* @__PURE__ */ new Date()
397
+ }).where(and(eq(uploadIntent.id, intent.id), eq(uploadIntent.status, "uploading")));
398
+ }
359
399
  if (isQuotaError(error)) return quotaResponse();
360
400
  throw error;
361
401
  }
@@ -400,4 +440,4 @@ const handleSharingApi = async (request, env, options) => {
400
440
  };
401
441
  const handleSharingApiRequest = (request, env, options = {}) => withDatabase(env.DB, () => handleSharingApi(request, env, options));
402
442
  //#endregion
403
- export { getAuthenticatedShareUser, getRepositoryMetadata, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, shareUserFromAuthValue };
443
+ export { getAuthenticatedShareUser, getRepositoryMetadata, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, readRequestText, shareUserFromAuthValue };
package/dist/fate.mjs CHANGED
@@ -171,7 +171,7 @@ const indexWalkthroughFiles = async (database, walkthroughId, filePaths) => {
171
171
  await database.update(walkthrough).set({ filesIndexedAt: /* @__PURE__ */ new Date() }).where(eq(walkthrough.id, walkthroughId));
172
172
  };
173
173
  const fetchShareStats = async (database, now = /* @__PURE__ */ new Date()) => {
174
- const dayMilliseconds = 1440 * 60 * 1e3;
174
+ const dayMilliseconds = 864e5;
175
175
  const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
176
176
  const start = /* @__PURE__ */ new Date(today.getTime() - 6 * dayMilliseconds);
177
177
  const dayForPlan = sql$1`date(${plan.createdAt}, 'unixepoch')`;
@@ -209,12 +209,12 @@ const fetchShareStats = async (database, now = /* @__PURE__ */ new Date()) => {
209
209
  totalWalkthroughs: walkthroughTotals[0]?.count ?? 0
210
210
  };
211
211
  };
212
- const shareCommentBodySchema = z.string().trim().min(1).max(128 * 1024);
212
+ const shareCommentBodySchema = z.string().trim().min(1).max(131072);
213
213
  const shareCommentAnchorBlockSchema = z.object({
214
214
  fingerprint: z.string().min(1).max(256),
215
215
  path: z.array(z.number().int().min(0).max(1e6)).max(64),
216
216
  runtimeKey: z.string().max(256).optional(),
217
- text: z.string().max(64 * 1024),
217
+ text: z.string().max(65536),
218
218
  type: z.string().min(1).max(64)
219
219
  });
220
220
  const shareCommentAnchorSchema = z.object({
@@ -222,7 +222,7 @@ const shareCommentAnchorSchema = z.object({
222
222
  kind: z.enum(["block", "text"]),
223
223
  quote: z.object({
224
224
  end: z.number().int().min(0).max(1e6),
225
- exact: z.string().max(64 * 1024),
225
+ exact: z.string().max(65536),
226
226
  prefix: z.string().max(1024),
227
227
  start: z.number().int().min(0).max(1e6),
228
228
  suffix: z.string().max(1024)
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { plan, planRelations, shareCommentMessage, shareCommentMessageRelations, shareCommentThread, shareCommentThreadKind, shareCommentThreadRelations, shareCommentThreadStatus, shareDailyUsage, shareDailyUsageRelations, shareKind, uploadIntent, user, userRelations, walkthrough, walkthroughFile, walkthroughFileRelations, walkthroughRelations } from "./schema.mjs";
2
2
  import { and, createDb, db, eq, gte, runDatabaseBatches, sql, withDatabase } from "./db.mjs";
3
- import { getAuthenticatedShareUser, getRepositoryMetadata, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, shareUserFromAuthValue } from "./api.mjs";
3
+ import { getAuthenticatedShareUser, getRepositoryMetadata, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, readRequestText, shareUserFromAuthValue } from "./api.mjs";
4
4
  import { SharingRoot, getSessionUserId, planDataView, shareCommentMessageDataView, shareCommentThreadDataView, shareStatsDataView, shareStatsDayDataView, uploadIntentDataView, uploadIntentSecretHeader, walkthroughDataView } from "./views.mjs";
5
5
  import { createSharingFateServer, shareCommentAnchorSchema, shareCommentTargetSchema } from "./fate.mjs";
6
- export { SharingRoot, and, createDb, createSharingFateServer, db, eq, getAuthenticatedShareUser, getRepositoryMetadata, getSessionUserId, gte, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, plan, planDataView, planRelations, runDatabaseBatches, shareCommentAnchorSchema, shareCommentMessage, shareCommentMessageDataView, shareCommentMessageRelations, shareCommentTargetSchema, shareCommentThread, shareCommentThreadDataView, shareCommentThreadKind, shareCommentThreadRelations, shareCommentThreadStatus, shareDailyUsage, shareDailyUsageRelations, shareKind, shareStatsDataView, shareStatsDayDataView, shareUserFromAuthValue, sql, uploadIntent, uploadIntentDataView, uploadIntentSecretHeader, user, userRelations, walkthrough, walkthroughDataView, walkthroughFile, walkthroughFileRelations, walkthroughRelations, withDatabase };
6
+ export { SharingRoot, and, createDb, createSharingFateServer, db, eq, getAuthenticatedShareUser, getRepositoryMetadata, getSessionUserId, gte, handleSharingApiRequest, parseGitPullRequestUrl, parseShareUpload, plan, planDataView, planRelations, readRequestText, runDatabaseBatches, shareCommentAnchorSchema, shareCommentMessage, shareCommentMessageDataView, shareCommentMessageRelations, shareCommentTargetSchema, shareCommentThread, shareCommentThreadDataView, shareCommentThreadKind, shareCommentThreadRelations, shareCommentThreadStatus, shareDailyUsage, shareDailyUsageRelations, shareKind, shareStatsDataView, shareStatsDayDataView, shareUserFromAuthValue, sql, uploadIntent, uploadIntentDataView, uploadIntentSecretHeader, user, userRelations, walkthrough, walkthroughDataView, walkthroughFile, walkthroughFileRelations, walkthroughRelations, withDatabase };
package/dist/schema.mjs CHANGED
@@ -105,6 +105,7 @@ const uploadIntent = sqliteTable("UploadIntent", {
105
105
  status: text({ enum: [
106
106
  "pending",
107
107
  "claimed",
108
+ "uploading",
108
109
  "uploaded"
109
110
  ] }).notNull().default("pending"),
110
111
  updatedAt: timestampColumn(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nkzw/codiff-service",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "Provider-neutral Codiff sharing service.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -63,10 +63,10 @@
63
63
  }
64
64
  },
65
65
  "dependencies": {
66
- "@nkzw/fate": "^1.3.2",
66
+ "@nkzw/fate": "^1.3.3",
67
67
  "drizzle-orm": "^0.45.2",
68
68
  "zod": "^4.4.3",
69
- "@nkzw/codiff-core": "1.10.0"
69
+ "@nkzw/codiff-core": "1.11.1"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "react": "^19.2.0",
package/schema.ts CHANGED
@@ -141,7 +141,7 @@ export const uploadIntent = sqliteTable(
141
141
  secretHash: text().notNull(),
142
142
  sharedByUserId: text().references(() => user.id, { onDelete: 'set null' }),
143
143
  shareKind: text({ enum: shareKind }).notNull().default('walkthrough'),
144
- status: text({ enum: ['pending', 'claimed', 'uploaded'] })
144
+ status: text({ enum: ['pending', 'claimed', 'uploading', 'uploaded'] })
145
145
  .notNull()
146
146
  .default('pending'),
147
147
  updatedAt: timestampColumn(),