diff --git a/.gitignore b/.gitignore
index 29beeceaa..78bd83fff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.PLAN*
+.worktrees/
# Byte-compiled / optimized / DLL files
__pycache__/
diff --git a/admin/hub_module/frontend/src/contexts/AuthContext.jsx b/admin/hub_module/frontend/src/contexts/AuthContext.jsx
index 84622396c..41a8c64f1 100644
--- a/admin/hub_module/frontend/src/contexts/AuthContext.jsx
+++ b/admin/hub_module/frontend/src/contexts/AuthContext.jsx
@@ -1,5 +1,12 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import api from '../services/api';
+import {
+ isDesktopMode,
+ desktopLogin,
+ desktopLogout,
+ desktopGetToken,
+ desktopGetCurrentUser,
+} from '../services/desktopAdapter';
const AuthContext = createContext(null);
@@ -10,25 +17,57 @@ export function AuthProvider({ children }) {
// Check for existing session on mount
useEffect(() => {
- const token = localStorage.getItem('token');
- if (token) {
- fetchCurrentUser();
- } else {
- setLoading(false);
- }
+ const checkSession = async () => {
+ try {
+ if (isDesktopMode()) {
+ // Desktop: check token in keychain
+ const token = await desktopGetToken();
+ if (token) {
+ await fetchCurrentUser();
+ } else {
+ setLoading(false);
+ }
+ } else {
+ // Browser: check token in localStorage
+ const token = localStorage.getItem('token');
+ if (token) {
+ fetchCurrentUser();
+ } else {
+ setLoading(false);
+ }
+ }
+ } catch (err) {
+ console.error('Session check failed:', err);
+ setLoading(false);
+ }
+ };
+ checkSession();
}, []);
const fetchCurrentUser = async () => {
try {
- const response = await api.get('/api/v1/auth/me');
- if (response.data.success && response.data.user) {
- setUser(response.data.user);
+ if (isDesktopMode()) {
+ // Desktop: use Tauri to fetch current user
+ const user = await desktopGetCurrentUser();
+ if (user) {
+ setUser(user);
+ } else {
+ // TODO: Clear token from keychain if /auth/me fails (M3)
+ }
} else {
- localStorage.removeItem('token');
+ // Browser: use axios
+ const response = await api.get('/api/v1/auth/me');
+ if (response.data.success && response.data.user) {
+ setUser(response.data.user);
+ } else {
+ localStorage.removeItem('token');
+ }
}
} catch (err) {
console.error('Failed to fetch user:', err);
- localStorage.removeItem('token');
+ if (!isDesktopMode()) {
+ localStorage.removeItem('token');
+ }
} finally {
setLoading(false);
}
@@ -49,17 +88,33 @@ export function AuthProvider({ children }) {
const login = useCallback(async (email, password) => {
try {
setError(null);
- const response = await api.post('/api/v1/auth/login', { email, password });
- if (response.data.success) {
- localStorage.setItem('token', response.data.token);
- setUser(response.data.user);
- return response.data;
- }
- // Handle requires verification response (403 with requiresVerification)
- if (response.data.requiresVerification) {
- const error = new Error(response.data.message || 'Email verification required');
- error.requiresVerification = true;
- throw error;
+
+ if (isDesktopMode()) {
+ // Desktop: use Tauri login command
+ const response = await desktopLogin(email, password);
+ if (response.success) {
+ // Token is stored in keychain; fetch full user data
+ const user = await desktopGetCurrentUser();
+ if (user) {
+ setUser(user);
+ }
+ return { success: true, user: response.user };
+ }
+ throw new Error('Login failed');
+ } else {
+ // Browser: use axios
+ const response = await api.post('/api/v1/auth/login', { email, password });
+ if (response.data.success) {
+ localStorage.setItem('token', response.data.token);
+ setUser(response.data.user);
+ return response.data;
+ }
+ // Handle requires verification response (403 with requiresVerification)
+ if (response.data.requiresVerification) {
+ const error = new Error(response.data.message || 'Email verification required');
+ error.requiresVerification = true;
+ throw error;
+ }
}
} catch (err) {
// Check for verification required in error response
@@ -129,18 +184,33 @@ export function AuthProvider({ children }) {
const handleOAuthCallback = useCallback(async (token) => {
if (token) {
- localStorage.setItem('token', token);
- await fetchCurrentUser();
+ if (isDesktopMode()) {
+ // TODO: Desktop OAuth callback → store token in keychain + fetch user (M2)
+ console.warn('[AuthContext] OAuth callback in desktop mode not yet implemented');
+ // For now, redirect to login with error
+ window.location.href = '/login?error=oauth_not_implemented';
+ } else {
+ localStorage.setItem('token', token);
+ await fetchCurrentUser();
+ }
}
}, []);
const logout = useCallback(async () => {
try {
- await api.post('/api/v1/auth/logout');
+ if (isDesktopMode()) {
+ // Desktop: use Tauri logout command (clears keychain)
+ await desktopLogout();
+ } else {
+ // Browser: use axios
+ await api.post('/api/v1/auth/logout');
+ }
} catch (err) {
console.error('Logout error:', err);
} finally {
- localStorage.removeItem('token');
+ if (!isDesktopMode()) {
+ localStorage.removeItem('token');
+ }
setUser(null);
}
}, []);
diff --git a/admin/hub_module/frontend/src/services/api.js b/admin/hub_module/frontend/src/services/api.js
index 315ca1a30..59ec8e937 100644
--- a/admin/hub_module/frontend/src/services/api.js
+++ b/admin/hub_module/frontend/src/services/api.js
@@ -1,4 +1,5 @@
import axios from 'axios';
+import { isDesktopMode, desktopRequest, desktopGetToken } from './desktopAdapter';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
@@ -10,7 +11,13 @@ const api = axios.create({
// Request interceptor to add auth token
api.interceptors.request.use(
- (config) => {
+ async (config) => {
+ // Desktop mode: token injected server-side, no need to add here
+ if (isDesktopMode()) {
+ return config;
+ }
+
+ // Browser mode: add token from localStorage
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -20,6 +27,19 @@ api.interceptors.request.use(
(error) => Promise.reject(error)
);
+// Custom adapter for desktop mode
+const desktopAdapter = async (config) => {
+ if (!isDesktopMode()) {
+ throw new Error('desktopAdapter called outside desktop mode');
+ }
+ return desktopRequest(config);
+};
+
+// Set adapter based on mode
+if (isDesktopMode()) {
+ api.defaults.adapter = desktopAdapter;
+}
+
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
@@ -27,10 +47,17 @@ api.interceptors.response.use(
const originalRequest = error.config;
// Handle 401 errors (token expired)
+ // TODO: In desktop mode, 401 → token refresh should be handled by Rust proxy (M3)
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
+ // Desktop mode: token refresh handled server-side; just retry
+ if (isDesktopMode()) {
+ return api(originalRequest);
+ }
+
+ // Browser mode: refresh via HTTP
const response = await api.post('/api/v1/auth/refresh');
if (response.data.success) {
localStorage.setItem('token', response.data.token);
@@ -38,8 +65,15 @@ api.interceptors.response.use(
return api(originalRequest);
}
} catch (refreshError) {
- localStorage.removeItem('token');
- window.location.href = '/login';
+ if (isDesktopMode()) {
+ // Desktop: redirect to login (token is invalid in keychain)
+ // TODO: Emit a logout event so AuthContext can clear state (M3)
+ window.location.href = '/login';
+ } else {
+ // Browser: clear localStorage and redirect
+ localStorage.removeItem('token');
+ window.location.href = '/login';
+ }
return Promise.reject(refreshError);
}
}
diff --git a/admin/hub_module/frontend/src/services/desktopAdapter.js b/admin/hub_module/frontend/src/services/desktopAdapter.js
new file mode 100644
index 000000000..b13b98845
--- /dev/null
+++ b/admin/hub_module/frontend/src/services/desktopAdapter.js
@@ -0,0 +1,150 @@
+/**
+ * Desktop mode adapter — routes API calls through Tauri when in desktop context
+ * Detects Tauri at runtime and provides a transport abstraction for both browser and desktop paths
+ * Browser: axios + localStorage (unchanged)
+ * Desktop: Tauri invoke('api_request') + keychain (token never in JS)
+ */
+
+/**
+ * Get Tauri invoke function — uses global Tauri API available in webview
+ * The desktop app injects __TAURI__ when loading the webui
+ */
+function getInvoke() {
+ // Tauri v2 makes invoke available on window.__TAURI__
+ if (typeof window !== 'undefined' && window.__TAURI__?.invoke) {
+ return window.__TAURI__.invoke;
+ }
+
+ // Fallback: check internal API
+ if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__?.commands?.invoke) {
+ return window.__TAURI_INTERNALS__.commands.invoke;
+ }
+
+ throw new Error('Tauri invoke not available — not running in Tauri desktop context');
+}
+
+/**
+ * Detect if running inside Tauri webview
+ * Check for __TAURI__ globals injected by Tauri v2
+ */
+export function isDesktopMode() {
+ if (typeof window === 'undefined') return false;
+ // Check for Tauri globals that indicate we're in the Tauri webview
+ return !!(
+ window.__TAURI__ ||
+ window.__TAURI_INTERNALS__ ||
+ window.__TAURI_METADATA__
+ );
+}
+
+/**
+ * Desktop transport adapter — replaces axios for desktop mode
+ * Calls Tauri's api_request command (Rust-proxied, token injected server-side)
+ */
+export async function desktopRequest(config) {
+ if (!isDesktopMode()) {
+ throw new Error('desktopRequest called outside desktop mode');
+ }
+
+ const invoke = getInvoke();
+ const method = config.method?.toUpperCase() || 'GET';
+ const path = config.url || '';
+ const body = config.data ? JSON.stringify(config.data) : null;
+
+ console.log(`[desktopAdapter] ${method} ${path}`);
+
+ try {
+ const response = await invoke('api_request', {
+ method,
+ path,
+ body
+ });
+
+ // Response: { status, body }
+ return {
+ data: response.body ? JSON.parse(response.body) : null,
+ status: response.status,
+ statusText: response.status < 400 ? 'OK' : 'Error',
+ headers: {},
+ config,
+ request: {}
+ };
+ } catch (err) {
+ console.error(`[desktopAdapter] Error: ${err}`);
+ const error = new Error(err);
+ error.response = {
+ status: 0,
+ data: { error: err },
+ statusText: 'Transport Error'
+ };
+ throw error;
+ }
+}
+
+/**
+ * Desktop auth adapter — Tauri commands for token storage + login/logout
+ * Token stored in OS keychain; login/logout via Tauri commands
+ */
+export async function desktopLogin(email, password) {
+ const invoke = getInvoke();
+
+ console.log('[desktopAdapter] login');
+ try {
+ const response = await invoke('login', { email, password });
+ // Rust returns { email, role, success }; token is in keychain
+ return {
+ success: response.success,
+ email: response.email,
+ role: response.role,
+ user: {
+ email: response.email,
+ roles: response.role ? [response.role] : ['user']
+ }
+ };
+ } catch (err) {
+ console.error(`[desktopAdapter] login error: ${err}`);
+ throw new Error(err);
+ }
+}
+
+export async function desktopLogout() {
+ const invoke = getInvoke();
+
+ console.log('[desktopAdapter] logout');
+ try {
+ await invoke('logout');
+ } catch (err) {
+ console.error(`[desktopAdapter] logout error: ${err}`);
+ // Non-fatal: proceed with logout even if keychain clear fails
+ }
+}
+
+export async function desktopGetToken() {
+ const invoke = getInvoke();
+
+ try {
+ return await invoke('get_token');
+ } catch (err) {
+ console.error(`[desktopAdapter] get_token error: ${err}`);
+ return null;
+ }
+}
+
+export async function desktopGetCurrentUser() {
+ const invoke = getInvoke();
+
+ console.log('[desktopAdapter] fetching current user');
+ try {
+ const response = await invoke('api_request', {
+ method: 'GET',
+ path: '/api/v1/auth/me',
+ body: null
+ });
+
+ const userData = response.body ? JSON.parse(response.body) : null;
+ return userData?.user || userData;
+ } catch (err) {
+ console.error(`[desktopAdapter] get current user error: ${err}`);
+ return null;
+ }
+}
diff --git a/desktop/.env.example b/desktop/.env.example
new file mode 100644
index 000000000..5ff4dde53
--- /dev/null
+++ b/desktop/.env.example
@@ -0,0 +1,13 @@
+# Waddlebot Desktop Configuration
+
+# Hub base URL (default: https://waddles.app)
+# For self-hosted or beta environments:
+# HUB_BASE=https://waddlebot.example.com
+HUB_BASE=https://waddles.app
+
+# Frontend environment (passed to React build)
+# VITE_API_URL will be set based on HUB_BASE
+VITE_DESKTOP=1
+
+# Logging level
+RUST_LOG=waddlebot_desktop=debug,tauri=info
diff --git a/desktop/Dockerfile b/desktop/Dockerfile
new file mode 100644
index 000000000..8aa563703
--- /dev/null
+++ b/desktop/Dockerfile
@@ -0,0 +1,64 @@
+# Multi-stage build for waddlebot-desktop (Tauri v2)
+# System deps for full Tauri build (linux/amd64 + linux/arm64)
+
+FROM debian:bookworm-slim@sha256:5d3a6ef55f3c75c81cea39dcc1cb75a8a36b91de5e2a0cf1f1c7cc31b6cbaef9 AS builder
+
+# Install system dependencies for Tauri + Rust + Node
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ curl \
+ libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
+ libssl-dev \
+ libappindicator3-dev \
+ libsoup-3.0-dev \
+ ca-certificates \
+ git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Rust (pinned toolchain via rust-toolchain.toml)
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none
+ENV PATH="/root/.cargo/bin:${PATH}"
+
+# Install Node.js
+RUN curl -fsSL https://deb.nodesource.com/setup_26.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy source
+COPY . .
+
+# Build frontend
+WORKDIR /app/admin/hub_module/frontend
+RUN npm ci && npm run build
+
+# Build Tauri app
+WORKDIR /app/desktop
+RUN npm ci
+RUN cargo build --release --locked
+
+# Final image (runtime only)
+FROM debian:bookworm-slim@sha256:5d3a6ef55f3c75c81cea39dcc1cb75a8a36b91de5e2a0cf1f1c7cc31b6cbaef9
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libwebkit2gtk-4.1-0 \
+ libgtk-3-0 \
+ libssl3 \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN groupadd -r appuser && useradd -r -g appuser appuser
+WORKDIR /app
+
+COPY --from=builder --chown=appuser:appuser /app/target/release/waddlebot-desktop /app/waddlebot-desktop
+COPY --from=builder --chown=appuser:appuser /app/admin/hub_module/frontend/dist /app/frontend/dist
+
+USER appuser
+
+# Health check: verify binary exists and is executable
+HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+ CMD test -x /app/waddlebot-desktop || exit 1
+
+ENTRYPOINT ["/app/waddlebot-desktop"]
diff --git a/desktop/README.md b/desktop/README.md
new file mode 100644
index 000000000..58b8edea1
--- /dev/null
+++ b/desktop/README.md
@@ -0,0 +1,271 @@
+# Waddlebot Desktop Client (Tauri v2) — MVP Auth Core
+
+**Status:** M1 (Auth Core) — Tauri command handlers implemented, token storage + API proxy complete. Unit tests cover keychain logic. Full GUI build pending system deps.
+
+## Overview
+
+A Tauri v2 desktop app that packages the existing Vite + React webui (`admin/hub_module/frontend/`) and adds secure Rust-side token storage + HTTP proxy for authenticated requests to the waddlebot hub.
+
+**Key features (MVP):**
+- Reuses existing React webui (Vite build)
+- OS keychain token storage (never localStorage)
+- Rust-proxied HTTP requests (token never touches JS)
+- Email/password login (OAuth deferred to M2)
+- End-user + community-admin roles (super-admin deferred)
+
+## Architecture
+
+```
+desktop/
+├── src-tauri/ # Rust backend (Tauri + API proxy)
+│ ├── src/
+│ │ ├── main.rs # Tauri app shell (MVP: minimal)
+│ │ ├── lib.rs # Public library exports
+│ │ └── api/
+│ │ ├── mod.rs
+│ │ ├── proxy.rs # HTTP proxy + login/logout logic
+│ │ ├── keychain.rs # Token storage (OS keychain + test backend)
+│ │ └── error.rs # Sanitized error types
+│ ├── Cargo.toml # Rust deps (exact versions, Cargo.lock committed)
+│ ├── rust-toolchain.toml # Rust 1.97.x pinned
+│ └── build.rs # Tauri build integration
+├── src/ # TypeScript frontend (symlink or reuse)
+├── package.json # npm deps (exact versions, package-lock.json committed)
+├── tauri.conf.json # Tauri configuration (webview, CSP, bundle settings)
+├── vite.config.ts # Vite configuration
+├── tsconfig.json # TypeScript configuration
+└── README.md # This file
+```
+
+## Build & Test
+
+### Prerequisites
+
+**What works now (no system deps needed):**
+- Rust library compilation (requires `cargo` + `rustc` 1.97+)
+- Unit tests for keychain + proxy modules
+- Integration tests (InMemoryKeychain, no network calls)
+- npm install + frontend production build
+
+**What requires system deps (deferred to CI):**
+- Full Tauri GUI build (needs webkit2gtk on Linux, Xcode on macOS, MSVC on Windows)
+- Desktop app `cargo tauri build`
+- Multi-arch builds (amd64 + arm64)
+
+### Commands
+
+```bash
+# Generate Cargo.lock (run once if not committed)
+cd desktop/src-tauri
+cargo update
+
+# Build Rust library + run unit + integration tests (no system deps needed)
+cargo test --lib # Unit tests (keychain + proxy + commands)
+cargo test --test integration_test # Integration tests
+
+# Check code quality
+cargo clippy --all-targets -- -D warnings
+cargo fmt --check
+cargo audit # Security scan
+
+# Build frontend (reuses existing Vite build)
+cd desktop
+npm ci
+npm run build:frontend
+
+# Full app build (requires system webkit deps — CI only, after Rust tests pass)
+cargo test # Full test suite including integration
+npm run build:all # desktop + frontend + Tauri GUI shell
+```
+
+## API Proxy & Keychain
+
+**Token Storage:** OS keychain only (macOS Keychain, Windows Credential Manager, Linux Secret Service via `keyring` crate). Never stored in localStorage or plaintext files.
+
+**HTTP Proxy:** `ApiProxy` trait + implementations:
+- Production: `OsKeychain` backend (real keychain access)
+- Testing: `InMemoryKeychain` backend (no keychain required)
+
+**Usage in Rust:**
+```rust
+// Create proxy with OS keychain
+let client = ApiProxy::new(
+ Arc::new(OsKeychain),
+ "https://waddles.app".to_string()
+);
+
+// Make authenticated request
+let req = ApiRequest {
+ method: "GET".to_string(),
+ path: "/communities".to_string(),
+ body: None
+};
+let response = client.request(req).await?;
+```
+
+**Usage in React (Tauri command):**
+```typescript
+// Tauri invokes Rust command — token never touches JS
+const response = await invoke('api_request', {
+ method: 'GET',
+ path: '/communities',
+ body: null
+});
+```
+
+## M1: Auth Core — Complete ✓
+
+**Implemented:**
+- [x] Tauri command handlers for token storage (`store_token`, `get_token`, `clear_token`)
+- [x] `api_request` Tauri command — Rust-proxied REST with keychain token injection
+- [x] `login` command — email/password authentication with sanitized response
+- [x] `logout` command — token clearing
+- [x] Structured logging with sanitization (no tokens/PII in logs)
+- [x] Token never touches JavaScript — stored in OS keychain, injected server-side
+- [x] Unit tests for keychain + token storage logic (InMemoryKeychain backend)
+
+**Command Handlers (Rust → JavaScript):**
+```rust
+// Token management
+store_token(token: String) -> Result<(), String>
+get_token() -> Result