From 274b726df460a20eba953cfdb308f5c823736f04 Mon Sep 17 00:00:00 2001
From: nyk <93952610+0xNyk@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:17:15 +0700
Subject: [PATCH] feat: add Update Available banner with GitHub release check
(#94)
* fix: migrate middleware.ts to proxy.ts for Next.js 16 (#88)
Next.js 16 deprecated the `middleware` file convention in favor of
`proxy`. The proxy runs on the Node.js runtime instead of Edge, so
safeCompare now uses crypto.timingSafeEqual instead of manual XOR.
All auth logic, CSRF validation, host matching, and security headers
are preserved unchanged.
* feat: add "Update Available" banner with GitHub release check
Add a dismissible emerald banner that appears when a newer GitHub release
exists, so self-hosting users know an update is available. The banner
dismisses per-version (reappears for new releases).
- Create src/lib/version.ts as single source of truth from package.json
- Add /api/releases/check route with 1hr caching and graceful fallback
- Add UpdateBanner component mirroring LocalModeBanner pattern
- Add update state to Zustand store with localStorage persistence
- Fix hardcoded v2.0 in header-bar.tsx and 2.0.0 in websocket.ts
---
README.md | 2 +-
src/app/[[...panel]]/page.tsx | 20 ++++++++-
src/app/api/releases/check/route.ts | 55 +++++++++++++++++++++++++
src/components/layout/header-bar.tsx | 3 +-
src/components/layout/update-banner.tsx | 39 ++++++++++++++++++
src/lib/version.ts | 6 +++
src/lib/websocket.ts | 3 +-
src/{middleware.ts => proxy.ts} | 16 +++----
src/store/index.ts | 18 ++++++++
9 files changed, 147 insertions(+), 15 deletions(-)
create mode 100644 src/app/api/releases/check/route.ts
create mode 100644 src/components/layout/update-banner.tsx
create mode 100644 src/lib/version.ts
rename src/{middleware.ts => proxy.ts} (92%)
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,