@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/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
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
|
+
}
|