diff --git a/backend/.env.example b/backend/.env.example index 5f7332bdeb..033e3a9c35 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,12 @@ R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com R2_ACCESS_KEY_ID=your-r2-access-key R2_SECRET_ACCESS_KEY=your-r2-secret-key R2_BUCKET_NAME=mike +# Only needed when the storage endpoint above is NOT reachable from the user's +# browser (e.g. a compose-internal hostname). Presigned download URLs are +# signed against this instead. Cloud R2/S3 endpoints are already public, so +# leave it unset there. NOTE: for the docker-compose stack this must be set in +# the compose-root .env or the shell, not here — see docker-compose.yml. +# R2_PUBLIC_ENDPOINT_URL=https://files.your-domain.com GEMINI_API_KEY=your-gemini-key ANTHROPIC_API_KEY=your-anthropic-key diff --git a/backend/src/__tests__/integration/user.routes.test.ts b/backend/src/__tests__/integration/user.routes.test.ts index 6be4b8f996..4403e2e259 100644 --- a/backend/src/__tests__/integration/user.routes.test.ts +++ b/backend/src/__tests__/integration/user.routes.test.ts @@ -647,6 +647,77 @@ describe("user.routes", () => { }); }); + // ── GET /user/mcp-connectors/oauth/callback (popup hand-off page) ───── + describe("GET /user/mcp-connectors/oauth/callback", () => { + // The 400 path (missing state/code) renders the same popup HTML via + // the same header helper as the success path, without needing any + // real OAuth machinery — so it is the regression probe for both. + it("relaxes COOP so window.opener survives, alongside the nonce CSP", async () => { + const log = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + const res = await request(app).get( + "/user/mcp-connectors/oauth/callback", + ); + + expect(res.status).toBe(400); + // Load-bearing: helmet's default COOP of same-origin would sever + // window.opener the moment the popup returns from the + // cross-origin consent page, silently breaking the postMessage + // hand-off. This must hold through the full app assembly (helmet + // runs on this very request), not just on the bare router. + expect(res.headers["cross-origin-opener-policy"]).toBe( + "unsafe-none", + ); + // The route-scoped CSP (with the per-response script nonce) must + // survive alongside the COOP relaxation. + expect(res.headers["content-security-policy"]).toContain( + "script-src 'nonce-", + ); + log.mockRestore(); + }); + + it("keeps helmet's default COOP on every other route", async () => { + // Contrast probe: the relaxation must stay scoped to the popup + // page. If it ever leaks app-wide, this fails. + supabaseState.tables.user_profiles = { + data: profileRow(), + error: null, + }; + + const res = await request(app).get("/user/profile").set(...AUTH); + + expect(res.headers["cross-origin-opener-policy"]).toBe( + "same-origin", + ); + }); + + it("neutralizes a breakout in the attacker-controlled error detail", async () => { + const log = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + // ?error= flows into the inline script's JSON literal. + // JSON.stringify leaves "<" alone, so an unescaped payload of + // "), + ); + + expect(res.status).toBe(400); + // No breakout: the only left is the page's own closing + // tag, and the payload's "<" chars were escaped to \u003c inside + // the JS string literal. + expect(res.text).not.toContain("/g)).toHaveLength(1); + expect(res.text).toContain("\\u003c/script>"); + log.mockRestore(); + }); + }); + // ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ──────── describe("PATCH /user/security/mfa-login", () => { it("returns 400 when enabling without a verified TOTP factor", async () => { diff --git a/backend/src/lib/__tests__/storageFs.test.ts b/backend/src/lib/__tests__/storageFs.test.ts new file mode 100644 index 0000000000..0fe297efa1 --- /dev/null +++ b/backend/src/lib/__tests__/storageFs.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +// STORAGE_DRIVER / STORAGE_FS_ROOT are captured at module load in ../storage, +// so each case configures process.env BEFORE importing a fresh copy (same +// reset-then-dynamic-import pattern as storagePresign.test.ts). +async function loadFsStorage(root: string) { + vi.resetModules(); + process.env.STORAGE_DRIVER = "fs"; + process.env.STORAGE_FS_ROOT = root; + process.env.DOWNLOAD_SIGNING_SECRET = "test-signing-secret"; + process.env.BACKEND_PUBLIC_URL = "http://localhost:3001"; + delete process.env.R2_ENDPOINT_URL; + delete process.env.R2_ACCESS_KEY_ID; + delete process.env.R2_SECRET_ACCESS_KEY; + return import("../storage"); +} + +let root: string; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "mike-storage-fs-")); +}); + +afterEach(async () => { + delete process.env.STORAGE_DRIVER; + delete process.env.STORAGE_FS_ROOT; + delete process.env.BACKEND_PUBLIC_URL; + await fs.rm(root, { recursive: true, force: true }); +}); + +describe("filesystem storage driver", () => { + it("is enabled by STORAGE_DRIVER=fs without any R2 config", async () => { + const { storageEnabled } = await loadFsStorage(root); + expect(storageEnabled).toBe(true); + }); + + it("round-trips upload → download → delete", async () => { + const storage = await loadFsStorage(root); + const key = "documents/u1/d1/source.pdf"; + const content = new TextEncoder().encode("pdf bytes").buffer as ArrayBuffer; + + await storage.uploadFile(key, content, "application/pdf"); + const back = await storage.downloadFile(key); + expect(back).not.toBeNull(); + expect(Buffer.from(back!).toString()).toBe("pdf bytes"); + + await storage.deleteFile(key); + expect(await storage.downloadFile(key)).toBeNull(); + }); + + it("deleteFile tolerates a missing key (S3 delete semantics)", async () => { + const storage = await loadFsStorage(root); + await expect(storage.deleteFile("documents/u1/gone.bin")).resolves + .toBeUndefined(); + }); + + it("listFiles matches S3 string-prefix semantics, not directories", async () => { + const storage = await loadFsStorage(root); + const enc = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer; + await storage.uploadFile("documents/u1/d1/source.pdf", enc("a"), "x"); + await storage.uploadFile("documents/u1/d1/versions/v1.docx", enc("b"), "x"); + await storage.uploadFile("documents/u1/d2/source.pdf", enc("c"), "x"); + await storage.uploadFile("generated/u1/d1/generated.docx", enc("d"), "x"); + + // Whole-directory prefix + expect(await storage.listFiles("documents/u1/d1/")).toEqual([ + "documents/u1/d1/source.pdf", + "documents/u1/d1/versions/v1.docx", + ]); + // Partial-segment prefix must match d1 AND d2, like S3 would + expect(await storage.listFiles("documents/u1/d")).toEqual([ + "documents/u1/d1/source.pdf", + "documents/u1/d1/versions/v1.docx", + "documents/u1/d2/source.pdf", + ]); + expect(await storage.listFiles("nope/")).toEqual([]); + }); + + it("getSignedUrl returns an expiring blob-token URL on the backend", async () => { + const storage = await loadFsStorage(root); + const url = await storage.getSignedUrl( + "documents/u1/d1/source.pdf", + 3600, + "Contract v2.pdf", + ); + expect(url).toMatch( + /^http:\/\/localhost:3001\/download\/signed\/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + ); + + const { verifyBlobToken } = await import("../downloadTokens"); + const token = url!.split("/download/signed/")[1]; + expect(verifyBlobToken(token)).toEqual({ + path: "documents/u1/d1/source.pdf", + filename: "Contract v2.pdf", + }); + }); + + it("rejects keys that escape the storage root", async () => { + const storage = await loadFsStorage(root); + await expect( + storage.uploadFile( + "../outside.bin", + new ArrayBuffer(1), + "application/octet-stream", + ), + ).rejects.toThrow(/escapes STORAGE_FS_ROOT/); + }); +}); + +describe("blob tokens", () => { + it("expired tokens verify as null", async () => { + await loadFsStorage(root); + const { signBlobToken, verifyBlobToken } = await import("../downloadTokens"); + const token = signBlobToken("documents/u1/d1/source.pdf", "a.pdf", -5); + expect(verifyBlobToken(token)).toBeNull(); + }); + + it("blob and permanent download tokens are not interchangeable", async () => { + await loadFsStorage(root); + const { signBlobToken, signDownload, verifyBlobToken, verifyDownload } = + await import("../downloadTokens"); + // A permanent token must not pass the blob verifier (it has no expiry), + // and a blob token must not pass the permanent verifier — the HMACs are + // domain-separated so one capability can't be replayed as the other. + expect(verifyBlobToken(signDownload("p", "f"))).toBeNull(); + expect(verifyDownload(signBlobToken("p", "f", 60))).toBeNull(); + }); +}); diff --git a/backend/src/lib/__tests__/storagePresign.test.ts b/backend/src/lib/__tests__/storagePresign.test.ts new file mode 100644 index 0000000000..9529bf2c85 --- /dev/null +++ b/backend/src/lib/__tests__/storagePresign.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +// These tests intentionally do NOT mock @aws-sdk/s3-request-presigner: the +// behavior under test is which endpoint the real presigner signs against. +// Presigning is pure local crypto (no network), so letting the real SDK +// produce the URL and parsing its host keeps the test honest — a mock would +// happily "sign" against whatever client the code handed it. +// +// PRESIGN_ENDPOINT is captured at module load in ../storage, so each case +// must configure process.env BEFORE importing a fresh copy of the module +// (same reset-then-dynamic-import pattern as storageErrors.test.ts). +async function loadStorage(publicEndpoint?: string) { + vi.resetModules(); + process.env.R2_ENDPOINT_URL = "http://storage:9000"; + process.env.R2_ACCESS_KEY_ID = "test-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "test-secret-key"; + process.env.R2_BUCKET_NAME = "mike"; + if (publicEndpoint === undefined) { + delete process.env.R2_PUBLIC_ENDPOINT_URL; + } else { + process.env.R2_PUBLIC_ENDPOINT_URL = publicEndpoint; + } + return import("../storage"); +} + +describe("getSignedUrl presign endpoint split", () => { + it("signs against R2_PUBLIC_ENDPOINT_URL, not the internal endpoint", async () => { + // Self-hosted deploys reach storage at a compose-internal hostname + // (http://storage:9000) that a browser can never resolve. The presigned + // URL is handed to the browser, so it must be signed against the + // host-published endpoint — and an S3 signature is bound to the host it + // was signed for, so this cannot be fixed up after the fact. + const { getSignedUrl } = await loadStorage("http://localhost:9100"); + + const url = await getSignedUrl("some/key"); + + expect(url).not.toBeNull(); + const parsed = new URL(url!); + expect(parsed.host).toBe("localhost:9100"); + expect(parsed.host).not.toBe("storage:9000"); + // Still a real path-style presigned GET for the requested object. + expect(parsed.pathname).toBe("/mike/some/key"); + expect(parsed.searchParams.get("X-Amz-Signature")).toBeTruthy(); + }); + + it("falls back to the R2_ENDPOINT_URL host when no public endpoint is set", async () => { + // Cloud R2/S3 endpoints are already publicly reachable; without + // R2_PUBLIC_ENDPOINT_URL the presigner must keep using the one + // configured endpoint unchanged. + const { getSignedUrl } = await loadStorage(undefined); + + const url = await getSignedUrl("some/key"); + + expect(url).not.toBeNull(); + expect(new URL(url!).host).toBe("storage:9000"); + }); +}); diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index 71207fc5ac..bdb483b391 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -79,3 +79,59 @@ export function verifyDownload( export function buildDownloadUrl(path: string, filename: string): string { return `/download/${signDownload(path, filename)}`; } + +/** + * Expiring blob tokens — the filesystem storage driver's stand-in for S3 + * presigned URLs. + * + * A presigned URL is a capability: whoever holds it can fetch that one object + * until it expires, with no session attached (the browser follows it as a + * plain click, so no Authorization header is available). These tokens + * reproduce exactly that contract: HMAC over {path, filename, exp}, verified + * by the unauthenticated `/download/signed/:token` route. Access control + * happened when the token was minted — the same moment it would have happened + * for a presigned URL. + * + * Unlike `signDownload` above, these DO expire: they replace URLs that always + * carried an expiry, and the routes that mint them re-check access on every + * request, so a short lifetime costs nothing. + */ +export function signBlobToken( + path: string, + filename: string, + expiresInSeconds: number, +): string { + const exp = Math.floor(Date.now() / 1000) + expiresInSeconds; + const payload = JSON.stringify({ p: path, f: filename, e: exp }); + const enc = b64urlEncode(Buffer.from(payload, "utf8")); + const sig = crypto + .createHmac("sha256", getSecret()) + .update("blob:" + enc) + .digest(); + return `${enc}.${b64urlEncode(sig)}`; +} + +export function verifyBlobToken( + token: string, +): { path: string; filename: string } | null { + const parts = token.split("."); + if (parts.length !== 2) return null; + const [enc, sigEnc] = parts; + const expected = crypto + .createHmac("sha256", getSecret()) + .update("blob:" + enc) + .digest(); + if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null; + try { + const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as { + p: string; + f: string; + e: number; + }; + if (!parsed?.p || !parsed?.f || typeof parsed.e !== "number") return null; + if (parsed.e < Math.floor(Date.now() / 1000)) return null; + return { path: parsed.p, filename: parsed.f }; + } catch { + return null; + } +} diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index faf9815e73..386665888b 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -17,7 +17,62 @@ import { } from "@aws-sdk/client-s3"; import * as S3Commands from "@aws-sdk/client-s3"; import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; +import fs from "fs/promises"; +import path from "path"; import { safeErrorLog } from "./safeError"; +import { signBlobToken } from "./downloadTokens"; + +// --------------------------------------------------------------------------- +// Driver selection — STORAGE_DRIVER=fs swaps the S3 client for the local +// filesystem, keeping this module's public API identical. Built for the +// self-contained desktop app (no storage daemon to supervise), but works for +// any single-node deploy. Everything below the dispatch points is unchanged +// S3 code. +// +// fs mode has no presigned URLs, so getSignedUrl returns a backend-served +// URL instead: an expiring HMAC "blob token" (see downloadTokens.ts) on the +// unauthenticated /download/signed/:token route — the same capability +// semantics a presigned URL has. BACKEND_PUBLIC_URL must be the +// browser-reachable base URL of this backend (the desktop supervisor sets +// it; defaults to localhost:PORT which is correct for local single-machine +// use). +// --------------------------------------------------------------------------- + +const FS_DRIVER = process.env.STORAGE_DRIVER === "fs"; +const FS_ROOT = process.env.STORAGE_FS_ROOT; + +function backendPublicUrl(): string { + return ( + process.env.BACKEND_PUBLIC_URL ?? + `http://localhost:${process.env.PORT ?? 3001}` + ).replace(/\/+$/, ""); +} + +// Storage keys are backend-constructed, but resolve-and-check anyway so a +// corrupted key can never escape the storage root. +function fsPathFor(key: string): string { + const root = path.resolve(FS_ROOT!); + const resolved = path.resolve(root, key); + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + throw new Error(`storage key escapes STORAGE_FS_ROOT: ${key}`); + } + return resolved; +} + +async function fsWalk(dir: string, out: string[], root: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await fsWalk(full, out, root); + else if (entry.isFile()) + out.push(path.relative(root, full).split(path.sep).join("/")); + } +} const GetObjectCommand = (S3Commands as any).GetObjectCommand; @@ -38,18 +93,52 @@ function getClient(): S3Client { return cachedClient; } +// Presigned URLs are handed to the user's browser, so their signature must be +// computed against an endpoint the browser can actually reach. Self-hosted +// deploys talk to storage over the compose network (http://storage:9000) — a +// hostname that only resolves inside Docker, and an S3 signature is bound to +// the host it was signed for, so the URL can't simply be rewritten afterwards. +// R2_PUBLIC_ENDPOINT_URL lets those deploys sign against the host-published +// endpoint instead; cloud R2/S3 endpoints are already public, so it defaults +// to R2_ENDPOINT_URL and nothing changes there. +// Read once at module load, like the internal client's config: the endpoint is +// static per process, and reading it here (rather than per call) keeps the +// cache honest — a frozen client can't disagree with a re-read env var. +const PRESIGN_ENDPOINT = process.env.R2_PUBLIC_ENDPOINT_URL; +let cachedPresignClient: S3Client | undefined; + +function getPresignClient(): S3Client { + if (!PRESIGN_ENDPOINT) return getClient(); + if (!cachedPresignClient) { + cachedPresignClient = new S3Client({ + region: "auto", + endpoint: PRESIGN_ENDPOINT, + forcePathStyle: true, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); + } + return cachedPresignClient; +} + const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; -export const storageEnabled = Boolean( - process.env.R2_ENDPOINT_URL && - process.env.R2_ACCESS_KEY_ID && - process.env.R2_SECRET_ACCESS_KEY, -); +export const storageEnabled = FS_DRIVER + ? Boolean(FS_ROOT) + : Boolean( + process.env.R2_ENDPOINT_URL && + process.env.R2_ACCESS_KEY_ID && + process.env.R2_SECRET_ACCESS_KEY, + ); function requireStorageConfig(): void { if (!storageEnabled) { throw new Error( - "R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", + FS_DRIVER + ? "STORAGE_FS_ROOT must be set when STORAGE_DRIVER=fs" + : "R2_ENDPOINT_URL, R2_ACCESS_KEY_ID, and R2_SECRET_ACCESS_KEY must be set", ); } } @@ -64,6 +153,12 @@ export async function uploadFile( contentType: string, ): Promise { requireStorageConfig(); + if (FS_DRIVER) { + const target = fsPathFor(key); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, Buffer.from(content)); + return; + } const client = getClient(); await client.send( new PutObjectCommand({ @@ -81,6 +176,23 @@ export async function uploadFile( export async function downloadFile(key: string): Promise { if (!storageEnabled) return null; + if (FS_DRIVER) { + try { + const bytes = await fs.readFile(fsPathFor(key)); + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + console.error("[storage] downloadFile failed", { + key, + error: safeErrorLog(error), + }); + } + return null; + } + } try { const client = getClient(); const response = (await client.send( @@ -100,6 +212,17 @@ export async function downloadFile(key: string): Promise { export async function listFiles(prefix: string): Promise { if (!storageEnabled) return []; + if (FS_DRIVER) { + // S3 prefixes are plain string prefixes, not directories ("documents/u1/d" + // matches "documents/u1/d2/…"). Walk the deepest whole directory in the + // prefix, then string-filter, so the two drivers agree exactly. + const root = path.resolve(FS_ROOT!); + const lastSlash = prefix.lastIndexOf("/"); + const dirPart = lastSlash >= 0 ? prefix.slice(0, lastSlash) : ""; + const all: string[] = []; + await fsWalk(dirPart ? fsPathFor(dirPart) : root, all, root); + return all.filter((k) => k.startsWith(prefix)).sort(); + } const client = getClient(); const keys: string[] = []; let ContinuationToken: string | undefined; @@ -125,6 +248,14 @@ export async function listFiles(prefix: string): Promise { export async function deleteFile(key: string): Promise { if (!storageEnabled) return; + if (FS_DRIVER) { + try { + await fs.unlink(fsPathFor(key)); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error; + } + return; + } const client = getClient(); await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); } @@ -139,8 +270,14 @@ export async function getSignedUrl( downloadFilename?: string, ): Promise { if (!storageEnabled) return null; + if (FS_DRIVER) { + const filename = + downloadFilename ?? normalizeDownloadFilename(path.posix.basename(key)); + const token = signBlobToken(key, filename, expiresIn); + return `${backendPublicUrl()}/download/signed/${token}`; + } try { - const client = getClient(); + const client = getPresignClient(); // Override the response Content-Disposition so the browser uses this // filename on download, instead of the last path segment of the R2 key // (which includes the document UUID). The `download` attribute on diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 9726f86e59..758fa86095 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { buildContentDisposition, downloadFile } from "../lib/storage"; -import { verifyDownload } from "../lib/downloadTokens"; +import { verifyDownload, verifyBlobToken } from "../lib/downloadTokens"; import { ensureDocAccess } from "../lib/access"; import { contentTypeForDocumentType } from "../lib/documentTypes"; @@ -15,6 +15,30 @@ function contentTypeFor(filename: string): string { return contentTypeForDocumentType(suffix); } +// GET /download/signed/:token — the filesystem storage driver's presigned +// URL. Deliberately unauthenticated, exactly like the S3 presigned URLs it +// replaces: the browser reaches it through a bare click or fetch with no +// Authorization header. The token IS the authorization — an expiring HMAC +// capability minted by an authenticated route (documents /url, workflow +// references) AFTER its own access check, scoped to one object. Registered +// before /:token so Express doesn't swallow it with the pattern below. +downloadsRouter.get("/signed/:token", async (req, res) => { + const info = verifyBlobToken(req.params.token); + if (!info) + return void res.status(404).json({ detail: "Invalid or expired link" }); + + const raw = await downloadFile(info.path); + if (!raw) + return void res.status(404).json({ detail: "File not found" }); + + res.setHeader("Content-Type", contentTypeFor(info.filename)); + res.setHeader( + "Content-Disposition", + buildContentDisposition("attachment", info.filename), + ); + res.send(Buffer.from(raw)); +}); + // GET /download/:token downloadsRouter.get("/:token", requireAuth, async (req, res) => { const userId = res.locals.userId as string; diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index e4234aae1d..f19074ea08 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -124,10 +124,17 @@ function mcpOAuthPopupHtml( ) { const targetOrigin = new URL(frontendUrl()).origin; const targetUrl = frontendUrl(); + // `detail` carries the attacker-controllable ?error= query value into this + // inline script. JSON.stringify does not escape "<", so a payload of + // " + + diff --git a/desktop/src/pages/local-boot.html b/desktop/src/pages/local-boot.html new file mode 100644 index 0000000000..a210fa198d --- /dev/null +++ b/desktop/src/pages/local-boot.html @@ -0,0 +1,85 @@ + + + + + Mike + + + +
+
+

Starting Mike on this Mac

+
Warming up…
+
+ + + diff --git a/desktop/src/pages/welcome.html b/desktop/src/pages/welcome.html new file mode 100644 index 0000000000..3c8b221cfb --- /dev/null +++ b/desktop/src/pages/welcome.html @@ -0,0 +1,144 @@ + + + + + Mike + + + +
+

Welcome to Mike

+

Where should Mike run? You can change this any time (⌘⇧,).

+
+ + +
+

+ Self-hosting Mike? Connect to your own server… +

+
+ + + diff --git a/desktop/src/preload.js b/desktop/src/preload.js new file mode 100644 index 0000000000..e919d6a275 --- /dev/null +++ b/desktop/src/preload.js @@ -0,0 +1,22 @@ +// Bridge for the shell's own pages (the connection screen). The Mike web app +// itself gets no privileged APIs — it must behave identically in a browser — +// with one deliberate exception: guestCredentials, which the login page uses +// to offer "Continue as guest" in local mode. It is read-only and gated +// main-side to local mode + the local frontend's origin. + +const { contextBridge, ipcRenderer } = require("electron"); + +contextBridge.exposeInMainWorld("mikeDesktop", { + getServerUrl: () => ipcRenderer.invoke("mike:get-server-url"), + setServerUrl: (url) => ipcRenderer.invoke("mike:set-server-url", url), + retry: () => ipcRenderer.invoke("mike:retry"), + // Local mode (self-contained stack). startLocal is gated main-side to the + // shell's own pages; localAvailable and onLocalStatus are read-only. + localAvailable: () => ipcRenderer.invoke("mike:local-available"), + startLocal: () => ipcRenderer.invoke("mike:start-local"), + chooseCloud: () => ipcRenderer.invoke("mike:choose-cloud"), + guestCredentials: () => ipcRenderer.invoke("mike:guest-credentials"), + openConnect: () => ipcRenderer.invoke("mike:open-connect"), + onLocalStatus: (cb) => + ipcRenderer.on("mike:local-status", (_event, msg) => cb(String(msg))), +}); diff --git a/docker-compose.yml b/docker-compose.yml index a38892a6cf..03ff973e49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -195,6 +195,15 @@ services: - SUPABASE_URL=http://gateway:8000 - SUPABASE_SECRET_KEY=${SUPABASE_SECRET_KEY:-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU} - R2_ENDPOINT_URL=http://storage:9000 + # Presigned URLs go to the user's browser, which can't resolve the + # compose-internal "storage" hostname — sign them against the + # host-published port instead (see backend/src/lib/storage.ts). The + # default works for a browser on the docker host; a REMOTE deploy must + # set R2_PUBLIC_ENDPOINT_URL to a public HTTPS URL that reverse-proxies + # to storage (the storage port itself is bound loopback-only below). This + # var is interpolated from the shell / compose-root .env, NOT backend/.env + # (env_file can't win over an interpolated value — see the note below). + - R2_PUBLIC_ENDPOINT_URL=${R2_PUBLIC_ENDPOINT_URL:-http://localhost:${STORAGE_PORT:-9000}} - R2_ACCESS_KEY_ID=rustfsadmin - R2_SECRET_ACCESS_KEY=rustfsadmin - R2_BUCKET_NAME=mike diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 3c779f5d99..31d9e2c85d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -19,6 +19,13 @@ if (process.env.NODE_ENV === "production") { const nextConfig: NextConfig = { /* config options here */ + // Standalone output emits a self-contained server (server.js + traced + // node_modules) that runs without the repo — the desktop app bundles it + // and runs it under Electron's own Node. Opt-in so the Docker image and + // dev workflow are untouched. + ...(process.env.NEXT_OUTPUT_STANDALONE === "1" + ? { output: "standalone" as const } + : {}), reactCompiler: true, turbopack: { root: __dirname, diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index ec8dd2fcc2..38d49bcd5e 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -14,6 +14,20 @@ import { authInputClassName, } from "@/app/components/auth/authStyles"; +// The Mac desktop shell's preload bridge. Only its local ("everything on +// this Mac") mode answers guestCredentials with a value — in a browser the +// bridge doesn't exist, and against a hosted server it returns null — so +// gating the guest button on the answer keeps this page byte-identical in +// behavior everywhere else. +type GuestCredentials = { email: string; password: string }; +declare global { + interface Window { + mikeDesktop?: { + guestCredentials?: () => Promise; + }; + } +} + export default function LoginPage() { const router = useRouter(); const { isAuthenticated, authLoading } = useAuth(); @@ -21,6 +35,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [guest, setGuest] = useState(null); useEffect(() => { if (!authLoading && isAuthenticated) { @@ -28,6 +43,21 @@ export default function LoginPage() { } }, [authLoading, isAuthenticated, router]); + useEffect(() => { + let cancelled = false; + window.mikeDesktop + ?.guestCredentials?.() + .then((creds) => { + if (!cancelled && creds?.email && creds?.password) { + setGuest(creds); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -53,6 +83,31 @@ export default function LoginPage() { } }; + const handleGuestLogin = async () => { + if (!guest) return; + setLoading(true); + setError(null); + + try { + const { error } = await supabase.auth.signInWithPassword(guest); + if (error) { + // First use: the guest account doesn't exist yet. Local mode + // autoconfirms signups, so this returns a session directly. + const { error: signUpError } = await supabase.auth.signUp(guest); + if (signUpError) throw signUpError; + } + router.push("/assistant"); + } catch (error: unknown) { + setError( + error instanceof Error + ? error.message + : "An error occurred during guest login", + ); + } finally { + setLoading(false); + } + }; + return (
@@ -127,6 +182,25 @@ export default function LoginPage() { {loading ? "Logging in..." : "Log in"}
+ {guest && ( + <> +
+
+ or +
+
+ + Continue as guest + + + )}
Don't have an account?{" "}