assafvayner HF Staff commited on
Commit
e830ad4
·
1 Parent(s): 6a3e50b

fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test

Browse files

Storage failures now surface as a 503 with a retry message instead of a bare 500:
`guarded()` wraps every form action, passing redirects/HttpErrors/ActionFailures
through and mapping anything else (including ReadOnlyError) to a 503 rendered by
the new +error.svelte.

- config: CURRENCY must be a 3-letter uppercase ISO 4217 code; reuse the validated
session secret and compaction interval instead of recomputing them
- app: release the writer lock on sveltekit:shutdown, with SIGTERM/SIGINT fallback
- login: per-IP limiter (20/min) ahead of the per-username one; scrypt wait queue
capped at 64 so a login flood is shed as a 503 rather than parked
- npm test now runs unit tests only; integration tests move to
vitest.integration.config.ts so the default run never touches the real bucket
- nosniff/Referrer-Policy on every response, spelling fixes, drop unused
splitParticipants, .git in .dockerignore, README env notes

.dockerignore CHANGED
@@ -1,3 +1,4 @@
 
1
  node_modules
2
  build
3
  .svelte-kit
 
1
+ .git
2
  node_modules
3
  build
4
  .svelte-kit
README.md CHANGED
@@ -26,11 +26,13 @@ Set these in the Space **Settings → Variables and secrets**:
26
  | `HF_TOKEN` | secret | fine-grained token with **write** access to the bucket below |
27
  | `HF_BUCKET` | variable | `buckets/assafvayner/splitwise-data` |
28
  | `HF_PREFIX` | variable | directory inside the bucket for this deployment, e.g. `prod` (integration tests use `test/<id>/`); empty = bucket root |
29
- | `SESSION_SECRET` | secret | `openssl rand -base64 32` |
30
  | `ORIGIN` | variable | the public URL of the Space, e.g. `https://assafvayner-splitwise.hf.space` (required for form submissions) |
31
- | `CURRENCY` | variable | ISO code, default `USD` |
32
  | `COMPACTION_INTERVAL_MS` | variable | default `3600000` (1 hour) |
33
 
 
 
34
  First boot creates the `admin` user with password `1234`; the first admin login forces a password change.
35
  Then create your friends' accounts on `/admin`.
36
 
@@ -42,8 +44,9 @@ npm install
42
  npm run dev
43
  ```
44
 
45
- `npm test` runs unit tests; `npm run test:e2e` runs the Playwright smoke test against a production build;
46
- `npm run test:integration` runs the real-bucket tests under a throwaway `test/<id>/` prefix (needs `HF_TOKEN`/`HF_BUCKET` in `.env`; skipped otherwise).
 
47
 
48
  ## Deploy
49
 
 
26
  | `HF_TOKEN` | secret | fine-grained token with **write** access to the bucket below |
27
  | `HF_BUCKET` | variable | `buckets/assafvayner/splitwise-data` |
28
  | `HF_PREFIX` | variable | directory inside the bucket for this deployment, e.g. `prod` (integration tests use `test/<id>/`); empty = bucket root |
29
+ | `SESSION_SECRET` | secret | `openssl rand -base64 32` — must be at least 32 characters |
30
  | `ORIGIN` | variable | the public URL of the Space, e.g. `https://assafvayner-splitwise.hf.space` (required for form submissions) |
31
+ | `CURRENCY` | variable | 3-letter uppercase ISO 4217 code, default `USD` |
32
  | `COMPACTION_INTERVAL_MS` | variable | default `3600000` (1 hour) |
33
 
34
+ `BODY_SIZE_LIMIT=8M`, `STORAGE=hf`, `PORT`, `HOST` and `NODE_ENV` are baked into the Dockerfile; there is no need to set them in the Space.
35
+
36
  First boot creates the `admin` user with password `1234`; the first admin login forces a password change.
37
  Then create your friends' accounts on `/admin`.
38
 
 
44
  npm run dev
45
  ```
46
 
47
+ `npm test` runs the unit tests only — it never touches the bucket. `npm run test:e2e` runs the Playwright
48
+ smoke test against a production build. `npm run test:integration` (a separate `vitest.integration.config.ts`)
49
+ runs the real-bucket tests under a throwaway `test/<id>/` prefix (needs `HF_TOKEN`/`HF_BUCKET` in `.env`; skipped otherwise).
50
 
51
  ## Deploy
52
 
package.json CHANGED
@@ -12,7 +12,7 @@
12
  "test": "vitest run",
13
  "test:watch": "vitest",
14
  "test:e2e": "playwright test",
15
- "test:integration": "vitest run tests/integration"
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.16.1",
 
12
  "test": "vitest run",
13
  "test:watch": "vitest",
14
  "test:e2e": "playwright test",
15
+ "test:integration": "vitest run --config vitest.integration.config.ts"
16
  },
17
  "dependencies": {
18
  "@huggingface/hub": "^2.16.1",
src/hooks.server.ts CHANGED
@@ -52,6 +52,8 @@ export const handle: Handle = async ({ event, resolve }) => {
52
  }
53
 
54
  const response = await resolve(event);
 
 
55
  if (!dev) response.headers.set('X-Frame-Options', 'DENY');
56
  return response;
57
  };
 
52
  }
53
 
54
  const response = await resolve(event);
55
+ response.headers.set('X-Content-Type-Options', 'nosniff');
56
+ response.headers.set('Referrer-Policy', 'same-origin');
57
  if (!dev) response.headers.set('X-Frame-Options', 'DENY');
58
  return response;
59
  };
src/lib/domain/types.ts CHANGED
@@ -68,14 +68,3 @@ export interface Settlement extends AuditFields {
68
  /** Everything the caller supplies; id/version/audit fields are added by the Ledger. */
69
  export type ExpenseBody = Omit<Expense, 'id' | 'version' | keyof AuditFields>;
70
  export type SettlementBody = Omit<Settlement, 'id' | 'version' | keyof AuditFields>;
71
-
72
- export function splitParticipants(split: Split): string[] {
73
- switch (split.mode) {
74
- case 'equal':
75
- return [...split.participants];
76
- case 'percent':
77
- return Object.keys(split.bp);
78
- case 'exact':
79
- return Object.keys(split.cents);
80
- }
81
- }
 
68
  /** Everything the caller supplies; id/version/audit fields are added by the Ledger. */
69
  export type ExpenseBody = Omit<Expense, 'id' | 'version' | keyof AuditFields>;
70
  export type SettlementBody = Omit<Settlement, 'id' | 'version' | keyof AuditFields>;
 
 
 
 
 
 
 
 
 
 
 
src/lib/server/actions.ts ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { error, isActionFailure, isHttpError, isRedirect, type Action } from '@sveltejs/kit';
2
+ import { ReadOnlyError } from './ledger/errors';
3
+
4
+ const STORAGE_MESSAGE = 'Could not save — the storage backend is unavailable. Please try again.';
5
+ const READ_ONLY_MESSAGE = 'The app is temporarily read-only; try again in a couple of minutes.';
6
+
7
+ /**
8
+ * Wraps every action so an unexpected throw (bucket down, disk read-only, scrypt overload)
9
+ * surfaces as a 503 the user can act on instead of a bare 500. Redirects, HttpErrors and
10
+ * `fail()` results are the action's own control flow and pass through untouched.
11
+ */
12
+ export function guarded<T extends Record<string, Action<any, any, any>>>(actions: T): T {
13
+ const wrapped: Record<string, Action<any, any, any>> = {};
14
+ for (const [name, action] of Object.entries(actions)) {
15
+ wrapped[name] = async (event) => {
16
+ try {
17
+ return await action(event);
18
+ } catch (e) {
19
+ if (isRedirect(e) || isHttpError(e) || isActionFailure(e)) throw e;
20
+ if (e instanceof ReadOnlyError) error(503, READ_ONLY_MESSAGE);
21
+ console.error('[action] storage failure', e);
22
+ error(503, STORAGE_MESSAGE);
23
+ }
24
+ };
25
+ }
26
+ return wrapped as T;
27
+ }
src/lib/server/app.ts CHANGED
@@ -2,7 +2,7 @@ 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';
@@ -23,6 +23,8 @@ export interface App {
23
  ledger: Ledger;
24
  guard: WriterGuard;
25
  loginLimiter: RateLimiter;
 
 
26
  compaction: CompactionStatus;
27
  /** Aligns the ledger's read-only flag with the writer lock, logging any transition. */
28
  syncReadOnly(): void;
@@ -90,6 +92,7 @@ async function build(): Promise<App> {
90
  ledger,
91
  guard,
92
  loginLimiter: new RateLimiter(5, 60_000),
 
93
  compaction,
94
  syncReadOnly() {
95
  const readOnly = !guard.canWrite;
@@ -112,6 +115,8 @@ async function build(): Promise<App> {
112
  }, config.compactionIntervalMs);
113
  timer.unref?.();
114
 
 
 
115
  if (guard.canWrite && ledger.stats().pendingEvents > 0) {
116
  app.runCompaction().catch((e) => console.error('[compaction] boot compaction failed', e));
117
  }
@@ -124,3 +129,48 @@ async function build(): Promise<App> {
124
  throw e;
125
  }
126
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { RateLimiter } from './auth/rate-limit';
3
  import { loadConfig, type Config } from './config';
4
  import { Ledger, type CompactionResult } from './ledger/ledger';
5
+ import { LOCK_PATH, 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';
 
23
  ledger: Ledger;
24
  guard: WriterGuard;
25
  loginLimiter: RateLimiter;
26
+ /** Second login gate, keyed by client IP, so one host cannot spray many usernames. */
27
+ ipLimiter: RateLimiter;
28
  compaction: CompactionStatus;
29
  /** Aligns the ledger's read-only flag with the writer lock, logging any transition. */
30
  syncReadOnly(): void;
 
92
  ledger,
93
  guard,
94
  loginLimiter: new RateLimiter(5, 60_000),
95
+ ipLimiter: new RateLimiter(20, 60_000),
96
  compaction,
97
  syncReadOnly() {
98
  const readOnly = !guard.canWrite;
 
115
  }, config.compactionIntervalMs);
116
  timer.unref?.();
117
 
118
+ registerShutdown(app, timer);
119
+
120
  if (guard.canWrite && ledger.stats().pendingEvents > 0) {
121
  app.runCompaction().catch((e) => console.error('[compaction] boot compaction failed', e));
122
  }
 
129
  throw e;
130
  }
131
  }
132
+
133
+ let shutdownRegistered = false;
134
+
135
+ /**
136
+ * Hands the writer lock back on the way out so a redeploy does not have to wait for the
137
+ * lock to go stale. adapter-node emits `sveltekit:shutdown` once the HTTP server is closed;
138
+ * the signal handlers are a fallback for hosts that do not (e.g. `vite dev`).
139
+ */
140
+ function registerShutdown(app: App, timer: ReturnType<typeof setInterval>): void {
141
+ if (shutdownRegistered) return;
142
+ shutdownRegistered = true;
143
+
144
+ let released: Promise<void> | null = null;
145
+ const release = (): Promise<void> => {
146
+ released ??= (async () => {
147
+ clearInterval(timer);
148
+ app.guard.stop();
149
+ if (!app.guard.canWrite) return;
150
+ try {
151
+ const bail = new Promise<never>((_, reject) => {
152
+ const t = setTimeout(() => reject(new Error('timed out')), 5_000);
153
+ t.unref?.();
154
+ });
155
+ await Promise.race([app.store.delete([LOCK_PATH]), bail]);
156
+ console.log('[app] released writer lock');
157
+ } catch (e) {
158
+ console.warn('[app] could not release writer lock', e);
159
+ }
160
+ })();
161
+ return released;
162
+ };
163
+
164
+ const emitter = process as NodeJS.EventEmitter;
165
+ emitter.on('sveltekit:shutdown', () => void release());
166
+
167
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
168
+ process.once(signal, () => {
169
+ // If nothing else listens for this signal our handler would otherwise swallow it.
170
+ const sole = process.listenerCount(signal) === 0;
171
+ release().finally(() => {
172
+ if (sole) process.exit(0);
173
+ });
174
+ });
175
+ }
176
+ }
src/lib/server/auth/guards.ts CHANGED
@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
2
  import type { User } from '../../domain/types';
3
  import { UsersRepo, UserValidationError } from '../users/repository';
4
 
5
- /** Every admin load/action calls this; the route gate in hooks is defence in depth, not the check. */
6
  export function requireAdmin(locals: App.Locals): User {
7
  const user = locals.user;
8
  if (!user || user.role !== 'admin') error(403, 'Admins only');
 
2
  import type { User } from '../../domain/types';
3
  import { UsersRepo, UserValidationError } from '../users/repository';
4
 
5
+ /** Every admin load/action calls this; the route gate in hooks is defense in depth, not the check. */
6
  export function requireAdmin(locals: App.Locals): User {
7
  const user = locals.user;
8
  if (!user || user.role !== 'admin') error(403, 'Admins only');
src/lib/server/auth/password.ts CHANGED
@@ -13,11 +13,23 @@ const MAX_P = 4;
13
 
14
  // Cap concurrent scrypt calls so a burst of logins can't exhaust CPU/memory; extra callers queue.
15
  const MAX_CONCURRENT_SCRYPT = 4;
 
 
 
16
  let activeScryptCalls = 0;
17
  const scryptWaitQueue: Array<() => void> = [];
18
 
 
 
 
 
 
 
 
 
19
  async function withScryptSlot<T>(fn: () => Promise<T>): Promise<T> {
20
  if (activeScryptCalls >= MAX_CONCURRENT_SCRYPT) {
 
21
  await new Promise<void>((resolve) => scryptWaitQueue.push(resolve));
22
  }
23
  activeScryptCalls++;
@@ -60,7 +72,9 @@ export async function verifyPassword(password: string, stored: string): Promise<
60
  const expected = Buffer.from(parts[5], 'base64');
61
  const actual = await scryptAsync(password, salt, n, r, p);
62
  return expected.length === actual.length && timingSafeEqual(expected, actual);
63
- } catch {
 
 
64
  return false;
65
  }
66
  }
 
13
 
14
  // Cap concurrent scrypt calls so a burst of logins can't exhaust CPU/memory; extra callers queue.
15
  const MAX_CONCURRENT_SCRYPT = 4;
16
+ // …and cap the queue itself, so a flood parks an unbounded number of requests instead of
17
+ // being shed. Callers past the cap fail fast rather than waiting minutes for a slot.
18
+ const MAX_QUEUED_SCRYPT = 64;
19
  let activeScryptCalls = 0;
20
  const scryptWaitQueue: Array<() => void> = [];
21
 
22
+ /** Thrown when the scrypt wait queue is full; callers surface it as a 503, not a bad password. */
23
+ export class ScryptOverloadError extends Error {
24
+ constructor() {
25
+ super('Too many concurrent login attempts');
26
+ this.name = 'ScryptOverloadError';
27
+ }
28
+ }
29
+
30
  async function withScryptSlot<T>(fn: () => Promise<T>): Promise<T> {
31
  if (activeScryptCalls >= MAX_CONCURRENT_SCRYPT) {
32
+ if (scryptWaitQueue.length >= MAX_QUEUED_SCRYPT) throw new ScryptOverloadError();
33
  await new Promise<void>((resolve) => scryptWaitQueue.push(resolve));
34
  }
35
  activeScryptCalls++;
 
72
  const expected = Buffer.from(parts[5], 'base64');
73
  const actual = await scryptAsync(password, salt, n, r, p);
74
  return expected.length === actual.length && timingSafeEqual(expected, actual);
75
+ } catch (e) {
76
+ // Overload is a server condition, not a wrong password: let the caller turn it into a 503.
77
+ if (e instanceof ScryptOverloadError) throw e;
78
  return false;
79
  }
80
  }
src/lib/server/config.ts CHANGED
@@ -29,14 +29,16 @@ export function loadConfig(): Config {
29
  const compactionIntervalMs = Number(env.COMPACTION_INTERVAL_MS ?? 3_600_000);
30
  if (!Number.isFinite(compactionIntervalMs) || compactionIntervalMs < 60_000)
31
  throw new Error('COMPACTION_INTERVAL_MS must be a number of milliseconds ≥ 60000');
 
 
32
  return {
33
  storage,
34
  hfToken: storage === 'hf' ? required('HF_TOKEN', env.HF_TOKEN) : '',
35
  hfBucket: hfBucket as `buckets/${string}`,
36
  hfPrefix: (env.HF_PREFIX ?? '').replace(/^\/+|\/+$/g, ''),
37
  localDataDir: env.LOCAL_DATA_DIR ?? '.data',
38
- sessionSecret: required('SESSION_SECRET', sessionSecret),
39
- currency: env.CURRENCY ?? 'USD',
40
- compactionIntervalMs: Number(env.COMPACTION_INTERVAL_MS ?? 3_600_000)
41
  };
42
  }
 
29
  const compactionIntervalMs = Number(env.COMPACTION_INTERVAL_MS ?? 3_600_000);
30
  if (!Number.isFinite(compactionIntervalMs) || compactionIntervalMs < 60_000)
31
  throw new Error('COMPACTION_INTERVAL_MS must be a number of milliseconds ≥ 60000');
32
+ const currency = env.CURRENCY ?? 'USD';
33
+ if (!/^[A-Z]{3}$/.test(currency)) throw new Error('CURRENCY must be a 3-letter uppercase ISO 4217 code');
34
  return {
35
  storage,
36
  hfToken: storage === 'hf' ? required('HF_TOKEN', env.HF_TOKEN) : '',
37
  hfBucket: hfBucket as `buckets/${string}`,
38
  hfPrefix: (env.HF_PREFIX ?? '').replace(/^\/+|\/+$/g, ''),
39
  localDataDir: env.LOCAL_DATA_DIR ?? '.data',
40
+ sessionSecret: secret,
41
+ currency,
42
+ compactionIntervalMs
43
  };
44
  }
src/lib/server/users/repository.ts CHANGED
@@ -96,6 +96,7 @@ export class UsersRepo {
96
  async authenticate(username: string, password: string): Promise<User | null> {
97
  const user = this.byUsername(username);
98
  // Always run a hash comparison so timing does not reveal whether the username exists.
 
99
  const ok = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
100
  return ok && user && user.active ? user : null;
101
  }
 
96
  async authenticate(username: string, password: string): Promise<User | null> {
97
  const user = this.byUsername(username);
98
  // Always run a hash comparison so timing does not reveal whether the username exists.
99
+ // A ScryptOverloadError from a login flood propagates: the guarded action turns it into a 503.
100
  const ok = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=');
101
  return ok && user && user.active ? user : null;
102
  }
src/routes/+error.svelte ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ </script>
4
+
5
+ <main class="space-y-4 p-4">
6
+ <p class="text-sm font-semibold text-gray-500">Error {page.status}</p>
7
+ <h1 class="text-2xl font-bold">
8
+ {page.status === 404 ? 'Page not found' : 'Something went wrong'}
9
+ </h1>
10
+ <p class="text-gray-700">{page.error?.message ?? 'Unexpected error'}</p>
11
+ <a href="/" class="block rounded-lg border border-gray-300 px-4 py-3 text-center">Back to home</a>
12
+ </main>
src/routes/admin/+page.server.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { toPublicUser } from '$lib/domain/types';
 
2
  import { requireAdmin, requireMemberTarget } from '$lib/server/auth/guards';
3
  import { UserValidationError } from '$lib/server/users/repository';
4
  import { fail } from '@sveltejs/kit';
@@ -17,7 +18,7 @@ function handle<T, E extends Record<string, unknown>>(fn: () => Promise<T>, extr
17
  });
18
  }
19
 
20
- export const actions: Actions = {
21
  create: async ({ request, locals }) => {
22
  requireAdmin(locals);
23
  const form = await request.formData();
@@ -77,4 +78,4 @@ export const actions: Actions = {
77
  { action: 'setActive', id }
78
  );
79
  }
80
- };
 
1
  import { toPublicUser } from '$lib/domain/types';
2
+ import { guarded } from '$lib/server/actions';
3
  import { requireAdmin, requireMemberTarget } from '$lib/server/auth/guards';
4
  import { UserValidationError } from '$lib/server/users/repository';
5
  import { fail } from '@sveltejs/kit';
 
18
  });
19
  }
20
 
21
+ export const actions = guarded<Actions>({
22
  create: async ({ request, locals }) => {
23
  requireAdmin(locals);
24
  const form = await request.formData();
 
78
  { action: 'setActive', id }
79
  );
80
  }
81
+ });
src/routes/admin/storage/+page.server.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { requireAdmin } from '$lib/server/auth/guards';
2
  import { fail } from '@sveltejs/kit';
3
  import type { Actions, PageServerLoad } from './$types';
@@ -17,7 +18,7 @@ export const load: PageServerLoad = ({ locals }) => {
17
  };
18
  };
19
 
20
- export const actions: Actions = {
21
  compact: async ({ locals }) => {
22
  requireAdmin(locals);
23
  try {
@@ -28,4 +29,4 @@ export const actions: Actions = {
28
  return fail(500, { error: 'Compaction failed; see server logs' });
29
  }
30
  }
31
- };
 
1
+ import { guarded } from '$lib/server/actions';
2
  import { requireAdmin } from '$lib/server/auth/guards';
3
  import { fail } from '@sveltejs/kit';
4
  import type { Actions, PageServerLoad } from './$types';
 
18
  };
19
  };
20
 
21
+ export const actions = guarded<Actions>({
22
  compact: async ({ locals }) => {
23
  requireAdmin(locals);
24
  try {
 
29
  return fail(500, { error: 'Compaction failed; see server logs' });
30
  }
31
  }
32
+ });
src/routes/expenses/[id]/+page.server.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
2
  import { error, fail, redirect } from '@sveltejs/kit';
3
  import type { Actions, PageServerLoad } from './$types';
@@ -19,7 +20,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
19
  };
20
  };
21
 
22
- export const actions: Actions = {
23
  delete: async ({ params, request, locals }) => {
24
  const form = await request.formData();
25
  const version = Number(form.get('version'));
@@ -35,4 +36,4 @@ export const actions: Actions = {
35
  locals.app.store.delete([expense.photo.key]).catch((err) => console.warn('[photos] delete failed', err));
36
  redirect(303, '/');
37
  }
38
- };
 
1
+ import { guarded } from '$lib/server/actions';
2
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
3
  import { error, fail, redirect } from '@sveltejs/kit';
4
  import type { Actions, PageServerLoad } from './$types';
 
20
  };
21
  };
22
 
23
+ export const actions = guarded<Actions>({
24
  delete: async ({ params, request, locals }) => {
25
  const form = await request.formData();
26
  const version = Number(form.get('version'));
 
36
  locals.app.store.delete([expense.photo.key]).catch((err) => console.warn('[photos] delete failed', err));
37
  redirect(303, '/');
38
  }
39
+ });
src/routes/expenses/[id]/edit/+page.server.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { toPublicUser } from '$lib/domain/types';
2
  import type { ExpensePhoto } from '$lib/domain/types';
 
3
  import { buildExpenseBody, expenseToFormValues, readExpenseForm } from '$lib/server/forms/expense-form';
4
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
5
  import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
@@ -16,7 +17,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
16
  };
17
  };
18
 
19
- export const actions: Actions = {
20
  default: async ({ params, request, locals }) => {
21
  const { users, ledger, store, config } = locals.app;
22
  const expense = ledger.getExpense(params.id);
@@ -65,4 +66,4 @@ export const actions: Actions = {
65
  if (staleKey) store.delete([staleKey]).catch((err) => console.warn('[photos] delete failed', err));
66
  redirect(303, `/expenses/${expense.id}`);
67
  }
68
- };
 
1
  import { toPublicUser } from '$lib/domain/types';
2
  import type { ExpensePhoto } from '$lib/domain/types';
3
+ import { guarded } from '$lib/server/actions';
4
  import { buildExpenseBody, expenseToFormValues, readExpenseForm } from '$lib/server/forms/expense-form';
5
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
6
  import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
 
17
  };
18
  };
19
 
20
+ export const actions = guarded<Actions>({
21
  default: async ({ params, request, locals }) => {
22
  const { users, ledger, store, config } = locals.app;
23
  const expense = ledger.getExpense(params.id);
 
66
  if (staleKey) store.delete([staleKey]).catch((err) => console.warn('[photos] delete failed', err));
67
  redirect(303, `/expenses/${expense.id}`);
68
  }
69
+ });
src/routes/expenses/new/+page.server.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { toPublicUser } from '$lib/domain/types';
2
  import { ulid } from '$lib/domain/ulid';
 
3
  import { buildExpenseBody, emptyExpenseForm, readExpenseForm } from '$lib/server/forms/expense-form';
4
  import { today } from '$lib/server/forms/form-utils';
5
  import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
@@ -18,7 +19,7 @@ export const load: PageServerLoad = ({ locals }) => {
18
  };
19
  };
20
 
21
- export const actions: Actions = {
22
  default: async ({ request, locals }) => {
23
  const { users, ledger, store, config } = locals.app;
24
  const active = users.activeMembers().map((u) => u.id);
@@ -37,4 +38,4 @@ export const actions: Actions = {
37
  await ledger.createExpense(locals.user!.id, built.body, expenseId);
38
  redirect(303, `/expenses/${expenseId}`);
39
  }
40
- };
 
1
  import { toPublicUser } from '$lib/domain/types';
2
  import { ulid } from '$lib/domain/ulid';
3
+ import { guarded } from '$lib/server/actions';
4
  import { buildExpenseBody, emptyExpenseForm, readExpenseForm } from '$lib/server/forms/expense-form';
5
  import { today } from '$lib/server/forms/form-utils';
6
  import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
 
19
  };
20
  };
21
 
22
+ export const actions = guarded<Actions>({
23
  default: async ({ request, locals }) => {
24
  const { users, ledger, store, config } = locals.app;
25
  const active = users.activeMembers().map((u) => u.id);
 
38
  await ledger.createExpense(locals.user!.id, built.body, expenseId);
39
  redirect(303, `/expenses/${expenseId}`);
40
  }
41
+ });
src/routes/login/+page.server.ts CHANGED
@@ -1,18 +1,31 @@
1
  import { dev } from '$app/environment';
 
2
  import { safeNext } from '$lib/server/auth/safe-next';
3
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
4
  import { fail, redirect } from '@sveltejs/kit';
5
  import type { Actions } from './$types';
6
 
7
- export const actions: Actions = {
8
- default: async ({ request, locals, cookies, url }) => {
 
 
9
  const form = await request.formData();
10
  const username = String(form.get('username') ?? '').trim();
11
  const password = String(form.get('password') ?? '');
12
  if (!username || !password)
13
  return fail(400, { error: 'Enter your username and password', username });
 
 
 
 
 
 
 
 
 
 
14
  if (!locals.app.loginLimiter.allow(username.toLowerCase())) {
15
- return fail(429, { error: 'Too many attempts. Wait a minute and try again.', username });
16
  }
17
  const user = await locals.app.users.authenticate(username, password);
18
  if (!user) return fail(400, { error: 'Invalid username or password', username });
@@ -33,4 +46,4 @@ export const actions: Actions = {
33
 
34
  redirect(303, safeNext(url.searchParams.get('next')));
35
  }
36
- };
 
1
  import { dev } from '$app/environment';
2
+ import { guarded } from '$lib/server/actions';
3
  import { safeNext } from '$lib/server/auth/safe-next';
4
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
5
  import { fail, redirect } from '@sveltejs/kit';
6
  import type { Actions } from './$types';
7
 
8
+ const TOO_MANY = 'Too many attempts. Wait a minute and try again.';
9
+
10
+ export const actions = guarded<Actions>({
11
+ default: async ({ request, locals, cookies, url, getClientAddress }) => {
12
  const form = await request.formData();
13
  const username = String(form.get('username') ?? '').trim();
14
  const password = String(form.get('password') ?? '');
15
  if (!username || !password)
16
  return fail(400, { error: 'Enter your username and password', username });
17
+
18
+ // Per-IP first: the username limiter alone lets one host spray many usernames.
19
+ let ip: string;
20
+ try {
21
+ ip = getClientAddress();
22
+ } catch {
23
+ ip = 'unknown';
24
+ }
25
+ if (!locals.app.ipLimiter.allow(ip)) return fail(429, { error: TOO_MANY, username });
26
+
27
  if (!locals.app.loginLimiter.allow(username.toLowerCase())) {
28
+ return fail(429, { error: TOO_MANY, username });
29
  }
30
  const user = await locals.app.users.authenticate(username, password);
31
  if (!user) return fail(400, { error: 'Invalid username or password', username });
 
46
 
47
  redirect(303, safeNext(url.searchParams.get('next')));
48
  }
49
+ });
src/routes/me/+page.server.ts CHANGED
@@ -1,8 +1,9 @@
 
1
  import { UserValidationError } from '$lib/server/users/repository';
2
  import { fail } from '@sveltejs/kit';
3
  import type { Actions } from './$types';
4
 
5
- export const actions: Actions = {
6
  rename: async ({ request, locals }) => {
7
  const form = await request.formData();
8
  const displayName = String(form.get('displayName') ?? '');
@@ -14,4 +15,4 @@ export const actions: Actions = {
14
  throw e;
15
  }
16
  }
17
- };
 
1
+ import { guarded } from '$lib/server/actions';
2
  import { UserValidationError } from '$lib/server/users/repository';
3
  import { fail } from '@sveltejs/kit';
4
  import type { Actions } from './$types';
5
 
6
+ export const actions = guarded<Actions>({
7
  rename: async ({ request, locals }) => {
8
  const form = await request.formData();
9
  const displayName = String(form.get('displayName') ?? '');
 
15
  throw e;
16
  }
17
  }
18
+ });
src/routes/me/password/+page.server.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { dev } from '$app/environment';
 
2
  import { verifyPassword } from '$lib/server/auth/password';
3
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
4
  import { UserValidationError } from '$lib/server/users/repository';
@@ -7,7 +8,7 @@ import type { Actions, PageServerLoad } from './$types';
7
 
8
  export const load: PageServerLoad = ({ locals }) => ({ forced: locals.user!.mustChangePassword });
9
 
10
- export const actions: Actions = {
11
  default: async ({ request, locals, cookies }) => {
12
  const user = locals.user!;
13
  const form = await request.formData();
@@ -39,4 +40,4 @@ export const actions: Actions = {
39
  }
40
  redirect(303, '/');
41
  }
42
- };
 
1
  import { dev } from '$app/environment';
2
+ import { guarded } from '$lib/server/actions';
3
  import { verifyPassword } from '$lib/server/auth/password';
4
  import { createSessionToken, SESSION_COOKIE, SESSION_TTL_SECONDS } from '$lib/server/auth/session';
5
  import { UserValidationError } from '$lib/server/users/repository';
 
8
 
9
  export const load: PageServerLoad = ({ locals }) => ({ forced: locals.user!.mustChangePassword });
10
 
11
+ export const actions = guarded<Actions>({
12
  default: async ({ request, locals, cookies }) => {
13
  const user = locals.user!;
14
  const form = await request.formData();
 
40
  }
41
  redirect(303, '/');
42
  }
43
+ });
src/routes/settlements/[id]/+page.server.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
2
  import { error, fail, redirect } from '@sveltejs/kit';
3
  import type { Actions, PageServerLoad } from './$types';
@@ -16,7 +17,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
16
  };
17
  };
18
 
19
- export const actions: Actions = {
20
  delete: async ({ params, request, locals }) => {
21
  const version = Number((await request.formData()).get('version'));
22
  try {
@@ -28,4 +29,4 @@ export const actions: Actions = {
28
  }
29
  redirect(303, '/');
30
  }
31
- };
 
1
+ import { guarded } from '$lib/server/actions';
2
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
3
  import { error, fail, redirect } from '@sveltejs/kit';
4
  import type { Actions, PageServerLoad } from './$types';
 
17
  };
18
  };
19
 
20
+ export const actions = guarded<Actions>({
21
  delete: async ({ params, request, locals }) => {
22
  const version = Number((await request.formData()).get('version'));
23
  try {
 
29
  }
30
  redirect(303, '/');
31
  }
32
+ });
src/routes/settlements/[id]/edit/+page.server.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
  readSettlementForm,
5
  settlementToFormValues
6
  } from '$lib/server/forms/settlement-form';
 
7
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
8
  import { error, fail, redirect } from '@sveltejs/kit';
9
  import type { Actions, PageServerLoad } from './$types';
@@ -18,7 +19,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
18
  };
19
  };
20
 
21
- export const actions: Actions = {
22
  default: async ({ params, request, locals }) => {
23
  const active = locals.app.users.activeMembers().map((u) => u.id);
24
  const form = await request.formData();
@@ -46,4 +47,4 @@ export const actions: Actions = {
46
  }
47
  redirect(303, `/settlements/${params.id}`);
48
  }
49
- };
 
4
  readSettlementForm,
5
  settlementToFormValues
6
  } from '$lib/server/forms/settlement-form';
7
+ import { guarded } from '$lib/server/actions';
8
  import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
9
  import { error, fail, redirect } from '@sveltejs/kit';
10
  import type { Actions, PageServerLoad } from './$types';
 
19
  };
20
  };
21
 
22
+ export const actions = guarded<Actions>({
23
  default: async ({ params, request, locals }) => {
24
  const active = locals.app.users.activeMembers().map((u) => u.id);
25
  const form = await request.formData();
 
47
  }
48
  redirect(303, `/settlements/${params.id}`);
49
  }
50
+ });
src/routes/settlements/new/+page.server.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { toPublicUser } from '$lib/domain/types';
 
2
  import { today } from '$lib/server/forms/form-utils';
3
  import {
4
  buildSettlementBody,
@@ -18,7 +19,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
18
  };
19
  };
20
 
21
- export const actions: Actions = {
22
  default: async ({ request, locals }) => {
23
  const active = locals.app.users.activeMembers().map((u) => u.id);
24
  const values = readSettlementForm(await request.formData());
@@ -27,4 +28,4 @@ export const actions: Actions = {
27
  const s = await locals.app.ledger.createSettlement(locals.user!.id, built.body);
28
  redirect(303, `/settlements/${s.id}`);
29
  }
30
- };
 
1
  import { toPublicUser } from '$lib/domain/types';
2
+ import { guarded } from '$lib/server/actions';
3
  import { today } from '$lib/server/forms/form-utils';
4
  import {
5
  buildSettlementBody,
 
19
  };
20
  };
21
 
22
+ export const actions = guarded<Actions>({
23
  default: async ({ request, locals }) => {
24
  const active = locals.app.users.activeMembers().map((u) => u.id);
25
  const values = readSettlementForm(await request.formData());
 
28
  const s = await locals.app.ledger.createSettlement(locals.user!.id, built.body);
29
  redirect(303, `/settlements/${s.id}`);
30
  }
31
+ });
tests/unit/auth/auth.test.ts CHANGED
@@ -32,6 +32,17 @@ describe('password', () => {
32
  expect(results).toHaveLength(10);
33
  for (const hash of results) expect(hash.startsWith('scrypt$')).toBe(true);
34
  });
 
 
 
 
 
 
 
 
 
 
 
35
  });
36
 
37
  describe('session token', () => {
 
32
  expect(results).toHaveLength(10);
33
  for (const hash of results) expect(hash.startsWith('scrypt$')).toBe(true);
34
  });
35
+
36
+ // 4 slots + a 64-deep wait queue: past that, callers are shed instead of parked forever.
37
+ it('sheds callers once the scrypt wait queue is full', async () => {
38
+ const settled = await Promise.allSettled(Array.from({ length: 70 }, (_, i) => hashPassword(`flood-${i}`)));
39
+ const rejected = settled.filter((r) => r.status === 'rejected');
40
+ expect(rejected.length).toBeGreaterThan(0);
41
+ for (const r of rejected) expect((r.reason as Error).message).toBe('Too many concurrent login attempts');
42
+ for (const r of settled.filter((r) => r.status === 'fulfilled')) {
43
+ expect((r as PromiseFulfilledResult<string>).value.startsWith('scrypt$')).toBe(true);
44
+ }
45
+ }, 60_000);
46
  });
47
 
48
  describe('session token', () => {
tests/unit/ledger/ledger.test.ts CHANGED
@@ -102,7 +102,7 @@ describe('Ledger', () => {
102
  expect(l2.stats().pendingEvents).toBe(0);
103
  });
104
 
105
- it('serialises concurrent writes', async () => {
106
  const ledger = await Ledger.load(store, { clock });
107
  const results = await Promise.all(Array.from({ length: 10 }, (_, i) => ledger.createExpense('u_a', { ...body, description: `e${i}` })));
108
  expect(new Set(results.map((r) => r.id)).size).toBe(10);
 
102
  expect(l2.stats().pendingEvents).toBe(0);
103
  });
104
 
105
+ it('serializes concurrent writes', async () => {
106
  const ledger = await Ledger.load(store, { clock });
107
  const results = await Promise.all(Array.from({ length: 10 }, (_, i) => ledger.createExpense('u_a', { ...body, description: `e${i}` })));
108
  expect(new Set(results.map((r) => r.id)).size).toBe(10);
tests/unit/server/actions.test.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RequestEvent } from '@sveltejs/kit';
2
+ import { fail, isActionFailure, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { guarded } from '../../../src/lib/server/actions';
5
+ import { ReadOnlyError } from '../../../src/lib/server/ledger/errors';
6
+
7
+ /** The guard never touches the event, so a bare object is enough. */
8
+ const event = { request: new Request('http://localhost/'), locals: {} } as unknown as RequestEvent;
9
+
10
+ describe('guarded', () => {
11
+ beforeEach(() => {
12
+ vi.spyOn(console, 'error').mockImplementation(() => {});
13
+ });
14
+
15
+ it('turns an unexpected throw into a 503 with a retry message', async () => {
16
+ const actions = guarded({
17
+ go: async (_e: RequestEvent) => {
18
+ throw new Error('boom');
19
+ }
20
+ });
21
+ const e = await actions.go(event).then(
22
+ () => null,
23
+ (err: unknown) => err
24
+ );
25
+ expect(isHttpError(e)).toBe(true);
26
+ expect(isHttpError(e) && e.status).toBe(503);
27
+ expect(isHttpError(e) && e.body.message).toBe(
28
+ 'Could not save — the storage backend is unavailable. Please try again.'
29
+ );
30
+ expect(console.error).toHaveBeenCalledWith('[action] storage failure', expect.any(Error));
31
+ });
32
+
33
+ it('maps ReadOnlyError to a 503 that says the app is read-only', async () => {
34
+ const actions = guarded({
35
+ go: async (_e: RequestEvent) => {
36
+ throw new ReadOnlyError();
37
+ }
38
+ });
39
+ const e = await actions.go(event).then(
40
+ () => null,
41
+ (err: unknown) => err
42
+ );
43
+ expect(isHttpError(e) && e.status).toBe(503);
44
+ expect(isHttpError(e) && e.body.message).toBe(
45
+ 'The app is temporarily read-only; try again in a couple of minutes.'
46
+ );
47
+ });
48
+
49
+ it('rethrows redirects unchanged', async () => {
50
+ const actions = guarded({
51
+ go: async (_e: RequestEvent) => {
52
+ redirect(303, '/');
53
+ }
54
+ });
55
+ const e = await actions.go(event).then(
56
+ () => null,
57
+ (err: unknown) => err
58
+ );
59
+ expect(isRedirect(e)).toBe(true);
60
+ expect(isRedirect(e) && e.status).toBe(303);
61
+ expect(isRedirect(e) && e.location).toBe('/');
62
+ });
63
+
64
+ it('returns action failures unchanged', async () => {
65
+ const failure = fail(400, { error: 'nope' });
66
+ const actions = guarded({ go: async (_e: RequestEvent) => failure });
67
+ const result = await actions.go(event);
68
+ expect(result).toBe(failure);
69
+ expect(isActionFailure(result)).toBe(true);
70
+ });
71
+ });
tsconfig.json CHANGED
@@ -19,6 +19,7 @@
19
  "./.svelte-kit/non-ambient.d.ts",
20
  "./.svelte-kit/types/**/$types.d.ts",
21
  "./vite.config.ts",
 
22
  "./src/**/*.js",
23
  "./src/**/*.ts",
24
  "./src/**/*.svelte",
 
19
  "./.svelte-kit/non-ambient.d.ts",
20
  "./.svelte-kit/types/**/$types.d.ts",
21
  "./vite.config.ts",
22
+ "./vitest.integration.config.ts",
23
  "./src/**/*.js",
24
  "./src/**/*.ts",
25
  "./src/**/*.svelte",
vite.config.ts CHANGED
@@ -5,7 +5,9 @@ import { defineConfig } from 'vitest/config';
5
  export default defineConfig({
6
  plugins: [tailwindcss(), sveltekit()],
7
  test: {
8
- include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
 
 
9
  environment: 'node'
10
  }
11
  });
 
5
  export default defineConfig({
6
  plugins: [tailwindcss(), sveltekit()],
7
  test: {
8
+ // Unit tests only: `npm test` must never touch the real bucket.
9
+ // Integration tests run via vitest.integration.config.ts.
10
+ include: ['tests/unit/**/*.test.ts'],
11
  environment: 'node'
12
  }
13
  });
vitest.integration.config.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vitest/config';
2
+ import base from './vite.config';
3
+
4
+ /** Real-bucket tests. Opt-in via `npm run test:integration`; needs HF_TOKEN/HF_BUCKET. */
5
+ export default defineConfig({
6
+ ...base,
7
+ test: { ...base.test, include: ['tests/integration/**/*.test.ts'] }
8
+ });