diff --git a/CLAUDE.md b/CLAUDE.md index 64a7d165..6e5f5315 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,8 @@ One Bun process (`src/server.tsx`) serves everything: - the React SPA, via Bun's HTML import of `src/apps/officer-web/index.html` (HMR in dev) - the REST API, a Hono app mounted at `/api` (`src/servers/hono.ts`) -- eight WebSocket providers — terminal, chat, task-runner, pipeline, dev-server proxy, cliamp, - cliamp-audio, desktop — plus a sidecar registration socket +- seven WebSocket providers — terminal, chat, task-runner, pipeline, cliamp, cliamp-audio, + desktop — plus the vault notifications hub and a sidecar registration socket - a browser relay on its own port (`BROWSER_RELAY_PORT`, default 18792) Long-running and privileged work lives in **sidecars**: separate processes that dial back in over @@ -57,7 +57,7 @@ src/ ``` `src/workspaces/officerdev` is the biggest of these: the windowed "apps" (FileBrowser, Chat, -Terminal, CodeEditor, Desktop, Projects, Dashboards…) that the shell hosts, behind an `AppRegistry`. +Terminal, CodeEditor, Desktop, Dashboards, Wallet…) that the shell hosts, behind an `AppRegistry`. `src/apps/officer-web` is only the shell — screens, routing and settings. **Path aliases** (`tsconfig.json`): `@/*` → `src/apps/officer-web`, `@/components/*` → @@ -78,7 +78,7 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper Two stores, and the split matters: **Postgres** (`src/databases/officer_db`) holds the account, passkeys, settings, dashboards, -projects, email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in +email accounts, queue and pipeline jobs. Schema in `src/schema/`, hand-written queries in `src/queries/`, types inferred from the schema in `src/types.ts`. **The filesystem** holds everything the agent authors. `OFFICER_ITEMS_DIR` contains one directory diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index d1ad4668..d283b9cf 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -34,7 +34,6 @@ export function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx index c04b1b88..3c9575dc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/UserMenu.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import * as Dropdown from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { User, LogOut, Settings, Package, Puzzle, Rocket, Sun, Moon, Bot } from 'lucide-react'; +import { User, LogOut, Settings, Package, Puzzle, Sun, Moon, Bot } from 'lucide-react'; import { useAuth } from 'hooks/useAuth'; import { useTranslation } from '@/lib/i18n'; import { useColorMode } from '@/components/ui/ThemeProvider'; @@ -65,12 +65,6 @@ export function UserMenu() { Integrations - - - - Apps - - {colorMode === 'dark' ? : } diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/AppsList.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/AppsList.tsx deleted file mode 100644 index 6d613fbe..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/AppsList.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { Trash2, Loader2, ExternalLink } from 'lucide-react'; -import { toast } from 'sonner'; -import { useClient } from 'hooks/useClient'; -import { useUserApps, type AppManifest } from 'state/useUserApps'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; - -export const AppsList = () => { - const client = useClient(); - const { apps, isLoading, refetch } = useUserApps(); - const [deleting, setDeleting] = useState(null); - const [deletingInProgress, setDeletingInProgress] = useState(false); - - const confirmDelete = async () => { - if (!deleting) return; - setDeletingInProgress(true); - try { - await client.delete(`/apps/${deleting.slug}`); - toast.success(`Deleted ${deleting.name}`); - refetch(); - } catch { - toast.error('Failed to delete app'); - } finally { - setDeletingInProgress(false); - setDeleting(null); - } - }; - - if (isLoading) { - return ( -
- -
- ); - } - - if (apps.length === 0) { - return ( -
- No published apps yet. Publish a project from the Projects page. -
- ); - } - - return ( - <> -
- {apps.map((app) => ( -
-
-
- {app.name} - v{app.version} -
- {app.description && ( -

{app.description}

- )} -
- Source: {app.sourceProject} - Commit: {app.commitHash} - Published: {new Date(app.publishedAt).toLocaleDateString()} -
-
- - - - -
- ))} -
- - { if (!open) setDeleting(null); }}> - - - Delete published app - - Are you sure you want to delete {deleting?.name}? This removes the published build. The source project is not affected. - - - - Cancel - - {deletingInProgress ? : 'Delete'} - - - - - - ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/index.tsx deleted file mode 100644 index 6c51c6f5..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Settings/AppsSettings/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useMemo } from 'react'; -import { Rocket, List } from 'lucide-react'; -import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout } from 'officerdev'; - -import { createSettingsPanelComponents, type SettingsSection } from '../SettingsPanel'; -import { AppsList } from './AppsList'; - -const GLOBAL_KEY = 'APPS_SETTINGS_SELECTED'; - -const sections: SettingsSection[] = [ - { key: 'published-apps', icon: List, title: 'Published Apps', description: 'View and manage your published apps', content: }, -]; - -const { Sidebar, Content } = createSettingsPanelComponents({ - globalKey: GLOBAL_KEY, - sidebarIcon: Rocket, - sidebarLabel: 'Apps', - sections, -}); - -const layout: LayoutNode = { - type: 'group', - id: 'apps-settings-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'apps-settings-left', appType: null }, size: 20 }, - { node: { type: 'panel', id: 'apps-settings-right', appType: null }, size: 80 }, - ], -}; - -export const AppsSettings = () => { - const panelComponents: PanelComponents = useMemo( - () => ({ - 'apps-settings-left': Sidebar, - 'apps-settings-right': Content, - }), - [], - ); - - return ( -
- {}} components={panelComponents} /> -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx index 458e3553..60703437 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/index.tsx @@ -2,4 +2,3 @@ export * from './ProfileSettings'; export * from './SystemSettings'; export * from './AISettings'; export * from './IntegrationsSettings'; -export * from './AppsSettings'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3d2e56a2..2b534417 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -10,7 +10,6 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/settings/ai'), title: 'AI Settings' }, { match: (p) => p.startsWith('/settings/profile'), title: 'Profile' }, { match: (p) => p.startsWith('/settings/integrations'), title: 'Integrations' }, - { match: (p) => p.startsWith('/settings/apps'), title: 'App Settings' }, { match: (p) => p.startsWith('/settings'), title: 'Settings' }, { match: (p) => p.startsWith('/chat'), title: 'Chat' }, { match: (p) => p.startsWith('/email'), title: 'Email' }, diff --git a/src/server.tsx b/src/server.tsx index a6dacbe9..76758295 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -11,7 +11,6 @@ import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; import { cliampWebsocket, cliampAudioWebsocket } from './servers/api/cliamp/relay'; import { desktopWebsocket } from './servers/api/desktop/websocket'; import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket'; -import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router'; import officerWeb from './apps/officer-web/index.gen.html'; import { startBrowserRelay } from './servers/api/browser/relay'; import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; @@ -38,7 +37,6 @@ type WSData = { | 'chat' | 'task-runner' | 'pipeline' - | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' @@ -50,10 +48,6 @@ type WSData = { cols?: number; rows?: number; search?: string; // raw query string, for providers that relay it to a sidecar - devServerPort?: number; - devServerSlug?: string; - wsProxyPath?: string; - wsToken?: string; }; // Sidecar registration WebSocket handler @@ -150,83 +144,6 @@ const handlers: Record = { sidecar: sidecarWebsocket, }; -// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.) -type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean }; - -// Bun hands WS frames over as `string | Buffer`, but the DOM WebSocket.send signature won't accept a -// Buffer (it can't rule out a SharedArrayBuffer backing). A Buffer is a Uint8Array -// at runtime, so this forwards as-is rather than paying for a copy on every proxied frame. -const asWsPayload = (raw: string | Buffer): string | Uint8Array => - typeof raw === 'string' ? raw : (raw as Uint8Array); -const devServerUpstreams = new Map, UpstreamState>(); - -const devServerWebsocket = { - async open(ws: ServerWebSocket) { - const { devServerPort, wsProxyPath, wsToken } = ws.data; - - // Validate JWT (deferred from upgrade which must be synchronous in Bun) - if (!wsToken) { - ws.close(4001, 'Unauthorized'); - return; - } - try { - const payload = await verify(wsToken); - if (!payload) { - ws.close(4001, 'Unauthorized'); - return; - } - if (payload.jti && (await isTokenBlacklisted(payload.jti))) { - ws.close(4001, 'Unauthorized'); - return; - } - } catch { - ws.close(4001, 'Unauthorized'); - return; - } - - const upstream = new WebSocket(`ws://localhost:${devServerPort}${wsProxyPath}`); - const state: UpstreamState = { ws: upstream, queue: [], ready: false }; - devServerUpstreams.set(ws, state); - - upstream.addEventListener('open', () => { - state.ready = true; - for (const msg of state.queue) upstream.send(asWsPayload(msg)); - state.queue.length = 0; - }); - - upstream.addEventListener('message', (event) => { - ws.send(event.data as string | ArrayBuffer); - }); - - upstream.addEventListener('close', () => { - devServerUpstreams.delete(ws); - ws.close(); - }); - - upstream.addEventListener('error', () => { - devServerUpstreams.delete(ws); - ws.close(); - }); - }, - message(ws: ServerWebSocket, raw: string | Buffer) { - const state = devServerUpstreams.get(ws); - if (!state) return; - if (state.ready) { - state.ws.send(asWsPayload(raw)); - } else { - state.queue.push(raw); - } - }, - close(ws: ServerWebSocket) { - const state = devServerUpstreams.get(ws); - if (state) { - state.ws.close(); - devServerUpstreams.delete(ws); - } - }, -}; -handlers['dev-server'] = devServerWebsocket; - async function upgradeWs( req: Request, server: any, @@ -269,35 +186,6 @@ async function upgradeWs( } } -function upgradeDevServerWs(req: Request, server: any) { - const url = new URL(req.url); - const match = url.pathname.match(/^\/api\/dev-server-proxy\/([^/]+)(\/.*)?$/); - if (!match) return new Response('Not found', { status: 404 }); - - const proxyId = match[1]!; - const entry = findEntryByProxyId(proxyId); - if (!entry) return new Response('No dev server running', { status: 404 }); - - const wsToken = url.searchParams.get('token'); - if (!wsToken) return new Response('Unauthorized', { status: 401 }); - - touchEntry(entry); - - const wsProxyPath = match[2] || '/'; - const ok = server.upgrade(req, { - data: { - userId: 0, - email: '', - provider: 'dev-server' as const, - devServerPort: entry.port, - devServerSlug: proxyId, - wsProxyPath, - wsToken, - }, - }); - if (!ok) return new Response('Upgrade failed', { status: 500 }); -} - const server = serve({ port: Number(PORT), idleTimeout: 60, @@ -312,10 +200,6 @@ const server = serve({ const file = Bun.file(`public${new URL(req.url).pathname}`); return new Response(file); }, - '/api/dev-server-proxy/*': (req, server) => { - if (req.headers.get('upgrade') === 'websocket') return upgradeDevServerWs(req, server); - return honoServer.fetch(req, server); - }, // Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket); // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy. '/api/vault/notifications/*': (req, server) => { diff --git a/src/servers/api/_shared/html-rewrite.ts b/src/servers/api/_shared/html-rewrite.ts deleted file mode 100644 index 9ad2a3ce..00000000 --- a/src/servers/api/_shared/html-rewrite.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Injected into proxied HTML to intercept fetch/XHR/WebSocket so absolute paths -// go through the proxy instead of hitting the host server directly. -// Also injects JWT auth headers from localStorage for authenticated requests. -export const proxyOverrideScript = (base: string) => - ``; - -// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override -export const rewriteHtml = (html: string, base: string) => - html - .replace(/]*)>/i, `${proxyOverrideScript(base)}`) - .replace(/(href|src|action)="\/(?!\/)/g, `$1="${base}/`) - .replace(/(href|src|action)='\/(?!\/)/g, `$1='${base}/`) - .replace(/url\(\//g, `url(${base}/`); - -// JS/CSS: only rewrite /_bun/ asset paths (Bun dev server specific) -export const rewriteAssetPaths = (text: string, base: string) => - text.replace(/\/_bun\//g, `${base}/_bun/`); diff --git a/src/servers/api/apps/index.ts b/src/servers/api/apps/index.ts deleted file mode 100644 index 71e82238..00000000 --- a/src/servers/api/apps/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { appsRouter, appServeRouter } from './router'; -export type { AppManifest } from './router'; diff --git a/src/servers/api/apps/router.ts b/src/servers/api/apps/router.ts deleted file mode 100644 index 8bb6772e..00000000 --- a/src/servers/api/apps/router.ts +++ /dev/null @@ -1,298 +0,0 @@ -import type { Context } from 'hono'; -import { join } from 'node:path'; -import { existsSync } from 'node:fs'; -import { readdir, cp, rm, mkdir } from 'node:fs/promises'; -import { createRouter } from '@@/create-router'; -import type { HonoVariables } from '@@/create-router'; -import { getUserAppsDir, getUserProjectsDir } from '@@/data-path'; -import * as errors from '@@/custom-errors'; -import { verify } from '@@/jwt'; -import { isTokenBlacklisted } from 'officerdb'; -import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite'; - -export type AppManifest = { - slug: string; - name: string; - version: string; - icon: string; - description: string; - sourceProject: string; - commitHash: string; - publishedAt: string; - buildDir: string; -}; - -// ── Protected routes (inside protectedRouter) ── - -export const appsRouter = createRouter(); - -appsRouter.get('/', async (ctx) => { - const user = ctx.get('user'); - const appsDir = getUserAppsDir(user.email); - - if (!existsSync(appsDir)) return ctx.json({ apps: [] }); - - const entries = await readdir(appsDir, { withFileTypes: true }); - const apps: AppManifest[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const manifestPath = join(appsDir, entry.name, 'manifest.json'); - if (!existsSync(manifestPath)) continue; - try { - const manifest = JSON.parse(await Bun.file(manifestPath).text()) as AppManifest; - apps.push(manifest); - } catch { - // skip malformed manifests - } - } - - return ctx.json({ apps }); -}); - -appsRouter.post('/publish', async (ctx) => { - const user = ctx.get('user'); - const body = ctx.get('body') as { projectSlug: string; name?: string; icon?: string; description?: string }; - const { projectSlug, name, icon, description } = body; - - if (!projectSlug) throw errors.BAD_REQUEST('Missing projectSlug'); - - const projectDir = join(getUserProjectsDir(user.email), projectSlug); - if (!existsSync(projectDir)) throw errors.BAD_REQUEST(`Project not found: ${projectSlug}`); - - const pkgPath = join(projectDir, 'package.json'); - if (!existsSync(pkgPath)) throw errors.BAD_REQUEST('No package.json found in project'); - - let pkgJson: { name?: string; version?: string; scripts?: Record }; - try { - pkgJson = JSON.parse(await Bun.file(pkgPath).text()); - } catch { - throw errors.BAD_REQUEST('Failed to read package.json'); - } - - if (!pkgJson.scripts?.build) throw errors.BAD_REQUEST('No "build" script in package.json'); - - const version = pkgJson.version ?? '0.1.0'; - const appSlug = projectSlug; - const appName = name ?? pkgJson.name ?? projectSlug; - - // Read .officerdev/meta.json for fallback description - let metaDescription = ''; - const metaPath = join(projectDir, '.officerdev', 'meta.json'); - if (existsSync(metaPath)) { - try { - const meta = JSON.parse(await Bun.file(metaPath).text()); - metaDescription = meta.description ?? ''; - } catch { - // ignore - } - } - - // Get git commit hash - let commitHash = 'unknown'; - try { - const proc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { cwd: projectDir, stdout: 'pipe', stderr: 'pipe' }); - await proc.exited; - if (proc.exitCode === 0) { - commitHash = (await new Response(proc.stdout).text()).trim(); - } - } catch { - // no git — that's fine - } - - // Run build - const buildProc = Bun.spawn(['bun', 'run', 'build'], { - cwd: projectDir, - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, NODE_ENV: 'production' }, - }); - - const buildTimeout = setTimeout(() => buildProc.kill(), 60_000); - const exitCode = await buildProc.exited; - clearTimeout(buildTimeout); - - if (exitCode !== 0) { - const stderr = await new Response(buildProc.stderr).text(); - throw errors.BAD_REQUEST(`Build failed (exit ${exitCode}):\n${stderr.slice(-500)}`); - } - - // Verify dist output - const distDir = join(projectDir, 'dist'); - if (!existsSync(distDir)) throw errors.BAD_REQUEST('Build did not produce a dist/ directory'); - - const distFiles = await readdir(distDir); - const hasHtml = distFiles.some((f) => f.endsWith('.html')); - if (!hasHtml) throw errors.BAD_REQUEST('Build output has no HTML files'); - - // Copy to versioned directory - const appsDir = getUserAppsDir(user.email); - const appDir = join(appsDir, appSlug); - const versionDir = join(appDir, 'v', version); - - await mkdir(versionDir, { recursive: true }); - await cp(distDir, versionDir, { recursive: true }); - - // Write manifest - const manifest: AppManifest = { - slug: appSlug, - name: appName, - version, - icon: icon ?? 'Box', - description: description ?? metaDescription, - sourceProject: projectSlug, - commitHash, - publishedAt: new Date().toISOString(), - buildDir: `v/${version}`, - }; - - await Bun.write(join(appDir, 'manifest.json'), JSON.stringify(manifest, null, 2)); - - return ctx.json(manifest); -}); - -appsRouter.delete('/:slug', async (ctx) => { - const user = ctx.get('user'); - const slug = ctx.req.param('slug'); - if (!slug) throw errors.BAD_REQUEST('Missing slug'); - - const appDir = join(getUserAppsDir(user.email), slug); - if (!existsSync(appDir)) throw errors.NOT_FOUND('App not found'); - - await rm(appDir, { recursive: true, force: true }); - return ctx.json({ ok: true }); -}); - -// ── Static serving (outside protectedRouter) ── - -export const appServeRouter = createRouter(); - -const MIME_TYPES: Record = { - '.html': 'text/html; charset=utf-8', - '.js': 'application/javascript; charset=utf-8', - '.mjs': 'application/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.ico': 'image/x-icon', - '.webp': 'image/webp', - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.eot': 'application/vnd.ms-fontobject', - '.map': 'application/json', -}; - -const getMimeType = (filePath: string): string => { - const ext = filePath.slice(filePath.lastIndexOf('.')); - return MIME_TYPES[ext] ?? 'application/octet-stream'; -}; - -type ValidateResult = { email: string } | null; - -async function validateToken(req: Request): Promise { - const authHeader = req.headers.get('authorization'); - const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : new URL(req.url).searchParams.get('token'); - if (!token) return null; - try { - const payload = await verify(token); - if (!payload?.email) return null; - if (payload.jti && (await isTokenBlacklisted(payload.jti))) return null; - return { email: payload.email }; - } catch { - return null; - } -} - -appServeRouter.all('/:slug/*', async (ctx) => { - const slug = ctx.req.param('slug'); - if (!slug) return ctx.text('Not found', 404); - - // Validate auth — HTML requests require token, assets check referer - const pathname = new URL(ctx.req.url).pathname; - const isHtml = - ctx.req.header('accept')?.includes('text/html') || - pathname.endsWith('.html') || - !pathname.slice(pathname.lastIndexOf('/') + 1).includes('.'); - - const validated = await validateToken(ctx.req.raw); - - if (!validated) { - // For non-HTML assets, try referer-based resolution - if (!isHtml) { - const referer = ctx.req.header('referer'); - if (referer) { - const match = referer.match(/\/api\/app-serve\/([^/?]+)/); - if (match) { - // Extract token from referer URL if present - try { - const refUrl = new URL(referer); - const refToken = refUrl.searchParams.get('token'); - if (refToken) { - const payload = await verify(refToken); - if (payload?.email) { - if (!payload.jti || !(await isTokenBlacklisted(payload.jti))) { - return serveFile(ctx, payload.email, slug); - } - } - } - } catch { - // fall through - } - } - } - } - return ctx.text('Unauthorized', 401); - } - - return serveFile(ctx, validated.email, slug); -}); - -async function serveFile(ctx: Context<{ Variables: HonoVariables }>, email: string, slug: string) { - const appDir = join(getUserAppsDir(email), slug); - const manifestPath = join(appDir, 'manifest.json'); - - if (!existsSync(manifestPath)) return ctx.text('App not found', 404); - - let manifest: AppManifest; - try { - manifest = JSON.parse(await Bun.file(manifestPath).text()); - } catch { - return ctx.text('Invalid manifest', 500); - } - - const buildDir = join(appDir, manifest.buildDir); - const prefix = `/api/app-serve/${slug}`; - - const url = new URL(ctx.req.url); - let filePath = url.pathname.replace(prefix, '') || '/'; - if (filePath === '/') filePath = '/index.html'; - - const fullPath = join(buildDir, filePath); - - // Security: prevent directory traversal - if (!fullPath.startsWith(buildDir)) return ctx.text('Forbidden', 403); - - // If file doesn't exist, serve index.html for SPA routing - const targetPath = existsSync(fullPath) ? fullPath : join(buildDir, 'index.html'); - if (!existsSync(targetPath)) return ctx.text('Not found', 404); - - const file = Bun.file(targetPath); - const mimeType = getMimeType(targetPath); - const needsRewrite = - mimeType.includes('text/html') || mimeType.includes('javascript') || mimeType.includes('text/css'); - - if (needsRewrite) { - const content = await file.text(); - const rewritten = mimeType.includes('text/html') - ? rewriteHtml(content, prefix) - : rewriteAssetPaths(content, prefix); - return new Response(rewritten, { headers: { 'content-type': mimeType } }); - } - - return new Response(file, { headers: { 'content-type': mimeType } }); -} diff --git a/src/servers/api/dev-server/router.ts b/src/servers/api/dev-server/router.ts deleted file mode 100644 index ad276fcd..00000000 --- a/src/servers/api/dev-server/router.ts +++ /dev/null @@ -1,314 +0,0 @@ -import type { Subprocess } from 'bun'; -import { join } from 'node:path'; -import { existsSync } from 'node:fs'; -import { createServer } from 'node:net'; -import { createRouter } from '@@/create-router'; -import { getUserProjectsDir } from '@@/data-path'; -import { CustomError } from '@@/custom-errors'; -import * as errors from '@@/custom-errors'; -import { verify } from '@@/jwt'; -import { isTokenBlacklisted } from 'officerdb'; -import { rewriteHtml, rewriteAssetPaths } from '../_shared/html-rewrite'; - -export const devServerRouter = createRouter(); -export const devServerProxyRouter = createRouter(); - -export type ServerEntry = { proc: Subprocess; port: number; slug: string; proxyId: string; logs: string[]; idleTimer: Timer | null }; - -const IDLE_TIMEOUT_MS = 5 * 60 * 1000; - -const servers = new Map(); -const proxyIdIndex = new Map(); -const pendingStarts = new Set(); - -const serverKey = (email: string, slug: string) => `${email}:${slug}`; - -export const findEntryByProxyId = (proxyId: string): ServerEntry | undefined => proxyIdIndex.get(proxyId); - -export const touchEntry = (entry: ServerEntry) => { - if (entry.idleTimer) clearTimeout(entry.idleTimer); - entry.idleTimer = setTimeout(() => { - console.log(`[dev-server] ${entry.slug} idle for 5m, stopping`); - entry.proc.kill(); - }, IDLE_TIMEOUT_MS); -}; - -const killEntry = (entry: ServerEntry, key: string) => { - if (entry.idleTimer) clearTimeout(entry.idleTimer); - entry.proc.kill(); - proxyIdIndex.delete(entry.proxyId); - servers.delete(key); -}; - -const findFreePort = (): Promise => - new Promise((resolve, reject) => { - const server = createServer(); - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - const port = typeof addr === 'object' && addr ? addr.port : 0; - server.close(() => resolve(port)); - }); - server.on('error', reject); - }); - -const collectLogs = (entry: ServerEntry, stream: ReadableStream | null, prefix: string) => { - if (!stream) return; - const reader = stream.getReader(); - const decoder = new TextDecoder(); - const pump = async () => { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const text = decoder.decode(value, { stream: true }); - for (const line of text.split('\n').filter(Boolean)) { - entry.logs.push(`[${prefix}] ${line}`); - if (entry.logs.length > 200) entry.logs.shift(); - } - } - }; - pump().catch(() => {}); -}; - -const waitForPort = async (port: number, timeoutMs = 8000) => { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(500) }); - await res.arrayBuffer(); - return true; - } catch { - await Bun.sleep(300); - } - } - return false; -}; - -devServerRouter.post('/start', async (ctx) => { - const user = ctx.get('user'); - const { slug } = ctx.get('body') as { slug: string }; - - if (!slug) throw errors.BAD_REQUEST('Missing slug'); - - const key = serverKey(user.email, slug); - - const existing = servers.get(key); - if (existing) { - touchEntry(existing); - return ctx.json({ url: `/api/dev-server-proxy/${existing.proxyId}/`, port: existing.port }); - } - - if (pendingStarts.has(key)) throw errors.BAD_REQUEST('Server is already starting'); - pendingStarts.add(key); - - try { - const projectDir = join(getUserProjectsDir(user.email), slug); - if (!existsSync(projectDir)) throw errors.BAD_REQUEST(`Project directory not found: ${slug}`); - - const pkgPath = join(projectDir, 'package.json'); - if (!existsSync(pkgPath)) throw errors.BAD_REQUEST('No package.json found in project'); - try { - const pkg = JSON.parse(await Bun.file(pkgPath).text()); - if (!pkg.scripts?.dev) throw errors.BAD_REQUEST('No "dev" script in package.json'); - } catch (err) { - if (err instanceof CustomError) throw err; - throw errors.BAD_REQUEST('Failed to read package.json'); - } - - const port = await findFreePort(); - const proxyId = crypto.randomUUID(); - - const proc = Bun.spawn(['bun', 'dev'], { - cwd: projectDir, - env: { ...process.env, PORT: String(port) }, - stdout: 'pipe', - stderr: 'pipe', - }); - console.log(`[dev-server] started ${slug} on port ${port} (pid ${proc.pid}) proxyId=${proxyId}`); - - const entry: ServerEntry = { proc, port, slug, proxyId, logs: [], idleTimer: null }; - servers.set(key, entry); - proxyIdIndex.set(proxyId, entry); - touchEntry(entry); - - collectLogs(entry, proc.stdout, 'stdout'); - collectLogs(entry, proc.stderr, 'stderr'); - - proc.exited.then((code) => { - console.log(`[dev-server] ${slug} exited with code ${code}`); - entry.logs.push(`[system] Process exited with code ${code}`); - if (entry.idleTimer) clearTimeout(entry.idleTimer); - proxyIdIndex.delete(entry.proxyId); - servers.delete(key); - }); - - const ready = await waitForPort(port); - if (!ready) { - const exitCode = proc.exitCode; - if (exitCode !== null) { - if (entry.idleTimer) clearTimeout(entry.idleTimer); - proxyIdIndex.delete(entry.proxyId); - servers.delete(key); - throw errors.BAD_REQUEST(`Dev server exited with code ${exitCode}. Logs:\n${entry.logs.slice(-20).join('\n')}`); - } - } - - return ctx.json({ url: `/api/dev-server-proxy/${proxyId}/`, port }); - } finally { - pendingStarts.delete(key); - } -}); - -devServerRouter.post('/stop', async (ctx) => { - const user = ctx.get('user'); - const { slug } = ctx.get('body') as { slug: string }; - - if (!slug) throw errors.BAD_REQUEST('Missing slug'); - - const key = serverKey(user.email, slug); - const entry = servers.get(key); - - if (entry) killEntry(entry, key); - - return ctx.json({ ok: true }); -}); - -devServerRouter.get('/status', async (ctx) => { - const user = ctx.get('user'); - const slug = ctx.req.query('slug'); - - if (!slug) throw errors.BAD_REQUEST('Missing slug'); - - const key = serverKey(user.email, slug); - const entry = servers.get(key); - - if (entry) { - return ctx.json({ running: true, url: `/api/dev-server-proxy/${entry.proxyId}/`, port: entry.port }); - } - - return ctx.json({ running: false }); -}); - -devServerRouter.get('/logs', async (ctx) => { - const user = ctx.get('user'); - const slug = ctx.req.query('slug'); - - if (!slug) throw errors.BAD_REQUEST('Missing slug'); - - const key = serverKey(user.email, slug); - const entry = servers.get(key); - - if (!entry) return ctx.json({ logs: [] }); - - return ctx.json({ logs: entry.logs.slice(-100) }); -}); - -// Proxy router — mounted outside protected router (no auth needed for sub-resources). -// Security: only proxies to ports that were started via authenticated /start calls. - -type ProxyParams = { entry: ServerEntry; proxyId: string; proxyPath: string; search: string; method: string; rawReq: Request }; - -async function proxyRequest({ entry, proxyId, proxyPath, search, method, rawReq }: ProxyParams): Promise { - const prefix = `/api/dev-server-proxy/${proxyId}`; - const targetUrl = `http://localhost:${entry.port}${proxyPath}${search}`; - - touchEntry(entry); - - try { - const headers = new Headers(rawReq.headers); - headers.delete('host'); - headers.delete('authorization'); - - const res = await fetch(targetUrl, { - method, - headers, - body: method !== 'GET' && method !== 'HEAD' ? rawReq.body : undefined, - redirect: 'manual', - }); - - const contentType = res.headers.get('content-type') ?? ''; - const needsRewrite = - contentType.includes('text/html') || - contentType.includes('javascript') || - contentType.includes('text/css'); - - if (needsRewrite) { - const text = await res.text(); - const rewritten = contentType.includes('text/html') - ? rewriteHtml(text, prefix) - : rewriteAssetPaths(text, prefix); - const newHeaders = new Headers(res.headers); - newHeaders.delete('content-length'); - newHeaders.delete('content-encoding'); - return new Response(rewritten, { status: res.status, headers: newHeaders }); - } - - return new Response(res.body, { status: res.status, headers: res.headers }); - } catch { - return new Response('Dev server not reachable', { status: 502 }); - } -} - -const isLikelyHtmlRequest = (req: Request): boolean => { - const accept = req.headers.get('accept') ?? ''; - return accept.includes('text/html'); -}; - -const stripTokenParam = (search: string): string => { - if (!search) return search; - const params = new URLSearchParams(search); - params.delete('token'); - const result = params.toString(); - return result ? `?${result}` : ''; -}; - -async function validateJwt(req: Request): Promise { - const authHeader = req.headers.get('authorization'); - const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : new URL(req.url).searchParams.get('token'); - if (!token) return false; - try { - const payload = await verify(token); - if (!payload) return false; - if (payload.jti && await isTokenBlacklisted(payload.jti)) return false; - return true; - } catch { - return false; - } -} - -// Proxy handler: tries proxyId from URL first, falls back to Referer for misrouted -// chunk requests caused by relative imports resolving at wrong path depth. -devServerProxyRouter.all('/:proxyId/*', async (ctx) => { - const proxyId = ctx.req.param('proxyId'); - if (!proxyId) return ctx.text('Not found', 404); - - const originalUrl = new URL(ctx.req.url); - const entry = proxyIdIndex.get(proxyId); - if (entry) { - if (isLikelyHtmlRequest(ctx.req.raw)) { - const valid = await validateJwt(ctx.req.raw); - if (!valid) return ctx.text('Unauthorized', 401); - } - const prefix = `/api/dev-server-proxy/${proxyId}`; - const proxyPath = originalUrl.pathname.replace(prefix, '') || '/'; - const search = stripTokenParam(originalUrl.search); - return proxyRequest({ entry, proxyId, proxyPath, search, method: ctx.req.method, rawReq: ctx.req.raw }); - } - - // proxyId didn't match — check Referer for the real proxyId - const referer = ctx.req.header('referer'); - if (referer) { - const match = referer.match(/\/api\/dev-server-proxy\/([^/]+)/); - if (match) { - const realProxyId = match[1]!; - const realEntry = proxyIdIndex.get(realProxyId); - if (realEntry) { - const proxyPath = originalUrl.pathname.replace('/api/dev-server-proxy', '') || '/'; - const search = stripTokenParam(originalUrl.search); - return proxyRequest({ entry: realEntry, proxyId: realProxyId, proxyPath, search, method: ctx.req.method, rawReq: ctx.req.raw }); - } - } - } - - return ctx.text('No dev server running', 404); -}); - diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index f37bd73b..d6d53f4a 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -35,8 +35,6 @@ export const getOwnerHomeDir = (email: string): string => process.env.HOME_DIR ? export const getUserPiConfigDir = (email: string) => join(DATA_PATH, email, 'home', '.pi', 'agent'); -export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects'); - export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'attachments', 'tmp'); export const getAttachmentsDir = (email: string, sessionId: string) => join(DATA_PATH, email, 'attachments', sessionId); @@ -61,5 +59,3 @@ export const toShellUsername = (username: string, email: string): string => { .slice(0, 32) || 'officer' ); }; - -export const getUserAppsDir = (email: string) => join(DATA_PATH, email, 'apps'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 8cfb9f25..f97f6cbb 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -35,7 +35,6 @@ import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd r import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port -import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { dockRouter } from './api/dock/dock'; import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations'; import { queueRouter } from './api/queue/queue'; @@ -43,7 +42,6 @@ import { emailRouter } from './api/email/email'; import { channelsRouter } from './channels/routes'; import { browserRouter } from './api/browser/router'; import { desktopRouter } from './api/desktop/rest'; -import { appsRouter, appServeRouter } from './api/apps'; import { bugReportRouter } from './api/bug-report/bug-report'; import { chatRouter } from './api/chat/chat'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; @@ -80,8 +78,6 @@ honoServer.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); honoServer.route('/api/auth', authRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter); honoServer.route('/api/waitlist', waitlistRouter); -honoServer.route('/api/dev-server-proxy', devServerProxyRouter); -honoServer.route('/api/app-serve', appServeRouter); // Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client // carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. Origin // gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The @@ -124,14 +120,12 @@ protectedRouter.route('/wallet', walletRouter); protectedRouter.route('/vpn', vpnRouter); protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/activity', activityRouter); -protectedRouter.route('/dev-server', devServerRouter); protectedRouter.route('/dock', dockRouter); protectedRouter.route('/integrations', integrationsRouter); protectedRouter.route('/queue', queueRouter); protectedRouter.route('/email', emailRouter); protectedRouter.route('/channels', channelsRouter); protectedRouter.route('/browser', browserRouter); -protectedRouter.route('/apps', appsRouter); protectedRouter.route('/bug-report', bugReportRouter); protectedRouter.route('/chat', chatRouter); protectedRouter.route('/pipeline-jobs', pipelineJobsRouter); diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index f9c391cf..a09cc88d 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from 'react'; import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; @@ -6,7 +5,6 @@ import { appRegistryMetas as chatMetas } from '../apps/Chat'; import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; -import { appRegistryMetas as previewMetas } from '../apps/Preview'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; import { appRegistryMetas as desktopMetas } from '../apps/Desktop'; import { appRegistryMetas as musicMetas } from '../apps/Music'; @@ -17,10 +15,6 @@ import { appRegistryMetas as invoicesMetas } from '../apps/Invoices'; import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { useAppRegistry } from './useAppRegistry'; -import { useUserApps } from 'state/useUserApps'; -import { createUserAppPanel } from '../apps/UserApp/UserAppPanel'; -import { createUserAppHeader } from '../apps/UserApp/UserAppHeader'; -import { resolveIcon } from '../utils/resolve-icon'; const apps = [ ...fileBrowserMetas, @@ -30,7 +24,6 @@ const apps = [ ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, - ...previewMetas, ...widgetMetas, ...desktopMetas, ...musicMetas, @@ -43,26 +36,6 @@ const apps = [ ]; export const AppRegistry = () => { - const { registerApp } = useAppRegistry(apps); - const { apps: userApps, email } = useUserApps(); - const registeredRef = useRef(new Set()); - - useEffect(() => { - if (!email || userApps.length === 0) return; - - for (const app of userApps) { - const key = `${email}/${app.slug}`; - if (registeredRef.current.has(key)) continue; - registeredRef.current.add(key); - - registerApp(key, { - name: app.name, - icon: resolveIcon(app.icon), - component: createUserAppPanel(app.slug), - header: createUserAppHeader(app.name, resolveIcon(app.icon)), - }); - } - }, [userApps, email, registerApp]); - + useAppRegistry(apps); return null; }; diff --git a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx b/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx deleted file mode 100644 index 0e0a3d6b..00000000 --- a/src/workspaces/officerdev/src/apps/Preview/PreviewApp.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Loader2, FolderKanban } from 'lucide-react'; -import { usePreview } from './PreviewContext'; - -export const PreviewApp = () => { - const { slug, cwdSlug, url, loading, error, iframeKey, projects, startServer, setSelectedSlug, clearError } = - usePreview(); - - if (!slug) { - if (projects.length === 0) { - return ( -
- No projects found -
- ); - } - - return ( -
- - Select a project to preview -
- {projects.map((p) => ( - - ))} -
-
- ); - } - - if (loading) { - return ( -
- - Starting dev server... -
- ); - } - - if (error) { - return ( -
- {error} -
- - {!cwdSlug && ( - - )} -
-
- ); - } - - if (!url) return null; - - return