Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
107
server/src/app.ts
Normal file
107
server/src/app.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { OpenAPIHono } from '@hono/zod-openapi';
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
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 { authRoutes } from './routes/auth';
|
||||
import { backupRoutes } from './routes/backups';
|
||||
import { deviceRoutes } from './routes/devices';
|
||||
import { ideaRoutes } from './routes/ideas';
|
||||
import { importRoutes } from './routes/imports';
|
||||
import { passkeyRoutes } from './routes/passkeys';
|
||||
import { peopleRoutes } from './routes/people';
|
||||
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');
|
||||
});
|
||||
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));
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user