feat: SQLite-backed installations, REST API, admin auth and web UI

Replaces the slice-2 hardcoded installation with a full install/deploy
pipeline:

- SQLite persistence (better-sqlite3) with a single `installations`
  table and a draft -> cloning -> building -> built -> starting ->
  running/error/stopped state machine; env vars and bearer tokens are
  encrypted at rest (AES-256-GCM, key derived from MASTER_KEY).
- Build/run orchestration: git clone (+ .env.example key detection),
  `nixpacks build` as a logged subprocess, dockerode container
  run/stop with forced host/port env vars and a TCP-connect health
  check before flipping an installation to "running".
- REST API (installations CRUD, build/deploy/redeploy, logs, token
  regeneration) behind single-operator session-cookie auth with a
  basic login lockout.
- Vite+React admin SPA: login, installation list, install wizard, and
  a detail page to configure env vars, trigger build/deploy/redeploy,
  view the build log, and copy the authenticated /mcp/<slug> URL.
- Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and
  docker-compose.yml for production deployment.

Validated end-to-end against a real cloned repo: draft creation with
env-key detection, build, the error path on a failed health check, and
delete, all through the live API.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:42:06 +02:00
parent 309e6aedc3
commit b8f5d8253f
33 changed files with 3502 additions and 42 deletions

View File

@@ -0,0 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { config } from "../config/env.js";
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
export const db = new Database(config.dbPath);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS installations (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
repo_url TEXT NOT NULL,
git_ref TEXT,
status TEXT NOT NULL,
status_detail TEXT,
image_tag TEXT,
container_id TEXT,
container_name TEXT NOT NULL,
internal_port INTEGER NOT NULL DEFAULT 8080,
http_path TEXT NOT NULL DEFAULT '/mcp',
port_env_var TEXT NOT NULL DEFAULT 'PORT',
host_env_var TEXT NOT NULL DEFAULT 'HOST',
bearer_token_encrypted TEXT,
env_json_encrypted TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);

View File

@@ -0,0 +1,192 @@
import crypto from "node:crypto";
import { db } from "./index.js";
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
export type InstallationStatus =
| "draft"
| "cloning"
| "building"
| "built"
| "starting"
| "running"
| "stopped"
| "error";
export interface Installation {
id: string;
slug: string;
name: string;
repoUrl: string;
gitRef: string | null;
status: InstallationStatus;
statusDetail: string | null;
imageTag: string | null;
containerId: string | null;
containerName: string;
internalPort: number;
httpPath: string;
portEnvVar: string;
hostEnvVar: string;
env: Record<string, string>;
bearerToken: string | null;
createdAt: string;
updatedAt: string;
}
interface Row {
id: string;
slug: string;
name: string;
repo_url: string;
git_ref: string | null;
status: InstallationStatus;
status_detail: string | null;
image_tag: string | null;
container_id: string | null;
container_name: string;
internal_port: number;
http_path: string;
port_env_var: string;
host_env_var: string;
bearer_token_encrypted: string | null;
env_json_encrypted: string | null;
created_at: string;
updated_at: string;
}
function rowToInstallation(row: Row): Installation {
return {
id: row.id,
slug: row.slug,
name: row.name,
repoUrl: row.repo_url,
gitRef: row.git_ref,
status: row.status,
statusDetail: row.status_detail,
imageTag: row.image_tag,
containerId: row.container_id,
containerName: row.container_name,
internalPort: row.internal_port,
httpPath: row.http_path,
portEnvVar: row.port_env_var,
hostEnvVar: row.host_env_var,
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function slugify(name: string): string {
return name
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export function createInstallation(input: { name: string; slug: string; repoUrl: string; gitRef?: string }): Installation {
const id = crypto.randomUUID();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO installations
(id, slug, name, repo_url, git_ref, status, container_name, created_at, updated_at)
VALUES (@id, @slug, @name, @repoUrl, @gitRef, 'draft', @containerName, @now, @now)`,
).run({
id,
slug: input.slug,
name: input.name,
repoUrl: input.repoUrl,
gitRef: input.gitRef ?? null,
containerName: `mcp-${input.slug}`,
now,
});
return getInstallationById(id)!;
}
export function getInstallationById(id: string): Installation | undefined {
const row = db.prepare("SELECT * FROM installations WHERE id = ?").get(id) as Row | undefined;
return row ? rowToInstallation(row) : undefined;
}
export function getInstallationBySlug(slug: string): Installation | undefined {
const row = db.prepare("SELECT * FROM installations WHERE slug = ?").get(slug) as Row | undefined;
return row ? rowToInstallation(row) : undefined;
}
export function listInstallations(): Installation[] {
const rows = db.prepare("SELECT * FROM installations ORDER BY created_at DESC").all() as Row[];
return rows.map(rowToInstallation);
}
export interface InstallationUpdate {
status?: InstallationStatus;
statusDetail?: string | null;
imageTag?: string | null;
containerId?: string | null;
internalPort?: number;
httpPath?: string;
portEnvVar?: string;
hostEnvVar?: string;
env?: Record<string, string>;
bearerToken?: string | null;
gitRef?: string | null;
}
export function updateInstallation(id: string, patch: InstallationUpdate): Installation {
const current = getInstallationById(id);
if (!current) throw new Error(`installation ${id} not found`);
const next = {
status: patch.status ?? current.status,
statusDetail: patch.statusDetail !== undefined ? patch.statusDetail : current.statusDetail,
imageTag: patch.imageTag !== undefined ? patch.imageTag : current.imageTag,
containerId: patch.containerId !== undefined ? patch.containerId : current.containerId,
internalPort: patch.internalPort ?? current.internalPort,
httpPath: patch.httpPath ?? current.httpPath,
portEnvVar: patch.portEnvVar ?? current.portEnvVar,
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
env: patch.env ?? current.env,
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
};
db.prepare(
`UPDATE installations SET
status = @status,
status_detail = @statusDetail,
image_tag = @imageTag,
container_id = @containerId,
internal_port = @internalPort,
http_path = @httpPath,
port_env_var = @portEnvVar,
host_env_var = @hostEnvVar,
env_json_encrypted = @envJsonEncrypted,
bearer_token_encrypted = @bearerTokenEncrypted,
git_ref = @gitRef,
updated_at = @updatedAt
WHERE id = @id`,
).run({
id,
status: next.status,
statusDetail: next.statusDetail,
imageTag: next.imageTag,
containerId: next.containerId,
internalPort: next.internalPort,
httpPath: next.httpPath,
portEnvVar: next.portEnvVar,
hostEnvVar: next.hostEnvVar,
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
gitRef: next.gitRef,
updatedAt: new Date().toISOString(),
});
return getInstallationById(id)!;
}
export function deleteInstallation(id: string): void {
db.prepare("DELETE FROM installations WHERE id = ?").run(id);
}