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.
Files changed (56) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +20 -1
  4. package/packages/api/src/do/connection-do.ts +142 -24
  5. package/packages/api/src/env.ts +6 -0
  6. package/packages/api/src/index.ts +7 -0
  7. package/packages/api/src/routes/auth.ts +85 -9
  8. package/packages/api/src/routes/channels.ts +3 -2
  9. package/packages/api/src/routes/dev-auth.ts +45 -0
  10. package/packages/api/src/routes/push.ts +52 -0
  11. package/packages/api/src/routes/upload.ts +73 -38
  12. package/packages/api/src/utils/fcm.ts +167 -0
  13. package/packages/api/src/utils/firebase.ts +218 -0
  14. package/packages/plugin/dist/src/channel.d.ts +6 -0
  15. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  16. package/packages/plugin/dist/src/channel.js +71 -15
  17. package/packages/plugin/dist/src/channel.js.map +1 -1
  18. package/packages/plugin/package.json +1 -1
  19. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  20. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  21. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
  22. package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
  23. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  24. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  25. package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
  26. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  27. package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
  28. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  29. package/packages/web/dist/index.html +6 -4
  30. package/packages/web/dist/sw.js +158 -1
  31. package/packages/web/index.html +4 -2
  32. package/packages/web/package.json +4 -1
  33. package/packages/web/src/App.tsx +117 -1
  34. package/packages/web/src/api.ts +21 -1
  35. package/packages/web/src/components/AccountSettings.tsx +131 -0
  36. package/packages/web/src/components/ChatWindow.tsx +302 -70
  37. package/packages/web/src/components/CronSidebar.tsx +89 -24
  38. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  39. package/packages/web/src/components/LoginPage.tsx +55 -7
  40. package/packages/web/src/components/MessageContent.tsx +71 -9
  41. package/packages/web/src/components/MobileLayout.tsx +28 -118
  42. package/packages/web/src/components/SessionTabs.tsx +41 -2
  43. package/packages/web/src/components/Sidebar.tsx +88 -66
  44. package/packages/web/src/e2e.ts +26 -5
  45. package/packages/web/src/firebase.ts +215 -3
  46. package/packages/web/src/foreground.ts +51 -0
  47. package/packages/web/src/index.css +10 -2
  48. package/packages/web/src/main.tsx +24 -2
  49. package/packages/web/src/push.ts +205 -0
  50. package/packages/web/src/ws.ts +20 -8
  51. package/scripts/dev.sh +158 -26
  52. package/scripts/mock-openclaw.mjs +382 -0
  53. package/scripts/test-e2e-chat.ts +2 -2
  54. package/scripts/test-e2e-live.ts +1 -1
  55. package/wrangler.toml +3 -0
  56. 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
- Or step by step:
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
- ### Build the plugin
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
- npm run build:plugin
283
+ ./scripts/dev.sh # Build + migrate + start server + mock AI + open browser
289
284
  ```
290
285
 
291
- ### Type-check everything
286
+ ### Build
292
287
 
293
288
  ```bash
294
- npm run typecheck
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.10",
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
- // No explicit cleanup needed the runtime manages the socket list
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") ?? "image/png";
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 SVG content from ${url.slice(0, 120)}`);
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 (no SVG)
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
- const localUrl = `/api/media/${key.replace("media/", "")}`;
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
- const messages = (result.results ?? []).map((row: Record<string, unknown>) => ({
789
- id: row.id,
790
- sender: row.sender,
791
- text: row.text ?? "",
792
- timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
793
- mediaUrl: row.media_url ?? undefined,
794
- a2ui: row.a2ui ?? undefined,
795
- threadId: row.thread_id ?? undefined,
796
- encrypted: row.encrypted ?? 0,
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) {
@@ -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 { verifyFirebaseIdToken } from "../utils/firebase.js";
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 ID token
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 verifyFirebaseIdToken(idToken, projectId);
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 };