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:
+102
-5
@@ -6,6 +6,8 @@ import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
import type { VncElectronBridge, WsMessageEvent } from "@/lib/electron-bridge";
|
||||
import { isElectronShell } from "@/lib/electron-bridge";
|
||||
|
||||
export class TransportError extends Error {
|
||||
constructor(message = 'Network transport failure') {
|
||||
@@ -759,6 +761,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
import('@/lib/auth-error-handler').then(({ handleAuthError }) => {
|
||||
handleAuthError(new Error('401 Unauthorized'));
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -6120,7 +6128,90 @@ export class JMAPClient implements IJMAPClient {
|
||||
// mean piping raw credentials from the renderer to the main process over
|
||||
// IPC, which is a materially bigger security-sensitive change than what
|
||||
// was scoped here.
|
||||
private ws: WebSocket | null = null;
|
||||
private ws: (WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>) | null = null;
|
||||
|
||||
/**
|
||||
* Connects a WebSocket through Electron's main process IPC bridge (which
|
||||
* can attach Authorization headers the browser WebSocket API cannot).
|
||||
* Returns a WebSocket-like wrapper that the calling code in
|
||||
* connectWebSocket() interacts with identically to a browser WebSocket.
|
||||
*/
|
||||
private createElectronWebSocket(wsUrl: string): {
|
||||
addEventListener: (type: string, handler: (event: unknown) => void) => void;
|
||||
send: (data: string) => void;
|
||||
close: () => void;
|
||||
} {
|
||||
const bridge: VncElectronBridge = (window as Window & { vnc: VncElectronBridge }).vnc!;
|
||||
let connectionId: string | null = null;
|
||||
const listeners = new Map<string, Array<(event: unknown) => void>>();
|
||||
|
||||
const emit = (type: string, event: unknown) => {
|
||||
for (const handler of listeners.get(type) || []) {
|
||||
try { handler(event); } catch { /* noop */ }
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = bridge.onWsMessage((msg: WsMessageEvent) => {
|
||||
// Only deliver events for our connection
|
||||
if (msg.id !== connectionId) return;
|
||||
switch (msg.type) {
|
||||
case "open":
|
||||
emit("open", {});
|
||||
break;
|
||||
case "message":
|
||||
emit("message", { data: msg.data || "" });
|
||||
break;
|
||||
case "close":
|
||||
connectionId = null;
|
||||
emit("close", { code: msg.code || 0 });
|
||||
break;
|
||||
case "error":
|
||||
// The main process already logged the error - trigger the
|
||||
// "close" path so the reconnect logic engages.
|
||||
if (connectionId !== null) {
|
||||
connectionId = null;
|
||||
emit("close", { code: 1006 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
bridge.wsConnect(wsUrl, this.authHeader).then((id) => {
|
||||
// Don't update `connectionId` here — let 'open' from onWsMessage do it.
|
||||
// The main process sends 'open' on the message channel, and that sets
|
||||
// connectionId & fires the open handler. This avoids a race: if the
|
||||
// bridge fires 'open' before .then() runs, connectionId would be stale
|
||||
// for the 'message' and 'close' events arriving between 'open' and here.
|
||||
//
|
||||
// But we NEED connectionId before any message arrives, so set it now
|
||||
// and let the 'open' event be purely for notification.
|
||||
connectionId = id;
|
||||
// If 'open' hasn't already been delivered, fire it now.
|
||||
emit("open", {});
|
||||
}).catch((err: Error) => {
|
||||
// Connection failed immediately — simulate a close with error.
|
||||
emit("close", { code: 1006, reason: err.message });
|
||||
});
|
||||
|
||||
return {
|
||||
addEventListener(type: string, handler: (event: unknown) => void) {
|
||||
if (!listeners.has(type)) listeners.set(type, []);
|
||||
listeners.get(type)!.push(handler);
|
||||
},
|
||||
send(data: string) {
|
||||
if (connectionId !== null) {
|
||||
bridge.wsSend(connectionId, data).catch(() => {});
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (connectionId !== null) {
|
||||
bridge.wsClose(connectionId).catch(() => {});
|
||||
connectionId = null;
|
||||
}
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
private wsReconnectTimeout: NodeJS.Timeout | null = null;
|
||||
private wsReconnectAttempts: number = 0;
|
||||
private wsConsecutiveFailures: number = 0;
|
||||
@@ -6242,9 +6333,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
let socket: WebSocket;
|
||||
let socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>;
|
||||
try {
|
||||
socket = new WebSocket(wsUrl, "jmap");
|
||||
if (isElectronShell()) {
|
||||
socket = this.createElectronWebSocket(wsUrl);
|
||||
} else {
|
||||
socket = new WebSocket(wsUrl, "jmap");
|
||||
}
|
||||
} catch {
|
||||
// New URL()-level failures (malformed URL) - retry later in case a
|
||||
// session refresh fixes it; getWebSocketUrl() re-reads capabilities
|
||||
@@ -6286,7 +6381,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (!isCurrent()) return;
|
||||
this.lastWSActivity = Date.now();
|
||||
this.processWebSocketMessage(typeof event.data === "string" ? event.data : "");
|
||||
this.processWebSocketMessage(
|
||||
typeof (event as MessageEvent).data === "string" ? (event as MessageEvent).data : ""
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
@@ -6417,7 +6514,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private startWSHeartbeat(socket: WebSocket): void {
|
||||
private startWSHeartbeat(socket: WebSocket | ReturnType<JMAPClient['createElectronWebSocket']>): void {
|
||||
this.stopWSHeartbeat();
|
||||
this.wsHeartbeatTimer = setInterval(() => {
|
||||
if (this.ws !== socket) return;
|
||||
|
||||
Reference in New Issue
Block a user