@schlessera/brain-ui-server 0.31.0 → 0.32.0

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 (95) hide show
  1. package/README.md +46 -5
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +49 -25
  4. package/dist/app.js.map +1 -1
  5. package/dist/config/env.d.ts +6 -5
  6. package/dist/config/env.d.ts.map +1 -1
  7. package/dist/config/env.js +13 -7
  8. package/dist/config/env.js.map +1 -1
  9. package/dist/files/walker.d.ts.map +1 -1
  10. package/dist/files/walker.js +70 -10
  11. package/dist/files/walker.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/middleware/auth.d.ts +25 -5
  17. package/dist/middleware/auth.d.ts.map +1 -1
  18. package/dist/middleware/auth.js +255 -47
  19. package/dist/middleware/auth.js.map +1 -1
  20. package/dist/middleware/body-limit.d.ts +15 -0
  21. package/dist/middleware/body-limit.d.ts.map +1 -0
  22. package/dist/middleware/body-limit.js +41 -0
  23. package/dist/middleware/body-limit.js.map +1 -0
  24. package/dist/middleware/origin.d.ts +20 -12
  25. package/dist/middleware/origin.d.ts.map +1 -1
  26. package/dist/middleware/origin.js +71 -25
  27. package/dist/middleware/origin.js.map +1 -1
  28. package/dist/middleware/passkeys.d.ts +5 -1
  29. package/dist/middleware/passkeys.d.ts.map +1 -1
  30. package/dist/middleware/passkeys.js +67 -26
  31. package/dist/middleware/passkeys.js.map +1 -1
  32. package/dist/routes/brain.d.ts.map +1 -1
  33. package/dist/routes/brain.js +7 -2
  34. package/dist/routes/brain.js.map +1 -1
  35. package/dist/routes/files.js +2 -2
  36. package/dist/routes/files.js.map +1 -1
  37. package/dist/routes/models.d.ts.map +1 -1
  38. package/dist/routes/models.js +22 -10
  39. package/dist/routes/models.js.map +1 -1
  40. package/dist/routes/pi-auth.d.ts.map +1 -1
  41. package/dist/routes/pi-auth.js +12 -4
  42. package/dist/routes/pi-auth.js.map +1 -1
  43. package/dist/routes/push.d.ts.map +1 -1
  44. package/dist/routes/push.js +12 -4
  45. package/dist/routes/push.js.map +1 -1
  46. package/dist/routes/render.d.ts +6 -0
  47. package/dist/routes/render.d.ts.map +1 -1
  48. package/dist/routes/render.js +8 -11
  49. package/dist/routes/render.js.map +1 -1
  50. package/dist/routes/share.d.ts +2 -0
  51. package/dist/routes/share.d.ts.map +1 -1
  52. package/dist/routes/share.js +30 -31
  53. package/dist/routes/share.js.map +1 -1
  54. package/dist/routes/skills.d.ts.map +1 -1
  55. package/dist/routes/skills.js +22 -8
  56. package/dist/routes/skills.js.map +1 -1
  57. package/dist/routes/web-search.d.ts.map +1 -1
  58. package/dist/routes/web-search.js +7 -2
  59. package/dist/routes/web-search.js.map +1 -1
  60. package/dist/share/staging.d.ts.map +1 -1
  61. package/dist/share/staging.js +2 -1
  62. package/dist/share/staging.js.map +1 -1
  63. package/dist/ws/clients.d.ts +13 -4
  64. package/dist/ws/clients.d.ts.map +1 -1
  65. package/dist/ws/clients.js +41 -5
  66. package/dist/ws/clients.js.map +1 -1
  67. package/dist/ws/connection.d.ts.map +1 -1
  68. package/dist/ws/connection.js +10 -2
  69. package/dist/ws/connection.js.map +1 -1
  70. package/dist/ws/host.d.ts +5 -0
  71. package/dist/ws/host.d.ts.map +1 -1
  72. package/dist/ws/host.js +18 -1
  73. package/dist/ws/host.js.map +1 -1
  74. package/package.json +3 -3
  75. package/src/app.ts +55 -23
  76. package/src/config/env.ts +18 -7
  77. package/src/files/walker.ts +66 -10
  78. package/src/index.ts +8 -0
  79. package/src/middleware/auth.ts +311 -48
  80. package/src/middleware/body-limit.ts +47 -0
  81. package/src/middleware/origin.ts +92 -24
  82. package/src/middleware/passkeys.ts +80 -29
  83. package/src/routes/brain.ts +7 -3
  84. package/src/routes/files.ts +2 -2
  85. package/src/routes/models.ts +17 -10
  86. package/src/routes/pi-auth.ts +10 -4
  87. package/src/routes/push.ts +10 -4
  88. package/src/routes/render.ts +14 -10
  89. package/src/routes/share.ts +35 -34
  90. package/src/routes/skills.ts +18 -8
  91. package/src/routes/web-search.ts +6 -2
  92. package/src/share/staging.ts +2 -1
  93. package/src/ws/clients.ts +45 -7
  94. package/src/ws/connection.ts +11 -2
  95. package/src/ws/host.ts +23 -1
@@ -1,32 +1,100 @@
1
- import type { Context } from "hono";
1
+ import type { Context, MiddlewareHandler } from "hono";
2
2
 
3
3
  /**
4
- * Same-origin enforcement for requests a browser sends without a preflight.
4
+ * Compare the browser's Origin with the request's externally visible origin.
5
5
  *
6
- * Every other state-changing route here reads `application/json`, which is not
7
- * a CORS-simple content type and so forces a preflight the attacker's origin
8
- * fails CSRF-safe by accident. A multipart upload has no such protection: it
9
- * is a simple request, and under `AUTH_MODE=tailscale` the credential is the
10
- * source IP rather than a cookie, so no SameSite flag stands in the way either.
11
- * Any page opened on the tailnet could otherwise POST into the brain.
12
- *
13
- * The check is free because the routes that use it are only ever called by the
14
- * app itself, same-origin. Same shape as the WebSocket upgrade's origin guard.
6
+ * Without a trusted proxy the server cannot know whether TLS was terminated
7
+ * upstream, so preserve the historical host+port comparison. Once proxy
8
+ * headers are trusted, include the scheme sourced from X-Forwarded-Proto.
15
9
  */
16
- export function isSameOriginRequest(c: Context, allowed: string[]): boolean {
17
- // Chromium and Firefox send this, and it is not settable by script.
18
- const site = c.req.header("sec-fetch-site");
19
- if (site) return site === "same-origin" || site === "none";
10
+ function originMatchesRequest(
11
+ c: Context,
12
+ origin: string,
13
+ trustProxy: boolean
14
+ ): boolean {
15
+ const host = c.req.header("host");
16
+ if (!host) return false;
17
+
18
+ const parsedOrigin = new URL(origin);
19
+ if (!trustProxy) return parsedOrigin.host === host;
20
+
21
+ const forwarded = c.req.header("x-forwarded-proto")?.split(",", 1)[0]?.trim();
22
+ const protocol = forwarded
23
+ ? `${forwarded.toLowerCase()}:`
24
+ : new URL(c.req.url).protocol;
25
+
26
+ return parsedOrigin.origin === new URL(`${protocol}//${host}`).origin;
27
+ }
20
28
 
21
- // Older browsers: fall back to Origin, and to the configured allowlist when
22
- // the deployment is split across origins.
29
+ /**
30
+ * Browser request origin policy shared by state-changing HTTP routes and the
31
+ * WebSocket upgrade.
32
+ *
33
+ * Fetch metadata is authoritative when it says same-origin (including the
34
+ * Vite proxy case where Host is rewritten). Older browsers fall back to an
35
+ * Origin comparison: host+port without TRUST_PROXY, or the full origin when
36
+ * proxy headers are trusted. Headerless non-browser clients remain accepted
37
+ * and are still subject to the route's auth guard. ALLOWED_ORIGINS is an
38
+ * additional path for explicitly configured split-topology deployments.
39
+ */
40
+ export function isSameOriginRequest(
41
+ c: Context,
42
+ allowedOrigins: readonly string[],
43
+ trustProxy = false
44
+ ): boolean {
45
+ const site = c.req.header("sec-fetch-site")?.toLowerCase();
23
46
  const origin = c.req.header("origin");
24
- if (!origin) return true; // A non-browser client; still gated by auth.
25
- if (allowed.length > 0) return allowed.includes(origin);
26
- try {
27
- const host = c.req.header("host");
28
- return !!host && new URL(origin).host === host;
29
- } catch {
30
- return false;
47
+
48
+ // An opaque sandboxed origin is present, not equivalent to a headerless
49
+ // non-browser client. Reject it even if other metadata claims same-origin.
50
+ if (origin?.toLowerCase() === "null") return false;
51
+
52
+ if (site === "same-origin" || site === "none") return true;
53
+
54
+ if (origin) {
55
+ try {
56
+ if (originMatchesRequest(c, origin, trustProxy)) return true;
57
+ } catch {
58
+ // Malformed Origin can still only match an allowlist by exact string.
59
+ }
31
60
  }
61
+
62
+ if (!site && !origin) return true;
63
+
64
+ return origin !== undefined && allowedOrigins.includes(origin);
65
+ }
66
+
67
+ /**
68
+ * Enforce the origin policy on every non-GET API request, with an optional
69
+ * additional allowlist confined to one route prefix.
70
+ */
71
+ export function originPolicy(
72
+ allowedOrigins: readonly string[],
73
+ trustProxy = false,
74
+ scopedAllowlist?: { pathPrefix: string; origins: readonly string[] }
75
+ ): MiddlewareHandler {
76
+ return async (c, next) => {
77
+ const requestAllowedOrigins =
78
+ scopedAllowlist && c.req.path.startsWith(scopedAllowlist.pathPrefix)
79
+ ? [...allowedOrigins, ...scopedAllowlist.origins]
80
+ : allowedOrigins;
81
+ if (
82
+ c.req.method !== "GET" &&
83
+ !isSameOriginRequest(c, requestAllowedOrigins, trustProxy)
84
+ ) {
85
+ return c.json({ error: "cross_origin_rejected" }, 403);
86
+ }
87
+ await next();
88
+ };
89
+ }
90
+
91
+ /** Require the exact JSON media type while allowing standard parameters. */
92
+ export function requireJson(): MiddlewareHandler {
93
+ return async (c, next) => {
94
+ const mediaType = c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
95
+ if (mediaType !== "application/json") {
96
+ return c.json({ error: "unsupported_media_type" }, 415);
97
+ }
98
+ await next();
99
+ };
32
100
  }
@@ -19,11 +19,18 @@ import {
19
19
  type AuthRuntime,
20
20
  issueSessionCookie,
21
21
  consumeLoginToken,
22
- LOGIN_RATE_LIMIT,
23
- GLOBAL_LOGIN_RATE_LIMIT,
22
+ isLoginBlocked,
23
+ recordLoginFailure,
24
+ PASSKEY_LOGIN_RATE_LIMIT,
25
+ acquirePasskeyVerification,
26
+ releasePasskeyVerification,
27
+ bumpSessionsEpoch,
24
28
  } from "./auth.js";
25
29
  import { clientIp } from "./tailscale.js";
26
30
  import type { WebAuthnConfig } from "../config/env.js";
31
+ import { readJsonBody } from "./body-limit.js";
32
+ import { requireJson } from "./origin.js";
33
+ import type { ClientSet } from "../ws/clients.js";
27
34
 
28
35
  /**
29
36
  * WebAuthn passkeys as an extension of `password` mode: the password bootstraps
@@ -43,9 +50,12 @@ import type { WebAuthnConfig } from "../config/env.js";
43
50
  /** Injected per-app dependencies for every passkey route and helper. */
44
51
  export interface PasskeyContext {
45
52
  db: Database;
53
+ /** Live sockets invalidated when a credential is revoked. */
54
+ clients: ClientSet;
55
+ /** WebAuthn identity plus ceremony-only origin and RP overrides. */
46
56
  webauthn: WebAuthnConfig;
47
57
  auth: AuthRuntime;
48
- /** ALLOWED_ORIGINS — split-topology origins also valid for ceremonies. */
58
+ /** ALLOWED_ORIGINS — globally allowed split-topology origins. */
49
59
  allowedOrigins: string[];
50
60
  /** Where ceremony failures are reported; absent means silence. */
51
61
  log?: Logger;
@@ -322,21 +332,17 @@ export function passkeyPublicRoutes(
322
332
  return c.json(options);
323
333
  });
324
334
 
325
- app.post("/auth/passkey/login-verify", async (c) => {
335
+ app.post("/auth/passkey/login-verify", requireJson(), async (c) => {
326
336
  if (mode !== "password") return notEnabled(c);
327
337
  const ip = clientIp(c, ctx.auth.trustProxy, ctx.auth.trustProxyHops) || "unknown";
328
- // Same buckets as password login: one combined online-guess budget.
329
- const perIpOk = consumeLoginToken(`ip:${ip}`, LOGIN_RATE_LIMIT);
330
- const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
331
- if (!perIpOk || !globalOk) {
332
- return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
333
- }
334
338
  const rp = resolveRp(c, ctx);
335
339
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
336
340
 
337
341
  let response: AuthenticationResponseJSON;
338
342
  try {
339
- response = await c.req.json();
343
+ const result = await readJsonBody<AuthenticationResponseJSON>(c);
344
+ if (result instanceof Response) return result;
345
+ response = result;
340
346
  } catch {
341
347
  return c.json({ error: "Invalid request body" }, 400);
342
348
  }
@@ -352,22 +358,59 @@ export function passkeyPublicRoutes(
352
358
  return fail();
353
359
  }
354
360
 
355
- try {
356
- const result = await verifyAuthentication({
357
- response,
358
- expectedChallenge: challenge,
359
- expectedOrigin: rp.origin,
360
- expectedRPID: rp.rpID,
361
- credential: toWebAuthnCredential(row),
361
+ // Only a credential for this RP carrying a live, single-use challenge can
362
+ // reach this budget. Random bodies are rejected above without allocating
363
+ // or incrementing attacker-controlled bucket keys.
364
+ const failureBucket = `pk:${ip}`;
365
+ if (isLoginBlocked(failureBucket, PASSKEY_LOGIN_RATE_LIMIT)) {
366
+ return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
367
+ }
368
+
369
+ if (!acquirePasskeyVerification(ip)) {
370
+ ctx.failures?.add(1, {
371
+ reason: "in_flight_limited",
372
+ ceremony: "authentication",
362
373
  });
363
- if (!result.verified) return fail();
364
- ctx.db
365
- .prepare(
366
- "UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
367
- )
368
- .run(result.authenticationInfo.newCounter, now(), row.id);
374
+ ctx.log?.emit({
375
+ severityText: "WARN",
376
+ body: "passkey verification capacity reached",
377
+ attributes: { ip },
378
+ });
379
+ return c.json({ error: "Too many login verifications in progress." }, 429);
380
+ }
381
+
382
+ try {
383
+ try {
384
+ const result = await verifyAuthentication({
385
+ response,
386
+ expectedChallenge: challenge,
387
+ expectedOrigin: rp.origin,
388
+ expectedRPID: rp.rpID,
389
+ credential: toWebAuthnCredential(row),
390
+ });
391
+ if (!result.verified) {
392
+ recordLoginFailure(failureBucket, PASSKEY_LOGIN_RATE_LIMIT);
393
+ ctx.failures?.add(1, {
394
+ reason: "verification_failed",
395
+ ceremony: "authentication",
396
+ });
397
+ ctx.log?.emit({
398
+ severityText: "WARN",
399
+ body: "passkey authentication failed",
400
+ });
401
+ return fail();
402
+ }
403
+ ctx.db
404
+ .prepare(
405
+ "UPDATE passkey_credentials SET counter = ?, last_used_at = ? WHERE id = ?"
406
+ )
407
+ .run(result.authenticationInfo.newCounter, now(), row.id);
408
+ } finally {
409
+ releasePasskeyVerification(ip);
410
+ }
369
411
  } catch (err) {
370
412
  const message = err instanceof Error ? err.message : String(err);
413
+ recordLoginFailure(failureBucket, PASSKEY_LOGIN_RATE_LIMIT);
371
414
  // The library throws on a counter regression — possible cloned
372
415
  // credential. Cloud passkeys legitimately sit at 0, so warn, don't
373
416
  // revoke.
@@ -390,7 +433,7 @@ export function passkeyPublicRoutes(
390
433
  return fail();
391
434
  }
392
435
 
393
- await issueSessionCookie(c, ctx.auth);
436
+ await issueSessionCookie(c, ctx.auth, ctx.db);
394
437
  return c.json({ ok: true });
395
438
  });
396
439
 
@@ -436,14 +479,19 @@ export function passkeyManagementRoutes(
436
479
  return c.json(options);
437
480
  });
438
481
 
439
- app.post("/auth/passkey/register-verify", async (c) => {
482
+ app.post("/auth/passkey/register-verify", requireJson(), async (c) => {
440
483
  if (mode !== "password") return notEnabled(c);
441
484
  const rp = resolveRp(c, ctx);
442
485
  if (!rp) return c.json({ error: "Origin not allowed" }, 400);
443
486
 
444
487
  let body: { response?: RegistrationResponseJSON; label?: unknown };
445
488
  try {
446
- body = await c.req.json();
489
+ const result = await readJsonBody<{
490
+ response?: RegistrationResponseJSON;
491
+ label?: unknown;
492
+ }>(c);
493
+ if (result instanceof Response) return result;
494
+ body = result;
447
495
  } catch {
448
496
  return c.json({ error: "Invalid request body" }, 400);
449
497
  }
@@ -510,11 +558,13 @@ export function passkeyManagementRoutes(
510
558
  return c.json({ credentials: rows.map(toSummary) });
511
559
  });
512
560
 
513
- app.put("/auth/passkey/:id", async (c) => {
561
+ app.put("/auth/passkey/:id", requireJson(), async (c) => {
514
562
  if (mode !== "password") return notEnabled(c);
515
563
  let body: { label?: unknown };
516
564
  try {
517
- body = await c.req.json();
565
+ const result = await readJsonBody<{ label?: unknown }>(c);
566
+ if (result instanceof Response) return result;
567
+ body = result;
518
568
  } catch {
519
569
  return c.json({ error: "Invalid request body" }, 400);
520
570
  }
@@ -531,6 +581,7 @@ export function passkeyManagementRoutes(
531
581
  .prepare("DELETE FROM passkey_credentials WHERE id = ?")
532
582
  .run(c.req.param("id"));
533
583
  if (result.changes === 0) return c.json({ error: "Unknown passkey" }, 404);
584
+ bumpSessionsEpoch(ctx.db, ctx.clients);
534
585
  return c.json({ ok: true });
535
586
  });
536
587
 
@@ -9,6 +9,8 @@ import {
9
9
  } from "../voice/keyterm-builder.js";
10
10
  import { existsSync } from "fs";
11
11
  import { join } from "path";
12
+ import { readJsonBody } from "../middleware/body-limit.js";
13
+ import { requireJson } from "../middleware/origin.js";
12
14
 
13
15
  export interface BrainRoutesDeps {
14
16
  brain: BrainClient;
@@ -288,13 +290,15 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
288
290
  });
289
291
  })
290
292
 
291
- .post("/brain/add", async (c) => {
292
- const body = await c.req.json<{
293
+ .post("/brain/add", requireJson(), async (c) => {
294
+ const result = await readJsonBody<{
293
295
  content: string;
294
296
  type?: string;
295
297
  title?: string;
296
298
  tags?: string[];
297
- }>();
299
+ }>(c);
300
+ if (result instanceof Response) return result;
301
+ const body = result;
298
302
  if (!body.content) {
299
303
  return c.json({ error: "Field 'content' is required" }, 400);
300
304
  }
@@ -24,7 +24,7 @@ function errorResponse(err: unknown, log?: Logger): { body: { error: string; siz
24
24
  return { body: { error: "file_too_large", size: err.size }, status: 413 };
25
25
  }
26
26
  log?.emit({ severityText: "ERROR", body: "file request failed", attributes: { error: err instanceof Error ? err.message : String(err) } });
27
- return { body: { error: err instanceof Error ? err.message : "internal_error" }, status: 500 };
27
+ return { body: { error: "internal_error" }, status: 500 };
28
28
  }
29
29
 
30
30
  export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Hono {
@@ -57,7 +57,7 @@ export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Ho
57
57
  "Content-Length": String(size),
58
58
  "Content-Disposition": "inline",
59
59
  "X-Content-Type-Options": "nosniff",
60
- "Content-Security-Policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'",
60
+ "Content-Security-Policy": "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; frame-ancestors 'none'",
61
61
  "Cache-Control": "private, max-age=0, must-revalidate",
62
62
  },
63
63
  });
@@ -18,6 +18,8 @@ import {
18
18
  } from "@schlessera/brain-ui-sdk";
19
19
  import type { BackendRegistry } from "../agent/backend.js";
20
20
  import type { ModelPricingState } from "../pricing/model-pricing.js";
21
+ import { readJsonBody } from "../middleware/body-limit.js";
22
+ import { requireJson } from "../middleware/origin.js";
21
23
  import {
22
24
  getBillingOverrides,
23
25
  getCustomOpenRouterModels,
@@ -97,8 +99,9 @@ export function createModelRoutes(deps: {
97
99
  return c.json(pricing.state());
98
100
  })
99
101
 
100
- .put("/models/hidden", async (c) => {
101
- const body = (await c.req.json().catch(() => null)) as unknown;
102
+ .put("/models/hidden", requireJson(), async (c) => {
103
+ const body = await readJsonBody(c).catch(() => null);
104
+ if (body instanceof Response) return body;
102
105
  const hidden = (body as { hidden?: unknown } | null)?.hidden;
103
106
  if (
104
107
  !Array.isArray(hidden) ||
@@ -113,8 +116,9 @@ export function createModelRoutes(deps: {
113
116
  return c.json(await buildCatalog());
114
117
  })
115
118
 
116
- .put("/models/default", async (c) => {
117
- const body = (await c.req.json().catch(() => null)) as unknown;
119
+ .put("/models/default", requireJson(), async (c) => {
120
+ const body = await readJsonBody(c).catch(() => null);
121
+ if (body instanceof Response) return body;
118
122
  const defaultId = (body as { defaultId?: unknown } | null)?.defaultId;
119
123
  if (defaultId !== null && typeof defaultId !== "string") {
120
124
  return c.json({ error: "defaultId must be a profile id or null" }, 400);
@@ -131,8 +135,9 @@ export function createModelRoutes(deps: {
131
135
  return c.json(await buildCatalog());
132
136
  })
133
137
 
134
- .put("/models/custom", async (c) => {
135
- const body = (await c.req.json().catch(() => null)) as unknown;
138
+ .put("/models/custom", requireJson(), async (c) => {
139
+ const body = await readJsonBody(c).catch(() => null);
140
+ if (body instanceof Response) return body;
136
141
  const models = (body as { models?: unknown } | null)?.models;
137
142
  if (!Array.isArray(models) || models.some((id) => typeof id !== "string")) {
138
143
  return c.json({ error: "models must be an array of OpenRouter model ids" }, 400);
@@ -185,8 +190,9 @@ export function createModelRoutes(deps: {
185
190
  return c.json(await buildCatalog());
186
191
  })
187
192
 
188
- .put("/models/thinking", async (c) => {
189
- const body = (await c.req.json().catch(() => null)) as unknown;
193
+ .put("/models/thinking", requireJson(), async (c) => {
194
+ const body = await readJsonBody(c).catch(() => null);
195
+ if (body instanceof Response) return body;
190
196
  const thinking = (body as { thinking?: unknown } | null)?.thinking;
191
197
  if (
192
198
  typeof thinking !== "object" ||
@@ -218,8 +224,9 @@ export function createModelRoutes(deps: {
218
224
  return c.json(await buildCatalog());
219
225
  })
220
226
 
221
- .put("/models/billing", async (c) => {
222
- const body = (await c.req.json().catch(() => null)) as unknown;
227
+ .put("/models/billing", requireJson(), async (c) => {
228
+ const body = await readJsonBody(c).catch(() => null);
229
+ if (body instanceof Response) return body;
223
230
  const billing = (body as { billing?: unknown } | null)?.billing;
224
231
  if (
225
232
  typeof billing !== "object" ||
@@ -17,6 +17,8 @@
17
17
  import { Hono } from "hono";
18
18
  import type { AgentConfig } from "../config/env.js";
19
19
  import { loadBackendModule, parsePiProfiles } from "../agent/backend.js";
20
+ import { readJsonBody } from "../middleware/body-limit.js";
21
+ import { requireJson } from "../middleware/origin.js";
20
22
 
21
23
  /** Structural mirror of the pi package's PiLoginFlow (primitives only). */
22
24
  export interface PiLoginFlowView {
@@ -104,8 +106,10 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
104
106
  const auth = await getAuth();
105
107
  return c.json({ providers: await auth.status(providers) });
106
108
  })
107
- .post("/pi-auth/login", async (c) => {
108
- const body = (await c.req.json().catch(() => null)) as {
109
+ .post("/pi-auth/login", requireJson(), async (c) => {
110
+ const result = await readJsonBody(c).catch(() => null);
111
+ if (result instanceof Response) return result;
112
+ const body = result as {
109
113
  providerId?: unknown;
110
114
  } | null;
111
115
  const providerId = typeof body?.providerId === "string" ? body.providerId : "";
@@ -138,8 +142,10 @@ export function createPiAuthRoutes(deps: PiAuthRoutesDeps): Hono {
138
142
  auth.cancelFlow(c.req.param("id"));
139
143
  return c.json({ ok: true });
140
144
  })
141
- .post("/pi-auth/logout", async (c) => {
142
- const body = (await c.req.json().catch(() => null)) as {
145
+ .post("/pi-auth/logout", requireJson(), async (c) => {
146
+ const result = await readJsonBody(c).catch(() => null);
147
+ if (result instanceof Response) return result;
148
+ const body = result as {
143
149
  providerId?: unknown;
144
150
  } | null;
145
151
  const providerId = typeof body?.providerId === "string" ? body.providerId : "";
@@ -2,6 +2,8 @@ import { Hono } from "hono";
2
2
  import { z } from "zod";
3
3
 
4
4
  import type { PushSender } from "../activity/push-sender.js";
5
+ import { readJsonBody } from "../middleware/body-limit.js";
6
+ import { requireJson } from "../middleware/origin.js";
5
7
 
6
8
  /**
7
9
  * Push subscription lifecycle. Behind the auth guard by mount position —
@@ -59,9 +61,11 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
59
61
  }
60
62
  })
61
63
 
62
- .post("/push/subscribe", async (c) => {
64
+ .post("/push/subscribe", requireJson(), async (c) => {
63
65
  try {
64
- const body = subscribeSchema.parse(await c.req.json());
66
+ const result = await readJsonBody(c);
67
+ if (result instanceof Response) return result;
68
+ const body = subscribeSchema.parse(result);
65
69
  sender.subscribe(body.subscription, body.label);
66
70
  return c.json({ ok: true });
67
71
  } catch (err) {
@@ -72,9 +76,11 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
72
76
  }
73
77
  })
74
78
 
75
- .post("/push/unsubscribe", async (c) => {
79
+ .post("/push/unsubscribe", requireJson(), async (c) => {
76
80
  try {
77
- const body = unsubscribeSchema.parse(await c.req.json());
81
+ const result = await readJsonBody(c);
82
+ if (result instanceof Response) return result;
83
+ const body = unsubscribeSchema.parse(result);
78
84
  return c.json({ removed: sender.unsubscribe(body.endpoint) });
79
85
  } catch (err) {
80
86
  return c.json(
@@ -3,16 +3,18 @@ import { Hono } from "hono";
3
3
  import { z } from "zod";
4
4
  import type { RenderRequest } from "@schlessera/brain-ui-sdk/protocol";
5
5
  import { buildHtmlDocument } from "@schlessera/brain-render-template";
6
+ import {
7
+ readJsonBody,
8
+ RENDER_BODY_LIMIT_BYTES,
9
+ } from "../middleware/body-limit.js";
10
+ import { requireJson } from "../middleware/origin.js";
6
11
 
7
12
  /**
8
- * Cap on the content string posted for rendering. The old 512KB bound
9
- * predated inlined assets: a shared document carries `data:` image URIs and
10
- * pre-rendered mermaid SVGs, which pass that on their own without the prose
11
- * being long. This is an HTTP body, not a socket frame, so it is not bounded
12
- * by the WebSocket budget.
13
+ * A shared document may carry inlined images and pre-rendered mermaid SVGs.
14
+ * The route's streaming reader owns its 5 MB request cap before JSON parsing;
15
+ * the schema validates shape only so it cannot disagree by counting UTF-16
16
+ * code units instead.
13
17
  */
14
- const MAX_CONTENT_BYTES = 4 * 1024 * 1024;
15
-
16
18
  /**
17
19
  * Rendering seam. The deployment owns the actual renderer process (e.g. the
18
20
  * network-denied headless Chrome in @schlessera/brain-render-puppeteer) and
@@ -25,14 +27,14 @@ export interface AppRenderer {
25
27
  }
26
28
 
27
29
  const bodySchema = z.object({
28
- content: z.string().min(1).max(MAX_CONTENT_BYTES),
30
+ content: z.string().min(1),
29
31
  contentType: z.enum(["markdown", "html"]),
30
32
  format: z.enum(["png", "pdf"]),
31
33
  title: z.string().max(200).optional(),
32
34
  }) satisfies z.ZodType<RenderRequest>;
33
35
 
34
36
  export function createRenderRoutes(renderer?: AppRenderer, log?: Logger) {
35
- return new Hono().post("/render", async (c) => {
37
+ return new Hono().post("/render", requireJson(), async (c) => {
36
38
  if (!renderer) {
37
39
  return c.json(
38
40
  { error: "render_unavailable", detail: "This deployment has no renderer configured" },
@@ -42,7 +44,9 @@ export function createRenderRoutes(renderer?: AppRenderer, log?: Logger) {
42
44
 
43
45
  let parsed;
44
46
  try {
45
- parsed = bodySchema.parse(await c.req.json());
47
+ const result = await readJsonBody(c, RENDER_BODY_LIMIT_BYTES);
48
+ if (result instanceof Response) return result;
49
+ parsed = bodySchema.parse(result);
46
50
  } catch (err) {
47
51
  return c.json({ error: "invalid_request", detail: err instanceof Error ? err.message : "bad body" }, 400);
48
52
  }
@@ -94,12 +94,14 @@ export interface ShareRoutesDeps {
94
94
  brainRoot: string;
95
95
  /** ALLOWED_ORIGINS — the same-origin check's split-topology allowlist. */
96
96
  allowedOrigins: string[];
97
+ /** Trust X-Forwarded-Proto when resolving the request's expected origin. */
98
+ trustProxy: boolean;
97
99
  /** Where failures are reported; absent means silence. */
98
100
  log?: Logger;
99
101
  }
100
102
 
101
103
  export function createShareRoutes(deps: ShareRoutesDeps): Hono {
102
- const { brainRoot, allowedOrigins, log } = deps;
104
+ const { brainRoot, allowedOrigins, trustProxy, log } = deps;
103
105
 
104
106
  /**
105
107
  * In-flight intakes. Each one holds its whole payload in memory while the
@@ -124,49 +126,48 @@ export function createShareRoutes(deps: ShareRoutesDeps): Hono {
124
126
  }
125
127
 
126
128
  return new Hono().post("/share", async (c) => {
127
- // See middleware/origin.ts: a multipart POST is a CORS-simple request, so it
128
- // reaches this route with no preflight, and in tailscale mode the credential
129
- // is the source IP. This route is only ever called by the app itself.
130
- if (!isSameOriginRequest(c, allowedOrigins)) {
129
+ // Keep the same shared policy at the route boundary as defense in depth for
130
+ // embedders that mount this exported route factory outside createApp().
131
+ if (!isSameOriginRequest(c, allowedOrigins, trustProxy)) {
131
132
  return c.json({ error: "cross_origin_rejected" }, 403);
132
133
  }
133
134
 
134
135
  if (inFlight >= SHARE_MAX_CONCURRENT_INTAKE) {
135
136
  return c.json({ error: "busy" }, 503);
136
137
  }
138
+ inFlight += 1;
137
139
 
138
- const tooLarge = () =>
139
- c.json({ error: "share_too_large", limit: SHARE_MAX_TOTAL_BYTES }, 413);
140
-
141
- // Cheap early-out for a client that declares its size honestly; the streamed
142
- // count below is what actually enforces the cap.
143
- const declaredLength = Number(c.req.header("content-length") ?? "0");
144
- if (Number.isFinite(declaredLength) && declaredLength > HARD_BODY_LIMIT) {
145
- return tooLarge();
146
- }
147
-
148
- const raw = await readCappedBody(c.req.raw, HARD_BODY_LIMIT);
149
- if (raw === null) return tooLarge();
150
-
151
- let form: FormData;
152
140
  try {
153
- const contentType = c.req.header("content-type");
154
- form = await new Response(raw, {
155
- headers: contentType ? { "content-type": contentType } : {},
156
- }).formData();
157
- } catch {
158
- // A truncated upload or a malformed multipart body. The share is gone
159
- // either way; the client re-offers it from its own copy.
160
- return c.json({ error: "invalid_form" }, 400);
161
- }
141
+ const tooLarge = () =>
142
+ c.json({ error: "share_too_large", limit: SHARE_MAX_TOTAL_BYTES }, 413);
143
+
144
+ // Cheap early-out for a client that declares its size honestly; the streamed
145
+ // count below is what actually enforces the cap.
146
+ const declaredLength = Number(c.req.header("content-length") ?? "0");
147
+ if (Number.isFinite(declaredLength) && declaredLength > HARD_BODY_LIMIT) {
148
+ return tooLarge();
149
+ }
162
150
 
163
- // Empty parts are what an app sends when it has nothing to attach.
164
- const files = form
165
- .getAll("files")
166
- .filter((value): value is File => value instanceof File && value.size > 0);
151
+ const raw = await readCappedBody(c.req.raw, HARD_BODY_LIMIT);
152
+ if (raw === null) return tooLarge();
153
+
154
+ let form: FormData;
155
+ try {
156
+ const contentType = c.req.header("content-type");
157
+ form = await new Response(raw, {
158
+ headers: contentType ? { "content-type": contentType } : {},
159
+ }).formData();
160
+ } catch {
161
+ // A truncated upload or a malformed multipart body. The share is gone
162
+ // either way; the client re-offers it from its own copy.
163
+ return c.json({ error: "invalid_form" }, 400);
164
+ }
165
+
166
+ // Empty parts are what an app sends when it has nothing to attach.
167
+ const files = form
168
+ .getAll("files")
169
+ .filter((value): value is File => value instanceof File && value.size > 0);
167
170
 
168
- inFlight += 1;
169
- try {
170
171
  const result = await stageShare(
171
172
  brainRoot,
172
173
  {