@schlessera/brain-ui-server 0.12.1 → 0.13.1

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/dist/ws/turns.js CHANGED
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Bytes one queued entry keeps alive in this process until its turn runs.
3
+ *
4
+ * Attachments dominate and are still base64 here — the host never decodes a
5
+ * follow-up's images, it hands the same strings to the backend when the turn
6
+ * starts — so their wire length IS the retained size. (A JS string may cost
7
+ * two bytes per character internally; this deliberately measures the payload,
8
+ * not the engine's representation, so the number matches what the sender put
9
+ * on the wire.) The client snapshot is a handful of short fields and is not
10
+ * worth walking.
11
+ */
12
+ export function queuedFollowUpBytes(entry) {
13
+ let bytes = Buffer.byteLength(entry.text, "utf-8");
14
+ for (const attachment of entry.attachments) {
15
+ bytes += attachment.data.length;
16
+ }
17
+ return bytes;
18
+ }
19
+ /** Total bytes currently parked in a session's follow-up queue. */
20
+ export function queuedBytes(turn) {
21
+ let bytes = 0;
22
+ for (const entry of turn.queue)
23
+ bytes += queuedFollowUpBytes(entry);
24
+ return bytes;
25
+ }
1
26
  /**
2
27
  * Mutable turn state for one ws host: the running session slots and every
3
28
  * pending interactive round-trip. This used to be six module-level globals
@@ -1 +1 @@
1
- {"version":3,"file":"turns.js","sourceRoot":"","sources":["../../src/ws/turns.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"turns.js","sourceRoot":"","sources":["../../src/ws/turns.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schlessera/brain-ui-server",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "brain-kit chat-UI server: Hono app factory, WebSocket turn coordinator, auth (password/passkeys/tailscale/proxy), session catalog, and brain/files/voice routes",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -45,9 +45,9 @@
45
45
  "prepublishOnly": "bun ../../scripts/check-dist.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@schlessera/brain-backend-claude": "0.12.1",
49
- "@schlessera/brain-render-template": "0.12.1",
50
- "@schlessera/brain-ui-sdk": "0.12.1",
48
+ "@schlessera/brain-backend-claude": "0.13.1",
49
+ "@schlessera/brain-render-template": "0.13.1",
50
+ "@schlessera/brain-ui-sdk": "0.13.1",
51
51
  "@simplewebauthn/server": "^13.3.2",
52
52
  "hono": "^4",
53
53
  "ignore": "^7.0.5",
package/src/app.ts CHANGED
@@ -9,6 +9,7 @@ import { brainRoutes } from "./routes/brain.js";
9
9
  import { sessionRoutes } from "./routes/sessions.js";
10
10
  import { voiceRoutes } from "./routes/voice.js";
11
11
  import { filesRoutes } from "./routes/files.js";
12
+ import { shareRoutes, shareTargetFallbackRoutes } from "./routes/share.js";
12
13
  import { createRenderRoutes, type AppRenderer } from "./routes/render.js";
13
14
  import { providerRoutes } from "./routes/providers.js";
14
15
  import { modelRoutes } from "./routes/models.js";
@@ -114,6 +115,9 @@ export function createApp(options: CreateAppOptions = {}) {
114
115
  // /api/status is intentionally NOT here — it leaks the git SHA, cron errors,
115
116
  // and a session oracle, so it lives behind the guard below.
116
117
  app.route("/api", healthRoutes);
118
+ // Not under /api, and not behind the guard: this is where a system share
119
+ // lands when no service worker was around to intercept it. See the route.
120
+ app.route("/", shareTargetFallbackRoutes);
117
121
  app.route("/api", authRoutes(authMode, { passwordDisabled: passwordLoginDisabled }));
118
122
  app.route("/api", passkeyPublicRoutes(authMode));
119
123
 
@@ -130,6 +134,7 @@ export function createApp(options: CreateAppOptions = {}) {
130
134
  app.route("/api", sessionRoutes);
131
135
  app.route("/api", voiceRoutes);
132
136
  app.route("/api", filesRoutes);
137
+ app.route("/api", shareRoutes);
133
138
  app.route("/api", createRenderRoutes(options.renderer));
134
139
  app.route("/api", providerRoutes);
135
140
  app.route("/api", modelRoutes);
package/src/index.ts CHANGED
@@ -35,5 +35,9 @@ export {
35
35
  // their own routes on top.
36
36
  export * as brainClient from "./brain/client.js";
37
37
 
38
+ // Share staging: a deployment can sweep expired staging dirs at boot; the
39
+ // intake route also sweeps opportunistically on every share.
40
+ export { pruneShareStaging, shareStagingRoot } from "./share/staging.js";
41
+
38
42
  // Voice keyterm cache rebuild (used by deployments after `brain sync`).
39
43
  export { buildKeyterms, writeCache } from "./voice/keyterm-builder.js";
@@ -0,0 +1,39 @@
1
+ import type { Context } from "hono";
2
+
3
+ /**
4
+ * Same-origin enforcement for requests a browser sends without a preflight.
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.
15
+ */
16
+ export function allowedOriginList(): string[] {
17
+ return (process.env.ALLOWED_ORIGINS ?? "")
18
+ .split(",")
19
+ .map((origin) => origin.trim())
20
+ .filter(Boolean);
21
+ }
22
+
23
+ export function isSameOriginRequest(c: Context, allowed = allowedOriginList()): boolean {
24
+ // Chromium and Firefox send this, and it is not settable by script.
25
+ const site = c.req.header("sec-fetch-site");
26
+ if (site) return site === "same-origin" || site === "none";
27
+
28
+ // Older browsers: fall back to Origin, and to the configured allowlist when
29
+ // the deployment is split across origins.
30
+ const origin = c.req.header("origin");
31
+ if (!origin) return true; // A non-browser client; still gated by auth.
32
+ if (allowed.length > 0) return allowed.includes(origin);
33
+ try {
34
+ const host = c.req.header("host");
35
+ return !!host && new URL(origin).host === host;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
@@ -0,0 +1,178 @@
1
+ import { Hono } from "hono";
2
+ import {
3
+ SHARE_MAX_CONCURRENT_INTAKE,
4
+ SHARE_MAX_TOTAL_BYTES,
5
+ } from "@schlessera/brain-ui-sdk/protocol";
6
+ import { isSameOriginRequest } from "../middleware/origin.js";
7
+ import {
8
+ EmptyShareError,
9
+ ShareTooLargeError,
10
+ pruneShareStaging,
11
+ stageShare,
12
+ } from "../share/staging.js";
13
+
14
+ /**
15
+ * Slack over the total cap for multipart framing (boundaries, part headers, and
16
+ * the title/text/url fields).
17
+ */
18
+ const BODY_SLACK_BYTES = 1_000_000;
19
+ const HARD_BODY_LIMIT = SHARE_MAX_TOTAL_BYTES + BODY_SLACK_BYTES;
20
+
21
+ /**
22
+ * Read the body, refusing to buffer more than `limit` bytes.
23
+ *
24
+ * `content-length` is only a hint — HTTP/2 and chunked transfer encoding omit
25
+ * it entirely, and a client is free to lie — so the cap has to be counted off
26
+ * the stream itself. Calling `formData()` first would hand an unbounded body to
27
+ * the multipart parser and let an authenticated caller exhaust memory well
28
+ * inside the advertised limits.
29
+ *
30
+ * Returns null when the body exceeds the limit.
31
+ */
32
+ async function readCappedBody(
33
+ request: Request,
34
+ limit: number
35
+ ): Promise<Blob | null> {
36
+ const stream = request.body;
37
+ if (!stream) return new Blob([]);
38
+
39
+ const reader = stream.getReader();
40
+ const chunks: BlobPart[] = [];
41
+ let total = 0;
42
+ try {
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+ if (!value) continue;
47
+ total += value.byteLength;
48
+ if (total > limit) return null;
49
+ // Copy out of the reader's view: the underlying buffer may be reused.
50
+ chunks.push(value.slice().buffer as ArrayBuffer);
51
+ }
52
+ } finally {
53
+ reader.releaseLock();
54
+ // Nothing more is wanted from an over-limit body; let the peer find out.
55
+ if (total > limit) await stream.cancel().catch(() => {});
56
+ }
57
+
58
+ // Hand the chunks to the parser as they are. Concatenating them into one
59
+ // buffer first would copy the whole payload a second time, for nothing.
60
+ return new Blob(chunks);
61
+ }
62
+
63
+ /**
64
+ * Answer a share that reached the SERVER — which means no service worker was
65
+ * there to intercept it.
66
+ *
67
+ * That happens: the worker is evicted, storage was cleared, or the app was
68
+ * installed before the worker activated. Android bakes the share target's
69
+ * intent filters into the WebAPK at install time, so the share sheet keeps
70
+ * offering the app regardless, and the POST lands here. Without this route the
71
+ * user gets a bare 404 inside the app window (the SPA fallback is GET-only).
72
+ *
73
+ * The body is deliberately not read: the payload cannot be recovered from here
74
+ * anyway. Landing in the app is still the best outcome, because loading the app
75
+ * re-registers the worker and the next share works.
76
+ *
77
+ * Registered BEFORE the auth guard, and it must stay there: a share navigation
78
+ * is cross-site, so the SameSite=Strict session cookie is absent by
79
+ * construction and an authenticated version of this route could never fire.
80
+ */
81
+ export const shareTargetFallbackRoutes = new Hono().post("/share-target", (c) =>
82
+ c.redirect("/?share_error=no_worker", 303)
83
+ );
84
+
85
+ /**
86
+ * In-flight intakes. Each one holds its whole payload in memory while the
87
+ * multipart parser runs, so without a bound the per-share cap multiplies by
88
+ * however many clients ask at once — on a box that also runs headless Chrome
89
+ * for the renderer.
90
+ */
91
+ let inFlight = 0;
92
+
93
+ /** At most one sweep per interval: the intake path should not stat the inbox on every upload. */
94
+ let lastPrune = 0;
95
+ const PRUNE_INTERVAL_MS = 10 * 60 * 1000;
96
+
97
+ function maybePrune(): void {
98
+ const now = Date.now();
99
+ if (now - lastPrune < PRUNE_INTERVAL_MS) return;
100
+ lastPrune = now;
101
+ // Deliberately not awaited: pruning is housekeeping, and the client is
102
+ // waiting on the staging result, not on it.
103
+ void pruneShareStaging().catch((err) => {
104
+ console.error("[share] prune failed:", err);
105
+ });
106
+ }
107
+
108
+ function firstString(form: FormData, name: string): string | undefined {
109
+ const value = form.get(name);
110
+ return typeof value === "string" && value.trim() ? value : undefined;
111
+ }
112
+
113
+ export const shareRoutes = new Hono().post("/share", async (c) => {
114
+ // See middleware/origin.ts: a multipart POST is a CORS-simple request, so it
115
+ // reaches this route with no preflight, and in tailscale mode the credential
116
+ // is the source IP. This route is only ever called by the app itself.
117
+ if (!isSameOriginRequest(c)) {
118
+ return c.json({ error: "cross_origin_rejected" }, 403);
119
+ }
120
+
121
+ if (inFlight >= SHARE_MAX_CONCURRENT_INTAKE) {
122
+ return c.json({ error: "busy" }, 503);
123
+ }
124
+
125
+ const tooLarge = () =>
126
+ c.json({ error: "share_too_large", limit: SHARE_MAX_TOTAL_BYTES }, 413);
127
+
128
+ // Cheap early-out for a client that declares its size honestly; the streamed
129
+ // count below is what actually enforces the cap.
130
+ const declaredLength = Number(c.req.header("content-length") ?? "0");
131
+ if (Number.isFinite(declaredLength) && declaredLength > HARD_BODY_LIMIT) {
132
+ return tooLarge();
133
+ }
134
+
135
+ const raw = await readCappedBody(c.req.raw, HARD_BODY_LIMIT);
136
+ if (raw === null) return tooLarge();
137
+
138
+ let form: FormData;
139
+ try {
140
+ const contentType = c.req.header("content-type");
141
+ form = await new Response(raw, {
142
+ headers: contentType ? { "content-type": contentType } : {},
143
+ }).formData();
144
+ } catch {
145
+ // A truncated upload or a malformed multipart body. The share is gone
146
+ // either way; the client re-offers it from its own copy.
147
+ return c.json({ error: "invalid_form" }, 400);
148
+ }
149
+
150
+ // Empty parts are what an app sends when it has nothing to attach.
151
+ const files = form
152
+ .getAll("files")
153
+ .filter((value): value is File => value instanceof File && value.size > 0);
154
+
155
+ inFlight += 1;
156
+ try {
157
+ const result = await stageShare({
158
+ title: firstString(form, "title"),
159
+ text: firstString(form, "text"),
160
+ url: firstString(form, "url"),
161
+ files,
162
+ });
163
+
164
+ maybePrune();
165
+ return c.json(result, 201);
166
+ } catch (err) {
167
+ if (err instanceof EmptyShareError) {
168
+ return c.json({ error: "empty_share" }, 400);
169
+ }
170
+ if (err instanceof ShareTooLargeError) {
171
+ return c.json({ error: err.reason, limit: err.limit }, 413);
172
+ }
173
+ console.error("[share]", err);
174
+ return c.json({ error: "share_failed" }, 500);
175
+ } finally {
176
+ inFlight -= 1;
177
+ }
178
+ });