botschat 0.1.14 → 0.1.16

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 (35) hide show
  1. package/package.json +1 -1
  2. package/packages/api/src/do/connection-do.ts +201 -59
  3. package/packages/api/src/index.ts +91 -14
  4. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  5. package/packages/plugin/dist/src/channel.js +75 -6
  6. package/packages/plugin/dist/src/channel.js.map +1 -1
  7. package/packages/plugin/dist/src/types.d.ts +1 -0
  8. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/ws-client.d.ts +1 -0
  10. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  11. package/packages/plugin/dist/src/ws-client.js +41 -4
  12. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  13. package/packages/plugin/package.json +1 -1
  14. package/packages/web/dist/assets/{index-CkIgZfHf.js → index--A7c71kf.js} +1 -1
  15. package/packages/web/dist/assets/{index-BJye3VHV.js → index-CEStVU9o.js} +137 -137
  16. package/packages/web/dist/assets/{index-CNSCbd7_.css → index-CLKSdbmx.css} +1 -1
  17. package/packages/web/dist/assets/{index-CQPXprFz.js → index-Cd5GHqU6.js} +1 -1
  18. package/packages/web/dist/assets/index-D3Vfl8Ll.js +2 -0
  19. package/packages/web/dist/assets/index-DUpmW4Ay.js +2 -0
  20. package/packages/web/dist/assets/index-DfBArjKG.js +1 -0
  21. package/packages/web/dist/assets/{index.esm-DgcFARs7.js → index.esm-Dc_1yrX1.js} +1 -1
  22. package/packages/web/dist/assets/{web-Bfku9Io_.js → web-CDcVasbM.js} +1 -1
  23. package/packages/web/dist/assets/{web-CnOlwlZw.js → web-D_QoLpUi.js} +1 -1
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/src/App.tsx +24 -4
  26. package/packages/web/src/api.ts +2 -1
  27. package/packages/web/src/components/ChatWindow.tsx +141 -75
  28. package/packages/web/src/components/ScheduleEditor.tsx +120 -47
  29. package/packages/web/src/components/ThreadPanel.tsx +34 -6
  30. package/packages/web/src/foreground.ts +40 -10
  31. package/packages/web/src/store.ts +4 -0
  32. package/packages/web/src/utils/time.ts +23 -0
  33. package/packages/web/dist/assets/index-CPOiRHa4.js +0 -2
  34. package/packages/web/dist/assets/index-DbUyNI4d.js +0 -1
  35. package/packages/web/dist/assets/index-Dpvhc_dU.js +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -5,6 +5,20 @@ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
5
5
  import { generateId as generateIdUtil } from "../utils/id.js";
6
6
  import { randomUUID } from "../utils/uuid.js";
7
7
 
8
+ /** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
9
+ interface BrowserAttachment {
10
+ authenticated: boolean;
11
+ tag: string;
12
+ foreground: boolean;
13
+ sessionKey: string | null;
14
+ /** Timestamp (ms) when the session last went to background. */
15
+ backgroundAt: number | null;
16
+ }
17
+
18
+ /** Grace period constants for push notification suppression. */
19
+ const BG_GRACE_MS = 15_000; // 15 s after going background
20
+ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
21
+
8
22
  /**
9
23
  * ConnectionDO — one Durable Object instance per BotsChat user.
10
24
  *
@@ -29,8 +43,15 @@ export class ConnectionDO implements DurableObject {
29
43
  /** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
30
44
  private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
31
45
 
32
- /** Browser sessions that report themselves in foreground (push notifications are suppressed). */
33
- private foregroundSessions = new Set<string>();
46
+ /**
47
+ * Recently disconnected browser sessions — provides a grace period so that
48
+ * brief network blips don't immediately trigger push notifications.
49
+ * In-memory only; if the DO hibernates, the grace period has expired anyway.
50
+ */
51
+ private recentDisconnects = new Map<string, number>();
52
+
53
+ /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
54
+ private lastOpenClawAcceptedAt = 0;
34
55
 
35
56
  constructor(state: DurableObjectState, env: Env) {
36
57
  this.state = state;
@@ -39,16 +60,33 @@ export class ConnectionDO implements DurableObject {
39
60
 
40
61
  /** Handle incoming HTTP requests (WebSocket upgrades). */
41
62
  async fetch(request: Request): Promise<Response> {
63
+ try {
64
+ return await this._fetch(request);
65
+ } catch (err) {
66
+ const msg = String(err);
67
+ if (msg.includes("Exceeded")) {
68
+ console.error("[DO] Storage limit exceeded:", msg);
69
+ return new Response("Storage limit exceeded, retry later", {
70
+ status: 503,
71
+ headers: { "Retry-After": "300" },
72
+ });
73
+ }
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ private async _fetch(request: Request): Promise<Response> {
42
79
  const url = new URL(request.url);
43
80
 
44
81
  // Route: /gateway/:accountId — OpenClaw plugin connects here
45
82
  if (url.pathname.startsWith("/gateway/")) {
46
- // Extract and store userId from the gateway path
47
83
  const userId = url.pathname.split("/gateway/")[1]?.split("?")[0];
48
84
  if (userId) {
49
- await this.state.storage.put("userId", userId);
85
+ const stored = await this.state.storage.get<string>("userId");
86
+ if (stored !== userId) {
87
+ await this.state.storage.put("userId", userId);
88
+ }
50
89
  }
51
- // Check if the API worker already verified the token against D1
52
90
  const preVerified = url.searchParams.get("verified") === "1";
53
91
  return this.handleOpenClawConnect(request, preVerified);
54
92
  }
@@ -92,22 +130,32 @@ export class ConnectionDO implements DurableObject {
92
130
 
93
131
  /** Called when a WebSocket receives a message (wakes from hibernation). */
94
132
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
95
- const tag = this.getTag(ws);
96
- const data = typeof message === "string" ? message : new TextDecoder().decode(message);
97
-
98
- let parsed: Record<string, unknown>;
99
133
  try {
100
- parsed = JSON.parse(data);
101
- } catch {
102
- return; // Ignore malformed JSON
103
- }
134
+ const tag = this.getTag(ws);
135
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
104
136
 
105
- if (tag === "openclaw") {
106
- // Message from OpenClaw → handle auth or forward to browsers
107
- await this.handleOpenClawMessage(ws, parsed);
108
- } else if (tag?.startsWith("browser:")) {
109
- // Message from browser → forward to OpenClaw
110
- await this.handleBrowserMessage(ws, parsed);
137
+ let parsed: Record<string, unknown>;
138
+ try {
139
+ parsed = JSON.parse(data);
140
+ } catch {
141
+ return;
142
+ }
143
+
144
+ if (tag === "openclaw") {
145
+ await this.handleOpenClawMessage(ws, parsed);
146
+ } else if (tag?.startsWith("browser:")) {
147
+ await this.handleBrowserMessage(ws, parsed);
148
+ }
149
+ } catch (err) {
150
+ const msg = String(err);
151
+ if (msg.includes("Exceeded")) {
152
+ console.error("[DO] Storage limit exceeded in webSocketMessage:", msg);
153
+ try {
154
+ ws.send(JSON.stringify({ type: "error", message: "Storage limit exceeded, retry later" }));
155
+ } catch { /* socket may already be dead */ }
156
+ return;
157
+ }
158
+ throw err;
111
159
  }
112
160
  }
113
161
 
@@ -120,9 +168,13 @@ export class ConnectionDO implements DurableObject {
120
168
  JSON.stringify({ type: "openclaw.disconnected" }),
121
169
  );
122
170
  }
123
- // Clean up foreground tracking for browser sessions
171
+ // Disconnect grace: remember recently-disconnected browser sessions so
172
+ // push notifications are still suppressed during brief network blips.
124
173
  if (tag?.startsWith("browser:")) {
125
- this.foregroundSessions.delete(tag);
174
+ const att = ws.deserializeAttachment() as BrowserAttachment | null;
175
+ if (att?.foreground) {
176
+ this.recentDisconnects.set(tag, Date.now());
177
+ }
126
178
  }
127
179
  }
128
180
 
@@ -139,10 +191,31 @@ export class ConnectionDO implements DurableObject {
139
191
  return new Response("Expected WebSocket upgrade", { status: 426 });
140
192
  }
141
193
 
194
+ const now = Date.now();
195
+ const cooldownMs = 30_000;
196
+ if (now - this.lastOpenClawAcceptedAt < cooldownMs) {
197
+ const retryAfter = Math.ceil((cooldownMs - (now - this.lastOpenClawAcceptedAt)) / 1000);
198
+ return new Response("Too many connections, retry later", {
199
+ status: 429,
200
+ headers: { "Retry-After": String(retryAfter) },
201
+ });
202
+ }
203
+ this.lastOpenClawAcceptedAt = now;
204
+
205
+ // Safety valve: if stale openclaw sockets accumulated (e.g. from
206
+ // rapid reconnects that authenticated but then lost their edge
207
+ // connection), close them all before accepting a new one.
208
+ const existing = this.state.getWebSockets("openclaw");
209
+ if (existing.length > 3) {
210
+ console.warn(`[DO] Safety valve: ${existing.length} openclaw sockets, closing all`);
211
+ for (const s of existing) {
212
+ try { s.close(4009, "replaced"); } catch { /* dead */ }
213
+ }
214
+ }
215
+
142
216
  const pair = new WebSocketPair();
143
217
  const [client, server] = [pair[0], pair[1]];
144
218
 
145
- // Accept with Hibernation API, tag as "openclaw"
146
219
  this.state.acceptWebSocket(server, ["openclaw"]);
147
220
 
148
221
  // If the API worker already verified the token against D1, mark as
@@ -166,7 +239,17 @@ export class ConnectionDO implements DurableObject {
166
239
 
167
240
  const tag = `browser:${sessionId}`;
168
241
  this.state.acceptWebSocket(server, [tag]);
169
- server.serializeAttachment({ authenticated: false, tag });
242
+ const att: BrowserAttachment = {
243
+ authenticated: false,
244
+ tag,
245
+ foreground: false,
246
+ sessionKey: null,
247
+ backgroundAt: null,
248
+ };
249
+ server.serializeAttachment(att);
250
+
251
+ // Clear disconnect grace entry — this session is back.
252
+ this.recentDisconnects.delete(tag);
170
253
 
171
254
  return new Response(null, { status: 101, webSocket: client });
172
255
  }
@@ -187,31 +270,26 @@ export class ConnectionDO implements DurableObject {
187
270
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
188
271
 
189
272
  if (isValid) {
190
- // Close any existing (potentially stale) openclaw sockets before
191
- // marking the new one as authenticated. Cloudflare's edge infra
192
- // terminates WebSocket connections every ~60 min (code 1006). The
193
- // plugin reconnects immediately, but the DO may not have detected
194
- // the old socket's death yet (no close frame → no webSocketClose
195
- // callback yet). Without this cleanup, getOpenClawSocket() could
196
- // return a stale/dead socket, silently dropping all messages.
273
+ // Close ALL other openclaw sockets. Use custom code 4009 so
274
+ // well-behaved plugins know they were replaced (not a crash)
275
+ // and should NOT reconnect. The Worker-level rate limit (10s)
276
+ // prevents the resulting close event from flooding the DO.
197
277
  const existingSockets = this.state.getWebSockets("openclaw");
278
+ let closedCount = 0;
198
279
  for (const oldWs of existingSockets) {
199
280
  if (oldWs !== ws) {
200
281
  try {
201
- oldWs.close(1000, "replaced by new connection");
202
- } catch {
203
- // Socket may already be dead ignore
204
- }
282
+ oldWs.close(4009, "replaced");
283
+ closedCount++;
284
+ } catch { /* already dead */ }
205
285
  }
206
286
  }
207
287
 
208
288
  ws.serializeAttachment({ ...attachment, authenticated: true });
209
- // Include userId so the plugin can derive the E2E key
210
289
  const userId = await this.state.storage.get<string>("userId");
211
- console.log(`[DO] auth.ok → userId=${userId}, closedStale=${existingSockets.length - 1}`);
290
+ console.log(`[DO] auth.ok → userId=${userId}, closed=${closedCount}, total=${existingSockets.length}`);
212
291
  ws.send(JSON.stringify({ type: "auth.ok", userId }));
213
- // Store gateway default model from plugin auth
214
- if (msg.model) {
292
+ if (msg.model && msg.model !== this.defaultModel) {
215
293
  this.defaultModel = msg.model as string;
216
294
  await this.state.storage.put("defaultModel", this.defaultModel);
217
295
  }
@@ -250,16 +328,19 @@ export class ConnectionDO implements DurableObject {
250
328
 
251
329
  // For agent.media, cache external images to R2 so they remain accessible
252
330
  // even after the original URL expires (e.g. DALL-E temporary URLs).
331
+ // Skip caching if the plugin already uploaded E2E-encrypted media.
253
332
  let persistedMediaUrl = msg.mediaUrl as string | undefined;
254
- if (msg.type === "agent.media" && persistedMediaUrl) {
333
+ if (msg.type === "agent.media" && persistedMediaUrl && !msg.mediaEncrypted) {
255
334
  const cachedUrl = await this.cacheExternalMedia(persistedMediaUrl);
256
335
  if (cachedUrl) {
257
336
  persistedMediaUrl = cachedUrl;
258
- // Update the message object so browsers get the cached URL
259
337
  msg.mediaUrl = cachedUrl;
260
338
  }
261
339
  }
262
340
 
341
+ // Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
342
+ const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
343
+
263
344
  await this.persistMessage({
264
345
  id: msg.messageId as string | undefined,
265
346
  sender: "agent",
@@ -268,7 +349,7 @@ export class ConnectionDO implements DurableObject {
268
349
  text: (msg.text ?? msg.caption ?? "") as string,
269
350
  mediaUrl: persistedMediaUrl,
270
351
  a2ui: msg.jsonl as string | undefined,
271
- encrypted: msg.encrypted ? 1 : 0,
352
+ encrypted: encryptedBits,
272
353
  });
273
354
  }
274
355
 
@@ -295,20 +376,24 @@ export class ConnectionDO implements DurableObject {
295
376
  await this.handleTaskScanResult(msg);
296
377
  }
297
378
 
298
- // Handle models list from plugin — persist to storage and broadcast to browsers
299
379
  if (msg.type === "models.list") {
300
- this.cachedModels = (msg.models as Array<{ id: string; name: string; provider: string }>) ?? [];
301
- await this.state.storage.put("cachedModels", this.cachedModels);
302
- console.log(`[DO] Persisted ${this.cachedModels.length} models to storage, broadcasting connection.status`);
380
+ const newModels = (msg.models as Array<{ id: string; name: string; provider: string }>) ?? [];
381
+ const changed = JSON.stringify(newModels) !== JSON.stringify(this.cachedModels);
382
+ this.cachedModels = newModels;
383
+ if (changed) {
384
+ await this.state.storage.put("cachedModels", this.cachedModels);
385
+ console.log(`[DO] Persisted ${this.cachedModels.length} models to storage`);
386
+ }
303
387
  this.broadcastToBrowsers(
304
388
  JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
305
389
  );
306
390
  }
307
391
 
308
- // Plugin applied BotsChat default model to OpenClaw config — update and broadcast
309
392
  if (msg.type === "defaultModel.updated" && typeof msg.model === "string") {
310
- this.defaultModel = msg.model;
311
- await this.state.storage.put("defaultModel", this.defaultModel);
393
+ if (msg.model !== this.defaultModel) {
394
+ this.defaultModel = msg.model;
395
+ await this.state.storage.put("defaultModel", this.defaultModel);
396
+ }
312
397
  this.broadcastToBrowsers(
313
398
  JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
314
399
  );
@@ -327,10 +412,10 @@ export class ConnectionDO implements DurableObject {
327
412
  const { notifyPreview: _stripped, ...msgForBrowser } = msg;
328
413
  this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
329
414
 
330
- // Send push notification if no browser session is in foreground
415
+ // Send push notification unless a device is (or was recently) in the foreground
331
416
  if (
332
417
  (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
333
- this.foregroundSessions.size === 0 &&
418
+ !this.shouldSuppressPush() &&
334
419
  (this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
335
420
  ) {
336
421
  this.sendPushNotifications(msg).catch((err) => {
@@ -343,7 +428,7 @@ export class ConnectionDO implements DurableObject {
343
428
  ws: WebSocket,
344
429
  msg: Record<string, unknown>,
345
430
  ): Promise<void> {
346
- const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
431
+ const attachment = ws.deserializeAttachment() as BrowserAttachment | null;
347
432
 
348
433
  // Handle browser auth — verify JWT token
349
434
  if (msg.type === "auth") {
@@ -394,15 +479,29 @@ export class ConnectionDO implements DurableObject {
394
479
  return;
395
480
  }
396
481
 
397
- // Handle foreground/background state tracking for push notifications
482
+ // ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
398
483
  if (msg.type === "foreground.enter") {
399
- const tag = attachment.tag;
400
- if (tag) this.foregroundSessions.add(tag);
484
+ ws.serializeAttachment({
485
+ ...attachment,
486
+ foreground: true,
487
+ sessionKey: (msg.sessionKey as string) ?? attachment.sessionKey ?? null,
488
+ backgroundAt: null,
489
+ } satisfies BrowserAttachment);
401
490
  return;
402
491
  }
403
492
  if (msg.type === "foreground.leave") {
404
- const tag = attachment.tag;
405
- if (tag) this.foregroundSessions.delete(tag);
493
+ ws.serializeAttachment({
494
+ ...attachment,
495
+ foreground: false,
496
+ backgroundAt: Date.now(),
497
+ } satisfies BrowserAttachment);
498
+ return;
499
+ }
500
+ if (msg.type === "focus.update") {
501
+ ws.serializeAttachment({
502
+ ...attachment,
503
+ sessionKey: (msg.sessionKey as string) ?? null,
504
+ } satisfies BrowserAttachment);
406
505
  return;
407
506
  }
408
507
 
@@ -618,6 +717,39 @@ export class ConnectionDO implements DurableObject {
618
717
  }
619
718
  }
620
719
 
720
+ // ---- Presence helpers ----
721
+
722
+ /**
723
+ * Determine whether push notifications should be suppressed because a device
724
+ * is (or was very recently) in the foreground.
725
+ *
726
+ * Checks three layers:
727
+ * 1. Any connected browser socket with `foreground === true`
728
+ * 2. Background grace: socket went background < BG_GRACE_MS ago
729
+ * 3. Disconnect grace: socket disconnected < DC_GRACE_MS ago
730
+ */
731
+ private shouldSuppressPush(): boolean {
732
+ const now = Date.now();
733
+
734
+ // 1 + 2: scan connected browser sockets
735
+ const sockets = this.state.getWebSockets();
736
+ for (const s of sockets) {
737
+ const att = s.deserializeAttachment() as BrowserAttachment | null;
738
+ if (!att || !att.tag?.startsWith("browser:") || !att.authenticated) continue;
739
+ if (att.foreground) return true;
740
+ if (att.backgroundAt && now - att.backgroundAt < BG_GRACE_MS) return true;
741
+ }
742
+
743
+ // 3: recently disconnected sessions
744
+ for (const [tag, disconnectedAt] of this.recentDisconnects) {
745
+ if (now - disconnectedAt < DC_GRACE_MS) return true;
746
+ // Expired — prune
747
+ this.recentDisconnects.delete(tag);
748
+ }
749
+
750
+ return false;
751
+ }
752
+
621
753
  // ---- Push notifications ----
622
754
 
623
755
  /**
@@ -985,15 +1117,21 @@ export class ConnectionDO implements DurableObject {
985
1117
  if (mediaUrl) {
986
1118
  mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
987
1119
  }
1120
+ const encBits = (row.encrypted as number) ?? 0;
1121
+ // Derive mediaEncrypted:
1122
+ // - User messages with encrypted=1: media was always encrypted by browser
1123
+ // - Any message with bit 1 set (encrypted >= 2): media was E2E encrypted
1124
+ const mediaEncrypted = (row.sender === "user" && encBits >= 1) || (encBits & 2) !== 0;
988
1125
  return {
989
1126
  id: row.id,
990
1127
  sender: row.sender,
991
1128
  text: row.text ?? "",
992
- timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
1129
+ timestamp: ((row.created_at as number) ?? 0) * 1000,
993
1130
  mediaUrl,
994
1131
  a2ui: row.a2ui ?? undefined,
995
1132
  threadId: row.thread_id ?? undefined,
996
- encrypted: row.encrypted ?? 0,
1133
+ encrypted: encBits,
1134
+ mediaEncrypted,
997
1135
  };
998
1136
  }),
999
1137
  );
@@ -1299,7 +1437,11 @@ export class ConnectionDO implements DurableObject {
1299
1437
  .first<{ user_id: string }>();
1300
1438
 
1301
1439
  const isValid = !!row;
1302
- await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
1440
+ try {
1441
+ await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
1442
+ } catch {
1443
+ // Non-critical — skip caching if storage is full
1444
+ }
1303
1445
  return isValid;
1304
1446
  } catch (err) {
1305
1447
  console.error("[DO] Failed to validate pairing token against D1:", err);
@@ -1,7 +1,8 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import type { Env } from "./env.js";
4
- import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
4
+ import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature, signMediaUrl } from "./utils/auth.js";
5
+ import { randomUUID } from "./utils/uuid.js";
5
6
  import { auth } from "./routes/auth.js";
6
7
  import { agents } from "./routes/agents.js";
7
8
  import { channels } from "./routes/channels.js";
@@ -365,7 +366,6 @@ async function verifyUserAccess(c: { req: { header: (n: string) => string | unde
365
366
  app.all("/api/gateway/:connId", async (c) => {
366
367
  let userId = c.req.param("connId");
367
368
 
368
- // If connId is not a real user ID (e.g. "default"), resolve via token
369
369
  if (!userId.startsWith("u_")) {
370
370
  const token =
371
371
  c.req.query("token") ??
@@ -376,7 +376,6 @@ app.all("/api/gateway/:connId", async (c) => {
376
376
  return c.json({ error: "Token required for gateway connection" }, 401);
377
377
  }
378
378
 
379
- // Look up user by pairing token (exclude revoked tokens)
380
379
  const row = await c.env.DB.prepare(
381
380
  "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
382
381
  )
@@ -387,26 +386,57 @@ app.all("/api/gateway/:connId", async (c) => {
387
386
  return c.json({ error: "Invalid pairing token" }, 401);
388
387
  }
389
388
  userId = row.user_id;
389
+ }
390
+
391
+ // --- Worker-level rate limit (Cache API) ---
392
+ // Protects the DO from being woken up during reconnection storms.
393
+ // The Cache API persists across Worker isolates within the same colo.
394
+ const GATEWAY_COOLDOWN_S = 10;
395
+ const cache = caches.default;
396
+ const rateCacheUrl = `https://rate.internal/gateway/${userId}`;
397
+ const rateCacheReq = new Request(rateCacheUrl);
398
+ const rateCached = await cache.match(rateCacheReq);
399
+ if (rateCached) {
400
+ return c.text("Too many connections, retry later", 429, {
401
+ "Retry-After": String(GATEWAY_COOLDOWN_S),
402
+ });
403
+ }
390
404
 
391
- // Update audit fields: last_connected_at, last_ip, connection_count
405
+ // Audit: update pairing token stats (only when not rate-limited)
406
+ const token = c.req.query("token") ?? c.req.header("X-Pairing-Token");
407
+ if (token) {
392
408
  const clientIp = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown";
393
- await c.env.DB.prepare(
394
- `UPDATE pairing_tokens
395
- SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
396
- WHERE token = ?`,
397
- )
398
- .bind(clientIp, token)
399
- .run();
409
+ c.executionCtx.waitUntil(
410
+ c.env.DB.prepare(
411
+ `UPDATE pairing_tokens
412
+ SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
413
+ WHERE token = ?`,
414
+ ).bind(clientIp, token).run(),
415
+ );
400
416
  }
401
417
 
402
418
  const doId = c.env.CONNECTION_DO.idFromName(userId);
403
419
  const stub = c.env.CONNECTION_DO.get(doId);
404
420
  const url = new URL(c.req.url);
405
- // Pass verified userId to DO — the API worker already validated the token
406
- // against D1 above, so DO can trust this.
407
421
  url.pathname = `/gateway/${userId}`;
408
422
  url.searchParams.set("verified", "1");
409
- return stub.fetch(new Request(url.toString(), c.req.raw));
423
+ const doResp = await stub.fetch(new Request(url.toString(), c.req.raw));
424
+
425
+ // Cache the rate limit after the DO responds (success or rate-limited).
426
+ // 101 = WebSocket accepted; 429 = DO's own rate limit.
427
+ // Either way, prevent further DO wake-ups for GATEWAY_COOLDOWN_S.
428
+ if (doResp.status === 101 || doResp.status === 429) {
429
+ c.executionCtx.waitUntil(
430
+ cache.put(
431
+ rateCacheReq,
432
+ new Response(null, {
433
+ headers: { "Cache-Control": `public, max-age=${GATEWAY_COOLDOWN_S}` },
434
+ }),
435
+ ),
436
+ );
437
+ }
438
+
439
+ return doResp;
410
440
  });
411
441
 
412
442
  // Browser client connects to: /api/ws/:userId/:sessionId
@@ -448,6 +478,53 @@ app.get("/api/messages/:userId", async (c) => {
448
478
  return stub.fetch(new Request(url.toString()));
449
479
  });
450
480
 
481
+ // ---- Plugin upload (pairing token auth) ----
482
+ app.post("/api/plugin-upload", async (c) => {
483
+ const token = c.req.header("X-Pairing-Token");
484
+ if (!token) {
485
+ return c.json({ error: "Missing X-Pairing-Token header" }, 401);
486
+ }
487
+ const row = await c.env.DB.prepare(
488
+ "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
489
+ )
490
+ .bind(token)
491
+ .first<{ user_id: string }>();
492
+ if (!row) {
493
+ return c.json({ error: "Invalid pairing token" }, 401);
494
+ }
495
+
496
+ const userId = row.user_id;
497
+ const contentType = c.req.header("Content-Type") ?? "";
498
+ if (!contentType.includes("multipart/form-data")) {
499
+ return c.json({ error: "Expected multipart/form-data" }, 400);
500
+ }
501
+
502
+ const formData = await c.req.formData();
503
+ const file = formData.get("file") as File | null;
504
+ if (!file) {
505
+ return c.json({ error: "No file provided" }, 400);
506
+ }
507
+
508
+ const MAX_SIZE = 20 * 1024 * 1024;
509
+ if (file.size > MAX_SIZE) {
510
+ return c.json({ error: "File too large (max 20 MB)" }, 413);
511
+ }
512
+
513
+ const fileType = file.type || "application/octet-stream";
514
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
515
+ const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
516
+ const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
517
+ const key = `media/${userId}/${filename}`;
518
+
519
+ await c.env.MEDIA.put(key, file.stream(), {
520
+ httpMetadata: { contentType: fileType },
521
+ });
522
+
523
+ const secret = getJwtSecret(c.env);
524
+ const url = await signMediaUrl(userId, filename, secret, 3600);
525
+ return c.json({ url, key });
526
+ });
527
+
451
528
  // ---- Protected routes (require Bearer token) — AFTER ws routes ----
452
529
  app.route("/api", protectedApp);
453
530
 
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}