Files
helios/docs/superpowers/plans/2026-08-18-m1-scaffold.md
marcuspaico e55de20573 docs: M1 scaffold implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 13:52:00 -07:00

1174 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<typeof HealthResponse>;
```
`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<typeof drizzle>` 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<typeof openDb>;
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 `<dataDir>/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<typeof MeResponse>;
```
- [ ] **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<string, { count: number; resetAt: number }>();
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<boolean> {
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<typeof SettingsResponse>;
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<typeof SettingsUpdate>;
```
- [ ] **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<string | undefined> {
return (await db.select().from(settings).where(eq(settings.key, key))).at(0)?.value;
}
async function put(db: Db, key: string, value: string): Promise<void> {
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<string | undefined> {
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
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Helios</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
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<T>(path: string, init?: RequestInit): Promise<T> {
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<MeResponse>("/api/me"),
setup: (password: string) => req<void>("/api/setup", { method: "POST", body: JSON.stringify({ password }) }),
login: (password: string) => req<void>("/api/login", { method: "POST", body: JSON.stringify({ password }) }),
logout: () => req<void>("/api/logout", { method: "POST" }),
getSettings: () => req<SettingsResponse>("/api/settings"),
putSettings: (body: SettingsUpdate) => req<void>("/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<string | null>(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 (
<main className="gate">
<h1>Helios</h1>
<p>{needsSetup ? "Create the password for this instance." : "Enter your password."}</p>
<form onSubmit={submit}>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={needsSetup ? "New password (min 8 chars)" : "Password"}
autoFocus
/>
<button type="submit">{needsSetup ? "Create password" : "Log in"}</button>
</form>
{error && <p className="error">{error}</p>}
</main>
);
}
```
`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<SettingsResponse | null>(null);
const [llmBaseUrl, setLlmBaseUrl] = useState("");
const [llmModel, setLlmModel] = useState("");
const [llmKey, setLlmKey] = useState("");
const [status, setStatus] = useState<string | null>(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 <p>Loading…</p>;
return (
<section>
<h2>AI provider</h2>
<p>Any OpenAI-compatible endpoint. OpenRouter by default; point at Ollama for a fully local setup.</p>
<form onSubmit={save}>
<label>Base URL <input value={llmBaseUrl} onChange={(e) => setLlmBaseUrl(e.target.value)} /></label>
<label>Model <input value={llmModel} onChange={(e) => setLlmModel(e.target.value)} /></label>
<label>
API key{current.llmKeyMasked ? ` (saved: ${current.llmKeyMasked})` : ""}
<input type="password" value={llmKey} onChange={(e) => setLlmKey(e.target.value)} placeholder="paste to replace" />
</label>
<button type="submit">Save</button>
</form>
{status && <p>{status}</p>}
</section>
);
}
```
`web/src/pages/Today.tsx`:
```tsx
export function Today() {
return (
<section>
<h2>Today</h2>
<p>No data yet. Connect a wearable in Settings once connectors land (M2).</p>
</section>
);
}
```
`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<MeResponse | null>(null);
const refresh = useCallback(() => { api.me().then(setMe); }, []);
useEffect(refresh, [refresh]);
if (!me) return null;
if (!me.authenticated) return <Gate needsSetup={me.needsSetup} onDone={refresh} />;
return (
<div className="shell">
<nav>
<Link to="/">Today</Link>
<Link to="/settings">Settings</Link>
<button onClick={() => api.logout().then(refresh)}>Log out</button>
</nav>
<Routes>
<Route path="/" element={<Today />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</div>
);
}
```
`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(
<BrowserRouter>
<App />
</BrowserRouter>,
);
```
`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 '<div id="root">' && 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.