The hard part is staying both secure and performant as the app grows. These are
the invariants we hold — treat them as a checklist on any PR that touches apps/desktop/src/main,
apps/desktop/src/preload, or window/webPreferences config.
renderer (sandboxed, context-isolated, NO node)
→ preload (contextBridge — the ONLY surface the renderer can reach)
→ main (thin: windows, auth deep-links, IPC routing — no heavy/native work)
→ utilityProcess (DB + pipeline/todos + native addons, off the main loop)
- Renderer is locked down (
createWindow,apps/desktop/src/main/index.ts):sandbox: true,contextIsolation: true,nodeIntegration: false. The renderer has no Node and no direct Electron/DB access — only the typedwindow.api.*bridge. - Heavy + native work is isolated in a
utilityProcess(apps/desktop/src/main/worker/*). Keeps the main loop responsive (performance) and shrinks main's attack surface. - Main is a router. It must not become a Node server: no business logic, no DB, no remote/network servers. New data capabilities go in the worker, exposed via IPC.
- The renderer may not navigate away from the app's own content (
will-navigateis denied for non-app URLs). - The renderer may not open windows;
setWindowOpenHandlerdenies all, and onlyhttps:/mailto:links are forwarded to the system browser (shell.openExternal). - Auth (Logto OIDC + PKCE) uses the system browser, never an in-app webview.
Browser-grade permissions are denied by default via setPermissionRequestHandler
setPermissionCheckHandleron the default session (apps/desktop/src/main/index.ts). The handlers are an explicit allowlist (ALLOWED) — "deny by default, allow exactly what we use."
Currently ALLOWED holds only clipboard-sanitized-write — the Settings
recovery-key "Copy" button (navigator.clipboard.writeText). Everything else
(notifications, camera, mic, geolocation, clipboard-read, …) is denied. To enable one
later — e.g. notifications when reminders ship — add the permission string to
ALLOWED. That is the entire change; nothing else grants it.
The production policy is offline-first with no remote origins:
default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self' data:; img-src 'self' data:; connect-src 'self'
It is enforced in two places (defense-in-depth):
- A
<meta http-equiv>tag inapps/desktop/src/renderer/index.html. - A
Content-Security-Policyresponse header set in the main process (apps/desktop/src/main/index.ts, viasession.defaultSession.webRequest.onHeadersReceived). A compromised renderer bundle could strip the meta tag; it cannot remove the header. The header is gated onapp.isPackagedso it only applies to the shipped app.
Why each directive is safe to keep tight:
script-src 'self'— no inline/evalscripts (hard invariant).style-src 'self'— Tailwind ships as a same-origin linked stylesheet; React'sstyle={{}}props apply via the CSSOM and the accent tweak usesel.style.setProperty, both exempt from CSP — so no'unsafe-inline'is needed in production.font-src 'self' data:— the UI fonts (Hanken Grotesk + JetBrains Mono) are bundled locally via@fontsource(imported insrc/renderer/src/main.tsx); there are no Google Fonts origins anymore.data:is required because Vite inlines small font subsets (e.g. cyrillic) asdata:URIs; the larger subsets are same-origin asset files.data:fonts cannot load remote content, so this stays offline-first.connect-src 'self'— the renderer talks to the worker over IPC, not HTTP. The legacy FastAPI HTTP client (ApiStatus/@mikan/contract/api) is not mounted in the app.
Dev exception. electron-vite dev needs style-src 'unsafe-inline' (Vite injects
<style> tags for HMR) and connect-src http://localhost:8000 (the optional FastAPI
round-trip smoke). A serve-only Vite plugin in electron.vite.config.ts rewrites the meta
CSP for the dev server only, and the runtime header is skipped in dev — so the policy that
actually ships stays strict. Keep the three copies (meta, DEV_CSP, runtime header)
in sync when changing the policy.
Runtime lockdown protects the renderer; these protect the binary, so a local attacker can't sidestep all of the above by relaunching the signed app differently.
- Electron fuses (
apps/desktop/electron-builder.config.cjs,electronFuses). electron-builder flips them at package time and re-signs. We disablerunAsNode,enableNodeOptionsEnvironmentVariable, andenableNodeCliInspectArguments— these otherwise let a signed app be relaunched as a raw Node process (ELECTRON_RUN_AS_NODE=1,--inspect) with the app's entitlements, bypassing the sandbox entirely — and enableenableCookieEncryption.- Deferred (Tier B):
onlyLoadAppFromAsar+enableEmbeddedAsarIntegrityValidation. The app ships unpacked native deps (asarUnpack: onnxruntime-node, ffmpeg-static, libSQL); asar-integrity can interact badly with unpacked natives + signing. Enable only after a notarized build is confirmed to launch, then verify withnpx @electron/fuses read --app <path>.app.
- Deferred (Tier B):
app.enableSandbox()(apps/desktop/src/main/index.ts) forces the sandbox on process-wide, so a future window can't regress the per-windowsandbox: true.- macOS hardened runtime + notarization are on (
hardenedRuntime: true,notarize: truein the builder config).
- One sealed secrets vault holds every main-process secret under
userData:neeme-secrets.bin(apps/desktop/src/main/secrets/store.ts). It is sealed with ElectronsafeStorage(macOS Keychain, Windows DPAPI, Linux Secret Service / libsecret or kwallet when available).- Slices in the vault are owned by their caller:
authfor the Logto refresh token- display claims,
connectorsfor Google connector refresh tokens,brokerfor the Turso broker token, andsyncKeyfor the per-device 64-hex encryption key.
- display claims,
main/index.tscallssecrets.loadAll()once before auth, broker, sync-key, and connector startup. After that,secrets.get()is an in-memory read; a boot with a warm vault does not keep asking the OS keychain for each feature.- Add future at-rest secrets as a key on
SecretsShape, not as another sealed file. Multiple files mean multiple keychain decrypts and, on unsigned/ad-hoc dev builds, multiple macOS prompts. - Legacy migration: if the vault is absent,
store.tstries to import the oldneeme-auth.bin,neeme-connectors.bin,neeme-sync-token.bin, andneeme-sync-key.bin, writes the vault once, then removes the legacy files. The first launch after upgrading may still prompt for those legacy decrypts. - Linux caveat: with no backing keyring available,
safeStoragefalls back to writing the blob unencrypted (still insideuserData). Document this for Linux users; recommend a configured keyring before enabling sync/auth.
- Slices in the vault are owned by their caller:
- Access tokens stay in memory only. Logto and Google refresh tokens are persisted in the vault, but short-lived access tokens are never written to disk.
- macOS prompts depend on signing.
pnpm devis ad-hoc signed, so Keychain "Always Allow" cannot persist reliably. A Developer-ID-signed, notarized release should prompt once for the vault and then honor "Always Allow"; repeated prompts from a packaged build usually mean the app is unsigned or signed with a changing identity. - Synced content is field-encrypted with AES-256-GCM (fresh 96-bit IV per
value, 128-bit auth tag, 256-bit key from
crypto.randomBytes) before it leaves the device, so the cloud primary only ever sees ciphertext (apps/desktop/src/main/db/crypto.ts). - All security tokens (PKCE verifier, OAuth state, nonce, keys, IVs) use
node:cryptoCSPRNG — neverMath.random().
- Never set
sandbox: false,nodeIntegration: true,contextIsolation: false, orwebSecurity: false.app.enableSandbox()keeps the first one true globally. - Never expose Node/
ipcRendererdirectly onwindow— only scoped methods viacontextBridgeinapps/desktop/src/preload; keep the preload free of Node built-ins (so the sandbox holds). - IPC trust model: the renderer is the only, sandboxed caller of
window.api.*. Safety at the boundary rests on the type system + parameterized DB queries (Drizzle /?-bound libSQL) + content-hashed file paths — not blanket runtime validation. Add an explicit runtime check only where a value is security-relevant (e.g. the recovery-key hex regex inservices/sync-prefs.ts, theConnectorIdallowlist inindex.ts). If you add a sink that interpolates a renderer value into SQL, a path, or a shell call, validate it there. - Keep permissions deny-by-default; opt features in via the
ALLOWEDallowlist. - When changing the CSP, update all three copies in lockstep — the
<meta>tag (renderer/index.html),DEV_CSP(electron.vite.config.ts), and the runtime header (main/index.ts). They drift silently and a stale copy weakens the shipped policy.