From 3157e6102a4e93e2ce1e1631f0936f39f9a3fa17 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sun, 12 Jul 2026 11:49:27 +0200 Subject: [PATCH] plans: Wails desktop app with client/server split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 separates the SPA from the oikos binary (delete embed.go, add CORS, make API base URL configurable, add auth interceptor, close dev-open gate). Phase 1 builds a thin Wails v3 desktop wrapper — native window + tray + notifications + auto-start + auto-update. SPA shared between browser and desktop builds. --- plans/2026-07-12-wails-desktop-app.md | 678 ++++++++++++++++++++++++++ plans/index.md | 1 + 2 files changed, 679 insertions(+) create mode 100644 plans/2026-07-12-wails-desktop-app.md diff --git a/plans/2026-07-12-wails-desktop-app.md b/plans/2026-07-12-wails-desktop-app.md new file mode 100644 index 0000000..01574ed --- /dev/null +++ b/plans/2026-07-12-wails-desktop-app.md @@ -0,0 +1,678 @@ +# 2026-07-12 — Wails desktop application + +**Status:** Planned — not started + +## Goal + +Transform the Oikos control room into a native desktop application using +[Wails](https://wails.io), built on top of a clean client/server split. The +server (API, MCP, scheduler, notifier, Nomos) stays on the homelab as a +long-running service. The client (SPA) is separated from the server binary and +deployed independently — any browser talks to the server over HTTP. The Wails +app is a thin native client: it loads the same SPA in a webview, configured +with the server URL and auth token, and adds system tray, native notifications, +auto-start, and auto-update. + +--- + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ Server (homelab, permanent) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ oikos api │ │ oikos sched │ │ oikos notif │ │ +│ │ :8090 │ │ (observe) │ │ (Matrix) │ │ +│ │ REST + SSE │ └──────────────┘ └──────────────┘ │ +│ │ MCP /mcp │ │ +│ └──────┬───────┘ ┌──────────────┐ │ +│ │ │ nomos serve │ │ +│ ├──────────┤ :8092 │ │ +│ │ MCP │ /agent/* │ │ +│ │ └──────┬───────┘ │ +│ │ │ │ +│ └────────┬────────┘ │ +│ ┌──────▼──────┐ │ +│ │ Postgres │ │ +│ └─────────────┘ │ +│ │ +│ Caddy: /api/* → :8090 /agent/* → :8092 /mcp → :8090 │ +│ / → static SPA (web/dist/) │ +└───────────────────────┬────────────────────────────────────┘ + │ HTTPS (bearer auth or OIDC) + ┌───────────────┼───────────────┐ + │ │ │ +┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ +│ Browser │ │ Wails app │ │ CLI/mobile │ +│ (SPA at /) │ │ (SPA in │ │ (future) │ +│ │ │ webview) │ │ │ +└──────────────┘ └─────────────┘ └─────────────┘ +``` + +### Server + +The existing server roles (`oikos api`, `oikos scheduler`, `oikos notifier`, +`nomos serve`, Postgres) run on the homelab mac-mini as systemd services — +unchanged. The SPA is no longer embedded in the `oikos` binary; it's a +standalone static build served by Caddy at `/`. The API routes (`/api/v1/*`, +`/mcp`, `/healthz`) don't conflict with root, and the old root redirect is +removed, so no path prefix is needed. + +### Clients + +Any HTTP client that speaks the REST API + bearer auth. The SPA is the +canonical client, deployed as static files. The Wails app wraps the same SPA +in a native webview. Future clients (CLI, mobile) use the same API. + +--- + +## Stack + +**Server:** existing Go code in `internal/` — no changes. `cmd/oikos` removes +the SPA embed and `/ui/*` routes. Caddy serves `web/dist/` at `/` with SPA +fallback. + +**SPA:** existing Svelte 5 + Vite + Tailwind 4 + shadcn-svelte in `web/`. +API base URL and auth token become runtime-configurable. `base: '/'` — no +path prefix needed since the SPA is served at root. + +**Desktop:** Wails v3 (Go + webview). The Wails app is a thin shell: +- Embeds the SPA as static assets (Wails's `go:embed`-based asset system) +- Reads server URL + token from OS keychain at startup, injects into webview +- SPA talks to the remote server over HTTPS — same as the browser +- No Go backend, no Postgres connection, no bundled sidecars +- Native shell: system tray, notifications, auto-start, auto-update, + window persistence + +--- + +## Phase 0 — Client/server split + +This phase separates the SPA from the `oikos` binary and makes it a +standalone client. The Wails app depends on this split being done first. + +### 0.1 — Remove SPA embed from the server + +- **Delete `web/embed.go`** — the server no longer embeds `web/dist/`. +- **`cmd/oikos/main.go`** — remove `uiHandler()` (~35 lines). The + `httpapi.ListenAndServe()` signature no longer takes a `uiHandler` param; + pass `nil` and handle nil in `server.go`. +- **`internal/httpapi/server.go`** — remove the `/ui/*` and `/ui` routes + (~15 lines at `server.go:175-182`), and the root redirect to `/ui/` + (`server.go:183-185`). +- **`web/dist/.gitkeep`** — delete (no longer needed to keep backend-only + builds green). +- **Dockerfile** — remove the node/ui-builder stage and `COPY --from=` of + `web/dist/`. The 3-stage Dockerfile (node → go → runtime) becomes a + 2-stage build (go → runtime). ~20 lines deleted. + +~80 lines deleted. The `oikos api` binary is now API-only: REST, SSE, MCP, +healthz. + +### 0.2 — Make SPA API base URL configurable and add auth interceptor + +The SPA currently hardcodes relative paths and has no auth headers: +```ts +// web/src/lib/api.ts +const BASE = '/agent' +const API = '/api/v1' +``` + +Replace with a runtime-configuration module (`web/src/lib/config.ts`): + +```ts +// web/src/lib/config.ts + +interface OikosConfig { + apiUrl: string // e.g. "https://oikos.hubris.network" + token?: string // bearer token for auth +} + +declare global { + interface Window { + __OIKOS_CONFIG__?: OikosConfig + } +} + +let cfg: OikosConfig | undefined + +export function initConfig(override?: OikosConfig) { + cfg = override ?? window.__OIKOS_CONFIG__ + if (cfg?.token) { + localStorage.setItem('oikos_token', cfg.token) + if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl) + } +} + +export function getConfig(): OikosConfig { + if (!cfg) { + const token = localStorage.getItem('oikos_token') + const apiUrl = localStorage.getItem('oikos_api_url') + if (token || apiUrl) { + cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined } + } + } + return cfg ?? { apiUrl: '' } +} + +export function isConfigured(): boolean { + const c = getConfig() + return !!c.apiUrl && !!c.token +} + +// Relative paths are used in dev (Vite proxy) and when the SPA shares an +// origin with the API server (Caddy reverse proxy). Absolute paths are used +// when the API server is on a different origin (Wails webview, remote access). +function apiBase(path: string): string { + const c = getConfig() + if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy + return `${c.apiUrl}${path}` +} + +// ---- Auth fetch wrapper ---- +// Replaces every raw fetch() call in api.ts. Prepends the API base URL +// (absolute when configured, relative when unset for Vite dev proxy) and +// adds the Authorization header. + +async function fetchWithAuth(path: string, opts?: RequestInit): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...(opts?.headers as Record ?? {}), + } + const c = getConfig() + if (c.token) { + headers['Authorization'] = `Bearer ${c.token}` + } + + return fetch(apiBase(path), { ...opts, headers }) +} + +// SSE path builder — EventSource doesn't take headers, so pass the token +// as a query parameter (the SSE handler in server.go checks it alongside +// the Authorization header). +export function sseUrl(path: string): string { + const c = getConfig() + const url = apiBase(path) + if (!c.token) return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}token=${encodeURIComponent(c.token)}` +} + +// Export for api.ts to use throughout +export { fetchWithAuth, apiBase } +``` + +Then `web/src/lib/api.ts` — replace every `fetch(...)` call with +`fetchWithAuth(...)`. Example: +```ts +// Before: +// const res = await fetch(`${API}/entities?${params}`) +// After: +import { fetchWithAuth, apiBase } from './config' +const API = apiBase('/api/v1') +const BASE = apiBase('/agent') +// ... +const res = await fetchWithAuth(`/api/v1/entities?${params}`) +``` + +**`web/src/lib/stores/events.ts`** — replace `new EventSource(...)` with +`new EventSource(sseUrl(...))`: +```ts +import { sseUrl } from '$lib/config' + +// Before: +// source = new EventSource('/api/v1/events/stream') +// After: +source = new EventSource(sseUrl('/api/v1/events/stream')) +``` + +**`web/src/lib/stores/chat.ts`** — the chat SSE is POST + ReadableStream via +`fetch()`, which already goes through `streamChat` in `api.ts`. When the plan +says "replace every fetch(...) call", `streamChat` is included — the POST to +`/agent/chat` becomes `fetchWithAuth('/agent/chat', ...)`. + +**`web/src/lib/stores/context.ts`** — `refreshContext()` calls +`fetchDashboardSummary()` and `fetchApprovals()` from `api.ts`. Those +already go through `fetchWithAuth`. No change needed here. + +**`web/src/lib/stores/workspace.ts`** — calls `fetchPlan()` and +`fetchQuestions()` from `api.ts`. No change needed. + +**`web/src/main.ts`** — call `initConfig()` before mounting the app: +```ts +import { initConfig, isConfigured } from '$lib/config' + +initConfig() + +const app = mount(isConfigured() ? App : Setup, { + target: document.getElementById('app')! +}) +export default app +``` + +This is the single largest frontend change: ~40 `fetch()` calls spread across +`api.ts` (all routes), `stores/events.ts` (EventSource), and the chat stream. +Each gets replaced with `fetchWithAuth()` or `sseUrl()`. + +**`web/vite.config.ts`** — `base: '/'` (remove `/ui/` prefix, since the SPA +is served at root after the split). The dev proxy stays — same origin in dev +means relative paths work. After 0.4 closes the dev-open auth gate, inject +the token via a `configure` hook: + +```ts +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [tailwindcss(), svelte()], + base: '/', + resolve: { + alias: { $lib: '/src/lib' } + }, + build: { + outDir: 'dist', + emptyOutDir: true + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8090', + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + const token = process.env.OIKOS_API_TOKEN + if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) + }) + } + }, + '/agent': { + target: 'http://localhost:8092', + rewrite: (path) => path.replace(/^\/agent/, ''), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + const token = process.env.OIKOS_API_TOKEN + if (token) proxyReq.setHeader('Authorization', `Bearer ${token}`) + }) + } + } + } + } +}) +``` + +### 0.3 — Add SSE query-param auth and CORS to the API server + +**SSE auth.** The SSE handler at `/api/v1/events/stream` currently relies on +`combinedAuth` middleware for bearer token validation. `EventSource` can't +send custom headers, so the SPA passes the token as a query param +(`?token=...`). The SSE handler needs to extract and validate it. + +**`internal/httpapi/sse.go`** — in `serveSSE`, before using the context's +actor, check for a query-param token: +```go +func (s *Server) serveSSE(w http.ResponseWriter, r *http.Request) { + // If combinedAuth didn't set an actor (no Authorization header — + // EventSource can't send one), try the query param. + if GetActor(r.Context()) == nil { + token := r.URL.Query().Get("token") + if token != "" { + validateStaticToken(s.cfg, r, token) + } + } + // ... rest of SSE handler +} +``` + +Extract the static-token validation from `combinedAuth` into a shared helper +so both the middleware and the SSE handler use the same logic. + +**CORS middleware.** Add CORS to the chi router. This is needed for Wails +(webview origin differs from the remote server) and local dev (Vite on +`:5173` vs server on `:8090`). For the browser production deployment (Caddy +serves both SPA and API from the same origin), it's a no-op. + +**`internal/httpapi/server.go`** — add before the auth middleware: +```go +r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{cfg.CORSAllowedOrigin}, + AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "If-Match"}, + AllowCredentials: true, + MaxAge: 86400, +})) +``` + +**`internal/config/config.go`** — add `CORSAllowedOrigin string`, populated +from `OIKOS_CORS_ORIGIN`. Default: `"*"` in dev, the Caddy site URL in prod. + +~30 lines added. + +### 0.4 — Auth: close the dev-open gate + +Currently `combinedAuth` opens the gate when `OIKOS_ENV=dev` and no tokens +are set (`server.go:228`). After the split, a client from any origin can hit +the API — the dev-open path is a security hole. + +- **Remove the `devOpen` path** from `combinedAuth` — every request must + carry a valid bearer token (via `Authorization` header or `?token=` query + param for SSE). +- **For local dev:** set `OIKOS_API_TOKEN=dev-token` and the SPA reads it + from `OIKOS_API_TOKEN` env var (Vite injects it into + `window.__OIKOS_CONFIG__` at dev time, and the Vite proxy forwards it). +- **Browser (production):** the SPA's `Config.svelte` page accepts a static + token (stored in `localStorage`). OIDC login flows are a follow-on + milestone. +- **Desktop (production):** the Wails app reads the token from the OS + keychain and injects it into `window.__OIKOS_CONFIG__` before the webview + loads. + +### 0.5 — SPA config page (first-launch / setup) + +The SPA needs a page for entering the server URL and auth token on first +launch. This page also serves as the foundation for future OIDC login. + +New file `web/src/pages/Config.svelte`: +- Two fields: "Server URL" (text input) and "Token" (password input) +- "Connect" button: calls `fetchWithAuth('/api/v1/dashboard/summary')` to + validate, stores in `localStorage` on success, calls `initConfig()` to + refresh runtime config, navigates to `#/overview` +- Tabs placeholder for future OIDC flow: "Token", "Login with Authentik" + (the second tab is disabled with "coming soon") +- Error state: connection failed, wrong token, server unreachable + +`web/src/App.svelte` — check `isConfigured()` at mount. If false, render +`Config.svelte` instead of the sidebar. On successful config, transition to +the full app. + +`web/src/main.ts` — simplify to always mount `App.svelte` (the config check +lives in App.svelte's mount hook, not in main.ts): + +```ts +import { mount } from 'svelte' +import App from './App.svelte' +import './app.css' +import { initConfig } from '$lib/config' + +initConfig() +mount(App, { target: document.getElementById('app')! }) +``` + +### 0.6 — Deploy SPA as standalone static files + +The SPA is built with `base: '/'` and served by Caddy at `/` with SPA +fallback. API routes take priority (explicit `handle_path` blocks in Caddy). + +**Caddy config** (add to the existing `compose/caddy/Caddyfile.oikos`): +``` +handle { + root * /var/www/oikos-ui + file_server + try_files {path} /index.html +} +``` + +The `index.html` doesn't need a placeholder (`__OIKOS_API_URL__`) in the +Caddy deployment case — the SPA and API share an origin, so relative paths +work and `__OIKOS_CONFIG__` only needs `apiUrl` unset. The token is entered +by the user on the Config page and stored in `localStorage`. + +**Build + deploy:** +```makefile +ui: ## Build the SPA for standalone deployment + cd web && npm run build + +deploy-ui: ui ## Deploy SPA to the Caddy host + scp -r web/dist/* mac-mini:/var/www/oikos-ui/ + ssh mac-mini sudo systemctl reload caddy +``` + +**Dockerfile** (server image, no Node required): +```dockerfile +# Stage 1: Build Go binary +FROM golang:1.26 AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o oikos -tags timetzdata ./cmd/oikos + +# Stage 2: Runtime +FROM debian:bookworm-slim +COPY --from=builder /app/oikos /usr/local/bin/oikos +EXPOSE 8090 +ENTRYPOINT ["oikos"] +``` + +Drops the node builder stage entirely. The UI is built and deployed +separately. + +### 0.7 — Verification + +``` +# Server +oikos api # API + SSE + MCP, no UI +curl localhost:8090/healthz # {"status":"ok"} +curl -H "Authorization: Bearer dev-token" \ + localhost:8090/api/v1/dashboard/summary # data + +# SPA (dev) +OIKOS_API_TOKEN=dev-token npm run dev # Vite at :5173, proxy to :8090 +open http://localhost:5173/ # Config page (enter URL + token) + # → Overview with live data + +# SPA (production test) +cd web && npm run build +caddy file-server -root dist --listen :3000 # serve SPA locally +# Run oikos api separately, open http://localhost:3000, +# enter apiUrl=http://localhost:8090 + token on Config page +``` + +--- + +## Phase 1 — Wails desktop app + +Built on top of the split. The Wails app is a thin native wrapper around the +same SPA, configured to talk to the deployed server over HTTPS. No bundled +Go server, no Postgres connection, no nomos sidecar. + +### 1.0 — Scaffold and window (1 session) + +Create the Wails project with a native window loading the built SPA. + +**`cmd/desktop/main.go`** — Wails v3 app: + +- On startup: read config from OS keychain (`keyring` package or Wails + secrets plugin). Keys: `oikos_server_url`, `oikos_token`. +- If no config in keychain: load the SPA anyway — `Config.svelte` handles + first-launch setup. +- If config exists: inject `window.__OIKOS_CONFIG__` before the webview + mounts. Wails v3's `AssetsHandler` can mutate `index.html` before serving: + ```go + assetsHandler: func(ctx context.Context, name string) (string, []byte, error) { + if name == "index.html" { + b, _ := assets.ReadFile("index.html") + html := strings.Replace(string(b), + ``, + fmt.Sprintf(``, configJSON), + 1) + return "index.html", []byte(html), nil + } + b, _ := assets.ReadFile(name) + return name, b, nil + } + ``` + `index.html` includes a placeholder ` + + ``` +- Window: title "Oikos — Control Room", 1400×900, min 1024×700, dark + title bar (`mac.TitleBarStyleHiddenInset` or equivalent dark mode). +- Wails embeds `web/dist/` into the binary (via `//go:embed all:dist` in + the Wails project or the standard Wails asset system). + +**`cmd/desktop/wails.json`** — Wails project config: +```json +{ + "name": "oikos-desktop", + "frontend:dir": "../../web", + "frontend:build": "npm run build", + "frontend:dev:watcher": "npm run dev" +} +``` + +**`Makefile`**: +```makefile +desktop: ui ## Build the Wails desktop app + wails build -clean -o oikos-desktop +``` + +**Dev loop for the desktop app:** +```sh +# Terminal 1: run the server locally (or point at remote) +OIKOS_API_TOKEN=dev-token oikos api + +# Terminal 2: start Wails in dev mode (hot-reload, connects to Vite) +cd cmd/desktop && wails dev +``` + +**`web/vite.config.ts`** — add `base: '/'` (already done in 0.2). No +Wails-specific Vite config needed since Wails v3 uses the standard Vite dev +server. + +**Verify:** +```sh +OIKOS_SERVER_URL=https://oikos.hubris.network OIKOS_DESKTOP_TOKEN=... make desktop +./oikos-desktop +# Window opens → Config page (if no keychain entry) or Overview with live data +``` + +### 1.1 — Native shell features (1 session) + +- **System tray** (Wails v3 `application.NewSystemTray`): + - Oikos logo icon (from `web/public/favicon.svg`) + - Menu: "Open Control Room" (focus/restore window), "Pending Approvals: N" + (fetched via backchannel HTTP call from Go, not the SPA), separator, + "Quit" + - When window is closed: minimize to tray instead of quitting (set + `HideOnClose`) + +- **Native notifications** (`application.Notification`): + - A backchannel goroutine polls `GET /api/v1/dashboard/summary` every 30s + (separate HTTPS client in Go, independent of the webview) + - Fires OS notification when `approvals_pending` or `signals_by_severity.critical` + increases since last poll + - Click notification → `Window.Restore()` + send a message to the SPA via + Wails events to navigate to the relevant page + +- **Window persistence**: remember size/position via Wails v3 + `window.PersistState` or a JSON file in `~/.config/oikos/window.json` + +- **Auto-start on login** (macOS): + - During setup flow, offer a checkbox: "Start automatically on login" + - Writes a LaunchAgent plist to `~/Library/LaunchAgents/com.hubris.oikos-desktop.plist` + that runs the binary on login + - Linux equivalent: `~/.config/autostart/oikos-desktop.desktop` + +**Verify:** Close window → app stays in tray. New approval arrives → OS +notification appears with count. Click notification → window opens to Ops +page. Restart machine → app opens automatically on login. + +### 1.2 — Token management (1 session) + +- **First launch**: `Config.svelte` prompts for server URL + token (same + page as the browser SPA's setup) +- On "Connect" success, `Config.svelte` calls a Wails binding + `SaveConfig(apiUrl, token)` that stores in the OS keychain: + ```go + func (a *App) SaveConfig(apiUrl string, token string) error { + keyring.Set("oikos_server_url", apiUrl) + keyring.Set("oikos_token", token) + return nil + } + ``` +- **Subsequent launches**: Wails reads keychain, injects config, SPA skips + Config page +- **Logout**: "Log out" menu item in system tray clears keychain and + refreshes the webview → `Config.svelte` appears + +**Verify:** Enter URL + token on first launch, quit, reopen → skips setup +and loads Overview. + +### 1.3 — Auto-update (1 session) + +- Check Gitea releases (or a configured update URL) on startup and every + 6 hours +- Wails v3 update plugin or a custom Go goroutine: `GET /releases/latest` → + compare semver → download binary → verify checksum → prompt restart +- Update manifest published alongside each release: + `oikos-desktop-darwin-arm64.json` with `version`, `url`, `sha256` + +**Verify:** Build v1.0.0, publish v1.0.1 → app detects update, downloads, +prompts restart. After restart, version is 1.0.1. + +### 1.4 — Distribution and packaging (1 session) + +- **macOS**: `.app` bundle via `wails build`, code-sign with Apple + Developer ID, notarize via `xcrun notarytool` + - Bundle ID: `com.hubris.oikos-desktop` + - Entitlements: network client, keychain access +- **Linux**: `.deb` and AppImage via `wails build` + packaging scripts +- **CI**: `.gitea/workflows/desktop.yml` — builds all targets on tag push, + uploads artifacts to Gitea releases +- **AGENTS.md** update: document the desktop app as a first-class client + +**Verify:** Download `.app` on a fresh Mac, open → first-launch setup → +connect to the homelab → full app works with zero dev tools. + +--- + +## What does NOT change + +- `internal/` — every package imported as-is. Zero modifications. +- `cmd/oikos/` — minus the SPA embed (0.1), the `oikos` binary is unchanged. +- `cmd/nomos/` — unchanged. The desktop app talks to nomos through the + server's `/agent` reverse proxy — same as the browser. +- `web/` — SPA source shared between browser and desktop builds. Gains + `config.ts` (auth interceptor), `Config.svelte` (setup page), and + `vite.config.ts` drops `/ui/` prefix + adds proxy token injection. All + existing pages, components, stores, hooks reused. +- `api/openapi.yaml` — unchanged. +- `seeds/` — unchanged. +- `docker-compose.yml` — server deployment unchanged (minus the Dockerfile + losing the UI build stage). + +--- + +## Risks and open questions + +1. **Wails v3 maturity.** v3 is newer than v2. Fallback: Wails v2 — same + architecture (Go + webview + embedded assets), different Go APIs. Scope + of impact: one file (`cmd/desktop/main.go`). The thin-wrapper approach + means the Wails API surface is ~50 lines of Go — trivially portable. + +2. **OIDC login flow.** The browser SPA needs an OIDC redirect flow via + Authentik for production use (static tokens are fine for homelab dev but + not for external access). The `Config.svelte` page has a tab placeholder + for this. It's a separate milestone — for now, both browser and desktop + clients use a static bearer token configured at first launch. + +3. **SSE query-param token in logs.** The token in `?token=...` appears in + Caddy access logs and server request logs. Mitigation: log redaction in + Caddy (`log { format filter { wrap json { fields { request>uri replace + "token=[^&]*" "token=***" } } } }`) and strip the query param from the + request logger in `server.go`. + +4. **Webview CORS for embedded assets.** Wails loads the SPA from `wails://` + or `asset://` origin, making cross-origin requests to the remote server. + CORS middleware (0.3) handles this. The `Access-Control-Allow-Origin` + must match the webview's origin, which may change between Wails versions. + Mitigation: allow the configured origin explicitly; fall back to `*` for + dev; Wails v3 document its asset origin. + +5. **Multiple clients hitting the same SSE broker.** Browser, Wails app, and + Nomos all connect to `/api/v1/events/stream`. The SSE broker already + handles multiple subscribers (fan-out via the subscriber list in + `sse.go`). Each client gets its own connection and replay. No change + needed. diff --git a/plans/index.md b/plans/index.md index e148d9a..2990e06 100644 --- a/plans/index.md +++ b/plans/index.md @@ -14,6 +14,7 @@ went sideways, open an investigation. | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | +| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | Planned — not started | ## Done