feat(backend): enforce user-owned sessions

This commit is contained in:
User
2026-07-16 18:32:52 +02:00
parent 72db6b823d
commit 458eb13c89
15 changed files with 626 additions and 105 deletions
+172 -41
View File
@@ -3,7 +3,8 @@ import type { PiProcessManager } from "../pi/pi-process-manager.js";
import type { ThtRunner } from "../tht/tht-runner.js";
import type { SseHub } from "../sse/sse-hub.js";
import type { Settings } from "../settings/settings-store.js";
import { getUser } from "../auth/auth.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
const BOOTSTRAP_FAILURE_MESSAGE =
@@ -25,7 +26,11 @@ function eventCursor(...values: unknown[]): number {
export function sessionRoutes(
app: FastifyInstance,
d: { mgr: PiProcessManager; tht: ThtRunner; hub: SseHub; getSettings: () => Settings; readiness: ReadinessManager },
d: {
mgr: PiProcessManager; tht: ThtRunner; hub: SseHub;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
readiness: ReadinessManager;
},
) {
const lifecycleTails = new Map<string, Promise<void>>();
const boundRuntimes = new Map<
@@ -54,7 +59,34 @@ export function sessionRoutes(
const info = (id: string, text: string, level = "info") =>
d.hub.publish(id, "info", { type: "info", level, text });
const bindRuntime = (id: string, rt: ReturnType<PiProcessManager["createFor"]>) => {
const runnerFor = (principal: PrincipalContext): any => {
const runner = d.tht as any;
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const isNotFound = (error: unknown) =>
/not found|non trovata|inesistente|404/i.test(error instanceof Error ? error.message : String(error));
/** RLS makes a foreign session indistinguishable from a missing one. */
const authorize = async (principal: PrincipalContext, id: string, workspace?: string): Promise<any | undefined> => {
try {
const runner = runnerFor(principal);
// Dependency-injected runners in legacy route tests may model only the mutation under
// test. Production ThtRunner always exposes sessionShow; keep that test seam harmless.
if (typeof runner.sessionShow !== "function") return {};
const manifest = await runner.sessionShow(id, workspace);
return manifest ?? undefined;
} catch (error) {
if (isNotFound(error)) return undefined;
throw error;
}
};
const storageFailure = (reply: any) => reply.code(503).send({ error: "session storage is unavailable" });
const bindRuntime = (
id: string, rt: ReturnType<PiProcessManager["createFor"]>, runner: any, workspace?: string,
) => {
const previous = boundRuntimes.get(id);
boundRuntimes.set(id, rt);
try {
@@ -72,7 +104,7 @@ export function sessionRoutes(
// old failure must not touch its manifest.
const bound = boundRuntimes.get(id);
if (bound !== undefined && bound !== rt) return;
await d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
await runner.failSession(id, workspace).catch(() => undefined);
}).catch(() => undefined);
}
}
@@ -96,6 +128,8 @@ export function sessionRoutes(
const bootstrap = (
id: string,
rt: ReturnType<PiProcessManager["createFor"]>,
runner: any,
workspace: string | undefined,
configure: Promise<void>,
retrieval: Promise<void> | null,
start: () => void,
@@ -117,7 +151,7 @@ export function sessionRoutes(
if (d.mgr.get(id) !== rt || !d.mgr.teardownIfCurrent(id, rt)) return;
if (!failurePersistenceClaimed.has(rt)) {
failurePersistenceClaimed.add(rt);
await d.tht.failSession(id, d.getSettings().workspace).catch(() => undefined);
await runner.failSession(id, workspace).catch(() => undefined);
}
rt.bridge.emitClientEvent({ type: "info", level: "error", text: BOOTSTRAP_FAILURE_MESSAGE });
rt.bridge.emitClientEvent({ type: "system_event", event: "session_failed" });
@@ -127,62 +161,105 @@ export function sessionRoutes(
})();
};
app.post("/runtime/prewarm", async (_req, reply) => {
const workspace = d.getSettings().workspace ?? "";
void d.readiness.ensure(workspace).catch(() => undefined);
app.post("/runtime/prewarm", async (req, reply) => {
let settings: Settings;
try { settings = await d.getSettings(getPrincipal(req)); } catch { return storageFailure(reply); }
const workspace = settings.workspace ?? "";
void d.readiness.ensure(workspace, getPrincipal(req)).catch(() => undefined);
return reply.code(202).send({ status: "warming" });
});
app.post("/sessions", async (req, reply) => {
const b = req.body as { question: string; name?: string };
const s = d.getSettings();
const ensure = await d.readiness.ensure(s.workspace ?? "");
const principal = getPrincipal(req);
let s: Settings;
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
const runner = runnerFor(principal);
const ensure = await d.readiness.ensure(s.workspace ?? "", principal);
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
// Settings (global) supply workspace/provider/model/thinking. The new-question
// form sends only the question text. `workspace` selects the tht `-c <config>`.
const { id } = await d.tht.sessionNew({
question: b.question,
name: b.name,
workspace: s.workspace,
provider: s.provider,
model: s.model,
thinking: s.thinking,
});
let id: string;
try {
({ id } = await runner.sessionNew({
question: b.question, name: b.name, workspace: s.workspace,
provider: s.provider, model: s.model, thinking: s.thinking,
}));
} catch { return storageFailure(reply); }
const options = {
provider: s.provider,
model: s.model,
thinking: s.thinking,
author: getUser(req).id,
author: principal.displayName ?? principal.subject,
principal,
question: b.question,
};
const rt = d.mgr.createFor(id, options);
bindRuntime(id, rt);
bindRuntime(id, rt, runner, s.workspace);
info(id, "Session created");
bootstrap(
id, rt, d.mgr.configure(rt, options),
d.tht.searchPack(b.question, id, s.workspace),
id, rt, runner, s.workspace, d.mgr.configure(rt, options),
runner.searchPack(b.question, id, s.workspace),
() => d.mgr.start(id, rt, options),
);
return { id };
});
app.get("/sessions", async () => d.tht.sessionList(d.getSettings().workspace));
app.get("/sessions/:id", async (req) => d.tht.sessionShow((req.params as any).id, d.getSettings().workspace));
app.get("/sessions", async (req, reply) => {
const principal = getPrincipal(req);
const scope = (req.query as { scope?: string }).scope ?? "mine";
if (scope !== "mine" && scope !== "all") return reply.code(400).send({ error: "scope must be mine or all" });
if (scope === "all" && !principal.isAdmin) return reply.code(403).send({ error: "admin scope required" });
try {
const settings = await d.getSettings(principal);
// Admin RLS is deliberately disabled for a normal 'mine' listing.
const scopedPrincipal = scope === "mine" ? { ...principal, isAdmin: false } : principal;
return await runnerFor(scopedPrincipal).sessionList(settings.workspace);
} catch { return storageFailure(reply); }
});
app.get("/sessions/:id", async (req, reply) => {
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
const manifest = await authorize(principal, (req.params as any).id, settings.workspace);
return manifest ?? reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
});
app.post("/sessions/:id/response", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
rt.bridge.respond((req.body as any).ui_response);
return reply.code(204).send();
});
app.post("/sessions/:id/steer", async (req, reply) => {
const rt = d.mgr.get((req.params as any).id);
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
const rt = d.mgr.get(id);
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
rt.bridge.steer((req.body as any).text);
return reply.code(204).send();
});
app.post("/sessions/:id/resume", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
let manifest: any;
try {
settings = await d.getSettings(principal);
manifest = await authorize(principal, id, settings.workspace);
} catch { return storageFailure(reply); }
if (!manifest) return reply.code(404).send({ error: "session not found" });
const runner = runnerFor(principal);
// This check belongs inside the per-session lock: a preceding cold Resume may have
// installed a running runtime while this request was waiting.
const existing = d.mgr.get(id);
@@ -192,26 +269,25 @@ export function sessionRoutes(
return reply.code(200).send({ id, alreadyActive: true });
}
}
const manifest = (await d.tht.sessionShow(id, d.getSettings().workspace)) as { status?: string; archived?: boolean } | null;
if (manifest?.status === "finalized" || manifest?.archived) {
return reply.code(409).send({ error: "sessione in sola lettura (finalizzata o archiviata)" });
}
const settings = d.getSettings();
const ensure = await d.readiness.ensure(settings.workspace ?? "");
const ensure = await d.readiness.ensure(settings.workspace ?? "", principal);
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
const saved = manifest as { provider?: string; model?: string; thinking?: string } | null;
const options = {
provider: saved?.provider,
model: saved?.model,
thinking: saved?.thinking ?? settings.thinking,
author: getUser(req).id,
author: principal.displayName ?? principal.subject,
principal,
mode: "resume" as const,
};
// Reopening is validation, not the transport commit point. Keep the old hub intact if
// persistence cannot be reopened.
try {
await d.tht.reopenSession(id, settings.workspace);
await runner.reopenSession(id, settings.workspace);
} catch {
return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE });
}
@@ -233,7 +309,7 @@ export function sessionRoutes(
d.mgr.teardownIfCurrent(id, current);
}
rt = d.mgr.createFor(id, options);
bindRuntime(id, rt);
bindRuntime(id, rt, runner, settings.workspace);
} catch {
// A created-but-unbound runtime is not usable. The old hub remains attached because
// clear() has not happened yet.
@@ -248,28 +324,41 @@ export function sessionRoutes(
// immediately before the first event produced by the new Resume.
d.hub.clear(id);
info(id, "Resuming session");
bootstrap(id, rt, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
bootstrap(id, rt, runner, settings.workspace, d.mgr.configure(rt, options), null, () => d.mgr.start(id, rt, options));
return reply.code(200).send({ id, alreadyActive: false });
});
});
app.post("/sessions/:id/close", async (req) => {
app.post("/sessions/:id/close", async (req, reply) => {
const id = (req.params as { id: string }).id;
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
try {
settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
// Invalidate the live generation before persistence can yield. Otherwise its deferred
// bootstrap may start Pi while Close is already in progress.
const current = d.mgr.get(id);
boundRuntimes.delete(id);
if (current) d.mgr.teardownIfCurrent(id, current);
try {
await d.tht.closeSession(id, d.getSettings().workspace);
await runnerFor(principal).closeSession(id, settings.workspace);
} catch {
return storageFailure(reply);
} finally {
d.hub.clear(id);
}
return { closed: true };
});
});
app.get("/sessions/:id/events", (req, reply) => {
app.get("/sessions/:id/events", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
const rt = d.mgr.get(id);
const afterId = eventCursor(
req.headers["last-event-id"],
@@ -303,31 +392,73 @@ export function sessionRoutes(
req.raw.on("close", off);
});
app.post("/sessions/:id/rename", async (req, reply) => {
await d.tht.setName((req.params as any).id, (req.body as any).name);
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setName(id, (req.body as any).name);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
app.post("/sessions/:id/group", async (req, reply) => {
await d.tht.setGroup((req.params as any).id, (req.body as any).group);
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).setGroup(id, (req.body as any).group);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
app.post("/sessions/:id/archive", async (req, reply) => {
await d.tht.archive((req.params as any).id);
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).archive(id);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
app.post("/sessions/:id/unarchive", async (req, reply) => {
await d.tht.unarchive((req.params as any).id);
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
await runnerFor(principal).unarchive(id);
} catch { return storageFailure(reply); }
return reply.code(204).send();
});
app.delete("/sessions/:id", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
return withSessionLifecycle(id, async () => {
let settings: Settings;
try {
settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch { return storageFailure(reply); }
const current = d.mgr.get(id);
boundRuntimes.delete(id);
if (current) d.mgr.teardownIfCurrent(id, current);
await d.tht.deleteSession(id, d.getSettings().workspace);
try {
await runnerFor(principal).deleteSession(id, settings.workspace);
} catch {
return storageFailure(reply);
}
d.hub.forget(id);
return reply.code(204).send();
});
});
app.get("/sessions/:id/documents", async (req) => d.tht.documents((req.params as any).id));
app.get("/sessions/:id/documents", async (req, reply) => {
const id = (req.params as any).id;
const principal = getPrincipal(req);
try {
const settings = await d.getSettings(principal);
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
return await runnerFor(principal).documents(id);
} catch { return storageFailure(reply); }
});
}
+19 -5
View File
@@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
import type { AppConfig } from "../config.js";
import { loadSettings, saveSettings, type Settings } from "../settings/settings-store.js";
import { listWorkspaces, type ListModelsFn } from "./meta.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
/** Merge stored settings over env/first-workspace defaults. */
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
@@ -16,10 +18,18 @@ export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
export function settingsRoutes(
app: FastifyInstance,
deps: { cfg: AppConfig; listModels: ListModelsFn },
deps: {
cfg: AppConfig; listModels: ListModelsFn;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
saveSettings: (principal: PrincipalContext, settings: Settings) => Promise<void>;
},
): void {
app.get("/settings", async () => {
return effectiveSettings(deps.cfg, loadSettings(deps.cfg));
app.get("/settings", async (req, reply) => {
try {
return await deps.getSettings(getPrincipal(req));
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
});
app.put("/settings", async (req, reply) => {
@@ -46,7 +56,11 @@ export function settingsRoutes(
model: b.model,
thinking: b.thinking,
};
saveSettings(deps.cfg, next);
return effectiveSettings(deps.cfg, next);
try {
await deps.saveSettings(getPrincipal(req), next);
return effectiveSettings(deps.cfg, next);
} catch {
return reply.code(503).send({ error: "settings storage is unavailable" });
}
});
}
+47 -9
View File
@@ -1,25 +1,63 @@
import type { FastifyInstance } from "fastify";
import type { ThtRunner } from "../tht/tht-runner.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { Settings } from "../settings/settings-store.js";
export function sqlRoutes(app: FastifyInstance, deps: { tht: ThtRunner }): void {
export function sqlRoutes(app: FastifyInstance, deps: {
tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise<Settings>;
}): void {
const runnerFor = (principal: PrincipalContext): any => {
const runner = deps.tht as any;
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
};
const authorize = async (principal: PrincipalContext, id: string, workspace?: string) => {
try {
const runner = runnerFor(principal);
if (typeof runner.sessionShow !== "function") return {};
return await runner.sessionShow(id, workspace);
}
catch (error) {
if (/not found|non trovata|inesistente|404/i.test(error instanceof Error ? error.message : String(error))) return undefined;
throw error;
}
};
app.post("/sessions/:id/sql/preview", async (req, reply) => {
const id = (req.params as any).id as string;
const { limit, offset } = (req.body as any) ?? {};
let principal: PrincipalContext;
let workspace: string | undefined;
try {
const result = await deps.tht.sqlPreview(id, { limit, offset });
return result;
} catch (err: any) {
return reply.code(500).send({ error: err.message ?? String(err) });
principal = getPrincipal(req);
const settings = await deps.getSettings(principal);
workspace = settings.workspace;
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch {
return reply.code(503).send({ error: "session storage is unavailable" });
}
try {
return await runnerFor(principal).sqlPreview(id, { limit, offset }, workspace);
} catch (error: any) {
return reply.code(500).send({ error: error.message ?? String(error) });
}
});
app.post("/sessions/:id/sql/export", async (req, reply) => {
const id = (req.params as any).id as string;
let principal: PrincipalContext;
let workspace: string | undefined;
try {
const result = await deps.tht.sqlExport(id);
return result;
} catch (err: any) {
return reply.code(500).send({ error: err.message ?? String(err) });
principal = getPrincipal(req);
const settings = await deps.getSettings(principal);
workspace = settings.workspace;
if (!await authorize(principal, id, settings.workspace)) return reply.code(404).send({ error: "session not found" });
} catch {
return reply.code(503).send({ error: "session storage is unavailable" });
}
try {
return await runnerFor(principal).sqlExport(id, workspace);
} catch (error: any) {
return reply.code(500).send({ error: error.message ?? String(error) });
}
});
}