botschat 0.1.4 → 0.1.7
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 +64 -24
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +7 -2
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +162 -42
- package/packages/api/src/index.ts +132 -13
- package/packages/api/src/routes/auth.ts +127 -30
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +72 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +151 -9
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +14 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +4 -3
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +286 -103
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +67 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +477 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +56 -27
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +143 -104
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +159 -21
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/e2e.ts +133 -0
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +12 -0
- package/packages/web/src/store.ts +16 -8
- package/packages/web/src/ws.ts +78 -4
- package/scripts/dev.sh +16 -16
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/wrangler.toml +3 -1
- package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import type { Env } from "./env.js";
|
|
4
|
-
import { authMiddleware } from "./utils/auth.js";
|
|
4
|
+
import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
|
|
5
5
|
import { auth } from "./routes/auth.js";
|
|
6
6
|
import { agents } from "./routes/agents.js";
|
|
7
7
|
import { channels } from "./routes/channels.js";
|
|
@@ -18,12 +18,67 @@ export { ConnectionDO } from "./do/connection-do.js";
|
|
|
18
18
|
|
|
19
19
|
const app = new Hono<{ Bindings: Env }>();
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
21
|
+
// Production CORS origins
|
|
22
|
+
const PRODUCTION_ORIGINS = [
|
|
23
|
+
"https://console.botschat.app",
|
|
24
|
+
"https://botschat.app",
|
|
25
|
+
"https://botschat-api.auxtenwpc.workers.dev",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// CORS and security headers — skip for WebSocket upgrade requests
|
|
29
|
+
// (101 responses have immutable headers in Cloudflare Workers)
|
|
30
|
+
const corsMiddleware = cors({
|
|
31
|
+
origin: (origin, c) => {
|
|
32
|
+
if (PRODUCTION_ORIGINS.includes(origin)) return origin;
|
|
33
|
+
// Only allow localhost/private IPs in development
|
|
34
|
+
if ((c as unknown as { env: Env }).env?.ENVIRONMENT === "development") {
|
|
35
|
+
if (/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return origin;
|
|
36
|
+
if (/^https?:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return origin;
|
|
37
|
+
}
|
|
38
|
+
return ""; // disallow
|
|
39
|
+
},
|
|
40
|
+
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.use("/*", async (c, next) => {
|
|
44
|
+
// WebSocket upgrades return 101 with immutable headers — skip CORS & security headers
|
|
45
|
+
if (c.req.header("Upgrade")?.toLowerCase() === "websocket") {
|
|
46
|
+
await next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Apply CORS for regular HTTP requests
|
|
51
|
+
return corsMiddleware(c, next);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Security response headers.
|
|
55
|
+
// In Cloudflare Workers, responses from Durable Objects (stub.fetch()) and
|
|
56
|
+
// subrequests have immutable headers. We clone the response first, then set
|
|
57
|
+
// security headers on the mutable clone. This also makes headers mutable for
|
|
58
|
+
// the CORS middleware which runs AFTER this (registered earlier → runs later
|
|
59
|
+
// in the response phase).
|
|
60
|
+
app.use("/*", async (c, next) => {
|
|
61
|
+
await next();
|
|
62
|
+
if (c.res.status === 101) return; // WebSocket 101 — can't clone
|
|
63
|
+
// Clone to ensure mutable headers
|
|
64
|
+
c.res = new Response(c.res.body, c.res);
|
|
65
|
+
c.res.headers.set(
|
|
66
|
+
"Content-Security-Policy",
|
|
67
|
+
"default-src 'self'; script-src 'self' https://apis.google.com https://*.firebaseapp.com; style-src 'self' 'unsafe-inline'; img-src 'self' https://*.r2.dev https://*.cloudflarestorage.com data: blob:; connect-src 'self' wss://*.botschat.app wss://console.botschat.app https://apis.google.com https://*.googleapis.com; frame-src https://accounts.google.com https://*.firebaseapp.com",
|
|
68
|
+
);
|
|
69
|
+
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
|
70
|
+
c.res.headers.set("X-Frame-Options", "DENY");
|
|
71
|
+
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
72
|
+
});
|
|
23
73
|
|
|
24
74
|
// Health check
|
|
25
75
|
app.get("/api/health", (c) => c.json({ status: "ok", version: "0.1.0" }));
|
|
26
76
|
|
|
77
|
+
// Rate limiting is handled by Cloudflare WAF Rate Limiting Rules (Dashboard).
|
|
78
|
+
// See the security audit for recommended rule configuration.
|
|
79
|
+
// No in-memory rate limiter — it cannot survive Worker isolate restarts
|
|
80
|
+
// and is not shared across instances.
|
|
81
|
+
|
|
27
82
|
// ---- Public routes (no auth) ----
|
|
28
83
|
app.route("/api/auth", auth);
|
|
29
84
|
app.route("/api/setup", setup);
|
|
@@ -49,11 +104,14 @@ protectedApp.get("/me", async (c) => {
|
|
|
49
104
|
created_at: number;
|
|
50
105
|
}>();
|
|
51
106
|
if (!row) return c.json({ error: "User not found" }, 404);
|
|
107
|
+
const settings = JSON.parse(row.settings_json || "{}");
|
|
108
|
+
// defaultModel is not stored in D1 — it comes from the plugin (connection.status).
|
|
109
|
+
delete settings.defaultModel;
|
|
52
110
|
return c.json({
|
|
53
111
|
id: row.id,
|
|
54
112
|
email: row.email,
|
|
55
113
|
displayName: row.display_name,
|
|
56
|
-
settings
|
|
114
|
+
settings,
|
|
57
115
|
createdAt: row.created_at,
|
|
58
116
|
});
|
|
59
117
|
});
|
|
@@ -62,6 +120,7 @@ protectedApp.patch("/me", async (c) => {
|
|
|
62
120
|
const userId = c.get("userId");
|
|
63
121
|
const body = await c.req.json<{ defaultModel?: string }>();
|
|
64
122
|
|
|
123
|
+
// defaultModel is not stored in D1 — get/set only via plugin (connection.status / push).
|
|
65
124
|
const existing = await c.env.DB.prepare(
|
|
66
125
|
"SELECT settings_json FROM users WHERE id = ?",
|
|
67
126
|
)
|
|
@@ -69,18 +128,36 @@ protectedApp.patch("/me", async (c) => {
|
|
|
69
128
|
.first<{ settings_json: string }>();
|
|
70
129
|
|
|
71
130
|
const settings = JSON.parse(existing?.settings_json || "{}");
|
|
72
|
-
|
|
73
|
-
if
|
|
74
|
-
settings.defaultModel = body.defaultModel;
|
|
75
|
-
}
|
|
76
|
-
|
|
131
|
+
delete settings.defaultModel;
|
|
132
|
+
// Persist other settings (if any) to D1; defaultModel is never written.
|
|
77
133
|
await c.env.DB.prepare(
|
|
78
134
|
"UPDATE users SET settings_json = ? WHERE id = ?",
|
|
79
135
|
)
|
|
80
136
|
.bind(JSON.stringify(settings), userId)
|
|
81
137
|
.run();
|
|
82
138
|
|
|
83
|
-
|
|
139
|
+
if (body.defaultModel !== undefined) {
|
|
140
|
+
try {
|
|
141
|
+
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
142
|
+
const stub = c.env.CONNECTION_DO.get(doId);
|
|
143
|
+
await stub.fetch(
|
|
144
|
+
new Request("https://internal/send", {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: { "Content-Type": "application/json" },
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
type: "settings.defaultModel",
|
|
149
|
+
defaultModel: body.defaultModel ?? "",
|
|
150
|
+
}),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error("Failed to push default model to OpenClaw:", err);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const outSettings = { ...settings };
|
|
159
|
+
delete outSettings.defaultModel;
|
|
160
|
+
return c.json({ ok: true, settings: outSettings });
|
|
84
161
|
});
|
|
85
162
|
|
|
86
163
|
// OpenClaw scan data — schedule/instructions/model cached in the ConnectionDO.
|
|
@@ -196,12 +273,29 @@ protectedApp.route("/channels/:channelId/sessions", sessions);
|
|
|
196
273
|
protectedApp.route("/pairing-tokens", pairing);
|
|
197
274
|
protectedApp.route("/upload", upload);
|
|
198
275
|
|
|
199
|
-
// ---- Media serving route (
|
|
276
|
+
// ---- Media serving route (signed URL or Bearer auth) ----
|
|
200
277
|
app.get("/api/media/:userId/:filename", async (c) => {
|
|
201
278
|
const userId = c.req.param("userId");
|
|
202
279
|
const filename = c.req.param("filename");
|
|
203
|
-
const key = `media/${userId}/${filename}`;
|
|
204
280
|
|
|
281
|
+
// Verify access: either a valid signed URL or a valid Bearer token
|
|
282
|
+
const expires = c.req.query("expires");
|
|
283
|
+
const sig = c.req.query("sig");
|
|
284
|
+
const secret = getJwtSecret(c.env);
|
|
285
|
+
|
|
286
|
+
if (expires && sig) {
|
|
287
|
+
// Signed URL verification
|
|
288
|
+
const valid = await verifyMediaSignature(userId, filename, expires, sig, secret);
|
|
289
|
+
if (!valid) {
|
|
290
|
+
return c.json({ error: "Invalid or expired media signature" }, 403);
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
// Fall back to Bearer token auth
|
|
294
|
+
const denied = await verifyUserAccess(c, userId);
|
|
295
|
+
if (denied) return denied;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const key = `media/${userId}/${filename}`;
|
|
205
299
|
const object = await c.env.MEDIA.get(key);
|
|
206
300
|
if (!object) {
|
|
207
301
|
return c.json({ error: "Not found" }, 404);
|
|
@@ -209,11 +303,29 @@ app.get("/api/media/:userId/:filename", async (c) => {
|
|
|
209
303
|
|
|
210
304
|
const headers = new Headers();
|
|
211
305
|
headers.set("Content-Type", object.httpMetadata?.contentType ?? "application/octet-stream");
|
|
212
|
-
headers.set("Cache-Control", "public, max-age=
|
|
306
|
+
headers.set("Cache-Control", "public, max-age=3600"); // 1h cache (matches signature expiry)
|
|
213
307
|
|
|
214
308
|
return new Response(object.body, { headers });
|
|
215
309
|
});
|
|
216
310
|
|
|
311
|
+
// ---- Helper: verify JWT and ensure userId matches ----
|
|
312
|
+
async function verifyUserAccess(c: { req: { header: (n: string) => string | undefined; query: (n: string) => string | undefined }; env: Env; json: (data: unknown, status?: number) => Response }, userId: string): Promise<Response | null> {
|
|
313
|
+
const authHeader = c.req.header("Authorization");
|
|
314
|
+
const tokenStr = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : c.req.query("token");
|
|
315
|
+
if (!tokenStr) {
|
|
316
|
+
return c.json({ error: "Missing Authorization header or token query param" }, 401);
|
|
317
|
+
}
|
|
318
|
+
const secret = getJwtSecret(c.env);
|
|
319
|
+
const payload = await verifyToken(tokenStr, secret);
|
|
320
|
+
if (!payload) {
|
|
321
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
322
|
+
}
|
|
323
|
+
if (payload.sub !== userId) {
|
|
324
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
325
|
+
}
|
|
326
|
+
return null; // access granted
|
|
327
|
+
}
|
|
328
|
+
|
|
217
329
|
// ---- WebSocket upgrade routes (BEFORE protected middleware) ----
|
|
218
330
|
|
|
219
331
|
// OpenClaw plugin connects to: /api/gateway/:connId
|
|
@@ -268,6 +380,9 @@ app.all("/api/gateway/:connId", async (c) => {
|
|
|
268
380
|
});
|
|
269
381
|
|
|
270
382
|
// Browser client connects to: /api/ws/:userId/:sessionId
|
|
383
|
+
// Auth is handled entirely inside the DO via the "auth" message after
|
|
384
|
+
// the WebSocket connection is established. This avoids putting the JWT
|
|
385
|
+
// in the URL query string (which would leak it in logs/browser history).
|
|
271
386
|
app.all("/api/ws/:userId/:sessionId", async (c) => {
|
|
272
387
|
const userId = c.req.param("userId");
|
|
273
388
|
const sessionId = c.req.param("sessionId");
|
|
@@ -281,6 +396,8 @@ app.all("/api/ws/:userId/:sessionId", async (c) => {
|
|
|
281
396
|
// Connection status: /api/connection/:userId/status
|
|
282
397
|
app.get("/api/connection/:userId/status", async (c) => {
|
|
283
398
|
const userId = c.req.param("userId");
|
|
399
|
+
const denied = await verifyUserAccess(c, userId);
|
|
400
|
+
if (denied) return denied;
|
|
284
401
|
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
285
402
|
const stub = c.env.CONNECTION_DO.get(doId);
|
|
286
403
|
const url = new URL(c.req.url);
|
|
@@ -291,6 +408,8 @@ app.get("/api/connection/:userId/status", async (c) => {
|
|
|
291
408
|
// Message history: /api/messages/:userId?sessionKey=xxx
|
|
292
409
|
app.get("/api/messages/:userId", async (c) => {
|
|
293
410
|
const userId = c.req.param("userId");
|
|
411
|
+
const denied = await verifyUserAccess(c, userId);
|
|
412
|
+
if (denied) return denied;
|
|
294
413
|
const doId = c.env.CONNECTION_DO.idFromName(userId);
|
|
295
414
|
const stub = c.env.CONNECTION_DO.get(doId);
|
|
296
415
|
const url = new URL(c.req.url);
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Env } from "../env.js";
|
|
3
|
-
import { createToken, hashPassword } from "../utils/auth.js";
|
|
3
|
+
import { createToken, createRefreshToken, verifyRefreshToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
|
|
4
4
|
import { verifyFirebaseIdToken } from "../utils/firebase.js";
|
|
5
5
|
import { generateId } from "../utils/id.js";
|
|
6
6
|
|
|
7
7
|
const auth = new Hono<{ Bindings: Env }>();
|
|
8
8
|
|
|
9
|
-
/** POST /api/auth/register */
|
|
9
|
+
/** POST /api/auth/register — disabled in production (OAuth only) */
|
|
10
10
|
auth.post("/register", async (c) => {
|
|
11
|
+
if (c.env.ENVIRONMENT !== "development") {
|
|
12
|
+
return c.json({ error: "Email registration is disabled. Please sign in with Google or GitHub." }, 403);
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
const { email, password, displayName } = await c.req.json<{
|
|
12
16
|
email: string;
|
|
13
17
|
password: string;
|
|
@@ -18,6 +22,23 @@ auth.post("/register", async (c) => {
|
|
|
18
22
|
return c.json({ error: "Email and password are required" }, 400);
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
// Basic email format validation
|
|
26
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
|
27
|
+
return c.json({ error: "Invalid email format" }, 400);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Password strength requirements
|
|
31
|
+
if (password.length < 8) {
|
|
32
|
+
return c.json({ error: "Password must be at least 8 characters long" }, 400);
|
|
33
|
+
}
|
|
34
|
+
// Cap password length to prevent PBKDF2 resource exhaustion (DoS)
|
|
35
|
+
if (password.length > 256) {
|
|
36
|
+
return c.json({ error: "Password must not exceed 256 characters" }, 400);
|
|
37
|
+
}
|
|
38
|
+
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
|
|
39
|
+
return c.json({ error: "Password must contain both letters and numbers" }, 400);
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
const id = generateId("u_");
|
|
22
43
|
const passwordHash = await hashPassword(password);
|
|
23
44
|
|
|
@@ -29,19 +50,25 @@ auth.post("/register", async (c) => {
|
|
|
29
50
|
.run();
|
|
30
51
|
} catch (err: unknown) {
|
|
31
52
|
if (err instanceof Error && err.message.includes("UNIQUE")) {
|
|
32
|
-
|
|
53
|
+
// Generic error to prevent email enumeration
|
|
54
|
+
return c.json({ error: "Registration failed. Please try a different email or sign in." }, 409);
|
|
33
55
|
}
|
|
34
56
|
throw err;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
|
-
const secret = c.env
|
|
59
|
+
const secret = getJwtSecret(c.env);
|
|
38
60
|
const token = await createToken(id, secret);
|
|
61
|
+
const refreshToken = await createRefreshToken(id, secret);
|
|
39
62
|
|
|
40
|
-
return c.json({ id, email, token }, 201);
|
|
63
|
+
return c.json({ id, email, token, refreshToken }, 201);
|
|
41
64
|
});
|
|
42
65
|
|
|
43
|
-
/** POST /api/auth/login */
|
|
66
|
+
/** POST /api/auth/login — disabled in production (OAuth only) */
|
|
44
67
|
auth.post("/login", async (c) => {
|
|
68
|
+
if (c.env.ENVIRONMENT !== "development") {
|
|
69
|
+
return c.json({ error: "Email login is disabled. Please sign in with Google or GitHub." }, 403);
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
const { email, password } = await c.req.json<{
|
|
46
73
|
email: string;
|
|
47
74
|
password: string;
|
|
@@ -51,26 +78,45 @@ auth.post("/login", async (c) => {
|
|
|
51
78
|
return c.json({ error: "Email and password are required" }, 400);
|
|
52
79
|
}
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
// Cap password length to prevent PBKDF2 resource exhaustion (DoS)
|
|
82
|
+
if (password.length > 256) {
|
|
83
|
+
return c.json({ error: "Invalid email or password" }, 401);
|
|
84
|
+
}
|
|
55
85
|
|
|
86
|
+
// Fetch user with password hash — we now verify in application code
|
|
56
87
|
const row = await c.env.DB.prepare(
|
|
57
|
-
"SELECT id, email, display_name FROM users WHERE email = ?
|
|
88
|
+
"SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
|
|
58
89
|
)
|
|
59
|
-
.bind(email.trim().toLowerCase()
|
|
60
|
-
.first<{ id: string; email: string; display_name: string | null }>();
|
|
90
|
+
.bind(email.trim().toLowerCase())
|
|
91
|
+
.first<{ id: string; email: string; display_name: string | null; password_hash: string }>();
|
|
61
92
|
|
|
62
|
-
if (!row) {
|
|
93
|
+
if (!row || !row.password_hash) {
|
|
94
|
+
return c.json({ error: "Invalid email or password" }, 401);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { valid, needsRehash } = await verifyPassword(password, row.password_hash);
|
|
98
|
+
if (!valid) {
|
|
63
99
|
return c.json({ error: "Invalid email or password" }, 401);
|
|
64
100
|
}
|
|
65
101
|
|
|
66
|
-
|
|
102
|
+
// Transparently upgrade legacy SHA-256 hashes to PBKDF2
|
|
103
|
+
if (needsRehash) {
|
|
104
|
+
const newHash = await hashPassword(password);
|
|
105
|
+
await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
|
|
106
|
+
.bind(newHash, row.id)
|
|
107
|
+
.run();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const secret = getJwtSecret(c.env);
|
|
67
111
|
const token = await createToken(row.id, secret);
|
|
112
|
+
const refreshToken = await createRefreshToken(row.id, secret);
|
|
68
113
|
|
|
69
114
|
return c.json({
|
|
70
115
|
id: row.id,
|
|
71
116
|
email: row.email,
|
|
72
117
|
displayName: row.display_name,
|
|
73
118
|
token,
|
|
119
|
+
refreshToken,
|
|
74
120
|
});
|
|
75
121
|
});
|
|
76
122
|
|
|
@@ -120,26 +166,41 @@ async function handleFirebaseAuth(c: {
|
|
|
120
166
|
|
|
121
167
|
// 2. Look up existing user by firebase_uid first, then by email
|
|
122
168
|
let row = await c.env.DB.prepare(
|
|
123
|
-
"SELECT id, email, display_name, auth_provider FROM users WHERE firebase_uid = ?",
|
|
169
|
+
"SELECT id, email, display_name, auth_provider, password_hash FROM users WHERE firebase_uid = ?",
|
|
124
170
|
)
|
|
125
171
|
.bind(firebaseUid)
|
|
126
|
-
.first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
|
|
172
|
+
.first<{ id: string; email: string; display_name: string | null; auth_provider: string; password_hash: string }>();
|
|
127
173
|
|
|
128
174
|
if (!row) {
|
|
129
|
-
// Check if there's an existing
|
|
130
|
-
|
|
131
|
-
"SELECT id, email, display_name, auth_provider FROM users WHERE email = ?",
|
|
175
|
+
// Check if there's an existing account with the same email
|
|
176
|
+
const existing = await c.env.DB.prepare(
|
|
177
|
+
"SELECT id, email, display_name, auth_provider, password_hash FROM users WHERE email = ?",
|
|
132
178
|
)
|
|
133
179
|
.bind(email)
|
|
134
|
-
.first<{ id: string; email: string; display_name: string | null; auth_provider: string }>();
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
180
|
+
.first<{ id: string; email: string; display_name: string | null; auth_provider: string; password_hash: string }>();
|
|
181
|
+
|
|
182
|
+
if (existing) {
|
|
183
|
+
if (existing.password_hash) {
|
|
184
|
+
// SECURITY: existing account has a password — do NOT auto-link.
|
|
185
|
+
// The user must sign in with their password first, or the admin
|
|
186
|
+
// must link accounts manually. Auto-linking would let an
|
|
187
|
+
// attacker who pre-registered the email access the real owner's
|
|
188
|
+
// data once they sign in with OAuth.
|
|
189
|
+
return c.json(
|
|
190
|
+
{
|
|
191
|
+
error: "An account with this email already exists. Please sign in with your email and password.",
|
|
192
|
+
code: "EMAIL_EXISTS_WITH_PASSWORD",
|
|
193
|
+
},
|
|
194
|
+
409,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
// Existing account was created via OAuth (no password) — safe to link
|
|
138
198
|
await c.env.DB.prepare(
|
|
139
199
|
"UPDATE users SET firebase_uid = ?, auth_provider = ?, updated_at = unixepoch() WHERE id = ?",
|
|
140
200
|
)
|
|
141
|
-
.bind(firebaseUid, authProvider,
|
|
201
|
+
.bind(firebaseUid, authProvider, existing.id)
|
|
142
202
|
.run();
|
|
203
|
+
row = existing;
|
|
143
204
|
}
|
|
144
205
|
}
|
|
145
206
|
|
|
@@ -153,18 +214,20 @@ async function handleFirebaseAuth(c: {
|
|
|
153
214
|
.bind(id, email, displayName, authProvider, firebaseUid)
|
|
154
215
|
.run();
|
|
155
216
|
|
|
156
|
-
row = { id, email, display_name: displayName, auth_provider: authProvider };
|
|
217
|
+
row = { id, email, display_name: displayName, auth_provider: authProvider, password_hash: "" };
|
|
157
218
|
}
|
|
158
219
|
|
|
159
|
-
// 4. Issue our own JWT
|
|
160
|
-
const secret = c.env
|
|
161
|
-
const token = await createToken(row
|
|
220
|
+
// 4. Issue our own JWT (access + refresh)
|
|
221
|
+
const secret = getJwtSecret(c.env);
|
|
222
|
+
const token = await createToken(row!.id, secret);
|
|
223
|
+
const refreshToken = await createRefreshToken(row!.id, secret);
|
|
162
224
|
|
|
163
225
|
return c.json({
|
|
164
|
-
id: row
|
|
165
|
-
email: row
|
|
166
|
-
displayName: row
|
|
226
|
+
id: row!.id,
|
|
227
|
+
email: row!.email,
|
|
228
|
+
displayName: row!.display_name,
|
|
167
229
|
token,
|
|
230
|
+
refreshToken,
|
|
168
231
|
});
|
|
169
232
|
}
|
|
170
233
|
|
|
@@ -173,6 +236,37 @@ auth.post("/firebase", (c) => handleFirebaseAuth(c));
|
|
|
173
236
|
auth.post("/google", (c) => handleFirebaseAuth(c));
|
|
174
237
|
auth.post("/github", (c) => handleFirebaseAuth(c));
|
|
175
238
|
|
|
239
|
+
/** POST /api/auth/refresh — exchange a refresh token for a new access token */
|
|
240
|
+
auth.post("/refresh", async (c) => {
|
|
241
|
+
const { refreshToken } = await c.req.json<{ refreshToken: string }>();
|
|
242
|
+
|
|
243
|
+
if (!refreshToken?.trim()) {
|
|
244
|
+
return c.json({ error: "refreshToken is required" }, 400);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const secret = getJwtSecret(c.env);
|
|
248
|
+
const payload = await verifyRefreshToken(refreshToken, secret);
|
|
249
|
+
|
|
250
|
+
if (!payload) {
|
|
251
|
+
return c.json({ error: "Invalid or expired refresh token" }, 401);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Issue a new short-lived access token
|
|
255
|
+
const token = await createToken(payload.sub, secret);
|
|
256
|
+
|
|
257
|
+
return c.json({ token });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/** GET /api/auth/config — public endpoint returning allowed auth methods */
|
|
261
|
+
auth.get("/config", (c) => {
|
|
262
|
+
const isDev = c.env.ENVIRONMENT === "development";
|
|
263
|
+
return c.json({
|
|
264
|
+
emailEnabled: isDev,
|
|
265
|
+
googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
266
|
+
githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
176
270
|
/** GET /api/auth/me — returns current user info */
|
|
177
271
|
auth.get("/me", async (c) => {
|
|
178
272
|
// This route requires auth middleware to be applied upstream
|
|
@@ -193,11 +287,14 @@ auth.get("/me", async (c) => {
|
|
|
193
287
|
|
|
194
288
|
if (!row) return c.json({ error: "User not found" }, 404);
|
|
195
289
|
|
|
290
|
+
const settings = JSON.parse(row.settings_json || "{}");
|
|
291
|
+
delete settings.defaultModel; // not in D1 — comes from plugin (connection.status)
|
|
292
|
+
|
|
196
293
|
return c.json({
|
|
197
294
|
id: row.id,
|
|
198
295
|
email: row.email,
|
|
199
296
|
displayName: row.display_name,
|
|
200
|
-
settings
|
|
297
|
+
settings,
|
|
201
298
|
createdAt: row.created_at,
|
|
202
299
|
});
|
|
203
300
|
});
|
|
@@ -28,7 +28,8 @@ pairing.get("/", async (c) => {
|
|
|
28
28
|
return c.json({
|
|
29
29
|
tokens: (results ?? []).map((r) => ({
|
|
30
30
|
id: r.id,
|
|
31
|
-
|
|
31
|
+
// SECURITY: never expose the full token in list responses.
|
|
32
|
+
// Full token is only returned once at creation time (POST).
|
|
32
33
|
tokenPreview: `bc_pat_...${r.token.slice(-8)}`,
|
|
33
34
|
label: r.label,
|
|
34
35
|
lastConnectedAt: r.last_connected_at,
|
|
@@ -46,6 +47,18 @@ pairing.post("/", async (c) => {
|
|
|
46
47
|
label: undefined,
|
|
47
48
|
}));
|
|
48
49
|
|
|
50
|
+
// Limit active (non-revoked) tokens per user
|
|
51
|
+
const MAX_ACTIVE_TOKENS = 10;
|
|
52
|
+
const countRow = await c.env.DB.prepare(
|
|
53
|
+
"SELECT COUNT(*) as cnt FROM pairing_tokens WHERE user_id = ? AND revoked_at IS NULL",
|
|
54
|
+
)
|
|
55
|
+
.bind(userId)
|
|
56
|
+
.first<{ cnt: number }>();
|
|
57
|
+
|
|
58
|
+
if (countRow && countRow.cnt >= MAX_ACTIVE_TOKENS) {
|
|
59
|
+
return c.json({ error: `Maximum of ${MAX_ACTIVE_TOKENS} active pairing tokens reached. Revoke an existing token first.` }, 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
const id = generateId("pt_");
|
|
50
63
|
const token = generatePairingToken();
|
|
51
64
|
|