@agentlip/hub 0.1.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/src/apiV1.ts ADDED
@@ -0,0 +1,1468 @@
1
+ /**
2
+ * Agentlip Hub HTTP API v1 handler
3
+ *
4
+ * Pure handler module implementing all /api/v1/* endpoints:
5
+ * - GET /api/v1/channels
6
+ * - POST /api/v1/channels (auth)
7
+ * - GET /api/v1/channels/:channel_id/topics
8
+ * - POST /api/v1/topics (auth)
9
+ * - PATCH /api/v1/topics/:topic_id (auth)
10
+ * - GET /api/v1/messages
11
+ * - POST /api/v1/messages (auth)
12
+ * - PATCH /api/v1/messages/:message_id (auth)
13
+ * - GET /api/v1/topics/:topic_id/attachments
14
+ * - POST /api/v1/topics/:topic_id/attachments (auth)
15
+ * - GET /api/v1/events
16
+ *
17
+ * Returns Response objects; does NOT touch packages/hub/src/index.ts.
18
+ */
19
+
20
+ import type { Database } from "bun:sqlite";
21
+ import { requireAuth } from "./authMiddleware";
22
+ import {
23
+ readJsonBody,
24
+ SIZE_LIMITS,
25
+ validationErrorResponse,
26
+ validateJsonSize,
27
+ } from "./bodyParser";
28
+ import type { HubRateLimiter } from "./rateLimiter";
29
+ import { rateLimitedResponse, addRateLimitHeaders } from "./rateLimiter";
30
+ import {
31
+ listChannels,
32
+ getChannelById,
33
+ listTopicsByChannel,
34
+ getTopicById,
35
+ listMessages,
36
+ listTopicAttachments,
37
+ findAttachmentByDedupeKey,
38
+ editMessage,
39
+ tombstoneDeleteMessage,
40
+ retopicMessage,
41
+ VersionConflictError,
42
+ MessageNotFoundError,
43
+ CrossChannelMoveError,
44
+ TopicNotFoundError,
45
+ insertEvent,
46
+ replayEvents,
47
+ getLatestEventId,
48
+ } from "@agentlip/kernel";
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // Types
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ export interface UrlExtractionConfig {
55
+ /** URL patterns to allow (matched against full URL). If empty/undefined, allows all http(s) URLs. */
56
+ allowlist?: RegExp[];
57
+ /** URL patterns to block (matched against full URL). Takes precedence over allowlist. */
58
+ blocklist?: RegExp[];
59
+ }
60
+
61
+ export interface ApiV1Context {
62
+ db: Database;
63
+ /** Expected Bearer token for mutation endpoints. */
64
+ authToken: string;
65
+ instanceId: string;
66
+ rateLimiter?: HubRateLimiter;
67
+ /** Optional hook invoked after successful mutations to publish newly-created event ids (e.g. for WS fanout). */
68
+ onEventIds?: (eventIds: number[]) => void;
69
+ /** Optional URL extraction configuration for auto-creating attachments from message content. */
70
+ urlExtraction?: UrlExtractionConfig;
71
+ }
72
+
73
+ interface RouteMatch {
74
+ handler: (req: Request, ctx: ApiV1Context, params: Record<string, string>) => Response | Promise<Response>;
75
+ params: Record<string, string>;
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────────
79
+ // ID Generation
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+
82
+ function generateId(prefix: string): string {
83
+ const randomPart = Math.random().toString(36).substring(2, 10);
84
+ const timestamp = Date.now().toString(36);
85
+ return `${prefix}_${timestamp}${randomPart}`;
86
+ }
87
+
88
+ function publishEventIds(
89
+ ctx: ApiV1Context,
90
+ eventIds: Array<number | null | undefined>
91
+ ): void {
92
+ if (!ctx.onEventIds) return;
93
+ const ids = eventIds.filter((id): id is number => typeof id === "number" && id > 0);
94
+ if (ids.length > 0) {
95
+ ctx.onEventIds(ids);
96
+ }
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // URL Extraction
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Extract http(s) URLs from text.
105
+ * Returns array of unique URLs found.
106
+ */
107
+ function extractUrls(text: string): string[] {
108
+ // Match http(s) URLs - simple but effective pattern
109
+ const urlPattern = /https?:\/\/[^\s<>'"]+/gi;
110
+ const matches = text.match(urlPattern) ?? [];
111
+
112
+ // Deduplicate and filter valid URLs
113
+ const seen = new Set<string>();
114
+ const urls: string[] = [];
115
+
116
+ for (const url of matches) {
117
+ // Clean up trailing punctuation that shouldn't be part of URL
118
+ let cleanUrl = url.replace(/[.,;:!?)]+$/, '');
119
+
120
+ // Validate it's a real URL
121
+ try {
122
+ const parsed = new URL(cleanUrl);
123
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
124
+ if (!seen.has(cleanUrl)) {
125
+ seen.add(cleanUrl);
126
+ urls.push(cleanUrl);
127
+ }
128
+ }
129
+ } catch {
130
+ // Invalid URL, skip
131
+ }
132
+ }
133
+
134
+ return urls;
135
+ }
136
+
137
+ /**
138
+ * Filter URLs based on allowlist/blocklist configuration.
139
+ *
140
+ * @param urls - Array of URLs to filter
141
+ * @param config - URL extraction configuration
142
+ * @returns Filtered array of allowed URLs
143
+ */
144
+ function filterUrls(urls: string[], config?: UrlExtractionConfig): string[] {
145
+ if (!config) return urls;
146
+
147
+ return urls.filter((url) => {
148
+ // Check blocklist first (takes precedence)
149
+ if (config.blocklist) {
150
+ for (const pattern of config.blocklist) {
151
+ if (pattern.test(url)) {
152
+ return false;
153
+ }
154
+ }
155
+ }
156
+
157
+ // Check allowlist (if provided)
158
+ if (config.allowlist && config.allowlist.length > 0) {
159
+ for (const pattern of config.allowlist) {
160
+ if (pattern.test(url)) {
161
+ return true;
162
+ }
163
+ }
164
+ return false; // Not in allowlist
165
+ }
166
+
167
+ // No allowlist = allow all (except blocked)
168
+ return true;
169
+ });
170
+ }
171
+
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+ // Error Response Helpers
174
+ // ─────────────────────────────────────────────────────────────────────────────
175
+
176
+ function notFoundResponse(message = "Not found"): Response {
177
+ return new Response(
178
+ JSON.stringify({
179
+ error: message,
180
+ code: "NOT_FOUND",
181
+ }),
182
+ {
183
+ status: 404,
184
+ headers: { "Content-Type": "application/json" },
185
+ }
186
+ );
187
+ }
188
+
189
+ function versionConflictResponse(error: VersionConflictError): Response {
190
+ return new Response(
191
+ JSON.stringify({
192
+ error: error.message,
193
+ code: "VERSION_CONFLICT",
194
+ details: {
195
+ current: error.currentVersion,
196
+ },
197
+ }),
198
+ {
199
+ status: 409,
200
+ headers: { "Content-Type": "application/json" },
201
+ }
202
+ );
203
+ }
204
+
205
+ function crossChannelMoveResponse(error: CrossChannelMoveError): Response {
206
+ return new Response(
207
+ JSON.stringify({
208
+ error: error.message,
209
+ code: "CROSS_CHANNEL_MOVE",
210
+ }),
211
+ {
212
+ status: 400,
213
+ headers: { "Content-Type": "application/json" },
214
+ }
215
+ );
216
+ }
217
+
218
+ function internalErrorResponse(): Response {
219
+ return new Response(
220
+ JSON.stringify({
221
+ error: "Internal server error",
222
+ code: "INTERNAL_ERROR",
223
+ }),
224
+ {
225
+ status: 500,
226
+ headers: { "Content-Type": "application/json" },
227
+ }
228
+ );
229
+ }
230
+
231
+ function serviceUnavailableResponse(reason: string, retryAfterSeconds = 1): Response {
232
+ return new Response(
233
+ JSON.stringify({
234
+ error: "Service temporarily unavailable",
235
+ code: "SERVICE_UNAVAILABLE",
236
+ details: { reason },
237
+ }),
238
+ {
239
+ status: 503,
240
+ headers: {
241
+ "Content-Type": "application/json",
242
+ "Retry-After": String(retryAfterSeconds),
243
+ },
244
+ }
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Check if error is a SQLite busy/lock error.
250
+ * Returns true if error indicates database is locked or busy.
251
+ */
252
+ function isSqliteBusyError(error: unknown): boolean {
253
+ if (error instanceof Error) {
254
+ return (
255
+ error.message.includes("database is locked") ||
256
+ error.message.includes("SQLITE_BUSY") ||
257
+ error.message.includes("database table is locked")
258
+ );
259
+ }
260
+ return false;
261
+ }
262
+
263
+ /**
264
+ * Check if error is a SQLite disk full error.
265
+ */
266
+ function isSqliteDiskFullError(error: unknown): boolean {
267
+ if (error instanceof Error) {
268
+ return (
269
+ error.message.includes("SQLITE_FULL") ||
270
+ error.message.includes("disk full") ||
271
+ error.message.includes("database or disk is full")
272
+ );
273
+ }
274
+ return false;
275
+ }
276
+
277
+ /**
278
+ * Handle database errors with appropriate HTTP responses.
279
+ * Maps SQLite errors to proper status codes (503 for busy/full, 400 for constraints).
280
+ * Falls back to 500 for unknown errors.
281
+ *
282
+ * @param error - Caught error
283
+ * @param context - Context string for logging
284
+ * @returns Response with appropriate status code
285
+ */
286
+ function handleDatabaseError(error: unknown, context: string): Response {
287
+ // Check for SQLite busy/lock errors (map to 503)
288
+ if (isSqliteBusyError(error)) {
289
+ console.warn(`${context} failed due to database lock:`, error);
290
+ return serviceUnavailableResponse("Database is busy, please retry", 1);
291
+ }
292
+
293
+ // Check for disk full errors (map to 503)
294
+ if (isSqliteDiskFullError(error)) {
295
+ console.error(`${context} failed due to disk full:`, error);
296
+ return serviceUnavailableResponse("Database is full, please contact administrator", 5);
297
+ }
298
+
299
+ // Check for UNIQUE constraint violations (map to 400)
300
+ if (error instanceof Error && error.message.includes("UNIQUE")) {
301
+ // Caller should handle this with specific message
302
+ return validationErrorResponse("Unique constraint violation");
303
+ }
304
+
305
+ // Unknown error (map to 500)
306
+ console.error(`${context} failed:`, error);
307
+ return internalErrorResponse();
308
+ }
309
+
310
+ function jsonResponse(data: unknown, status = 200): Response {
311
+ return new Response(JSON.stringify(data), {
312
+ status,
313
+ headers: { "Content-Type": "application/json" },
314
+ });
315
+ }
316
+
317
+ // ─────────────────────────────────────────────────────────────────────────────
318
+ // Endpoint Handlers
319
+ // ─────────────────────────────────────────────────────────────────────────────
320
+
321
+ /**
322
+ * GET /api/v1/channels
323
+ */
324
+ function handleListChannels(req: Request, ctx: ApiV1Context): Response {
325
+ const channels = listChannels(ctx.db);
326
+ return jsonResponse({ channels });
327
+ }
328
+
329
+ /**
330
+ * POST /api/v1/channels
331
+ */
332
+ async function handleCreateChannel(req: Request, ctx: ApiV1Context): Promise<Response> {
333
+ // Auth required
334
+ const authResult = requireAuth(req, ctx.authToken);
335
+ if (!authResult.ok) {
336
+ return authResult.response;
337
+ }
338
+
339
+ // Parse and validate body
340
+ const bodyResult = await readJsonBody<{ name: string; description?: string }>(req);
341
+ if (!bodyResult.ok) {
342
+ return bodyResult.response;
343
+ }
344
+
345
+ const { name, description } = bodyResult.data;
346
+
347
+ // Validate name
348
+ if (!name || typeof name !== "string" || name.trim().length === 0) {
349
+ return validationErrorResponse("Channel name is required");
350
+ }
351
+ if (name.length > 100) {
352
+ return validationErrorResponse("Channel name must be <= 100 characters");
353
+ }
354
+
355
+ try {
356
+ const channelId = generateId("ch");
357
+ const now = new Date().toISOString();
358
+
359
+ // Use transaction
360
+ const result = ctx.db.transaction(() => {
361
+ // Insert channel
362
+ ctx.db.run(
363
+ `INSERT INTO channels (id, name, description, created_at)
364
+ VALUES (?, ?, ?, ?)`,
365
+ [channelId, name, description ?? null, now]
366
+ );
367
+
368
+ // Emit event
369
+ const eventId = insertEvent({
370
+ db: ctx.db,
371
+ name: "channel.created",
372
+ scopes: { channel_id: channelId },
373
+ entity: { type: "channel", id: channelId },
374
+ data: {
375
+ channel: {
376
+ id: channelId,
377
+ name,
378
+ description: description ?? null,
379
+ created_at: now,
380
+ },
381
+ },
382
+ });
383
+
384
+ return { channelId, eventId };
385
+ })();
386
+
387
+ publishEventIds(ctx, [result.eventId]);
388
+
389
+ const channel = {
390
+ id: result.channelId,
391
+ name,
392
+ description: description ?? null,
393
+ created_at: now,
394
+ };
395
+
396
+ return jsonResponse({ channel, event_id: result.eventId }, 201);
397
+ } catch (error) {
398
+ if (error instanceof Error && error.message.includes("UNIQUE")) {
399
+ return validationErrorResponse("Channel name already exists");
400
+ }
401
+ return handleDatabaseError(error, "Channel creation");
402
+ }
403
+ }
404
+
405
+ /**
406
+ * GET /api/v1/channels/:channel_id/topics
407
+ */
408
+ function handleListTopics(
409
+ req: Request,
410
+ ctx: ApiV1Context,
411
+ params: Record<string, string>
412
+ ): Response {
413
+ const channelId = params.channel_id;
414
+
415
+ // Validate channel exists
416
+ const channel = getChannelById(ctx.db, channelId);
417
+ if (!channel) {
418
+ return notFoundResponse("Channel not found");
419
+ }
420
+
421
+ // Parse pagination params
422
+ const url = new URL(req.url);
423
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
424
+ const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
425
+
426
+ const result = listTopicsByChannel(ctx.db, channelId, { limit, offset });
427
+
428
+ return jsonResponse({ topics: result.items, has_more: result.hasMore });
429
+ }
430
+
431
+ /**
432
+ * POST /api/v1/topics
433
+ */
434
+ async function handleCreateTopic(req: Request, ctx: ApiV1Context): Promise<Response> {
435
+ // Auth required
436
+ const authResult = requireAuth(req, ctx.authToken);
437
+ if (!authResult.ok) {
438
+ return authResult.response;
439
+ }
440
+
441
+ // Parse and validate body
442
+ const bodyResult = await readJsonBody<{ channel_id: string; title: string }>(req);
443
+ if (!bodyResult.ok) {
444
+ return bodyResult.response;
445
+ }
446
+
447
+ const { channel_id, title } = bodyResult.data;
448
+
449
+ // Validate inputs
450
+ if (!channel_id || typeof channel_id !== "string") {
451
+ return validationErrorResponse("channel_id is required");
452
+ }
453
+ if (!title || typeof title !== "string" || title.trim().length === 0) {
454
+ return validationErrorResponse("title is required");
455
+ }
456
+ if (title.length > 200) {
457
+ return validationErrorResponse("title must be <= 200 characters");
458
+ }
459
+
460
+ // Validate channel exists
461
+ const channel = getChannelById(ctx.db, channel_id);
462
+ if (!channel) {
463
+ return notFoundResponse("Channel not found");
464
+ }
465
+
466
+ try {
467
+ const topicId = generateId("topic");
468
+ const now = new Date().toISOString();
469
+
470
+ // Use transaction
471
+ const result = ctx.db.transaction(() => {
472
+ // Insert topic
473
+ ctx.db.run(
474
+ `INSERT INTO topics (id, channel_id, title, created_at, updated_at)
475
+ VALUES (?, ?, ?, ?, ?)`,
476
+ [topicId, channel_id, title, now, now]
477
+ );
478
+
479
+ // Emit event
480
+ const eventId = insertEvent({
481
+ db: ctx.db,
482
+ name: "topic.created",
483
+ scopes: { channel_id, topic_id: topicId },
484
+ entity: { type: "topic", id: topicId },
485
+ data: {
486
+ topic: {
487
+ id: topicId,
488
+ channel_id,
489
+ title,
490
+ created_at: now,
491
+ updated_at: now,
492
+ },
493
+ },
494
+ });
495
+
496
+ return { topicId, eventId };
497
+ })();
498
+
499
+ publishEventIds(ctx, [result.eventId]);
500
+
501
+ const topic = {
502
+ id: result.topicId,
503
+ channel_id,
504
+ title,
505
+ created_at: now,
506
+ updated_at: now,
507
+ };
508
+
509
+ return jsonResponse({ topic, event_id: result.eventId }, 201);
510
+ } catch (error) {
511
+ if (error instanceof Error && error.message.includes("UNIQUE")) {
512
+ return validationErrorResponse("Topic title already exists in this channel");
513
+ }
514
+ return handleDatabaseError(error, "Topic creation");
515
+ }
516
+ }
517
+
518
+ /**
519
+ * PATCH /api/v1/topics/:topic_id
520
+ */
521
+ async function handleUpdateTopic(
522
+ req: Request,
523
+ ctx: ApiV1Context,
524
+ params: Record<string, string>
525
+ ): Promise<Response> {
526
+ // Auth required
527
+ const authResult = requireAuth(req, ctx.authToken);
528
+ if (!authResult.ok) {
529
+ return authResult.response;
530
+ }
531
+
532
+ const topicId = params.topic_id;
533
+
534
+ // Parse and validate body
535
+ const bodyResult = await readJsonBody<{ title: string }>(req);
536
+ if (!bodyResult.ok) {
537
+ return bodyResult.response;
538
+ }
539
+
540
+ const { title } = bodyResult.data;
541
+
542
+ // Validate title
543
+ if (!title || typeof title !== "string" || title.trim().length === 0) {
544
+ return validationErrorResponse("title is required");
545
+ }
546
+ if (title.length > 200) {
547
+ return validationErrorResponse("title must be <= 200 characters");
548
+ }
549
+
550
+ // Validate topic exists
551
+ const topic = getTopicById(ctx.db, topicId);
552
+ if (!topic) {
553
+ return notFoundResponse("Topic not found");
554
+ }
555
+
556
+ try {
557
+ const now = new Date().toISOString();
558
+
559
+ // Use transaction
560
+ const result = ctx.db.transaction(() => {
561
+ const oldTitle = topic.title;
562
+
563
+ // Update topic
564
+ ctx.db.run(
565
+ `UPDATE topics
566
+ SET title = ?, updated_at = ?
567
+ WHERE id = ?`,
568
+ [title, now, topicId]
569
+ );
570
+
571
+ // Emit event
572
+ const eventId = insertEvent({
573
+ db: ctx.db,
574
+ name: "topic.renamed",
575
+ scopes: { channel_id: topic.channel_id, topic_id: topicId },
576
+ entity: { type: "topic", id: topicId },
577
+ data: {
578
+ topic_id: topicId,
579
+ old_title: oldTitle,
580
+ new_title: title,
581
+ },
582
+ });
583
+
584
+ return { eventId };
585
+ })();
586
+
587
+ publishEventIds(ctx, [result.eventId]);
588
+
589
+ const updatedTopic = {
590
+ id: topicId,
591
+ channel_id: topic.channel_id,
592
+ title,
593
+ created_at: topic.created_at,
594
+ updated_at: now,
595
+ };
596
+
597
+ return jsonResponse({ topic: updatedTopic, event_id: result.eventId });
598
+ } catch (error) {
599
+ if (error instanceof Error && error.message.includes("UNIQUE")) {
600
+ return validationErrorResponse("Topic title already exists in this channel");
601
+ }
602
+ return handleDatabaseError(error, "Topic update");
603
+ }
604
+ }
605
+
606
+ /**
607
+ * GET /api/v1/messages
608
+ */
609
+ function handleListMessages(req: Request, ctx: ApiV1Context): Response {
610
+ const url = new URL(req.url);
611
+ const channelId = url.searchParams.get("channel_id") ?? undefined;
612
+ const topicId = url.searchParams.get("topic_id") ?? undefined;
613
+ const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
614
+ const beforeId = url.searchParams.get("before_id") ?? undefined;
615
+ const afterId = url.searchParams.get("after_id") ?? undefined;
616
+
617
+ // Validate at least one scope
618
+ if (!channelId && !topicId) {
619
+ return validationErrorResponse("At least one of channel_id or topic_id is required");
620
+ }
621
+
622
+ try {
623
+ const result = listMessages(ctx.db, {
624
+ channelId,
625
+ topicId,
626
+ limit,
627
+ beforeId,
628
+ afterId,
629
+ });
630
+
631
+ return jsonResponse({ messages: result.items, has_more: result.hasMore });
632
+ } catch (error) {
633
+ return handleDatabaseError(error, "Message listing");
634
+ }
635
+ }
636
+
637
+ /**
638
+ * POST /api/v1/messages
639
+ */
640
+ async function handleCreateMessage(req: Request, ctx: ApiV1Context): Promise<Response> {
641
+ // Auth required
642
+ const authResult = requireAuth(req, ctx.authToken);
643
+ if (!authResult.ok) {
644
+ return authResult.response;
645
+ }
646
+
647
+ // Parse and validate body
648
+ const bodyResult = await readJsonBody<{
649
+ topic_id: string;
650
+ sender: string;
651
+ content_raw: string;
652
+ }>(req);
653
+ if (!bodyResult.ok) {
654
+ return bodyResult.response;
655
+ }
656
+
657
+ const { topic_id, sender, content_raw } = bodyResult.data;
658
+
659
+ // Validate inputs
660
+ if (!topic_id || typeof topic_id !== "string") {
661
+ return validationErrorResponse("topic_id is required");
662
+ }
663
+ if (!sender || typeof sender !== "string" || sender.trim().length === 0) {
664
+ return validationErrorResponse("sender is required");
665
+ }
666
+ if (typeof content_raw !== "string") {
667
+ return validationErrorResponse("content_raw must be a string");
668
+ }
669
+ if (content_raw.length > SIZE_LIMITS.MESSAGE_BODY) {
670
+ return validationErrorResponse("content_raw exceeds 64KB limit");
671
+ }
672
+
673
+ // Validate topic exists
674
+ const topic = getTopicById(ctx.db, topic_id);
675
+ if (!topic) {
676
+ return notFoundResponse("Topic not found");
677
+ }
678
+
679
+ try {
680
+ const messageId = generateId("msg");
681
+ const now = new Date().toISOString();
682
+
683
+ // Extract URLs from content
684
+ const extractedUrls = extractUrls(content_raw);
685
+ const allowedUrls = filterUrls(extractedUrls, ctx.urlExtraction);
686
+
687
+ // Use transaction
688
+ const result = ctx.db.transaction(() => {
689
+ // Insert message
690
+ ctx.db.run(
691
+ `INSERT INTO messages (id, topic_id, channel_id, sender, content_raw, version, created_at)
692
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
693
+ [messageId, topic_id, topic.channel_id, sender, content_raw, now]
694
+ );
695
+
696
+ // Update topic updated_at
697
+ ctx.db.run(
698
+ `UPDATE topics SET updated_at = ? WHERE id = ?`,
699
+ [now, topic_id]
700
+ );
701
+
702
+ // Emit message.created event
703
+ const messageEventId = insertEvent({
704
+ db: ctx.db,
705
+ name: "message.created",
706
+ scopes: { channel_id: topic.channel_id, topic_id },
707
+ entity: { type: "message", id: messageId },
708
+ data: {
709
+ message: {
710
+ id: messageId,
711
+ topic_id,
712
+ channel_id: topic.channel_id,
713
+ sender,
714
+ content_raw,
715
+ version: 1,
716
+ created_at: now,
717
+ edited_at: null,
718
+ deleted_at: null,
719
+ deleted_by: null,
720
+ },
721
+ },
722
+ });
723
+
724
+ const allEventIds: number[] = [messageEventId];
725
+
726
+ // Create URL attachments (with dedupe)
727
+ for (const url of allowedUrls) {
728
+ const valueJson = { url };
729
+ const dedupeKey = url; // Use URL itself as dedupe key
730
+ const attachmentId = generateId("att");
731
+
732
+ // Check if attachment already exists (dedupe)
733
+ const existing = ctx.db
734
+ .query<{ id: string }, [string, string, string, string]>(
735
+ `SELECT id FROM topic_attachments
736
+ WHERE topic_id = ? AND kind = ? AND COALESCE(key, '') = ? AND dedupe_key = ?`
737
+ )
738
+ .get(topic_id, "url", "", dedupeKey);
739
+
740
+ if (!existing) {
741
+ // Insert new attachment
742
+ ctx.db.run(
743
+ `INSERT INTO topic_attachments (id, topic_id, kind, key, value_json, dedupe_key, source_message_id, created_at)
744
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
745
+ [
746
+ attachmentId,
747
+ topic_id,
748
+ "url",
749
+ null,
750
+ JSON.stringify(valueJson),
751
+ dedupeKey,
752
+ messageId,
753
+ now,
754
+ ]
755
+ );
756
+
757
+ // Emit topic.attachment_added event
758
+ const attachmentEventId = insertEvent({
759
+ db: ctx.db,
760
+ name: "topic.attachment_added",
761
+ scopes: { channel_id: topic.channel_id, topic_id },
762
+ entity: { type: "attachment", id: attachmentId },
763
+ data: {
764
+ attachment: {
765
+ id: attachmentId,
766
+ topic_id,
767
+ kind: "url",
768
+ key: null,
769
+ value_json: valueJson,
770
+ dedupe_key: dedupeKey,
771
+ source_message_id: messageId,
772
+ created_at: now,
773
+ },
774
+ },
775
+ });
776
+
777
+ allEventIds.push(attachmentEventId);
778
+ }
779
+ // If duplicate, no event is emitted
780
+ }
781
+
782
+ return { messageId, eventIds: allEventIds };
783
+ })();
784
+
785
+ publishEventIds(ctx, result.eventIds);
786
+
787
+ const message = {
788
+ id: result.messageId,
789
+ topic_id,
790
+ channel_id: topic.channel_id,
791
+ sender,
792
+ content_raw,
793
+ version: 1,
794
+ created_at: now,
795
+ edited_at: null,
796
+ deleted_at: null,
797
+ deleted_by: null,
798
+ };
799
+
800
+ return jsonResponse({ message, event_id: result.eventIds[0] }, 201);
801
+ } catch (error) {
802
+ return handleDatabaseError(error, "Message creation");
803
+ }
804
+ }
805
+
806
+ /**
807
+ * PATCH /api/v1/messages/:message_id
808
+ */
809
+ async function handlePatchMessage(
810
+ req: Request,
811
+ ctx: ApiV1Context,
812
+ params: Record<string, string>
813
+ ): Promise<Response> {
814
+ // Auth required
815
+ const authResult = requireAuth(req, ctx.authToken);
816
+ if (!authResult.ok) {
817
+ return authResult.response;
818
+ }
819
+
820
+ const messageId = params.message_id;
821
+
822
+ // Parse and validate body
823
+ const bodyResult = await readJsonBody<{
824
+ op: "edit" | "delete" | "move_topic";
825
+ content_raw?: string;
826
+ actor?: string;
827
+ to_topic_id?: string;
828
+ mode?: "one" | "later" | "all";
829
+ expected_version?: number;
830
+ }>(req);
831
+ if (!bodyResult.ok) {
832
+ return bodyResult.response;
833
+ }
834
+
835
+ const { op, content_raw, actor, to_topic_id, mode, expected_version } = bodyResult.data;
836
+
837
+ // Validate op
838
+ if (!op || !["edit", "delete", "move_topic"].includes(op)) {
839
+ return validationErrorResponse("op must be one of: edit, delete, move_topic");
840
+ }
841
+
842
+ try {
843
+ // Handle each operation
844
+ if (op === "edit") {
845
+ // Validate content_raw
846
+ if (!content_raw || typeof content_raw !== "string") {
847
+ return validationErrorResponse("content_raw is required for edit operation");
848
+ }
849
+ if (content_raw.length > SIZE_LIMITS.MESSAGE_BODY) {
850
+ return validationErrorResponse("content_raw exceeds 64KB limit");
851
+ }
852
+
853
+ const result = editMessage({
854
+ db: ctx.db,
855
+ messageId,
856
+ newContentRaw: content_raw,
857
+ expectedVersion: expected_version,
858
+ });
859
+
860
+ publishEventIds(ctx, [result.eventId]);
861
+
862
+ // Get updated message
863
+ const message = ctx.db
864
+ .query<{
865
+ id: string;
866
+ topic_id: string;
867
+ channel_id: string;
868
+ sender: string;
869
+ content_raw: string;
870
+ version: number;
871
+ created_at: string;
872
+ edited_at: string | null;
873
+ deleted_at: string | null;
874
+ deleted_by: string | null;
875
+ }, [string]>(
876
+ `SELECT id, topic_id, channel_id, sender, content_raw, version,
877
+ created_at, edited_at, deleted_at, deleted_by
878
+ FROM messages WHERE id = ?`
879
+ )
880
+ .get(messageId);
881
+
882
+ return jsonResponse({ message, event_id: result.eventId });
883
+ } else if (op === "delete") {
884
+ // Validate actor
885
+ if (!actor || typeof actor !== "string" || actor.trim().length === 0) {
886
+ return validationErrorResponse("actor is required for delete operation");
887
+ }
888
+
889
+ const result = tombstoneDeleteMessage({
890
+ db: ctx.db,
891
+ messageId,
892
+ actor,
893
+ expectedVersion: expected_version,
894
+ });
895
+
896
+ publishEventIds(ctx, [result.eventId]);
897
+
898
+ // Get updated message
899
+ const message = ctx.db
900
+ .query<{
901
+ id: string;
902
+ topic_id: string;
903
+ channel_id: string;
904
+ sender: string;
905
+ content_raw: string;
906
+ version: number;
907
+ created_at: string;
908
+ edited_at: string | null;
909
+ deleted_at: string | null;
910
+ deleted_by: string | null;
911
+ }, [string]>(
912
+ `SELECT id, topic_id, channel_id, sender, content_raw, version,
913
+ created_at, edited_at, deleted_at, deleted_by
914
+ FROM messages WHERE id = ?`
915
+ )
916
+ .get(messageId);
917
+
918
+ return jsonResponse({ message, event_id: result.eventId === 0 ? null : result.eventId });
919
+ } else if (op === "move_topic") {
920
+ // Validate to_topic_id
921
+ if (!to_topic_id || typeof to_topic_id !== "string") {
922
+ return validationErrorResponse("to_topic_id is required for move_topic operation");
923
+ }
924
+
925
+ // Validate mode
926
+ if (!mode || !["one", "later", "all"].includes(mode)) {
927
+ return validationErrorResponse("mode must be one of: one, later, all");
928
+ }
929
+
930
+ const result = retopicMessage({
931
+ db: ctx.db,
932
+ messageId,
933
+ toTopicId: to_topic_id,
934
+ mode,
935
+ expectedVersion: expected_version,
936
+ });
937
+
938
+ const eventIds = result.affectedMessages.map((m) => m.eventId);
939
+ publishEventIds(ctx, eventIds);
940
+
941
+ return jsonResponse({
942
+ affected_count: result.affectedCount,
943
+ event_ids: eventIds,
944
+ });
945
+ }
946
+
947
+ return internalErrorResponse();
948
+ } catch (error) {
949
+ if (error instanceof VersionConflictError) {
950
+ return versionConflictResponse(error);
951
+ }
952
+ if (error instanceof MessageNotFoundError || error instanceof TopicNotFoundError) {
953
+ return notFoundResponse(error.message);
954
+ }
955
+ if (error instanceof CrossChannelMoveError) {
956
+ return crossChannelMoveResponse(error);
957
+ }
958
+ return handleDatabaseError(error, "Message patch");
959
+ }
960
+ }
961
+
962
+ /**
963
+ * GET /api/v1/topics/:topic_id/attachments
964
+ */
965
+ function handleListAttachments(
966
+ req: Request,
967
+ ctx: ApiV1Context,
968
+ params: Record<string, string>
969
+ ): Response {
970
+ const topicId = params.topic_id;
971
+
972
+ // Validate topic exists
973
+ const topic = getTopicById(ctx.db, topicId);
974
+ if (!topic) {
975
+ return notFoundResponse("Topic not found");
976
+ }
977
+
978
+ const url = new URL(req.url);
979
+ const kind = url.searchParams.get("kind") ?? undefined;
980
+
981
+ const attachments = listTopicAttachments(ctx.db, topicId, kind);
982
+
983
+ return jsonResponse({ attachments });
984
+ }
985
+
986
+ // ─────────────────────────────────────────────────────────────────────────────
987
+ // Attachment Metadata Validation
988
+ // ─────────────────────────────────────────────────────────────────────────────
989
+
990
+ const ATTACHMENT_LIMITS = {
991
+ URL_MAX_LENGTH: 2048,
992
+ STRING_FIELD_MAX_LENGTH: 500,
993
+ } as const;
994
+
995
+ /**
996
+ * Check if a string contains XSS-ish patterns.
997
+ * Reject strings with HTML tags, script protocols, or control characters.
998
+ */
999
+ function containsXssPatterns(str: string): boolean {
1000
+ if (typeof str !== "string") return false;
1001
+
1002
+ // Check for HTML tags
1003
+ if (str.includes("<") || str.includes("</") || str.includes(">")) {
1004
+ return true;
1005
+ }
1006
+
1007
+ // Check for javascript: protocol
1008
+ if (str.toLowerCase().includes("javascript:")) {
1009
+ return true;
1010
+ }
1011
+
1012
+ // Check for data: protocol with script
1013
+ if (str.toLowerCase().includes("data:") && str.toLowerCase().includes("script")) {
1014
+ return true;
1015
+ }
1016
+
1017
+ // Check for control characters (except common whitespace)
1018
+ for (let i = 0; i < str.length; i++) {
1019
+ const code = str.charCodeAt(i);
1020
+ // Allow tab (9), newline (10), carriage return (13), space (32)
1021
+ if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
1022
+ return true;
1023
+ }
1024
+ }
1025
+
1026
+ return false;
1027
+ }
1028
+
1029
+ /**
1030
+ * Validate URL string for attachment metadata.
1031
+ * Returns { valid: true } or { valid: false, reason: string }.
1032
+ */
1033
+ function validateAttachmentUrl(url: unknown): { valid: true } | { valid: false; reason: string } {
1034
+ // Must be string
1035
+ if (typeof url !== "string") {
1036
+ return { valid: false, reason: "url must be a string" };
1037
+ }
1038
+
1039
+ // Check length
1040
+ if (url.length === 0) {
1041
+ return { valid: false, reason: "url cannot be empty" };
1042
+ }
1043
+ if (url.length > ATTACHMENT_LIMITS.URL_MAX_LENGTH) {
1044
+ return { valid: false, reason: "url exceeds maximum length" };
1045
+ }
1046
+
1047
+ // Check for XSS patterns
1048
+ if (containsXssPatterns(url)) {
1049
+ return { valid: false, reason: "url contains invalid characters or patterns" };
1050
+ }
1051
+
1052
+ // Parse URL
1053
+ let parsed: URL;
1054
+ try {
1055
+ parsed = new URL(url);
1056
+ } catch {
1057
+ return { valid: false, reason: "url is not a valid URL format" };
1058
+ }
1059
+
1060
+ // Only allow http/https protocols
1061
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1062
+ return { valid: false, reason: "url protocol must be http or https" };
1063
+ }
1064
+
1065
+ return { valid: true };
1066
+ }
1067
+
1068
+ /**
1069
+ * Validate a generic string field (title, description, etc).
1070
+ * Returns { valid: true } or { valid: false, reason: string }.
1071
+ */
1072
+ function validateStringField(
1073
+ value: unknown,
1074
+ fieldName: string
1075
+ ): { valid: true } | { valid: false; reason: string } {
1076
+ if (typeof value !== "string") {
1077
+ return { valid: false, reason: `${fieldName} must be a string` };
1078
+ }
1079
+
1080
+ if (value.length > ATTACHMENT_LIMITS.STRING_FIELD_MAX_LENGTH) {
1081
+ return { valid: false, reason: `${fieldName} exceeds maximum length` };
1082
+ }
1083
+
1084
+ if (containsXssPatterns(value)) {
1085
+ return { valid: false, reason: `${fieldName} contains invalid characters or patterns` };
1086
+ }
1087
+
1088
+ return { valid: true };
1089
+ }
1090
+
1091
+ /**
1092
+ * Validate attachment value_json based on kind.
1093
+ * Returns { valid: true } or { valid: false, reason: string }.
1094
+ */
1095
+ function validateAttachmentValueJson(
1096
+ kind: string,
1097
+ valueJson: Record<string, unknown>
1098
+ ): { valid: true } | { valid: false; reason: string } {
1099
+ // For URL/link kinds, apply strict validation
1100
+ if (kind === "url" || kind === "link") {
1101
+ // Require url field
1102
+ if (!("url" in valueJson)) {
1103
+ return { valid: false, reason: "url field is required" };
1104
+ }
1105
+
1106
+ // Validate url
1107
+ const urlValidation = validateAttachmentUrl(valueJson.url);
1108
+ if (!urlValidation.valid) {
1109
+ return urlValidation;
1110
+ }
1111
+
1112
+ // Validate optional title
1113
+ if ("title" in valueJson && valueJson.title !== null && valueJson.title !== undefined) {
1114
+ const titleValidation = validateStringField(valueJson.title, "title");
1115
+ if (!titleValidation.valid) {
1116
+ return titleValidation;
1117
+ }
1118
+ }
1119
+
1120
+ // Validate optional description
1121
+ if ("description" in valueJson && valueJson.description !== null && valueJson.description !== undefined) {
1122
+ const descValidation = validateStringField(valueJson.description, "description");
1123
+ if (!descValidation.valid) {
1124
+ return descValidation;
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ // For unknown kinds, only apply generic checks
1130
+ // (object type and size are already validated by caller)
1131
+
1132
+ return { valid: true };
1133
+ }
1134
+
1135
+ /**
1136
+ * POST /api/v1/topics/:topic_id/attachments
1137
+ */
1138
+ async function handleCreateAttachment(
1139
+ req: Request,
1140
+ ctx: ApiV1Context,
1141
+ params: Record<string, string>
1142
+ ): Promise<Response> {
1143
+ // Auth required
1144
+ const authResult = requireAuth(req, ctx.authToken);
1145
+ if (!authResult.ok) {
1146
+ return authResult.response;
1147
+ }
1148
+
1149
+ const topicId = params.topic_id;
1150
+
1151
+ // Validate topic exists
1152
+ const topic = getTopicById(ctx.db, topicId);
1153
+ if (!topic) {
1154
+ return notFoundResponse("Topic not found");
1155
+ }
1156
+
1157
+ // Parse and validate body
1158
+ const bodyResult = await readJsonBody<{
1159
+ kind: string;
1160
+ key?: string;
1161
+ value_json: Record<string, unknown>;
1162
+ dedupe_key?: string;
1163
+ source_message_id?: string;
1164
+ }>(req, { maxBytes: SIZE_LIMITS.ATTACHMENT });
1165
+ if (!bodyResult.ok) {
1166
+ return bodyResult.response;
1167
+ }
1168
+
1169
+ const { kind, key, value_json, dedupe_key, source_message_id } = bodyResult.data;
1170
+
1171
+ // Validate inputs
1172
+ if (!kind || typeof kind !== "string" || kind.trim().length === 0) {
1173
+ return validationErrorResponse("kind is required");
1174
+ }
1175
+ if (!value_json || typeof value_json !== "object" || Array.isArray(value_json)) {
1176
+ return validationErrorResponse("value_json must be an object");
1177
+ }
1178
+
1179
+ // Validate value_json size
1180
+ if (!validateJsonSize(value_json, SIZE_LIMITS.ATTACHMENT)) {
1181
+ return validationErrorResponse("value_json exceeds 16KB limit");
1182
+ }
1183
+
1184
+ // Validate value_json content based on kind
1185
+ const valueJsonValidation = validateAttachmentValueJson(kind, value_json);
1186
+ if (!valueJsonValidation.valid) {
1187
+ return validationErrorResponse(valueJsonValidation.reason);
1188
+ }
1189
+
1190
+ try {
1191
+ // Compute dedupe_key if not provided
1192
+ const finalDedupeKey = dedupe_key ?? JSON.stringify(value_json);
1193
+
1194
+ const attachmentId = generateId("att");
1195
+ const now = new Date().toISOString();
1196
+ const valueJsonStr = JSON.stringify(value_json);
1197
+
1198
+ // Use transaction with idempotency check
1199
+ const result = ctx.db.transaction(() => {
1200
+ // Check if attachment already exists (dedupe)
1201
+ const existing = ctx.db
1202
+ .query<{ id: string }, [string, string, string, string]>(
1203
+ `SELECT id FROM topic_attachments
1204
+ WHERE topic_id = ? AND kind = ? AND COALESCE(key, '') = ? AND dedupe_key = ?`
1205
+ )
1206
+ .get(topicId, kind, key ?? "", finalDedupeKey);
1207
+
1208
+ if (existing) {
1209
+ // Return existing attachment, no event
1210
+ return { attachmentId: existing.id, eventId: null, deduplicated: true };
1211
+ }
1212
+
1213
+ // Insert new attachment
1214
+ ctx.db.run(
1215
+ `INSERT INTO topic_attachments (id, topic_id, kind, key, value_json, dedupe_key, source_message_id, created_at)
1216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1217
+ [
1218
+ attachmentId,
1219
+ topicId,
1220
+ kind,
1221
+ key ?? null,
1222
+ valueJsonStr,
1223
+ finalDedupeKey,
1224
+ source_message_id ?? null,
1225
+ now,
1226
+ ]
1227
+ );
1228
+
1229
+ // Emit event
1230
+ const eventId = insertEvent({
1231
+ db: ctx.db,
1232
+ name: "topic.attachment_added",
1233
+ scopes: { channel_id: topic.channel_id, topic_id: topicId },
1234
+ entity: { type: "attachment", id: attachmentId },
1235
+ data: {
1236
+ attachment: {
1237
+ id: attachmentId,
1238
+ topic_id: topicId,
1239
+ kind,
1240
+ key: key ?? null,
1241
+ value_json,
1242
+ dedupe_key: finalDedupeKey,
1243
+ source_message_id: source_message_id ?? null,
1244
+ created_at: now,
1245
+ },
1246
+ },
1247
+ });
1248
+
1249
+ return { attachmentId, eventId, deduplicated: false };
1250
+ })();
1251
+
1252
+ if (result.deduplicated) {
1253
+ const existing = findAttachmentByDedupeKey(
1254
+ ctx.db,
1255
+ topicId,
1256
+ kind,
1257
+ key ?? null,
1258
+ finalDedupeKey
1259
+ );
1260
+
1261
+ if (!existing) {
1262
+ // Should never happen: dedupe check found a row, but we can't fetch it.
1263
+ return internalErrorResponse();
1264
+ }
1265
+
1266
+ return jsonResponse({ attachment: existing, event_id: null });
1267
+ }
1268
+
1269
+ publishEventIds(ctx, [result.eventId]);
1270
+
1271
+ const attachment = {
1272
+ id: result.attachmentId,
1273
+ topic_id: topicId,
1274
+ kind,
1275
+ key: key ?? null,
1276
+ value_json,
1277
+ dedupe_key: finalDedupeKey,
1278
+ source_message_id: source_message_id ?? null,
1279
+ created_at: now,
1280
+ };
1281
+
1282
+ return jsonResponse({ attachment, event_id: result.eventId }, 201);
1283
+ } catch (error) {
1284
+ return handleDatabaseError(error, "Attachment creation");
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * GET /api/v1/events
1290
+ */
1291
+ function handleListEvents(req: Request, ctx: ApiV1Context): Response {
1292
+ const url = new URL(req.url);
1293
+ const after = parseInt(url.searchParams.get("after") ?? "0", 10);
1294
+ const limit = Math.min(
1295
+ parseInt(url.searchParams.get("limit") ?? "100", 10),
1296
+ 1000
1297
+ );
1298
+
1299
+ try {
1300
+ const replayUntil = getLatestEventId(ctx.db);
1301
+ const events = replayEvents({
1302
+ db: ctx.db,
1303
+ afterEventId: after,
1304
+ replayUntil,
1305
+ limit,
1306
+ });
1307
+
1308
+ // Convert events to simplified format
1309
+ const formattedEvents = events.map((event) => ({
1310
+ event_id: event.event_id,
1311
+ ts: event.ts,
1312
+ name: event.name,
1313
+ data_json: event.data,
1314
+ }));
1315
+
1316
+ return jsonResponse({ events: formattedEvents });
1317
+ } catch (error) {
1318
+ return handleDatabaseError(error, "Event listing");
1319
+ }
1320
+ }
1321
+
1322
+ // ─────────────────────────────────────────────────────────────────────────────
1323
+ // Route Matching
1324
+ // ─────────────────────────────────────────────────────────────────────────────
1325
+
1326
+ interface Route {
1327
+ method: string;
1328
+ pattern: RegExp;
1329
+ paramNames: string[];
1330
+ handler: (req: Request, ctx: ApiV1Context, params: Record<string, string>) => Response | Promise<Response>;
1331
+ }
1332
+
1333
+ const routes: Route[] = [
1334
+ {
1335
+ method: "GET",
1336
+ pattern: /^\/api\/v1\/channels$/,
1337
+ paramNames: [],
1338
+ handler: handleListChannels,
1339
+ },
1340
+ {
1341
+ method: "POST",
1342
+ pattern: /^\/api\/v1\/channels$/,
1343
+ paramNames: [],
1344
+ handler: handleCreateChannel,
1345
+ },
1346
+ {
1347
+ method: "GET",
1348
+ pattern: /^\/api\/v1\/channels\/([^/]+)\/topics$/,
1349
+ paramNames: ["channel_id"],
1350
+ handler: handleListTopics,
1351
+ },
1352
+ {
1353
+ method: "POST",
1354
+ pattern: /^\/api\/v1\/topics$/,
1355
+ paramNames: [],
1356
+ handler: handleCreateTopic,
1357
+ },
1358
+ {
1359
+ method: "PATCH",
1360
+ pattern: /^\/api\/v1\/topics\/([^/]+)$/,
1361
+ paramNames: ["topic_id"],
1362
+ handler: handleUpdateTopic,
1363
+ },
1364
+ {
1365
+ method: "GET",
1366
+ pattern: /^\/api\/v1\/messages$/,
1367
+ paramNames: [],
1368
+ handler: handleListMessages,
1369
+ },
1370
+ {
1371
+ method: "POST",
1372
+ pattern: /^\/api\/v1\/messages$/,
1373
+ paramNames: [],
1374
+ handler: handleCreateMessage,
1375
+ },
1376
+ {
1377
+ method: "PATCH",
1378
+ pattern: /^\/api\/v1\/messages\/([^/]+)$/,
1379
+ paramNames: ["message_id"],
1380
+ handler: handlePatchMessage,
1381
+ },
1382
+ {
1383
+ method: "GET",
1384
+ pattern: /^\/api\/v1\/topics\/([^/]+)\/attachments$/,
1385
+ paramNames: ["topic_id"],
1386
+ handler: handleListAttachments,
1387
+ },
1388
+ {
1389
+ method: "POST",
1390
+ pattern: /^\/api\/v1\/topics\/([^/]+)\/attachments$/,
1391
+ paramNames: ["topic_id"],
1392
+ handler: handleCreateAttachment,
1393
+ },
1394
+ {
1395
+ method: "GET",
1396
+ pattern: /^\/api\/v1\/events$/,
1397
+ paramNames: [],
1398
+ handler: handleListEvents,
1399
+ },
1400
+ ];
1401
+
1402
+ function matchRoute(method: string, path: string): RouteMatch | null {
1403
+ for (const route of routes) {
1404
+ if (route.method !== method) continue;
1405
+
1406
+ const match = path.match(route.pattern);
1407
+ if (!match) continue;
1408
+
1409
+ const params: Record<string, string> = {};
1410
+ for (let i = 0; i < route.paramNames.length; i++) {
1411
+ params[route.paramNames[i]] = match[i + 1];
1412
+ }
1413
+
1414
+ return { handler: route.handler, params };
1415
+ }
1416
+
1417
+ return null;
1418
+ }
1419
+
1420
+ // ─────────────────────────────────────────────────────────────────────────────
1421
+ // Main Handler
1422
+ // ─────────────────────────────────────────────────────────────────────────────
1423
+
1424
+ /**
1425
+ * Handle API v1 requests.
1426
+ *
1427
+ * Pure handler that returns Response. Does not touch index.ts.
1428
+ * Integrator should mount this at /api/v1/*.
1429
+ *
1430
+ * @param req - HTTP request
1431
+ * @param ctx - Handler context (db, auth, instance ID)
1432
+ * @returns HTTP response
1433
+ */
1434
+ export async function handleApiV1(req: Request, ctx: ApiV1Context): Promise<Response> {
1435
+ // Rate limiting (if provided). IMPORTANT: HubRateLimiter.check() consumes a token,
1436
+ // so only call it once per request.
1437
+ let rateLimitResult: ReturnType<HubRateLimiter["check"]> | null = null;
1438
+ if (ctx.rateLimiter) {
1439
+ rateLimitResult = ctx.rateLimiter.check(req);
1440
+ if (!rateLimitResult.allowed) {
1441
+ return rateLimitedResponse(rateLimitResult);
1442
+ }
1443
+ }
1444
+
1445
+ const url = new URL(req.url);
1446
+ const path = url.pathname;
1447
+ const method = req.method;
1448
+
1449
+ // Match route
1450
+ const match = matchRoute(method, path);
1451
+ if (!match) {
1452
+ return new Response("Not found", { status: 404 });
1453
+ }
1454
+
1455
+ try {
1456
+ const response = await match.handler(req, ctx, match.params);
1457
+
1458
+ // Add rate limit headers using the result from the single check.
1459
+ if (rateLimitResult) {
1460
+ return addRateLimitHeaders(response, rateLimitResult);
1461
+ }
1462
+
1463
+ return response;
1464
+ } catch (error) {
1465
+ console.error("Unhandled error in API handler:", error);
1466
+ return internalErrorResponse();
1467
+ }
1468
+ }