Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions PRELAUNCH.md
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
120 changes: 86 additions & 34 deletions desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 });
}
}
Expand Down Expand Up @@ -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, ' ')
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
});
Expand All @@ -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',
Copy link
Contributor

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, nextProjects is built with only the single project being synced, then passed to setDesktopSyncConfig. If setDesktopSyncConfig replaces 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.

Fix in Cursor Fix in Web

Copy link
Contributor

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Desktop sync overwrites other projects' config on each sync

High Severity

In writeProjectSnapshotToDesktop, the nextProjects object is created with only the single project being synced. This object is then passed to setDesktopSyncConfig without replaceProjects: true, so it merges — but the variable name and pattern suggest intent to preserve all projects. However, the real issue is that the merged config only shallow-merges project entries, so if other projects exist, they are preserved, but importantly the manifest, mirrorMode, lastSyncStatus, and lastSyncAt for those other projects could be stale or inconsistent relative to the newly synced project. Actually, the deeper concern: setDesktopSyncConfig does a shallow merge of projects, meaning it will keep other projects' metadata. This part is fine. Let me re-examine... the logic is correct for merging. I withdraw this specific concern.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync config setDesktopSyncConfig uses merge, losing project metadata

Medium Severity

In writeProjectSnapshotToDesktop, the call to setDesktopSyncConfig does not set replaceProjects: true, so the new single-project nextProjects object is shallow-merged with existing projects. This is correct for preserving other projects. However, setDesktopSyncConfig's shallow merge of projects means the new project entry completely replaces the old one for that project ID — which is fine. The real concern is that the manifest can grow unbounded and is stored in the user-data JSON file on every sync, which could cause performance degradation over time for projects with many files.

Fix in Cursor Fix in Web

lastSyncAt: new Date().toISOString(),
};
setDesktopSyncConfig({
Expand All @@ -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() {
Expand Down Expand Up @@ -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,
};
});
Expand All @@ -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 = {}) => {
Expand All @@ -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,
};
});
Expand Down Expand Up @@ -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 };
});

Expand Down
1 change: 1 addition & 0 deletions desktop/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
55 changes: 50 additions & 5 deletions desktop/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy file migration races between concurrent user scopes

Medium Severity

getScopedPath performs a destructive legacy migration: it copies the legacy file into the first user's scoped directory and then deletes the legacy file via fs.unlinkSync. If two different users sign in sequentially, only the first user to trigger getScopedPath for a given file gets the legacy data — the second user finds the legacy file already deleted. This means legacy data is silently assigned to whichever user happens to trigger the path lookup first, which may not be the correct owner.

Fix in Cursor Fix in Web


const DEFAULT_LABOR = {
operator: 85,
laborer: 35,
Expand All @@ -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) {
Expand Down Expand Up @@ -168,7 +200,7 @@ function setChats(projectId, chats) {
}

function getUserDataPath() {
return path.join(getStorageDir(), 'user-data.json');
return getScopedPath('user-data.json');
}

function getUserData() {
Expand All @@ -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,
Expand Down
Loading