# Helios M1 — Scaffold Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A running, deployable Helios shell: Bun+Hono API with password auth, encrypted settings storage, a Vite+React SPA (setup/login/settings), single Docker image, Gitea Actions CI. **Architecture:** Bun workspaces monorepo (`server`, `web`, `shared`). Server is an API-first Hono app that also serves the built SPA. SQLite via drizzle-orm (bun:sqlite driver) with drizzle-kit migrations run at boot. Secrets encrypted AES-256-GCM with a key generated at first boot into the data dir. **Tech Stack:** Bun ≥1.1, Hono 4, drizzle-orm + drizzle-kit, Zod 3, Vite 6 + React 19 + react-router 7, TypeScript 5 strict. Tests: `bun test` (built-in) — NOT Vitest: bun:sqlite cannot load under node-based Vitest. (Spec amendment included in Task 1.) **Spec:** docs/superpowers/specs/2026-08-18-helios-design.md ## Global Constraints - MIT license; no telemetry; no personal (Marcus) data anywhere in the repo. - All API request/response bodies validated with Zod schemas defined in `shared`. - Secrets (LLM key, vendor tokens) never leave the server unencrypted at rest and are returned to clients only masked (`sk-…abcd`). - Data dir is a single mount point: `DATA_DIR` env, default `/data` (falls back to `./data` outside Docker). - TypeScript `strict: true` everywhere; `bun run typecheck` must pass at every commit. - Commit messages: Conventional Commits, reference PAI-90 (M1 issue) in the body of the first commit of each task. --- ### Task 1: Monorepo scaffold + CI **Files:** - Create: `package.json`, `tsconfig.base.json`, `.gitignore`, `LICENSE`, `README.md` - Create: `server/package.json`, `server/tsconfig.json`, `server/src/index.ts` - Create: `shared/package.json`, `shared/tsconfig.json`, `shared/src/types.ts` - Create: `.gitea/workflows/ci.yml` - Modify: `docs/superpowers/specs/2026-08-18-helios-design.md` (Vitest → bun test) **Interfaces:** - Produces: workspace layout every later task lives in; `bun run typecheck` and `bun test` as the repo-wide gates. - [ ] **Step 1: Root files** `package.json`: ```json { "name": "helios", "private": true, "workspaces": ["server", "web", "shared"], "scripts": { "typecheck": "bunx tsc -b server shared", "test": "bun test server" } } ``` `tsconfig.base.json`: ```json { "compilerOptions": { "strict": true, "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022", "types": ["bun-types"], "skipLibCheck": true, "composite": true, "verbatimModuleSyntax": true } } ``` `.gitignore`: ``` node_modules/ dist/ data/ *.log .DS_Store ``` `LICENSE`: MIT text, `Copyright (c) 2026 Marcus Rehbock and Helios contributors`. `README.md`: ```markdown # Helios Self-hosted, open-source health companion — a FOSS alternative to closed "24/7 AI health companion" products. Your data stays on your server; the AI runs on a key you provide (OpenRouter, or Ollama for zero third parties). Status: pre-alpha scaffold. See docs/superpowers/specs/ for the design. ## Develop bun install bun run typecheck && bun test ``` - [ ] **Step 2: Workspace stubs** `shared/package.json`: ```json { "name": "@helios/shared", "version": "0.0.1", "exports": { ".": "./src/types.ts" }, "dependencies": { "zod": "^3.24.0" } } ``` `shared/tsconfig.json`: ```json { "extends": "../tsconfig.base.json", "include": ["src"] } ``` `shared/src/types.ts`: ```ts import { z } from "zod"; export const HealthResponse = z.object({ ok: z.literal(true) }); export type HealthResponse = z.infer; ``` `server/package.json`: ```json { "name": "@helios/server", "version": "0.0.1", "dependencies": { "@helios/shared": "workspace:*", "drizzle-orm": "^0.44.0", "hono": "^4.6.0", "zod": "^3.24.0" }, "devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" } } ``` `server/tsconfig.json`: ```json { "extends": "../tsconfig.base.json", "include": ["src", "test"], "references": [{ "path": "../shared" }] } ``` `server/src/index.ts`: ```ts import { Hono } from "hono"; const app = new Hono(); app.get("/api/health", (c) => c.json({ ok: true })); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; ``` - [ ] **Step 3: Install + verify gates** Run: `bun install && bun run typecheck` Expected: exit 0. Run: `bun --watch=never server/src/index.ts & sleep 1 && curl -s localhost:3000/api/health; kill %1` Expected: `{"ok":true}` - [ ] **Step 4: CI workflow** `.gitea/workflows/ci.yml`: ```yaml name: CI on: push: branches: [main] jobs: check: runs-on: ubuntu-latest container: image: oven/bun:1 steps: - name: Checkout run: | apt-get update -qq && apt-get install -y -qq git >/dev/null git init -q . && git fetch -q --depth 1 "https://git.rehbock.xyz/${{ github.repository }}.git" "${{ github.sha }}" && git checkout -q FETCH_HEAD - run: bun install --frozen-lockfile - run: bun run typecheck - run: bun test server ``` - [ ] **Step 5: Spec amendment (Vitest → bun test)** In `docs/superpowers/specs/2026-08-18-helios-design.md`, replace the Testing bullet `- Vitest: unit normalization` list intro word `Vitest:` with `bun test (bun:sqlite requires the bun runtime; node-based Vitest cannot load it):`. - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "chore: monorepo scaffold, CI, MIT license Bun workspaces (server/web/shared), typecheck+test gates, Gitea CI. Refs PAI-90." git push ``` Verify CI goes green on Gitea before starting Task 2. --- ### Task 2: DB layer (drizzle schema + migrations at boot) **Files:** - Create: `server/src/db/schema.ts`, `server/src/db/index.ts`, `server/drizzle.config.ts` - Create: `server/drizzle/` (generated migrations, committed) - Test: `server/test/db.test.ts` **Interfaces:** - Produces: `openDb(dataDir: string): Db` where `Db = ReturnType` with schema `{ settings, sessions }`; runs migrations on open. Table `settings`: `key: text PK`, `value: text`. Table `sessions`: `id: text PK`, `createdAt: integer`, `expiresAt: integer` (unix ms). - [ ] **Step 1: Write the failing test** `server/test/db.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { openDb } from "../src/db"; import { settings } from "../src/db/schema"; describe("db", () => { test("opens, migrates, round-trips a setting", async () => { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(dir); await db.insert(settings).values({ key: "llm_base_url", value: "https://openrouter.ai/api/v1" }); const rows = await db.select().from(settings); expect(rows).toEqual([{ key: "llm_base_url", value: "https://openrouter.ai/api/v1" }]); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test server/test/db.test.ts` Expected: FAIL — cannot resolve `../src/db`. - [ ] **Step 3: Implement** `server/src/db/schema.ts`: ```ts import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const settings = sqliteTable("settings", { key: text("key").primaryKey(), value: text("value").notNull(), }); export const sessions = sqliteTable("sessions", { id: text("id").primaryKey(), createdAt: integer("created_at").notNull(), expiresAt: integer("expires_at").notNull(), }); ``` `server/drizzle.config.ts`: ```ts import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "sqlite", schema: "./src/db/schema.ts", out: "./drizzle", }); ``` Run: `cd server && bunx drizzle-kit generate --name init` → commit the generated `server/drizzle/*` files. `server/src/db/index.ts`: ```ts import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; import * as schema from "./schema"; export type Db = ReturnType; export function openDb(dataDir: string) { mkdirSync(dataDir, { recursive: true }); const sqlite = new Database(join(dataDir, "helios.db"), { create: true }); sqlite.exec("PRAGMA journal_mode = WAL"); const db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: join(import.meta.dir, "../../drizzle") }); return db; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test server/test/db.test.ts` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(db): drizzle schema (settings, sessions) + boot migrations" ``` --- ### Task 3: Crypto module (key file + AES-256-GCM) **Files:** - Create: `server/src/lib/crypto.ts` - Test: `server/test/crypto.test.ts` **Interfaces:** - Produces: `loadOrCreateKey(dataDir: string): Buffer` (32-byte key at `/secret.key`, file mode 0600); `encrypt(key: Buffer, plaintext: string): string` returning `"enc:" + base64(iv|tag|ciphertext)`; `decrypt(key: Buffer, sealed: string): string`; `mask(secret: string): string` → `"…" + last 4 chars` (or `"…"` if shorter than 5). - [ ] **Step 1: Write the failing test** `server/test/crypto.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { decrypt, encrypt, loadOrCreateKey, mask } from "../src/lib/crypto"; describe("crypto", () => { test("key is created once and reused", () => { const dir = mkdtempSync(join(tmpdir(), "helios-")); const a = loadOrCreateKey(dir); const b = loadOrCreateKey(dir); expect(a.length).toBe(32); expect(a.equals(b)).toBe(true); }); test("encrypt/decrypt round-trip; ciphertexts differ per call", () => { const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-"))); const sealed = encrypt(key, "sk-or-v1-secret"); expect(sealed.startsWith("enc:")).toBe(true); expect(decrypt(key, sealed)).toBe("sk-or-v1-secret"); expect(encrypt(key, "sk-or-v1-secret")).not.toBe(sealed); }); test("tampering fails closed", () => { const key = loadOrCreateKey(mkdtempSync(join(tmpdir(), "helios-"))); const sealed = encrypt(key, "x"); const bad = sealed.slice(0, -2) + (sealed.endsWith("A") ? "BB" : "AA"); expect(() => decrypt(key, bad)).toThrow(); }); test("mask keeps only tail", () => { expect(mask("sk-or-v1-abcdef")).toBe("…cdef"); expect(mask("abc")).toBe("…"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test server/test/crypto.test.ts` — Expected: FAIL, module missing. - [ ] **Step 3: Implement** `server/src/lib/crypto.ts`: ```ts import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; export function loadOrCreateKey(dataDir: string): Buffer { mkdirSync(dataDir, { recursive: true }); const path = join(dataDir, "secret.key"); if (!existsSync(path)) writeFileSync(path, randomBytes(32), { mode: 0o600 }); const key = readFileSync(path); if (key.length !== 32) throw new Error(`secret.key must be 32 bytes, got ${key.length}`); return key; } export function encrypt(key: Buffer, plaintext: string): string { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return "enc:" + Buffer.concat([iv, cipher.getAuthTag(), ct]).toString("base64"); } export function decrypt(key: Buffer, sealed: string): string { if (!sealed.startsWith("enc:")) throw new Error("not an encrypted value"); const buf = Buffer.from(sealed.slice(4), "base64"); const decipher = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12)); decipher.setAuthTag(buf.subarray(12, 28)); return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString("utf8"); } export function mask(secret: string): string { return secret.length >= 5 ? "…" + secret.slice(-4) : "…"; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test server/test/crypto.test.ts` — Expected: PASS (4 tests). - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(crypto): boot-generated key + AES-256-GCM sealed values" ``` --- ### Task 4: App factory + auth (setup, login, sessions, rate limit) **Files:** - Create: `server/src/app.ts` (Hono factory taking `{ db, key }`), `server/src/routes/auth.ts` - Modify: `server/src/index.ts` (use factory), `shared/src/types.ts` (auth schemas) - Test: `server/test/auth.test.ts` **Interfaces:** - Consumes: `openDb`, `loadOrCreateKey` (Tasks 2–3). - Produces: `createApp(deps: { db: Db; key: Buffer }): Hono` — used by index.ts and every later route task. Auth contract: `GET /api/me` → `{ needsSetup: boolean, authenticated: boolean }`; `POST /api/setup {password}` (400 if already set up, min length 8); `POST /api/login {password}` → sets `helios_session` httpOnly cookie (30-day expiry, sliding); `POST /api/logout`; middleware rejects other `/api/*` with 401 when unauthenticated. Rate limit: 10 failed logins per 15 min per IP → 429. - [ ] **Step 1: Add schemas to shared** Append to `shared/src/types.ts`: ```ts export const PasswordBody = z.object({ password: z.string().min(8).max(200) }); export const MeResponse = z.object({ needsSetup: z.boolean(), authenticated: z.boolean() }); export type MeResponse = z.infer; ``` - [ ] **Step 2: Write the failing test** `server/test/auth.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createApp } from "../src/app"; import { openDb } from "../src/db"; import { loadOrCreateKey } from "../src/lib/crypto"; function makeApp() { const dir = mkdtempSync(join(tmpdir(), "helios-")); return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) }); } const json = (body: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); describe("auth", () => { test("fresh instance needs setup; setup then login yields a session", async () => { const app = makeApp(); let me = await (await app.request("/api/me")).json(); expect(me).toEqual({ needsSetup: true, authenticated: false }); expect((await app.request("/api/setup", json({ password: "hunter2hunter2" }))).status).toBe(204); expect((await app.request("/api/setup", json({ password: "again-not-allowed" }))).status).toBe(400); const login = await app.request("/api/login", json({ password: "hunter2hunter2" })); expect(login.status).toBe(204); const cookie = login.headers.get("set-cookie")!; expect(cookie).toContain("helios_session="); expect(cookie).toContain("HttpOnly"); me = await (await app.request("/api/me", { headers: { cookie } })).json(); expect(me.authenticated).toBe(true); }); test("wrong password 401; protected route 401 without cookie", async () => { const app = makeApp(); await app.request("/api/setup", json({ password: "hunter2hunter2" })); expect((await app.request("/api/login", json({ password: "wrong-wrong-1" }))).status).toBe(401); expect((await app.request("/api/settings")).status).toBe(401); }); test("11th failed login from one IP is rate-limited", async () => { const app = makeApp(); await app.request("/api/setup", json({ password: "hunter2hunter2" })); const hdrs = { "content-type": "application/json", "x-forwarded-for": "10.9.8.7" }; let last = 0; for (let i = 0; i < 11; i++) { last = (await app.request("/api/login", { method: "POST", headers: hdrs, body: JSON.stringify({ password: "wrong-wrong-1" }) })).status; } expect(last).toBe(429); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `bun test server/test/auth.test.ts` — Expected: FAIL, `createApp` missing. - [ ] **Step 4: Implement** `server/src/routes/auth.ts`: ```ts import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { getCookie, setCookie } from "hono/cookie"; import { randomBytes } from "node:crypto"; import { PasswordBody } from "@helios/shared"; import type { Db } from "../db"; import { sessions, settings } from "../db/schema"; const SESSION_MS = 30 * 24 * 3600 * 1000; const WINDOW_MS = 15 * 60 * 1000; const MAX_FAILURES = 10; export type AuthDeps = { db: Db }; export function authRoutes({ db }: AuthDeps) { const failures = new Map(); const app = new Hono(); const getHash = async () => (await db.select().from(settings).where(eq(settings.key, "password_hash"))).at(0)?.value; app.get("/me", async (c) => { const sid = getCookie(c, "helios_session"); let authenticated = false; if (sid) { const row = (await db.select().from(sessions).where(eq(sessions.id, sid))).at(0); authenticated = !!row && row.expiresAt > Date.now(); } return c.json({ needsSetup: !(await getHash()), authenticated }); }); app.post("/setup", async (c) => { if (await getHash()) return c.json({ error: "already set up" }, 400); const body = PasswordBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: "password must be 8–200 chars" }, 400); const hash = await Bun.password.hash(body.data.password, "argon2id"); await db.insert(settings).values({ key: "password_hash", value: hash }); return c.body(null, 204); }); app.post("/login", async (c) => { const ip = c.req.header("x-forwarded-for") ?? "local"; const f = failures.get(ip); if (f && f.count >= MAX_FAILURES && Date.now() < f.resetAt) return c.json({ error: "too many attempts" }, 429); const body = PasswordBody.safeParse(await c.req.json().catch(() => null)); const hash = await getHash(); const ok = body.success && !!hash && (await Bun.password.verify(body.data.password, hash)); if (!ok) { const cur = f && Date.now() < f.resetAt ? f : { count: 0, resetAt: Date.now() + WINDOW_MS }; failures.set(ip, { count: cur.count + 1, resetAt: cur.resetAt }); return c.json({ error: "invalid password" }, 401); } failures.delete(ip); const id = randomBytes(32).toString("base64url"); await db.insert(sessions).values({ id, createdAt: Date.now(), expiresAt: Date.now() + SESSION_MS }); setCookie(c, "helios_session", id, { httpOnly: true, sameSite: "Lax", path: "/", maxAge: SESSION_MS / 1000 }); return c.body(null, 204); }); app.post("/logout", async (c) => { const sid = getCookie(c, "helios_session"); if (sid) await db.delete(sessions).where(eq(sessions.id, sid)); setCookie(c, "helios_session", "", { httpOnly: true, path: "/", maxAge: 0 }); return c.body(null, 204); }); return app; } export async function isAuthenticated(db: Db, sid: string | undefined): Promise { if (!sid) return false; const row = (await db.select().from(sessions).where(eq(sessions.id, sid))).at(0); return !!row && row.expiresAt > Date.now(); } ``` `server/src/app.ts`: ```ts import { Hono } from "hono"; import { getCookie } from "hono/cookie"; import type { Db } from "./db"; import { authRoutes, isAuthenticated } from "./routes/auth"; export type Deps = { db: Db; key: Buffer }; const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]); export function createApp(deps: Deps) { const app = new Hono(); app.get("/api/health", (c) => c.json({ ok: true })); app.use("/api/*", async (c, next) => { if (PUBLIC.has(c.req.path)) return next(); if (!(await isAuthenticated(deps.db, getCookie(c, "helios_session")))) { return c.json({ error: "unauthenticated" }, 401); } return next(); }); app.route("/api", authRoutes({ db: deps.db })); // Later route groups (settings, connectors, chat) mount here. app.get("/api/settings", (c) => c.json({ error: "not implemented" }, 501)); // replaced in Task 5 return app; } ``` `server/src/index.ts` (replace): ```ts import { openDb } from "./db"; import { loadOrCreateKey } from "./lib/crypto"; import { createApp } from "./app"; const dataDir = process.env.DATA_DIR ?? "./data"; const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) }); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; ``` - [ ] **Step 5: Run tests to verify they pass** Run: `bun test server` — Expected: PASS (db, crypto, auth). - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat(auth): setup/login/logout, argon2id, sessions, login rate limit" ``` --- ### Task 5: Settings API (encrypted LLM config) **Files:** - Create: `server/src/routes/settings.ts` - Modify: `server/src/app.ts` (mount, drop 501 stub), `shared/src/types.ts` - Test: `server/test/settings.test.ts` **Interfaces:** - Consumes: `createApp` deps, `encrypt`/`decrypt`/`mask`. - Produces: `GET /api/settings` → `SettingsResponse { llmBaseUrl: string, llmModel: string, llmKeyMasked: string | null }`; `PUT /api/settings` accepts `SettingsUpdate { llmBaseUrl?, llmModel?, llmKey? }` (any subset), stores `llm_key` encrypted, 204. Defaults when unset: `llmBaseUrl "https://openrouter.ai/api/v1"`, `llmModel "anthropic/claude-sonnet-4.5"`. DB keys: `llm_base_url`, `llm_model`, `llm_key`. Later milestones read the key via `getSetting(db, key, cryptoKey)` exported from this file. - [ ] **Step 1: Add schemas to shared** Append to `shared/src/types.ts`: ```ts export const SettingsResponse = z.object({ llmBaseUrl: z.string(), llmModel: z.string(), llmKeyMasked: z.string().nullable(), }); export type SettingsResponse = z.infer; export const SettingsUpdate = z.object({ llmBaseUrl: z.string().url().optional(), llmModel: z.string().min(1).optional(), llmKey: z.string().min(1).optional(), }); export type SettingsUpdate = z.infer; ``` - [ ] **Step 2: Write the failing test** `server/test/settings.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eq } from "drizzle-orm"; import { createApp } from "../src/app"; import { openDb } from "../src/db"; import { settings } from "../src/db/schema"; import { loadOrCreateKey } from "../src/lib/crypto"; async function authedApp() { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(dir); const app = createApp({ db, key: loadOrCreateKey(dir) }); const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) }); await app.request("/api/setup", j({ password: "hunter2hunter2" })); const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; return { app, db, cookie }; } describe("settings", () => { test("defaults, update, masked key, encrypted at rest", async () => { const { app, db, cookie } = await authedApp(); const h = { cookie }; let s = await (await app.request("/api/settings", { headers: h })).json(); expect(s).toEqual({ llmBaseUrl: "https://openrouter.ai/api/v1", llmModel: "anthropic/claude-sonnet-4.5", llmKeyMasked: null, }); const put = await app.request("/api/settings", { method: "PUT", headers: { ...h, "content-type": "application/json" }, body: JSON.stringify({ llmKey: "sk-or-v1-supersecret-abcd", llmModel: "meta-llama/llama-4" }), }); expect(put.status).toBe(204); s = await (await app.request("/api/settings", { headers: h })).json(); expect(s.llmKeyMasked).toBe("…abcd"); expect(s.llmModel).toBe("meta-llama/llama-4"); const raw = (await db.select().from(settings).where(eq(settings.key, "llm_key"))).at(0)!.value; expect(raw.startsWith("enc:")).toBe(true); expect(raw).not.toContain("supersecret"); }); test("rejects bad body", async () => { const { app, cookie } = await authedApp(); const r = await app.request("/api/settings", { method: "PUT", headers: { cookie, "content-type": "application/json" }, body: JSON.stringify({ llmBaseUrl: "not a url" }), }); expect(r.status).toBe(400); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `bun test server/test/settings.test.ts` — Expected: FAIL (501 stub / missing route). - [ ] **Step 4: Implement** `server/src/routes/settings.ts`: ```ts import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { SettingsUpdate } from "@helios/shared"; import type { Db } from "../db"; import { settings } from "../db/schema"; import { decrypt, encrypt, mask } from "../lib/crypto"; const DEFAULTS = { llm_base_url: "https://openrouter.ai/api/v1", llm_model: "anthropic/claude-sonnet-4.5", } as const; async function get(db: Db, key: string): Promise { return (await db.select().from(settings).where(eq(settings.key, key))).at(0)?.value; } async function put(db: Db, key: string, value: string): Promise { await db.insert(settings).values({ key, value }).onConflictDoUpdate({ target: settings.key, set: { value } }); } /** Decrypted read for other route groups (chat, connectors). */ export async function getSetting(db: Db, key: string, cryptoKey: Buffer): Promise { const v = await get(db, key); return v?.startsWith("enc:") ? decrypt(cryptoKey, v) : v; } export function settingsRoutes(deps: { db: Db; key: Buffer }) { const app = new Hono(); app.get("/settings", async (c) => { const [base, model, sealed] = await Promise.all([ get(deps.db, "llm_base_url"), get(deps.db, "llm_model"), get(deps.db, "llm_key"), ]); return c.json({ llmBaseUrl: base ?? DEFAULTS.llm_base_url, llmModel: model ?? DEFAULTS.llm_model, llmKeyMasked: sealed ? mask(decrypt(deps.key, sealed)) : null, }); }); app.put("/settings", async (c) => { const body = SettingsUpdate.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400); const { llmBaseUrl, llmModel, llmKey } = body.data; if (llmBaseUrl) await put(deps.db, "llm_base_url", llmBaseUrl); if (llmModel) await put(deps.db, "llm_model", llmModel); if (llmKey) await put(deps.db, "llm_key", encrypt(deps.key, llmKey)); return c.body(null, 204); }); return app; } ``` In `server/src/app.ts`: delete the `app.get("/api/settings", …501…)` stub line, add `import { settingsRoutes } from "./routes/settings";` and after the auth mount add `app.route("/api", settingsRoutes(deps));`. - [ ] **Step 5: Run tests** Run: `bun test server` — Expected: all PASS. - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat(settings): LLM config API with AES-GCM sealed key, masked reads" ``` --- ### Task 6: Web SPA (setup, login, settings, shell) **Files:** - Create: `web/package.json`, `web/tsconfig.json`, `web/vite.config.ts`, `web/index.html` - Create: `web/src/main.tsx`, `web/src/App.tsx`, `web/src/api.ts`, `web/src/pages/Gate.tsx`, `web/src/pages/Settings.tsx`, `web/src/pages/Today.tsx`, `web/src/styles.css` - Modify: root `package.json` scripts **Interfaces:** - Consumes: the API contract from Tasks 4–5 (`/api/me`, `/api/setup`, `/api/login`, `/api/logout`, `/api/settings`). - Produces: `web/dist/` static build the server serves in Task 7. Routes: `/` (Today placeholder), `/settings`; unauthenticated users see the Gate (setup or login form depending on `needsSetup`). - [ ] **Step 1: Scaffold workspace** `web/package.json`: ```json { "name": "@helios/web", "version": "0.0.1", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@helios/shared": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0" }, "devDependencies": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "typescript": "^5.7.0", "vite": "^6.0.0" } } ``` `web/tsconfig.json`: ```json { "compilerOptions": { "strict": true, "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "bundler", "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "noEmit": true }, "include": ["src"] } ``` `web/vite.config.ts`: ```ts import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], server: { proxy: { "/api": "http://localhost:3000" } }, }); ``` `web/index.html`: ```html Helios
``` Root `package.json` scripts become: ```json { "typecheck": "bunx tsc -b server shared && bunx tsc -p web", "test": "bun test server", "build:web": "bun run --cwd web build" } ``` - [ ] **Step 2: API client** `web/src/api.ts`: ```ts import type { MeResponse, SettingsResponse, SettingsUpdate } from "@helios/shared"; async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText); return res.status === 204 ? (undefined as T) : res.json(); } export const api = { me: () => req("/api/me"), setup: (password: string) => req("/api/setup", { method: "POST", body: JSON.stringify({ password }) }), login: (password: string) => req("/api/login", { method: "POST", body: JSON.stringify({ password }) }), logout: () => req("/api/logout", { method: "POST" }), getSettings: () => req("/api/settings"), putSettings: (body: SettingsUpdate) => req("/api/settings", { method: "PUT", body: JSON.stringify(body) }), }; ``` - [ ] **Step 3: Pages + shell** `web/src/pages/Gate.tsx`: ```tsx import { useState } from "react"; import { api } from "../api"; export function Gate({ needsSetup, onDone }: { needsSetup: boolean; onDone: () => void }) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const submit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); try { if (needsSetup) await api.setup(password); await api.login(password); onDone(); } catch (err) { setError(err instanceof Error ? err.message : "failed"); } }; return (

Helios

{needsSetup ? "Create the password for this instance." : "Enter your password."}

setPassword(e.target.value)} placeholder={needsSetup ? "New password (min 8 chars)" : "Password"} autoFocus />
{error &&

{error}

}
); } ``` `web/src/pages/Settings.tsx`: ```tsx import { useEffect, useState } from "react"; import type { SettingsResponse } from "@helios/shared"; import { api } from "../api"; export function Settings() { const [current, setCurrent] = useState(null); const [llmBaseUrl, setLlmBaseUrl] = useState(""); const [llmModel, setLlmModel] = useState(""); const [llmKey, setLlmKey] = useState(""); const [status, setStatus] = useState(null); useEffect(() => { api.getSettings().then((s) => { setCurrent(s); setLlmBaseUrl(s.llmBaseUrl); setLlmModel(s.llmModel); }); }, []); const save = async (e: React.FormEvent) => { e.preventDefault(); setStatus(null); try { await api.putSettings({ llmBaseUrl, llmModel, ...(llmKey ? { llmKey } : {}) }); setLlmKey(""); setCurrent(await api.getSettings()); setStatus("Saved"); } catch (err) { setStatus(err instanceof Error ? err.message : "failed"); } }; if (!current) return

Loading…

; return (

AI provider

Any OpenAI-compatible endpoint. OpenRouter by default; point at Ollama for a fully local setup.

{status &&

{status}

}
); } ``` `web/src/pages/Today.tsx`: ```tsx export function Today() { return (

Today

No data yet. Connect a wearable in Settings once connectors land (M2).

); } ``` `web/src/App.tsx`: ```tsx import { useCallback, useEffect, useState } from "react"; import { Link, Route, Routes } from "react-router"; import type { MeResponse } from "@helios/shared"; import { api } from "./api"; import { Gate } from "./pages/Gate"; import { Settings } from "./pages/Settings"; import { Today } from "./pages/Today"; export function App() { const [me, setMe] = useState(null); const refresh = useCallback(() => { api.me().then(setMe); }, []); useEffect(refresh, [refresh]); if (!me) return null; if (!me.authenticated) return ; return (
} /> } />
); } ``` `web/src/main.tsx`: ```tsx import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router"; import { App } from "./App"; import "./styles.css"; createRoot(document.getElementById("root")!).render( , ); ``` `web/src/styles.css` — minimal, design pass comes with real features: ```css :root { color-scheme: light dark; font-family: system-ui, sans-serif; } body { margin: 0; } .shell, .gate { max-width: 640px; margin: 0 auto; padding: 24px; } nav { display: flex; gap: 16px; margin-bottom: 24px; } form { display: flex; flex-direction: column; gap: 12px; max-width: 360px; } input { padding: 8px; } .error { color: crimson; } ``` - [ ] **Step 4: Verify build + manual smoke** Run: `bun install && bun run typecheck && bun run build:web` Expected: all exit 0, `web/dist/index.html` exists. Manual smoke: `bun server/src/index.ts & (cd web && bunx vite) ` → open http://localhost:5173 → create password, log in, save a fake key, see `…abcd` mask. Kill both. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(web): SPA shell — gate, settings, today placeholder" ``` --- ### Task 7: Static serving + Docker + compose **Files:** - Modify: `server/src/app.ts` (serve `web/dist`), `.gitea/workflows/ci.yml` (build web too) - Create: `Dockerfile`, `docker-compose.yml`, `docs/deploy.md` **Interfaces:** - Consumes: `web/dist` (Task 6), `createApp` (Task 4). - Produces: `ghcr-style` single image; compose file users copy; SPA fallback so client routes deep-link. - [ ] **Step 1: Serve the SPA from Hono** In `server/src/app.ts` add after the API mounts: ```ts import { serveStatic } from "hono/bun"; // …inside createApp, after API routes: app.use("/*", serveStatic({ root: "./web/dist" })); app.get("/*", serveStatic({ path: "./web/dist/index.html" })); // SPA fallback ``` - [ ] **Step 2: Dockerfile** ```dockerfile FROM oven/bun:1 AS build WORKDIR /app COPY package.json bun.lock* ./ COPY server/package.json server/ COPY web/package.json web/ COPY shared/package.json shared/ RUN bun install --frozen-lockfile COPY . . RUN bun run build:web FROM oven/bun:1-slim WORKDIR /app COPY --from=build /app /app ENV DATA_DIR=/data EXPOSE 3000 CMD ["bun", "server/src/index.ts"] ``` `docker-compose.yml`: ```yaml services: helios: build: . ports: - "3000:3000" volumes: - ./data:/data restart: unless-stopped ``` - [ ] **Step 3: docs/deploy.md** ```markdown # Deploying Helios git clone https://git.rehbock.xyz/marcus/helios.git && cd helios docker compose up -d Open http://your-host:3000 and create the instance password. Back up the `./data` directory — it holds the SQLite DB, uploads, and `secret.key`. Without `secret.key` your stored API keys are unrecoverable. Run the host disk encrypted; Helios encrypts credentials column-level but not the whole DB file. ## Reverse proxy (Caddy) helios.example.com { reverse_proxy localhost:3000 } ``` - [ ] **Step 4: CI builds the image** In `.gitea/workflows/ci.yml` add `- run: bun run build:web` after typecheck (keeps CI honest about the web build; image publishing waits for a release milestone). - [ ] **Step 5: Verify** Run: `bun run build:web && (bun server/src/index.ts &) && sleep 1 && curl -s localhost:3000/ | grep -q '
' && curl -s localhost:3000/api/health && kill %1` Expected: HTML shell served + `{"ok":true}`. If Docker is available locally: `docker compose up --build -d && curl -s localhost:3000/api/health && docker compose down`. - [ ] **Step 6: Commit + push, verify CI, close out** ```bash git add -A && git commit -m "feat(deploy): serve SPA from server, Dockerfile, compose, deploy docs" git push ``` Verify Gitea CI green. Update Linear: mark the M1 issue Done with a closing comment listing what shipped. --- ## Self-Review (done at write time) - **Spec coverage:** M1 = milestone 1 of the spec (scaffold, CI, Docker, auth, settings + encryption). Dashboards/connectors/chat/labs/briefs are M2+ by design. ✔ - **Placeholders:** none — every step has concrete code/commands. ✔ - **Type consistency:** `createApp({ db, key })` used in Tasks 4–7; `getSetting(db, key, cryptoKey)` exported in Task 5 for later milestones; cookie name `helios_session` consistent. ✔ - **Deviation from spec:** test runner is `bun test`, not Vitest (bun:sqlite constraint) — spec amended in Task 1 Step 5.