assafvayner HF Staff commited on
Commit
4bb4f90
·
1 Parent(s): b0ccc27

feat(app): wire config, singleton app bootstrap, and auth hooks

Browse files
.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
- placeholder: true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }