botschat 0.1.10 → 0.1.13
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 +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
package/README.md
CHANGED
|
@@ -117,24 +117,17 @@ Clone, install, and run the server on your machine. Wrangler uses [Miniflare](ht
|
|
|
117
117
|
git clone https://github.com/botschat-app/botsChat.git
|
|
118
118
|
cd botsChat
|
|
119
119
|
npm install
|
|
120
|
-
# One-command startup: build web → migrate D1 → start on 0.0.0.0:8787
|
|
121
120
|
./scripts/dev.sh
|
|
122
121
|
```
|
|
123
122
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
npm run build -w packages/web # Build the React frontend
|
|
128
|
-
npm run db:migrate # Apply D1 migrations (local)
|
|
129
|
-
npx wrangler dev --config wrangler.toml --ip 0.0.0.0 # Start on port 8787
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Open `http://localhost:8787` in your browser.
|
|
123
|
+
One command does everything: build frontend → migrate database → start server → launch Mock AI → open browser with auto-login. No environment variables to set, no separate terminals to manage.
|
|
133
124
|
|
|
134
125
|
Other dev commands:
|
|
135
126
|
|
|
136
127
|
```bash
|
|
137
|
-
./scripts/dev.sh reset # Nuke local DB → re-migrate → start
|
|
128
|
+
./scripts/dev.sh reset # Nuke local DB → re-migrate → start full dev env
|
|
129
|
+
./scripts/dev.sh server # Server only (no mock AI, no browser)
|
|
130
|
+
./scripts/dev.sh mock # Start mock OpenClaw standalone (foreground)
|
|
138
131
|
./scripts/dev.sh migrate # Only run D1 migrations
|
|
139
132
|
./scripts/dev.sh build # Only build web frontend
|
|
140
133
|
./scripts/dev.sh sync # Sync plugin to remote OpenClaw host + restart gateway
|
|
@@ -282,16 +275,19 @@ openclaw plugins remove botschat
|
|
|
282
275
|
|
|
283
276
|
## Development
|
|
284
277
|
|
|
285
|
-
|
|
278
|
+
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full development guide — local setup, Mock OpenClaw for testing, architecture principles, WebSocket protocol, database migrations, and more.
|
|
279
|
+
|
|
280
|
+
### Quick Start
|
|
286
281
|
|
|
287
282
|
```bash
|
|
288
|
-
|
|
283
|
+
./scripts/dev.sh # Build + migrate + start server + mock AI + open browser
|
|
289
284
|
```
|
|
290
285
|
|
|
291
|
-
###
|
|
286
|
+
### Build
|
|
292
287
|
|
|
293
288
|
```bash
|
|
294
|
-
npm run
|
|
289
|
+
npm run build -w packages/web # Build frontend
|
|
290
|
+
npm run build -w packages/plugin # Build plugin
|
|
295
291
|
```
|
|
296
292
|
|
|
297
293
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Push notification device tokens (FCM registration tokens)
|
|
2
|
+
CREATE TABLE IF NOT EXISTS push_tokens (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
5
|
+
token TEXT NOT NULL,
|
|
6
|
+
platform TEXT NOT NULL CHECK (platform IN ('web', 'ios', 'android')),
|
|
7
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
8
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
9
|
+
UNIQUE(user_id, token)
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_push_tokens_user ON push_tokens(user_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botschat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "A self-hosted chat interface for OpenClaw AI agents",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/*"
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
"db:migrate:remote": "wrangler d1 migrations apply botschat-db --remote",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
18
|
"build:plugin": "npm run build -w packages/plugin",
|
|
19
|
+
"ios:build": "npm run build -w packages/web && npx cap sync ios",
|
|
20
|
+
"ios:open": "npx cap open ios",
|
|
21
|
+
"ios:run": "npx cap run ios",
|
|
22
|
+
"ios:sync": "npx cap sync ios",
|
|
23
|
+
"android:build": "npm run build -w packages/web && npx cap sync android",
|
|
24
|
+
"android:open": "npx cap open android",
|
|
25
|
+
"android:run": "npx cap run android",
|
|
26
|
+
"android:sync": "npx cap sync android",
|
|
19
27
|
"test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
|
|
20
28
|
"test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
|
|
21
29
|
},
|
|
@@ -49,10 +57,21 @@
|
|
|
49
57
|
"url": "git+https://github.com/botschat-app/botsChat.git"
|
|
50
58
|
},
|
|
51
59
|
"devDependencies": {
|
|
60
|
+
"@capacitor/cli": "^8.1.0",
|
|
52
61
|
"typescript": "^5.7.0",
|
|
53
62
|
"wrangler": "^3.100.0"
|
|
54
63
|
},
|
|
55
64
|
"dependencies": {
|
|
65
|
+
"@capacitor/android": "^8.1.0",
|
|
66
|
+
"@capacitor/app": "^8.0.1",
|
|
67
|
+
"@capacitor/core": "^8.1.0",
|
|
68
|
+
"@capacitor/haptics": "^8.0.0",
|
|
69
|
+
"@capacitor/ios": "^8.1.0",
|
|
70
|
+
"@capacitor/keyboard": "^8.0.0",
|
|
71
|
+
"@capacitor/push-notifications": "^8.0.1",
|
|
72
|
+
"@capacitor/splash-screen": "^8.0.1",
|
|
73
|
+
"@capacitor/status-bar": "^8.0.1",
|
|
74
|
+
"@capgo/capacitor-social-login": "^8.3.1",
|
|
56
75
|
"react-resizable-panels": "^4.6.2"
|
|
57
76
|
}
|
|
58
77
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Env } from "../env.js";
|
|
2
|
-
import { verifyToken, getJwtSecret } from "../utils/auth.js";
|
|
2
|
+
import { verifyToken, getJwtSecret, signMediaUrl } from "../utils/auth.js";
|
|
3
|
+
import { getFcmAccessToken, sendPushNotification } from "../utils/fcm.js";
|
|
3
4
|
import { generateId as generateIdUtil } from "../utils/id.js";
|
|
4
5
|
import { randomUUID } from "../utils/uuid.js";
|
|
5
6
|
|
|
@@ -27,6 +28,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
27
28
|
/** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
|
|
28
29
|
private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
|
|
29
30
|
|
|
31
|
+
/** Browser sessions that report themselves in foreground (push notifications are suppressed). */
|
|
32
|
+
private foregroundSessions = new Set<string>();
|
|
33
|
+
|
|
30
34
|
constructor(state: DurableObjectState, env: Env) {
|
|
31
35
|
this.state = state;
|
|
32
36
|
this.env = env;
|
|
@@ -115,7 +119,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
115
119
|
JSON.stringify({ type: "openclaw.disconnected" }),
|
|
116
120
|
);
|
|
117
121
|
}
|
|
118
|
-
//
|
|
122
|
+
// Clean up foreground tracking for browser sessions
|
|
123
|
+
if (tag?.startsWith("browser:")) {
|
|
124
|
+
this.foregroundSessions.delete(tag);
|
|
125
|
+
}
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/** Called when a WebSocket encounters an error. */
|
|
@@ -313,6 +320,17 @@ export class ConnectionDO implements DurableObject {
|
|
|
313
320
|
console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
|
|
314
321
|
}
|
|
315
322
|
this.broadcastToBrowsers(JSON.stringify(msg));
|
|
323
|
+
|
|
324
|
+
// Send push notification if no browser session is in foreground
|
|
325
|
+
if (
|
|
326
|
+
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
|
|
327
|
+
this.foregroundSessions.size === 0 &&
|
|
328
|
+
this.env.FCM_SERVICE_ACCOUNT_JSON
|
|
329
|
+
) {
|
|
330
|
+
this.sendPushNotifications(msg).catch((err) => {
|
|
331
|
+
console.error("[DO] Push notification failed:", err);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
316
334
|
}
|
|
317
335
|
|
|
318
336
|
private async handleBrowserMessage(
|
|
@@ -370,6 +388,18 @@ export class ConnectionDO implements DurableObject {
|
|
|
370
388
|
return;
|
|
371
389
|
}
|
|
372
390
|
|
|
391
|
+
// Handle foreground/background state tracking for push notifications
|
|
392
|
+
if (msg.type === "foreground.enter") {
|
|
393
|
+
const tag = attachment.tag;
|
|
394
|
+
if (tag) this.foregroundSessions.add(tag);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (msg.type === "foreground.leave") {
|
|
398
|
+
const tag = attachment.tag;
|
|
399
|
+
if (tag) this.foregroundSessions.delete(tag);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
373
403
|
// Persist user messages to D1
|
|
374
404
|
if (msg.type === "user.message") {
|
|
375
405
|
console.log("[DO] User inbound:", JSON.stringify({
|
|
@@ -564,6 +594,65 @@ export class ConnectionDO implements DurableObject {
|
|
|
564
594
|
}
|
|
565
595
|
}
|
|
566
596
|
|
|
597
|
+
// ---- Push notifications ----
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Send push notifications to all of the user's registered devices.
|
|
601
|
+
* Called when an agent message arrives and no browser session is in foreground.
|
|
602
|
+
* Sends data-only FCM messages so clients can decrypt E2E content before showing.
|
|
603
|
+
*/
|
|
604
|
+
private async sendPushNotifications(msg: Record<string, unknown>): Promise<void> {
|
|
605
|
+
const userId = await this.state.storage.get<string>("userId");
|
|
606
|
+
if (!userId) return;
|
|
607
|
+
|
|
608
|
+
const { results } = await this.env.DB.prepare(
|
|
609
|
+
"SELECT id, token, platform FROM push_tokens WHERE user_id = ?",
|
|
610
|
+
)
|
|
611
|
+
.bind(userId)
|
|
612
|
+
.all<{ id: string; token: string; platform: string }>();
|
|
613
|
+
|
|
614
|
+
if (!results || results.length === 0) return;
|
|
615
|
+
|
|
616
|
+
// Build data payload — includes ciphertext so the client can decrypt
|
|
617
|
+
const data: Record<string, string> = {
|
|
618
|
+
type: msg.type as string,
|
|
619
|
+
sessionKey: (msg.sessionKey as string) ?? "",
|
|
620
|
+
messageId: (msg.messageId as string) ?? "",
|
|
621
|
+
encrypted: msg.encrypted ? "1" : "0",
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
if (msg.type === "agent.text") {
|
|
625
|
+
data.text = (msg.text as string) ?? "";
|
|
626
|
+
} else if (msg.type === "agent.media") {
|
|
627
|
+
data.text = (msg.caption as string) || "";
|
|
628
|
+
data.mediaUrl = (msg.mediaUrl as string) ?? "";
|
|
629
|
+
} else if (msg.type === "agent.a2ui") {
|
|
630
|
+
data.text = "New interactive message";
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON!);
|
|
634
|
+
const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
|
|
635
|
+
|
|
636
|
+
const invalidTokenIds: string[] = [];
|
|
637
|
+
await Promise.allSettled(
|
|
638
|
+
results.map(async (row) => {
|
|
639
|
+
const ok = await sendPushNotification({
|
|
640
|
+
accessToken,
|
|
641
|
+
projectId,
|
|
642
|
+
fcmToken: row.token,
|
|
643
|
+
data,
|
|
644
|
+
});
|
|
645
|
+
if (!ok) invalidTokenIds.push(row.id);
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// Clean up invalid/expired tokens
|
|
650
|
+
for (const id of invalidTokenIds) {
|
|
651
|
+
await this.env.DB.prepare("DELETE FROM push_tokens WHERE id = ?").bind(id).run();
|
|
652
|
+
console.log(`[DO] Removed invalid push token: ${id}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
567
656
|
// ---- Media caching ----
|
|
568
657
|
|
|
569
658
|
// ---- SSRF protection ----
|
|
@@ -639,16 +728,11 @@ export class ConnectionDO implements DurableObject {
|
|
|
639
728
|
return null;
|
|
640
729
|
}
|
|
641
730
|
|
|
642
|
-
const contentType = response.headers.get("Content-Type") ?? "
|
|
643
|
-
// Validate that the response is actually an image
|
|
644
|
-
if (!contentType.startsWith("image/")) {
|
|
645
|
-
console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
731
|
+
const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
|
|
648
732
|
|
|
649
|
-
// Reject SVG (can contain scripts — XSS vector)
|
|
650
|
-
if (contentType.includes("svg")) {
|
|
651
|
-
console.warn(`[DO] cacheExternalMedia: blocked
|
|
733
|
+
// Reject SVG (can contain scripts — XSS vector) and executable types
|
|
734
|
+
if (contentType.includes("svg") || contentType.includes("javascript") || contentType.includes("executable")) {
|
|
735
|
+
console.warn(`[DO] cacheExternalMedia: blocked dangerous Content-Type "${contentType}" from ${url.slice(0, 120)}`);
|
|
652
736
|
return null;
|
|
653
737
|
}
|
|
654
738
|
|
|
@@ -670,14 +754,18 @@ export class ConnectionDO implements DurableObject {
|
|
|
670
754
|
return null;
|
|
671
755
|
}
|
|
672
756
|
|
|
673
|
-
// Determine extension from Content-Type
|
|
757
|
+
// Determine extension from Content-Type
|
|
674
758
|
const extMap: Record<string, string> = {
|
|
675
759
|
"image/png": "png",
|
|
676
760
|
"image/jpeg": "jpg",
|
|
677
761
|
"image/gif": "gif",
|
|
678
762
|
"image/webp": "webp",
|
|
763
|
+
"application/pdf": "pdf",
|
|
764
|
+
"audio/mpeg": "mp3",
|
|
765
|
+
"audio/wav": "wav",
|
|
766
|
+
"video/mp4": "mp4",
|
|
679
767
|
};
|
|
680
|
-
const ext = extMap[contentType] ?? "png";
|
|
768
|
+
const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
|
|
681
769
|
const key = `media/${userId}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
|
682
770
|
|
|
683
771
|
// Upload to R2
|
|
@@ -685,7 +773,11 @@ export class ConnectionDO implements DurableObject {
|
|
|
685
773
|
httpMetadata: { contentType },
|
|
686
774
|
});
|
|
687
775
|
|
|
688
|
-
|
|
776
|
+
// Sign the cached media URL so the browser can fetch it without Bearer auth.
|
|
777
|
+
// key is "media/{userId}/{filename}" — extract just the filename part.
|
|
778
|
+
const cachedFilename = key.replace(`media/${userId}/`, "");
|
|
779
|
+
const secret = getJwtSecret(this.env);
|
|
780
|
+
const localUrl = await signMediaUrl(userId, cachedFilename, secret, 3600);
|
|
689
781
|
console.log(`[DO] cacheExternalMedia: OK ${url.slice(0, 80)} → ${localUrl} (${body.byteLength} bytes)`);
|
|
690
782
|
return localUrl;
|
|
691
783
|
} catch (err) {
|
|
@@ -694,6 +786,21 @@ export class ConnectionDO implements DurableObject {
|
|
|
694
786
|
}
|
|
695
787
|
}
|
|
696
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Re-sign a media URL with a fresh signature. Handles both relative
|
|
791
|
+
* (/api/media/...) and absolute (https://host/api/media/...) URLs.
|
|
792
|
+
* Returns a freshly signed relative URL, or the original if not a media URL.
|
|
793
|
+
*/
|
|
794
|
+
private async refreshMediaUrl(url: string, secret: string): Promise<string> {
|
|
795
|
+
// Extract userId and filename from /api/media/:userId/:filename patterns
|
|
796
|
+
const match = url.match(/\/api\/media\/([^/?]+)\/([^?]+)/);
|
|
797
|
+
if (!match) return url; // Not a media URL — return as-is
|
|
798
|
+
|
|
799
|
+
const userId = decodeURIComponent(match[1]);
|
|
800
|
+
const filename = decodeURIComponent(match[2]);
|
|
801
|
+
return signMediaUrl(userId, filename, secret, 3600);
|
|
802
|
+
}
|
|
803
|
+
|
|
697
804
|
// ---- Message persistence ----
|
|
698
805
|
|
|
699
806
|
private async persistMessage(opts: {
|
|
@@ -785,16 +892,27 @@ export class ConnectionDO implements DurableObject {
|
|
|
785
892
|
}
|
|
786
893
|
}
|
|
787
894
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
895
|
+
// Re-sign media URLs so they're always fresh when loading history.
|
|
896
|
+
// Stored URLs may have expired signatures or no signature at all.
|
|
897
|
+
const secret = getJwtSecret(this.env);
|
|
898
|
+
const messages = await Promise.all(
|
|
899
|
+
(result.results ?? []).map(async (row: Record<string, unknown>) => {
|
|
900
|
+
let mediaUrl = (row.media_url as string | null) ?? undefined;
|
|
901
|
+
if (mediaUrl) {
|
|
902
|
+
mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
id: row.id,
|
|
906
|
+
sender: row.sender,
|
|
907
|
+
text: row.text ?? "",
|
|
908
|
+
timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
|
|
909
|
+
mediaUrl,
|
|
910
|
+
a2ui: row.a2ui ?? undefined,
|
|
911
|
+
threadId: row.thread_id ?? undefined,
|
|
912
|
+
encrypted: row.encrypted ?? 0,
|
|
913
|
+
};
|
|
914
|
+
}),
|
|
915
|
+
);
|
|
798
916
|
|
|
799
917
|
return Response.json({ messages, replyCounts });
|
|
800
918
|
} catch (err) {
|
package/packages/api/src/env.ts
CHANGED
|
@@ -6,6 +6,12 @@ export type Env = {
|
|
|
6
6
|
ENVIRONMENT: string;
|
|
7
7
|
JWT_SECRET?: string;
|
|
8
8
|
FIREBASE_PROJECT_ID?: string;
|
|
9
|
+
GOOGLE_WEB_CLIENT_ID?: string;
|
|
10
|
+
GOOGLE_IOS_CLIENT_ID?: string;
|
|
9
11
|
/** Canonical public URL override — if set, always use this as cloudUrl. */
|
|
10
12
|
PUBLIC_URL?: string;
|
|
13
|
+
/** Secret for dev-token auth bypass (automated testing). Endpoint is 404 when unset. */
|
|
14
|
+
DEV_AUTH_SECRET?: string;
|
|
15
|
+
/** FCM Service Account JSON for push notifications (stored as secret via `wrangler secret put`). */
|
|
16
|
+
FCM_SERVICE_ACCOUNT_JSON?: string;
|
|
11
17
|
};
|
|
@@ -11,7 +11,9 @@ import { models } from "./routes/models.js";
|
|
|
11
11
|
import { pairing } from "./routes/pairing.js";
|
|
12
12
|
import { sessions } from "./routes/sessions.js";
|
|
13
13
|
import { upload } from "./routes/upload.js";
|
|
14
|
+
import { push } from "./routes/push.js";
|
|
14
15
|
import { setup } from "./routes/setup.js";
|
|
16
|
+
import { devAuth } from "./routes/dev-auth.js";
|
|
15
17
|
|
|
16
18
|
// Re-export the Durable Object class so wrangler can find it
|
|
17
19
|
export { ConnectionDO } from "./do/connection-do.js";
|
|
@@ -23,6 +25,9 @@ const PRODUCTION_ORIGINS = [
|
|
|
23
25
|
"https://console.botschat.app",
|
|
24
26
|
"https://botschat.app",
|
|
25
27
|
"https://botschat-api.auxtenwpc.workers.dev",
|
|
28
|
+
"capacitor://localhost", // iOS Capacitor app
|
|
29
|
+
"http://localhost", // Android Capacitor app (http scheme)
|
|
30
|
+
"https://localhost", // Android Capacitor app (https scheme)
|
|
26
31
|
];
|
|
27
32
|
|
|
28
33
|
// CORS and security headers — skip for WebSocket upgrade requests
|
|
@@ -81,6 +86,7 @@ app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
|
|
|
81
86
|
|
|
82
87
|
// ---- Public routes (no auth) ----
|
|
83
88
|
app.route("/api/auth", auth);
|
|
89
|
+
app.route("/api/dev-auth", devAuth);
|
|
84
90
|
app.route("/api/setup", setup);
|
|
85
91
|
|
|
86
92
|
// ---- Protected routes (require Bearer token) ----
|
|
@@ -271,6 +277,7 @@ protectedApp.route("/channels/:channelId/tasks/:taskId/jobs", jobs);
|
|
|
271
277
|
// Nested session routes under /api/channels/:channelId/sessions
|
|
272
278
|
protectedApp.route("/channels/:channelId/sessions", sessions);
|
|
273
279
|
protectedApp.route("/pairing-tokens", pairing);
|
|
280
|
+
protectedApp.route("/push-tokens", push);
|
|
274
281
|
protectedApp.route("/upload", upload);
|
|
275
282
|
|
|
276
283
|
// ---- Media serving route (signed URL or Bearer auth) ----
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Env } from "../env.js";
|
|
3
3
|
import { createToken, createRefreshToken, verifyRefreshToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
|
|
4
|
-
import {
|
|
4
|
+
import { verifyAnyGoogleToken } from "../utils/firebase.js";
|
|
5
5
|
import { generateId } from "../utils/id.js";
|
|
6
6
|
|
|
7
7
|
const auth = new Hono<{ Bindings: Env }>();
|
|
@@ -140,20 +140,21 @@ async function handleFirebaseAuth(c: {
|
|
|
140
140
|
return c.json({ error: "Firebase sign-in is not configured" }, 500);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
// 1. Verify the Firebase
|
|
143
|
+
// 1. Verify the ID token (Firebase or native Google)
|
|
144
|
+
// Allowed Google client IDs for native iOS/Android sign-in
|
|
145
|
+
const allowedGoogleClientIds = [
|
|
146
|
+
c.env.GOOGLE_WEB_CLIENT_ID, // Web Client ID (iOSServerClientId)
|
|
147
|
+
c.env.GOOGLE_IOS_CLIENT_ID, // iOS Client ID
|
|
148
|
+
].filter(Boolean) as string[];
|
|
149
|
+
|
|
144
150
|
let firebaseUser;
|
|
145
151
|
try {
|
|
146
|
-
firebaseUser = await
|
|
152
|
+
firebaseUser = await verifyAnyGoogleToken(idToken, projectId, allowedGoogleClientIds);
|
|
147
153
|
} catch (err) {
|
|
148
154
|
const msg = err instanceof Error ? err.message : "Token verification failed";
|
|
149
155
|
return c.json({ error: msg }, 401);
|
|
150
156
|
}
|
|
151
157
|
|
|
152
|
-
const email = firebaseUser.email?.toLowerCase();
|
|
153
|
-
if (!email) {
|
|
154
|
-
return c.json({ error: "Account has no email address" }, 400);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
158
|
const firebaseUid = firebaseUser.sub;
|
|
158
159
|
// Determine provider from Firebase token (google.com, github.com, etc.)
|
|
159
160
|
const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
|
|
@@ -161,7 +162,21 @@ async function handleFirebaseAuth(c: {
|
|
|
161
162
|
? "google"
|
|
162
163
|
: signInProvider.includes("github")
|
|
163
164
|
? "github"
|
|
164
|
-
: signInProvider
|
|
165
|
+
: signInProvider.includes("apple")
|
|
166
|
+
? "apple"
|
|
167
|
+
: signInProvider;
|
|
168
|
+
|
|
169
|
+
// Apple Sign-In may hide the user's real email; generate a short placeholder
|
|
170
|
+
let email = firebaseUser.email?.toLowerCase() || null;
|
|
171
|
+
if (!email && authProvider === "apple") {
|
|
172
|
+
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(firebaseUid))))
|
|
173
|
+
.slice(0, 6).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
174
|
+
email = `apple_${hash}@privaterelay.appleid.com`;
|
|
175
|
+
}
|
|
176
|
+
if (!email) {
|
|
177
|
+
return c.json({ error: "Account has no email address" }, 400);
|
|
178
|
+
}
|
|
179
|
+
|
|
165
180
|
const displayName = firebaseUser.name ?? email.split("@")[0];
|
|
166
181
|
|
|
167
182
|
// 2. Look up existing user by firebase_uid first, then by email
|
|
@@ -235,6 +250,44 @@ async function handleFirebaseAuth(c: {
|
|
|
235
250
|
auth.post("/firebase", (c) => handleFirebaseAuth(c));
|
|
236
251
|
auth.post("/google", (c) => handleFirebaseAuth(c));
|
|
237
252
|
auth.post("/github", (c) => handleFirebaseAuth(c));
|
|
253
|
+
auth.post("/apple", (c) => handleFirebaseAuth(c));
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* POST /api/auth/dev-login — development-only passwordless login by email.
|
|
257
|
+
* Used for mobile debugging when OAuth is not yet working.
|
|
258
|
+
*/
|
|
259
|
+
auth.post("/dev-login", async (c) => {
|
|
260
|
+
if (c.env.ENVIRONMENT !== "development") {
|
|
261
|
+
return c.json({ error: "Dev login is only available in development mode" }, 403);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const { email } = await c.req.json<{ email: string }>();
|
|
265
|
+
if (!email?.trim()) {
|
|
266
|
+
return c.json({ error: "email is required" }, 400);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const row = await c.env.DB.prepare(
|
|
270
|
+
"SELECT id, email, display_name FROM users WHERE email = ?",
|
|
271
|
+
)
|
|
272
|
+
.bind(email.trim().toLowerCase())
|
|
273
|
+
.first<{ id: string; email: string; display_name: string | null }>();
|
|
274
|
+
|
|
275
|
+
if (!row) {
|
|
276
|
+
return c.json({ error: "User not found" }, 404);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const secret = getJwtSecret(c.env);
|
|
280
|
+
const token = await createToken(row.id, secret);
|
|
281
|
+
const refreshToken = await createRefreshToken(row.id, secret);
|
|
282
|
+
|
|
283
|
+
return c.json({
|
|
284
|
+
id: row.id,
|
|
285
|
+
email: row.email,
|
|
286
|
+
displayName: row.display_name,
|
|
287
|
+
token,
|
|
288
|
+
refreshToken,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
238
291
|
|
|
239
292
|
/** POST /api/auth/refresh — exchange a refresh token for a new access token */
|
|
240
293
|
auth.post("/refresh", async (c) => {
|
|
@@ -264,6 +317,7 @@ auth.get("/config", (c) => {
|
|
|
264
317
|
emailEnabled: isDev,
|
|
265
318
|
googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
266
319
|
githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
320
|
+
appleEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
267
321
|
});
|
|
268
322
|
});
|
|
269
323
|
|
|
@@ -299,4 +353,26 @@ auth.get("/me", async (c) => {
|
|
|
299
353
|
});
|
|
300
354
|
});
|
|
301
355
|
|
|
356
|
+
/** DELETE /api/auth/account — permanently delete the authenticated user's account and all data */
|
|
357
|
+
auth.delete("/account", async (c) => {
|
|
358
|
+
const userId = c.get("userId" as never) as string;
|
|
359
|
+
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
|
360
|
+
|
|
361
|
+
// Delete all user media from R2
|
|
362
|
+
const prefix = `${userId}/`;
|
|
363
|
+
let cursor: string | undefined;
|
|
364
|
+
do {
|
|
365
|
+
const listed = await c.env.MEDIA.list({ prefix, cursor });
|
|
366
|
+
if (listed.objects.length > 0) {
|
|
367
|
+
await Promise.all(listed.objects.map(obj => c.env.MEDIA.delete(obj.key)));
|
|
368
|
+
}
|
|
369
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
370
|
+
} while (cursor);
|
|
371
|
+
|
|
372
|
+
// Delete user record — all related tables use ON DELETE CASCADE
|
|
373
|
+
await c.env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId).run();
|
|
374
|
+
|
|
375
|
+
return c.json({ ok: true });
|
|
376
|
+
});
|
|
377
|
+
|
|
302
378
|
export { auth };
|
|
@@ -74,10 +74,11 @@ channels.post("/", async (c) => {
|
|
|
74
74
|
.bind(taskId, id, "Ad Hoc Chat", "adhoc", sessionKey)
|
|
75
75
|
.run();
|
|
76
76
|
|
|
77
|
-
// Auto-create a default session
|
|
77
|
+
// Auto-create a default session (INSERT OR IGNORE to handle duplicate session_key
|
|
78
|
+
// gracefully — can happen if user re-creates a channel with the same name)
|
|
78
79
|
const sessionId = generateId("ses_");
|
|
79
80
|
await c.env.DB.prepare(
|
|
80
|
-
"INSERT INTO sessions (id, channel_id, user_id, name, session_key) VALUES (?, ?, ?, ?, ?)",
|
|
81
|
+
"INSERT OR IGNORE INTO sessions (id, channel_id, user_id, name, session_key) VALUES (?, ?, ?, ?, ?)",
|
|
81
82
|
)
|
|
82
83
|
.bind(sessionId, id, userId, "Session 1", sessionKey)
|
|
83
84
|
.run();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../env.js";
|
|
3
|
+
import { createToken, getJwtSecret } from "../utils/auth.js";
|
|
4
|
+
import { generateId } from "../utils/id.js";
|
|
5
|
+
|
|
6
|
+
const devAuth = new Hono<{ Bindings: Env }>();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/dev-auth/login — secret-gated dev login for automated testing.
|
|
10
|
+
* Returns 404 when DEV_AUTH_SECRET is not configured (endpoint invisible).
|
|
11
|
+
* Auto-creates the user record in D1 if it doesn't exist (upsert).
|
|
12
|
+
*/
|
|
13
|
+
devAuth.post("/login", async (c) => {
|
|
14
|
+
const devSecret = c.env.DEV_AUTH_SECRET;
|
|
15
|
+
if (!devSecret || c.env.ENVIRONMENT !== "development") {
|
|
16
|
+
return c.json({ error: "Not found" }, 404);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { secret, userId: requestedUserId } = await c.req.json<{ secret: string; userId?: string }>();
|
|
20
|
+
if (!secret || secret !== devSecret) {
|
|
21
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const userId = requestedUserId || "dev-test-user";
|
|
25
|
+
const jwtSecret = getJwtSecret(c.env);
|
|
26
|
+
const token = await createToken(userId, jwtSecret);
|
|
27
|
+
|
|
28
|
+
// Ensure the user exists in D1 (upsert) so foreign key constraints are satisfied.
|
|
29
|
+
// Dev-auth users get a placeholder email and no password (login only via dev-auth).
|
|
30
|
+
try {
|
|
31
|
+
const existing = await c.env.DB.prepare("SELECT id FROM users WHERE id = ?").bind(userId).first();
|
|
32
|
+
if (!existing) {
|
|
33
|
+
const email = `${userId}@dev.botschat.test`;
|
|
34
|
+
await c.env.DB.prepare(
|
|
35
|
+
"INSERT INTO users (id, email, password_hash, display_name) VALUES (?, ?, '', ?)",
|
|
36
|
+
).bind(userId, email, userId).run();
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error("[dev-auth] Failed to upsert user:", err);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return c.json({ token, userId });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export { devAuth };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../env.js";
|
|
3
|
+
import { generateId } from "../utils/id.js";
|
|
4
|
+
|
|
5
|
+
const push = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
|
|
6
|
+
|
|
7
|
+
/** POST /api/push-tokens — register or update a device push token */
|
|
8
|
+
push.post("/", async (c) => {
|
|
9
|
+
const userId = c.get("userId");
|
|
10
|
+
const { token, platform } = await c.req.json<{ token: string; platform: string }>();
|
|
11
|
+
|
|
12
|
+
if (!token || !platform) {
|
|
13
|
+
return c.json({ error: "Missing token or platform" }, 400);
|
|
14
|
+
}
|
|
15
|
+
if (!["web", "ios", "android"].includes(platform)) {
|
|
16
|
+
return c.json({ error: "Invalid platform (web | ios | android)" }, 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const id = generateId("pt_");
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
|
|
22
|
+
// Upsert: if the same user+token already exists, update the timestamp
|
|
23
|
+
await c.env.DB.prepare(
|
|
24
|
+
`INSERT INTO push_tokens (id, user_id, token, platform, created_at, updated_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
26
|
+
ON CONFLICT(user_id, token) DO UPDATE SET platform = excluded.platform, updated_at = excluded.updated_at`,
|
|
27
|
+
)
|
|
28
|
+
.bind(id, userId, token, platform, now, now)
|
|
29
|
+
.run();
|
|
30
|
+
|
|
31
|
+
return c.json({ ok: true, id }, 201);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** DELETE /api/push-tokens — unregister a device push token */
|
|
35
|
+
push.delete("/", async (c) => {
|
|
36
|
+
const userId = c.get("userId");
|
|
37
|
+
const { token } = await c.req.json<{ token: string }>().catch(() => ({ token: "" }));
|
|
38
|
+
|
|
39
|
+
if (!token) {
|
|
40
|
+
return c.json({ error: "Missing token" }, 400);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await c.env.DB.prepare(
|
|
44
|
+
"DELETE FROM push_tokens WHERE user_id = ? AND token = ?",
|
|
45
|
+
)
|
|
46
|
+
.bind(userId, token)
|
|
47
|
+
.run();
|
|
48
|
+
|
|
49
|
+
return c.json({ ok: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export { push };
|