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

38 KiB
Raw Blame History

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:

{
  "name": "helios",
  "private": true,
  "workspaces": ["server", "web", "shared"],
  "scripts": {
    "typecheck": "bunx tsc -b server shared",
    "test": "bun test server"
  }
}

tsconfig.base.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:

# 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:

{ "name": "@helios/shared", "version": "0.0.1", "exports": { ".": "./src/types.ts" }, "dependencies": { "zod": "^3.24.0" } }

shared/tsconfig.json:

{ "extends": "../tsconfig.base.json", "include": ["src"] }

shared/src/types.ts:

import { z } from "zod";

export const HealthResponse = z.object({ ok: z.literal(true) });
export type HealthResponse = z.infer<typeof HealthResponse>;

server/package.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:

{ "extends": "../tsconfig.base.json", "include": ["src", "test"], "references": [{ "path": "../shared" }] }

server/src/index.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:

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
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:

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:

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:

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:

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
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:

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:

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
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:

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:

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:

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:

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):

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
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:

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:

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:

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
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:

{
  "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:

{
  "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:

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3000" } },
});

web/index.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:

{
  "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:

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:

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:

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:

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:

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:

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:

: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
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:

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
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:

services:
  helios:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./data:/data
    restart: unless-stopped
  • Step 3: docs/deploy.md
# 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
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.