feat: Phase 3+4 — security hardening + polish + offline + Electron push

Phase 3 (security):
- P3.1: Feature gate server-side enforcement (403 on disabled features)
- P3.2: Unified auth error interceptor (401→logout)
- P3.3: Store-level state isolation via StoreSnapshot contract
  (added message-list-tabs + task stores to snapshot/restore cycle)
- P3.4: Push event bus extraction — email-store no longer imports
  calendar/contact/filter/file stores directly
- P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter

Phase 4 (polish):
- P4.1: Offline write queue — pending operations in localStorage,
  auto-retry on reconnect, offline-queue-indicator banner
- P4.2: Identity spoofing — fromOverrideEmail domain validation
- P4.3: WebSocket push for Electron via main-process IPC bridge
  (ws package with Authorization headers)
This commit is contained in:
Bernd Rodler
2026-08-07 22:10:26 +02:00
parent 0ac429fe36
commit cfdd091d22
29 changed files with 1068 additions and 93 deletions
+65
View File
@@ -15,6 +15,7 @@ import { get as httpGet } from "node:http";
import path from "node:path";
import fs from "node:fs";
import type { Duplex } from "node:stream";
import { WebSocket } from "ws";
import { attachKeyService, checkEncryptionAvailable } from "./key-service";
let serverProcess: ChildProcess | null = null;
@@ -463,6 +464,70 @@ ipcMain.handle(
},
);
// --- WebSocket bridge for renderer ----------------------------------------
// The browser WebSocket constructor cannot attach Authorization headers, so
// JMAP-over-WebSocket (RFC 8887) push paths that require auth at the upgrade
// handshake are unreachable from the renderer. This IPC bridge opens the
// WebSocket from the main process (where we control headers) and forwards
// messages to the renderer as 'vnc:ws-message' events.
const wsConnections = new Map<string, WebSocket>();
ipcMain.handle(
"vnc:ws-connect",
(event, { url, authHeader }: { url: string; authHeader: string }) => {
const id = randomBytes(8).toString("hex");
const ws = new WebSocket(url, {
headers: { Authorization: authHeader },
});
ws.on("open", () => {
event.sender.send("vnc:ws-message", { id, type: "open" });
});
ws.on("message", (data: Buffer) => {
event.sender.send("vnc:ws-message", {
id,
type: "message",
data: data.toString(),
});
});
ws.on("close", (code: number) => {
wsConnections.delete(id);
event.sender.send("vnc:ws-message", { id, type: "close", code });
});
ws.on("error", (err: Error) => {
event.sender.send("vnc:ws-message", {
id,
type: "error",
message: err.message,
});
});
wsConnections.set(id, ws);
return id;
},
);
ipcMain.handle(
"vnc:ws-send",
(_event, { id, data }: { id: string; data: string }) => {
const ws = wsConnections.get(id);
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
ws.send(data);
return true;
},
);
ipcMain.handle("vnc:ws-close", (_event, { id }: { id: string }) => {
const ws = wsConnections.get(id);
if (!ws) return;
ws.close();
wsConnections.delete(id);
});
// --- Auto-update -------------------------------------------------------
// GitHub Releases as the update feed (electron-builder.config.js's
// `publish` block) - the skill's recommendation over standing up a new
+30
View File
@@ -13,6 +13,14 @@ export interface ShowNotificationResult {
shown: boolean;
}
export interface WsMessageEvent {
id: string;
type: "open" | "message" | "close" | "error";
data?: string;
code?: number;
message?: string;
}
contextBridge.exposeInMainWorld("vnc", {
isElectron: true,
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
@@ -24,4 +32,26 @@ contextBridge.exposeInMainWorld("vnc", {
options?: ShowNotificationOptions,
): Promise<ShowNotificationResult> =>
ipcRenderer.invoke("vnc:show-notification", title, options),
// WebSocket bridge for JMAP-over-WebSocket (RFC 8887). The browser
// WebSocket constructor cannot attach Authorization headers, so
// connections go through the main process which controls headers.
wsConnect: (
url: string,
authHeader: string,
): Promise<string> =>
ipcRenderer.invoke("vnc:ws-connect", { url, authHeader }),
wsSend: (id: string, data: string): Promise<boolean> =>
ipcRenderer.invoke("vnc:ws-send", { id, data }),
wsClose: (id: string): Promise<void> =>
ipcRenderer.invoke("vnc:ws-close", { id }),
onWsMessage: (callback: (event: WsMessageEvent) => void): () => void => {
const handler = (_event: Electron.IpcRendererEvent, data: WsMessageEvent) =>
callback(data);
ipcRenderer.on("vnc:ws-message", handler);
return () => { ipcRenderer.removeListener("vnc:ws-message", handler); };
},
});