botschat 0.1.2 → 0.1.4
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 +12 -3
- package/migrations/0009_google_auth.sql +10 -0
- package/migrations/0010_pairing_token_security.sql +10 -0
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +13 -7
- package/packages/api/src/env.ts +3 -0
- package/packages/api/src/index.ts +13 -5
- package/packages/api/src/routes/auth.ts +100 -0
- package/packages/api/src/routes/pairing.ts +12 -5
- package/packages/api/src/routes/setup.ts +199 -0
- package/packages/api/src/utils/firebase.ts +179 -0
- package/packages/api/src/utils/resolve-url.ts +79 -0
- package/packages/plugin/dist/src/channel.d.ts +1 -1
- package/packages/plugin/dist/src/channel.js +3 -3
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +5 -1
- package/packages/web/dist/assets/{index-C-wI8eHy.css → index-DuGeoFJT.css} +1 -1
- package/packages/web/dist/assets/index-DyzTR_Y4.js +847 -0
- package/packages/web/dist/botschat-logo.png +0 -0
- package/packages/web/dist/index.html +3 -3
- package/packages/web/index.html +1 -1
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +39 -0
- package/packages/web/src/api.ts +12 -0
- package/packages/web/src/components/LoginPage.tsx +131 -13
- package/packages/web/src/components/OnboardingPage.tsx +383 -0
- package/packages/web/src/firebase.ts +93 -0
- package/packages/web/vite.config.ts +1 -1
- package/wrangler.toml +2 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +0 -93
package/README.md
CHANGED
|
@@ -85,9 +85,17 @@ npm install
|
|
|
85
85
|
|
|
86
86
|
### Step 2: Deploy BotsChat Server
|
|
87
87
|
|
|
88
|
-
Choose one of the
|
|
88
|
+
Choose one of the three options below:
|
|
89
89
|
|
|
90
|
-
#### Option A:
|
|
90
|
+
#### Option A: Use Hosted Console (Recommended)
|
|
91
|
+
|
|
92
|
+
The easiest way to get started — no deployment needed. We run a hosted BotsChat instance at **[console.botschat.app](https://console.botschat.app)**. Just sign up, generate a pairing token, and connect your OpenClaw instance directly.
|
|
93
|
+
|
|
94
|
+
Your API keys and data still stay on your machine — the hosted console only relays chat messages via WebSocket, exactly the same as a self-hosted deployment.
|
|
95
|
+
|
|
96
|
+
> Skip to [Step 3](#step-3-install-the-openclaw-plugin) after signing up.
|
|
97
|
+
|
|
98
|
+
#### Option B: Run Locally
|
|
91
99
|
|
|
92
100
|
Wrangler uses [Miniflare](https://miniflare.dev) under the hood, so D1, R2, and Durable Objects all run locally — **no Cloudflare account needed**.
|
|
93
101
|
|
|
@@ -116,7 +124,7 @@ Other dev commands:
|
|
|
116
124
|
./scripts/dev.sh logs # Tail remote gateway logs
|
|
117
125
|
```
|
|
118
126
|
|
|
119
|
-
#### Option
|
|
127
|
+
#### Option C: Deploy to Cloudflare
|
|
120
128
|
|
|
121
129
|
For remote access (e.g. chatting with your agents from your phone), deploy to Cloudflare Workers. The free tier is more than enough for personal use.
|
|
122
130
|
|
|
@@ -156,6 +164,7 @@ Open the BotsChat web UI, register an account, and generate a **pairing token**
|
|
|
156
164
|
**3. Configure the connection**
|
|
157
165
|
|
|
158
166
|
```bash
|
|
167
|
+
# For hosted console, use https://console.botschat.app
|
|
159
168
|
# For local deployment, use http://localhost:8787 or your LAN IP
|
|
160
169
|
# For Cloudflare deployment, use your Workers URL
|
|
161
170
|
openclaw config set channels.botschat.cloudUrl <BOTSCHAT_URL>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Support Firebase OAuth (Google, GitHub, etc.) users who don't have a password.
|
|
2
|
+
|
|
3
|
+
-- auth_provider: 'email' (default), 'google', 'github'
|
|
4
|
+
ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'email';
|
|
5
|
+
|
|
6
|
+
-- Firebase UID — unique across all Firebase auth providers
|
|
7
|
+
ALTER TABLE users ADD COLUMN firebase_uid TEXT;
|
|
8
|
+
|
|
9
|
+
-- Index for quick lookup by firebase_uid
|
|
10
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_firebase_uid ON users(firebase_uid) WHERE firebase_uid IS NOT NULL;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Pairing token security enhancements: soft-delete (revoke), audit fields.
|
|
2
|
+
|
|
3
|
+
-- Soft-delete: revoked tokens are rejected but kept for audit trail
|
|
4
|
+
ALTER TABLE pairing_tokens ADD COLUMN revoked_at INTEGER;
|
|
5
|
+
|
|
6
|
+
-- Last connecting IP (for audit)
|
|
7
|
+
ALTER TABLE pairing_tokens ADD COLUMN last_ip TEXT;
|
|
8
|
+
|
|
9
|
+
-- Connection count (for anomaly detection)
|
|
10
|
+
ALTER TABLE pairing_tokens ADD COLUMN connection_count INTEGER NOT NULL DEFAULT 0;
|
package/package.json
CHANGED
|
@@ -36,11 +36,13 @@ export class ConnectionDO implements DurableObject {
|
|
|
36
36
|
// Route: /gateway/:accountId — OpenClaw plugin connects here
|
|
37
37
|
if (url.pathname.startsWith("/gateway/")) {
|
|
38
38
|
// Extract and store userId from the gateway path
|
|
39
|
-
const userId = url.pathname.split("/gateway/")[1];
|
|
39
|
+
const userId = url.pathname.split("/gateway/")[1]?.split("?")[0];
|
|
40
40
|
if (userId) {
|
|
41
41
|
await this.state.storage.put("userId", userId);
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
// Check if the API worker already verified the token against D1
|
|
44
|
+
const preVerified = url.searchParams.get("verified") === "1";
|
|
45
|
+
return this.handleOpenClawConnect(request, preVerified);
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// Route: /client/:sessionId — Browser client connects here
|
|
@@ -121,7 +123,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
121
123
|
|
|
122
124
|
// ---- Connection handlers ----
|
|
123
125
|
|
|
124
|
-
private handleOpenClawConnect(request: Request): Response {
|
|
126
|
+
private handleOpenClawConnect(request: Request, preVerified = false): Response {
|
|
125
127
|
if (request.headers.get("Upgrade") !== "websocket") {
|
|
126
128
|
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
127
129
|
}
|
|
@@ -132,8 +134,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
132
134
|
// Accept with Hibernation API, tag as "openclaw"
|
|
133
135
|
this.state.acceptWebSocket(server, ["openclaw"]);
|
|
134
136
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
+
// If the API worker already verified the token against D1, mark as
|
|
138
|
+
// pre-verified. The plugin still sends an auth message, which we'll
|
|
139
|
+
// fast-track through without re-validating the token.
|
|
140
|
+
server.serializeAttachment({ authenticated: false, tag: "openclaw", preVerified });
|
|
137
141
|
|
|
138
142
|
return new Response(null, { status: 101, webSocket: client });
|
|
139
143
|
}
|
|
@@ -162,12 +166,14 @@ export class ConnectionDO implements DurableObject {
|
|
|
162
166
|
ws: WebSocket,
|
|
163
167
|
msg: Record<string, unknown>,
|
|
164
168
|
): Promise<void> {
|
|
165
|
-
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
|
|
169
|
+
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string; preVerified?: boolean } | null;
|
|
166
170
|
|
|
167
171
|
// Handle auth handshake
|
|
168
172
|
if (msg.type === "auth") {
|
|
169
173
|
const token = msg.token as string;
|
|
170
|
-
|
|
174
|
+
// If the API worker already validated this token against D1, skip
|
|
175
|
+
// the DO-level check. Otherwise fall back to local validation.
|
|
176
|
+
const isValid = attachment?.preVerified || await this.validatePairingToken(token);
|
|
171
177
|
|
|
172
178
|
if (isValid) {
|
|
173
179
|
ws.serializeAttachment({ ...attachment, authenticated: true });
|
package/packages/api/src/env.ts
CHANGED
|
@@ -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 { setup } from "./routes/setup.js";
|
|
14
15
|
|
|
15
16
|
// Re-export the Durable Object class so wrangler can find it
|
|
16
17
|
export { ConnectionDO } from "./do/connection-do.js";
|
|
@@ -25,6 +26,7 @@ app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
|
|
|
25
26
|
|
|
26
27
|
// ---- Public routes (no auth) ----
|
|
27
28
|
app.route("/api/auth", auth);
|
|
29
|
+
app.route("/api/setup", setup);
|
|
28
30
|
|
|
29
31
|
// ---- Protected routes (require Bearer token) ----
|
|
30
32
|
const protectedApp = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
|
|
@@ -232,9 +234,9 @@ app.all("/api/gateway/:connId", async (c) => {
|
|
|
232
234
|
return c.json({ error: "Token required for gateway connection" }, 401);
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
// Look up user by pairing token
|
|
237
|
+
// Look up user by pairing token (exclude revoked tokens)
|
|
236
238
|
const row = await c.env.DB.prepare(
|
|
237
|
-
"SELECT user_id FROM pairing_tokens WHERE token = ?",
|
|
239
|
+
"SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
|
|
238
240
|
)
|
|
239
241
|
.bind(token)
|
|
240
242
|
.first<{ user_id: string }>();
|
|
@@ -244,18 +246,24 @@ app.all("/api/gateway/:connId", async (c) => {
|
|
|
244
246
|
}
|
|
245
247
|
userId = row.user_id;
|
|
246
248
|
|
|
247
|
-
// Update last_connected_at
|
|
249
|
+
// Update audit fields: last_connected_at, last_ip, connection_count
|
|
250
|
+
const clientIp = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown";
|
|
248
251
|
await c.env.DB.prepare(
|
|
249
|
-
|
|
252
|
+
`UPDATE pairing_tokens
|
|
253
|
+
SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
|
|
254
|
+
WHERE token = ?`,
|
|
250
255
|
)
|
|
251
|
-
.bind(token)
|
|
256
|
+
.bind(clientIp, token)
|
|
252
257
|
.run();
|
|
253
258
|
}
|
|
254
259
|
|
|
255
260
|
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
256
261
|
const stub = c.env.CONNECTION_DO.get(doId);
|
|
257
262
|
const url = new URL(c.req.url);
|
|
263
|
+
// Pass verified userId to DO — the API worker already validated the token
|
|
264
|
+
// against D1 above, so DO can trust this.
|
|
258
265
|
url.pathname = `/gateway/${userId}`;
|
|
266
|
+
url.searchParams.set("verified", "1");
|
|
259
267
|
return stub.fetch(new Request(url.toString(), c.req.raw));
|
|
260
268
|
});
|
|
261
269
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Env } from "../env.js";
|
|
3
3
|
import { createToken, hashPassword } from "../utils/auth.js";
|
|
4
|
+
import { verifyFirebaseIdToken } from "../utils/firebase.js";
|
|
4
5
|
import { generateId } from "../utils/id.js";
|
|
5
6
|
|
|
6
7
|
const auth = new Hono<{ Bindings: Env }>();
|
|
@@ -73,6 +74,105 @@ auth.post("/login", async (c) => {
|
|
|
73
74
|
});
|
|
74
75
|
});
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* POST /api/auth/firebase — sign in (or register) with a Firebase ID token.
|
|
79
|
+
* Works for all Firebase-backed providers (Google, GitHub, etc.).
|
|
80
|
+
*/
|
|
81
|
+
async function handleFirebaseAuth(c: {
|
|
82
|
+
req: { json: <T>() => Promise<T> };
|
|
83
|
+
env: Env;
|
|
84
|
+
json: (data: unknown, status?: number) => Response;
|
|
85
|
+
}) {
|
|
86
|
+
const { idToken } = await c.req.json<{ idToken: string }>();
|
|
87
|
+
|
|
88
|
+
if (!idToken?.trim()) {
|
|
89
|
+
return c.json({ error: "idToken is required" }, 400);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const projectId = c.env.FIREBASE_PROJECT_ID;
|
|
93
|
+
if (!projectId) {
|
|
94
|
+
return c.json({ error: "Firebase sign-in is not configured" }, 500);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 1. Verify the Firebase ID token
|
|
98
|
+
let firebaseUser;
|
|
99
|
+
try {
|
|
100
|
+
firebaseUser = await verifyFirebaseIdToken(idToken, projectId);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const msg = err instanceof Error ? err.message : "Token verification failed";
|
|
103
|
+
return c.json({ error: msg }, 401);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const email = firebaseUser.email?.toLowerCase();
|
|
107
|
+
if (!email) {
|
|
108
|
+
return c.json({ error: "Account has no email address" }, 400);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const firebaseUid = firebaseUser.sub;
|
|
112
|
+
// Determine provider from Firebase token (google.com, github.com, etc.)
|
|
113
|
+
const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
|
|
114
|
+
const authProvider = signInProvider.includes("google")
|
|
115
|
+
? "google"
|
|
116
|
+
: signInProvider.includes("github")
|
|
117
|
+
? "github"
|
|
118
|
+
: signInProvider;
|
|
119
|
+
const displayName = firebaseUser.name ?? email.split("@")[0];
|
|
120
|
+
|
|
121
|
+
// 2. Look up existing user by firebase_uid first, then by email
|
|
122
|
+
let row = await c.env.DB.prepare(
|
|
123
|
+
"SELECT id, email, display_name, auth_provider FROM users WHERE firebase_uid = ?",
|
|
124
|
+
)
|
|
125
|
+
.bind(firebaseUid)
|
|
126
|
+
.first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
|
|
127
|
+
|
|
128
|
+
if (!row) {
|
|
129
|
+
// Check if there's an existing email-based account — link it
|
|
130
|
+
row = await c.env.DB.prepare(
|
|
131
|
+
"SELECT id, email, display_name, auth_provider FROM users WHERE email = ?",
|
|
132
|
+
)
|
|
133
|
+
.bind(email)
|
|
134
|
+
.first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
|
|
135
|
+
|
|
136
|
+
if (row) {
|
|
137
|
+
// Link Firebase UID to existing account
|
|
138
|
+
await c.env.DB.prepare(
|
|
139
|
+
"UPDATE users SET firebase_uid = ?, auth_provider = ?, updated_at = unixepoch() WHERE id = ?",
|
|
140
|
+
)
|
|
141
|
+
.bind(firebaseUid, authProvider, row.id)
|
|
142
|
+
.run();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!row) {
|
|
147
|
+
// 3. Create a new user (OAuth-only, no password)
|
|
148
|
+
const id = generateId("u_");
|
|
149
|
+
await c.env.DB.prepare(
|
|
150
|
+
`INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
|
|
151
|
+
VALUES (?, ?, '', ?, ?, ?)`,
|
|
152
|
+
)
|
|
153
|
+
.bind(id, email, displayName, authProvider, firebaseUid)
|
|
154
|
+
.run();
|
|
155
|
+
|
|
156
|
+
row = { id, email, display_name: displayName, auth_provider: authProvider };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 4. Issue our own JWT
|
|
160
|
+
const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
|
|
161
|
+
const token = await createToken(row.id, secret);
|
|
162
|
+
|
|
163
|
+
return c.json({
|
|
164
|
+
id: row.id,
|
|
165
|
+
email: row.email,
|
|
166
|
+
displayName: row.display_name,
|
|
167
|
+
token,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Register the unified Firebase auth handler and provider-specific aliases
|
|
172
|
+
auth.post("/firebase", (c) => handleFirebaseAuth(c));
|
|
173
|
+
auth.post("/google", (c) => handleFirebaseAuth(c));
|
|
174
|
+
auth.post("/github", (c) => handleFirebaseAuth(c));
|
|
175
|
+
|
|
76
176
|
/** GET /api/auth/me — returns current user info */
|
|
77
177
|
auth.get("/me", async (c) => {
|
|
78
178
|
// This route requires auth middleware to be applied upstream
|
|
@@ -4,12 +4,15 @@ import { generateId, generatePairingToken } from "../utils/id.js";
|
|
|
4
4
|
|
|
5
5
|
const pairing = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
|
|
6
6
|
|
|
7
|
-
/** GET /api/pairing-tokens — list pairing tokens for the current user */
|
|
7
|
+
/** GET /api/pairing-tokens — list active (non-revoked) pairing tokens for the current user */
|
|
8
8
|
pairing.get("/", async (c) => {
|
|
9
9
|
const userId = c.get("userId");
|
|
10
10
|
|
|
11
11
|
const { results } = await c.env.DB.prepare(
|
|
12
|
-
|
|
12
|
+
`SELECT id, token, label, last_connected_at, last_ip, connection_count, created_at
|
|
13
|
+
FROM pairing_tokens
|
|
14
|
+
WHERE user_id = ? AND revoked_at IS NULL
|
|
15
|
+
ORDER BY created_at DESC`,
|
|
13
16
|
)
|
|
14
17
|
.bind(userId)
|
|
15
18
|
.all<{
|
|
@@ -17,17 +20,20 @@ pairing.get("/", async (c) => {
|
|
|
17
20
|
token: string;
|
|
18
21
|
label: string | null;
|
|
19
22
|
last_connected_at: number | null;
|
|
23
|
+
last_ip: string | null;
|
|
24
|
+
connection_count: number;
|
|
20
25
|
created_at: number;
|
|
21
26
|
}>();
|
|
22
27
|
|
|
23
28
|
return c.json({
|
|
24
29
|
tokens: (results ?? []).map((r) => ({
|
|
25
30
|
id: r.id,
|
|
26
|
-
// Show only last 8 chars for security
|
|
27
31
|
token: r.token,
|
|
28
32
|
tokenPreview: `bc_pat_...${r.token.slice(-8)}`,
|
|
29
33
|
label: r.label,
|
|
30
34
|
lastConnectedAt: r.last_connected_at,
|
|
35
|
+
lastIp: r.last_ip,
|
|
36
|
+
connectionCount: r.connection_count,
|
|
31
37
|
createdAt: r.created_at,
|
|
32
38
|
})),
|
|
33
39
|
});
|
|
@@ -59,13 +65,14 @@ pairing.post("/", async (c) => {
|
|
|
59
65
|
);
|
|
60
66
|
});
|
|
61
67
|
|
|
62
|
-
/** DELETE /api/pairing-tokens/:id — revoke a pairing token */
|
|
68
|
+
/** DELETE /api/pairing-tokens/:id — soft-revoke a pairing token */
|
|
63
69
|
pairing.delete("/:id", async (c) => {
|
|
64
70
|
const userId = c.get("userId");
|
|
65
71
|
const tokenId = c.req.param("id");
|
|
66
72
|
|
|
73
|
+
// Soft-delete: set revoked_at instead of removing the row
|
|
67
74
|
await c.env.DB.prepare(
|
|
68
|
-
"
|
|
75
|
+
"UPDATE pairing_tokens SET revoked_at = unixepoch() WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
|
69
76
|
)
|
|
70
77
|
.bind(tokenId, userId)
|
|
71
78
|
.run();
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../env.js";
|
|
3
|
+
import { createToken, hashPassword } from "../utils/auth.js";
|
|
4
|
+
import { verifyFirebaseIdToken } from "../utils/firebase.js";
|
|
5
|
+
import { generateId, generatePairingToken } from "../utils/id.js";
|
|
6
|
+
import { resolveCloudUrlWithHints } from "../utils/resolve-url.js";
|
|
7
|
+
|
|
8
|
+
const setup = new Hono<{ Bindings: Env }>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/setup/init — One-shot CLI onboarding endpoint.
|
|
12
|
+
*
|
|
13
|
+
* Accepts email+password OR a Firebase idToken.
|
|
14
|
+
* Returns everything the CLI needs to configure the OpenClaw plugin:
|
|
15
|
+
* - userId, JWT token, pairing token, cloud URL, ready-to-run commands.
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: if the user already has a pairing token, a new one is created
|
|
18
|
+
* for this setup session (old ones remain valid).
|
|
19
|
+
*/
|
|
20
|
+
setup.post("/init", async (c) => {
|
|
21
|
+
const body = await c.req.json<{
|
|
22
|
+
email?: string;
|
|
23
|
+
password?: string;
|
|
24
|
+
idToken?: string;
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
let userId: string;
|
|
28
|
+
let email: string;
|
|
29
|
+
let displayName: string | null = null;
|
|
30
|
+
|
|
31
|
+
if (body.idToken) {
|
|
32
|
+
// ---- Firebase auth path ----
|
|
33
|
+
const projectId = c.env.FIREBASE_PROJECT_ID;
|
|
34
|
+
if (!projectId) {
|
|
35
|
+
return c.json({ error: "Firebase sign-in is not configured" }, 500);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let firebaseUser;
|
|
39
|
+
try {
|
|
40
|
+
firebaseUser = await verifyFirebaseIdToken(body.idToken, projectId);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const msg = err instanceof Error ? err.message : "Token verification failed";
|
|
43
|
+
return c.json({ error: msg }, 401);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
email = firebaseUser.email?.toLowerCase() ?? "";
|
|
47
|
+
if (!email) return c.json({ error: "Account has no email" }, 400);
|
|
48
|
+
|
|
49
|
+
displayName = firebaseUser.name ?? null;
|
|
50
|
+
const firebaseUid = firebaseUser.sub;
|
|
51
|
+
|
|
52
|
+
// Find or create user
|
|
53
|
+
let row = await c.env.DB.prepare(
|
|
54
|
+
"SELECT id, display_name FROM users WHERE firebase_uid = ?",
|
|
55
|
+
).bind(firebaseUid).first<{ id: string; display_name: string | null }>();
|
|
56
|
+
|
|
57
|
+
if (!row) {
|
|
58
|
+
row = await c.env.DB.prepare(
|
|
59
|
+
"SELECT id, display_name FROM users WHERE email = ?",
|
|
60
|
+
).bind(email).first<{ id: string; display_name: string | null }>();
|
|
61
|
+
|
|
62
|
+
if (row) {
|
|
63
|
+
await c.env.DB.prepare(
|
|
64
|
+
"UPDATE users SET firebase_uid = ?, updated_at = unixepoch() WHERE id = ?",
|
|
65
|
+
).bind(firebaseUid, row.id).run();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!row) {
|
|
70
|
+
const id = generateId("u_");
|
|
71
|
+
const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
|
|
72
|
+
const authProvider = signInProvider.includes("google") ? "google"
|
|
73
|
+
: signInProvider.includes("github") ? "github" : signInProvider;
|
|
74
|
+
await c.env.DB.prepare(
|
|
75
|
+
`INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
|
|
76
|
+
VALUES (?, ?, '', ?, ?, ?)`,
|
|
77
|
+
).bind(id, email, displayName ?? email.split("@")[0], authProvider, firebaseUid).run();
|
|
78
|
+
row = { id, display_name: displayName };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
userId = row.id;
|
|
82
|
+
displayName = row.display_name;
|
|
83
|
+
} else {
|
|
84
|
+
// ---- Email + password path ----
|
|
85
|
+
if (!body.email?.trim() || !body.password?.trim()) {
|
|
86
|
+
return c.json({ error: "email+password or idToken is required" }, 400);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
email = body.email.trim().toLowerCase();
|
|
90
|
+
const passwordHash = await hashPassword(body.password);
|
|
91
|
+
|
|
92
|
+
const row = await c.env.DB.prepare(
|
|
93
|
+
"SELECT id, display_name FROM users WHERE email = ? AND password_hash = ?",
|
|
94
|
+
).bind(email, passwordHash).first<{ id: string; display_name: string | null }>();
|
|
95
|
+
|
|
96
|
+
if (!row) {
|
|
97
|
+
return c.json({ error: "Invalid email or password" }, 401);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
userId = row.id;
|
|
101
|
+
displayName = row.display_name;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- Create a fresh pairing token for this setup ----
|
|
105
|
+
const ptId = generateId("pt_");
|
|
106
|
+
const pairingToken = generatePairingToken();
|
|
107
|
+
await c.env.DB.prepare(
|
|
108
|
+
"INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
|
|
109
|
+
).bind(ptId, userId, pairingToken, "CLI setup").run();
|
|
110
|
+
|
|
111
|
+
// ---- Ensure a default channel exists ----
|
|
112
|
+
let channel = await c.env.DB.prepare(
|
|
113
|
+
"SELECT id, name FROM channels WHERE user_id = ? LIMIT 1",
|
|
114
|
+
).bind(userId).first<{ id: string; name: string }>();
|
|
115
|
+
|
|
116
|
+
if (!channel) {
|
|
117
|
+
const chId = generateId("ch_");
|
|
118
|
+
await c.env.DB.prepare(
|
|
119
|
+
"INSERT INTO channels (id, user_id, name, description) VALUES (?, ?, ?, ?)",
|
|
120
|
+
).bind(chId, userId, "My Agent", "Default channel").run();
|
|
121
|
+
channel = { id: chId, name: "My Agent" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Issue JWT ----
|
|
125
|
+
const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
|
|
126
|
+
const token = await createToken(userId, secret);
|
|
127
|
+
|
|
128
|
+
// ---- Resolve the best cloud URL for the plugin to connect back ----
|
|
129
|
+
const { cloudUrl, isLoopback, hint } = resolveCloudUrlWithHints(c.req.raw, c.env);
|
|
130
|
+
|
|
131
|
+
return c.json({
|
|
132
|
+
userId,
|
|
133
|
+
email,
|
|
134
|
+
displayName,
|
|
135
|
+
token,
|
|
136
|
+
pairingToken,
|
|
137
|
+
cloudUrl,
|
|
138
|
+
...(isLoopback ? { cloudUrlWarning: hint } : {}),
|
|
139
|
+
channel: { id: channel.id, name: channel.name },
|
|
140
|
+
setupCommands: [
|
|
141
|
+
"openclaw plugins install @botschat/openclaw-plugin",
|
|
142
|
+
`openclaw config set channels.botschat.cloudUrl ${cloudUrl}`,
|
|
143
|
+
`openclaw config set channels.botschat.pairingToken ${pairingToken}`,
|
|
144
|
+
"openclaw config set channels.botschat.enabled true",
|
|
145
|
+
"openclaw gateway restart",
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* GET /api/setup/cloud-url — Returns the recommended cloudUrl for the plugin.
|
|
152
|
+
*
|
|
153
|
+
* Used by the web onboarding page to display the correct URL in commands.
|
|
154
|
+
* No auth required (the URL is not secret).
|
|
155
|
+
*/
|
|
156
|
+
setup.get("/cloud-url", async (c) => {
|
|
157
|
+
const { cloudUrl, isLoopback, hint } = resolveCloudUrlWithHints(c.req.raw, c.env);
|
|
158
|
+
return c.json({ cloudUrl, isLoopback, ...(hint ? { hint } : {}) });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* GET /api/setup/status — Check if the user's OpenClaw is connected.
|
|
163
|
+
*
|
|
164
|
+
* Used by CLI to verify the setup was successful.
|
|
165
|
+
* Requires Bearer token auth.
|
|
166
|
+
*/
|
|
167
|
+
setup.get("/status", async (c) => {
|
|
168
|
+
const authHeader = c.req.header("Authorization");
|
|
169
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
170
|
+
return c.json({ error: "Missing Authorization header" }, 401);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { verifyToken } = await import("../utils/auth.js");
|
|
174
|
+
const jwtSecret = c.env.JWT_SECRET ?? "botschat-dev-secret";
|
|
175
|
+
const payload = await verifyToken(authHeader.slice(7), jwtSecret);
|
|
176
|
+
if (!payload) {
|
|
177
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const userId = payload.sub;
|
|
181
|
+
|
|
182
|
+
// Query the DO for connection status
|
|
183
|
+
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
184
|
+
const stub = c.env.CONNECTION_DO.get(doId);
|
|
185
|
+
const resp = await stub.fetch(new Request("https://internal/status"));
|
|
186
|
+
|
|
187
|
+
if (!resp.ok) {
|
|
188
|
+
return c.json({ connected: false });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const status = await resp.json() as { openclawConnected?: boolean };
|
|
192
|
+
|
|
193
|
+
return c.json({
|
|
194
|
+
connected: !!status.openclawConnected,
|
|
195
|
+
userId,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export { setup };
|