feat: replace mock mode with real backend dev mode

Add --dev flag to `opal server start` that disables auth (injects
userID=1 for all requests) and exposes a /auth/dev-session endpoint,
so the frontend can develop against a real backend without OAuth
config. Remove VITE_MOCK_MODE and all mock data/branches from the
frontend stores. Add scripts/dev.sh to start both services locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 17:07:34 +01:00
parent 80ea17227d
commit d51c6da18d
8 changed files with 111 additions and 468 deletions
+16 -5
View File
@@ -97,20 +97,30 @@ var serverStartCmd = &cobra.Command{
Examples:
opal server start
opal server start --addr :8080
opal server start --dev
opal server start --db /var/lib/opal/opal.db`,
Run: func(cmd *cobra.Command, args []string) {
addr, _ := cmd.Flags().GetString("addr")
dbPath, _ := cmd.Flags().GetString("db")
devMode, _ := cmd.Flags().GetBool("dev")
// Override DB path if specified
if dbPath != "" {
os.Setenv("OPAL_DB_PATH", dbPath)
}
// Validate server configuration
if err := validateServerConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err)
os.Exit(1)
// In dev mode, skip OAuth config validation
if devMode {
fmt.Println("┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓")
fmt.Println("┃ ⚠ DEV MODE ENABLED ⚠ ┃")
fmt.Println("┃ Auth disabled — all requests use uid 1 ┃")
fmt.Println("┃ Do NOT use in production! ┃")
fmt.Println("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛")
} else {
if err := validateServerConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Server configuration validation failed:\n%v\n", err)
os.Exit(1)
}
}
// Load config (read-only — uses defaults if no opal.yml exists)
@@ -127,7 +137,7 @@ Examples:
defer engine.CloseDB()
// Create and start server
server := api.NewServer(addr)
server := api.NewServer(addr, devMode)
if err := server.Start(); err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
os.Exit(1)
@@ -194,6 +204,7 @@ func init() {
serverStartCmd.Flags().StringP("addr", "a", ":8080", "Server address")
serverStartCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
serverStartCmd.Flags().Bool("dev", false, "Enable dev mode (no auth, no OAuth env vars required)")
keygenCmd.Flags().StringP("name", "n", "", "Name for this API key (e.g., device name)")
keygenCmd.Flags().StringP("db", "d", "", "Database path (default: config directory)")
+10
View File
@@ -73,6 +73,16 @@ func GetUserID(r *http.Request) int {
return userID
}
// DevAuthMiddleware always injects userID=1 into context — for local dev only
func DevAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), userIDKey, 1)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// CORSMiddleware adds CORS headers for future web frontend
func CORSMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
+35 -12
View File
@@ -11,15 +11,17 @@ import (
// Server represents the API server
type Server struct {
router chi.Router
addr string
router chi.Router
addr string
devMode bool
}
// NewServer creates a new API server
func NewServer(addr string) *Server {
func NewServer(addr string, devMode bool) *Server {
s := &Server{
router: chi.NewRouter(),
addr: addr,
router: chi.NewRouter(),
addr: addr,
devMode: devMode,
}
s.setupRoutes()
return s
@@ -39,16 +41,37 @@ func (s *Server) setupRoutes() {
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL)
r.Get("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout)
if s.devMode {
// Dev mode: fake session endpoint so the frontend can "log in"
r.Get("/auth/dev-session", func(w http.ResponseWriter, r *http.Request) {
JSON(w, http.StatusOK, map[string]interface{}{
"access_token": "dev-token",
"refresh_token": "",
"expires_at": 9999999999,
"token_type": "bearer",
"user": map[string]interface{}{
"id": 1,
"username": "dev",
"email": "dev@localhost",
},
})
})
} else {
// OAuth endpoints (no auth required)
r.Get("/auth/login", handlers.GetLoginURL)
r.Get("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/callback", handlers.OAuthCallback)
r.Post("/auth/refresh", handlers.RefreshToken)
r.Post("/auth/logout", handlers.Logout)
}
// Protected routes
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware())
if s.devMode {
r.Use(DevAuthMiddleware())
} else {
r.Use(AuthMiddleware())
}
// Tasks
r.Route("/tasks", func(r chi.Router) {
+1 -1
View File
@@ -2,5 +2,5 @@
VITE_API_URL=https://opal.example.com/api
VITE_AUTH_URL=https://auth.example.com
# OAuth
# OAuth — set to "false" for local dev (used with `opal server start --dev`)
VITE_OAUTH_ENABLED=true
-315
View File
@@ -1,315 +0,0 @@
/**
* Mock task data for local development / design review.
* Covers a realistic spread of projects, priorities, tags, due dates, and statuses.
*/
const now = Math.floor(Date.now() / 1000);
const HOUR = 3600;
const DAY = 86400;
/** @type {import('$lib/api/types.js').Task[]} */
export const mockTasks = [
// ── Pending tasks ────────────────────────────────────────────
{
uuid: '11111111-1111-4111-a111-111111111101',
id: 1,
status: 'P',
description: 'Set up Caddy reverse proxy for opal-web',
project: 'Infrastructure',
priority: 3,
created: now - 7 * DAY,
modified: now - 1 * DAY,
start: now - 2 * HOUR,
end: null,
due: now + 2 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['devops', 'selfhosted'],
urgency: 14.2
},
{
uuid: '11111111-1111-4111-a111-111111111102',
id: 2,
status: 'P',
description: 'Write unit tests for task filter parsing',
project: 'Opal',
priority: 2,
created: now - 5 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: now + 5 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['testing', 'backend'],
urgency: 7.3
},
{
uuid: '11111111-1111-4111-a111-111111111103',
id: 3,
status: 'P',
description: 'Fix tag extraction for nested wiki-links',
project: 'Jade',
priority: 2,
created: now - 3 * DAY,
modified: now - 3 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug'],
urgency: 4.1
},
{
uuid: '11111111-1111-4111-a111-111111111104',
id: 4,
status: 'P',
description: 'Grocery run - farmers market',
project: null,
priority: 1,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now + 1 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['errand'],
urgency: 3.5
},
{
uuid: '11111111-1111-4111-a111-111111111105',
id: 5,
status: 'P',
description: 'Design task detail page for opal-web',
project: 'Opal',
priority: 3,
created: now - 2 * DAY,
modified: now - 2 * DAY,
start: null,
end: null,
due: now - 1 * DAY, // overdue
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend', 'design'],
urgency: 15.8
},
{
uuid: '11111111-1111-4111-a111-111111111106',
id: 6,
status: 'P',
description: 'Renew domain registration for jnss.me',
project: 'Infrastructure',
priority: 1,
created: now - 14 * DAY,
modified: now - 14 * DAY,
start: null,
end: null,
due: now + 30 * DAY,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['admin'],
urgency: 2.4
},
{
uuid: '11111111-1111-4111-a111-111111111107',
id: 7,
status: 'P',
description: 'Add recurrence UI to task creation form',
project: 'Opal',
priority: 1,
created: now - 4 * DAY,
modified: now - 4 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['frontend'],
urgency: 2.9
},
{
uuid: '11111111-1111-4111-a111-111111111108',
id: 8,
status: 'P',
description: 'Migrate Nextcloud to latest stable',
project: 'Infrastructure',
priority: 0,
created: now - 10 * DAY,
modified: now - 10 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['selfhosted', 'maintenance'],
urgency: 1.6
},
{
uuid: '11111111-1111-4111-a111-111111111109',
id: 9,
status: 'P',
description: 'Read "Designing Data-Intensive Applications" ch. 7',
project: null,
priority: 0,
created: now - 6 * DAY,
modified: now - 6 * DAY,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['reading', 'learning'],
urgency: 1.2
},
{
uuid: '11111111-1111-4111-a111-111111111110',
id: 10,
status: 'P',
description: 'Review PR: sync conflict resolution strategy',
project: 'Opal',
priority: 2,
created: now - 1 * DAY,
modified: now - 1 * DAY,
start: null,
end: null,
due: now, // due today
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['review', 'backend'],
urgency: 10.5
},
// ── Completed tasks ──────────────────────────────────────────
{
uuid: '22222222-2222-4222-a222-222222222201',
id: 11,
status: 'C',
description: 'Implement XDG directory support',
project: 'Opal',
priority: 2,
created: now - 14 * DAY,
modified: now - 7 * DAY,
start: null,
end: now - 7 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['backend', 'refactor'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222202',
id: 12,
status: 'C',
description: 'Set up Authentik OAuth provider',
project: 'Infrastructure',
priority: 3,
created: now - 21 * DAY,
modified: now - 10 * DAY,
start: null,
end: now - 10 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['auth', 'selfhosted'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222203',
id: 13,
status: 'C',
description: 'Build setup wizard for first-run',
project: 'Opal',
priority: 2,
created: now - 10 * DAY,
modified: now - 5 * DAY,
start: null,
end: now - 5 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['ux', 'backend'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222204',
id: 14,
status: 'C',
description: 'Fix PersistentPreRun initialization order',
project: 'Opal',
priority: 3,
created: now - 8 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['bug', 'backend'],
urgency: 0
},
{
uuid: '22222222-2222-4222-a222-222222222205',
id: 15,
status: 'C',
description: 'Write deployment guide with Caddy config',
project: 'Opal',
priority: 1,
created: now - 9 * DAY,
modified: now - 6 * DAY,
start: null,
end: now - 6 * DAY,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: ['docs'],
urgency: 0
}
];
+14 -13
View File
@@ -17,17 +17,18 @@ import { getItem, setItem, removeItem } from '$lib/utils/storage.js';
*/
const STORAGE_KEY = 'opal_auth';
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
const DEV_MODE = import.meta.env.VITE_OAUTH_ENABLED === 'false';
/**
* Load auth state from localStorage
* @returns {AuthState}
*/
function loadAuth() {
// In mock mode, always return authenticated
if (MOCK_MODE) {
// In dev mode, auto-authenticate with a dev user.
// API requests still go to the real backend (which runs with auth disabled).
if (DEV_MODE) {
return {
accessToken: 'mock-token',
accessToken: 'dev-token',
refreshToken: '',
expiresAt: 9999999999,
user: { id: 1, username: 'dev', email: 'dev@localhost' },
@@ -73,10 +74,10 @@ function loadAuth() {
*/
function createAuthStore() {
const { subscribe, set, update } = writable(loadAuth());
return {
subscribe,
/**
* Set authentication tokens
* @param {AuthTokens} tokens
@@ -90,15 +91,15 @@ function createAuthStore() {
expiresAt: tokens.expires_at,
isAuthenticated: true
};
if (browser) {
setItem(STORAGE_KEY, newState);
}
return newState;
});
},
/**
* Set user info
* @param {User} user
@@ -112,7 +113,7 @@ function createAuthStore() {
return newState;
});
},
/**
* Set full auth data (tokens + user)
* @param {AuthTokens & {user: User}} data
@@ -125,14 +126,14 @@ function createAuthStore() {
user: data.user,
isAuthenticated: true
};
if (browser) {
setItem(STORAGE_KEY, newState);
}
set(newState);
},
/**
* Clear auth (logout)
*/
+3 -122
View File
@@ -1,35 +1,18 @@
import { writable, derived } from 'svelte/store';
import { tasks as tasksAPI } from '$lib/api/endpoints.js';
import { queueChange } from '$lib/utils/sync-queue.js';
import { generateUUID } from '$lib/utils/uuid.js';
/**
* @typedef {import('$lib/api/types.js').Task} Task
* @typedef {import('$lib/api/types.js').TaskFilters} TaskFilters
*/
const MOCK_MODE = import.meta.env.VITE_MOCK_MODE === 'true';
/** Report names that map to pending tasks in mock mode */
const PENDING_REPORTS = new Set(['list', 'next', 'active', 'ready', 'overdue', 'waiting', 'newest', 'oldest']);
/**
* Create tasks store
*/
function createTasksStore() {
const { subscribe, set, update } = writable(/** @type {Task[]} */ ([]));
/** @type {Task[]} */
let mockData = [];
/** Ensure mock data is loaded */
async function ensureMockData() {
if (mockData.length === 0) {
const { mockTasks } = await import('$lib/mock/tasks.js');
mockData = [...mockTasks];
}
}
return {
subscribe,
@@ -38,18 +21,6 @@ function createTasksStore() {
* @param {string} reportName - Backend report name (e.g. 'list', 'next', 'completed')
*/
async loadReport(reportName) {
if (MOCK_MODE) {
await ensureMockData();
if (reportName === 'completed') {
set(mockData.filter(t => t.status === 'C'));
} else if (PENDING_REPORTS.has(reportName)) {
set(mockData.filter(t => t.status === 'P'));
} else {
set(mockData.filter(t => t.status === 'P'));
}
return;
}
try {
const tasks = await tasksAPI.listByReport(reportName);
set(tasks);
@@ -65,55 +36,9 @@ function createTasksStore() {
* @returns {Promise<Task>}
*/
async parseAndCreate(input) {
if (MOCK_MODE) {
await ensureMockData();
// Naive parse: non-modifier words become description
const words = input.split(/\s+/);
const descWords = [];
const task = /** @type {Task} */ ({
uuid: generateUUID(),
id: mockData.length + 1,
status: 'P',
description: '',
project: null,
priority: 1,
created: Date.now() / 1000,
modified: Date.now() / 1000,
start: null,
end: null,
due: null,
scheduled: null,
wait: null,
until: null,
recurrence_duration: null,
parent_uuid: null,
tags: []
});
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.startsWith('project:')) {
task.project = w.slice(8);
} else if (w.startsWith('priority:') || w.startsWith('pri:')) {
const val = w.includes(':') ? w.split(':')[1] : '1';
/** @type {Record<string, number>} */
const map = { H: 3, M: 2, L: 0, h: 3, m: 2, l: 0 };
task.priority = /** @type {import('$lib/api/types.js').TaskPriority} */ ((map[val] ?? parseInt(val, 10)) || 1);
} else if (w.startsWith('+')) {
task.tags = [...(task.tags || []), w.slice(1)];
} else {
descWords.push(w);
}
}
task.description = descWords.join(' ') || 'New task';
mockData = [task, ...mockData];
update(tasks => [task, ...tasks]);
return task;
}
try {
const created = await tasksAPI.parse(input);
const result = await tasksAPI.parse(input);
const created = result.task ?? result;
update(tasks => [created, ...tasks]);
return created;
} catch (error) {
@@ -123,20 +48,10 @@ function createTasksStore() {
},
/**
* Load all tasks from API (or mock data in dev)
* Load all tasks from API
* @param {TaskFilters} [filters]
*/
async load(filters = {}) {
if (MOCK_MODE) {
await ensureMockData();
let filtered = mockData;
if (filters.status) {
filtered = filtered.filter(t => t.status === filters.status);
}
set(filtered);
return;
}
try {
const tasks = await tasksAPI.list(filters);
set(tasks);
@@ -151,13 +66,6 @@ function createTasksStore() {
* @param {Partial<Task>} task
*/
async add(task) {
if (MOCK_MODE) {
const fullTask = /** @type {Task} */ ({ ...task, id: mockData.length + 1 });
mockData = [...mockData, fullTask];
update(tasks => [...tasks, fullTask]);
return fullTask;
}
try {
const created = await tasksAPI.create(task);
update(tasks => [...tasks, created]);
@@ -191,14 +99,6 @@ function createTasksStore() {
return tasks;
});
if (MOCK_MODE) {
const index = mockData.findIndex(t => t.uuid === uuid);
if (index >= 0) {
mockData[index] = { ...mockData[index], ...updates, modified: Date.now() / 1000 };
}
return;
}
try {
const updated = await tasksAPI.update(uuid, updates);
// Sync with server response
@@ -228,11 +128,6 @@ function createTasksStore() {
// Optimistic removal
update(tasks => tasks.filter(t => t.uuid !== uuid));
if (MOCK_MODE) {
mockData = mockData.filter(t => t.uuid !== uuid);
return;
}
try {
await tasksAPI.delete(uuid);
} catch (error) {
@@ -250,20 +145,6 @@ function createTasksStore() {
* @param {string} uuid
*/
async complete(uuid) {
if (MOCK_MODE) {
const mi = mockData.findIndex(t => t.uuid === uuid);
if (mi >= 0) {
mockData[mi] = {
...mockData[mi],
status: /** @type {'P'|'C'} */ ('C'),
end: Date.now() / 1000,
modified: Date.now() / 1000
};
}
update(tasks => tasks.filter(t => t.uuid !== uuid));
return;
}
try {
await tasksAPI.complete(uuid);
update(tasks => tasks.filter(t => t.uuid !== uuid));
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
# Start backend and frontend for local development.
# Backend runs with --dev (auth disabled), frontend points at localhost:8080.
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cleanup() {
echo ""
echo "Shutting down..."
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Start backend
echo "Starting backend (dev mode) on :8080..."
cd "$ROOT/opal-task"
go run . server start --dev --addr :8080 &
BACKEND_PID=$!
# Give the backend a moment to start before launching the frontend
sleep 1
# Start frontend
echo "Starting frontend..."
cd "$ROOT/opal-web"
bun run dev &
FRONTEND_PID=$!
wait