diff --git a/docs/superpowers/plans/2026-08-18-m1-scaffold.md b/docs/superpowers/plans/2026-08-18-m1-scaffold.md new file mode 100644 index 0000000..afb68ff --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-m1-scaffold.md @@ -0,0 +1,1173 @@ +# 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.