diff --git a/Dockerfile b/Dockerfile index dcbc76a..5a8753c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,11 @@ RUN mkdir -p /app/uploads EXPOSE 3000 +RUN apk add --no-cache curl + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index 6d8185d..4788c4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,12 @@ services: depends_on: db: condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health'] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 volumes: - uploads:/app/uploads diff --git a/docs/backup.md b/docs/backup.md new file mode 100644 index 0000000..290001e --- /dev/null +++ b/docs/backup.md @@ -0,0 +1,83 @@ +# Pounce — Backup & Restore + +Pounce runs as the **kanban** Portainer stack with: +- App on port **3200** (container 3000) +- PostgreSQL on port **3201** (container 5432) +- Named volumes: `kanban_pgdata`, `kanban_uploads` + +## Database Backup (pg_dump) + +### Via docker exec (recommended) + +```bash +# Find the db container name +docker ps --filter "label=com.docker.compose.project=kanban" --format '{{.Names}}' | grep db + +# Dump to a local file +docker exec pg_dump -U kanban kanban > pounce-backup-$(date +%Y%m%d).sql +``` + +### Via host port + +```bash +PGPASSWORD=kanban pg_dump -h 192.168.3.65 -p 3201 -U kanban kanban > pounce-backup-$(date +%Y%m%d).sql +``` + +### Compressed backup + +```bash +docker exec pg_dump -U kanban -Fc kanban > pounce-backup-$(date +%Y%m%d).dump +``` + +## Uploads Volume Backup + +```bash +# Find the volume mount path +docker volume inspect kanban_uploads --format '{{.Mountpoint}}' + +# Copy files out +docker cp :/app/uploads ./uploads-backup-$(date +%Y%m%d) +``` + +## Restore + +### Database restore (plain SQL) + +```bash +# Drop and recreate (will lose existing data) +docker exec -i psql -U kanban -c "DROP DATABASE kanban; CREATE DATABASE kanban;" +docker exec -i psql -U kanban kanban < pounce-backup-20250101.sql +``` + +### Database restore (custom format) + +```bash +docker exec -i pg_restore -U kanban -d kanban --clean --if-exists < pounce-backup-20250101.dump +``` + +### Uploads restore + +```bash +docker cp ./uploads-backup-20250101/. :/app/uploads/ +``` + +### After restore + +Restart the app container so Prisma picks up the restored data: + +```bash +docker restart +``` + +## Automated Backups (cron example) + +```bash +# /etc/cron.d/pounce-backup — daily at 3 AM +0 3 * * * root docker exec kanban-db-1 pg_dump -U kanban kanban | gzip > /backups/pounce-$(date +\%Y\%m\%d).sql.gz +``` + +Retain the last 14 daily backups: + +```bash +find /backups -name 'pounce-*.sql.gz' -mtime +14 -delete +``` diff --git a/package-lock.json b/package-lock.json index 449fb1c..35154b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "dompurify": "^3.3.1", "express": "^5.1.0", "marked": "^15.0.0", + "pino": "^10.3.1", "socket.io": "^4.8.0", "socket.io-client": "^4.8.0", "svelte-dnd-action": "^0.9.52", @@ -498,6 +499,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1578,6 +1585,15 @@ "node": ">= 0.4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -3184,6 +3200,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3262,6 +3287,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -3337,6 +3399,22 @@ } } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3382,6 +3460,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3431,6 +3515,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3535,6 +3628,15 @@ "node": ">=6" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3783,6 +3885,15 @@ "node": ">= 0.6" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3792,6 +3903,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3904,6 +4024,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index a37a397..cf057d8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dompurify": "^3.3.1", "express": "^5.1.0", "marked": "^15.0.0", + "pino": "^10.3.1", "socket.io": "^4.8.0", "socket.io-client": "^4.8.0", "svelte-dnd-action": "^0.9.52", diff --git a/server.js b/server.js index 8493e67..3a038ca 100644 --- a/server.js +++ b/server.js @@ -2,8 +2,11 @@ import express from 'express'; import { createServer } from 'http'; import { Server } from 'socket.io'; import { PrismaClient } from '@prisma/client'; +import pino from 'pino'; import { handler } from './build/handler.js'; +const logger = pino({ name: 'pounce' }); + const app = express(); const server = createServer(app); @@ -64,7 +67,7 @@ io.use(async (socket, next) => { } next(); } catch (err) { - console.error('Socket auth error:', err); + logger.error({ err }, 'Socket auth error'); socket.data.user = null; next(); } @@ -73,7 +76,7 @@ io.use(async (socket, next) => { // ─── Socket.IO connection handler ──────────────────────────── io.on('connection', (socket) => { const user = socket.data.user; - console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`); + logger.info({ socketId: socket.id, user: user?.name ?? 'anon' }, 'Socket connected'); // Join user-specific room for notifications if (user) { @@ -122,7 +125,7 @@ io.on('connection', (socket) => { }); socket.on('disconnect', () => { - console.log(`Socket disconnected: ${socket.id}`); + logger.info({ socketId: socket.id }, 'Socket disconnected'); for (const boardId of joinedBoards) { if (presence.has(boardId)) { presence.get(boardId).delete(socket.id); @@ -152,5 +155,5 @@ const port = process.env.PORT || 3000; const host = process.env.HOST || '0.0.0.0'; server.listen(port, host, () => { - console.log(`Server running on http://${host}:${port}`); + logger.info({ host, port }, 'Server running'); }); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 96b7e7b..d5436be 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,8 +1,10 @@ import type { Handle } from '@sveltejs/kit'; import { resolveTenant } from '$lib/server/tenant.js'; import { validateSession, sessionCookieName } from '$lib/server/auth.js'; +import { logger } from '$lib/server/logger.js'; export const handle: Handle = async ({ event, resolve }) => { + const start = Date.now(); // 1. Resolve tenant from hostname const hostname = event.url.hostname; const tenant = await resolveTenant(hostname); @@ -45,5 +47,14 @@ export const handle: Handle = async ({ event, resolve }) => { // 3. Read theme preference event.locals.theme = event.cookies.get('theme') || undefined; - return resolve(event); + const response = await resolve(event); + + logger.info({ + method: event.request.method, + path: event.url.pathname, + status: response.status, + duration_ms: Date.now() - start + }, 'request'); + + return response; }; diff --git a/src/lib/server/guards.ts b/src/lib/server/guards.ts index 2bfc9f8..7009fe0 100644 --- a/src/lib/server/guards.ts +++ b/src/lib/server/guards.ts @@ -1,6 +1,6 @@ import { error } from '@sveltejs/kit'; import type { Prisma } from '@prisma/client'; -import { canViewBoard, canEditBoard, canDeleteBoard, canManageMembers, isTenantAdmin } from './permissions.js'; +import { canViewBoard, canEditBoard, canDeleteBoard, canManageMembers, isSiteAdmin, isTenantAdmin } from './permissions.js'; type TxClient = Prisma.TransactionClient; @@ -50,6 +50,14 @@ export function requireAuth(user: SessionUser | null): asserts user is SessionUs if (!user) throw error(401, 'Not authenticated'); } +/** + * Asserts user is a site admin. Throws 403 if not. + */ +export function requireSiteAdmin(user: SessionUser | null): asserts user is SessionUser { + requireAuth(user); + if (!isSiteAdmin(user)) throw error(403, 'Site admin access required'); +} + /** * Asserts user is a tenant admin. Throws 403 if not. */ diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts new file mode 100644 index 0000000..d25794e --- /dev/null +++ b/src/lib/server/logger.ts @@ -0,0 +1,10 @@ +import pino from 'pino'; + +export const logger = pino({ name: 'pounce' }); + +export function createRequestLogger(event: { request: Request; url: URL }) { + return logger.child({ + method: event.request.method, + path: event.url.pathname + }); +} diff --git a/src/lib/server/ratelimit.ts b/src/lib/server/ratelimit.ts new file mode 100644 index 0000000..8f95150 --- /dev/null +++ b/src/lib/server/ratelimit.ts @@ -0,0 +1,57 @@ +const hits = new Map(); + +export interface RateLimitResult { + ok: boolean; + remaining: number; + retryAfter?: number; +} + +export function rateLimit( + ip: string, + opts: { window: number; max: number } +): RateLimitResult { + const now = Date.now(); + const windowStart = now - opts.window; + + let timestamps = hits.get(ip); + if (timestamps) { + timestamps = timestamps.filter((t) => t > windowStart); + hits.set(ip, timestamps); + } else { + timestamps = []; + hits.set(ip, timestamps); + } + + if (timestamps.length >= opts.max) { + const oldest = timestamps[0]; + const retryAfter = Math.ceil((oldest + opts.window - now) / 1000); + return { ok: false, remaining: 0, retryAfter }; + } + + timestamps.push(now); + return { ok: true, remaining: opts.max - timestamps.length }; +} + +/** Pre-configured: 10 login attempts per 60s */ +export function loginLimiter(ip: string): RateLimitResult { + return rateLimit(ip, { window: 60_000, max: 10 }); +} + +/** Pre-configured: 5 registrations per 3600s */ +export function registerLimiter(ip: string): RateLimitResult { + return rateLimit(ip, { window: 3_600_000, max: 5 }); +} + +// Periodic cleanup of stale entries every 60s +setInterval(() => { + const now = Date.now(); + const maxWindow = 3_600_000; // largest window we use + for (const [ip, timestamps] of hits) { + const fresh = timestamps.filter((t) => t > now - maxWindow); + if (fresh.length === 0) { + hits.delete(ip); + } else { + hits.set(ip, fresh); + } + } +}, 60_000).unref(); diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts index abd959e..b066b85 100644 --- a/src/routes/admin/+page.server.ts +++ b/src/routes/admin/+page.server.ts @@ -8,8 +8,8 @@ export const load: PageServerLoad = async ({ locals }) => { if (!tenant) throw error(400, 'Unknown tenant'); requireTenantAdmin(locals.user); - const { users, boards } = await withTenant(tenant.id, async (tx) => { - const [users, boards] = await Promise.all([ + const { users, boards, tags, categories } = await withTenant(tenant.id, async (tx) => { + const [users, boards, tags, categories] = await Promise.all([ tx.user.findMany({ where: { tenantId: tenant.id }, select: { @@ -31,10 +31,18 @@ export const load: PageServerLoad = async ({ locals }) => { _count: { select: { members: true } } }, orderBy: { updatedAt: 'desc' } + }), + tx.tag.findMany({ + where: { tenantId: tenant.id }, + orderBy: { name: 'asc' } + }), + tx.category.findMany({ + where: { tenantId: tenant.id }, + orderBy: { name: 'asc' } }) ]); - return { users, boards }; + return { users, boards, tags, categories }; }); - return { users, boards, tenantName: tenant.name }; + return { users, boards, tags, categories, tenantName: tenant.name }; }; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index 890ad64..70e3c01 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -1,23 +1,114 @@ + + + Site Admin - Pounce + + +
+

Site Administration

+ + +
+ {#each [ + { label: 'Tenants', value: stats.tenants }, + { label: 'Users', value: stats.users }, + { label: 'Boards', value: stats.boards }, + { label: 'Cards', value: stats.cards } + ] as stat} +
+
{stat.value.toLocaleString()}
+
{stat.label}
+
+ {/each} +
+ + +
+
+

Tenants

+ +
+ + + {#if showCreate} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ {/if} + + +
+ + + + + + + + + + + + + + {#each tenants as tenant (tenant.id)} + + + + + + + + + + {/each} + {#if tenants.length === 0} + + + + {/if} + +
NameSlugHostnamesUsersBoardsCreated
{tenant.name}{tenant.slug} + {#each tenant.hostnames as h} + {h.hostname} + {/each} + {tenant._count.users}{tenant._count.boards}{formatDate(tenant.createdAt)} + {#if deletingId === tenant.id} + Are you sure? + + + {:else} + + {/if} +
No tenants
+
+
+
diff --git a/src/routes/api/admin/site/tenants/+server.ts b/src/routes/api/admin/site/tenants/+server.ts new file mode 100644 index 0000000..5259d7a --- /dev/null +++ b/src/routes/api/admin/site/tenants/+server.ts @@ -0,0 +1,54 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { requireSiteAdmin } from '$lib/server/guards.js'; +import { withBypassRLS } from '$lib/server/rls.js'; + +export const GET: RequestHandler = async ({ locals }) => { + requireSiteAdmin(locals.user); + + const tenants = await withBypassRLS(async (tx) => { + return tx.tenant.findMany({ + include: { + _count: { select: { users: true, boards: true } }, + hostnames: { select: { hostname: true, isPrimary: true } } + }, + orderBy: { createdAt: 'desc' } + }); + }); + + return json({ tenants }); +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + requireSiteAdmin(locals.user); + + const body = await request.json(); + const { name, slug, hostname } = body; + + if (!name || !slug) { + return json({ error: 'Name and slug are required' }, { status: 400 }); + } + + const tenant = await withBypassRLS(async (tx) => { + const existing = await tx.tenant.findUnique({ where: { slug } }); + if (existing) return null; + + return tx.tenant.create({ + data: { + name, + slug, + hostnames: hostname ? { create: { hostname, isPrimary: true } } : undefined + }, + include: { + _count: { select: { users: true, boards: true } }, + hostnames: { select: { hostname: true, isPrimary: true } } + } + }); + }); + + if (!tenant) { + return json({ error: 'A tenant with this slug already exists' }, { status: 409 }); + } + + return json({ tenant }, { status: 201 }); +}; diff --git a/src/routes/api/admin/site/tenants/[tenantId]/+server.ts b/src/routes/api/admin/site/tenants/[tenantId]/+server.ts new file mode 100644 index 0000000..138b4cb --- /dev/null +++ b/src/routes/api/admin/site/tenants/[tenantId]/+server.ts @@ -0,0 +1,45 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { requireSiteAdmin } from '$lib/server/guards.js'; +import { withBypassRLS } from '$lib/server/rls.js'; + +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + requireSiteAdmin(locals.user); + + const body = await request.json(); + const { name } = body; + + if (!name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const tenant = await withBypassRLS(async (tx) => { + const existing = await tx.tenant.findUnique({ where: { id: params.tenantId } }); + if (!existing) return null; + + return tx.tenant.update({ + where: { id: params.tenantId }, + data: { name } + }); + }); + + if (!tenant) throw error(404, 'Tenant not found'); + + return json({ tenant }); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + requireSiteAdmin(locals.user); + + const deleted = await withBypassRLS(async (tx) => { + const existing = await tx.tenant.findUnique({ where: { id: params.tenantId } }); + if (!existing) return false; + + await tx.tenant.delete({ where: { id: params.tenantId } }); + return true; + }); + + if (!deleted) throw error(404, 'Tenant not found'); + + return json({ ok: true }); +}; diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 0ffd4ec..719ff59 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js'; import { verifyPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { loginSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; +import { loginLimiter } from '$lib/server/ratelimit.js'; -export const POST: RequestHandler = async ({ request, locals, cookies }) => { +export const POST: RequestHandler = async ({ request, locals, cookies, getClientAddress }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); + const limit = loginLimiter(getClientAddress()); + if (!limit.ok) { + return json({ error: 'Too many login attempts', retryAfter: limit.retryAfter }, { status: 429 }); + } + const body = await request.json(); const result = loginSchema.safeParse(body); if (!result.success) { diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 3f816d4..e8d4cbe 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -3,11 +3,17 @@ import type { RequestHandler } from './$types.js'; import { hashPassword, createSession, sessionCookieName, sessionCookieOptions } from '$lib/server/auth.js'; import { registerSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; +import { registerLimiter } from '$lib/server/ratelimit.js'; -export const POST: RequestHandler = async ({ request, locals, cookies }) => { +export const POST: RequestHandler = async ({ request, locals, cookies, getClientAddress }) => { const tenant = locals.tenant; if (!tenant) throw error(400, 'Unknown tenant'); + const limit = registerLimiter(getClientAddress()); + if (!limit.ok) { + return json({ error: 'Too many registration attempts', retryAfter: limit.retryAfter }, { status: 429 }); + } + const body = await request.json(); const result = registerSchema.safeParse(body); if (!result.success) { diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts new file mode 100644 index 0000000..4882177 --- /dev/null +++ b/src/routes/api/health/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { prisma } from '$lib/server/db.js'; + +export const GET: RequestHandler = async () => { + let db = false; + + try { + await prisma.$queryRaw`SELECT 1`; + db = true; + } catch { + // DB unreachable + } + + const body = { status: db ? 'ok' : 'degraded', db, uptime: process.uptime() }; + return json(body, { status: db ? 200 : 503 }); +};