@schlessera/brain-ui-server 0.30.1 → 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.
- package/README.md +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +49 -25
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +6 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +13 -7
- package/dist/config/env.js.map +1 -1
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +70 -10
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +25 -5
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +255 -47
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +15 -0
- package/dist/middleware/body-limit.d.ts.map +1 -0
- package/dist/middleware/body-limit.js +41 -0
- package/dist/middleware/body-limit.js.map +1 -0
- package/dist/middleware/origin.d.ts +20 -12
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +71 -25
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +5 -1
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +67 -26
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +7 -2
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.js +2 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +22 -10
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/pi-auth.d.ts.map +1 -1
- package/dist/routes/pi-auth.js +12 -4
- package/dist/routes/pi-auth.js.map +1 -1
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +12 -4
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/render.d.ts +6 -0
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +8 -11
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +2 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +30 -31
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/skills.js +22 -8
- package/dist/routes/skills.js.map +1 -1
- package/dist/routes/web-search.d.ts.map +1 -1
- package/dist/routes/web-search.js +7 -2
- package/dist/routes/web-search.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +2 -1
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/clients.d.ts +13 -4
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +41 -5
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +10 -2
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/host.d.ts +5 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +18 -1
- package/dist/ws/host.js.map +1 -1
- package/package.json +3 -3
- package/src/app.ts +55 -23
- package/src/config/env.ts +18 -7
- package/src/files/walker.ts +66 -10
- package/src/index.ts +8 -0
- package/src/middleware/auth.ts +311 -48
- package/src/middleware/body-limit.ts +47 -0
- package/src/middleware/origin.ts +92 -24
- package/src/middleware/passkeys.ts +80 -29
- package/src/routes/brain.ts +7 -3
- package/src/routes/files.ts +2 -2
- package/src/routes/models.ts +17 -10
- package/src/routes/pi-auth.ts +10 -4
- package/src/routes/push.ts +10 -4
- package/src/routes/render.ts +14 -10
- package/src/routes/share.ts +35 -34
- package/src/routes/skills.ts +18 -8
- package/src/routes/web-search.ts +6 -2
- package/src/share/staging.ts +2 -1
- package/src/ws/clients.ts +45 -7
- package/src/ws/connection.ts +11 -2
- package/src/ws/host.ts +23 -1
package/src/middleware/origin.ts
CHANGED
|
@@ -1,32 +1,100 @@
|
|
|
1
|
-
import type { Context } from "hono";
|
|
1
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Compare the browser's Origin with the request's externally visible origin.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/routes/brain.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/routes/files.ts
CHANGED
|
@@ -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:
|
|
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
|
});
|
package/src/routes/models.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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" ||
|
package/src/routes/pi-auth.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 : "";
|
package/src/routes/push.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
package/src/routes/render.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
}
|
package/src/routes/share.ts
CHANGED
|
@@ -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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
{
|