botschat 0.1.10 → 0.1.12

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 (41) hide show
  1. package/package.json +14 -1
  2. package/packages/api/src/do/connection-do.ts +52 -23
  3. package/packages/api/src/env.ts +4 -0
  4. package/packages/api/src/index.ts +4 -0
  5. package/packages/api/src/routes/auth.ts +46 -3
  6. package/packages/api/src/routes/channels.ts +3 -2
  7. package/packages/api/src/routes/dev-auth.ts +45 -0
  8. package/packages/api/src/routes/upload.ts +73 -38
  9. package/packages/api/src/utils/firebase.ts +130 -0
  10. package/packages/plugin/dist/src/channel.d.ts +6 -0
  11. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  12. package/packages/plugin/dist/src/channel.js +71 -15
  13. package/packages/plugin/dist/src/channel.js.map +1 -1
  14. package/packages/plugin/package.json +1 -1
  15. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-CCBhODDo.css} +1 -1
  16. package/packages/web/dist/assets/index-CCFgKLX_.js +1 -0
  17. package/packages/web/dist/assets/index-D8mBAwjS.js +1516 -0
  18. package/packages/web/dist/assets/index-Dx64BDkP.js +1 -0
  19. package/packages/web/dist/assets/index-E-nzPZl8.js +2 -0
  20. package/packages/web/dist/assets/web-DJQW-VLX.js +1 -0
  21. package/packages/web/dist/index.html +2 -2
  22. package/packages/web/package.json +4 -1
  23. package/packages/web/src/App.tsx +76 -0
  24. package/packages/web/src/api.ts +11 -1
  25. package/packages/web/src/components/ChatWindow.tsx +302 -70
  26. package/packages/web/src/components/CronSidebar.tsx +89 -24
  27. package/packages/web/src/components/LoginPage.tsx +9 -1
  28. package/packages/web/src/components/MessageContent.tsx +71 -9
  29. package/packages/web/src/components/MobileLayout.tsx +28 -118
  30. package/packages/web/src/components/SessionTabs.tsx +41 -2
  31. package/packages/web/src/components/Sidebar.tsx +88 -66
  32. package/packages/web/src/e2e.ts +26 -5
  33. package/packages/web/src/firebase.ts +127 -2
  34. package/packages/web/src/index.css +10 -2
  35. package/packages/web/src/main.tsx +23 -2
  36. package/packages/web/src/ws.ts +20 -8
  37. package/scripts/dev.sh +28 -22
  38. package/scripts/test-e2e-chat.ts +2 -2
  39. package/scripts/test-e2e-live.ts +1 -1
  40. package/wrangler.toml +3 -0
  41. package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -16,6 +16,10 @@
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",
19
23
  "test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
20
24
  "test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
21
25
  },
@@ -49,10 +53,19 @@
49
53
  "url": "git+https://github.com/botschat-app/botsChat.git"
50
54
  },
51
55
  "devDependencies": {
56
+ "@capacitor/cli": "^8.1.0",
52
57
  "typescript": "^5.7.0",
53
58
  "wrangler": "^3.100.0"
54
59
  },
55
60
  "dependencies": {
61
+ "@capacitor/app": "^8.0.1",
62
+ "@capacitor/core": "^8.1.0",
63
+ "@capacitor/haptics": "^8.0.0",
64
+ "@capacitor/ios": "^8.1.0",
65
+ "@capacitor/keyboard": "^8.0.0",
66
+ "@capacitor/splash-screen": "^8.0.1",
67
+ "@capacitor/status-bar": "^8.0.1",
68
+ "@capgo/capacitor-social-login": "^8.3.1",
56
69
  "react-resizable-panels": "^4.6.2"
57
70
  }
58
71
  }
@@ -1,5 +1,5 @@
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
3
  import { generateId as generateIdUtil } from "../utils/id.js";
4
4
  import { randomUUID } from "../utils/uuid.js";
5
5
 
@@ -639,16 +639,11 @@ export class ConnectionDO implements DurableObject {
639
639
  return null;
640
640
  }
641
641
 
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
- }
642
+ const contentType = response.headers.get("Content-Type") ?? "application/octet-stream";
648
643
 
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)}`);
644
+ // Reject SVG (can contain scripts — XSS vector) and executable types
645
+ if (contentType.includes("svg") || contentType.includes("javascript") || contentType.includes("executable")) {
646
+ console.warn(`[DO] cacheExternalMedia: blocked dangerous Content-Type "${contentType}" from ${url.slice(0, 120)}`);
652
647
  return null;
653
648
  }
654
649
 
@@ -670,14 +665,18 @@ export class ConnectionDO implements DurableObject {
670
665
  return null;
671
666
  }
672
667
 
673
- // Determine extension from Content-Type (no SVG)
668
+ // Determine extension from Content-Type
674
669
  const extMap: Record<string, string> = {
675
670
  "image/png": "png",
676
671
  "image/jpeg": "jpg",
677
672
  "image/gif": "gif",
678
673
  "image/webp": "webp",
674
+ "application/pdf": "pdf",
675
+ "audio/mpeg": "mp3",
676
+ "audio/wav": "wav",
677
+ "video/mp4": "mp4",
679
678
  };
680
- const ext = extMap[contentType] ?? "png";
679
+ const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
681
680
  const key = `media/${userId}/${Date.now()}-${randomUUID().slice(0, 8)}.${ext}`;
682
681
 
683
682
  // Upload to R2
@@ -685,7 +684,11 @@ export class ConnectionDO implements DurableObject {
685
684
  httpMetadata: { contentType },
686
685
  });
687
686
 
688
- const localUrl = `/api/media/${key.replace("media/", "")}`;
687
+ // Sign the cached media URL so the browser can fetch it without Bearer auth.
688
+ // key is "media/{userId}/{filename}" — extract just the filename part.
689
+ const cachedFilename = key.replace(`media/${userId}/`, "");
690
+ const secret = getJwtSecret(this.env);
691
+ const localUrl = await signMediaUrl(userId, cachedFilename, secret, 3600);
689
692
  console.log(`[DO] cacheExternalMedia: OK ${url.slice(0, 80)} → ${localUrl} (${body.byteLength} bytes)`);
690
693
  return localUrl;
691
694
  } catch (err) {
@@ -694,6 +697,21 @@ export class ConnectionDO implements DurableObject {
694
697
  }
695
698
  }
696
699
 
700
+ /**
701
+ * Re-sign a media URL with a fresh signature. Handles both relative
702
+ * (/api/media/...) and absolute (https://host/api/media/...) URLs.
703
+ * Returns a freshly signed relative URL, or the original if not a media URL.
704
+ */
705
+ private async refreshMediaUrl(url: string, secret: string): Promise<string> {
706
+ // Extract userId and filename from /api/media/:userId/:filename patterns
707
+ const match = url.match(/\/api\/media\/([^/?]+)\/([^?]+)/);
708
+ if (!match) return url; // Not a media URL — return as-is
709
+
710
+ const userId = decodeURIComponent(match[1]);
711
+ const filename = decodeURIComponent(match[2]);
712
+ return signMediaUrl(userId, filename, secret, 3600);
713
+ }
714
+
697
715
  // ---- Message persistence ----
698
716
 
699
717
  private async persistMessage(opts: {
@@ -785,16 +803,27 @@ export class ConnectionDO implements DurableObject {
785
803
  }
786
804
  }
787
805
 
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
- }));
806
+ // Re-sign media URLs so they're always fresh when loading history.
807
+ // Stored URLs may have expired signatures or no signature at all.
808
+ const secret = getJwtSecret(this.env);
809
+ const messages = await Promise.all(
810
+ (result.results ?? []).map(async (row: Record<string, unknown>) => {
811
+ let mediaUrl = (row.media_url as string | null) ?? undefined;
812
+ if (mediaUrl) {
813
+ mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
814
+ }
815
+ return {
816
+ id: row.id,
817
+ sender: row.sender,
818
+ text: row.text ?? "",
819
+ timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
820
+ mediaUrl,
821
+ a2ui: row.a2ui ?? undefined,
822
+ threadId: row.thread_id ?? undefined,
823
+ encrypted: row.encrypted ?? 0,
824
+ };
825
+ }),
826
+ );
798
827
 
799
828
  return Response.json({ messages, replyCounts });
800
829
  } catch (err) {
@@ -6,6 +6,10 @@ 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;
11
15
  };
@@ -12,6 +12,7 @@ import { pairing } from "./routes/pairing.js";
12
12
  import { sessions } from "./routes/sessions.js";
13
13
  import { upload } from "./routes/upload.js";
14
14
  import { setup } from "./routes/setup.js";
15
+ import { devAuth } from "./routes/dev-auth.js";
15
16
 
16
17
  // Re-export the Durable Object class so wrangler can find it
17
18
  export { ConnectionDO } from "./do/connection-do.js";
@@ -23,6 +24,8 @@ const PRODUCTION_ORIGINS = [
23
24
  "https://console.botschat.app",
24
25
  "https://botschat.app",
25
26
  "https://botschat-api.auxtenwpc.workers.dev",
27
+ "capacitor://localhost", // iOS Capacitor app
28
+ "http://localhost", // Android Capacitor app
26
29
  ];
27
30
 
28
31
  // CORS and security headers — skip for WebSocket upgrade requests
@@ -81,6 +84,7 @@ app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
81
84
 
82
85
  // ---- Public routes (no auth) ----
83
86
  app.route("/api/auth", auth);
87
+ app.route("/api/dev-auth", devAuth);
84
88
  app.route("/api/setup", setup);
85
89
 
86
90
  // ---- Protected routes (require Bearer token) ----
@@ -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,10 +140,16 @@ 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);
@@ -236,6 +242,43 @@ auth.post("/firebase", (c) => handleFirebaseAuth(c));
236
242
  auth.post("/google", (c) => handleFirebaseAuth(c));
237
243
  auth.post("/github", (c) => handleFirebaseAuth(c));
238
244
 
245
+ /**
246
+ * POST /api/auth/dev-login — development-only passwordless login by email.
247
+ * Used for mobile debugging when OAuth is not yet working.
248
+ */
249
+ auth.post("/dev-login", async (c) => {
250
+ if (c.env.ENVIRONMENT !== "development") {
251
+ return c.json({ error: "Dev login is only available in development mode" }, 403);
252
+ }
253
+
254
+ const { email } = await c.req.json<{ email: string }>();
255
+ if (!email?.trim()) {
256
+ return c.json({ error: "email is required" }, 400);
257
+ }
258
+
259
+ const row = await c.env.DB.prepare(
260
+ "SELECT id, email, display_name FROM users WHERE email = ?",
261
+ )
262
+ .bind(email.trim().toLowerCase())
263
+ .first<{ id: string; email: string; display_name: string | null }>();
264
+
265
+ if (!row) {
266
+ return c.json({ error: "User not found" }, 404);
267
+ }
268
+
269
+ const secret = getJwtSecret(c.env);
270
+ const token = await createToken(row.id, secret);
271
+ const refreshToken = await createRefreshToken(row.id, secret);
272
+
273
+ return c.json({
274
+ id: row.id,
275
+ email: row.email,
276
+ displayName: row.display_name,
277
+ token,
278
+ refreshToken,
279
+ });
280
+ });
281
+
239
282
  /** POST /api/auth/refresh — exchange a refresh token for a new access token */
240
283
  auth.post("/refresh", async (c) => {
241
284
  const { refreshToken } = await c.req.json<{ refreshToken: string }>();
@@ -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 };
@@ -8,50 +8,85 @@ export const upload = new Hono<{
8
8
  Variables: { userId: string };
9
9
  }>();
10
10
 
11
+ /** Allowed non-image MIME types for file attachments. */
12
+ const ALLOWED_FILE_TYPES = new Set([
13
+ "application/pdf",
14
+ "text/plain",
15
+ "text/csv",
16
+ "text/markdown",
17
+ "application/json",
18
+ "application/zip",
19
+ "application/gzip",
20
+ "application/x-tar",
21
+ "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/webm", "audio/aac",
22
+ "video/mp4", "video/webm", "video/quicktime",
23
+ ]);
24
+
25
+ /** Safe file extensions for each category. */
26
+ const SAFE_EXTENSIONS = new Set([
27
+ "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
28
+ "pdf", "txt", "csv", "md", "json", "zip", "gz", "tar",
29
+ "mp3", "wav", "ogg", "m4a", "aac", "webm",
30
+ "mp4", "mov",
31
+ ]);
32
+
11
33
  /** POST / — Upload a file to R2 and return a signed URL. */
12
34
  upload.post("/", async (c) => {
13
- const userId = c.get("userId");
14
- const contentType = c.req.header("Content-Type") ?? "";
35
+ try {
36
+ const userId = c.get("userId");
37
+ const contentType = c.req.header("Content-Type") ?? "";
15
38
 
16
- if (!contentType.includes("multipart/form-data")) {
17
- return c.json({ error: "Expected multipart/form-data" }, 400);
18
- }
39
+ if (!contentType.includes("multipart/form-data")) {
40
+ return c.json({ error: "Expected multipart/form-data" }, 400);
41
+ }
19
42
 
20
- const formData = await c.req.formData();
21
- const file = formData.get("file") as File | null;
43
+ const formData = await c.req.formData();
44
+ const file = formData.get("file") as File | null;
22
45
 
23
- if (!file) {
24
- return c.json({ error: "No file provided" }, 400);
25
- }
46
+ if (!file) {
47
+ return c.json({ error: "No file provided" }, 400);
48
+ }
26
49
 
27
- // Validate file type only raster images allowed (SVG is an XSS vector)
28
- if (!file.type.startsWith("image/") || file.type.includes("svg")) {
29
- return c.json({ error: "Only image files are allowed (SVG is not permitted)" }, 400);
30
- }
50
+ const fileType = file.type || "";
31
51
 
32
- // Limit file size to 10 MB
33
- const MAX_SIZE = 10 * 1024 * 1024;
34
- if (file.size > MAX_SIZE) {
35
- return c.json({ error: "File too large (max 10 MB)" }, 413);
36
- }
52
+ // Block SVG (XSS vector) and executables
53
+ if (fileType.includes("svg") || fileType.includes("executable") || fileType.includes("javascript")) {
54
+ return c.json({ error: "File type not permitted (SVG, executables, scripts are blocked)" }, 400);
55
+ }
37
56
 
38
- // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
39
- const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
40
- // SVG is excluded — it can contain <script> tags and is a known XSS vector
41
- const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"].includes(ext) ? ext : "png";
42
- const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
43
- const key = `media/${userId}/${filename}`;
44
-
45
- // Upload to R2
46
- await c.env.MEDIA.put(key, file.stream(), {
47
- httpMetadata: {
48
- contentType: file.type,
49
- },
50
- });
51
-
52
- // Return a signed URL (1 hour expiry)
53
- const secret = getJwtSecret(c.env);
54
- const url = await signMediaUrl(userId, filename, secret, 3600);
55
-
56
- return c.json({ url, key });
57
+ // Allow images (except SVG) and a curated set of other file types
58
+ const isImage = fileType.startsWith("image/");
59
+ const isAllowedFile = ALLOWED_FILE_TYPES.has(fileType);
60
+ if (!isImage && !isAllowedFile) {
61
+ return c.json({ error: `File type '${fileType}' is not supported` }, 400);
62
+ }
63
+
64
+ // Limit file size to 10 MB
65
+ const MAX_SIZE = 10 * 1024 * 1024;
66
+ if (file.size > MAX_SIZE) {
67
+ return c.json({ error: "File too large (max 10 MB)" }, 413);
68
+ }
69
+
70
+ // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
71
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
72
+ const safeExt = SAFE_EXTENSIONS.has(ext) ? ext : (isImage ? "png" : "bin");
73
+ const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
74
+ const key = `media/${userId}/${filename}`;
75
+
76
+ // Upload to R2
77
+ await c.env.MEDIA.put(key, file.stream(), {
78
+ httpMetadata: {
79
+ contentType: fileType || "application/octet-stream",
80
+ },
81
+ });
82
+
83
+ // Return a signed URL (1 hour expiry)
84
+ const secret = getJwtSecret(c.env);
85
+ const url = await signMediaUrl(userId, filename, secret, 3600);
86
+
87
+ return c.json({ url, key });
88
+ } catch (err) {
89
+ console.error("[upload] Error:", err);
90
+ return c.json({ error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }, 500);
91
+ }
57
92
  });
@@ -121,6 +121,136 @@ export async function verifyFirebaseIdToken(
121
121
  return verifyWithKey(matchingKey, signedContent, signatureBytes, payload, projectId);
122
122
  }
123
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Google ID Token verification (for native iOS/Android sign-in)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const GOOGLE_OAUTH_JWKS_URL =
129
+ "https://www.googleapis.com/oauth2/v3/certs";
130
+
131
+ let cachedGoogleOAuthKeys: JsonWebKey[] | null = null;
132
+ let cachedGoogleOAuthAt = 0;
133
+
134
+ async function getGoogleOAuthPublicKeys(): Promise<JsonWebKey[]> {
135
+ const now = Date.now();
136
+ if (cachedGoogleOAuthKeys && now - cachedGoogleOAuthAt < CACHE_TTL_MS) {
137
+ return cachedGoogleOAuthKeys;
138
+ }
139
+ const resp = await fetch(GOOGLE_OAUTH_JWKS_URL);
140
+ if (!resp.ok) {
141
+ throw new Error(`Failed to fetch Google OAuth JWKS: ${resp.status}`);
142
+ }
143
+ const jwks = (await resp.json()) as { keys: JsonWebKey[] };
144
+ cachedGoogleOAuthKeys = jwks.keys;
145
+ cachedGoogleOAuthAt = now;
146
+ return jwks.keys;
147
+ }
148
+
149
+ /**
150
+ * Verify a Google ID token (from native Google Sign-In) and return the payload.
151
+ * Google ID tokens have iss=accounts.google.com and aud=<web-client-id>.
152
+ */
153
+ export async function verifyGoogleIdToken(
154
+ idToken: string,
155
+ allowedClientIds: string[],
156
+ ): Promise<FirebaseTokenPayload> {
157
+ const { header, payload, signatureBytes, signedContent } =
158
+ parseJwtUnverified(idToken);
159
+
160
+ if (header.alg !== "RS256") {
161
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
162
+ }
163
+
164
+ // Find matching key from Google's OAuth JWKS
165
+ let keys = await getGoogleOAuthPublicKeys();
166
+ let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
167
+ if (!matchingKey) {
168
+ cachedGoogleOAuthKeys = null;
169
+ keys = await getGoogleOAuthPublicKeys();
170
+ matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
171
+ if (!matchingKey) {
172
+ throw new Error(`No matching Google OAuth key for kid: ${header.kid}`);
173
+ }
174
+ }
175
+
176
+ return verifyGoogleTokenWithKey(matchingKey, signedContent, signatureBytes, payload, allowedClientIds);
177
+ }
178
+
179
+ async function verifyGoogleTokenWithKey(
180
+ jwk: JsonWebKey,
181
+ signedContent: string,
182
+ signatureBytes: Uint8Array,
183
+ payload: FirebaseTokenPayload,
184
+ allowedClientIds: string[],
185
+ ): Promise<FirebaseTokenPayload> {
186
+ const key = await crypto.subtle.importKey(
187
+ "jwk", jwk,
188
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
189
+ false, ["verify"],
190
+ );
191
+
192
+ const valid = await crypto.subtle.verify(
193
+ "RSASSA-PKCS1-v1_5", key, signatureBytes,
194
+ new TextEncoder().encode(signedContent),
195
+ );
196
+ if (!valid) throw new Error("Invalid Google token signature");
197
+
198
+ const now = Math.floor(Date.now() / 1000);
199
+ if (payload.exp < now) throw new Error("Google token has expired");
200
+ if (payload.iat > now + 300) throw new Error("Google token issued in the future");
201
+
202
+ // Google ID tokens have iss = "accounts.google.com" or "https://accounts.google.com"
203
+ if (payload.iss !== "accounts.google.com" && payload.iss !== "https://accounts.google.com") {
204
+ throw new Error(`Invalid Google token issuer: ${payload.iss}`);
205
+ }
206
+
207
+ // Audience must be one of our allowed client IDs
208
+ if (!allowedClientIds.includes(payload.aud)) {
209
+ throw new Error(`Invalid Google token audience: ${payload.aud}`);
210
+ }
211
+
212
+ if (!payload.sub) throw new Error("Missing subject in Google token");
213
+
214
+ // Synthesize firebase-like fields so the rest of the auth flow works
215
+ if (!payload.firebase) {
216
+ payload.firebase = {
217
+ sign_in_provider: "google.com",
218
+ identities: { "google.com": [payload.sub] },
219
+ };
220
+ }
221
+
222
+ return payload;
223
+ }
224
+
225
+ /**
226
+ * Detect whether a token is a Firebase ID token or a Google ID token,
227
+ * and verify accordingly.
228
+ */
229
+ export async function verifyAnyGoogleToken(
230
+ idToken: string,
231
+ firebaseProjectId: string,
232
+ allowedGoogleClientIds: string[],
233
+ ): Promise<FirebaseTokenPayload> {
234
+ // Peek at the issuer to decide which verification path to use
235
+ const { payload: peek } = parseJwtUnverified(idToken);
236
+
237
+ if (peek.iss === `${FIREBASE_TOKEN_ISSUER_PREFIX}${firebaseProjectId}`) {
238
+ // Standard Firebase ID token
239
+ return verifyFirebaseIdToken(idToken, firebaseProjectId);
240
+ }
241
+
242
+ if (peek.iss === "accounts.google.com" || peek.iss === "https://accounts.google.com") {
243
+ // Native Google ID token (from iOS/Android)
244
+ return verifyGoogleIdToken(idToken, allowedGoogleClientIds);
245
+ }
246
+
247
+ throw new Error(`Unrecognized token issuer: ${peek.iss}`);
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Shared verification helpers
252
+ // ---------------------------------------------------------------------------
253
+
124
254
  async function verifyWithKey(
125
255
  jwk: JsonWebKey,
126
256
  signedContent: string,
@@ -22,6 +22,12 @@ export declare const botschatPlugin: {
22
22
  readonly agentPrompt: {
23
23
  readonly messageToolHints: () => string[];
24
24
  };
25
+ readonly messaging: {
26
+ readonly targetResolver: {
27
+ readonly hint: "Use the session key (e.g. agent:botschat:botschat:u_xxx:ses:ses_xxx)";
28
+ readonly looksLikeId: (raw: string, _normalized: string) => boolean;
29
+ };
30
+ };
25
31
  readonly reload: {
26
32
  readonly configPrefixes: readonly ["channels.botschat"];
27
33
  };
@@ -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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"}