Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# Required: long random string for signing session cookies
# Generate with: openssl rand -hex 32
SESSION_SECRET=change-me-to-a-long-random-string

# Database file path
# Default in dev: ./data/tipsplit.db
# Default in container: /app/data/tipsplit.db
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ cp .env.example .env
Edit `.env` — at minimum:

```bash
SESSION_SECRET= # openssl rand -hex 32
INITIAL_MANAGER_PIN=1234 # remove after first login
```

Expand Down Expand Up @@ -194,7 +193,7 @@ Each exported calculation appends one row with the full breakdown.

```bash
npm install
cp .env.example .env # set SESSION_SECRET and INITIAL_MANAGER_PIN
cp .env.example .env # set INITIAL_MANAGER_PIN
npm run dev # http://localhost:5173
npm test # unit tests
npm run check # TypeScript
Expand Down
2 changes: 2 additions & 0 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export async function findUserByPin(pin: string): Promise<UserRow | null> {
return null;
}

// Sessions use a cryptographically random 32-byte token stored server-side in SQLite.
// No cookie signing is needed because the token itself is the secret.
export function createSession(userId: number): string {
const id = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_SECS * 1000).toISOString();
Expand Down
68 changes: 65 additions & 3 deletions src/routes/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,91 @@ import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { findUserByPin, createSession } from '$lib/server/auth';

const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const MAX_ATTEMPTS = 5;
const GLOBAL_MAX = 50;

// Per-IP failed attempt tracking
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>();

// Global counter for distributed brute force detection
let globalFailed = { count: 0, firstAttempt: 0 };
Comment on lines +9 to +13
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rate-limiter state is kept in-process (failedAttempts/globalFailed). In adapter-node deployments with multiple app instances/processes (or after restarts), limits won’t be enforced consistently and can be bypassed by spreading attempts across instances. Consider moving counters to a shared store (SQLite/Redis) or explicitly documenting that the limiter is best-effort per-process only.

Copilot uses AI. Check for mistakes.

// Sweep stale entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of failedAttempts) {
if (now - entry.firstAttempt > WINDOW_MS) failedAttempts.delete(ip);
Comment on lines +15 to +19
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module-level setInterval will run for the lifetime of the server process and can be duplicated on dev/HMR reloads, creating multiple sweep timers. Consider removing the interval and doing request-driven cleanup only, or guarding it (e.g., via globalThis) and calling timer.unref() so it doesn’t keep processes alive unexpectedly.

Copilot uses AI. Check for mistakes.
}
if (globalFailed.count > 0 && now - globalFailed.firstAttempt > WINDOW_MS) {
globalFailed = { count: 0, firstAttempt: 0 };
}
}, 5 * 60 * 1000);

export const load: PageServerLoad = ({ locals }) => {
if (locals.user) redirect(303, '/calculate');
return {};
};

export const actions: Actions = {
default: async ({ request, cookies, locals }) => {
default: async ({ request, cookies, locals, getClientAddress }) => {
if (locals.user) redirect(303, '/calculate');

const pin = String((await request.formData()).get('pin') ?? '').trim();

if (pin.length < 4) return fail(400, { error: 'PIN must be at least 4 digits' });

const ip = getClientAddress();
const now = Date.now();
Comment on lines +39 to +40
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClientAddress() commonly resolves to the reverse proxy’s IP when the app is behind a proxy (the repo includes a Caddy reverse_proxy example in deploy/Caddyfile.example). If that’s the case, all users would share the same rate-limit bucket. Consider deriving the client IP from trusted forwarded headers (and rejecting spoofed values) or documenting the required proxy/header setup for accurate IP rate limiting.

Copilot uses AI. Check for mistakes.

// Lazy cleanup: expire this IP's window if stale
const entry = failedAttempts.get(ip);
if (entry && now - entry.firstAttempt > WINDOW_MS) {
failedAttempts.delete(ip);
}

// Check per-IP rate limit
const current = failedAttempts.get(ip);
if (current && current.count >= MAX_ATTEMPTS) {
return fail(429, { error: 'Too many attempts. Try again in a few minutes.' });
}

// Check global rate limit (distributed brute force)
if (globalFailed.count > 0 && now - globalFailed.firstAttempt > WINDOW_MS) {
globalFailed = { count: 0, firstAttempt: 0 };
}
if (globalFailed.count >= GLOBAL_MAX) {
return fail(429, { error: 'Too many attempts. Try again in a few minutes.' });
Comment on lines +55 to +59
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global failure limit (GLOBAL_MAX) creates a trivial denial-of-service: an attacker can intentionally submit 50 bad PINs to block all logins for 15 minutes. Consider making the global counter non-blocking (e.g., only logging/alerting), scoping it (per-user/per-username/per-IP-range), or using an adaptive backoff instead of a hard global lockout.

Copilot uses AI. Check for mistakes.
}

const user = await findUserByPin(pin);
if (!user) return fail(401, { error: 'Incorrect PIN — try again' });
if (!user) {
// Increment per-IP counter
const existing = failedAttempts.get(ip);
if (existing) {
existing.count++;
} else {
failedAttempts.set(ip, { count: 1, firstAttempt: now });
}
// Increment global counter
if (globalFailed.count === 0) {
globalFailed = { count: 1, firstAttempt: now };
} else {
globalFailed.count++;
}
return fail(401, { error: 'Incorrect PIN — try again' });
}

// Successful login: clear per-IP counter
failedAttempts.delete(ip);

const sessionId = createSession(user.id);
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24,
secure: process.env.NODE_ENV === 'production',
secure: process.env.NODE_ENV !== 'development',
});

redirect(303, '/calculate');
Expand Down
16 changes: 12 additions & 4 deletions src/routes/calculate/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@ export const load: PageServerLoad = ({ locals }) => {

const settings = getSettings();
const today = new Date().toISOString().split('T')[0];
const cutoffHour = parseInt(settings.lunch_cutoff?.split(':')[0] ?? '15', 10);
// Convert Pacific time: UTC-7 (PDT) or UTC-8 (PST) — approximate with UTC-7
const pacificHour = (new Date().getUTCHours() - 7 + 24) % 24;
const defaultShift = pacificHour >= cutoffHour ? 'Dinner' : 'Lunch';
const [cutoffH, cutoffM] = (settings.lunch_cutoff ?? '15:00').split(':').map(Number);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Los_Angeles',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(new Date());
const localHour = parseInt(parts.find(p => p.type === 'hour')!.value, 10);
const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value, 10);
const defaultShift = localHour > cutoffH || (localHour === cutoffH && localMinute >= cutoffM)
? 'Dinner'
: 'Lunch';

return { staff, settings, today, defaultShift, user: locals.user };
};
Expand Down
20 changes: 17 additions & 3 deletions src/routes/settings/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,24 @@ export const actions: Actions = {
}

const fd = await request.formData();

const ccFeeRate = parseFloat(String(fd.get('cc_fee_rate') ?? ''));
const kitchenPct = parseFloat(String(fd.get('kitchen_pct') ?? ''));
const barLiquorPct = parseFloat(String(fd.get('bar_liquor_pct') ?? ''));

if (isNaN(ccFeeRate) || ccFeeRate < 0 || ccFeeRate > 100)
return fail(400, { error: 'CC fee rate must be between 0 and 100' });
if (isNaN(kitchenPct) || kitchenPct < 0 || kitchenPct > 100)
return fail(400, { error: 'Kitchen % must be between 0 and 100' });
if (isNaN(barLiquorPct) || barLiquorPct < 0 || barLiquorPct > 100)
return fail(400, { error: 'Bar liquor % must be between 0 and 100' });
if (kitchenPct + barLiquorPct > 100)
return fail(400, { error: 'Kitchen % and bar liquor % cannot exceed 100% combined' });

const updates: [string, string][] = [
['cc_fee_rate', String(fd.get('cc_fee_rate') ?? '')],
['kitchen_pct', String(fd.get('kitchen_pct') ?? '')],
['bar_liquor_pct', String(fd.get('bar_liquor_pct') ?? '')],
['cc_fee_rate', String(ccFeeRate)],
['kitchen_pct', String(kitchenPct)],
['bar_liquor_pct', String(barLiquorPct)],
['lunch_cutoff', String(fd.get('lunch_cutoff') ?? '15:00')],
['restaurant_name', String(fd.get('restaurant_name') ?? '')],
['google_sheets_spreadsheet_id', String(fd.get('google_sheets_spreadsheet_id') ?? '')],
Expand Down