1174 lines
38 KiB
Markdown
1174 lines
38 KiB
Markdown
# 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.
|