import { OpenAPIHono } from '@hono/zod-openapi'; import type { MiddlewareHandler } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { compress } from 'hono/compress'; import { secureHeaders } from 'hono/secure-headers'; import { sessionMiddleware } from './auth/guards'; import type { AppContext } from './context'; import { docsRoutes } from './docs'; import { fail, requestOrigin, type Env } from './http'; import { accountRoutes } from './routes/accounts'; import { aiRoutes } from './routes/ai'; import { alertRoutes } from './routes/alerts'; import { authRoutes } from './routes/auth'; import { backupRoutes } from './routes/backups'; import { budgetRoutes } from './routes/budgets'; import { categoryRoutes } from './routes/categories'; import { deviceRoutes } from './routes/devices'; import { exportRoutes } from './routes/export'; import { ideaRoutes } from './routes/ideas'; import { importRoutes } from './routes/imports'; import { passkeyRoutes } from './routes/passkeys'; import { peopleRoutes } from './routes/people'; import { reportRoutes } from './routes/reports'; import { subscriptionRoutes } from './routes/subscriptions'; import { systemRoutes } from './routes/system'; import { transactionRoutes } from './routes/transactions'; import { validationHook } from './schemas'; import { webApp } from './static'; /** Changes must come from Spendlight's own pages, never from another site. */ function sameOriginWrites(ctx: AppContext): MiddlewareHandler { return async (c, next) => { if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { const origin = c.req.header('origin'); if (origin) { const allowed = new Set([requestOrigin(c, ctx)]); if (ctx.config.publicUrl) allowed.add(ctx.config.publicUrl.origin); if (!allowed.has(origin)) fail(403, 'wrong_origin', 'This request didn’t come from Spendlight.'); } } await next(); }; } export function createApp(ctx: AppContext) { const app = new OpenAPIHono({ defaultHook: validationHook }); app.onError((error, c) => { if (error instanceof HTTPException) { const code = (error.cause as { code?: string } | undefined)?.code ?? 'error'; return c.json({ error: { code, message: error.message } }, error.status); } ctx.log.error(`${c.req.method} ${new URL(c.req.url).pathname} failed`, error); return c.json({ error: { code: 'server_error', message: 'Something went wrong on our side. Please try again.' } }, 500); }); app.use( '*', secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:', 'blob:'], fontSrc: ["'self'", 'data:'], connectSrc: ["'self'"], manifestSrc: ["'self'"], workerSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"], }, crossOriginEmbedderPolicy: false, referrerPolicy: 'same-origin', }), ); app.use('*', async (c, next) => { await next(); c.header('X-Spendlight-Version', ctx.config.version); }); app.use('/api/*', async (c, next) => { await next(); c.header('Cache-Control', 'no-store'); }); // JSON answers can be large (a year of reports, 500 transactions); send them gzipped. app.use('/api/*', compress()); app.use('/api/*', sessionMiddleware(ctx)); app.use('/api/*', sameOriginWrites(ctx)); app.route('/api/v1', systemRoutes(ctx)); app.route('/api/v1', authRoutes(ctx)); app.route('/api/v1', passkeyRoutes(ctx)); app.route('/api/v1', deviceRoutes(ctx)); app.route('/api/v1', peopleRoutes(ctx)); app.route('/api/v1', backupRoutes(ctx)); app.route('/api/v1', accountRoutes(ctx)); app.route('/api/v1', importRoutes(ctx)); app.route('/api/v1', transactionRoutes(ctx)); app.route('/api/v1', categoryRoutes(ctx)); app.route('/api/v1', budgetRoutes(ctx)); app.route('/api/v1', reportRoutes(ctx)); app.route('/api/v1', subscriptionRoutes(ctx)); app.route('/api/v1', alertRoutes(ctx)); app.route('/api/v1', exportRoutes(ctx)); app.route('/api/v1', aiRoutes(ctx)); if (ctx.config.ideasEnabled) app.route('/api/v1', ideaRoutes(ctx)); app.doc31('/api/v1/openapi.json', { openapi: '3.1.0', info: { title: 'Spendlight API', version: ctx.config.version, description: 'Everything the Spendlight screens do goes through this API. Sign in at /api/v1/auth/login; the session cookie authenticates the rest.', }, }); app.route('/api/docs', docsRoutes()); app.all('/api/*', (c) => c.json({ error: { code: 'not_found', message: 'No such address in the Spendlight API.' } }, 404)); app.use('*', webApp(ctx.config.webDist)); return app; }