security update + make easter egg optional.

This commit is contained in:
KingKDot
2026-08-01 01:54:07 -05:00
parent bd7b3ea6f4
commit 34158ba673
22 changed files with 534 additions and 90 deletions
+9 -1
View File
@@ -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();
});
});
+57
View File
@@ -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) {
+21
View File
@@ -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 };
}