-
Notifications
You must be signed in to change notification settings - Fork 2
Pre-launch hardening #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9e7612a
abf2417
f7b6271
1b6626f
bf7d30c
acf8469
907a857
0e62398
dac6cb1
47c5ec6
c7b9ad4
5d9bc8b
1e29aeb
d53e731
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Desktop sync overwrites other projects' config on each syncHigh Severity In
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sync config
|
||
| 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,18 +4373,30 @@ 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); | ||
| const projectMap = {}; | ||
| 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 }; | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Legacy file migration races between concurrent user scopesMedium Severity
|
||
|
|
||
| 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, | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sync config projects overwritten losing other project entries
High Severity
In
writeProjectSnapshotToDesktop,nextProjectsis built with only the single project being synced, then passed tosetDesktopSyncConfig. IfsetDesktopSyncConfigreplaces the projects map (depending on its implementation), every other project's metadata (path, manifest, sync status) is lost from the desktop sync config. This would break multi-project sync setups, causing previously synced projects to lose their config entries.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bugbot Autofix determined this is a false positive.
The setDesktopSyncConfig function already merges projects by default (when replaceProjects is not set), using {...current.projects, ...next.projects} at lines 148-150.