fix(deploy): close container final review

This commit is contained in:
2026-07-12 00:44:39 +02:00
parent 0ca6346f75
commit a3a266fd81
30 changed files with 526 additions and 37 deletions
+6 -1
View File
@@ -42,7 +42,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const listModels = deps?.listModels ?? createPiModelLister(config);
const getSettings = deps?.getSettings ?? (() => effectiveSettings(config, loadSettings(config)));
app.addHook("preHandler", authPreHandler(config.authMode));
const authenticate = authPreHandler(config.authMode);
app.addHook("preHandler", async (req, reply) => {
// Process readiness is intentionally unauthenticated for local container/proxy probes.
if (req.url === "/health") return;
return authenticate(req, reply);
});
app.get("/health", async () => ({ status: "ok" }));
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings,
+6 -3
View File
@@ -1,6 +1,6 @@
import type { FastifyRequest, FastifyReply } from "fastify";
export function authPreHandler(mode: "none" | "mock" | "oidc") {
export function authPreHandler(mode: "none" | "mock" | "upstream") {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") {
(req as any).user = { id: "dev@local" };
@@ -9,8 +9,11 @@ export function authPreHandler(mode: "none" | "mock" | "oidc") {
id: (req.headers["x-mock-user"] as string) ?? "mock",
};
} else {
reply.code(501);
throw new Error("OIDC non configurato (MVP: usa none/mock)");
const id = req.headers["x-authenticated-user"];
if (typeof id !== "string" || id.trim() === "") {
return reply.code(401).send({ error: "authenticated upstream identity required" });
}
(req as any).user = { id };
}
};
}
+9 -2
View File
@@ -1,6 +1,6 @@
export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "oidc";
authMode: "none" | "mock" | "upstream";
defaults: { provider?: string; model?: string; thinking?: string };
maxPiProcesses: number;
settingsFile: string;
@@ -8,13 +8,20 @@ export interface AppConfig {
ollamaEnsureTimeoutMs: number;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authMode = env.AUTH_MODE ?? "none";
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`);
}
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
}
return {
host: env.HOST ?? "127.0.0.1",
port: Number(env.PORT ?? 8787),
harnessDir: env.THT_HARNESS_DIR ?? "../harness",
thtBin: env.THT_BIN ?? "tht",
piBin: env.PI_BIN ?? "pi",
authMode: (env.AUTH_MODE as AppConfig["authMode"]) ?? "none",
authMode: authMode as AppConfig["authMode"],
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
+14
View File
@@ -22,3 +22,17 @@ test("mode mock legge l'header", async () => {
});
expect(res.json()).toEqual({ id: "alice" });
});
test("upstream mode requires the authenticated proxy identity header", async () => {
const app = Fastify();
app.addHook("preHandler", authPreHandler("upstream"));
app.get("/me", async (req) => getUser(req));
expect((await app.inject({ method: "GET", url: "/me" })).statusCode).toBe(401);
const authenticated = await app.inject({
method: "GET",
url: "/me",
headers: { "x-authenticated-user": "alice@example.test" },
});
expect(authenticated.json()).toEqual({ id: "alice@example.test" });
});
+14
View File
@@ -32,3 +32,17 @@ test("loadConfig keeps local development defaults", () => {
});
expect(loadConfig({}).dataRoot).toBeUndefined();
});
test("loadConfig rejects unauthenticated public exposure", () => {
expect(() => loadConfig({
THOTH_PUBLIC_EXPOSURE: "true",
AUTH_MODE: "none",
})).toThrow(/public exposure requires AUTH_MODE=upstream/);
});
test("loadConfig accepts an authenticated upstream trust boundary", () => {
expect(loadConfig({
THOTH_PUBLIC_EXPOSURE: "true",
AUTH_MODE: "upstream",
}).authMode).toBe("upstream");
});
+11
View File
@@ -10,6 +10,17 @@ test("GET /health reports process readiness without external services", async ()
expect(res.json()).toEqual({ status: "ok" });
});
test("GET /health remains available to container probes in upstream auth mode", async () => {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "/tmp/h",
AUTH_MODE: "upstream",
THOTH_PUBLIC_EXPOSURE: "true",
}));
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok" });
});
test("SSE response headers are flushed before the first event", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
await app.listen({ port: 0, host: "127.0.0.1" });