Files
Spendlight/server/src/app.ts
Chris Olson a68c343647 Milestone 3: launch (20261008.05)
Backup and restore: restore from Settings (any backup in the folder, or an
uploaded file) or from the command line with scripts/restore.sh. Each restore
checks the file first, saves a copy of the current data so it can be undone,
and keeps the person restoring signed in. Backups are now single
self-contained files. Drilled end to end: last night's backup brings
everything back exactly, including onto an empty new machine.

Speed: a phone's first load is 147 KB compressed (target 150). The ideas box,
account menu and sign-in screens load on demand, a small built-in toast
replaces sonner, the Inter font is trimmed to the characters used, and files
and API answers are served compressed. Every screen's data comes back in
under 100 ms with 50,000 transactions (server/bench).

Accessibility: no WCAG 2.2 AA issues on any screen in either theme. Darker
status text colors, 44 px touch targets on phones, reduced motion honoured,
and focus returns after dialogs close.

Also: an in-app guide ("How Spendlight works"), OFX/QFX/QBO import, and tags
on individual parts of a split purchase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:23:45 -04:00

125 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Env> {
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<Env>({ 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;
}