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.
Files changed (66) hide show
  1. package/README.md +64 -24
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +7 -2
  4. package/packages/api/package.json +2 -1
  5. package/packages/api/src/do/connection-do.ts +162 -42
  6. package/packages/api/src/index.ts +132 -13
  7. package/packages/api/src/routes/auth.ts +127 -30
  8. package/packages/api/src/routes/pairing.ts +14 -1
  9. package/packages/api/src/routes/setup.ts +72 -24
  10. package/packages/api/src/routes/upload.ts +12 -8
  11. package/packages/api/src/utils/auth.ts +212 -43
  12. package/packages/api/src/utils/id.ts +30 -14
  13. package/packages/api/src/utils/rate-limit.ts +73 -0
  14. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  15. package/packages/plugin/dist/src/accounts.js +1 -0
  16. package/packages/plugin/dist/src/accounts.js.map +1 -1
  17. package/packages/plugin/dist/src/channel.d.ts +1 -0
  18. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/channel.js +151 -9
  20. package/packages/plugin/dist/src/channel.js.map +1 -1
  21. package/packages/plugin/dist/src/types.d.ts +16 -0
  22. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  23. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  24. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  25. package/packages/plugin/dist/src/ws-client.js +14 -3
  26. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  27. package/packages/plugin/package.json +4 -3
  28. package/packages/web/dist/architecture.png +0 -0
  29. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  30. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
  31. package/packages/web/dist/botschat-icon.svg +4 -0
  32. package/packages/web/dist/index.html +23 -3
  33. package/packages/web/dist/manifest.json +24 -0
  34. package/packages/web/dist/sw.js +40 -0
  35. package/packages/web/index.html +21 -1
  36. package/packages/web/package.json +1 -0
  37. package/packages/web/src/App.tsx +286 -103
  38. package/packages/web/src/analytics.ts +57 -0
  39. package/packages/web/src/api.ts +67 -3
  40. package/packages/web/src/components/ChatWindow.tsx +11 -11
  41. package/packages/web/src/components/ConnectionSettings.tsx +477 -0
  42. package/packages/web/src/components/CronDetail.tsx +475 -235
  43. package/packages/web/src/components/CronSidebar.tsx +1 -1
  44. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  45. package/packages/web/src/components/E2ESettings.tsx +122 -0
  46. package/packages/web/src/components/IconRail.tsx +56 -27
  47. package/packages/web/src/components/JobList.tsx +2 -6
  48. package/packages/web/src/components/LoginPage.tsx +143 -104
  49. package/packages/web/src/components/MobileLayout.tsx +480 -0
  50. package/packages/web/src/components/OnboardingPage.tsx +159 -21
  51. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  52. package/packages/web/src/components/Sidebar.tsx +1 -1
  53. package/packages/web/src/components/TaskBar.tsx +2 -2
  54. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  55. package/packages/web/src/e2e.ts +133 -0
  56. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  57. package/packages/web/src/index.css +59 -0
  58. package/packages/web/src/main.tsx +12 -0
  59. package/packages/web/src/store.ts +16 -8
  60. package/packages/web/src/ws.ts +78 -4
  61. package/scripts/dev.sh +16 -16
  62. package/scripts/test-e2e-live.ts +194 -0
  63. package/scripts/verify-e2e-db.ts +48 -0
  64. package/scripts/verify-e2e.ts +56 -0
  65. package/wrangler.toml +3 -1
  66. 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
- // Global CORS
22
- app.use("/*", cors({ origin: "*", allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }));
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: JSON.parse(row.settings_json || "{}"),
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 (body.defaultModel !== undefined) {
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
- return c.json({ ok: true, settings });
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 (public, no auth) ----
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=31536000, immutable");
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
- return c.json({ error: "Email already registered" }, 409);
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.JWT_SECRET ?? "botschat-dev-secret";
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
- const passwordHash = await hashPassword(password);
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 = ? AND password_hash = ?",
88
+ "SELECT id, email, display_name, password_hash FROM users WHERE email = ?",
58
89
  )
59
- .bind(email.trim().toLowerCase(), passwordHash)
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
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
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 email-based account link it
130
- row = await c.env.DB.prepare(
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 (row) {
137
- // Link Firebase UID to existing account
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, row.id)
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.JWT_SECRET ?? "botschat-dev-secret";
161
- const token = await createToken(row.id, secret);
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.id,
165
- email: row.email,
166
- displayName: row.display_name,
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: JSON.parse(row.settings_json || "{}"),
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
- token: r.token,
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