Spaces:
Running
Running
Commit ·
4bb4f90
1
Parent(s): b0ccc27
feat(app): wire config, singleton app bootstrap, and auth hooks
Browse files- .env.example +1 -0
- src/hooks.server.ts +44 -0
- src/lib/server/app.ts +97 -1
- src/lib/server/config.ts +37 -0
.env.example
CHANGED
|
@@ -5,4 +5,5 @@ CURRENCY=USD
|
|
| 5 |
# Only for STORAGE=hf:
|
| 6 |
# HF_TOKEN=hf_xxx
|
| 7 |
# HF_BUCKET=buckets/assafvayner/splitwise-data
|
|
|
|
| 8 |
# COMPACTION_INTERVAL_MS=3600000
|
|
|
|
| 5 |
# Only for STORAGE=hf:
|
| 6 |
# HF_TOKEN=hf_xxx
|
| 7 |
# HF_BUCKET=buckets/assafvayner/splitwise-data
|
| 8 |
+
# HF_PREFIX=prod
|
| 9 |
# COMPACTION_INTERVAL_MS=3600000
|
src/hooks.server.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { dev } from '$app/environment';
|
| 2 |
+
import { SESSION_COOKIE, verifySessionToken } from '$lib/server/auth/session';
|
| 3 |
+
import { getApp } from '$lib/server/app';
|
| 4 |
+
import { error, redirect, type Handle } from '@sveltejs/kit';
|
| 5 |
+
|
| 6 |
+
const PUBLIC_PATHS = new Set(['/login']);
|
| 7 |
+
const ALLOWED_WHILE_PASSWORD_CHANGE = new Set(['/me/password', '/logout']);
|
| 8 |
+
|
| 9 |
+
export const handle: Handle = async ({ event, resolve }) => {
|
| 10 |
+
const app = await getApp();
|
| 11 |
+
event.locals.app = app;
|
| 12 |
+
event.locals.user = null;
|
| 13 |
+
|
| 14 |
+
const token = event.cookies.get(SESSION_COOKIE);
|
| 15 |
+
if (token) {
|
| 16 |
+
const payload = verifySessionToken(token, app.config.sessionSecret);
|
| 17 |
+
const user = payload ? app.users.byId(payload.uid) : undefined;
|
| 18 |
+
if (payload && user && user.active && user.sessionVersion === payload.sv) {
|
| 19 |
+
event.locals.user = user;
|
| 20 |
+
} else {
|
| 21 |
+
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const path = event.url.pathname;
|
| 26 |
+
const user = event.locals.user;
|
| 27 |
+
|
| 28 |
+
if (!user) {
|
| 29 |
+
if (!PUBLIC_PATHS.has(path)) redirect(303, `/login?next=${encodeURIComponent(path)}`);
|
| 30 |
+
} else {
|
| 31 |
+
if (path === '/login') redirect(303, '/');
|
| 32 |
+
if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE.has(path)) redirect(303, '/me/password');
|
| 33 |
+
if (path.startsWith('/admin') && user.role !== 'admin') error(403, 'Admins only');
|
| 34 |
+
if (event.request.method !== 'GET' && event.request.method !== 'HEAD' && path !== '/logout') {
|
| 35 |
+
app.ledger.readOnly = !app.guard.canWrite;
|
| 36 |
+
if (!app.guard.canWrite)
|
| 37 |
+
error(503, 'Read-only: another instance of the app is currently active. Try again in a couple of minutes.');
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const response = await resolve(event);
|
| 42 |
+
if (!dev) response.headers.set('X-Frame-Options', 'DENY');
|
| 43 |
+
return response;
|
| 44 |
+
};
|
src/lib/server/app.ts
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export interface App {
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
|
|
|
| 1 |
+
import { randomUUID } from 'node:crypto';
|
| 2 |
+
import { RateLimiter } from './auth/rate-limit';
|
| 3 |
+
import { loadConfig, type Config } from './config';
|
| 4 |
+
import { Ledger, type CompactionResult } from './ledger/ledger';
|
| 5 |
+
import { WriterGuard } from './ledger/writer-guard';
|
| 6 |
+
import { HfBucketStore } from './storage/hf-bucket-store';
|
| 7 |
+
import { LocalFsStore } from './storage/local-fs-store';
|
| 8 |
+
import { PrefixedStore } from './storage/prefixed-store';
|
| 9 |
+
import type { BucketStore } from './storage/store';
|
| 10 |
+
import { UsersRepo } from './users/repository';
|
| 11 |
+
|
| 12 |
+
export interface CompactionStatus {
|
| 13 |
+
lastRunAt: string | null;
|
| 14 |
+
lastResult: CompactionResult | null;
|
| 15 |
+
lastError: string | null;
|
| 16 |
+
running: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
export interface App {
|
| 20 |
+
config: Config;
|
| 21 |
+
store: BucketStore;
|
| 22 |
+
users: UsersRepo;
|
| 23 |
+
ledger: Ledger;
|
| 24 |
+
guard: WriterGuard;
|
| 25 |
+
loginLimiter: RateLimiter;
|
| 26 |
+
compaction: CompactionStatus;
|
| 27 |
+
runCompaction(): Promise<CompactionResult>;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
let instance: Promise<App> | null = null;
|
| 31 |
+
|
| 32 |
+
/** Builds the singleton on first call; subsequent calls return the same promise. */
|
| 33 |
+
export function getApp(): Promise<App> {
|
| 34 |
+
instance ??= build().catch((e) => {
|
| 35 |
+
instance = null;
|
| 36 |
+
throw e;
|
| 37 |
+
});
|
| 38 |
+
return instance;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function build(): Promise<App> {
|
| 42 |
+
const config = loadConfig();
|
| 43 |
+
const base: BucketStore =
|
| 44 |
+
config.storage === 'hf' ? new HfBucketStore(config.hfBucket, config.hfToken) : new LocalFsStore(config.localDataDir);
|
| 45 |
+
const store: BucketStore = config.hfPrefix ? new PrefixedStore(base, config.hfPrefix) : base;
|
| 46 |
+
console.log(
|
| 47 |
+
`[app] storage=${config.storage}${config.storage === 'hf' ? ` bucket=${config.hfBucket}` : ` dir=${config.localDataDir}`}${config.hfPrefix ? ` prefix=${config.hfPrefix}/` : ''}`
|
| 48 |
+
);
|
| 49 |
+
|
| 50 |
+
const users = await UsersRepo.load(store);
|
| 51 |
+
const guard = new WriterGuard(store, randomUUID());
|
| 52 |
+
const acquired = await guard.acquire();
|
| 53 |
+
if (!acquired) console.error('[app] another instance holds the writer lock; starting READ-ONLY until it goes stale');
|
| 54 |
+
guard.start();
|
| 55 |
+
|
| 56 |
+
const ledger = await Ledger.load(store);
|
| 57 |
+
ledger.readOnly = !guard.canWrite;
|
| 58 |
+
|
| 59 |
+
const compaction: CompactionStatus = { lastRunAt: null, lastResult: null, lastError: null, running: false };
|
| 60 |
+
const app: App = {
|
| 61 |
+
config,
|
| 62 |
+
store,
|
| 63 |
+
users,
|
| 64 |
+
ledger,
|
| 65 |
+
guard,
|
| 66 |
+
loginLimiter: new RateLimiter(5, 60_000),
|
| 67 |
+
compaction,
|
| 68 |
+
async runCompaction() {
|
| 69 |
+
compaction.running = true;
|
| 70 |
+
try {
|
| 71 |
+
const result = await ledger.compact();
|
| 72 |
+
compaction.lastResult = result;
|
| 73 |
+
compaction.lastError = null;
|
| 74 |
+
return result;
|
| 75 |
+
} catch (e) {
|
| 76 |
+
compaction.lastError = e instanceof Error ? e.message : String(e);
|
| 77 |
+
throw e;
|
| 78 |
+
} finally {
|
| 79 |
+
compaction.running = false;
|
| 80 |
+
compaction.lastRunAt = new Date().toISOString();
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const timer = setInterval(() => {
|
| 86 |
+
ledger.readOnly = !guard.canWrite;
|
| 87 |
+
if (ledger.readOnly) return;
|
| 88 |
+
app.runCompaction().catch((e) => console.error('[compaction] failed', e));
|
| 89 |
+
}, config.compactionIntervalMs);
|
| 90 |
+
timer.unref?.();
|
| 91 |
+
|
| 92 |
+
if (guard.canWrite && ledger.stats().pendingEvents > 0) {
|
| 93 |
+
app.runCompaction().catch((e) => console.error('[compaction] boot compaction failed', e));
|
| 94 |
+
}
|
| 95 |
+
console.log(
|
| 96 |
+
`[app] ready: ${users.all().length} users, ${ledger.listExpenses().length} expenses, ${ledger.stats().pendingEvents} pending events`
|
| 97 |
+
);
|
| 98 |
+
return app;
|
| 99 |
}
|
src/lib/server/config.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { env } from '$env/dynamic/private';
|
| 2 |
+
import { dev } from '$app/environment';
|
| 3 |
+
|
| 4 |
+
export interface Config {
|
| 5 |
+
storage: 'local' | 'hf';
|
| 6 |
+
hfToken: string;
|
| 7 |
+
hfBucket: `buckets/${string}`;
|
| 8 |
+
/** Directory inside the bucket that this deployment owns; '' means the bucket root. */
|
| 9 |
+
hfPrefix: string;
|
| 10 |
+
localDataDir: string;
|
| 11 |
+
sessionSecret: string;
|
| 12 |
+
currency: string;
|
| 13 |
+
compactionIntervalMs: number;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function required(name: string, value: string | undefined): string {
|
| 17 |
+
if (!value) throw new Error(`Missing required environment variable ${name}`);
|
| 18 |
+
return value;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function loadConfig(): Config {
|
| 22 |
+
const storage = (env.STORAGE ?? (dev ? 'local' : 'hf')) as Config['storage'];
|
| 23 |
+
if (storage !== 'local' && storage !== 'hf') throw new Error(`STORAGE must be 'local' or 'hf', got '${storage}'`);
|
| 24 |
+
const sessionSecret = env.SESSION_SECRET ?? (dev ? 'dev-only-session-secret-do-not-use-in-prod' : undefined);
|
| 25 |
+
const hfBucket = storage === 'hf' ? required('HF_BUCKET', env.HF_BUCKET) : '';
|
| 26 |
+
if (storage === 'hf' && !hfBucket.startsWith('buckets/')) throw new Error('HF_BUCKET must look like buckets/<namespace>/<name>');
|
| 27 |
+
return {
|
| 28 |
+
storage,
|
| 29 |
+
hfToken: storage === 'hf' ? required('HF_TOKEN', env.HF_TOKEN) : '',
|
| 30 |
+
hfBucket: hfBucket as `buckets/${string}`,
|
| 31 |
+
hfPrefix: (env.HF_PREFIX ?? '').replace(/^\/+|\/+$/g, ''),
|
| 32 |
+
localDataDir: env.LOCAL_DATA_DIR ?? '.data',
|
| 33 |
+
sessionSecret: required('SESSION_SECRET', sessionSecret),
|
| 34 |
+
currency: env.CURRENCY ?? 'USD',
|
| 35 |
+
compactionIntervalMs: Number(env.COMPACTION_INTERVAL_MS ?? 3_600_000)
|
| 36 |
+
};
|
| 37 |
+
}
|