diff --git a/PRELAUNCH.md b/PRELAUNCH.md new file mode 100644 index 0000000..7e5b501 --- /dev/null +++ b/PRELAUNCH.md @@ -0,0 +1,48 @@ +# openmud pre-launch checklist + +This checklist is the short operational view of the current pre-launch hardening work. + +## North-star workflow + +`Homepage -> sign in -> open desktop app -> set up sync -> open a project -> add task/chat -> reopen on another client and see the right state` + +## Must-hold product rules + +- Web and desktop sign-in must be reliable. +- Signing out or switching accounts must not leak old account state. +- Projects are cloud-canonical. +- Chats and tasks must reconcile predictably across clients. +- Desktop sync is mirror-based and non-destructive. +- Missing mirror files must not silently delete app documents. +- Users must be able to tell which features are: + - cloud-backed + - local cache + - local-only + - desktop-only + +## Current pre-launch deliverables + +- [x] Desktop auth handoff moved off raw token URLs +- [x] Web and desktop local state scoped by `user.id` +- [x] Project deletion is explicit and durable through the API +- [x] Project chat/task state sync uses a cloud-backed per-project state record +- [x] Desktop sync no longer deletes app documents on mirror absence +- [x] Auth flow documented +- [x] Sync ownership documented +- [x] QA checklist documented + +## Release-blocking QA + +- Sign in on web +- Sign in on desktop +- Sign out on web +- Sign out on desktop +- Switch between two accounts on one machine +- Delete a project and confirm it does not return +- Create/update a chat thread on one client and verify it on the other +- Create/update a task on one client and verify it on the other +- Set up desktop sync +- Add a file in openmud and verify it mirrors to disk +- Add a file on disk and verify it imports into openmud +- Remove a mirror file on disk and verify the openmud document is preserved +- Change the sync root and verify no project data is wiped diff --git a/desktop/main.js b/desktop/main.js index 761fd93..30f50a0 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -5,6 +5,7 @@ if (process.env.DEV === '1' || process.env.NODE_ENV === 'development') { const { app, BrowserWindow, shell, dialog, Menu, ipcMain } = require('electron'); const path = require('path'); const http = require('http'); +const crypto = require('crypto'); const isDev = process.env.DEV === '1' || process.env.NODE_ENV === 'development'; @@ -20,12 +21,15 @@ if (!app.isPackaged && process.argv.length >= 2) { function handleDeepLink(url) { if (!url) return; try { - // openmud://auth?access_token=...&refresh_token=... + // openmud://auth?handoff=... const parsed = new URL(url); if (parsed.hostname === 'auth') { + const handoffCode = parsed.searchParams.get('handoff'); const accessToken = parsed.searchParams.get('access_token'); const refreshToken = parsed.searchParams.get('refresh_token'); - if (accessToken && refreshToken && mainWindowRef && !mainWindowRef.isDestroyed()) { + if (handoffCode && mainWindowRef && !mainWindowRef.isDestroyed()) { + mainWindowRef.webContents.send('mudrag:auth-callback', { handoff_code: handoffCode }); + } else if (accessToken && refreshToken && mainWindowRef && !mainWindowRef.isDestroyed()) { mainWindowRef.webContents.send('mudrag:auth-callback', { access_token: accessToken, refresh_token: refreshToken }); } } @@ -92,8 +96,19 @@ const desktopSyncState = { rootPath: '', watcher: null, eventTimers: {}, + projectIgnoreUntil: {}, }; +function logPrelaunchEvent(eventName, payload = {}) { + console.log(JSON.stringify({ + event: eventName, + source: 'desktop', + user_id: storage.getActiveUser ? storage.getActiveUser() : 'anon', + at: new Date().toISOString(), + ...payload, + })); +} + function slugifyProjectName(name) { const cleaned = String(name || 'Project') .replace(/[<>:"/\\|?*\u0000-\u001F]/g, ' ') @@ -189,33 +204,13 @@ function ensureProjectFolders(projectDir, folders) { }); } -function removeExtraPaths(projectDir, desiredFiles) { - const current = []; - listRelativeFilesRecursive(projectDir, projectDir, current); - current.forEach((file) => { - const rel = String(file.relativePath || '').replace(/\\/g, '/'); - if (!desiredFiles.has(rel)) { - try { fs.unlinkSync(file.path); } catch (err) {} - } - }); +function hashBuffer(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); } -function pruneEmptyDirs(rootDir) { - if (!rootDir || !fs.existsSync(rootDir)) return; - let entries = []; - try { - entries = fs.readdirSync(rootDir, { withFileTypes: true }); - } catch (err) { - return; - } - entries.forEach((entry) => { - if (!entry.isDirectory()) return; - const absPath = path.join(rootDir, entry.name); - pruneEmptyDirs(absPath); - try { - if (fs.readdirSync(absPath).length === 0) fs.rmdirSync(absPath); - } catch (err) {} - }); +function muteProjectWatcher(projectId, ms = 4000) { + if (!projectId) return; + desktopSyncState.projectIgnoreUntil[projectId] = Date.now() + ms; } function emitDesktopSyncEvent(data) { @@ -254,6 +249,9 @@ function startDesktopSyncWatcher(rootPath) { return projectPath && projectPath.endsWith('/' + projectName); }); const projectId = match ? match[0] : null; + if (projectId && desktopSyncState.projectIgnoreUntil[projectId] && desktopSyncState.projectIgnoreUntil[projectId] > Date.now()) { + return; + } const projectPath = match && match[1] ? match[1].path : path.join(targetRoot, projectName); scheduleDesktopSyncEvent(projectId, projectPath, 'desktop-change'); }); @@ -275,23 +273,42 @@ function writeProjectSnapshotToDesktop(opts) { try { fs.renameSync(previousDir, desiredDir); } catch (err) {} } ensureProjectFolders(desiredDir, payload.folders || []); - const desiredFiles = new Set(); + const manifest = {}; + muteProjectWatcher(payload.projectId, 4500); (payload.files || []).forEach((file) => { const relPath = String(file && file.relativePath || '').replace(/^\/+/, '').replace(/\\/g, '/'); if (!relPath || !file.base64) return; - desiredFiles.add(relPath); + const decoded = Buffer.from(String(file.base64), 'base64'); const targetPath = path.join(desiredDir, relPath); ensureDirSync(path.dirname(targetPath)); + const fileHash = hashBuffer(decoded); + manifest[relPath] = { + id: file.id || null, + name: file.name || path.basename(relPath), + size: file.size || decoded.length, + hash: fileHash, + mirroredAt: new Date().toISOString(), + }; try { - fs.writeFileSync(targetPath, Buffer.from(String(file.base64), 'base64')); + let shouldWrite = true; + if (fs.existsSync(targetPath)) { + try { + const existing = fs.readFileSync(targetPath); + shouldWrite = hashBuffer(existing) !== fileHash; + } catch (_) { + shouldWrite = true; + } + } + if (shouldWrite) fs.writeFileSync(targetPath, decoded); } catch (err) {} }); - removeExtraPaths(desiredDir, desiredFiles); - pruneEmptyDirs(desiredDir); const nextProjects = {}; nextProjects[payload.projectId] = { path: desiredDir, name: payload.projectName, + manifest, + mirrorMode: 'non_destructive', + lastSyncStatus: 'mirror_active', lastSyncAt: new Date().toISOString(), }; setDesktopSyncConfig({ @@ -301,7 +318,13 @@ function writeProjectSnapshotToDesktop(opts) { projects: nextProjects, }); startDesktopSyncWatcher(rootPath); - return { ok: true, rootPath, projectPath: desiredDir }; + return { + ok: true, + rootPath, + projectPath: desiredDir, + syncMode: 'non_destructive', + statusLabels: ['synced', 'mirror active'], + }; } function getWebPath() { @@ -4350,6 +4373,14 @@ ipcMain.handle('mudrag:read-local-file', async (_, filePath) => { return readFileForImport(filePath); }); +ipcMain.handle('mudrag:set-active-account', async (_, opts = {}) => { + const userId = String(opts.userId || '').trim() || 'anon'; + const email = String(opts.email || '').trim(); + const activeUser = storage.setActiveUser(userId); + logPrelaunchEvent('desktop_account_scope_set', { scoped_user_id: activeUser, email: email || null }); + return { ok: true, userId: activeUser, email }; +}); + ipcMain.handle('mudrag:desktop-sync-setup', async (_, opts = {}) => { const rootPath = opts.rootPath || getDesktopSyncConfig().rootPath || getDefaultDesktopSyncRoot(); ensureDirSync(rootPath); @@ -4357,11 +4388,15 @@ ipcMain.handle('mudrag:desktop-sync-setup', async (_, opts = {}) => { const projects = Array.isArray(opts.projects) ? opts.projects : []; projects.forEach((project) => { if (!project || !project.id) return; + muteProjectWatcher(project.id, 4500); const projectPath = path.join(rootPath, slugifyProjectName(project.name)); ensureDirSync(projectPath); projectMap[project.id] = { path: projectPath, name: project.name || 'Project', + manifest: {}, + mirrorMode: 'non_destructive', + lastSyncStatus: 'mirror_active', lastSyncAt: null, }; }); @@ -4372,11 +4407,22 @@ ipcMain.handle('mudrag:desktop-sync-setup', async (_, opts = {}) => { projects: projectMap, }); startDesktopSyncWatcher(rootPath); + logPrelaunchEvent('desktop_sync_setup_completed', { + root_path: rootPath, + project_count: Object.keys(projectMap).length, + }); return { ok: true, rootPath, config }; }); ipcMain.handle('mudrag:desktop-sync-project', async (_, opts = {}) => { - return writeProjectSnapshotToDesktop(opts); + const result = writeProjectSnapshotToDesktop(opts); + logPrelaunchEvent(result && result.ok ? 'desktop_sync_project_completed' : 'desktop_sync_project_failed', { + project_id: opts.projectId || null, + project_name: opts.projectName || null, + root_path: result && result.rootPath ? result.rootPath : null, + message: result && result.error ? result.error : null, + }); + return result; }); ipcMain.handle('mudrag:desktop-sync-status', async (_, opts = {}) => { @@ -4390,6 +4436,8 @@ ipcMain.handle('mudrag:desktop-sync-status', async (_, opts = {}) => { rootPath, projectPath: projectMeta && projectMeta.path ? projectMeta.path : '', projectName: projectMeta && projectMeta.name ? projectMeta.name : '', + syncMode: projectMeta && projectMeta.mirrorMode ? projectMeta.mirrorMode : 'non_destructive', + statusLabels: projectMeta && projectMeta.lastSyncStatus ? ['synced', 'mirror active'] : ['mirror active'], lastSyncAt: projectMeta && projectMeta.lastSyncAt ? projectMeta.lastSyncAt : cfg.lastSyncAt || null, }; }); @@ -4422,6 +4470,10 @@ ipcMain.handle('mudrag:desktop-sync-remove-project', async (_, projectId) => { const nextProjects = { ...(cfg.projects || {}) }; delete nextProjects[projectId]; setDesktopSyncConfig({ replaceProjects: true, projects: nextProjects }); + logPrelaunchEvent('desktop_sync_project_removed', { + project_id: projectId, + project_path: projectMeta && projectMeta.path ? projectMeta.path : null, + }); return { ok: true }; }); diff --git a/desktop/preload.js b/desktop/preload.js index cbd44f2..54cc48c 100644 --- a/desktop/preload.js +++ b/desktop/preload.js @@ -6,6 +6,7 @@ const { contextBridge, ipcRenderer } = require('electron'); const desktopBridge = { openFolder: () => ipcRenderer.invoke('mudrag:open-folder'), + setActiveAccount: (opts) => ipcRenderer.invoke('mudrag:set-active-account', opts), openMail: (opts) => ipcRenderer.invoke('mudrag:open-mail', opts), importMailAttachments: (opts) => ipcRenderer.invoke('mudrag:import-mail-attachments', opts), openExternal: (url) => ipcRenderer.invoke('mudrag:open-external', url), diff --git a/desktop/storage.js b/desktop/storage.js index 4fd93e6..ea68a74 100644 --- a/desktop/storage.js +++ b/desktop/storage.js @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const { app } = require('electron'); +let activeUserId = 'anon'; function getStorageDir() { if (app && app.getPath) { @@ -21,6 +22,37 @@ function ensureDir(dir) { } } +function sanitizeUserId(value) { + const text = String(value || '').trim(); + if (!text) return 'anon'; + return text.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) || 'anon'; +} + +function getUserStorageDir(userId) { + return path.join(getStorageDir(), 'users', sanitizeUserId(userId || activeUserId)); +} + +function getLegacyPath(fileName) { + return path.join(getStorageDir(), fileName); +} + +function getScopedPath(fileName, userId) { + const target = path.join(getUserStorageDir(userId), fileName); + if (!fs.existsSync(target)) { + const legacyPath = getLegacyPath(fileName); + if (fs.existsSync(legacyPath)) { + ensureDir(path.dirname(target)); + try { + fs.copyFileSync(legacyPath, target); + fs.unlinkSync(legacyPath); + } catch (_) { + // best effort migration from legacy global storage + } + } + } + return target; +} + const DEFAULT_LABOR = { operator: 85, laborer: 35, @@ -36,19 +68,19 @@ const DEFAULT_EQUIPMENT = { }; function getRatesPath() { - return path.join(getStorageDir(), 'rates.json'); + return getScopedPath('rates.json'); } function getProjectsPath() { - return path.join(getStorageDir(), 'projects.json'); + return getScopedPath('projects.json'); } function getProfilePath() { - return path.join(getStorageDir(), 'profile.json'); + return getScopedPath('profile.json'); } function getChatsPath() { - return path.join(getStorageDir(), 'chats.json'); + return getScopedPath('chats.json'); } function readJSON(filePath, defaultValue) { @@ -168,7 +200,7 @@ function setChats(projectId, chats) { } function getUserDataPath() { - return path.join(getStorageDir(), 'user-data.json'); + return getScopedPath('user-data.json'); } function getUserData() { @@ -182,8 +214,21 @@ function setUserData(data) { return next; } +function setActiveUser(userId) { + activeUserId = sanitizeUserId(userId); + ensureDir(getUserStorageDir(activeUserId)); + return activeUserId; +} + +function getActiveUser() { + return sanitizeUserId(activeUserId); +} + module.exports = { getStorageDir, + getUserStorageDir, + setActiveUser, + getActiveUser, getRates, setRates, getProjects, diff --git a/desktop/tests/storage.test.js b/desktop/tests/storage.test.js new file mode 100644 index 0000000..243c9f9 --- /dev/null +++ b/desktop/tests/storage.test.js @@ -0,0 +1,74 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const Module = require('module'); + +function loadWithMocks(targetPath, mocks) { + const resolved = require.resolve(targetPath); + delete require.cache[resolved]; + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (mocks && Object.prototype.hasOwnProperty.call(mocks, request)) { + return mocks[request]; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { + return require(resolved); + } finally { + Module._load = originalLoad; + } +} + +function withTempHome(fn) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openmud-storage-test-')); + const originalHome = process.env.HOME; + process.env.HOME = tmpDir; + try { + return fn(tmpDir); + } finally { + process.env.HOME = originalHome; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +test('desktop storage scopes project data by active user', () => withTempHome(() => { + const storage = loadWithMocks(path.join(__dirname, '..', 'storage.js'), { + electron: { app: null }, + }); + + storage.setActiveUser('user_a'); + storage.setProjects([{ id: 'p_a', name: 'Project A' }]); + + storage.setActiveUser('user_b'); + assert.deepEqual(storage.getProjects(), []); + storage.setProjects([{ id: 'p_b', name: 'Project B' }]); + + storage.setActiveUser('user_a'); + assert.deepEqual(storage.getProjects(), [{ id: 'p_a', name: 'Project A' }]); + + storage.setActiveUser('user_b'); + assert.deepEqual(storage.getProjects(), [{ id: 'p_b', name: 'Project B' }]); +})); + +test('desktop storage migrates legacy global files into a user scope', () => withTempHome((tmpDir) => { + const legacyStorageDir = path.join(tmpDir, '.mudrag', 'storage'); + fs.mkdirSync(legacyStorageDir, { recursive: true }); + fs.writeFileSync( + path.join(legacyStorageDir, 'projects.json'), + JSON.stringify([{ id: 'legacy_project', name: 'Legacy Project' }], null, 2), + 'utf8' + ); + + const storage = loadWithMocks(path.join(__dirname, '..', 'storage.js'), { + electron: { app: null }, + }); + + storage.setActiveUser('migrated_user'); + assert.deepEqual(storage.getProjects(), [{ id: 'legacy_project', name: 'Legacy Project' }]); + + const scopedPath = path.join(legacyStorageDir, 'users', 'migrated_user', 'projects.json'); + assert.equal(fs.existsSync(scopedPath), true); +})); diff --git a/docs/AUTH_FLOW.md b/docs/AUTH_FLOW.md new file mode 100644 index 0000000..e9413c6 --- /dev/null +++ b/docs/AUTH_FLOW.md @@ -0,0 +1,70 @@ +# openmud auth flow + +This document describes the intended sign-in, sign-out, and desktop handoff behavior for pre-launch. + +## Goals + +- No raw session tokens in desktop handoff URLs +- No stale account state after sign-out +- No cross-account leakage on shared machines + +## Web sign-in + +1. The browser signs in through Supabase using: + - magic link + - Google + - Apple +2. The client stores the authenticated session through Supabase. +3. openmud scopes account-bound local state by `user.id`. +4. The web app then loads the current user's cloud-backed project state. + +## Desktop handoff + +1. A signed-in browser calls `POST /api/desktop-handoff/start`. +2. The API validates the authenticated user and stores an encrypted, short-lived handoff record. +3. The browser opens the desktop app with: + + `openmud://auth?handoff=` + +4. The desktop renderer redeems that code with `POST /api/desktop-handoff/redeem`. +5. The API returns the session tokens over HTTPS. +6. The renderer restores the Supabase session locally with `setSession(...)`. + +## Why this is safer + +- The URL no longer carries raw `access_token` and `refresh_token` values. +- The handoff code is opaque. +- The code is short-lived. +- The code is consumed on redeem. + +## Sign-out + +On sign-out: + +- Supabase session is cleared +- openmud switches local storage scope back to the anonymous namespace +- account-bound local state is no longer visible +- the desktop app storage scope is reset to `anon` + +## Account switching + +On account switch: + +- web local state is read from the new user's scoped namespace +- desktop JSON storage switches to the new user's scoped directory +- cloud-backed projects and project state load for the new account only + +## Account-bound local state + +The following data is scoped by `user.id`: + +- projects cache +- active project / active chat +- chat thread data +- project task/project state cache +- subscription/account metadata +- provider keys +- relay token +- company profile and logo +- desktop sync enabled flag +- per-account document IndexedDB namespace diff --git a/docs/PRELAUNCH_QA.md b/docs/PRELAUNCH_QA.md new file mode 100644 index 0000000..5b2caec --- /dev/null +++ b/docs/PRELAUNCH_QA.md @@ -0,0 +1,78 @@ +# openmud pre-launch QA matrix + +Use this checklist before public promotion. + +## 1. Sign-in + +### Web +- Open `/welcome` +- Sign in with magic link +- Sign in with Google +- Confirm redirect back into openmud +- Confirm the account email is correct + +### Desktop +- From a signed-in browser, click **Open openmud** +- Confirm the desktop app opens +- Confirm desktop sign-in completes without a raw token URL +- Confirm the correct account email appears in the app + +## 2. Sign-out + +### Web +- Sign out from `/try` +- Sign out from `/settings` +- Confirm projects, chats, tasks, provider keys, and account UI no longer show the previous account + +### Desktop +- Sign out in the desktop app +- Confirm desktop storage switches back to anonymous state + +## 3. Account switching + +- Sign in as Account A +- Create or load distinct project/task/chat state +- Sign out +- Sign in as Account B +- Confirm Account A state is not visible +- Switch back to Account A +- Confirm Account A state returns intact + +Run this sequence across: +- web only +- desktop only +- web then desktop +- desktop then web + +## 4. Projects + +- Create a project +- Rename a project +- Delete a project +- Reload the app +- Reopen another client +- Confirm deleted projects do not resurrect + +## 5. Chats and tasks + +- Create multiple chat threads in one project +- Add messages in a non-default thread +- Switch clients and confirm the correct thread state appears +- Add and complete tasks +- Switch clients and confirm task state matches + +## 6. Desktop sync + +- Set up the sync root +- Confirm the mirror folder is created +- Upload a document in openmud and confirm it appears in the mirror +- Add a file in the mirror and confirm it imports into openmud +- Edit a mirrored file and confirm the app copy updates +- Remove a file from the mirror and confirm the openmud document is preserved +- Change the sync root and confirm project data is not wiped + +## 7. Failure visibility + +- Force a failed desktop handoff and confirm the UI shows a useful error +- Force a failed sync action and confirm the error is visible +- Check logs for structured auth/handoff/sync events diff --git a/docs/SYNC_OWNERSHIP.md b/docs/SYNC_OWNERSHIP.md new file mode 100644 index 0000000..0ead10b --- /dev/null +++ b/docs/SYNC_OWNERSHIP.md @@ -0,0 +1,69 @@ +# openmud sync ownership model + +This document defines the source of truth for early public release. + +## Canonical ownership + +### Account identity +- **Cloud canonical** + +### Projects +- **Cloud canonical** +- Local web/desktop records are caches + +### Chats +- **Cloud canonical** +- Stored in the per-project state record +- Local copies are caches + +### Tasks +- **Cloud canonical** +- Stored in the per-project state record +- Local copies are caches + +### Documents +- **Local app canonical** +- Stored in the browser's per-user IndexedDB namespace +- Not yet treated as full cloud-canonical assets + +### Desktop mirror folder +- **Mirror-only** +- Used for local filesystem workflows +- Not allowed to delete the app copy just because a file is missing + +### Project RAG index +- **Derived data** +- Built from documents and imported text +- Not the source of truth for the underlying files + +## Sync rules + +## Projects + +- Create/update goes to the cloud project record +- Delete is explicit through the projects API +- Missing local cache does not mean delete + +## Chats and tasks + +- Synced as per-project cloud state +- The client treats cloud state as authoritative across devices +- Local edits debounce into cloud updates + +## Desktop sync + +- openmud mirrors project documents out to the desktop folder +- openmud imports desktop edits back in +- openmud does **not** treat mirror absence as a delete command +- Explicit destructive actions require explicit user intent: + - deleting the project + - deleting a document inside openmud + +## User mental model + +- Use the web app for account, cloud projects, hosted models, and synced chat/task state. +- Use the desktop app when you need: + - local folder sync + - local file access + - desktop-linked automations +- The mirror folder is a working copy, not the authority for deleting project documents. diff --git a/package.json b/package.json index c87d796..8ffe521 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "setup:auth": "node scripts/setup-auth.js", "generate:apple-jwt": "node scripts/generate-apple-jwt.js", "test:e2e": "playwright test", + "test:e2e:prelaunch": "playwright test -c playwright.prelaunch.config.ts", "test:e2e:ui": "playwright test --ui", "bot:site": "node scripts/site-bot.js", "bot:site:live": "HEADLESS=false BOT_BROWSER_CHANNEL=chrome BOT_SLOW_MO_MS=250 node scripts/site-bot.js", diff --git a/playwright.prelaunch.config.ts b/playwright.prelaunch.config.ts new file mode 100644 index 0000000..1e8d93a --- /dev/null +++ b/playwright.prelaunch.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e-web', + timeout: 30_000, + expect: { + timeout: 10_000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4303', + trace: 'on-first-retry', + }, + webServer: { + command: 'python3 -m http.server 4303 --directory web', + url: 'http://127.0.0.1:4303', + reuseExistingServer: true, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/e2e-web/prelaunch.spec.ts b/tests/e2e-web/prelaunch.spec.ts new file mode 100644 index 0000000..38bcd0e --- /dev/null +++ b/tests/e2e-web/prelaunch.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; + +test('welcome page explains browser versus desktop workflow', async ({ page }) => { + await page.goto('/welcome.html'); + + await expect(page.locator('h1')).toContainText('Welcome to openmud'); + await expect(page.locator('.hero-sub')).toContainText('browser for hosted chat, desktop app for folder sync and local tools'); + await expect(page.locator('#form-sign-in')).toBeVisible(); +}); + +test('download page describes non-destructive mirror behavior', async ({ page }) => { + await page.goto('/download.html'); + + await expect(page.locator('.dl-note')).toContainText('The browser still works for hosted chat and cloud-backed project state'); + await expect(page.locator('.dl-list')).toContainText('Mirror sync is non-destructive'); + await expect(page.locator('.dl-list')).toContainText('does not automatically delete the app copy'); +}); + +test('settings page shows desktop sync guidance without blank screen', async ({ page }) => { + await page.goto('/settings.html'); + + await expect(page.locator('#desktop-sync-wrap')).toBeVisible(); + await expect(page.locator('#desktop-sync-wrap')).toContainText('Desktop sync'); + await expect(page.locator('#desktop-sync-wrap')).toContainText('Desktop app only'); + await expect(page.locator('#desktop-sync-wrap')).toContainText('without deleting app files just because a mirror file is missing'); +}); + +test('account-scoped browser storage isolates projects between users', async ({ page }) => { + await page.goto('/welcome.html'); + + const result = await page.evaluate(() => { + const state = (window as any).openmudAccountState; + state.setActiveUser({ id: 'user_a', email: 'a@example.com' }); + localStorage.setItem('mudrag_projects', JSON.stringify([{ id: 'p_a' }])); + state.setActiveUser({ id: 'user_b', email: 'b@example.com' }); + const seenAsUserB = localStorage.getItem('mudrag_projects'); + localStorage.setItem('mudrag_projects', JSON.stringify([{ id: 'p_b' }])); + state.setActiveUser({ id: 'user_a', email: 'a@example.com' }); + const seenAgainAsUserA = localStorage.getItem('mudrag_projects'); + state.setActiveUser(null); + const seenAsAnon = localStorage.getItem('mudrag_projects'); + return { seenAsUserB, seenAgainAsUserA, seenAsAnon }; + }); + + expect(result.seenAsUserB).toBeNull(); + expect(result.seenAgainAsUserA).toBe(JSON.stringify([{ id: 'p_a' }])); + expect(result.seenAsAnon).toBeNull(); +}); diff --git a/tests/integration/account-state.test.js b/tests/integration/account-state.test.js new file mode 100644 index 0000000..e1a3bf4 --- /dev/null +++ b/tests/integration/account-state.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function createStorage() { + const store = new Map(); + return { + getItem(key) { + return store.has(String(key)) ? store.get(String(key)) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + }, + removeItem(key) { + store.delete(String(key)); + }, + __rawSetItem(key, value) { + store.set(String(key), String(value)); + }, + }; +} + +function loadAccountState() { + const scriptPath = path.join(__dirname, '..', '..', 'web', 'assets', 'js', 'account-state.js'); + const source = fs.readFileSync(scriptPath, 'utf8'); + const localStorage = createStorage(); + const sessionStorage = createStorage(); + const context = { + window: { + localStorage, + sessionStorage, + indexedDB: { + open() { + throw new Error('indexedDB not needed in this test'); + }, + }, + }, + console, + setTimeout, + clearTimeout, + }; + context.window.window = context.window; + vm.createContext(context); + vm.runInContext(source, context); + return context.window; +} + +test('account-state scopes account keys by user id', () => { + const window = loadAccountState(); + const state = window.openmudAccountState; + + state.setActiveUser({ id: 'user_a', email: 'a@example.com' }); + window.localStorage.setItem('mudrag_projects', JSON.stringify([{ id: 'p_a' }])); + + state.setActiveUser({ id: 'user_b', email: 'b@example.com' }); + assert.equal(window.localStorage.getItem('mudrag_projects'), null); + + window.localStorage.setItem('mudrag_projects', JSON.stringify([{ id: 'p_b' }])); + state.setActiveUser({ id: 'user_a', email: 'a@example.com' }); + assert.equal(window.localStorage.getItem('mudrag_projects'), JSON.stringify([{ id: 'p_a' }])); +}); + +test('account-state migrates legacy account-bound keys into the active scope', () => { + const window = loadAccountState(); + const state = window.openmudAccountState; + window.localStorage.__rawSetItem('mudrag_projects', JSON.stringify([{ id: 'legacy' }])); + + state.setActiveUser({ id: 'user_legacy', email: 'legacy@example.com' }); + window.localStorage.getItem('mudrag_projects'); + assert.equal(window.localStorage.getItem('mudrag_projects'), JSON.stringify([{ id: 'legacy' }])); +}); + +test('account-state resets scoped view on sign-out', () => { + const window = loadAccountState(); + const state = window.openmudAccountState; + + state.setActiveUser({ id: 'user_a', email: 'a@example.com' }); + window.localStorage.setItem('mudrag_provider_keys_v1', JSON.stringify({ openai: 'sk-user-a' })); + + state.setActiveUser(null); + assert.equal(window.localStorage.getItem('mudrag_provider_keys_v1'), null); + assert.equal(state.getCurrentUserId(), 'anon'); +}); diff --git a/tests/integration/desktop-handoff.test.js b/tests/integration/desktop-handoff.test.js new file mode 100644 index 0000000..88683db --- /dev/null +++ b/tests/integration/desktop-handoff.test.js @@ -0,0 +1,318 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const Module = require('module'); + +function loadWithMocks(targetPath, mocks) { + const resolved = require.resolve(targetPath); + delete require.cache[resolved]; + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (mocks && Object.prototype.hasOwnProperty.call(mocks, request)) { + return mocks[request]; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { + return require(resolved); + } finally { + Module._load = originalLoad; + } +} + +function createRes() { + const state = { statusCode: 200, headers: {}, body: null }; + return { + setHeader(name, value) { + state.headers[name] = value; + }, + status(code) { + state.statusCode = code; + return this; + }, + json(payload) { + state.body = payload; + return this; + }, + end() { + return this; + }, + _getState() { + return state; + }, + }; +} + +test('desktop handoff start stores encrypted tokens and returns opaque code', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'desktop-handoff', 'start.js'); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + process.env.OPENMUD_SESSION_SECRET = 'test-secret'; + + const insertedRows = []; + let deleteUserId = null; + + const handler = loadWithMocks(handlerPath, { + '../lib/auth': { + getUserFromRequest: async () => ({ + id: 'user_123', + email: 'builder@example.com', + accessToken: 'access-token-abc', + }), + }, + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + assert.equal(table, 'desktop_auth_handoffs'); + return { + delete() { + return { + eq(field, value) { + assert.equal(field, 'user_id'); + deleteUserId = value; + return Promise.resolve({ error: null }); + }, + }; + }, + insert(rows) { + insertedRows.push(rows); + return Promise.resolve({ error: null }); + }, + }; + }, + }; + }, + }, + }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer access-token-abc' }, + body: { refresh_token: 'refresh-token-xyz' }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 200); + assert.ok(body.handoff_code); + assert.ok(body.expires_at); + assert.equal(deleteUserId, 'user_123'); + assert.equal(insertedRows.length, 1); + assert.equal(insertedRows[0].user_id, 'user_123'); + assert.notEqual(insertedRows[0].access_token_encrypted, 'access-token-abc'); + assert.notEqual(insertedRows[0].refresh_token_encrypted, 'refresh-token-xyz'); + assert.equal(insertedRows[0].code_hash.length, 64); +}); + +test('desktop handoff redeem returns decrypted tokens once', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'desktop-handoff', 'redeem.js'); + const secureTokens = require(path.join(repoRoot, 'web', 'api', 'lib', 'secure-tokens.js')); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + process.env.OPENMUD_SESSION_SECRET = 'test-secret'; + + const handoffCode = 'opaque-code-123'; + const row = { + id: 'handoff_row_1', + user_id: 'user_123', + access_token_encrypted: secureTokens.encryptText('access-token-abc'), + refresh_token_encrypted: secureTokens.encryptText('refresh-token-xyz'), + expires_at: new Date(Date.now() + 60_000).toISOString(), + consumed_at: null, + }; + let updatedId = null; + + const handler = loadWithMocks(handlerPath, { + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + assert.equal(table, 'desktop_auth_handoffs'); + return { + select() { + return { + eq(field, value) { + assert.equal(field, 'code_hash'); + assert.equal(value, secureTokens.hashOpaqueCode(handoffCode)); + return { + maybeSingle: async () => ({ data: row, error: null }), + }; + }, + }; + }, + update(payload) { + assert.ok(payload.consumed_at); + return { + eq(field, value) { + assert.equal(field, 'id'); + updatedId = value; + return { + is(isField, isValue) { + assert.equal(isField, 'consumed_at'); + assert.equal(isValue, null); + return { + select() { + return { + maybeSingle: async () => ({ data: { id: updatedId }, error: null }), + }; + }, + }; + }, + }; + }, + }; + }, + delete() { + return { + eq() { + return Promise.resolve({ error: null }); + }, + }; + }, + }; + }, + }; + }, + }, + }); + + const req = { + method: 'POST', + headers: {}, + body: { handoff_code: handoffCode }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 200); + assert.equal(body.access_token, 'access-token-abc'); + assert.equal(body.refresh_token, 'refresh-token-xyz'); + assert.equal(updatedId, 'handoff_row_1'); +}); + +test('desktop handoff redeem rejects consumed codes', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'desktop-handoff', 'redeem.js'); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + process.env.OPENMUD_SESSION_SECRET = 'test-secret'; + + const handler = loadWithMocks(handlerPath, { + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + assert.equal(table, 'desktop_auth_handoffs'); + return { + select() { + return { + eq() { + return { + maybeSingle: async () => ({ + data: { + id: 'handoff_row_2', + user_id: 'user_123', + access_token_encrypted: 'unused', + refresh_token_encrypted: 'unused', + expires_at: new Date(Date.now() + 60_000).toISOString(), + consumed_at: new Date().toISOString(), + }, + error: null, + }), + }; + }, + }; + }, + }; + }, + }; + }, + }, + }); + + const req = { + method: 'POST', + headers: {}, + body: { handoff_code: 'opaque-code-consumed' }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 410); + assert.match(body.error, /already used/i); +}); + +test('desktop handoff redeem rejects expired codes', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'desktop-handoff', 'redeem.js'); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + process.env.OPENMUD_SESSION_SECRET = 'test-secret'; + + let deletedId = null; + + const handler = loadWithMocks(handlerPath, { + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + assert.equal(table, 'desktop_auth_handoffs'); + return { + select() { + return { + eq() { + return { + maybeSingle: async () => ({ + data: { + id: 'handoff_row_3', + user_id: 'user_123', + access_token_encrypted: 'unused', + refresh_token_encrypted: 'unused', + expires_at: new Date(Date.now() - 60_000).toISOString(), + consumed_at: null, + }, + error: null, + }), + }; + }, + }; + }, + delete() { + return { + eq(field, value) { + assert.equal(field, 'id'); + deletedId = value; + return Promise.resolve({ error: null }); + }, + }; + }, + }; + }, + }; + }, + }, + }); + + const req = { + method: 'POST', + headers: {}, + body: { handoff_code: 'opaque-code-expired' }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 410); + assert.match(body.error, /expired/i); + assert.equal(deletedId, 'handoff_row_3'); +}); diff --git a/tests/integration/project-state.test.js b/tests/integration/project-state.test.js new file mode 100644 index 0000000..5aa37a6 --- /dev/null +++ b/tests/integration/project-state.test.js @@ -0,0 +1,193 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const Module = require('module'); + +function loadWithMocks(targetPath, mocks) { + const resolved = require.resolve(targetPath); + delete require.cache[resolved]; + const originalLoad = Module._load; + Module._load = function patchedLoad(request, parent, isMain) { + if (mocks && Object.prototype.hasOwnProperty.call(mocks, request)) { + return mocks[request]; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { + return require(resolved); + } finally { + Module._load = originalLoad; + } +} + +function createRes() { + const state = { statusCode: 200, headers: {}, body: null }; + return { + setHeader(name, value) { + state.headers[name] = value; + }, + status(code) { + state.statusCode = code; + return this; + }, + json(payload) { + state.body = payload; + return this; + }, + end() { + return this; + }, + _getState() { + return state; + }, + }; +} + +test('project-state PUT upserts chats and project data for owned project', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'project-state.js'); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + + const upsertPayloads = []; + + const handler = loadWithMocks(handlerPath, { + './lib/auth': { + getUserFromRequest: async () => ({ id: 'user_1', email: 'builder@example.com' }), + }, + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + if (table === 'projects') { + return { + select() { + return { + eq() { + return { + eq() { + return { + maybeSingle: async () => ({ data: { id: 'p_1' }, error: null }), + }; + }, + }; + }, + }; + }, + }; + } + if (table === 'project_state') { + return { + upsert(payload) { + upsertPayloads.push(payload); + return { + select() { + return { + single: async () => ({ + data: { + project_id: payload.project_id, + project_data_json: payload.project_data_json, + chats_json: payload.chats_json, + active_chat_id: payload.active_chat_id, + updated_at: payload.updated_at, + }, + error: null, + }), + }; + }, + }; + }, + }; + } + throw new Error('Unexpected table ' + table); + }, + }; + }, + }, + }); + + const req = { + method: 'PUT', + headers: { authorization: 'Bearer test-token' }, + body: { + project_id: 'p_1', + project_data: { tasks: [{ id: 't1', title: 'Call supplier' }] }, + chats: { c_1: { name: 'Chat 1', messages: [{ role: 'user', content: 'hi' }] } }, + active_chat_id: 'c_1', + }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 200); + assert.equal(upsertPayloads.length, 1); + assert.equal(upsertPayloads[0].project_id, 'p_1'); + assert.equal(upsertPayloads[0].user_id, 'user_1'); + assert.deepEqual(body.project_state.project_data.tasks, [{ id: 't1', title: 'Call supplier' }]); + assert.equal(body.project_state.active_chat_id, 'c_1'); +}); + +test('projects DELETE removes owned project', async () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const handlerPath = path.join(repoRoot, 'web', 'api', 'projects.js'); + process.env.SUPABASE_URL = 'https://example.supabase.co'; + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role'; + + let deletedProjectId = null; + + const handler = loadWithMocks(handlerPath, { + './lib/auth': { + getUserFromRequest: async () => ({ id: 'user_1', email: 'builder@example.com' }), + }, + '@supabase/supabase-js': { + createClient() { + return { + from(table) { + assert.equal(table, 'projects'); + return { + select() { + return { + eq() { + return { + eq() { + return { + maybeSingle: async () => ({ data: { id: 'p_1' }, error: null }), + }; + }, + }; + }, + }; + }, + delete() { + return { + eq(field, value) { + if (field === 'id') deletedProjectId = value; + return { + eq: async () => ({ error: null }), + }; + }, + }; + }, + }; + }, + }; + }, + }, + }); + + const req = { + method: 'DELETE', + headers: { authorization: 'Bearer test-token' }, + query: { id: 'p_1' }, + }; + const res = createRes(); + + await handler(req, res); + + const { statusCode, body } = res._getState(); + assert.equal(statusCode, 200); + assert.equal(body.ok, true); + assert.equal(deletedProjectId, 'p_1'); +}); diff --git a/web/api/desktop-handoff/redeem.js b/web/api/desktop-handoff/redeem.js new file mode 100644 index 0000000..2b3606e --- /dev/null +++ b/web/api/desktop-handoff/redeem.js @@ -0,0 +1,77 @@ +const { createClient } = require('@supabase/supabase-js'); +const { decryptText, hashOpaqueCode } = require('../lib/secure-tokens'); + +module.exports = async function handler(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') return res.status(200).end(); + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const handoffCode = String((req.body && req.body.handoff_code) || '').trim(); + if (!handoffCode) { + return res.status(400).json({ error: 'handoff_code required' }); + } + + const url = process.env.SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !serviceKey) { + return res.status(500).json({ error: 'Server misconfigured' }); + } + + const supabase = createClient(url, serviceKey); + const codeHash = hashOpaqueCode(handoffCode); + + try { + const { data: row, error } = await supabase + .from('desktop_auth_handoffs') + .select('id, user_id, access_token_encrypted, refresh_token_encrypted, expires_at, consumed_at') + .eq('code_hash', codeHash) + .maybeSingle(); + + if (error) throw error; + if (!row) { + return res.status(404).json({ error: 'Desktop sign-in code not found.' }); + } + if (row.consumed_at) { + return res.status(410).json({ error: 'Desktop sign-in code was already used.' }); + } + if (row.expires_at && Date.parse(row.expires_at) < Date.now()) { + await supabase.from('desktop_auth_handoffs').delete().eq('id', row.id); + return res.status(410).json({ error: 'Desktop sign-in code expired. Try opening the app again.' }); + } + + const { data: updated, error: updateError } = await supabase + .from('desktop_auth_handoffs') + .update({ consumed_at: new Date().toISOString() }) + .eq('id', row.id) + .is('consumed_at', null) + .select('id') + .maybeSingle(); + if (updateError) throw updateError; + if (!updated) { + return res.status(410).json({ error: 'Desktop sign-in code was already used.' }); + } + + console.log(JSON.stringify({ + event: 'desktop_handoff_redeemed', + user_id: row.user_id, + handoff_id: row.id, + })); + + return res.status(200).json({ + access_token: decryptText(row.access_token_encrypted), + refresh_token: decryptText(row.refresh_token_encrypted), + }); + } catch (err) { + console.error(JSON.stringify({ + event: 'desktop_handoff_redeem_failed', + message: err.message || 'Unknown error', + })); + return res.status(500).json({ error: 'Desktop sign-in failed.' }); + } +}; diff --git a/web/api/desktop-handoff/start.js b/web/api/desktop-handoff/start.js new file mode 100644 index 0000000..728bc3f --- /dev/null +++ b/web/api/desktop-handoff/start.js @@ -0,0 +1,70 @@ +const { createClient } = require('@supabase/supabase-js'); +const { getUserFromRequest } = require('../lib/auth'); +const { encryptText, generateOpaqueCode, hashOpaqueCode } = require('../lib/secure-tokens'); + +module.exports = async function handler(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') return res.status(200).end(); + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + const user = await getUserFromRequest(req); + if (!user || !user.id || !user.accessToken) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const refreshToken = String((req.body && req.body.refresh_token) || '').trim(); + if (!refreshToken) { + return res.status(400).json({ error: 'refresh_token required' }); + } + + const url = process.env.SUPABASE_URL; + const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !serviceKey) { + return res.status(500).json({ error: 'Server misconfigured' }); + } + + const supabase = createClient(url, serviceKey); + const handoffCode = generateOpaqueCode(); + const expiresAt = new Date(Date.now() + (5 * 60 * 1000)).toISOString(); + + try { + await supabase + .from('desktop_auth_handoffs') + .delete() + .eq('user_id', user.id); + + const { error } = await supabase.from('desktop_auth_handoffs').insert({ + user_id: user.id, + code_hash: hashOpaqueCode(handoffCode), + access_token_encrypted: encryptText(user.accessToken), + refresh_token_encrypted: encryptText(refreshToken), + expires_at: expiresAt, + }); + + if (error) throw error; + + console.log(JSON.stringify({ + event: 'desktop_handoff_started', + user_id: user.id, + expires_at: expiresAt, + })); + + return res.status(200).json({ + handoff_code: handoffCode, + expires_at: expiresAt, + }); + } catch (err) { + console.error(JSON.stringify({ + event: 'desktop_handoff_start_failed', + user_id: user.id, + message: err.message || 'Unknown error', + })); + return res.status(500).json({ error: 'Could not prepare desktop sign-in.' }); + } +}; diff --git a/web/api/lib/migrations/004_desktop_handoffs.sql b/web/api/lib/migrations/004_desktop_handoffs.sql new file mode 100644 index 0000000..f1cf20e --- /dev/null +++ b/web/api/lib/migrations/004_desktop_handoffs.sql @@ -0,0 +1,26 @@ +-- openmud pre-launch hardening — desktop auth handoff codes +-- Run after the combined setup migration. + +CREATE TABLE IF NOT EXISTS desktop_auth_handoffs ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL UNIQUE, + access_token_encrypted TEXT NOT NULL, + refresh_token_encrypted TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ NULL +); + +CREATE INDEX IF NOT EXISTS idx_desktop_auth_handoffs_user_created + ON desktop_auth_handoffs (user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_desktop_auth_handoffs_expires + ON desktop_auth_handoffs (expires_at DESC); + +ALTER TABLE desktop_auth_handoffs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "service_manage_desktop_auth_handoffs" + ON desktop_auth_handoffs + USING (true) + WITH CHECK (true); diff --git a/web/api/lib/migrations/005_project_state.sql b/web/api/lib/migrations/005_project_state.sql new file mode 100644 index 0000000..4df78f0 --- /dev/null +++ b/web/api/lib/migrations/005_project_state.sql @@ -0,0 +1,20 @@ +-- openmud pre-launch hardening — synced per-project chat/task state + +CREATE TABLE IF NOT EXISTS project_state ( + project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + project_data_json JSONB NOT NULL DEFAULT '{}'::jsonb, + chats_json JSONB NOT NULL DEFAULT '{}'::jsonb, + active_chat_id TEXT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_project_state_user_updated + ON project_state (user_id, updated_at DESC); + +ALTER TABLE project_state ENABLE ROW LEVEL SECURITY; + +CREATE POLICY IF NOT EXISTS "service_manage_project_state" + ON project_state + USING (true) + WITH CHECK (true); diff --git a/web/api/lib/secure-tokens.js b/web/api/lib/secure-tokens.js new file mode 100644 index 0000000..91af268 --- /dev/null +++ b/web/api/lib/secure-tokens.js @@ -0,0 +1,64 @@ +const crypto = require('crypto'); + +function env(name) { + return String(process.env[name] || '').trim(); +} + +function getTokenSecret() { + const raw = env('OPENMUD_SESSION_SECRET') + || env('EMAIL_TOKEN_SECRET'); + if (!raw) { + throw new Error('OPENMUD_SESSION_SECRET or EMAIL_TOKEN_SECRET is required.'); + } + return crypto.createHash('sha256').update(raw).digest(); +} + +function base64UrlEncode(input) { + return Buffer.from(input) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64UrlDecodeBuffer(input) { + const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4; + const withPad = normalized + (padding ? '='.repeat(4 - padding) : ''); + return Buffer.from(withPad, 'base64'); +} + +function encryptText(plainText) { + const key = getTokenSecret(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(String(plainText || ''), 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${base64UrlEncode(iv)}.${base64UrlEncode(tag)}.${base64UrlEncode(encrypted)}`; +} + +function decryptText(payload) { + const parts = String(payload || '').split('.'); + if (parts.length !== 3) throw new Error('Invalid encrypted payload.'); + const iv = base64UrlDecodeBuffer(parts[0]); + const tag = base64UrlDecodeBuffer(parts[1]); + const data = base64UrlDecodeBuffer(parts[2]); + const decipher = crypto.createDecipheriv('aes-256-gcm', getTokenSecret(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); +} + +function generateOpaqueCode() { + return crypto.randomBytes(24).toString('base64url'); +} + +function hashOpaqueCode(value) { + return crypto.createHash('sha256').update(String(value || '')).digest('hex'); +} + +module.exports = { + encryptText, + decryptText, + generateOpaqueCode, + hashOpaqueCode, +}; diff --git a/web/api/project-state.js b/web/api/project-state.js new file mode 100644 index 0000000..57a94e9 --- /dev/null +++ b/web/api/project-state.js @@ -0,0 +1,130 @@ +const { createClient } = require('@supabase/supabase-js'); +const { getUserFromRequest } = require('./lib/auth'); + +function normalizeObject(value, fallback) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return fallback; + return value; +} + +module.exports = async function handler(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') return res.status(200).end(); + + const user = await getUserFromRequest(req); + if (!user) { + return res.status(401).json({ error: 'Sign in to sync project state.', project_state: null }); + } + + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + return res.status(500).json({ error: 'Server misconfigured', project_state: null }); + } + + const supabase = createClient(url, key); + + try { + if (req.method === 'GET') { + const projectId = String((req.query && req.query.project_id) || '').trim(); + if (!projectId) { + return res.status(400).json({ error: 'project_id required', project_state: null }); + } + + const { data: project } = await supabase + .from('projects') + .select('id') + .eq('id', projectId) + .eq('user_id', user.id) + .maybeSingle(); + if (!project) { + return res.status(404).json({ error: 'Project not found', project_state: null }); + } + + const { data, error } = await supabase + .from('project_state') + .select('project_id, project_data_json, chats_json, active_chat_id, updated_at') + .eq('project_id', projectId) + .eq('user_id', user.id) + .maybeSingle(); + if (error) throw error; + + return res.status(200).json({ + project_state: data ? { + project_id: data.project_id, + project_data: normalizeObject(data.project_data_json, {}), + chats: normalizeObject(data.chats_json, {}), + active_chat_id: data.active_chat_id || null, + updated_at: data.updated_at || null, + } : null, + }); + } + + if (req.method === 'PUT') { + const projectId = String((req.body && req.body.project_id) || '').trim(); + if (!projectId) { + return res.status(400).json({ error: 'project_id required', project_state: null }); + } + + const { data: project } = await supabase + .from('projects') + .select('id') + .eq('id', projectId) + .eq('user_id', user.id) + .maybeSingle(); + if (!project) { + return res.status(404).json({ error: 'Project not found', project_state: null }); + } + + const payload = { + project_id: projectId, + user_id: user.id, + project_data_json: normalizeObject(req.body && req.body.project_data, {}), + chats_json: normalizeObject(req.body && req.body.chats, {}), + active_chat_id: req.body && req.body.active_chat_id ? String(req.body.active_chat_id) : null, + updated_at: new Date().toISOString(), + }; + + const { data, error } = await supabase + .from('project_state') + .upsert(payload, { onConflict: 'project_id' }) + .select('project_id, project_data_json, chats_json, active_chat_id, updated_at') + .single(); + if (error) throw error; + + console.log(JSON.stringify({ + event: 'project_state_synced', + user_id: user.id, + project_id: projectId, + chat_count: Object.keys(payload.chats_json || {}).length, + task_count: Array.isArray(payload.project_data_json && payload.project_data_json.tasks) + ? payload.project_data_json.tasks.length + : 0, + })); + + return res.status(200).json({ + project_state: { + project_id: data.project_id, + project_data: normalizeObject(data.project_data_json, {}), + chats: normalizeObject(data.chats_json, {}), + active_chat_id: data.active_chat_id || null, + updated_at: data.updated_at || null, + }, + }); + } + + res.setHeader('Allow', 'GET, PUT'); + return res.status(405).json({ error: 'Method not allowed', project_state: null }); + } catch (err) { + console.error(JSON.stringify({ + event: 'project_state_api_error', + user_id: user.id, + project_id: (req.query && req.query.project_id) || (req.body && req.body.project_id) || null, + method: req.method, + message: err.message || 'Unknown error', + })); + return res.status(500).json({ error: err.message || 'Server error', project_state: null }); + } +}; diff --git a/web/api/projects.js b/web/api/projects.js index 3f65ee3..6e17dc1 100644 --- a/web/api/projects.js +++ b/web/api/projects.js @@ -3,7 +3,7 @@ const { getUserFromRequest } = require('./lib/auth'); module.exports = async function handler(req, res) { res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') return res.status(200).end(); @@ -25,7 +25,7 @@ module.exports = async function handler(req, res) { if (req.method === 'GET') { const { data, error } = await supabase .from('projects') - .select('id, name, created_at') + .select('id, name, created_at, updated_at') .eq('user_id', user.id) .order('created_at', { ascending: false }); if (error) throw error; @@ -62,7 +62,35 @@ module.exports = async function handler(req, res) { return res.status(200).json({ projects: rows }); } - res.setHeader('Allow', 'GET, POST, PUT'); + if (req.method === 'DELETE') { + const projectId = String((req.query && req.query.id) || (req.body && req.body.id) || '').trim(); + if (!projectId) { + return res.status(400).json({ error: 'id required', project: null }); + } + const { data: project } = await supabase + .from('projects') + .select('id') + .eq('id', projectId) + .eq('user_id', user.id) + .maybeSingle(); + if (!project) { + return res.status(404).json({ error: 'Project not found', project: null }); + } + const { error } = await supabase + .from('projects') + .delete() + .eq('id', projectId) + .eq('user_id', user.id); + if (error) throw error; + console.log(JSON.stringify({ + event: 'project_deleted', + user_id: user.id, + project_id: projectId, + })); + return res.status(200).json({ ok: true, id: projectId }); + } + + res.setHeader('Allow', 'GET, POST, PUT, DELETE'); return res.status(405).json({ error: 'Method not allowed' }); } catch (e) { console.error('Projects API error:', e); diff --git a/web/assets/js/account-state.js b/web/assets/js/account-state.js new file mode 100644 index 0000000..3cfcb49 --- /dev/null +++ b/web/assets/js/account-state.js @@ -0,0 +1,343 @@ +(function () { + 'use strict'; + + var SESSION_SCOPE_KEY = 'openmud_active_user_id_session'; + var SESSION_EMAIL_KEY = 'openmud_active_user_email_session'; + var SCOPED_PREFIX = 'openmud:user:'; + var MIGRATION_PREFIX = 'openmud:migrated:'; + var currentUserId = 'anon'; + var currentUserEmail = ''; + var dbMigrationPromises = {}; + + var SCOPED_KEYS = { + mudrag_projects: true, + mudrag_activeProject: true, + mudrag_messages: true, + mudrag_project_data: true, + mudrag_activeChat: true, + mudrag_subscriber_email: true, + mudrag_subscription_active: true, + mudrag_subscription_tier: true, + mudrag_provider_keys_v1: true, + openmud_oc_relay_token: true, + mudrag_usage: true, + mudrag_company_profile: true, + mudrag_company_logo: true, + mudrag_desktop_sync_enabled: true, + proposalFromBid: true + }; + + var SCOPED_PREFIXES = [ + 'mudrag_folder_expanded_' + ]; + + var rawGetItem = window.localStorage && window.localStorage.getItem + ? window.localStorage.getItem.bind(window.localStorage) + : function () { return null; }; + var rawSetItem = window.localStorage && window.localStorage.setItem + ? window.localStorage.setItem.bind(window.localStorage) + : function () {}; + var rawRemoveItem = window.localStorage && window.localStorage.removeItem + ? window.localStorage.removeItem.bind(window.localStorage) + : function () {}; + + function sanitizeUserId(value) { + var text = String(value == null ? '' : value).trim(); + if (!text) return 'anon'; + return text.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80) || 'anon'; + } + + function readSessionScope() { + try { + var stored = window.sessionStorage.getItem(SESSION_SCOPE_KEY) || ''; + if (stored) currentUserId = sanitizeUserId(stored); + var storedEmail = window.sessionStorage.getItem(SESSION_EMAIL_KEY) || ''; + if (storedEmail) currentUserEmail = String(storedEmail || '').trim(); + } catch (e) { + currentUserId = currentUserId || 'anon'; + } + } + + function rememberSessionScope(userId, email) { + currentUserId = sanitizeUserId(userId); + currentUserEmail = String(email || '').trim(); + try { + if (currentUserId === 'anon') { + window.sessionStorage.removeItem(SESSION_SCOPE_KEY); + window.sessionStorage.removeItem(SESSION_EMAIL_KEY); + return; + } + window.sessionStorage.setItem(SESSION_SCOPE_KEY, currentUserId); + if (currentUserEmail) window.sessionStorage.setItem(SESSION_EMAIL_KEY, currentUserEmail); + else window.sessionStorage.removeItem(SESSION_EMAIL_KEY); + } catch (e) { + // ignore storage failures + } + } + + function getUserFromInput(input) { + if (!input) return null; + if (input.user && input.user.id) { + return { + id: input.user.id, + email: input.user.email || '' + }; + } + if (input.id) { + return { + id: input.id, + email: input.email || '' + }; + } + return null; + } + + function setActiveUser(input) { + var user = getUserFromInput(input); + if (!user || !user.id) { + rememberSessionScope('anon', ''); + return { userId: 'anon', email: '' }; + } + rememberSessionScope(user.id, user.email || ''); + return { + userId: currentUserId, + email: currentUserEmail + }; + } + + function getCurrentUserId() { + return sanitizeUserId(currentUserId || 'anon'); + } + + function getCurrentUserEmail() { + return String(currentUserEmail || '').trim(); + } + + function shouldScopeKey(key) { + var text = String(key || ''); + if (!text) return false; + if (SCOPED_KEYS[text]) return true; + for (var i = 0; i < SCOPED_PREFIXES.length; i++) { + if (text.indexOf(SCOPED_PREFIXES[i]) === 0) return true; + } + return false; + } + + function getScopedKey(key, userId) { + var text = String(key || ''); + if (!shouldScopeKey(text)) return text; + return SCOPED_PREFIX + sanitizeUserId(userId || currentUserId) + ':' + text; + } + + function maybeMigrateLegacyKey(key, scopedKey) { + if (scopedKey === key) return; + try { + if (rawGetItem(scopedKey) != null) return; + var legacy = rawGetItem(key); + if (legacy == null) return; + rawSetItem(scopedKey, legacy); + rawRemoveItem(key); + } catch (e) { + // ignore storage failures + } + } + + function patchLocalStorage() { + if (!window.localStorage || window.localStorage.__openmudScopedPatched) return; + + window.localStorage.getItem = function (key) { + var scopedKey = getScopedKey(key, currentUserId); + if (scopedKey !== key) maybeMigrateLegacyKey(key, scopedKey); + return rawGetItem(scopedKey); + }; + + window.localStorage.setItem = function (key, value) { + var scopedKey = getScopedKey(key, currentUserId); + if (scopedKey !== key) rawRemoveItem(key); + return rawSetItem(scopedKey, value); + }; + + window.localStorage.removeItem = function (key) { + var scopedKey = getScopedKey(key, currentUserId); + rawRemoveItem(scopedKey); + if (scopedKey !== key) rawRemoveItem(key); + }; + + window.localStorage.__openmudScopedPatched = true; + } + + function getDocumentDbName(baseName, userId) { + return String(baseName || 'mudrag_docs') + '__' + sanitizeUserId(userId || currentUserId || 'anon'); + } + + function openDatabase(name, version, upgrade) { + return new Promise(function (resolve, reject) { + var req = version ? window.indexedDB.open(name, version) : window.indexedDB.open(name); + req.onerror = function () { reject(req.error); }; + req.onsuccess = function () { resolve(req.result); }; + req.onupgradeneeded = function (event) { + if (typeof upgrade === 'function') upgrade(event.target.result, event); + }; + }); + } + + function listDatabases() { + if (!window.indexedDB || typeof window.indexedDB.databases !== 'function') { + return Promise.resolve([]); + } + return window.indexedDB.databases().catch(function () { return []; }); + } + + function databaseExists(name) { + return listDatabases().then(function (list) { + return (list || []).some(function (entry) { + return entry && entry.name === name; + }); + }); + } + + function hasAnyRecords(db) { + var storeNames = Array.prototype.slice.call(db.objectStoreNames || []); + if (!storeNames.length) return Promise.resolve(false); + + return new Promise(function (resolve) { + var index = 0; + + function checkNext() { + if (index >= storeNames.length) { + resolve(false); + return; + } + var storeName = storeNames[index++]; + var tx = null; + try { + tx = db.transaction(storeName, 'readonly'); + } catch (err) { + checkNext(); + return; + } + var countReq = tx.objectStore(storeName).count(); + countReq.onsuccess = function () { + if (Number(countReq.result || 0) > 0) { + resolve(true); + return; + } + checkNext(); + }; + countReq.onerror = function () { + checkNext(); + }; + } + + checkNext(); + }); + } + + function readAllFromStore(db, storeName) { + return new Promise(function (resolve) { + var tx = null; + try { + tx = db.transaction(storeName, 'readonly'); + } catch (err) { + resolve([]); + return; + } + var req = tx.objectStore(storeName).getAll(); + req.onsuccess = function () { resolve(req.result || []); }; + req.onerror = function () { resolve([]); }; + }); + } + + function copyStoresIntoTarget(sourceDb, targetDb) { + var sourceNames = Array.prototype.slice.call(sourceDb.objectStoreNames || []); + var targetNames = Array.prototype.slice.call(targetDb.objectStoreNames || []); + var storeNames = targetNames.filter(function (name) { + return sourceNames.indexOf(name) >= 0; + }); + if (!storeNames.length) return Promise.resolve(false); + + return Promise.all(storeNames.map(function (storeName) { + return readAllFromStore(sourceDb, storeName).then(function (records) { + if (!records || !records.length) return false; + return new Promise(function (resolve, reject) { + var tx = targetDb.transaction(storeName, 'readwrite'); + var store = tx.objectStore(storeName); + records.forEach(function (record) { + try { store.put(record); } catch (err) { /* ignore individual failures */ } + }); + tx.oncomplete = function () { resolve(true); }; + tx.onerror = function () { reject(tx.error); }; + }); + }).catch(function () { + return false; + }); + })).then(function (results) { + return results.some(Boolean); + }); + } + + function migrateLegacyDatabaseIfNeeded(baseName, targetDb, migrationKey) { + if (!window.indexedDB) return Promise.resolve(false); + try { + if (rawGetItem(migrationKey) === 'done') return Promise.resolve(false); + } catch (e) { + // continue + } + + return hasAnyRecords(targetDb).then(function (targetHasData) { + if (targetHasData) { + try { rawSetItem(migrationKey, 'done'); } catch (e) {} + return false; + } + return databaseExists(baseName).then(function (exists) { + if (!exists) { + try { rawSetItem(migrationKey, 'done'); } catch (e) {} + return false; + } + return openDatabase(baseName).then(function (legacyDb) { + return copyStoresIntoTarget(legacyDb, targetDb).then(function (copied) { + try { legacyDb.close(); } catch (e) {} + try { rawSetItem(migrationKey, 'done'); } catch (e) {} + return copied; + }); + }).catch(function () { + return false; + }); + }); + }); + } + + function openScopedDatabase(options) { + options = options || {}; + var baseName = String(options.baseName || 'mudrag_docs'); + var version = Number(options.version || 1) || 1; + var upgrade = options.upgrade; + var scopedName = getDocumentDbName(baseName); + var migrationKey = MIGRATION_PREFIX + scopedName; + + return openDatabase(scopedName, version, upgrade).then(function (db) { + if (scopedName === baseName) return db; + if (!dbMigrationPromises[scopedName]) { + dbMigrationPromises[scopedName] = migrateLegacyDatabaseIfNeeded(baseName, db, migrationKey) + .catch(function () { return false; }) + .then(function () { return true; }); + } + return dbMigrationPromises[scopedName].then(function () { + return db; + }); + }); + } + + readSessionScope(); + patchLocalStorage(); + + window.openmudAccountState = { + setActiveUser: setActiveUser, + getCurrentUserId: getCurrentUserId, + getCurrentUserEmail: getCurrentUserEmail, + getScopedKey: getScopedKey, + shouldScopeKey: shouldScopeKey, + getDocumentDbName: getDocumentDbName, + openScopedDatabase: openScopedDatabase + }; +}()); diff --git a/web/assets/js/app.js b/web/assets/js/app.js index 87cfc10..5d7773b 100644 --- a/web/assets/js/app.js +++ b/web/assets/js/app.js @@ -69,8 +69,21 @@ var _desktopSyncIgnoreUntil = {}; var _desktopSyncBootstrapped = false; var _desktopSyncStatusCache = null; + var _projectStateSyncTimers = {}; + var _currentAccountScope = 'anon'; var STORAGE_TASKS_SECTION_EXPANDED = 'mudrag_tasks_section_expanded'; + function logClientPrelaunchEvent(eventName, details) { + try { + console.log(JSON.stringify(Object.assign({ + event: eventName, + source: 'web-client', + user_id: _currentAccountScope || 'anon', + at: new Date().toISOString() + }, details || {}))); + } catch (e) {} + } + function getAuthHeaders() { var h = { 'Content-Type': 'application/json' }; if (_authToken) h['Authorization'] = 'Bearer ' + _authToken; @@ -253,6 +266,8 @@ } function syncAuthSession(session) { + var nextScope = (session && session.user && session.user.id) ? String(session.user.id) : 'anon'; + var scopeChanged = nextScope !== _currentAccountScope; if (session && session.user && session.access_token) { _authToken = session.access_token; try { @@ -262,7 +277,36 @@ } else { _authToken = null; } + _currentAccountScope = nextScope; updateNavAuth(); + if (scopeChanged) { + handleAccountScopeChange(); + } + } + + function handleAccountScopeChange() { + _desktopSyncStatusCache = null; + _desktopSyncBootstrapped = false; + activeProjectId = getActiveId(); + activeChatId = activeProjectId ? getActiveChatId(activeProjectId) : null; + if (!activeProjectId) { + var scopedProjects = getProjects(); + if (scopedProjects.length > 0) { + activeProjectId = scopedProjects[0].id; + setActiveId(activeProjectId); + activeChatId = getActiveChatId(activeProjectId); + } else if (!_authToken) { + ensureProject(); + activeProjectId = getActiveId(); + activeChatId = activeProjectId ? getActiveChatId(activeProjectId) : null; + } + } + renderProjects(); + renderChats(); + renderMessages(); + renderTasksSection(); + renderDocuments(); + refreshDesktopSyncStatus(activeProjectId || '').catch(function () {}); } function updateNavAuth() { @@ -321,22 +365,77 @@ if (data && Array.isArray(data.projects) && data.projects.length > 0) { localStorage.setItem(STORAGE_PROJECTS, JSON.stringify(data.projects)); if (cb) cb(data.projects); + return; } if (cb) cb(); }) .catch(function () { if (cb) cb(); }); } - function syncMessagesToApi(projectId, msgs) { + function getProjectStateSnapshot(projectId) { + return { + project_id: projectId, + project_data: getProjectData(projectId), + chats: getChats(projectId), + active_chat_id: getActiveChatId(projectId) + }; + } + + function syncProjectStateToApi(projectId) { if (!getAuthHeaders().Authorization || !projectId) return; - fetch(API_BASE + '/chat-messages', { + fetch(API_BASE + '/project-state', { method: 'PUT', headers: getAuthHeaders(), - body: JSON.stringify({ project_id: projectId, messages: msgs || getMessages(projectId) }) - }).catch(function () {}); + body: JSON.stringify(getProjectStateSnapshot(projectId)) + }).then(function (resp) { + if (!resp || !resp.ok) { + logClientPrelaunchEvent('project_state_sync_failed', { + project_id: projectId, + status: resp ? resp.status : null + }); + return; + } + logClientPrelaunchEvent('project_state_synced_client', { project_id: projectId }); + }).catch(function (err) { + logClientPrelaunchEvent('project_state_sync_failed', { + project_id: projectId, + message: err && err.message ? err.message : 'network_error' + }); + }); } - function loadMessagesFromApi(projectId, cb) { + function scheduleProjectStateSync(projectId) { + if (!getAuthHeaders().Authorization || !projectId) return; + clearTimeout(_projectStateSyncTimers[projectId]); + _projectStateSyncTimers[projectId] = setTimeout(function () { + syncProjectStateToApi(projectId); + }, 800); + } + + function setChatsForProject(projectId, chats, options) { + if (!projectId) return {}; + options = options || {}; + try { + var raw = localStorage.getItem(STORAGE_MESSAGES); + var all = raw ? JSON.parse(raw) : {}; + all[projectId] = chats || {}; + localStorage.setItem(STORAGE_MESSAGES, JSON.stringify(all)); + if (!options.silent) scheduleProjectStateSync(projectId); + return all[projectId]; + } catch (e) { + return {}; + } + } + + function syncMessagesToApi(projectId, msgs) { + if (!projectId) return; + if (msgs && msgs.length > 0) { + setMessages(projectId, msgs, { silent: true }); + } + scheduleProjectStateSync(projectId); + } + + function loadLegacyMessagesFromApi(projectId, cb) { if (!getAuthHeaders().Authorization || !projectId) { if (cb) cb([]); return; } fetch(API_BASE + '/chat-messages?project_id=' + encodeURIComponent(projectId), { method: 'GET', headers: getAuthHeaders() }) .then(function (r) { return r.ok ? r.json() : null; }) @@ -347,6 +446,46 @@ .catch(function () { if (cb) cb([]); }); } + function loadMessagesFromApi(projectId, cb) { + if (!getAuthHeaders().Authorization || !projectId) { if (cb) cb([]); return; } + fetch(API_BASE + '/project-state?project_id=' + encodeURIComponent(projectId), { method: 'GET', headers: getAuthHeaders() }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (data) { + var state = data && data.project_state ? data.project_state : null; + if (!state) { + loadLegacyMessagesFromApi(projectId, cb); + return; + } + logClientPrelaunchEvent('project_state_loaded_client', { + project_id: projectId, + chat_count: Object.keys(state.chats || {}).length, + task_count: Array.isArray(state.project_data && state.project_data.tasks) ? state.project_data.tasks.length : 0 + }); + if (state.project_data) setProjectData(projectId, state.project_data, { silent: true }); + if (state.chats) setChatsForProject(projectId, state.chats, { silent: true }); + if (state.active_chat_id) { + setActiveChatId(projectId, state.active_chat_id, { silent: true }); + } + var cid = getActiveChatId(projectId) || state.active_chat_id || null; + var chats = getChats(projectId); + if (!cid) { + var keys = Object.keys(chats); + cid = keys.length ? keys[0] : null; + if (cid) setActiveChatId(projectId, cid, { silent: true }); + } + var msgs = (cid && chats[cid] && chats[cid].messages) ? chats[cid].messages : []; + if (cb) cb(msgs); + }) + .catch(function (err) { + logClientPrelaunchEvent('project_state_load_failed', { + project_id: projectId, + message: err && err.message ? err.message : 'network_error' + }); + showToast('Could not load synced project state. Showing local cache.'); + loadLegacyMessagesFromApi(projectId, cb); + }); + } + function getActiveId() { return localStorage.getItem(STORAGE_ACTIVE) || null; } @@ -369,11 +508,13 @@ return all[projectId] || {}; } - function setProjectData(projectId, data) { + function setProjectData(projectId, data, options) { if (!projectId) return; + options = options || {}; var all = getAllProjectData(); all[projectId] = data || {}; localStorage.setItem(STORAGE_PROJECT_DATA, JSON.stringify(all)); + if (!options.silent) scheduleProjectStateSync(projectId); } function removeProjectData(projectId) { @@ -1063,8 +1204,8 @@ enabled: true, results: result.results || [], message: rootPath - ? 'Desktop sync is on. All project documents are mirrored to ' + shortenHomePath(rootPath) + '.' - : 'Desktop sync is on. All project documents are mirrored to your Openmud Desktop folder.' + ? 'Desktop sync is on. Project documents are mirrored to ' + shortenHomePath(rootPath) + ' without deleting app files when a mirror file goes missing.' + : 'Desktop sync is on. Project documents are mirrored to your Openmud Desktop folder without deleting app files when a mirror file goes missing.' }; }); }); @@ -1107,6 +1248,9 @@ }); var desktopFiles = Array.isArray(listing.files) ? listing.files : []; var desktopPaths = {}; + var importedCount = 0; + var updatedCount = 0; + var unchangedCount = 0; var work = Promise.resolve(); desktopFiles.forEach(function (fileInfo) { work = work.then(function () { @@ -1119,20 +1263,38 @@ if (existing) { if (existing.name !== imported.name) { return renameDocument(existing.id, imported.name).then(function () { - return arrayBuffersEqual(existing.data, data) ? null : updateDocumentContent(existing.id, data); + if (arrayBuffersEqual(existing.data, data)) { + unchangedCount += 1; + return null; + } + updatedCount += 1; + return updateDocumentContent(existing.id, data); }); } if (!arrayBuffersEqual(existing.data, data)) { + updatedCount += 1; return updateDocumentContent(existing.id, data); } + unchangedCount += 1; return null; } var parts = relPath.split('/').filter(Boolean); var fileName = parts.pop() || imported.name || 'Imported file'; var folderName = parts.length ? parts.join(' / ') : ''; + var fallbackExisting = docs.find(function (doc) { + if (!doc || (doc.source || '') !== 'desktop-sync') return false; + var currentRelPath = buildDocumentRelativePath(doc, folderLookup); + var currentFolderName = currentRelPath.split('/').slice(0, -1).join(' / '); + return currentFolderName === folderName && arrayBuffersEqual(doc.data, data); + }); + if (fallbackExisting) { + unchangedCount += 1; + return renameDocument(fallbackExisting.id, fileName); + } var targetPromise = folderName ? getOrCreateFolder(projectId, folderName) : Promise.resolve(null); return targetPromise.then(function (folderId) { var file = new File([data], fileName, { type: imported.mime || 'application/octet-stream' }); + importedCount += 1; return saveDocument(projectId, file, folderId, { source: 'desktop-sync', source_meta: { relative_path: relPath } @@ -1142,17 +1304,33 @@ }); }); return work.then(function () { - var deletions = docs.filter(function (doc) { + var untouchedAppDocs = docs.filter(function (doc) { return !desktopPaths[buildDocumentRelativePath(doc, folderLookup)]; - }).map(function (doc) { - return deleteDocument(doc.id); + }).length; + renderDocuments(); + renderTasksSection(); + if (window.mudrag && window.mudrag.renderCanvas) window.mudrag.renderCanvas(); + _desktopSyncStatusCache = Object.assign({}, _desktopSyncStatusCache || {}, { + enabled: true, + syncMode: 'non_destructive', + statusLabels: ['synced', 'mirror active'] }); - return Promise.all(deletions).then(function () { - renderDocuments(); - renderTasksSection(); - if (window.mudrag && window.mudrag.renderCanvas) window.mudrag.renderCanvas(); - return { ok: true, imported: desktopFiles.length, deleted: deletions.length }; + logClientPrelaunchEvent('desktop_sync_import_completed', { + project_id: projectId, + imported: importedCount, + updated: updatedCount, + preserved: untouchedAppDocs }); + return { + ok: true, + imported: importedCount, + updated: updatedCount, + unchanged: unchangedCount, + preserved: untouchedAppDocs, + untouchedAppDocs: untouchedAppDocs, + syncMode: 'non_destructive', + statusLabels: ['synced', 'mirror active'] + }; }); }); } @@ -1247,16 +1425,16 @@ } if (enabled) { labelEl.textContent = projectPath - ? 'This project syncs to your Desktop folder.' - : 'Desktop sync is enabled for this app.'; + ? 'Mirror active for this project.' + : 'Mirror active for this app.'; pathEl.textContent = projectPath ? ('Project folder: ' + projectPath) : ('Root folder: ' + (rootPath || '~/Desktop/Openmud')); - helpEl.textContent = 'The first setup syncs every project into the root folder. After that, uploads in openmud sync out automatically and Desktop edits sync back in while the desktop app is open.'; + helpEl.textContent = 'The first setup mirrors every project into the root folder. After that, uploads in openmud sync out automatically and Desktop edits import back in while the desktop app is open. Missing mirror files do not delete the app copy automatically.'; } else { labelEl.textContent = 'Desktop sync is off.'; pathEl.textContent = 'Default root folder: ~/Desktop/Openmud'; - helpEl.textContent = 'Set up sync to create the Openmud folder, mirror every project into it, and keep the Desktop folder and openmud in sync.'; + helpEl.textContent = 'Set up sync to create the Openmud folder, mirror every project into it, and import Desktop edits back into openmud without destructive delete-on-absence behavior.'; } if (btnDesktopSyncSetup) { btnDesktopSyncSetup.hidden = enabled; @@ -1293,7 +1471,7 @@ return Promise.resolve({ ok: isDesktopSyncEnabled(), message: isDesktopSyncEnabled() - ? 'Desktop sync is enabled. openmud mirrors every project into your Desktop sync root and watches for local changes while the desktop app is open.' + ? 'Desktop sync is enabled. openmud mirrors every project into your Desktop sync root, imports local changes while the desktop app is open, and does not delete app documents just because a mirror file is missing.' : 'Desktop sync is off. Ask openmud to set up Desktop sync or use Settings to choose the folder and mirror all project documents.', rootPath: (_desktopSyncStatusCache && _desktopSyncStatusCache.rootPath) || '' }); @@ -1408,13 +1586,16 @@ } catch (e) { return null; } } - function setActiveChatId(projectId, cid) { + function setActiveChatId(projectId, cid, options) { + if (!projectId) return; + options = options || {}; try { var raw = localStorage.getItem(STORAGE_ACTIVE_CHAT); var ac = raw ? JSON.parse(raw) : {}; ac[projectId] = cid; localStorage.setItem(STORAGE_ACTIVE_CHAT, JSON.stringify(ac)); } catch (e) {} + if (!options.silent) scheduleProjectStateSync(projectId); } function getMessages(projectId) { @@ -1432,12 +1613,12 @@ } catch (e) { return []; } } - var _messagesSyncTimer = null; - function setMessages(projectId, msgs) { + function setMessages(projectId, msgs, options) { + options = options || {}; var cid = activeChatId || getActiveChatId(projectId); if (!cid) { cid = chatIdGen(); - setActiveChatId(projectId, cid); + setActiveChatId(projectId, cid, { silent: true }); activeChatId = cid; } var raw = localStorage.getItem(STORAGE_MESSAGES); @@ -1446,12 +1627,7 @@ if (!all[projectId][cid]) all[projectId][cid] = { name: 'New chat', messages: [], createdAt: Date.now() }; all[projectId][cid].messages = msgs; localStorage.setItem(STORAGE_MESSAGES, JSON.stringify(all)); - if (getAuthHeaders().Authorization && projectId) { - clearTimeout(_messagesSyncTimer); - _messagesSyncTimer = setTimeout(function () { - syncMessagesToApi(projectId, msgs); - }, 800); - } + if (!options.silent) scheduleProjectStateSync(projectId); } function createNewChat(projectId) { @@ -1461,8 +1637,9 @@ if (!all[projectId]) all[projectId] = {}; all[projectId][cid] = { name: 'New chat', messages: [], createdAt: Date.now() }; localStorage.setItem(STORAGE_MESSAGES, JSON.stringify(all)); - setActiveChatId(projectId, cid); + setActiveChatId(projectId, cid, { silent: true }); activeChatId = cid; + scheduleProjectStateSync(projectId); return cid; } @@ -1479,6 +1656,7 @@ setActiveChatId(projectId, next); activeChatId = next; } + scheduleProjectStateSync(projectId); } function renameChatThread(projectId, chatIdToRename, newName) { @@ -1487,6 +1665,7 @@ if (all[projectId] && all[projectId][chatIdToRename]) { all[projectId][chatIdToRename].name = newName; localStorage.setItem(STORAGE_MESSAGES, JSON.stringify(all)); + scheduleProjectStateSync(projectId); } } @@ -4057,12 +4236,22 @@ setProjects(projects); // Remove chats for this project from localStorage try { - var allChatsRaw = localStorage.getItem('mudrag_chats'); + var allChatsRaw = localStorage.getItem(STORAGE_MESSAGES); var allChats = allChatsRaw ? JSON.parse(allChatsRaw) : {}; delete allChats[projectId]; - localStorage.setItem('mudrag_chats', JSON.stringify(allChats)); + localStorage.setItem(STORAGE_MESSAGES, JSON.stringify(allChats)); + var activeChatsRaw = localStorage.getItem(STORAGE_ACTIVE_CHAT); + var activeChats = activeChatsRaw ? JSON.parse(activeChatsRaw) : {}; + delete activeChats[projectId]; + localStorage.setItem(STORAGE_ACTIVE_CHAT, JSON.stringify(activeChats)); } catch (_) {} removeProjectData(projectId); + if (getAuthHeaders().Authorization) { + fetch(API_BASE + '/projects?id=' + encodeURIComponent(projectId), { + method: 'DELETE', + headers: getAuthHeaders() + }).catch(function () {}); + } // Sync deletion to desktop API if (isToolServerOrigin && API_BASE) { fetch(API_BASE + '/storage/projects/' + encodeURIComponent(projectId), { method: 'DELETE' }).catch(function () {}); @@ -4366,6 +4555,31 @@ var docClipboard = null; // { type: 'doc'|'folder', doc?, folder?, folderDocs? } function openDB() { + var accountState = window.openmudAccountState || null; + if (accountState && accountState.openScopedDatabase) { + return accountState.openScopedDatabase({ + baseName: DB_NAME, + version: DB_VERSION, + upgrade: function (db, e) { + if (!db.objectStoreNames.contains(DOC_STORE)) { + var docStore = db.createObjectStore(DOC_STORE, { keyPath: 'id' }); + docStore.createIndex('projectId', 'projectId', { unique: false }); + } + if (e.oldVersion < 2 && db.objectStoreNames.contains(DOC_STORE)) { + try { + var docStore = e.target.transaction.objectStore(DOC_STORE); + if (!docStore.indexNames.contains('folderId')) { + docStore.createIndex('folderId', 'folderId', { unique: false }); + } + } catch (err) { /* ignore */ } + } + if (!db.objectStoreNames.contains(FOLDERS_STORE)) { + var folderStore = db.createObjectStore(FOLDERS_STORE, { keyPath: 'id' }); + folderStore.createIndex('projectId', 'projectId', { unique: false }); + } + } + }); + } return new Promise(function (resolve, reject) { var req = indexedDB.open(DB_NAME, DB_VERSION); req.onerror = function () { reject(req.error); }; @@ -6427,6 +6641,13 @@ if (isDesktopSyncAvailable() && isDesktopSyncEnabled()) { syncProjectToDesktop(projectId).catch(function () {}); } + if (getAuthHeaders().Authorization) { + fetch(API_BASE + '/projects', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ id: projectId, name: trimmed }) + }).catch(function () {}); + } } var _renamingProject = false; @@ -7060,7 +7281,7 @@ function ensureProject() { var projects = getProjects(); if (projects.length === 0) { - createProject('Untitled project'); + if (!getAuthHeaders().Authorization) createProject('Untitled project'); } else if (!activeProjectId || !projects.find(function (p) { return p.id === activeProjectId; })) { switchProject(projects[0].id); } @@ -9978,10 +10199,18 @@ } } } - if (isToolServerOrigin || (useDesktopApi && toolPort)) { - loadStorageProjects(doInit); + function startAppAfterAuth() { + if (isToolServerOrigin || (useDesktopApi && toolPort)) { + loadStorageProjects(doInit); + } else { + doInit(); + } + } + + if (window.mudragAuthReady && typeof window.mudragAuthReady.then === 'function') { + window.mudragAuthReady.finally(startAppAfterAuth); } else { - doInit(); + startAppAfterAuth(); } (function initMobileSidebar() { @@ -10577,8 +10806,20 @@ syncProjectFromDesktop(projectId).then(function (result) { if (result && result.ok) { showToast('Desktop changes synced into ' + getTaskProjectLabel(projectId)); + } else if (result && result.error) { + logClientPrelaunchEvent('desktop_sync_import_failed', { + project_id: projectId, + message: result.error + }); + showToast('Desktop sync issue: ' + result.error); } - }).catch(function () {}); + }).catch(function (err) { + logClientPrelaunchEvent('desktop_sync_import_failed', { + project_id: projectId, + message: err && err.message ? err.message : 'unknown_error' + }); + showToast('Desktop sync issue: could not import mirror changes.'); + }); }, 900); }); } diff --git a/web/assets/js/auth.js b/web/assets/js/auth.js index 36ea384..240db7b 100644 --- a/web/assets/js/auth.js +++ b/web/assets/js/auth.js @@ -59,6 +59,117 @@ return _clientPromise; } + function getAccountState() { + return (typeof window !== 'undefined' && window.openmudAccountState) ? window.openmudAccountState : null; + } + + function logAuthEvent(eventName, details) { + try { + var accountState = getAccountState(); + console.log(JSON.stringify(Object.assign({ + event: eventName, + source: 'web-auth', + user_id: accountState && accountState.getCurrentUserId ? accountState.getCurrentUserId() : 'anon', + at: new Date().toISOString() + }, details || {}))); + } catch (e) {} + } + + function syncDesktopAccountContext(session) { + if (typeof window === 'undefined' || !window.mudragDesktop || !window.mudragDesktop.setActiveAccount) return Promise.resolve(); + var user = session && session.user ? session.user : null; + return window.mudragDesktop.setActiveAccount({ + userId: user && user.id ? user.id : '', + email: user && user.email ? user.email : '' + }).catch(function () { + return { ok: false }; + }); + } + + function syncAccountScope(session) { + var accountState = getAccountState(); + if (accountState && typeof accountState.setActiveUser === 'function') { + accountState.setActiveUser(session || null); + } + return syncDesktopAccountContext(session); + } + + function readJsonResponse(resp) { + if (!resp) return Promise.resolve(null); + return resp.text().then(function (text) { + if (!text) return null; + try { + return JSON.parse(text); + } catch (e) { + return { error: 'Unexpected server response.' }; + } + }).catch(function () { + return null; + }); + } + + function createDesktopHandoff(session) { + if (!session || !session.access_token || !session.refresh_token) { + return Promise.reject(new Error('You need to sign in before opening the desktop app.')); + } + logAuthEvent('desktop_handoff_start_requested'); + return fetch(getApiBase() + '/desktop-handoff/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + session.access_token + }, + body: JSON.stringify({ + refresh_token: session.refresh_token + }) + }).then(function (resp) { + return readJsonResponse(resp).then(function (data) { + if (!resp.ok) { + throw new Error((data && data.error) || 'Could not prepare desktop sign-in.'); + } + if (!data || !data.handoff_code) { + throw new Error('Desktop handoff was not created.'); + } + logAuthEvent('desktop_handoff_start_completed', { + expires_at: data.expires_at || null + }); + return data; + }); + }).catch(function (err) { + logAuthEvent('desktop_handoff_start_failed', { + message: err && err.message ? err.message : 'unknown_error' + }); + throw err; + }); + } + + function redeemDesktopHandoff(handoffCode) { + var code = String(handoffCode || '').trim(); + if (!code) return Promise.reject(new Error('Missing desktop handoff code.')); + logAuthEvent('desktop_handoff_redeem_requested'); + return fetch(getApiBase() + '/desktop-handoff/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handoff_code: code }) + }).then(function (resp) { + return readJsonResponse(resp).then(function (data) { + if (!resp.ok) { + throw new Error((data && data.error) || 'Desktop sign-in failed.'); + } + if (!data || !data.access_token || !data.refresh_token) { + throw new Error('Desktop sign-in response was incomplete.'); + } + logAuthEvent('desktop_handoff_redeem_completed'); + return data; + }); + }).catch(function (err) { + logAuthEvent('desktop_handoff_redeem_failed', { + message: err && err.message ? err.message : 'unknown_error' + }); + throw err; + }); + } + function getSafeReturnPath() { if (typeof window === 'undefined' || !window.location) return '/try'; var path = window.location.pathname || '/'; @@ -97,7 +208,22 @@ function initDesktopAuthBridge() { if (typeof window === 'undefined' || !window.mudragDesktop || !window.mudragDesktop.onAuthCallback) return; window.mudragDesktop.onAuthCallback(function (data) { - if (!data || !data.access_token || !data.refresh_token) return; + if (!data) return; + if (data.handoff_code) { + redeemDesktopHandoff(data.handoff_code).then(function (tokens) { + return getClient().then(function (client) { + if (!client) return null; + return client.auth.setSession({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token + }); + }); + }).catch(function (e) { + console.warn('[mudrag] desktop handoff redeem failed', e); + }); + return; + } + if (!data.access_token || !data.refresh_token) return; getClient().then(function (client) { if (!client) return; client.auth.setSession({ access_token: data.access_token, refresh_token: data.refresh_token }) @@ -117,12 +243,20 @@ window.mudragAuth = { signInWithEmail: function (email) { + logAuthEvent('auth_signin_email_started'); return getClient().then(function (client) { if (!client) return Promise.reject(new Error('Auth not configured. Missing SUPABASE_URL / SUPABASE_ANON_KEY.')); - return client.auth.signInWithOtp({ email: email.trim(), options: { emailRedirectTo: getRedirectUrl() } }); + return client.auth.signInWithOtp({ email: email.trim(), options: { emailRedirectTo: getRedirectUrl() } }) + .then(function (result) { + logAuthEvent(result && result.error ? 'auth_signin_email_failed' : 'auth_signin_email_completed', { + message: result && result.error ? result.error.message || 'unknown_error' : null + }); + return result; + }); }); }, signInWithGoogle: function () { + logAuthEvent('auth_signin_google_started'); return getClient().then(function (client) { if (!client) return Promise.reject(new Error('Auth not configured. Missing SUPABASE_URL / SUPABASE_ANON_KEY.')); var redirectTo = getRedirectUrl(); @@ -132,13 +266,18 @@ }).then(function (result) { var oauthUrl = result && result.data && result.data.url; if (oauthUrl && typeof window !== 'undefined' && window.location) { + logAuthEvent('auth_signin_google_redirect_ready'); window.location.assign(oauthUrl); } + if (result && result.error) { + logAuthEvent('auth_signin_google_failed', { message: result.error.message || 'unknown_error' }); + } return result; }); }); }, signInWithApple: function () { + logAuthEvent('auth_signin_apple_started'); return getClient().then(function (client) { if (!client) return Promise.reject(new Error('Auth not configured. Missing SUPABASE_URL / SUPABASE_ANON_KEY.')); var redirectTo = getRedirectUrl(); @@ -148,29 +287,77 @@ }).then(function (result) { var oauthUrl = result && result.data && result.data.url; if (oauthUrl && typeof window !== 'undefined' && window.location) { + logAuthEvent('auth_signin_apple_redirect_ready'); window.location.assign(oauthUrl); } + if (result && result.error) { + logAuthEvent('auth_signin_apple_failed', { message: result.error.message || 'unknown_error' }); + } return result; }); }); }, signOut: function () { return getClient().then(function (client) { - if (!client) return Promise.resolve(); - return client.auth.signOut(); + if (!client) { + return syncAccountScope(null).then(function () { + logAuthEvent('auth_signout_completed'); + return null; + }); + } + return client.auth.signOut().then(function (result) { + return syncAccountScope(null).then(function () { + logAuthEvent('auth_signout_completed'); + return result; + }); + }).catch(function (err) { + return syncAccountScope(null).then(function () { + logAuthEvent('auth_signout_failed', { + message: err && err.message ? err.message : 'unknown_error' + }); + throw err; + }); + }); }); }, getSession: function () { return getClient().then(function (client) { - if (!client) return { data: { session: null }, error: null }; - return client.auth.getSession(); + if (!client) { + return syncAccountScope(null).then(function () { + return { data: { session: null }, error: null }; + }); + } + return client.auth.getSession().then(function (result) { + var session = result && result.data ? result.data.session : null; + return syncAccountScope(session).then(function () { + logAuthEvent('auth_session_loaded', { + signed_in: !!(session && session.user) + }); + return result; + }); + }); }); }, onAuthStateChange: function (cb) { getClient().then(function (client) { if (!client) return; - client.auth.onAuthStateChange(cb); + client.auth.onAuthStateChange(function (event, session) { + syncAccountScope(session).then(function () { + logAuthEvent('auth_state_changed', { + auth_event: event || '', + signed_in: !!(session && session.user) + }); + cb(event, session); + }); + }); }); }, + createDesktopHandoff: createDesktopHandoff }; + + window.mudragAuthReady = window.mudragAuth.getSession() + .then(function (result) { return result; }) + .catch(function () { + return { data: { session: null }, error: null }; + }); })(); diff --git a/web/download.html b/web/download.html index 56132a7..8fee14b 100644 --- a/web/download.html +++ b/web/download.html @@ -37,17 +37,18 @@

openmud

Desktop app for macOS

macOS desktop app

-

Download openmud for Mac to get reliable folder sync, local file access, and the desktop-only project mirror workflow.

+

Download openmud for Mac when you need folder sync, local file access, and desktop-linked tools. The browser still works for hosted chat and cloud-backed project state.

Download for Mac GitHub releases
-

After download: unzip the archive, move `openmud.app` into Applications, launch it, then set up your sync folder in Settings.

+

After download: unzip the archive, move `openmud.app` into Applications, launch it, sign in, then set up your sync folder in Settings if you want a local mirror.

diff --git a/web/settings.html b/web/settings.html index ce540b6..46a7ee6 100644 --- a/web/settings.html +++ b/web/settings.html @@ -33,6 +33,7 @@

Settings

+

Cloud account

Account