security update + make easter egg optional.
This commit is contained in:
@@ -58,6 +58,7 @@ import { handleRegistrationRoutes } from "./server/routes/registration-routes";
|
||||
import { consumeHttpDownloadPayload, type PendingHttpDownload } from "./server/http-download-consumer";
|
||||
import { startBuildProcess as runBuildProcess } from "./server/build-process";
|
||||
import { createHttpFetchHandler } from "./server/http-dispatch";
|
||||
import { createHonoRouteHandler } from "./server/hono-router";
|
||||
import { startMaintenanceLoops } from "./server/maintenance-loops";
|
||||
import {
|
||||
deliverNotificationWithScreenshot,
|
||||
@@ -711,6 +712,13 @@ async function startServer() {
|
||||
handleCrashReport: notificationPluginHandlers.handleCrashReport,
|
||||
};
|
||||
|
||||
const routedHttpHandler = createHonoRouteHandler([
|
||||
{
|
||||
basePath: "/api/users",
|
||||
handler: (req, url, srv) => handleUsersRoutes(req, url, srv as any),
|
||||
},
|
||||
]);
|
||||
|
||||
const server = Bun.serve<SocketData>({
|
||||
port: PORT,
|
||||
hostname: HOST,
|
||||
@@ -742,7 +750,7 @@ async function startServer() {
|
||||
(req, url) => handleEnrollmentRoutes(req, url),
|
||||
(req, url) => handleChatRoutes(req, url),
|
||||
(req, url) => handleSolRoutes(req, url),
|
||||
(req, url, srv) => handleUsersRoutes(req, url, srv as any),
|
||||
routedHttpHandler,
|
||||
(req, url, srv) => handlePermissionGroupsRoutes(req, url, srv as any),
|
||||
(req, url, srv) => handleBuildRoutes(req, url, srv as any, routeDeps.build),
|
||||
(req, url, srv) => handleDeployRoutes(req, url, srv as any, routeDeps.deploy),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHonoRouteHandler } from "./hono-router";
|
||||
|
||||
describe("createHonoRouteHandler", () => {
|
||||
const routed = createHonoRouteHandler([
|
||||
{
|
||||
basePath: "/api/users",
|
||||
handler: async (_req, url, server) => Response.json({
|
||||
path: url.pathname,
|
||||
server,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
test("routes exact and nested group paths", async () => {
|
||||
const server = { name: "test-server" };
|
||||
const exactUrl = new URL("https://localhost/api/users");
|
||||
const exact = await routed(new Request(exactUrl), exactUrl, server);
|
||||
expect(exact?.status).toBe(200);
|
||||
expect(await exact?.json()).toEqual({ path: "/api/users", server });
|
||||
expect(exact?.headers.get("x-request-id")).toBeTruthy();
|
||||
|
||||
const nestedUrl = new URL("https://localhost/api/users/42/role");
|
||||
const nested = await routed(new Request(nestedUrl), nestedUrl, server);
|
||||
expect(nested?.status).toBe(200);
|
||||
expect((await nested?.json()).path).toBe("/api/users/42/role");
|
||||
});
|
||||
|
||||
test("does not claim similar or unrelated paths", async () => {
|
||||
for (const path of ["/api/users-other", "/api/clients"]) {
|
||||
const url = new URL(`https://localhost${path}`);
|
||||
expect(await routed(new Request(url), url, {})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls through when a migrated handler does not own a nested route", async () => {
|
||||
const fallthrough = createHonoRouteHandler([
|
||||
{
|
||||
basePath: "/api/users",
|
||||
handler: async () => null,
|
||||
},
|
||||
]);
|
||||
const url = new URL("https://localhost/api/users/42/permission-groups");
|
||||
expect(await fallthrough(new Request(url), url, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Hono, type Handler } from "hono";
|
||||
import { requestId } from "hono/request-id";
|
||||
import type { RouteHandler } from "./http-dispatch";
|
||||
|
||||
type RouterEnv = {
|
||||
Bindings: {
|
||||
server: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const FALLTHROUGH_HEADER = "X-Overlord-Route-Fallthrough";
|
||||
|
||||
export type HonoRouteGroup = {
|
||||
basePath: `/${string}`;
|
||||
handler: RouteHandler;
|
||||
};
|
||||
|
||||
function ownsPath(basePath: string, pathname: string): boolean {
|
||||
return pathname === basePath || pathname.startsWith(`${basePath}/`);
|
||||
}
|
||||
|
||||
export function createHonoRouteHandler(groups: readonly HonoRouteGroup[]): RouteHandler {
|
||||
const app = new Hono<RouterEnv>();
|
||||
app.use("*", requestId());
|
||||
app.use("*", async (context, next) => {
|
||||
await next();
|
||||
context.res.headers.set("X-Request-Id", context.get("requestId"));
|
||||
});
|
||||
|
||||
for (const group of groups) {
|
||||
const run: Handler<RouterEnv> = async (context) => {
|
||||
const req = context.req.raw;
|
||||
const response = await group.handler(
|
||||
req,
|
||||
new URL(req.url),
|
||||
context.env.server,
|
||||
);
|
||||
return response ?? new Response(null, {
|
||||
status: 404,
|
||||
headers: { [FALLTHROUGH_HEADER]: "1" },
|
||||
});
|
||||
};
|
||||
app.all(group.basePath, run);
|
||||
app.all(`${group.basePath}/*`, run);
|
||||
}
|
||||
|
||||
return async (req, url, server) => {
|
||||
if (!groups.some((group) => ownsPath(group.basePath, url.pathname))) {
|
||||
return null;
|
||||
}
|
||||
const response = await app.fetch(req, { server });
|
||||
if (response.headers.get(FALLTHROUGH_HEADER) === "1") {
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BaseIssue, BaseSchema, InferOutput } from "valibot";
|
||||
import { safeParse } from "valibot";
|
||||
|
||||
type ValidationIssue = {
|
||||
path?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function formatIssue(issue: BaseIssue<unknown>): ValidationIssue {
|
||||
const path = issue.path
|
||||
?.map((item) => String(item.key))
|
||||
.filter(Boolean)
|
||||
.join(".");
|
||||
return path ? { path, message: issue.message } : { message: issue.message };
|
||||
}
|
||||
|
||||
export async function parseJsonBody<
|
||||
const TSchema extends BaseSchema<unknown, unknown, BaseIssue<unknown>>,
|
||||
>(req: Request, schema: TSchema): Promise<InferOutput<TSchema>> {
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await req.json();
|
||||
} catch {
|
||||
throw Response.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = safeParse(schema, input);
|
||||
if (!result.success) {
|
||||
throw Response.json(
|
||||
{
|
||||
error: "Invalid request body",
|
||||
issues: result.issues.map(formatIssue),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return result.output;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
setUserGroups,
|
||||
} from "../../users";
|
||||
import { createHttpFetchHandler } from "../http-dispatch";
|
||||
import { createHonoRouteHandler } from "../hono-router";
|
||||
import { handlePermissionGroupsRoutes } from "./permission-groups-routes";
|
||||
import { handleUsersRoutes } from "./users-routes";
|
||||
|
||||
@@ -55,7 +56,10 @@ function callThroughServerRoutes(method: string, path: string, token: string | n
|
||||
metrics: { withHttpMetrics: (fn) => fn() },
|
||||
CORS_HEADERS: {},
|
||||
routes: [
|
||||
(req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer),
|
||||
createHonoRouteHandler([{
|
||||
basePath: "/api/users",
|
||||
handler: (req, routeUrl, server) => handleUsersRoutes(req, routeUrl, server as typeof mockServer),
|
||||
}]),
|
||||
(req, routeUrl, server) => handlePermissionGroupsRoutes(req, routeUrl, server as typeof mockServer),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
array,
|
||||
boolean,
|
||||
minLength,
|
||||
object,
|
||||
optional,
|
||||
partialCheck,
|
||||
picklist,
|
||||
pipe,
|
||||
record,
|
||||
string,
|
||||
} from "valibot";
|
||||
|
||||
const nonEmptyString = pipe(string(), minLength(1, "Must not be empty"));
|
||||
|
||||
export const createUserBodySchema = object({
|
||||
username: nonEmptyString,
|
||||
password: nonEmptyString,
|
||||
role: picklist(["admin", "operator", "viewer"], "Invalid role"),
|
||||
mustChangePassword: optional(boolean()),
|
||||
});
|
||||
|
||||
export const updatePasswordBodySchema = pipe(
|
||||
object({
|
||||
password: optional(nonEmptyString),
|
||||
newPassword: optional(nonEmptyString),
|
||||
currentPassword: optional(string()),
|
||||
}),
|
||||
partialCheck(
|
||||
[["password"], ["newPassword"]],
|
||||
(input) => Boolean(input.password || input.newPassword),
|
||||
"Password required",
|
||||
),
|
||||
);
|
||||
|
||||
export const updateRoleBodySchema = object({
|
||||
role: picklist(["admin", "operator", "viewer"], "Invalid role"),
|
||||
});
|
||||
|
||||
export const updateClientAccessBodySchema = object({
|
||||
scope: picklist(["all", "allowlist", "denylist", "none"], "Invalid client access scope"),
|
||||
});
|
||||
|
||||
export const clientAccessRuleBodySchema = object({
|
||||
clientId: nonEmptyString,
|
||||
access: picklist(["allow", "deny"], "Invalid client access rule"),
|
||||
});
|
||||
|
||||
export const updateCanBuildBodySchema = object({ canBuild: boolean() });
|
||||
export const updateCanUploadFilesBodySchema = object({ canUploadFiles: boolean() });
|
||||
|
||||
export const updateFeaturePermissionsBodySchema = object({
|
||||
permissions: record(string(), boolean()),
|
||||
});
|
||||
|
||||
export const updatePluginAccessBodySchema = object({
|
||||
scope: picklist(["all", "allowlist", "none"], "Invalid plugin access scope"),
|
||||
pluginIds: optional(array(nonEmptyString)),
|
||||
});
|
||||
@@ -30,6 +30,48 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("admin-created users must change password", () => {
|
||||
test("POST /api/users rejects malformed JSON as a client error", async () => {
|
||||
const admin = await createAuthedAdmin();
|
||||
const url = new URL("https://localhost/api/users");
|
||||
const res = await handleUsersRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${admin.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{not-json",
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
expect(await res?.json()).toEqual({ error: "Invalid JSON body" });
|
||||
});
|
||||
|
||||
test("POST /api/users reports schema validation issues", async () => {
|
||||
const admin = await createAuthedAdmin();
|
||||
const url = new URL("https://localhost/api/users");
|
||||
const res = await handleUsersRoutes(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${admin.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username: "new-user", password: PASSWORD, role: "owner" }),
|
||||
}),
|
||||
url,
|
||||
mockServer,
|
||||
);
|
||||
|
||||
expect(res?.status).toBe(400);
|
||||
const body = await res?.json();
|
||||
expect(body.error).toBe("Invalid request body");
|
||||
expect(body.issues).toContainEqual({ path: "role", message: "Invalid role" });
|
||||
});
|
||||
|
||||
test("POST /api/users marks new users for password change", async () => {
|
||||
const admin = await createAuthedAdmin();
|
||||
const username = `mcp_user_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
|
||||
@@ -35,6 +35,18 @@ import {
|
||||
updateUserRole,
|
||||
} from "../../users";
|
||||
import { makeAuthCookie } from "./auth-cookie";
|
||||
import { parseJsonBody } from "../request-validation";
|
||||
import {
|
||||
clientAccessRuleBodySchema,
|
||||
createUserBodySchema,
|
||||
updateCanBuildBodySchema,
|
||||
updateCanUploadFilesBodySchema,
|
||||
updateClientAccessBodySchema,
|
||||
updateFeaturePermissionsBodySchema,
|
||||
updatePasswordBodySchema,
|
||||
updatePluginAccessBodySchema,
|
||||
updateRoleBodySchema,
|
||||
} from "./user-request-schemas";
|
||||
|
||||
type RequestIpProvider = {
|
||||
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||
@@ -83,17 +95,9 @@ export async function handleUsersRoutes(
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/users") {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, createUserBodySchema);
|
||||
const { username, password, role, mustChangePassword } = body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!["admin", "operator", "viewer"].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createUser(
|
||||
username,
|
||||
password,
|
||||
@@ -123,7 +127,7 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, updatePasswordBodySchema);
|
||||
const { password, newPassword, currentPassword } = body;
|
||||
|
||||
const canChange = user.userId === userId || user.role === "admin";
|
||||
@@ -209,13 +213,9 @@ export async function handleUsersRoutes(
|
||||
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/role$/)) {
|
||||
const authedUser = requirePermission(user, "users:manage");
|
||||
const userId = parseInt(url.pathname.split("/")[3]);
|
||||
const body = await req.json();
|
||||
const body = await parseJsonBody(req, updateRoleBodySchema);
|
||||
const { role } = body;
|
||||
|
||||
if (!role || !["admin", "operator", "viewer"].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (userId === authedUser.userId) {
|
||||
return Response.json({ error: "Cannot change your own role" }, { status: 400 });
|
||||
}
|
||||
@@ -286,8 +286,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const scope = body?.scope as ClientAccessScope;
|
||||
const body = await parseJsonBody(req, updateClientAccessBodySchema);
|
||||
const scope = body.scope as ClientAccessScope;
|
||||
const result = setUserClientAccessScope(userId, scope);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
@@ -314,9 +314,9 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const clientId = String(body?.clientId || "").trim();
|
||||
const access = body?.access as ClientAccessRuleKind;
|
||||
const body = await parseJsonBody(req, clientAccessRuleBodySchema);
|
||||
const clientId = body.clientId.trim();
|
||||
const access = body.access as ClientAccessRuleKind;
|
||||
const result = setUserClientAccessRule(userId, clientId, access);
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
@@ -411,8 +411,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canBuild = !!body?.canBuild;
|
||||
const body = await parseJsonBody(req, updateCanBuildBodySchema);
|
||||
const canBuild = body.canBuild;
|
||||
const previous = Boolean(targetUser.can_build);
|
||||
const result = setUserCanBuild(userId, canBuild);
|
||||
if (!result.success) {
|
||||
@@ -445,8 +445,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const canUploadFiles = !!body?.canUploadFiles;
|
||||
const body = await parseJsonBody(req, updateCanUploadFilesBodySchema);
|
||||
const canUploadFiles = body.canUploadFiles;
|
||||
const previous = Boolean(targetUser.can_upload_files);
|
||||
const result = setUserCanUploadFiles(userId, canUploadFiles);
|
||||
if (!result.success) {
|
||||
@@ -491,11 +491,8 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const permissions = body?.permissions;
|
||||
if (!permissions || typeof permissions !== "object") {
|
||||
return Response.json({ error: "Invalid permissions object" }, { status: 400 });
|
||||
}
|
||||
const body = await parseJsonBody(req, updateFeaturePermissionsBodySchema);
|
||||
const permissions = body.permissions;
|
||||
|
||||
const validated: Partial<Record<FeatureName, boolean>> = {};
|
||||
for (const [key, value] of Object.entries(permissions)) {
|
||||
@@ -603,9 +600,9 @@ export async function handleUsersRoutes(
|
||||
return Response.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const scope = body?.scope as PluginAccessScope;
|
||||
const pluginIds = Array.isArray(body?.pluginIds) ? body.pluginIds.filter((id: any) => typeof id === "string" && id.trim()) : undefined;
|
||||
const body = await parseJsonBody(req, updatePluginAccessBodySchema);
|
||||
const scope = body.scope as PluginAccessScope;
|
||||
const pluginIds = body.pluginIds?.map((id) => id.trim());
|
||||
|
||||
const scopeResult = setUserPluginAccessScope(userId, scope);
|
||||
if (!scopeResult.success) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Browser TypeScript
|
||||
|
||||
Browser source lives here and is bundled into `public/assets/generated/` by
|
||||
`bun run build:web`. Generated assets are ignored by Git and must not be edited
|
||||
directly.
|
||||
|
||||
The development supervisor runs the browser build once and watches this folder.
|
||||
Production and E2E preparation also run the build before serving assets.
|
||||
|
||||
Migrate existing `public/assets/*.js` modules incrementally:
|
||||
|
||||
1. Move one leaf/shared module into this directory and add it as a browser
|
||||
entrypoint in `package.json`.
|
||||
2. Keep its public export names stable and update consumers to import from
|
||||
`/assets/generated/` (or the equivalent relative path).
|
||||
3. Add runtime schemas for data crossing HTTP, WebSocket, storage, or plugin
|
||||
boundaries; TypeScript types alone do not validate external data.
|
||||
4. Run `bun run typecheck`, `bun run build:web`, and the affected browser tests.
|
||||
|
||||
Do not duplicate or replace the generated wire-protocol types here. Wire changes
|
||||
must continue through `protocol/wire-contract.json` and its generator.
|
||||
@@ -0,0 +1,72 @@
|
||||
import { object, record, safeParse, string, unknown } from "valibot";
|
||||
|
||||
export type SharedUiSettings = Record<string, unknown>;
|
||||
|
||||
const sharedUiSettingsResponseSchema = object({
|
||||
settings: record(string(), unknown()),
|
||||
});
|
||||
|
||||
export async function loadSharedUiSettings(scope: string): Promise<SharedUiSettings> {
|
||||
try {
|
||||
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
|
||||
const input: unknown = await res.json().catch(() => null);
|
||||
const result = safeParse(sharedUiSettingsResponseSchema, input);
|
||||
return result.success ? result.output.settings : {};
|
||||
} catch (err) {
|
||||
console.warn(`ui-settings: failed to load ${scope}`, err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function createSharedUiSettingsSaver(
|
||||
scope: string,
|
||||
readSettings: () => SharedUiSettings | null | undefined,
|
||||
delayMs = 350,
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastPayload = "";
|
||||
|
||||
async function saveNow(): Promise<void> {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
let settings: SharedUiSettings;
|
||||
try {
|
||||
settings = readSettings() ?? {};
|
||||
} catch (err) {
|
||||
console.warn(`ui-settings: failed to read ${scope}`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(settings);
|
||||
if (payload === lastPayload) return;
|
||||
lastPayload = payload;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ui-settings/${encodeURIComponent(scope)}`, {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
keepalive: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings }),
|
||||
});
|
||||
if (!res.ok) lastPayload = "";
|
||||
} catch (err) {
|
||||
lastPayload = "";
|
||||
console.warn(`ui-settings: failed to save ${scope}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(saveNow, delayMs);
|
||||
}
|
||||
|
||||
return { scheduleSave, saveNow };
|
||||
}
|
||||
Reference in New Issue
Block a user