Spaces:
Sleeping
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
Browse filesStorage 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 +1 -0
- README.md +7 -4
- package.json +1 -1
- src/hooks.server.ts +2 -0
- src/lib/domain/types.ts +0 -11
- src/lib/server/actions.ts +27 -0
- src/lib/server/app.ts +51 -1
- src/lib/server/auth/guards.ts +1 -1
- src/lib/server/auth/password.ts +15 -1
- src/lib/server/config.ts +5 -3
- src/lib/server/users/repository.ts +1 -0
- src/routes/+error.svelte +12 -0
- src/routes/admin/+page.server.ts +3 -2
- src/routes/admin/storage/+page.server.ts +3 -2
- src/routes/expenses/[id]/+page.server.ts +3 -2
- src/routes/expenses/[id]/edit/+page.server.ts +3 -2
- src/routes/expenses/new/+page.server.ts +3 -2
- src/routes/login/+page.server.ts +17 -4
- src/routes/me/+page.server.ts +3 -2
- src/routes/me/password/+page.server.ts +3 -2
- src/routes/settlements/[id]/+page.server.ts +3 -2
- src/routes/settlements/[id]/edit/+page.server.ts +3 -2
- src/routes/settlements/new/+page.server.ts +3 -2
- tests/unit/auth/auth.test.ts +11 -0
- tests/unit/ledger/ledger.test.ts +1 -1
- tests/unit/server/actions.test.ts +71 -0
- tsconfig.json +1 -0
- vite.config.ts +3 -1
- vitest.integration.config.ts +8 -0
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
node_modules
|
| 2 |
build
|
| 3 |
.svelte-kit
|
|
|
|
| 1 |
+
.git
|
| 2 |
node_modules
|
| 3 |
build
|
| 4 |
.svelte-kit
|
|
@@ -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
|
| 46 |
-
|
|
|
|
| 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 |
|
|
@@ -12,7 +12,7 @@
|
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
| 14 |
"test:e2e": "playwright test",
|
| 15 |
-
"test:integration": "vitest run
|
| 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",
|
|
@@ -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 |
};
|
|
@@ -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>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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
|
| 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');
|
|
@@ -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 |
}
|
|
@@ -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:
|
| 39 |
-
currency
|
| 40 |
-
compactionIntervalMs
|
| 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 |
}
|
|
@@ -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 |
}
|
|
@@ -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>
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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 |
-
|
| 8 |
-
|
|
|
|
|
|
|
| 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:
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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
|
| 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 |
+
});
|
|
@@ -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', () => {
|
|
@@ -102,7 +102,7 @@ describe('Ledger', () => {
|
|
| 102 |
expect(l2.stats().pendingEvents).toBe(0);
|
| 103 |
});
|
| 104 |
|
| 105 |
-
it('
|
| 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);
|
|
@@ -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 |
+
});
|
|
@@ -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",
|
|
@@ -5,7 +5,9 @@ import { defineConfig } from 'vitest/config';
|
|
| 5 |
export default defineConfig({
|
| 6 |
plugins: [tailwindcss(), sveltekit()],
|
| 7 |
test: {
|
| 8 |
-
|
|
|
|
|
|
|
| 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 |
});
|
|
@@ -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 |
+
});
|