38 KiB
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_DIRenv, default/data(falls back to./dataoutside Docker). - TypeScript
strict: trueeverywhere;bun run typecheckmust 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 typecheckandbun testas 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): DbwhereDb = ReturnType<typeof drizzle>with schema{ settings, sessions }; runs migrations on open. Tablesettings:key: text PK,value: text. Tablesessions: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): stringreturning"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}→ setshelios_sessionhttpOnly 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:
createAppdeps,encrypt/decrypt/mask. -
Produces:
GET /api/settings→SettingsResponse { llmBaseUrl: string, llmModel: string, llmKeyMasked: string | null };PUT /api/settingsacceptsSettingsUpdate { llmBaseUrl?, llmModel?, llmKey? }(any subset), storesllm_keyencrypted, 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 viagetSetting(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.jsonscripts
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 onneedsSetup). -
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(serveweb/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-stylesingle 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 namehelios_sessionconsistent. ✔ - Deviation from spec: test runner is
bun test, not Vitest (bun:sqlite constraint) — spec amended in Task 1 Step 5.