Skip to content

Commit e26582e

Browse files
committed
feat: Completed the reconstruction of the system settings page
feat: Add API security protection fix: Fix a large number of known issues
1 parent 1d0eead commit e26582e

125 files changed

Lines changed: 5056 additions & 1188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ BYOK_ALLOW_EPHEMERAL_KEY="false"
1313
DEPLOYMENT_MODE="local"
1414
ALLOW_LOCAL_NETWORK_PROXY=""
1515
# Trust x-forwarded-* and related proxy headers only when your platform strips spoofed values.
16-
TRUST_PROXY_HEADERS="true"
16+
TRUST_PROXY_HEADERS="false"
1717

1818
# Optional in local mode, required in hosted mode.
1919
# RATE_LIMIT_STORE, DOCUMENT_PARSE_JOB_STORE, and PLUGIN_REGISTRY_STORE support memory or upstash.

docs/environment-variables.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ registry lookups survive across instances.
8383

8484
## Upload Limits
8585

86-
| Variable | Purpose |
87-
| --------------------------- | ------------------------------------------------------------------------------------------- |
86+
| Variable | Purpose |
87+
| --------------------------- | --------------------------------------------------------------------------------------------- |
8888
| `MAX_ATTACHMENT_FILE_BYTES` | Maximum chat attachment file size in bytes. Defaults to `10485760` and is clamped internally. |
8989

9090
## Public URLs

src/__tests__/appConfig.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("app config normalization", () => {
3636
expect(normalizeChatConfig({})).toEqual(DEFAULT_CHAT_CONFIG);
3737
expect(normalizeSystemSettings({})).toEqual(DEFAULT_SYSTEM_SETTINGS);
3838
expect(DEFAULT_SYSTEM_SETTINGS.enableHtmlVisualPrompt).toBe(true);
39+
expect(DEFAULT_SYSTEM_SETTINGS.personality).toBe("default");
3940
});
4041

4142
it("migrates legacy reasoning booleans to reasoning modes", () => {
@@ -96,4 +97,21 @@ describe("app config normalization", () => {
9697
DEFAULT_SYSTEM_SETTINGS.fontSize,
9798
);
9899
});
100+
101+
it("normalizes system personality without legacy reply style fields", () => {
102+
expect(normalizeSystemSettings({ personality: "efficient" })).toMatchObject(
103+
{
104+
personality: "efficient",
105+
},
106+
);
107+
expect(normalizeSystemSettings({ personality: "verbose" })).toMatchObject({
108+
personality: DEFAULT_SYSTEM_SETTINGS.personality,
109+
});
110+
expect(
111+
normalizeSystemSettings({ replyStyle: "concise" }),
112+
).not.toHaveProperty("replyStyle");
113+
expect(normalizeSystemSettings({ replyTone: "direct" })).not.toHaveProperty(
114+
"replyTone",
115+
);
116+
});
99117
});

src/__tests__/byokServices.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ vi.mock("@/lib/utils/voiceModels", async () =>
7676
vi.importActual("../lib/utils/voiceModels"),
7777
);
7878

79+
vi.mock("../lib/api/client", async () => {
80+
const actual = await vi.importActual("../lib/api/client");
81+
return {
82+
...actual,
83+
signedApiFetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
84+
fetch(input, init),
85+
),
86+
};
87+
});
88+
7989
const providerWithoutLocalKey: ModelProvider = {
8090
id: "env-provider",
8191
name: "Env Gemini",

src/__tests__/chatPanelUrlState.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
describe("chat panel URL state", () => {
88
it("parses valid panel and settings tab query params", () => {
99
const state = parseChatPanelUrlState(
10-
new URLSearchParams("panel=settings&settingsTab=memory&keep=1"),
10+
new URLSearchParams("panel=settings&settingsTab=about&keep=1"),
1111
);
1212

1313
expect(state.panel).toBe("settings");
14-
expect(state.settingsTab).toBe("memory");
14+
expect(state.settingsTab).toBe("about");
1515
expect(state.needsReplace).toBe(false);
1616
});
1717

src/__tests__/chatServiceToolConfirmation.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ vi.mock("../lib/byok/client", () => ({
4242
fetchWithByokRetry: vi.fn((requestFactory) => requestFactory()),
4343
}));
4444

45+
vi.mock("../lib/api/client", async () => {
46+
const actual = await vi.importActual("../lib/api/client");
47+
return {
48+
...actual,
49+
signedApiFetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
50+
fetch(input, init),
51+
),
52+
};
53+
});
54+
4555
vi.mock("@/lib/plugin/resolve", () => ({
4656
getEnabledPluginFunctions: vi.fn((plugin: Plugin) => plugin.functions || []),
4757
}));

src/__tests__/chatShellA11y.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe("chat shell accessibility", () => {
3333
expect(sidebar).toContain("env(safe-area-inset-bottom)");
3434
});
3535

36-
it("isolates the main chat region while the mobile sidebar drawer is open", () => {
36+
it("isolates the main chat region while the non-desktop sidebar drawer is open", () => {
3737
const chatApp = readFileSync(
3838
resolve(process.cwd(), "src/components/app/ChatApp.tsx"),
3939
"utf8",
@@ -43,6 +43,13 @@ describe("chat shell accessibility", () => {
4343
"utf8",
4444
);
4545

46+
expect(chatApp).toContain("isNonDesktopViewport");
47+
expect(chatApp).toContain("window.innerWidth < 1024");
48+
expect(chatApp).toContain(
49+
"const isSidebarDrawerOpen = isSidebarOpen && isNonDesktopViewport",
50+
);
51+
expect(chatApp).toContain("md:pl-16 lg:pl-0");
52+
expect(chatApp).not.toContain("backdrop-blur-[1px]");
4653
expect(chatApp).toContain("mainInertProps");
4754
expect(chatApp).toContain("inert");
4855
expect(chatApp).toContain("aria-hidden");
@@ -51,4 +58,36 @@ describe("chat shell accessibility", () => {
5158
expect(sidebar).toContain("handleSidebarKeyDown");
5259
expect(sidebar).toContain("restoreFocusRef");
5360
});
61+
62+
it("keeps mobile header icon buttons keyboard-focus visible", () => {
63+
const chatApp = readFileSync(
64+
resolve(process.cwd(), "src/components/app/ChatApp.tsx"),
65+
"utf8",
66+
);
67+
68+
expect(chatApp).toContain(
69+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
70+
);
71+
expect(chatApp).toContain(
72+
'<MessageSquarePlus size={16} aria-hidden="true" />',
73+
);
74+
});
75+
76+
it("contains workspace settings scrolling on small viewports", () => {
77+
const modal = readFileSync(
78+
resolve(
79+
process.cwd(),
80+
"src/components/layout/WorkspaceSettingsModal.tsx",
81+
),
82+
"utf8",
83+
);
84+
85+
expect(modal).toContain("document.body.style.overflow");
86+
expect(modal).toContain("100dvh");
87+
expect(modal).toContain("overscroll-contain");
88+
expect(modal).toContain("env(safe-area-inset-bottom)");
89+
expect(modal).toContain("min-w-0 truncate");
90+
expect(modal).toContain("title={plugin.title}");
91+
expect(modal).toContain("title={col.name}");
92+
});
5493
});

src/__tests__/clearAppData.test.ts

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,19 @@ const {
2121
const removeMock = vi.fn(() => Promise.resolve());
2222
return {
2323
appDbMock: {
24-
getItem: vi.fn(),
25-
clear: vi.fn(() => Promise.resolve()),
24+
getItem: vi.fn(async (key: string) => {
25+
void key;
26+
return undefined as unknown;
27+
}),
28+
setItem: vi.fn(async (key: string, value: unknown) => {
29+
void key;
30+
void value;
31+
}),
32+
removeItem: vi.fn(async (key: string) => {
33+
void key;
34+
}),
35+
keys: vi.fn(async (): Promise<string[]> => []),
36+
clear: vi.fn(async () => undefined),
2637
},
2738
dirMock: vi.fn(() => ({
2839
exists: vi.fn(() => Promise.resolve(true)),
@@ -60,9 +71,21 @@ vi.mock("../store/storage/storageConfig", () => ({
6071

6172
vi.mock("../lib/byok/client", () => ({
6273
encryptSecret: encryptSecretMock,
74+
fetchWithByokRetry: vi.fn((requestFactory) => requestFactory()),
6375
}));
6476

65-
const { clearBrowserAppData } = await import("../lib/data/clearAppData");
77+
vi.mock("../lib/api/client", async () => {
78+
const actual = await vi.importActual("../lib/api/client");
79+
return {
80+
...actual,
81+
signedApiFetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
82+
fetch(input, init),
83+
),
84+
};
85+
});
86+
87+
const { clearBrowserAppData, clearBrowserAppDataSources } =
88+
await import("../lib/data/clearAppData");
6689
const { deleteOPFSDirectory } = await import("../utils/opfs");
6790

6891
const ragConfig: RAGConfig = {
@@ -159,6 +182,93 @@ describe("clear app data", () => {
159182
expect(appDbMock.clear).toHaveBeenCalled();
160183
});
161184

185+
it("clears cache metadata without clearing user settings or stores", async () => {
186+
appDbMock.getItem.mockResolvedValueOnce(
187+
JSON.stringify({
188+
state: {
189+
marketPlugins: [{ id: "cached-plugin" }],
190+
marketPluginsTimestamp: 123,
191+
marketAgents: [{ identifier: "cached-agent" }],
192+
marketAgentsTimestamp: 456,
193+
marketAgentsLocale: "zh",
194+
skillCatalogs: { en: { skills: [] } },
195+
skillCatalogTimestamps: { en: 789 },
196+
skillDefinitions: { "en:test.json": { id: "test" } },
197+
skillDefinitionTimestamps: { "en:test.json": 999 },
198+
modelMetadata: { "gpt-test": { id: "gpt-test" } },
199+
modelMetadataTimestamp: 321,
200+
installedPlugins: [{ id: "keep-plugin" }],
201+
},
202+
version: 4,
203+
}),
204+
);
205+
206+
await clearBrowserAppDataSources({
207+
sources: ["cache"],
208+
rag: ragConfig,
209+
});
210+
211+
expect(appDbMock.clear).not.toHaveBeenCalled();
212+
expect(localforageClearMock).not.toHaveBeenCalled();
213+
expect(appDbMock.removeItem).not.toHaveBeenCalled();
214+
expect(appDbMock.setItem).toHaveBeenCalledWith(
215+
"neo-chat-settings",
216+
expect.stringContaining('"installedPlugins":[{"id":"keep-plugin"}]'),
217+
);
218+
const saved = JSON.parse(appDbMock.setItem.mock.calls[0][1] as string);
219+
expect(saved.state).toMatchObject({
220+
marketPlugins: [],
221+
marketPluginsTimestamp: 0,
222+
marketAgents: [],
223+
marketAgentsTimestamp: 0,
224+
marketAgentsLocale: "",
225+
skillCatalogs: {},
226+
skillCatalogTimestamps: {},
227+
skillDefinitions: {},
228+
skillDefinitionTimestamps: {},
229+
modelMetadata: {},
230+
modelMetadataTimestamp: 0,
231+
});
232+
});
233+
234+
it("clears chat metadata and per-session message records when requested", async () => {
235+
appDbMock.keys.mockResolvedValueOnce([
236+
"neo-chat-storage",
237+
"session_messages_a",
238+
"session_messages_b",
239+
"unrelated",
240+
]);
241+
242+
await clearBrowserAppDataSources({
243+
sources: ["chats"],
244+
rag: ragConfig,
245+
});
246+
247+
expect(appDbMock.removeItem).toHaveBeenCalledWith("neo-chat-storage");
248+
expect(appDbMock.removeItem).toHaveBeenCalledWith("session_messages_a");
249+
expect(appDbMock.removeItem).toHaveBeenCalledWith("session_messages_b");
250+
expect(appDbMock.removeItem).not.toHaveBeenCalledWith("unrelated");
251+
expect(appDbMock.clear).not.toHaveBeenCalled();
252+
});
253+
254+
it("clears knowledge metadata, vectors, and OPFS knowledge files when requested", async () => {
255+
await clearBrowserAppDataSources({
256+
sources: ["knowledge"],
257+
rag: ragConfig,
258+
});
259+
260+
expect(fetch).toHaveBeenCalledWith(
261+
"/api/rag/delete",
262+
expect.objectContaining({
263+
method: "POST",
264+
body: expect.stringContaining('"namespace":"collection-1"'),
265+
}),
266+
);
267+
expect(dirMock).toHaveBeenCalledWith("knowledge-base");
268+
expect(appDbMock.removeItem).toHaveBeenCalledWith("knowledge-storage");
269+
expect(appDbMock.clear).not.toHaveBeenCalled();
270+
});
271+
162272
it("rejects unsafe OPFS directory paths", async () => {
163273
await deleteOPFSDirectory("../secret");
164274

src/__tests__/clientApi.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, describe, expect, it, vi } from "vitest";
22
import {
3+
clearApiProofSessionCache,
34
getResponseErrorMessage,
45
readJsonResponse,
56
readJsonResponseOrThrow,
7+
signedApiFetch,
68
} from "../lib/api/client";
79

810
describe("client API response helpers", () => {
11+
afterEach(() => {
12+
clearApiProofSessionCache();
13+
vi.restoreAllMocks();
14+
});
15+
916
it("parses JSON responses and returns null for empty or malformed bodies", async () => {
1017
await expect(
1118
readJsonResponse<{ ok: boolean }>(Response.json({ ok: true })),
@@ -36,4 +43,71 @@ describe("client API response helpers", () => {
3643
getResponseErrorMessage(new Response("<html></html>"), "Fallback"),
3744
).resolves.toBe("Fallback");
3845
});
46+
47+
it("signs protected API requests with a cached request proof session", async () => {
48+
const fetchMock = vi
49+
.spyOn(globalThis, "fetch")
50+
.mockImplementation(
51+
async (input: RequestInfo | URL, init?: RequestInit) => {
52+
if (String(input) === "/api/request-proof/session") {
53+
return Response.json({
54+
enabled: true,
55+
clientKey: "dGVzdC1jbGllbnQtcHJvb2Yta2V5",
56+
expiresAt: Date.now() + 600_000,
57+
serverTime: Date.now(),
58+
windowMs: 60_000,
59+
});
60+
}
61+
62+
return Response.json({
63+
ok: true,
64+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
65+
body: init?.body,
66+
signal: init?.signal ? "present" : "missing",
67+
});
68+
},
69+
);
70+
const controller = new AbortController();
71+
72+
const response = await signedApiFetch("/api/chat", {
73+
method: "POST",
74+
headers: { "Content-Type": "application/json" },
75+
body: JSON.stringify({ ok: true }),
76+
signal: controller.signal,
77+
});
78+
const data = await response.json();
79+
80+
expect(fetchMock).toHaveBeenCalledTimes(2);
81+
expect(data.headers["content-type"]).toBe("application/json");
82+
expect(data.headers["x-neo-api-proof-timestamp"]).toBeTruthy();
83+
expect(data.headers["x-neo-api-proof-nonce"]).toBeTruthy();
84+
expect(data.headers["x-neo-api-proof-signature"]).toBeTruthy();
85+
expect(data.body).toBe(JSON.stringify({ ok: true }));
86+
expect(data.signal).toBe("present");
87+
88+
await signedApiFetch("/api/search", { method: "POST" });
89+
expect(fetchMock).toHaveBeenCalledTimes(3);
90+
});
91+
92+
it("does not add proof headers when the server reports proof disabled", async () => {
93+
vi.spyOn(globalThis, "fetch").mockImplementation(
94+
async (input: RequestInfo | URL, init?: RequestInit) => {
95+
if (String(input) === "/api/request-proof/session") {
96+
return Response.json({
97+
enabled: false,
98+
serverTime: Date.now(),
99+
});
100+
}
101+
102+
return Response.json({
103+
headers: Object.fromEntries(new Headers(init?.headers).entries()),
104+
});
105+
},
106+
);
107+
108+
const response = await signedApiFetch("/api/chat", { method: "POST" });
109+
const data = await response.json();
110+
111+
expect(data.headers["x-neo-api-proof-signature"]).toBeUndefined();
112+
});
39113
});

0 commit comments

Comments
 (0)