5.8 KB190 lines
Blame
1import Fastify from "fastify";
2import cors from "@fastify/cors";
3import jwt from "@fastify/jwt";
4import multipart from "@fastify/multipart";
5import type Database from "better-sqlite3";
6import { initDatabase } from "./services/database.js";
7import { repoRoutes } from "./routes/repos.js";
8import { diffRoutes } from "./routes/diffs.js";
9import { canopyRoutes, canopyGlobalRoutes, canopyWebhookRoute } from "./routes/canopy.js";
10import { ringRoutes } from "./routes/ring.js";
11import { CanopyRunner } from "./services/canopy-runner.js";
12import { CanopyPoller } from "./services/canopy-poller.js";
13import { CanopyEventBus } from "./services/canopy-events.js";
14import { MononokeProvisioner } from "./services/mononoke-provisioner.js";
15import { PagesDeployer } from "./services/pages-deployer.js";
16
17const app = Fastify({
18 logger: {
19 level: process.env.LOG_LEVEL ?? "info",
20 transport:
21 process.env.NODE_ENV !== "production"
22 ? { target: "pino-pretty" }
23 : undefined,
24 },
25});
26
27// Plugins
28await app.register(cors, {
29 origin: (process.env.CORS_ORIGIN ?? "http://localhost:3000")
30 .split(",")
31 .map((origin) => origin.trim())
32 .filter(Boolean),
33});
34
35await app.register(jwt, {
36 secret: process.env.JWT_SECRET ?? "grove-dev-secret",
37});
38
39await app.register(multipart, {
40 limits: { fileSize: 500 * 1024 * 1024 }, // 500MB
41});
42
43// Initialize database
44const db = initDatabase(
45 process.env.DATABASE_PATH ?? "./data/grove.db"
46);
47
48// Make db available to routes
49app.decorate("db", db);
50
51// Upsert a local user record from hub JWT claims for FK references
52function ensureLocalUser(
53 database: Database.Database,
54 user: { id: number; username: string; display_name?: string }
55) {
56 database
57 .prepare(
58 `INSERT INTO users (id, username, display_name, updated_at)
59 VALUES (?, ?, ?, datetime('now'))
60 ON CONFLICT(id) DO UPDATE SET
61 username = excluded.username,
62 display_name = excluded.display_name,
63 updated_at = datetime('now')`
64 )
65 .run(user.id, user.username, user.display_name ?? user.username);
66}
67
68// Upsert a local org record from hub API for FK references
69function ensureLocalOrg(
70 database: Database.Database,
71 org: { id: number; name: string; display_name?: string }
72) {
73 database
74 .prepare(
75 `INSERT INTO orgs (id, name, display_name, updated_at)
76 VALUES (?, ?, ?, datetime('now'))
77 ON CONFLICT(id) DO UPDATE SET
78 name = excluded.name,
79 display_name = excluded.display_name,
80 updated_at = datetime('now')`
81 )
82 .run(org.id, org.name, org.display_name ?? org.name);
83}
84
85app.decorate("ensureLocalOrg", ensureLocalOrg.bind(null, db));
86
87// Auth decorator — verifies hub-signed JWTs and ensures local user exists
88app.decorate("authenticate", async function (request: any, reply: any) {
89 try {
90 await request.jwtVerify();
91 const { id, username, display_name } = request.user as any;
92 ensureLocalUser(db, { id, username, display_name });
93 } catch (err) {
94 reply.code(401).send({ error: "Unauthorized" });
95 }
96});
97
98// Mononoke repo provisioner
99const mononokeProvisioner = new MononokeProvisioner(
100 process.env.MONONOKE_CONFIG_PATH ?? "/data/grove/mononoke-config",
101 app.log,
102 process.env.GROVE_BRIDGE_URL ?? "http://grove-bridge:3100"
103);
104app.decorate("mononokeProvisioner", mononokeProvisioner);
105
106// Health check — verifies DB is accessible (catches migration failures, corruption)
107app.get("/health", async (_req, reply) => {
108 try {
109 db.prepare("SELECT 1").get();
110 return { status: "ok", service: "grove-api" };
111 } catch {
112 return reply.code(503).send({ status: "error", service: "grove-api", reason: "database unavailable" });
113 }
114});
115
116// Canopy CI/CD
117const canopyEnabled = process.env.CANOPY_ENABLED === "true";
118
119const canopyEventBus = new CanopyEventBus();
120app.decorate("canopyEventBus", canopyEventBus);
121
122let runner: CanopyRunner | undefined;
123if (canopyEnabled) {
124 const workspaceDir = process.env.CANOPY_WORKSPACE_DIR ?? "./data/canopy/workspaces";
125 runner = new CanopyRunner(
126 db,
127 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
128 workspaceDir,
129 process.env.CANOPY_WORKSPACE_HOST_DIR ?? workspaceDir,
130 process.env.JWT_SECRET ?? "grove-dev-secret",
131 app.log,
132 canopyEventBus
133 );
134 app.decorate("canopyRunner", runner);
135}
136
137// Pages static site hosting
138const pagesDeployer = new PagesDeployer(
139 db,
140 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
141 process.env.PAGES_SITES_DIR ?? "./data/pages/sites",
142 app.log
143);
144app.decorate("pagesDeployer", pagesDeployer);
145
146// Caddy on-demand TLS ask endpoint
147app.get("/api/pages/ask", async (request: any, reply: any) => {
148 const domain = (request.query as any).domain;
149 if (!domain) {
150 return reply.code(400).send("missing domain");
151 }
152 if (pagesDeployer.isDomainConfigured(domain)) {
153 return reply.code(200).send("ok");
154 }
155 return reply.code(404).send("not configured");
156});
157
158// Routes
159await app.register(repoRoutes, { prefix: "/api/repos" });
160await app.register(diffRoutes, { prefix: "/api/repos" });
161await app.register(canopyRoutes, { prefix: "/api/repos" });
162await app.register(canopyGlobalRoutes, { prefix: "/api" });
163await app.register(canopyWebhookRoute, { prefix: "/api" });
164await app.register(ringRoutes, { prefix: "/api" });
165
166// Start
167const port = parseInt(process.env.PORT ?? "4000", 10);
168const host = process.env.HOST ?? "0.0.0.0";
169
170try {
171 await app.listen({ port, host });
172 app.log.info(`Grove API server running at http://${host}:${port}`);
173
174 // Start Canopy poller after server is listening
175 if (canopyEnabled && runner) {
176 const poller = new CanopyPoller(
177 db,
178 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100",
179 runner,
180 pagesDeployer,
181 app.log
182 );
183 poller.start(15000);
184 app.log.info("Canopy CI/CD enabled — polling every 15s");
185 }
186} catch (err) {
187 app.log.error(err);
188 process.exit(1);
189}
190