All files / api server.ts

93.33% Statements 42/45
77.77% Branches 14/18
100% Functions 8/8
97.67% Lines 42/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123                                            357x   357x         357x 446x   446x 446x                                     357x 357x   357x     357x 1x       357x     357x     357x 361x 1x   360x       357x 1x 4x 4x 3x   1x             325x 325x       19x 19x       3x     357x 357x 357x 357x 357x 357x 357x 357x 357x 357x 357x   357x     357x   357x    
import { serveStatic } from "@hono/node-server/serve-static";
import type Database from "better-sqlite3-multiple-ciphers";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { ContainerContext } from "../crypto/lifecycle.js";
import { isDemoMode } from "../demo/demo-mode.js";
import type { SyncScheduler } from "../sync/sync-scheduler.js";
import { attachmentRoutes } from "./routes/attachments.js";
import { connectorRoutes } from "./routes/connectors.js";
import { draftRoutes } from "./routes/drafts.js";
import { encryptionRoutes } from "./routes/encryption.js";
import { identityRoutes } from "./routes/identities.js";
import { inboxRoutes } from "./routes/inbox.js";
import { labelRoutes } from "./routes/labels.js";
import { messageRoutes } from "./routes/messages.js";
import { searchRoutes } from "./routes/search.js";
import { sendRoutes } from "./routes/send.js";
import { syncRoutes } from "./routes/sync.js";
import { trustedSenderRoutes } from "./routes/trusted-senders.js";
import { webhookRoutes } from "./routes/webhook.js";
 
export function createApp(context: ContainerContext): { app: Hono } {
	const app = new Hono();
 
	app.use("*", cors());
 
	// Content Security Policy — defense-in-depth against XSS.
	// Even if the HTML sanitizer misses something, these headers prevent
	// inline script execution and restrict resource loading.
	app.use("*", async (c, next) => {
		await next();
		// Only set CSP on HTML responses (the SPA shell)
		const ct = c.res.headers.get("content-type") ?? "";
		Iif (ct.includes("text/html")) {
			c.res.headers.set(
				"Content-Security-Policy",
				[
					"default-src 'self'",
					"script-src 'self'",
					"style-src 'self' 'unsafe-inline'",
					"img-src 'self' data: cid: https:",
					"font-src 'self'",
					"connect-src 'self'",
					"frame-src 'self' blob:",
					"object-src 'none'",
					"base-uri 'self'",
				].join("; "),
			);
		}
	});
 
	// Serve frontend static files
	app.use("/assets/*", serveStatic({ root: "./frontend/dist" }));
	app.get("/stork.svg", serveStatic({ root: "./frontend/dist", path: "stork.svg" }));
 
	const api = new Hono();
 
	// ── Demo mode indicator ─────────────────────────────────────────────────
	api.get("/demo", (c) => {
		return c.json({ demo: isDemoMode() });
	});
 
	// ── Always-accessible endpoints (health, status, setup, unlock) ─────────
	api.route("/", encryptionRoutes(context));
 
	// ── Push-based webhook endpoints — bypass lock middleware, handle lock state themselves ──
	api.route("/webhook", webhookRoutes(context));
 
	// ── Lock middleware — blocks all data routes until unlocked ──────────────
	api.use("*", async (c, next) => {
		if (context.state !== "unlocked") {
			return c.json({ error: "Container is locked", state: context.state }, 423);
		}
		await next();
	});
 
	// ── Demo read-only middleware — blocks mutations on data routes ──────────
	if (isDemoMode()) {
		api.use("*", async (c, next) => {
			const method = c.req.method;
			if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
				return c.json({ error: "This is a read-only demo" }, 403);
			}
			await next();
		});
	}
 
	// ── Data routes (all require unlocked state via middleware above) ────────
 
	function getDb(): Database.Database {
		Iif (!context.db) throw new Error("db not available");
		return context.db;
	}
 
	function getScheduler(): SyncScheduler {
		Iif (!context.scheduler) throw new Error("scheduler not available");
		return context.scheduler;
	}
 
	function getR2Poller() {
		return context.r2Poller;
	}
 
	api.route("/identities", identityRoutes(getDb));
	api.route("/connectors", connectorRoutes(getDb, getScheduler, getR2Poller));
	api.route("/inbox", inboxRoutes(getDb));
	api.route("/messages", messageRoutes(getDb));
	api.route("/labels", labelRoutes(getDb));
	api.route("/attachments", attachmentRoutes(getDb));
	api.route("/search", searchRoutes(getDb));
	api.route("/sync", syncRoutes(getScheduler, getDb));
	api.route("/send", sendRoutes(getDb));
	api.route("/drafts", draftRoutes(getDb));
	api.route("/", trustedSenderRoutes(getDb));
 
	app.route("/api", api);
 
	// SPA fallback — serve index.html for all non-API, non-asset routes
	app.get("*", serveStatic({ root: "./frontend/dist", path: "index.html" }));
 
	return { app };
}