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.
- package/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +7 -1
- package/packages/api/src/do/connection-do.ts +90 -1
- package/packages/api/src/env.ts +2 -0
- package/packages/api/src/index.ts +4 -1
- package/packages/api/src/routes/auth.ts +39 -6
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +89 -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-CCBhODDo.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/{index-CCFgKLX_.js → index-Civeg2lm.js} +1 -1
- 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-Dx64BDkP.js → index-lVB82JKU.js} +1 -1
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/{web-DJQW-VLX.js → web-CUXjh_UA.js} +1 -1
- 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/src/App.tsx +42 -2
- package/packages/web/src/api.ts +10 -0
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +49 -9
- package/packages/web/src/firebase.ts +89 -2
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/main.tsx +2 -1
- package/packages/web/src/push.ts +205 -0
- package/scripts/dev.sh +139 -13
- package/scripts/mock-openclaw.mjs +382 -0
- package/packages/web/dist/assets/index-D8mBAwjS.js +0 -1516
- 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
|
-
|
|
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/*"
|
|
@@ -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
|
-
//
|
|
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 ----
|
package/packages/api/src/env.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -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};
|