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.
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +201 -59
- package/packages/api/src/index.ts +91 -14
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +75 -6
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +1 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +41 -4
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/{index-CkIgZfHf.js → index--A7c71kf.js} +1 -1
- package/packages/web/dist/assets/{index-BJye3VHV.js → index-CEStVU9o.js} +137 -137
- package/packages/web/dist/assets/{index-CNSCbd7_.css → index-CLKSdbmx.css} +1 -1
- package/packages/web/dist/assets/{index-CQPXprFz.js → index-Cd5GHqU6.js} +1 -1
- package/packages/web/dist/assets/index-D3Vfl8Ll.js +2 -0
- package/packages/web/dist/assets/index-DUpmW4Ay.js +2 -0
- package/packages/web/dist/assets/index-DfBArjKG.js +1 -0
- package/packages/web/dist/assets/{index.esm-DgcFARs7.js → index.esm-Dc_1yrX1.js} +1 -1
- package/packages/web/dist/assets/{web-Bfku9Io_.js → web-CDcVasbM.js} +1 -1
- package/packages/web/dist/assets/{web-CnOlwlZw.js → web-D_QoLpUi.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/src/App.tsx +24 -4
- package/packages/web/src/api.ts +2 -1
- package/packages/web/src/components/ChatWindow.tsx +141 -75
- package/packages/web/src/components/ScheduleEditor.tsx +120 -47
- package/packages/web/src/components/ThreadPanel.tsx +34 -6
- package/packages/web/src/foreground.ts +40 -10
- package/packages/web/src/store.ts +4 -0
- package/packages/web/src/utils/time.ts +23 -0
- package/packages/web/dist/assets/index-CPOiRHa4.js +0 -2
- package/packages/web/dist/assets/index-DbUyNI4d.js +0 -1
- package/packages/web/dist/assets/index-Dpvhc_dU.js +0 -2
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
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},
|
|
290
|
+
console.log(`[DO] auth.ok → userId=${userId}, closed=${closedCount}, total=${existingSockets.length}`);
|
|
212
291
|
ws.send(JSON.stringify({ type: "auth.ok", userId }));
|
|
213
|
-
|
|
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:
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
311
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
482
|
+
// ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
|
|
398
483
|
if (msg.type === "foreground.enter") {
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.bind(clientIp, token)
|
|
399
|
-
|
|
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
|
-
|
|
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":"AAQA,OAAO,KAAK,EAAuC,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC/F,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
|