Files
ThothII/backend/src/app.ts
T

349 lines
15 KiB
TypeScript

import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AppConfig } from "./config.js";
import { ThtRunner } from "./tht/tht-runner.js";
import { PiProcessManager } from "./pi/pi-process-manager.js";
import { SseHub } from "./sse/sse-hub.js";
import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js";
import type { PrincipalContext } from "./auth/principal.js";
import type { LoadedAuthConfig } from "./auth/types.js";
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js";
import type { WindowsAuthStorageBridge } from "./auth/windows-auth-storage.js";
import { registerAuthRoutes } from "./auth/routes.js";
import { createOidcProtocol, type OidcProtocol, type OidcProtocolOptions } from "./auth/oidc-client.js";
import { isUsableAuthenticationSecret } from "./auth/secret-policy.js";
import { secretValue } from "./config/secret-bundle.js";
import { sessionRoutes } from "./routes/sessions.js";
import { sqlRoutes } from "./routes/sql.js";
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
import { createPiModelLister } from "./pi/list-models.js";
import { createPiManagement, type PiManagementService } from "./pi/management.js";
import { loadSettings, type Settings } from "./settings/settings-store.js";
import { ReadinessManager } from "./runtime/readiness-manager.js";
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
import { piManagementRoutes } from "./routes/pi-management.js";
import { supportsSessionRuntime } from "./workspaces/bindings.js";
import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./workspaces/secret-requirements.js";
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
export interface BuildAppDeps {
thtRunner?: ThtRunner;
mgr?: PiProcessManager;
spawnFn?: () => any;
listModels?: ListModelsFn;
getSettings?: (principal?: PrincipalContext) => Settings | Promise<Settings>;
readiness?: ReadinessManager;
hub?: SseHub;
workspaceRegistry?: WorkspaceRegistry;
workspaceDiagnoser?: WorkspaceDiagnoser;
workspaceSecretStore?: WorkspaceSecretStore;
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
maintenanceBarrier?: MaintenanceBarrier;
piManagement?: PiManagementService;
localUserRegistry?: LocalUserRegistry;
authSessionStore?: AuthSessionStore;
/** Explicit test-only transport seam; production always invokes the hidden tht bridge. */
authStorageBridgeForTest?: WindowsAuthStorageBridge;
oidcProtocol?: OidcProtocol;
/** Explicit test seam; production uses the provider-neutral OIDC constructor. */
oidcProtocolFactory?: (options: OidcProtocolOptions) => OidcProtocol;
}
export interface AppWithAuthSessionStore extends FastifyInstance {
thothiiAuthSessionStore?: AuthSessionStore;
}
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
app.decorateRequest("authConfigSnapshot", undefined);
app.decorateRequest("authConfigSnapshotCaptured", false);
app.decorateRequest("authConfigSnapshotUnavailable", false);
const isolatedTestRoot = process.env.VITEST === "true"
? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`)
: undefined;
const workspaceSecretStore = deps?.workspaceSecretStore ?? new WorkspaceSecretStore({
root: isolatedTestRoot ?? config.workspaceSecretStoreRoot,
runtimeRoot: isolatedTestRoot === undefined
? config.workspaceSecretRuntimeRoot
: join(isolatedTestRoot, "runtime"),
installationId: config.workspaceRegistry.installationId,
});
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
app.register(cors, {
// The delegator runs at CORS's onRequest hook. It owns the one request-scoped config load
// which subsequent auth hooks and routes consume, including preflights that end here.
delegator: (request, callback) => {
const snapshot = captureAuthConfigSnapshot(request, config.authentication);
const origin = configuredOrigin(snapshot);
const snapshotUsesCookies = snapshot?.value.mode === "local" || snapshot?.value.mode === "oidc";
callback(null, {
origin: snapshotUsesCookies && origin ? corsOrigin(request, origin) : cookieAuth ? false : true,
credentials: snapshotUsesCookies,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
});
},
});
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
app.register(cookie);
app.register(rateLimit, { global: false });
const tht = deps?.thtRunner ?? new ThtRunner({
thtBin: config.thtBin,
harnessDir: config.harnessDir,
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
dataRoot: config.dataRoot,
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
secretRoots: config.workspaceRegistry.secretRoots,
secretsFile: config.secretsFile,
secretFiles: config.secretFiles,
workspaceSecretStore,
semanticRuntime: {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
},
});
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
const hub = deps?.hub ?? new SseHub();
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
const workspaceDiagnoser = deps?.workspaceDiagnoser
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
});
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => {
const lease = resolveRuntimeBindingsWithWorkspaceSecrets(
workspace,
process.env,
config.workspaceRegistry.secretRoots,
workspaceSecretStore,
);
try {
return supportsSessionRuntime(lease.bindings);
} finally {
lease.release();
}
});
const readiness = deps?.readiness ?? new ReadinessManager(
tht as ThtRunner,
Math.round(config.ollamaEnsureTimeoutMs / 1000),
);
const listModels = deps?.listModels ?? createPiModelLister(config, {
warn: (detail) => app.log.warn(
{ component: "pi-model-list", detail },
"Pi enabled-model configuration warning",
),
});
const runnerFor = (principal: PrincipalContext): any => {
const candidate = tht as any;
return typeof candidate.withPrincipal === "function" ? candidate.withPrincipal(principal) : candidate;
};
const getSettings = async (principal: PrincipalContext): Promise<Settings> => {
if (deps?.getSettings) return await deps.getSettings(principal);
const stored = loadSettings(config);
const effective = effectiveSettings(config, stored);
// In the registry system the legacy `harness/workspaces/*.yaml` default is obsolete: when no
// installation workspace is pinned, default to the first active registry workspace.
if (!stored.workspace) {
try {
const revisions = await workspaceRegistry.list();
if (revisions.length > 0) effective.workspace = revisions[0].id;
} catch {
// Registry not bootstrapped yet; keep the legacy fallback.
}
}
return effective;
};
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
const localRegistryResolver = deps?.localUserRegistry === undefined
? createCurrentLocalUserRegistryResolver()
: undefined;
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
};
const localUserForSnapshot = async (loaded: LoadedAuthConfig, subject: string) => {
try {
if (loaded.value.mode !== "local") return { revision: loaded.revision, user: undefined };
const registry = resolveLocalUserRegistry(loaded);
if (!registry) throw new AuthSessionOperationalError();
const user = await registry.findBySubject(subject);
return {
revision: loaded.revision,
user: user === undefined ? undefined : {
enabled: user.enabled,
authRevision: user.authRevision,
roles: user.roles,
},
};
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
throw new AuthSessionOperationalError();
}
};
const sessionValidityForSnapshot = (loaded: LoadedAuthConfig): AuthSessionValidity => ({
currentAuthConfigRevision: () => loaded.revision,
currentLocalUser: (subject) => localUserForSnapshot(loaded, subject),
});
const resolveOidcProtocol = (loaded: LoadedAuthConfig): OidcProtocol | undefined => {
if (deps?.oidcProtocol) return deps.oidcProtocol;
if (loaded.value.mode !== "oidc") return undefined;
try {
const clientSecret = secretValue(config, loaded.value.oidc.clientSecretRef);
if (!isUsableAuthenticationSecret("THT_OIDC_CLIENT_SECRET", clientSecret)) return undefined;
return (deps?.oidcProtocolFactory ?? createOidcProtocol)({
issuer: loaded.value.oidc.issuer,
clientId: loaded.value.oidc.clientId,
clientSecret,
callbackUrl: new URL("/api/auth/oidc/callback", loaded.value.publicUrl).href,
scopes: loaded.value.oidc.scopes,
groupsClaim: loaded.value.oidc.groupsClaim,
});
} catch {
return undefined;
}
};
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => {
try {
return config.authentication?.current().revision ?? "";
} catch {
throw new AuthSessionOperationalError();
}
},
currentLocalUser: async (subject) => {
try {
const loaded = config.authentication?.current();
if (!loaded) return { revision: "", user: undefined };
return await localUserForSnapshot(loaded, subject);
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
throw new AuthSessionOperationalError();
}
},
}, deps?.authStorageBridgeForTest === undefined
? undefined
: process.platform === "win32"
? { windowsStorageBridge: deps.authStorageBridgeForTest }
: { posixStorageBridge: deps.authStorageBridgeForTest })
: undefined);
(app as AppWithAuthSessionStore).thothiiAuthSessionStore = authSessionStore;
const authenticate = authenticateSession({
mode: config.authMode,
publicExposure: config.publicExposure,
authentication: config.authentication,
sessionStore: authSessionStore,
sessionValidityForSnapshot,
});
app.addHook("preHandler", (req, reply, done) => {
if (isMaintenanceControl(req.url)) {
if (!isLoopback(req.ip)) {
reply.code(403).send({ error: "loopback maintenance control required" });
}
}
done();
});
app.addHook("preHandler", authenticate);
app.get("/health", async () => ({ status: "ok" }));
app.get("/health/dwh", async () => {
// In the registry system there is no single legacy DWH config: ping the first active
// workspace's rendered runtime config. If the registry is not bootstrapped yet, do not
// block the app — per-workspace diagnostics and the session precheck own reachability.
try {
const revisions = await workspaceRegistry.list();
if (revisions.length > 0) {
return await tht.dbPing(revisions[0].snapshotPath);
}
} catch {
// fall through
}
return { ok: true, detail: "workspace diagnostics own DWH reachability" };
});
registerAuthRoutes(app, {
authMode: config.authMode,
authentication: config.authentication,
sessionStore: authSessionStore,
localUserRegistry: deps?.localUserRegistry,
resolveLocalUserRegistry,
resolveOidcProtocol,
});
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
dwhPrecheck: config.dwhPrecheck,
legacyWorkspaceMode: config.legacyWorkspaceMode,
workspaceRuntimeSupport,
maintenanceBarrier,
});
app.post("/internal/maintenance/activate", async (req, reply) => {
try {
await maintenanceBarrier.activate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
code: "maintenance_durability_failed",
error: "maintenance activation durability was not acknowledged",
});
}
});
app.post("/internal/maintenance/deactivate", async (req, reply) => {
try {
maintenanceBarrier.deactivate();
return maintenanceBarrier.status();
} catch {
return reply.code(500).send({
...maintenanceBarrier.status(),
code: "maintenance_durability_failed",
error: "maintenance deactivation durability was not acknowledged",
});
}
});
app.get("/internal/maintenance/status", async (req, reply) => {
return maintenanceBarrier.status();
});
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
workspaceRoutes(app, {
registry: workspaceRegistry,
config: config.workspaceRegistry,
diagnose: workspaceDiagnoser,
secretStore: workspaceSecretStore,
});
settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { service: piManagement });
return app;
}
function corsOrigin(request: FastifyRequest, expectedOrigin: string): string | false {
const supplied = request.headers.origin;
if (typeof supplied !== "string") return false;
try {
return new URL(supplied).origin === expectedOrigin ? expectedOrigin : false;
} catch {
return false;
}
}
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
function isMaintenanceControl(url: string): boolean {
return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url);
}