Skip to content

[Mac app] Self-contained local mode — the whole stack runs inside Mike.app (stacked on #302) - #351

Draft
amal66 wants to merge 17 commits into
open-legal-products:mainfrom
amal66:feat/mac-self-contained-plan
Draft

[Mac app] Self-contained local mode — the whole stack runs inside Mike.app (stacked on #302)#351
amal66 wants to merge 17 commits into
open-legal-products:mainfrom
amal66:feat/mac-self-contained-plan

Conversation

@amal66

@amal66 amal66 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

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 this
Mac
. 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.js
is 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 inside lib/storage.ts behind the
    same 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.
  • Blob-token signed URLs — fs mode has no S3 presigner, so getSignedUrl
    returns an expiring HMAC capability URL on a new unauthenticated route
    GET /download/signed/:token with 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/:token
    HMAC so the two token kinds can't be replayed as each other (tested).

Frontend: output: "standalone" behind NEXT_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 fast
    shutdown), stale-pidfile recovery, fail-fast if any child dies during boot.
  • First-run bootstrap: initdb into userData/local/pgdata, the Supabase
    role ladder (incl. service_role with BYPASSRLS — schema.sql enables
    RLS 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): fresh
    installs baseline all shipped migrations; upgrades apply only unseen ones.
  • Per-install secrets minted on first run (JWT secret, anon/service keys, DB
    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, 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 to
    service_role.
  • First-launch chooser — a pristine launch of a stack-carrying build asks
    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.
  • Connect screen also offers "Run locally on this Mac" (only when the build
    actually carries the stack); a boot progress page streams supervisor status;
    --server-url= still beats local mode so e2e can always retarget.
  • Scripts: 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 local
    URLs baked), local:stage + dist:local (package Mike.app with the whole
    stack in resources).
  • e2e/local.e2e.mjs — wipes userData and drives the true first-run every
    time: cold initdb → six services → signup → project → library upload →
    row-menu download, asserting bytes land under userData/local/storage and
    the download URL is a blob-token signed URL.

Verification (all green)

  • Local-mode e2e (MIKE_E2E_DEV=1 npm run e2e:local) — PASSED, on a
    wiped userData (true first-run) every time:
    ✓ local stack booted from scratch; app routed to /login
    ✓ signed up + auto-signed-in as local-e2e-…@example.com (no network)
    ✓ project "Local E2E …" created (data in local postgres)
    ✓ upload landed as plain files under userData/local/storage (1 object[s])
    ✓ downloaded back via blob-token URL: local-doc-….pdf
    LOCAL E2E PASSED
    
  • Backend unit suite: 621 passed (incl. new storageFs.test.ts: fs
    round-trip, S3 string-prefix listing semantics, traversal guard,
    blob-token expiry + HMAC domain separation).
  • Existing remote-mode e2e suites re-run against the compose stack with
    this exact shell build: flows.e2e.mjs 12/12 PASSED (S3 presigned-URL
    path untouched), app.e2e.mjs PASSED.
  • Packaged build provenlocal:stage + dist:local produce a
    Mike.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 suites
    also re-passed against this packaged build.
  • First-launch chooser verified on the packaged app, pristine userData
    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).
  • No-web proof: with the renderer's network recorded through boot →
    login → full signup → authenticated app load, every request targeted
    loopback (localhost:42813/42814/42815) — zero external hosts. The only
    outbound calls in local mode are the LLM provider you configure (or
    localhost:11434 with Ollama) and CourtListener if used.

Replicate the base case (before this PR)

git checkout <base>; cd desktop && npm install && npm start
# → the app can only point at a server URL; without a reachable Mike server
#   you get the connect screen. docker-compose is the only no-cloud path.

Replicate this PR

git checkout <branch>
cd desktop && npm install
npm run local:fetch     # needs: go (brew install go); downloads pg + postgrest
npm run local:build     # backend tsc + Next standalone (few minutes)
MIKE_E2E_DEV=1 npm run e2e:local   # automated first-run proof
# or interactively:
npm run start:local     # boots the stack, lands on the local login
# sign up (offline), upload a doc in Library, download it back;
# check ~/Library/Application Support/Mike/local/ for pgdata + storage files

Tradeoffs / design decisions (explicit)

  1. Bundle real GoTrue + PostgREST + Postgres binaries instead of
    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).
  2. pg node client instead of psql for bootstrap/migrations — the zonky
    Postgres 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.
  3. Fixed ports (42810–42815) because Next bakes its API origins at build
    time. A port conflict fails loudly with a clear message instead of
    auto-shifting (which would strand the baked URLs).
  4. Placeholder-anon-key swap at the gateway rather than baking a real key:
    per-install secrets never exist in the bundle, and the well-known demo JWT
    secret is never trusted by the local services.
  5. No SMTP in v1 — signup autoconfirms offline; email password recovery is
    unavailable in local mode and the connect screen says so. (An SMTP-sink
    surfacing mails in-app is a possible follow-up.)
  6. LibreOffice detect-don't-bundle — supervisor sets
    SOFFICE_BINARY_PATH when /Applications/LibreOffice.app exists;
    otherwise the product's existing docx-preview fallback applies. Bundling
    would add ~700MB.
  7. Blob-token route is unauthenticated by design — it replaces S3
    presigned URLs, which carry no session either; the token is the expiring,
    object-scoped capability. The permanent /download/:token route (auth +
    DB access check) is untouched, and the two HMAC domains are separated.
  8. LLM calls still need a key or Ollama — unchanged product behavior;
    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 signed
config is generated at build time (scripts/make-signed-local-config.mjs
scans for Mach-O magic bytes and emits electron-builder.release.local.json
with mac.binaries filled), and npm run dist:local:signed builds a
notarized DMG under the same APPLE_* env contract as the shell's
dist:signed. The operator runbook — including nested-binary verification
and 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's
signed 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)

  • Executing the signed build (org Apple Developer certificate — human-only).
  • Backup/export menu item; Postgres major-version upgrade path (pinned to 17).
  • SMTP sink; demo mode.

🤖 Generated with Claude Code

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

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));
Comment thread desktop/e2e/flows.e2e.mjs
Comment on lines +379 to +381
return readFileSync(CAPTURE_FILE, "utf8")
.split("\n")
.includes(EXTERNAL_URL);
@amal66

amal66 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Two additions from hands-on first-run testing (cca05965500e84)

Testing the packaged app as a real downloader surfaced one blocker and one UX gap.

1. 62b8314 fix: nothing in the product was clickable after local boot

Base case (replicate on cca0596): package with local:stage + dist:local, wipe ~/Library/Application Support/Mike, open the app, choose "Run everything on this Mac", wait for the login page → no physical click lands anywhere. The page is healthy (renders, hydrates; a CDP-injected click focuses #email) — but native clicks vanish.

Cause: local-boot.html marks its whole body -webkit-app-region: drag with no no-drag island, and Chromium only replaces a window's native drag regions when the next document reports regions of its own. The web app declares none, so the boot page's full-window drag region stays in force over the product — and a drag region consumes every mouse event (electron/electron#1354). The e2e suites click via CDP, which injects below the native drag layer, so they stayed green.

Fix: after every committed http(s) navigation the shell inserts html, body { -webkit-app-region: no-drag; }, forcing the renderer to report a fresh (empty) region set; the boot page also gets the same no-drag content island as welcome/connect.

Replicate the fix: same steps on 5500e84 — login page accepts clicks. Note the decisive check is a physical click; CDP clicks pass on both sides of the fix.

2. 5500e84 feat: "Continue as guest" in local mode

Base case: local mode demanded an email+password signup for a database living in your own ~/Library.

Now: the login page shows Continue as guest (only inside the local desktop product) — one click in, no typing. The guest is a real GoTrue user whose random per-install password lives with the other first-run secrets in userData/local/secrets.json; auth, sessions, and the data model stay byte-identical to a server deploy. The shell hands the credentials over one gated IPC call: local mode only, and only to a sender frame on the local frontend's origin — a hosted page gets null, and in a plain browser the bridge doesn't exist, so the page behaves identically everywhere else.

Replicate: npm run local:build && npm run local:stage && npm run dist:local, launch, reach the local login page → "Continue as guest" → product. e2e/local.e2e.mjs now proves both the first click (signUp fallback, autoconfirmed offline) and the repeat click (plain sign-in).

Tradeoffs (explicit):

  • Guest creds are readable by anything on the machine that can read userData/local — the same trust boundary as pgdata beside it, so no new exposure.
  • Wiping secrets.json while keeping pgdata orphans the guest account (button surfaces the auth error rather than silently recovering — the shell should never delete account data on its own). Supported reset stays "delete userData/local".
  • Existing installs get guest fields topped up in the secrets file on next read; no version bump.

Verification: dev-mode and packaged-app e2e:local both fully green including the two guest rounds; remote-mode behavior untouched (bridge answers null outside local mode).

amal66 and others added 17 commits August 21, 2026 10:21
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>
@amal66
amal66 force-pushed the feat/mac-self-contained-plan branch from 5500e84 to 9194e80 Compare August 21, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants