api/src/server.tsblame
View source
3e3af551import Fastify from "fastify";
3e3af552import cors from "@fastify/cors";
3e3af553import jwt from "@fastify/jwt";
90d5eb84import multipart from "@fastify/multipart";
3c994d35import type Database from "better-sqlite3";
3e3af556import { initDatabase } from "./services/database.js";
3e3af557import { repoRoutes } from "./routes/repos.js";
d12933e8import { diffRoutes } from "./routes/diffs.js";
1da98749import { canopyRoutes, canopyGlobalRoutes, canopyWebhookRoute } from "./routes/canopy.js";
3cbdca610import { ringRoutes } from "./routes/ring.js";
80fafdf11import { CanopyRunner } from "./services/canopy-runner.js";
80fafdf12import { CanopyPoller } from "./services/canopy-poller.js";
5bcd5db13import { CanopyEventBus } from "./services/canopy-events.js";
966d71f14import { MononokeProvisioner } from "./services/mononoke-provisioner.js";
e5b523e15import { PagesDeployer } from "./services/pages-deployer.js";
3e3af5516
3e3af5517const app = Fastify({
3e3af5518 logger: {
3e3af5519 level: process.env.LOG_LEVEL ?? "info",
3e3af5520 transport:
3e3af5521 process.env.NODE_ENV !== "production"
3e3af5522 ? { target: "pino-pretty" }
3e3af5523 : undefined,
3e3af5524 },
3e3af5525});
3e3af5526
3e3af5527// Plugins
3e3af5528await app.register(cors, {
a33b2b629 origin: (process.env.CORS_ORIGIN ?? "http://localhost:3000")
a33b2b630 .split(",")
a33b2b631 .map((origin) => origin.trim())
a33b2b632 .filter(Boolean),
3e3af5533});
3e3af5534
3e3af5535await app.register(jwt, {
3c994d336 secret: process.env.JWT_SECRET ?? "grove-dev-secret",
3e3af5537});
3e3af5538
90d5eb839await app.register(multipart, {
90d5eb840 limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
90d5eb841});
90d5eb842
3e3af5543// Initialize database
3e3af5544const db = initDatabase(
3e3af5545 process.env.DATABASE_PATH ?? "./data/grove.db"
3e3af5546);
3e3af5547
3e3af5548// Make db available to routes
3e3af5549app.decorate("db", db);
3e3af5550
3c994d351// Upsert a local user record from hub JWT claims for FK references
3c994d352function ensureLocalUser(
3c994d353 database: Database.Database,
3c994d354 user: { id: number; username: string; display_name?: string }
3c994d355) {
3c994d356 database
3c994d357 .prepare(
3c994d358 `INSERT INTO users (id, username, display_name, updated_at)
3c994d359 VALUES (?, ?, ?, datetime('now'))
3c994d360 ON CONFLICT(id) DO UPDATE SET
3c994d361 username = excluded.username,
3c994d362 display_name = excluded.display_name,
3c994d363 updated_at = datetime('now')`
3c994d364 )
3c994d365 .run(user.id, user.username, user.display_name ?? user.username);
3c994d366}
3c994d367
79efd4168// Upsert a local org record from hub API for FK references
79efd4169function ensureLocalOrg(
79efd4170 database: Database.Database,
79efd4171 org: { id: number; name: string; display_name?: string }
79efd4172) {
79efd4173 database
79efd4174 .prepare(
79efd4175 `INSERT INTO orgs (id, name, display_name, updated_at)
79efd4176 VALUES (?, ?, ?, datetime('now'))
79efd4177 ON CONFLICT(id) DO UPDATE SET
79efd4178 name = excluded.name,
79efd4179 display_name = excluded.display_name,
79efd4180 updated_at = datetime('now')`
79efd4181 )
79efd4182 .run(org.id, org.name, org.display_name ?? org.name);
79efd4183}
79efd4184
79efd4185app.decorate("ensureLocalOrg", ensureLocalOrg.bind(null, db));
79efd4186
3c994d387// Auth decorator — verifies hub-signed JWTs and ensures local user exists
3c994d388app.decorate("authenticate", async function (request: any, reply: any) {
3c994d389 try {
3c994d390 await request.jwtVerify();
3c994d391 const { id, username, display_name } = request.user as any;
3c994d392 ensureLocalUser(db, { id, username, display_name });
3c994d393 } catch (err) {
3c994d394 reply.code(401).send({ error: "Unauthorized" });
3c994d395 }
3c994d396});
3c994d397
966d71f98// Mononoke repo provisioner
966d71f99const mononokeProvisioner = new MononokeProvisioner(
966d71f100 process.env.MONONOKE_CONFIG_PATH ?? "/data/grove/mononoke-config",
6c9fcae101 app.log,
6c9fcae102 process.env.GROVE_BRIDGE_URL ?? "http://grove-bridge:3100"
966d71f103);
966d71f104app.decorate("mononokeProvisioner", mononokeProvisioner);
966d71f105
fb964da106// Health check — verifies DB is accessible (catches migration failures, corruption)
fb964da107app.get("/health", async (_req, reply) => {
fb964da108 try {
fb964da109 db.prepare("SELECT 1").get();
fb964da110 return { status: "ok", service: "grove-api" };
fb964da111 } catch {
fb964da112 return reply.code(503).send({ status: "error", service: "grove-api", reason: "database unavailable" });
fb964da113 }
fb964da114});
3e3af55115
80fafdf116// Canopy CI/CD
80fafdf117const canopyEnabled = process.env.CANOPY_ENABLED === "true";
80fafdf118
5bcd5db119const canopyEventBus = new CanopyEventBus();
5bcd5db120app.decorate("canopyEventBus", canopyEventBus);
5bcd5db121
80fafdf122let runner: CanopyRunner | undefined;
80fafdf123if (canopyEnabled) {
1e64dbc124 const workspaceDir = process.env.CANOPY_WORKSPACE_DIR ?? "./data/canopy/workspaces";
80fafdf125 runner = new CanopyRunner(
80fafdf126 db,
80fafdf127 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
1e64dbc128 workspaceDir,
1e64dbc129 process.env.CANOPY_WORKSPACE_HOST_DIR ?? workspaceDir,
80fafdf130 process.env.JWT_SECRET ?? "grove-dev-secret",
5bcd5db131 app.log,
5bcd5db132 canopyEventBus
80fafdf133 );
80fafdf134 app.decorate("canopyRunner", runner);
80fafdf135}
80fafdf136
e5b523e137// Pages static site hosting
e5b523e138const pagesDeployer = new PagesDeployer(
e5b523e139 db,
e5b523e140 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
e5b523e141 process.env.PAGES_SITES_DIR ?? "./data/pages/sites",
e5b523e142 app.log
e5b523e143);
e5b523e144app.decorate("pagesDeployer", pagesDeployer);
e5b523e145
e5b523e146// Caddy on-demand TLS ask endpoint
e5b523e147app.get("/api/pages/ask", async (request: any, reply: any) => {
e5b523e148 const domain = (request.query as any).domain;
e5b523e149 if (!domain) {
e5b523e150 return reply.code(400).send("missing domain");
e5b523e151 }
e5b523e152 if (pagesDeployer.isDomainConfigured(domain)) {
e5b523e153 return reply.code(200).send("ok");
e5b523e154 }
e5b523e155 return reply.code(404).send("not configured");
e5b523e156});
e5b523e157
3e3af55158// Routes
3e3af55159await app.register(repoRoutes, { prefix: "/api/repos" });
d12933e160await app.register(diffRoutes, { prefix: "/api/repos" });
f0bb192161await app.register(canopyRoutes, { prefix: "/api/repos" });
1da9874162await app.register(canopyGlobalRoutes, { prefix: "/api" });
f0bb192163await app.register(canopyWebhookRoute, { prefix: "/api" });
3cbdca6164await app.register(ringRoutes, { prefix: "/api" });
3e3af55165
3e3af55166// Start
3e3af55167const port = parseInt(process.env.PORT ?? "4000", 10);
3e3af55168const host = process.env.HOST ?? "0.0.0.0";
3e3af55169
3e3af55170try {
3e3af55171 await app.listen({ port, host });
3e3af55172 app.log.info(`Grove API server running at http://${host}:${port}`);
80fafdf173
80fafdf174 // Start Canopy poller after server is listening
80fafdf175 if (canopyEnabled && runner) {
80fafdf176 const poller = new CanopyPoller(
80fafdf177 db,
80fafdf178 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
80fafdf179 runner,
e5b523e180 pagesDeployer,
80fafdf181 app.log
80fafdf182 );
80fafdf183 poller.start(15000);
80fafdf184 app.log.info("Canopy CI/CD enabled — polling every 15s");
80fafdf185 }
3e3af55186} catch (err) {
3e3af55187 app.log.error(err);
3e3af55188 process.exit(1);
3e3af55189}