@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
package/src/config/env.ts CHANGED
@@ -19,7 +19,10 @@ import type { BillingMode } from "@schlessera/brain-ui-sdk/protocol";
19
19
 
20
20
  import { SEVERITIES, type Severity } from "../observability/types.js";
21
21
  import { envFlag } from "./env-core.js";
22
- import { WEB_SEARCH_PROVIDERS } from "@schlessera/brain-ui-sdk/server";
22
+ import {
23
+ filterSubprocessEnv,
24
+ WEB_SEARCH_PROVIDERS,
25
+ } from "@schlessera/brain-ui-sdk/server";
23
26
 
24
27
  // --- descriptor -------------------------------------------------------------
25
28
 
@@ -126,6 +129,12 @@ export const ENV_VARS: readonly EnvVarDescriptor[] = [
126
129
  default: "the shipped pattern set",
127
130
  required: false,
128
131
  },
132
+ {
133
+ name: "BRAIN_UI_WS_MAX_CONNECTIONS",
134
+ description: "Maximum number of WebSocket connections accepted by one server process.",
135
+ default: "32",
136
+ required: false,
137
+ },
129
138
  {
130
139
  name: "BRAIN_UI_WS_RATE",
131
140
  description:
@@ -471,6 +480,8 @@ export interface ServerConfig {
471
480
  logLevel: Severity;
472
481
  /** Inbound WebSocket frame metering, per connection. */
473
482
  wsRate: { ratePerSecond: number; burst: number };
483
+ /** Maximum WebSocket connections accepted by one server process. */
484
+ wsMaxConnections: number;
474
485
  auth: AuthConfig;
475
486
  webauthn: WebAuthnConfig;
476
487
  agent: AgentConfig;
@@ -633,6 +644,7 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
633
644
  ambientBilling: resolveAmbientBillingMode(env),
634
645
  },
635
646
  logLevel: parseSeverity(env.BRAIN_UI_LOG_LEVEL),
647
+ wsMaxConnections: positiveNumber(env.BRAIN_UI_WS_MAX_CONNECTIONS, 32),
636
648
  wsRate: {
637
649
  ratePerSecond: positiveNumber(env.BRAIN_UI_WS_RATE, 20),
638
650
  burst: positiveNumber(env.BRAIN_UI_WS_BURST, 60),
@@ -651,14 +663,13 @@ export function resolveServerConfig(env: EnvRecord = process.env): ServerConfig
651
663
  }
652
664
 
653
665
  /**
654
- * The parent environment for spawned subprocesses (brain CLI, whatsup), plus
655
- * overrides. Child processes legitimately inherit the whole environment
656
- * (PATH, credentials for the tools they run) that is process plumbing, not
657
- * configuration, but it still reads `process.env`, so it lives behind this
658
- * chokepoint.
666
+ * The filtered parent environment for spawned subprocesses (brain CLI,
667
+ * whatsup), plus explicit overrides. The shared descriptor strips server-only
668
+ * material while retaining all known subprocess capabilities and unknown
669
+ * variables. This reads `process.env`, so it lives behind this chokepoint.
659
670
  */
660
671
  export function subprocessEnv(extra: Record<string, string> = {}): EnvRecord {
661
- return { ...process.env, ...extra };
672
+ return { ...filterSubprocessEnv(process.env), ...extra };
662
673
  }
663
674
 
664
675
  /**
@@ -1,6 +1,5 @@
1
- import { readdir, readFile, stat, realpath } from "node:fs/promises";
2
- import { join, resolve, sep, posix, dirname } from "node:path";
3
- import { existsSync } from "node:fs";
1
+ import { lstat, readdir, readFile, stat, realpath } from "node:fs/promises";
2
+ import { join, relative, resolve, sep, posix, dirname } from "node:path";
4
3
  import ignore, { type Ignore } from "ignore";
5
4
  import type { FileEntry, FileContentKind } from "@schlessera/brain-ui-sdk/protocol";
6
5
  import { FILE_SIZE_CAP_BYTES } from "@schlessera/brain-ui-sdk/protocol";
@@ -25,6 +24,15 @@ const HARD_EXCLUDE_FILE_NAMES = new Set(
25
24
  ].map((n) => n.toLowerCase())
26
25
  );
27
26
 
27
+ const UNRESOLVABLE_PATH_CODES = new Set([
28
+ "ENOENT",
29
+ "ENOTDIR",
30
+ "ENAMETOOLONG",
31
+ "ELOOP",
32
+ "EACCES",
33
+ "EPERM",
34
+ ]);
35
+
28
36
  export class PathEscapeError extends Error {
29
37
  constructor(rel: string) {
30
38
  super(`path_escape: ${rel}`);
@@ -52,8 +60,14 @@ export class TooLargeError extends Error {
52
60
  */
53
61
  export async function safeResolve(rel: string, root: string): Promise<string> {
54
62
  if (typeof rel !== "string") throw new PathEscapeError(String(rel));
55
- // Reject absolute paths and null bytes
56
- if (rel.startsWith("/") || rel.startsWith("\\") || rel.includes("\0")) {
63
+ const requestSegments = rel.split("/");
64
+ // Reject absolute paths, null bytes, and Windows path syntax before resolving.
65
+ if (
66
+ rel.startsWith("/") ||
67
+ rel.startsWith("\\") ||
68
+ rel.includes("\0") ||
69
+ requestSegments.some((segment) => segment.includes(":") || segment.includes("\\"))
70
+ ) {
57
71
  throw new PathEscapeError(rel);
58
72
  }
59
73
  // Normalize separators; reject ".." segments anywhere
@@ -61,14 +75,49 @@ export async function safeResolve(rel: string, root: string): Promise<string> {
61
75
  for (const seg of normalized.split("/")) {
62
76
  if (seg === "..") throw new PathEscapeError(rel);
63
77
  }
64
- const absRoot = resolve(root);
78
+ const lexicalRoot = resolve(root);
79
+ let absRoot: string;
80
+ try {
81
+ absRoot = await realpath(lexicalRoot);
82
+ } catch (err) {
83
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
84
+ absRoot = lexicalRoot;
85
+ }
65
86
  const abs = resolve(absRoot, normalized);
66
87
  if (abs !== absRoot && !abs.startsWith(absRoot + sep)) {
67
88
  throw new PathEscapeError(rel);
68
89
  }
69
- // Check for symlink escape via realpath when the path exists
70
- if (existsSync(abs)) {
71
- const real = await realpath(abs);
90
+ // Canonicalize every existing component. Stopping at the first genuinely
91
+ // missing component permits callers to create a new path, while lstat
92
+ // distinguishes that case from a dangling symlink, which must fail closed.
93
+ let candidate = absRoot;
94
+ const candidates = [candidate];
95
+ for (const segment of relative(absRoot, abs).split(sep)) {
96
+ if (!segment || segment === ".") continue;
97
+ candidate = join(candidate, segment);
98
+ candidates.push(candidate);
99
+ }
100
+
101
+ for (const existing of candidates) {
102
+ try {
103
+ await lstat(existing);
104
+ } catch (err) {
105
+ const code = (err as NodeJS.ErrnoException).code;
106
+ if (code && UNRESOLVABLE_PATH_CODES.has(code)) break;
107
+ throw err;
108
+ }
109
+
110
+ let real: string;
111
+ try {
112
+ real = await realpath(existing);
113
+ } catch (err) {
114
+ const code = (err as NodeJS.ErrnoException).code;
115
+ if (code === "ENOENT" || code === "ENOTDIR") {
116
+ throw new PathEscapeError(rel);
117
+ }
118
+ if (code && UNRESOLVABLE_PATH_CODES.has(code)) break;
119
+ throw err;
120
+ }
72
121
  if (real !== absRoot && !real.startsWith(absRoot + sep)) {
73
122
  throw new PathEscapeError(rel);
74
123
  }
@@ -373,7 +422,14 @@ export async function buildWikilinkMap(root: string): Promise<Record<string, str
373
422
  }
374
423
 
375
424
  if (childStat.isDirectory()) {
376
- await walk(relChild);
425
+ try {
426
+ await walk(relChild);
427
+ } catch (err) {
428
+ // Directory names come from readdir rather than an external request.
429
+ // Keep request-facing validation strict, but skip a discovered path
430
+ // that safeResolve rejects instead of discarding the whole map.
431
+ if (!(err instanceof PathEscapeError)) throw err;
432
+ }
377
433
  continue;
378
434
  }
379
435
  const lower = d.name.toLowerCase();
package/src/index.ts CHANGED
@@ -40,6 +40,9 @@ export {
40
40
  type ModelDiscoveryState,
41
41
  } from "./agent/backend.js";
42
42
  export {
43
+ authGuard,
44
+ bumpSessionsEpoch,
45
+ isWsAuthorized,
43
46
  resolveAuthMode,
44
47
  type AuthMode,
45
48
  type AuthRuntime,
@@ -107,6 +110,11 @@ export {
107
110
  // intake route also sweeps opportunistically on every share.
108
111
  export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
109
112
 
113
+ // Skill archive transport sizing for deployment shells. The route keeps the
114
+ // application-level check; consumers use this value only to avoid setting a
115
+ // smaller process-level request ceiling.
116
+ export { MAX_ARCHIVE_BYTES } from "./skills/install.js";
117
+
110
118
  // Observability: the producing side is the OpenTelemetry API, the consuming
111
119
  // side is ours. Swap the consumer to change where a deployment reports; a test
112
120
  // swaps in the recording one and asserts on what the server actually said.