botschat 0.1.12 → 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 (37) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +7 -1
  4. package/packages/api/src/do/connection-do.ts +90 -1
  5. package/packages/api/src/env.ts +2 -0
  6. package/packages/api/src/index.ts +4 -1
  7. package/packages/api/src/routes/auth.ts +39 -6
  8. package/packages/api/src/routes/push.ts +52 -0
  9. package/packages/api/src/utils/fcm.ts +167 -0
  10. package/packages/api/src/utils/firebase.ts +89 -1
  11. package/packages/plugin/package.json +1 -1
  12. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  13. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  14. package/packages/web/dist/assets/{index-CCBhODDo.css → index-Bd_RDcgO.css} +1 -1
  15. package/packages/web/dist/assets/{index-CCFgKLX_.js → index-Civeg2lm.js} +1 -1
  16. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  17. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  18. package/packages/web/dist/assets/{index-Dx64BDkP.js → index-lVB82JKU.js} +1 -1
  19. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  20. package/packages/web/dist/assets/{web-DJQW-VLX.js → web-CUXjh_UA.js} +1 -1
  21. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  22. package/packages/web/dist/index.html +6 -4
  23. package/packages/web/dist/sw.js +158 -1
  24. package/packages/web/index.html +4 -2
  25. package/packages/web/src/App.tsx +42 -2
  26. package/packages/web/src/api.ts +10 -0
  27. package/packages/web/src/components/AccountSettings.tsx +131 -0
  28. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  29. package/packages/web/src/components/LoginPage.tsx +49 -9
  30. package/packages/web/src/firebase.ts +89 -2
  31. package/packages/web/src/foreground.ts +51 -0
  32. package/packages/web/src/main.tsx +2 -1
  33. package/packages/web/src/push.ts +205 -0
  34. package/scripts/dev.sh +139 -13
  35. package/scripts/mock-openclaw.mjs +382 -0
  36. package/packages/web/dist/assets/index-D8mBAwjS.js +0 -1516
  37. package/packages/web/dist/assets/index-E-nzPZl8.js +0 -2
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.12",
3
+ "version": "0.1.13",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -20,6 +20,10 @@
20
20
  "ios:open": "npx cap open ios",
21
21
  "ios:run": "npx cap run ios",
22
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",
23
27
  "test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
24
28
  "test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
25
29
  },
@@ -58,11 +62,13 @@
58
62
  "wrangler": "^3.100.0"
59
63
  },
60
64
  "dependencies": {
65
+ "@capacitor/android": "^8.1.0",
61
66
  "@capacitor/app": "^8.0.1",
62
67
  "@capacitor/core": "^8.1.0",
63
68
  "@capacitor/haptics": "^8.0.0",
64
69
  "@capacitor/ios": "^8.1.0",
65
70
  "@capacitor/keyboard": "^8.0.0",
71
+ "@capacitor/push-notifications": "^8.0.1",
66
72
  "@capacitor/splash-screen": "^8.0.1",
67
73
  "@capacitor/status-bar": "^8.0.1",
68
74
  "@capgo/capacitor-social-login": "^8.3.1",
@@ -1,5 +1,6 @@
1
1
  import type { Env } from "../env.js";
2
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 ----
@@ -12,4 +12,6 @@ export type Env = {
12
12
  PUBLIC_URL?: string;
13
13
  /** Secret for dev-token auth bypass (automated testing). Endpoint is 404 when unset. */
14
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;
15
17
  };
@@ -11,6 +11,7 @@ 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";
15
16
  import { devAuth } from "./routes/dev-auth.js";
16
17
 
@@ -25,7 +26,8 @@ const PRODUCTION_ORIGINS = [
25
26
  "https://botschat.app",
26
27
  "https://botschat-api.auxtenwpc.workers.dev",
27
28
  "capacitor://localhost", // iOS Capacitor app
28
- "http://localhost", // Android Capacitor app
29
+ "http://localhost", // Android Capacitor app (http scheme)
30
+ "https://localhost", // Android Capacitor app (https scheme)
29
31
  ];
30
32
 
31
33
  // CORS and security headers — skip for WebSocket upgrade requests
@@ -275,6 +277,7 @@ protectedApp.route("/channels/:channelId/tasks/:taskId/jobs", jobs);
275
277
  // Nested session routes under /api/channels/:channelId/sessions
276
278
  protectedApp.route("/channels/:channelId/sessions", sessions);
277
279
  protectedApp.route("/pairing-tokens", pairing);
280
+ protectedApp.route("/push-tokens", push);
278
281
  protectedApp.route("/upload", upload);
279
282
 
280
283
  // ---- Media serving route (signed URL or Bearer auth) ----
@@ -155,11 +155,6 @@ async function handleFirebaseAuth(c: {
155
155
  return c.json({ error: msg }, 401);
156
156
  }
157
157
 
158
- const email = firebaseUser.email?.toLowerCase();
159
- if (!email) {
160
- return c.json({ error: "Account has no email address" }, 400);
161
- }
162
-
163
158
  const firebaseUid = firebaseUser.sub;
164
159
  // Determine provider from Firebase token (google.com, github.com, etc.)
165
160
  const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
@@ -167,7 +162,21 @@ async function handleFirebaseAuth(c: {
167
162
  ? "google"
168
163
  : signInProvider.includes("github")
169
164
  ? "github"
170
- : 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
+
171
180
  const displayName = firebaseUser.name ?? email.split("@")[0];
172
181
 
173
182
  // 2. Look up existing user by firebase_uid first, then by email
@@ -241,6 +250,7 @@ async function handleFirebaseAuth(c: {
241
250
  auth.post("/firebase", (c) => handleFirebaseAuth(c));
242
251
  auth.post("/google", (c) => handleFirebaseAuth(c));
243
252
  auth.post("/github", (c) => handleFirebaseAuth(c));
253
+ auth.post("/apple", (c) => handleFirebaseAuth(c));
244
254
 
245
255
  /**
246
256
  * POST /api/auth/dev-login — development-only passwordless login by email.
@@ -307,6 +317,7 @@ auth.get("/config", (c) => {
307
317
  emailEnabled: isDev,
308
318
  googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
309
319
  githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
320
+ appleEnabled: !!c.env.FIREBASE_PROJECT_ID,
310
321
  });
311
322
  });
312
323
 
@@ -342,4 +353,26 @@ auth.get("/me", async (c) => {
342
353
  });
343
354
  });
344
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
+
345
378
  export { auth };
@@ -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 };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * FCM HTTP v1 API — send push notifications from Cloudflare Workers.
3
+ *
4
+ * Uses a Google Service Account to obtain OAuth2 access tokens,
5
+ * then sends data-only messages via FCM so clients can decrypt
6
+ * E2E-encrypted content before showing notifications.
7
+ */
8
+
9
+ type ServiceAccount = {
10
+ client_email: string;
11
+ private_key: string;
12
+ token_uri: string;
13
+ };
14
+
15
+ // Module-level token cache (survives within DO lifecycle)
16
+ let cachedAccessToken: string | null = null;
17
+ let cachedTokenExpiry = 0;
18
+
19
+ /**
20
+ * Get a valid Google OAuth2 access token for FCM.
21
+ * Caches the token in memory; refreshes 5 minutes before expiry.
22
+ */
23
+ export async function getFcmAccessToken(serviceAccountJson: string): Promise<string> {
24
+ const now = Math.floor(Date.now() / 1000);
25
+ if (cachedAccessToken && cachedTokenExpiry > now + 300) {
26
+ return cachedAccessToken;
27
+ }
28
+
29
+ const sa: ServiceAccount = JSON.parse(serviceAccountJson);
30
+
31
+ // Build JWT assertion for Google OAuth2
32
+ const header = { alg: "RS256", typ: "JWT" };
33
+ const claims = {
34
+ iss: sa.client_email,
35
+ scope: "https://www.googleapis.com/auth/firebase.messaging",
36
+ aud: sa.token_uri,
37
+ iat: now,
38
+ exp: now + 3600,
39
+ };
40
+
41
+ const key = await importPKCS8Key(sa.private_key);
42
+ const jwt = await signJwt(header, claims, key);
43
+
44
+ // Exchange JWT for access token
45
+ const res = await fetch(sa.token_uri, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
49
+ });
50
+
51
+ if (!res.ok) {
52
+ const err = await res.text();
53
+ throw new Error(`FCM OAuth2 token exchange failed: ${res.status} ${err}`);
54
+ }
55
+
56
+ const data = (await res.json()) as { access_token: string; expires_in: number };
57
+ cachedAccessToken = data.access_token;
58
+ cachedTokenExpiry = now + data.expires_in;
59
+ return data.access_token;
60
+ }
61
+
62
+ export type PushPayload = {
63
+ accessToken: string;
64
+ projectId: string;
65
+ fcmToken: string;
66
+ /** Data payload — sent as FCM data-only message so client can decrypt + show notification. */
67
+ data: Record<string, string>;
68
+ };
69
+
70
+ /**
71
+ * Send a data-only push notification via FCM HTTP v1 API.
72
+ * Returns true on success. Returns false if the token is invalid (404/410)
73
+ * so the caller can clean it up.
74
+ */
75
+ export async function sendPushNotification(opts: PushPayload): Promise<boolean> {
76
+ const url = `https://fcm.googleapis.com/v1/projects/${opts.projectId}/messages:send`;
77
+
78
+ const message = {
79
+ message: {
80
+ token: opts.fcmToken,
81
+ // Data-only message — no "notification" field.
82
+ // Client receives the data and shows a local notification after decryption.
83
+ data: opts.data,
84
+ // Platform-specific overrides for data-only delivery
85
+ android: {
86
+ priority: "high" as const,
87
+ },
88
+ apns: {
89
+ headers: {
90
+ "apns-priority": "10",
91
+ "apns-push-type": "background",
92
+ },
93
+ payload: {
94
+ aps: {
95
+ "content-available": 1,
96
+ sound: "default",
97
+ },
98
+ },
99
+ },
100
+ webpush: {
101
+ headers: {
102
+ Urgency: "high",
103
+ },
104
+ },
105
+ },
106
+ };
107
+
108
+ const res = await fetch(url, {
109
+ method: "POST",
110
+ headers: {
111
+ Authorization: `Bearer ${opts.accessToken}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify(message),
115
+ });
116
+
117
+ if (!res.ok) {
118
+ const err = await res.text();
119
+ console.error(`[FCM] Send failed for token ...${opts.fcmToken.slice(-8)}: ${res.status} ${err}`);
120
+ // Token invalid/expired — caller should remove it
121
+ if (res.status === 404 || res.status === 410) return false;
122
+ }
123
+ return res.ok;
124
+ }
125
+
126
+ // ---- Crypto helpers for RS256 JWT signing ----
127
+
128
+ async function importPKCS8Key(pem: string): Promise<CryptoKey> {
129
+ const pemContents = pem
130
+ .replace(/-----BEGIN PRIVATE KEY-----/, "")
131
+ .replace(/-----END PRIVATE KEY-----/, "")
132
+ .replace(/\n/g, "");
133
+ const binary = atob(pemContents);
134
+ const der = new Uint8Array(binary.length);
135
+ for (let i = 0; i < binary.length; i++) {
136
+ der[i] = binary.charCodeAt(i);
137
+ }
138
+ return crypto.subtle.importKey(
139
+ "pkcs8",
140
+ der.buffer as ArrayBuffer,
141
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
142
+ false,
143
+ ["sign"],
144
+ );
145
+ }
146
+
147
+ function base64url(data: string | Uint8Array): string {
148
+ const str = typeof data === "string" ? btoa(data) : btoa(String.fromCharCode(...data));
149
+ return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
150
+ }
151
+
152
+ async function signJwt(
153
+ header: Record<string, string>,
154
+ payload: Record<string, unknown>,
155
+ key: CryptoKey,
156
+ ): Promise<string> {
157
+ const headerB64 = base64url(JSON.stringify(header));
158
+ const payloadB64 = base64url(JSON.stringify(payload));
159
+ const input = `${headerB64}.${payloadB64}`;
160
+ const sig = await crypto.subtle.sign(
161
+ "RSASSA-PKCS1-v1_5",
162
+ key,
163
+ new TextEncoder().encode(input),
164
+ );
165
+ const sigB64 = base64url(new Uint8Array(sig));
166
+ return `${input}.${sigB64}`;
167
+ }
@@ -235,7 +235,7 @@ export async function verifyAnyGoogleToken(
235
235
  const { payload: peek } = parseJwtUnverified(idToken);
236
236
 
237
237
  if (peek.iss === `${FIREBASE_TOKEN_ISSUER_PREFIX}${firebaseProjectId}`) {
238
- // Standard Firebase ID token
238
+ // Standard Firebase ID token (from web Firebase popup)
239
239
  return verifyFirebaseIdToken(idToken, firebaseProjectId);
240
240
  }
241
241
 
@@ -244,9 +244,97 @@ export async function verifyAnyGoogleToken(
244
244
  return verifyGoogleIdToken(idToken, allowedGoogleClientIds);
245
245
  }
246
246
 
247
+ if (peek.iss === "https://appleid.apple.com") {
248
+ // Native Apple ID token (from iOS Sign in with Apple)
249
+ return verifyAppleIdToken(idToken, []);
250
+ }
251
+
247
252
  throw new Error(`Unrecognized token issuer: ${peek.iss}`);
248
253
  }
249
254
 
255
+ // ---------------------------------------------------------------------------
256
+ // Apple ID Token verification (for native iOS Sign in with Apple)
257
+ // ---------------------------------------------------------------------------
258
+
259
+ const APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
260
+
261
+ let cachedAppleKeys: JsonWebKey[] | null = null;
262
+ let cachedAppleAt = 0;
263
+
264
+ async function getApplePublicKeys(): Promise<JsonWebKey[]> {
265
+ const now = Date.now();
266
+ if (cachedAppleKeys && now - cachedAppleAt < CACHE_TTL_MS) {
267
+ return cachedAppleKeys;
268
+ }
269
+ const resp = await fetch(APPLE_JWKS_URL);
270
+ if (!resp.ok) {
271
+ throw new Error(`Failed to fetch Apple JWKS: ${resp.status}`);
272
+ }
273
+ const jwks = (await resp.json()) as { keys: JsonWebKey[] };
274
+ cachedAppleKeys = jwks.keys;
275
+ cachedAppleAt = now;
276
+ return jwks.keys;
277
+ }
278
+
279
+ export async function verifyAppleIdToken(
280
+ idToken: string,
281
+ allowedAudiences: string[],
282
+ ): Promise<FirebaseTokenPayload> {
283
+ const { header, payload, signatureBytes, signedContent } =
284
+ parseJwtUnverified(idToken);
285
+
286
+ if (header.alg !== "RS256") {
287
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
288
+ }
289
+
290
+ let keys = await getApplePublicKeys();
291
+ let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
292
+ if (!matchingKey) {
293
+ cachedAppleKeys = null;
294
+ keys = await getApplePublicKeys();
295
+ matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
296
+ if (!matchingKey) {
297
+ throw new Error(`No matching Apple key for kid: ${header.kid}`);
298
+ }
299
+ }
300
+
301
+ const key = await crypto.subtle.importKey(
302
+ "jwk", matchingKey,
303
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
304
+ false, ["verify"],
305
+ );
306
+
307
+ const valid = await crypto.subtle.verify(
308
+ "RSASSA-PKCS1-v1_5", key, signatureBytes,
309
+ new TextEncoder().encode(signedContent),
310
+ );
311
+ if (!valid) throw new Error("Invalid Apple token signature");
312
+
313
+ const now = Math.floor(Date.now() / 1000);
314
+ if (payload.exp < now) throw new Error("Apple token has expired");
315
+ if (payload.iat > now + 300) throw new Error("Apple token issued in the future");
316
+
317
+ if (payload.iss !== "https://appleid.apple.com") {
318
+ throw new Error(`Invalid Apple token issuer: ${payload.iss}`);
319
+ }
320
+
321
+ if (allowedAudiences.length > 0 && !allowedAudiences.includes(payload.aud)) {
322
+ throw new Error(`Invalid Apple token audience: ${payload.aud}`);
323
+ }
324
+
325
+ if (!payload.sub) throw new Error("Missing subject in Apple token");
326
+
327
+ // Synthesize firebase-like fields so the rest of the auth flow works
328
+ if (!payload.firebase) {
329
+ payload.firebase = {
330
+ sign_in_provider: "apple.com",
331
+ identities: { "apple.com": [payload.sub] },
332
+ };
333
+ }
334
+
335
+ return payload;
336
+ }
337
+
250
338
  // ---------------------------------------------------------------------------
251
339
  // Shared verification helpers
252
340
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botschat/botschat",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "BotsChat channel plugin for OpenClaw — connects your OpenClaw agent to the BotsChat cloud platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1 @@
1
+ import{r as i}from"./index-Kr85Nj_-.js";const t=i("PushNotifications",{});export{t as PushNotifications};
@@ -0,0 +1,2 @@
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/web-vKLTVUul.js","assets/index-Kr85Nj_-.js","assets/index-Bd_RDcgO.css"])))=>i.map(i=>d[i]);
2
+ import{r as p,f as r}from"./index-Kr85Nj_-.js";const o=p("App",{web:()=>r(()=>import("./web-vKLTVUul.js"),__vite__mapDeps([0,1,2])).then(e=>new e.AppWeb)});export{o as App};