diff --git a/README.md b/README.md
index 79a32d6..cb15ca1 100644
--- a/README.md
+++ b/README.md
@@ -107,7 +107,7 @@ Outbound webhooks with delivery history, configurable alert rules with cooldowns
```
mission-control/
├── src/
-│ ├── middleware.ts # Auth gate + CSRF + network access control
+│ ├── proxy.ts # Auth gate + CSRF + network access control
│ ├── app/
│ │ ├── page.tsx # SPA shell — routes all panels
│ │ ├── login/page.tsx # Login page
diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx
index 1bdfec0..f18a852 100644
--- a/src/app/[[...panel]]/page.tsx
+++ b/src/app/[[...panel]]/page.tsx
@@ -34,13 +34,14 @@ import { GitHubSyncPanel } from '@/components/panels/github-sync-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
+import { UpdateBanner } from '@/components/layout/update-banner'
import { useWebSocket } from '@/lib/websocket'
import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store'
export default function Home() {
const { connect } = useWebSocket()
- const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, liveFeedOpen, toggleLiveFeed } = useMissionControl()
+ const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
// Sync URL → Zustand activeTab
const pathname = usePathname()
@@ -63,6 +64,20 @@ export default function Home() {
.then(data => { if (data?.user) setCurrentUser(data.user) })
.catch(() => {})
+ // Check for available updates
+ fetch('/api/releases/check')
+ .then(res => res.ok ? res.json() : null)
+ .then(data => {
+ if (data?.updateAvailable) {
+ setUpdateAvailable({
+ latestVersion: data.latestVersion,
+ releaseUrl: data.releaseUrl,
+ releaseNotes: data.releaseNotes,
+ })
+ }
+ })
+ .catch(() => {})
+
// Check capabilities, then conditionally connect to gateway
fetch('/api/status?action=capabilities')
.then(res => res.ok ? res.json() : null)
@@ -103,7 +118,7 @@ export default function Home() {
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
})
- }, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription])
+ }, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
if (!isClient) {
return (
@@ -130,6 +145,7 @@ export default function Home() {
+
diff --git a/src/app/api/releases/check/route.ts b/src/app/api/releases/check/route.ts
new file mode 100644
index 0000000..d5e202d
--- /dev/null
+++ b/src/app/api/releases/check/route.ts
@@ -0,0 +1,55 @@
+import { NextResponse } from 'next/server'
+import { APP_VERSION } from '@/lib/version'
+
+const GITHUB_RELEASES_URL =
+ 'https://api.github.com/repos/builderz-labs/mission-control/releases/latest'
+
+/** Simple semver compare: returns 1 if a > b, -1 if a < b, 0 if equal. */
+function compareSemver(a: string, b: string): number {
+ const pa = a.replace(/^v/, '').split('.').map(Number)
+ const pb = b.replace(/^v/, '').split('.').map(Number)
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ const na = pa[i] ?? 0
+ const nb = pb[i] ?? 0
+ if (na > nb) return 1
+ if (na < nb) return -1
+ }
+ return 0
+}
+
+export async function GET() {
+ try {
+ const res = await fetch(GITHUB_RELEASES_URL, {
+ headers: { Accept: 'application/vnd.github+json' },
+ next: { revalidate: 3600 }, // ISR cache for 1 hour
+ })
+
+ if (!res.ok) {
+ return NextResponse.json(
+ { updateAvailable: false, currentVersion: APP_VERSION },
+ { headers: { 'Cache-Control': 'public, max-age=3600' } }
+ )
+ }
+
+ const release = await res.json()
+ const latestVersion = (release.tag_name ?? '').replace(/^v/, '')
+ const updateAvailable = compareSemver(latestVersion, APP_VERSION) > 0
+
+ return NextResponse.json(
+ {
+ updateAvailable,
+ currentVersion: APP_VERSION,
+ latestVersion,
+ releaseUrl: release.html_url ?? '',
+ releaseNotes: release.body ?? '',
+ },
+ { headers: { 'Cache-Control': 'public, max-age=3600' } }
+ )
+ } catch {
+ // Network error — fail gracefully
+ return NextResponse.json(
+ { updateAvailable: false, currentVersion: APP_VERSION },
+ { headers: { 'Cache-Control': 'public, max-age=600' } }
+ )
+ }
+}
diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx
index 8f14bd5..8d38cfb 100644
--- a/src/components/layout/header-bar.tsx
+++ b/src/components/layout/header-bar.tsx
@@ -7,6 +7,7 @@ import { useWebSocket } from '@/lib/websocket'
import { useNavigateToPanel } from '@/lib/navigation'
import { ThemeToggle } from '@/components/ui/theme-toggle'
import { DigitalClock } from '@/components/ui/digital-clock'
+import { APP_VERSION } from '@/lib/version'
interface SearchResult {
type: string
@@ -136,7 +137,7 @@ export function HeaderBar() {
{tabLabels[activeTab] || 'Mission Control'}
- v2.0
+ v{APP_VERSION}
diff --git a/src/components/layout/update-banner.tsx b/src/components/layout/update-banner.tsx
new file mode 100644
index 0000000..65b374f
--- /dev/null
+++ b/src/components/layout/update-banner.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import { useMissionControl } from '@/store'
+
+export function UpdateBanner() {
+ const { updateAvailable, updateDismissedVersion, dismissUpdate } = useMissionControl()
+
+ if (!updateAvailable) return null
+ if (updateDismissedVersion === updateAvailable.latestVersion) return null
+
+ return (
+
+
+
+
+ Update available: v{updateAvailable.latestVersion}
+
+ {' — a newer version of Mission Control is available.'}
+
+
+ View Release
+
+
+
+ )
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
new file mode 100644
index 0000000..0a0331e
--- /dev/null
+++ b/src/lib/version.ts
@@ -0,0 +1,6 @@
+// Single source of truth for the application version.
+// Reads from package.json at build time so every consumer
+// (header, websocket handshake, API routes) stays in sync.
+import pkg from '../../package.json'
+
+export const APP_VERSION: string = pkg.version
diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts
index 460a475..701894a 100644
--- a/src/lib/websocket.ts
+++ b/src/lib/websocket.ts
@@ -9,6 +9,7 @@ import {
getCachedDeviceToken,
cacheDeviceToken,
} from '@/lib/device-identity'
+import { APP_VERSION } from '@/lib/version'
// Gateway protocol version (v3 required by OpenClaw 2026.x)
const PROTOCOL_VERSION = 3
@@ -172,7 +173,7 @@ export function useWebSocket() {
client: {
id: 'gateway-client',
displayName: 'Mission Control',
- version: '2.0.0',
+ version: APP_VERSION,
platform: 'web',
mode: 'ui',
instanceId: `mc-${Date.now()}`
diff --git a/src/middleware.ts b/src/proxy.ts
similarity index 92%
rename from src/middleware.ts
rename to src/proxy.ts
index acd8bed..44d14d9 100644
--- a/src/middleware.ts
+++ b/src/proxy.ts
@@ -1,18 +1,14 @@
+import crypto from 'node:crypto'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
-/** Edge-compatible constant-time string comparison. */
+/** Constant-time string comparison using Node.js crypto. */
function safeCompare(a: string, b: string): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
- const encoder = new TextEncoder()
- const bufA = encoder.encode(a)
- const bufB = encoder.encode(b)
+ const bufA = Buffer.from(a)
+ const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
- let result = 0
- for (let i = 0; i < bufA.length; i++) {
- result |= bufA[i] ^ bufB[i]
- }
- return result === 0
+ return crypto.timingSafeEqual(bufA, bufB)
}
function envFlag(name: string): boolean {
@@ -56,7 +52,7 @@ function applySecurityHeaders(response: NextResponse): NextResponse {
return response
}
-export function middleware(request: NextRequest) {
+export function proxy(request: NextRequest) {
// Network access control.
// In production: default-deny unless explicitly allowed.
// In dev/test: allow all hosts unless overridden.
diff --git a/src/store/index.ts b/src/store/index.ts
index d263360..0087976 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -261,6 +261,12 @@ interface MissionControlStore {
dismissBanner: () => void
setSubscription: (sub: { type: string; rateLimitTier?: string } | null) => void
+ // Update availability
+ updateAvailable: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null
+ updateDismissedVersion: string | null
+ setUpdateAvailable: (info: { latestVersion: string; releaseUrl: string; releaseNotes: string } | null) => void
+ dismissUpdate: (version: string) => void
+
// WebSocket & Connection
connection: ConnectionStatus
lastMessage: any
@@ -408,6 +414,18 @@ export const useMissionControl = create()(
dismissBanner: () => set({ bannerDismissed: true }),
setSubscription: (sub) => set({ subscription: sub }),
+ // Update availability
+ updateAvailable: null,
+ updateDismissedVersion: (() => {
+ if (typeof window === 'undefined') return null
+ try { return localStorage.getItem('mc-update-dismissed-version') } catch { return null }
+ })(),
+ setUpdateAvailable: (info) => set({ updateAvailable: info }),
+ dismissUpdate: (version) => {
+ try { localStorage.setItem('mc-update-dismissed-version', version) } catch {}
+ set({ updateDismissedVersion: version })
+ },
+
// Connection state
connection: {
isConnected: false,