botschat 0.1.3 → 0.1.6

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.
Files changed (46) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -1
  3. package/packages/api/package.json +2 -1
  4. package/packages/api/src/do/connection-do.ts +128 -33
  5. package/packages/api/src/index.ts +103 -6
  6. package/packages/api/src/routes/auth.ts +123 -29
  7. package/packages/api/src/routes/pairing.ts +14 -1
  8. package/packages/api/src/routes/setup.ts +70 -24
  9. package/packages/api/src/routes/upload.ts +12 -8
  10. package/packages/api/src/utils/auth.ts +212 -43
  11. package/packages/api/src/utils/id.ts +30 -14
  12. package/packages/api/src/utils/rate-limit.ts +73 -0
  13. package/packages/plugin/dist/src/channel.js +9 -3
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/package.json +2 -2
  16. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
  17. package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
  18. package/packages/web/dist/botschat-icon.svg +4 -0
  19. package/packages/web/dist/index.html +23 -3
  20. package/packages/web/dist/manifest.json +24 -0
  21. package/packages/web/dist/sw.js +40 -0
  22. package/packages/web/index.html +21 -1
  23. package/packages/web/src/App.tsx +241 -96
  24. package/packages/web/src/api.ts +63 -3
  25. package/packages/web/src/components/ChatWindow.tsx +11 -11
  26. package/packages/web/src/components/ConnectionSettings.tsx +475 -0
  27. package/packages/web/src/components/CronDetail.tsx +475 -235
  28. package/packages/web/src/components/CronSidebar.tsx +1 -1
  29. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  30. package/packages/web/src/components/IconRail.tsx +56 -16
  31. package/packages/web/src/components/JobList.tsx +2 -6
  32. package/packages/web/src/components/LoginPage.tsx +126 -103
  33. package/packages/web/src/components/MobileLayout.tsx +480 -0
  34. package/packages/web/src/components/OnboardingPage.tsx +7 -16
  35. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  36. package/packages/web/src/components/Sidebar.tsx +1 -1
  37. package/packages/web/src/components/TaskBar.tsx +2 -2
  38. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  39. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  40. package/packages/web/src/index.css +59 -0
  41. package/packages/web/src/main.tsx +9 -0
  42. package/packages/web/src/store.ts +12 -5
  43. package/packages/web/src/ws.ts +2 -0
  44. package/scripts/dev.sh +13 -13
  45. package/wrangler.toml +3 -1
  46. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # BotsChat
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/botschat)](https://www.npmjs.com/package/botschat)
4
- [![npm](https://img.shields.io/npm/v/@botschat/openclaw-plugin)](https://www.npmjs.com/package/@botschat/openclaw-plugin)
4
+ [![npm](https://img.shields.io/npm/v/@botschat/botschat)](https://www.npmjs.com/package/@botschat/botschat)
5
5
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
6
6
 
7
7
  A self-hosted chat interface for [OpenClaw](https://github.com/openclaw/openclaw) AI agents.
@@ -154,7 +154,7 @@ After the BotsChat server is running, connect your OpenClaw instance to it.
154
154
  **1. Install the plugin**
155
155
 
156
156
  ```bash
157
- openclaw plugins install @botschat/openclaw-plugin
157
+ openclaw plugins install @botschat/botschat
158
158
  ```
159
159
 
160
160
  **2. Create a pairing token**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -49,5 +49,8 @@
49
49
  "devDependencies": {
50
50
  "typescript": "^5.7.0",
51
51
  "wrangler": "^3.100.0"
52
+ },
53
+ "dependencies": {
54
+ "react-resizable-panels": "^4.6.2"
52
55
  }
53
56
  }
@@ -8,7 +8,8 @@
8
8
  "deploy": "wrangler deploy --config ../../wrangler.toml"
9
9
  },
10
10
  "dependencies": {
11
- "hono": "^4.6.0"
11
+ "hono": "^4.6.0",
12
+ "jose": "^6.1.3"
12
13
  },
13
14
  "devDependencies": {
14
15
  "@cloudflare/workers-types": "^4.20250109.0",
@@ -1,4 +1,6 @@
1
1
  import type { Env } from "../env.js";
2
+ import { verifyToken, getJwtSecret } from "../utils/auth.js";
3
+ import { generateId as generateIdUtil } from "../utils/id.js";
2
4
 
3
5
  /**
4
6
  * ConnectionDO — one Durable Object instance per BotsChat user.
@@ -283,10 +285,31 @@ export class ConnectionDO implements DurableObject {
283
285
  ): Promise<void> {
284
286
  const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
285
287
 
286
- // Handle browser auth (session cookie/token validation)
288
+ // Handle browser auth verify JWT token
287
289
  if (msg.type === "auth") {
288
- // For now, accept browser connections. In production, validate
289
- // the session token against D1.
290
+ const token = msg.token as string | undefined;
291
+ if (!token) {
292
+ ws.send(JSON.stringify({ type: "auth.fail", reason: "Missing token" }));
293
+ ws.close(4001, "Missing auth token");
294
+ return;
295
+ }
296
+
297
+ const secret = getJwtSecret(this.env);
298
+ const payload = await verifyToken(token, secret);
299
+ if (!payload) {
300
+ ws.send(JSON.stringify({ type: "auth.fail", reason: "Invalid or expired token" }));
301
+ ws.close(4001, "Authentication failed");
302
+ return;
303
+ }
304
+
305
+ // Verify the token's userId matches this DO's userId
306
+ const doUserId = await this.state.storage.get<string>("userId");
307
+ if (doUserId && payload.sub !== doUserId) {
308
+ ws.send(JSON.stringify({ type: "auth.fail", reason: "User mismatch" }));
309
+ ws.close(4001, "User mismatch");
310
+ return;
311
+ }
312
+
290
313
  ws.serializeAttachment({ ...attachment, authenticated: true });
291
314
  ws.send(JSON.stringify({ type: "auth.ok" }));
292
315
 
@@ -472,6 +495,37 @@ export class ConnectionDO implements DurableObject {
472
495
 
473
496
  // ---- Media caching ----
474
497
 
498
+ // ---- SSRF protection ----
499
+
500
+ /** Check if a URL is safe to fetch (not pointing to private/internal networks). */
501
+ private isUrlSafeToFetch(urlStr: string): boolean {
502
+ try {
503
+ const parsed = new URL(urlStr);
504
+ // Only allow https (block http, ftp, file, etc.)
505
+ if (parsed.protocol !== "https:") return false;
506
+
507
+ const hostname = parsed.hostname;
508
+ // Block private/reserved IP ranges and localhost
509
+ if (
510
+ hostname === "localhost" ||
511
+ hostname === "127.0.0.1" ||
512
+ hostname === "[::1]" ||
513
+ hostname.endsWith(".local") ||
514
+ /^10\./.test(hostname) ||
515
+ /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
516
+ /^192\.168\./.test(hostname) ||
517
+ /^169\.254\./.test(hostname) || // link-local
518
+ /^0\./.test(hostname) ||
519
+ hostname === "[::ffff:127.0.0.1]"
520
+ ) {
521
+ return false;
522
+ }
523
+ return true;
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+
475
529
  /**
476
530
  * Download an external image and cache it in R2. Returns the local
477
531
  * API URL (e.g. /api/media/...) or null if caching fails.
@@ -483,17 +537,28 @@ export class ConnectionDO implements DurableObject {
483
537
  // Also skip URLs that point back to our own media endpoint (absolute form)
484
538
  if (/\/api\/media\//.test(url)) return null;
485
539
 
540
+ // SSRF protection: only allow HTTPS URLs to public hosts
541
+ if (!this.isUrlSafeToFetch(url)) {
542
+ console.warn(`[DO] cacheExternalMedia: blocked unsafe URL ${url.slice(0, 120)}`);
543
+ return null;
544
+ }
545
+
486
546
  console.log(`[DO] cacheExternalMedia: attempting to cache ${url.slice(0, 120)}`);
487
547
 
548
+ const MAX_MEDIA_SIZE = 20 * 1024 * 1024; // 20 MB max
549
+
488
550
  try {
489
551
  const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
490
552
 
491
553
  // Download the external image — use arrayBuffer to avoid stream issues
492
554
  const controller = new AbortController();
493
- const timeoutId = setTimeout(() => controller.abort(), 30_000);
555
+ const timeoutId = setTimeout(() => controller.abort(), 15_000); // 15s timeout
494
556
  let response: Response;
495
557
  try {
496
- response = await fetch(url, { signal: controller.signal });
558
+ response = await fetch(url, {
559
+ signal: controller.signal,
560
+ redirect: "follow", // follow redirects, but URL was already validated
561
+ });
497
562
  } finally {
498
563
  clearTimeout(timeoutId);
499
564
  }
@@ -506,8 +571,21 @@ export class ConnectionDO implements DurableObject {
506
571
  const contentType = response.headers.get("Content-Type") ?? "image/png";
507
572
  // Validate that the response is actually an image
508
573
  if (!contentType.startsWith("image/")) {
509
- console.warn(`[DO] cacheExternalMedia: unexpected Content-Type "${contentType}" for ${url.slice(0, 120)}`);
510
- // Still try to cache it — some servers return wrong content types
574
+ console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
575
+ return null;
576
+ }
577
+
578
+ // Reject SVG (can contain scripts — XSS vector)
579
+ if (contentType.includes("svg")) {
580
+ console.warn(`[DO] cacheExternalMedia: blocked SVG content from ${url.slice(0, 120)}`);
581
+ return null;
582
+ }
583
+
584
+ // Check Content-Length header early if available
585
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
586
+ if (contentLength > MAX_MEDIA_SIZE) {
587
+ console.warn(`[DO] cacheExternalMedia: Content-Length ${contentLength} exceeds limit for ${url.slice(0, 120)}`);
588
+ return null;
511
589
  }
512
590
 
513
591
  // Read the body as ArrayBuffer for maximum compatibility with R2
@@ -516,14 +594,17 @@ export class ConnectionDO implements DurableObject {
516
594
  console.warn(`[DO] cacheExternalMedia: empty body for ${url.slice(0, 120)}`);
517
595
  return null;
518
596
  }
597
+ if (body.byteLength > MAX_MEDIA_SIZE) {
598
+ console.warn(`[DO] cacheExternalMedia: body size ${body.byteLength} exceeds limit for ${url.slice(0, 120)}`);
599
+ return null;
600
+ }
519
601
 
520
- // Determine extension from Content-Type
602
+ // Determine extension from Content-Type (no SVG)
521
603
  const extMap: Record<string, string> = {
522
604
  "image/png": "png",
523
605
  "image/jpeg": "jpg",
524
606
  "image/gif": "gif",
525
607
  "image/webp": "webp",
526
- "image/svg+xml": "svg",
527
608
  };
528
609
  const ext = extMap[contentType] ?? "png";
529
610
  const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${ext}`;
@@ -853,14 +934,9 @@ export class ConnectionDO implements DurableObject {
853
934
  return channelId;
854
935
  }
855
936
 
856
- /** Generate a short random ID (URL-safe). */
937
+ /** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
857
938
  private generateId(prefix = ""): string {
858
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
859
- let id = prefix;
860
- for (let i = 0; i < 16; i++) {
861
- id += chars[Math.floor(Math.random() * chars.length)];
862
- }
863
- return id;
939
+ return generateIdUtil(prefix);
864
940
  }
865
941
 
866
942
  /**
@@ -913,23 +989,42 @@ export class ConnectionDO implements DurableObject {
913
989
  }
914
990
 
915
991
  private async validatePairingToken(token: string): Promise<boolean> {
916
- // Query D1 to validate the pairing token
917
- // The DO receives the env via constructor, but D1 queries from DO
918
- // require fetching through the API worker. For simplicity in the DO,
919
- // we store validated tokens in DO storage after first validation.
920
- //
921
- // Check DO-local cache first:
922
- const cached = await this.state.storage.get<boolean>(`token:${token}`);
923
- if (cached === true) return true;
924
- if (cached === false) return false;
925
-
926
- // If not cached, we accept the token optimistically and let the
927
- // API worker validate it on the next REST call. In production,
928
- // the API worker should validate before routing to the DO.
992
+ // The API worker validates pairing tokens against D1 before routing
993
+ // to the DO (and passes ?verified=1). Connections that arrive here
994
+ // pre-verified are fast-tracked in handleOpenClawMessage.
929
995
  //
930
- // For now, accept any non-empty bc_pat_ token:
931
- const isValid = token.startsWith("bc_pat_") && token.length > 10;
932
- await this.state.storage.put(`token:${token}`, isValid);
933
- return isValid;
996
+ // For tokens that arrive WITHOUT pre-verification (e.g. direct DO
997
+ // access, which shouldn't happen in normal flow), we validate
998
+ // against D1 ourselves and cache the result with a TTL.
999
+
1000
+ if (!token || !token.startsWith("bc_pat_") || token.length < 20) {
1001
+ return false;
1002
+ }
1003
+
1004
+ // Check DO-local cache first (with 30-second TTL — short to ensure
1005
+ // revoked tokens are invalidated quickly)
1006
+ const cacheKey = `token:${token}`;
1007
+ const cached = await this.state.storage.get<{ valid: boolean; cachedAt: number }>(cacheKey);
1008
+ if (cached) {
1009
+ const ageMs = Date.now() - cached.cachedAt;
1010
+ if (ageMs < 30_000) return cached.valid; // 30-second TTL
1011
+ // Expired — fall through to re-validate
1012
+ }
1013
+
1014
+ // Validate against D1
1015
+ try {
1016
+ const row = await this.env.DB.prepare(
1017
+ "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
1018
+ )
1019
+ .bind(token)
1020
+ .first<{ user_id: string }>();
1021
+
1022
+ const isValid = !!row;
1023
+ await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
1024
+ return isValid;
1025
+ } catch (err) {
1026
+ console.error("[DO] Failed to validate pairing token against D1:", err);
1027
+ return false;
1028
+ }
934
1029
  }
935
1030
  }
@@ -1,7 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import type { Env } from "./env.js";
4
- import { authMiddleware } from "./utils/auth.js";
4
+ import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
5
5
  import { auth } from "./routes/auth.js";
6
6
  import { agents } from "./routes/agents.js";
7
7
  import { channels } from "./routes/channels.js";
@@ -18,12 +18,67 @@ export { ConnectionDO } from "./do/connection-do.js";
18
18
 
19
19
  const app = new Hono<{ Bindings: Env }>();
20
20
 
21
- // Global CORS
22
- app.use("/*", cors({ origin: "*", allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }));
21
+ // Production CORS origins
22
+ const PRODUCTION_ORIGINS = [
23
+ "https://console.botschat.app",
24
+ "https://botschat.app",
25
+ "https://botschat-api.auxtenwpc.workers.dev",
26
+ ];
27
+
28
+ // CORS and security headers — skip for WebSocket upgrade requests
29
+ // (101 responses have immutable headers in Cloudflare Workers)
30
+ const corsMiddleware = cors({
31
+ origin: (origin, c) => {
32
+ if (PRODUCTION_ORIGINS.includes(origin)) return origin;
33
+ // Only allow localhost/private IPs in development
34
+ if ((c as unknown as { env: Env }).env?.ENVIRONMENT === "development") {
35
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return origin;
36
+ if (/^https?:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return origin;
37
+ }
38
+ return ""; // disallow
39
+ },
40
+ allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
41
+ });
42
+
43
+ app.use("/*", async (c, next) => {
44
+ // WebSocket upgrades return 101 with immutable headers — skip CORS & security headers
45
+ if (c.req.header("Upgrade")?.toLowerCase() === "websocket") {
46
+ await next();
47
+ return;
48
+ }
49
+
50
+ // Apply CORS for regular HTTP requests
51
+ return corsMiddleware(c, next);
52
+ });
53
+
54
+ // Security response headers.
55
+ // In Cloudflare Workers, responses from Durable Objects (stub.fetch()) and
56
+ // subrequests have immutable headers. We clone the response first, then set
57
+ // security headers on the mutable clone. This also makes headers mutable for
58
+ // the CORS middleware which runs AFTER this (registered earlier → runs later
59
+ // in the response phase).
60
+ app.use("/*", async (c, next) => {
61
+ await next();
62
+ if (c.res.status === 101) return; // WebSocket 101 — can't clone
63
+ // Clone to ensure mutable headers
64
+ c.res = new Response(c.res.body, c.res);
65
+ c.res.headers.set(
66
+ "Content-Security-Policy",
67
+ "default-src 'self'; script-src 'self' https://apis.google.com https://*.firebaseapp.com; style-src 'self' 'unsafe-inline'; img-src 'self' https://*.r2.dev https://*.cloudflarestorage.com data: blob:; connect-src 'self' wss://*.botschat.app wss://console.botschat.app https://apis.google.com https://*.googleapis.com; frame-src https://accounts.google.com https://*.firebaseapp.com",
68
+ );
69
+ c.res.headers.set("X-Content-Type-Options", "nosniff");
70
+ c.res.headers.set("X-Frame-Options", "DENY");
71
+ c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
72
+ });
23
73
 
24
74
  // Health check
25
75
  app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
26
76
 
77
+ // Rate limiting is handled by Cloudflare WAF Rate Limiting Rules (Dashboard).
78
+ // See the security audit for recommended rule configuration.
79
+ // No in-memory rate limiter — it cannot survive Worker isolate restarts
80
+ // and is not shared across instances.
81
+
27
82
  // ---- Public routes (no auth) ----
28
83
  app.route("/api/auth", auth);
29
84
  app.route("/api/setup", setup);
@@ -196,12 +251,29 @@ protectedApp.route("/channels/:channelId/sessions", sessions);
196
251
  protectedApp.route("/pairing-tokens", pairing);
197
252
  protectedApp.route("/upload", upload);
198
253
 
199
- // ---- Media serving route (public, no auth) ----
254
+ // ---- Media serving route (signed URL or Bearer auth) ----
200
255
  app.get("/api/media/:userId/:filename", async (c) => {
201
256
  const userId = c.req.param("userId");
202
257
  const filename = c.req.param("filename");
203
- const key = `media/${userId}/${filename}`;
204
258
 
259
+ // Verify access: either a valid signed URL or a valid Bearer token
260
+ const expires = c.req.query("expires");
261
+ const sig = c.req.query("sig");
262
+ const secret = getJwtSecret(c.env);
263
+
264
+ if (expires && sig) {
265
+ // Signed URL verification
266
+ const valid = await verifyMediaSignature(userId, filename, expires, sig, secret);
267
+ if (!valid) {
268
+ return c.json({ error: "Invalid or expired media signature" }, 403);
269
+ }
270
+ } else {
271
+ // Fall back to Bearer token auth
272
+ const denied = await verifyUserAccess(c, userId);
273
+ if (denied) return denied;
274
+ }
275
+
276
+ const key = `media/${userId}/${filename}`;
205
277
  const object = await c.env.MEDIA.get(key);
206
278
  if (!object) {
207
279
  return c.json({ error: "Not found" }, 404);
@@ -209,11 +281,29 @@ app.get("/api/media/:userId/:filename", async (c) => {
209
281
 
210
282
  const headers = new Headers();
211
283
  headers.set("Content-Type", object.httpMetadata?.contentType ?? "application/octet-stream");
212
- headers.set("Cache-Control", "public, max-age=31536000, immutable");
284
+ headers.set("Cache-Control", "public, max-age=3600"); // 1h cache (matches signature expiry)
213
285
 
214
286
  return new Response(object.body, { headers });
215
287
  });
216
288
 
289
+ // ---- Helper: verify JWT and ensure userId matches ----
290
+ async function verifyUserAccess(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env; json: (data: unknown, status?: number) => Response }, userId: string): Promise<Response | null> {
291
+ const authHeader = c.req.header("Authorization");
292
+ const tokenStr = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : c.req.query("token");
293
+ if (!tokenStr) {
294
+ return c.json({ error: "Missing Authorization header or token query param" }, 401);
295
+ }
296
+ const secret = getJwtSecret(c.env);
297
+ const payload = await verifyToken(tokenStr, secret);
298
+ if (!payload) {
299
+ return c.json({ error: "Invalid or expired token" }, 401);
300
+ }
301
+ if (payload.sub !== userId) {
302
+ return c.json({ error: "Forbidden" }, 403);
303
+ }
304
+ return null; // access granted
305
+ }
306
+
217
307
  // ---- WebSocket upgrade routes (BEFORE protected middleware) ----
218
308
 
219
309
  // OpenClaw plugin connects to: /api/gateway/:connId
@@ -268,6 +358,9 @@ app.all("/api/gateway/:connId", async (c) => {
268
358
  });
269
359
 
270
360
  // Browser client connects to: /api/ws/:userId/:sessionId
361
+ // Auth is handled entirely inside the DO via the "auth" message after
362
+ // the WebSocket connection is established. This avoids putting the JWT
363
+ // in the URL query string (which would leak it in logs/browser history).
271
364
  app.all("/api/ws/:userId/:sessionId", async (c) => {
272
365
  const userId = c.req.param("userId");
273
366
  const sessionId = c.req.param("sessionId");
@@ -281,6 +374,8 @@ app.all("/api/ws/:userId/:sessionId", async (c) => {
281
374
  // Connection status: /api/connection/:userId/status
282
375
  app.get("/api/connection/:userId/status", async (c) => {
283
376
  const userId = c.req.param("userId");
377
+ const denied = await verifyUserAccess(c, userId);
378
+ if (denied) return denied;
284
379
  const doId = c.env.CONNECTION_DO.idFromName(userId);
285
380
  const stub = c.env.CONNECTION_DO.get(doId);
286
381
  const url = new URL(c.req.url);
@@ -291,6 +386,8 @@ app.get("/api/connection/:userId/status", async (c) => {
291
386
  // Message history: /api/messages/:userId?sessionKey=xxx
292
387
  app.get("/api/messages/:userId", async (c) => {
293
388
  const userId = c.req.param("userId");
389
+ const denied = await verifyUserAccess(c, userId);
390
+ if (denied) return denied;
294
391
  const doId = c.env.CONNECTION_DO.idFromName(userId);
295
392
  const stub = c.env.CONNECTION_DO.get(doId);
296
393
  const url = new URL(c.req.url);