Private
Public Access
1
0

Phase 9: Admin & DevOps — health check, rate limiting, logging, site admin, Docker healthcheck

- Health check endpoint (GET /api/health) with DB probe and uptime
- In-memory sliding window rate limiting on login (10/60s) and register (5/3600s)
- Structured logging with pino (request timing in hooks, Socket.IO events in server.js)
- Site admin dashboard with tenant stats, CRUD, and requireSiteAdmin guard
- Tenant admin enhanced with tag and category management UI
- Docker HEALTHCHECK in Dockerfile and docker-compose.yml
- Backup documentation (docs/backup.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-26 01:24:24 -05:00
parent 7559102479
commit 0bba161c12
19 changed files with 932 additions and 21 deletions
+12 -1
View File
@@ -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;
};
+9 -1
View File
@@ -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.
*/
+10
View File
@@ -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
});
}
+57
View File
@@ -0,0 +1,57 @@
const hits = new Map<string, number[]>();
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();
+12 -4
View File
@@ -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 };
};
+228 -9
View File
@@ -1,23 +1,114 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props();
let users = $state(data.users.map((u: any) => ({ ...u })));
let tags = $state(data.tags.map((t: any) => ({ ...t })));
let categories = $state(data.categories.map((c: any) => ({ ...c })));
// Tag form
let newTagName = $state('');
let newTagColor = $state('#3b82f6');
let editingTagId = $state<string | null>(null);
let editTagName = $state('');
let editTagColor = $state('');
// Category form
let newCategoryName = $state('');
let editingCategoryId = $state<string | null>(null);
let editCategoryName = $state('');
async function changeRole(userId: string, tenantRole: string) {
const res = await fetch(`/api/admin/users/${userId}`, {
const result = await api(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tenantRole })
});
if (!res.ok) {
const json = await res.json();
alert(json.error || json.message || 'Failed to change role');
return;
if (result.ok) {
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: result.data.user.tenantRole } : u));
}
}
const json = await res.json();
users = users.map((u: any) => (u.id === userId ? { ...u, tenantRole: json.user.tenantRole } : u));
// ─── Tags ─────────────────────────────────────────────
async function createTag() {
if (!newTagName.trim()) return;
const result = await api('/api/tags', {
method: 'POST',
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
});
if (result.ok) {
tags = [...tags, result.data.tag].sort((a: any, b: any) => a.name.localeCompare(b.name));
newTagName = '';
newTagColor = '#3b82f6';
toast.success('Tag created');
}
}
function startEditTag(tag: any) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color;
}
async function saveTag(id: string) {
const result = await api(`/api/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name: editTagName.trim(), color: editTagColor })
});
if (result.ok) {
tags = tags.map((t: any) => (t.id === id ? result.data.tag : t));
editingTagId = null;
toast.success('Tag updated');
}
}
async function deleteTag(id: string) {
const result = await api(`/api/tags/${id}`, { method: 'DELETE' });
if (result.ok) {
tags = tags.filter((t: any) => t.id !== id);
toast.success('Tag deleted');
}
}
// ─── Categories ───────────────────────────────────────
async function createCategory() {
if (!newCategoryName.trim()) return;
const result = await api('/api/categories', {
method: 'POST',
body: JSON.stringify({ name: newCategoryName.trim() })
});
if (result.ok) {
categories = [...categories, result.data.category].sort((a: any, b: any) => a.name.localeCompare(b.name));
newCategoryName = '';
toast.success('Category created');
}
}
function startEditCategory(cat: any) {
editingCategoryId = cat.id;
editCategoryName = cat.name;
}
async function saveCategory(id: string) {
const result = await api(`/api/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify({ name: editCategoryName.trim() })
});
if (result.ok) {
categories = categories.map((c: any) => (c.id === id ? result.data.category : c));
editingCategoryId = null;
toast.success('Category updated');
}
}
async function deleteCategory(id: string) {
const result = await api(`/api/categories/${id}`, { method: 'DELETE' });
if (result.ok) {
categories = categories.filter((c: any) => c.id !== id);
toast.success('Category deleted');
}
}
function formatDate(d: string) {
@@ -74,7 +165,7 @@
</section>
<!-- Boards -->
<section>
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Boards</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm">
@@ -115,4 +206,132 @@
</table>
</div>
</section>
<!-- Tags -->
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Tags</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Create tag -->
<form
onsubmit={(e) => { e.preventDefault(); createTag(); }}
class="flex items-end gap-2 mb-4"
>
<div class="flex-1">
<label for="new-tag-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="new-tag-name"
type="text"
bind:value={newTagName}
placeholder="New tag..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="new-tag-color" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Color</label>
<input
id="new-tag-color"
type="color"
bind:value={newTagColor}
class="h-[34px] w-12 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/>
</div>
<button
type="submit"
disabled={!newTagName.trim()}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
Add
</button>
</form>
<!-- Tag list -->
{#if tags.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No tags yet</p>
{:else}
<div class="space-y-2">
{#each tags as tag (tag.id)}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
{#if editingTagId === tag.id}
<input
type="color"
bind:value={editTagColor}
class="h-6 w-8 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0"
/>
<input
type="text"
bind:value={editTagName}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/>
<button onclick={() => saveTag(tag.id)} class="text-xs text-[var(--color-primary)] hover:underline">Save</button>
<button onclick={() => (editingTagId = null)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Cancel</button>
{:else}
<span
class="inline-block h-4 w-4 rounded-full flex-shrink-0"
style="background-color: {tag.color}"
></span>
<span class="flex-1 text-sm text-gray-900 dark:text-gray-100">{tag.name}</span>
<button onclick={() => startEditTag(tag)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Edit</button>
<button onclick={() => deleteTag(tag.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
<!-- Categories -->
<section>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Categories</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<!-- Create category -->
<form
onsubmit={(e) => { e.preventDefault(); createCategory(); }}
class="flex items-end gap-2 mb-4"
>
<div class="flex-1">
<label for="new-category-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="new-category-name"
type="text"
bind:value={newCategoryName}
placeholder="New category..."
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-1.5 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<button
type="submit"
disabled={!newCategoryName.trim()}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
Add
</button>
</form>
<!-- Category list -->
{#if categories.length === 0}
<p class="text-sm text-gray-500 dark:text-gray-400">No categories yet</p>
{:else}
<div class="space-y-2">
{#each categories as cat (cat.id)}
<div class="flex items-center gap-2 rounded-lg bg-gray-50 dark:bg-gray-800 px-3 py-2">
{#if editingCategoryId === cat.id}
<input
type="text"
bind:value={editCategoryName}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/>
<button onclick={() => saveCategory(cat.id)} class="text-xs text-[var(--color-primary)] hover:underline">Save</button>
<button onclick={() => (editingCategoryId = null)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Cancel</button>
{:else}
<span class="flex-1 text-sm text-gray-900 dark:text-gray-100">{cat.name}</span>
<button onclick={() => startEditCategory(cat)} class="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">Edit</button>
<button onclick={() => deleteCategory(cat.id)} class="text-xs text-red-500 hover:text-red-600">Delete</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</section>
</div>
+30
View File
@@ -0,0 +1,30 @@
import type { PageServerLoad } from './$types.js';
import { requireSiteAdmin } from '$lib/server/guards.js';
import { withBypassRLS } from '$lib/server/rls.js';
export const load: PageServerLoad = async ({ locals }) => {
requireSiteAdmin(locals.user);
const { stats, tenants } = await withBypassRLS(async (tx) => {
const [tenantCount, userCount, boardCount, cardCount, tenants] = await Promise.all([
tx.tenant.count(),
tx.user.count(),
tx.board.count(),
tx.card.count(),
tx.tenant.findMany({
include: {
_count: { select: { users: true, boards: true } },
hostnames: { select: { hostname: true, isPrimary: true } }
},
orderBy: { createdAt: 'desc' }
})
]);
return {
stats: { tenants: tenantCount, users: userCount, boards: boardCount, cards: cardCount },
tenants
};
});
return { stats, tenants };
};
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.js';
let { data } = $props();
let tenants = $state(data.tenants.map((t: any) => ({ ...t })));
let stats = $state(data.stats);
// Create tenant form
let showCreate = $state(false);
let newName = $state('');
let newSlug = $state('');
let newHostname = $state('');
let creating = $state(false);
// Delete confirmation
let deletingId = $state<string | null>(null);
async function createTenant() {
if (!newName.trim() || !newSlug.trim()) return;
creating = true;
const result = await api('/api/admin/site/tenants', {
method: 'POST',
body: JSON.stringify({
name: newName.trim(),
slug: newSlug.trim(),
hostname: newHostname.trim() || undefined
})
});
creating = false;
if (result.ok) {
tenants = [result.data.tenant, ...tenants];
stats = { ...stats, tenants: stats.tenants + 1 };
newName = '';
newSlug = '';
newHostname = '';
showCreate = false;
toast.success('Tenant created');
}
}
async function deleteTenant(id: string) {
const result = await api(`/api/admin/site/tenants/${id}`, { method: 'DELETE' });
if (result.ok) {
tenants = tenants.filter((t: any) => t.id !== id);
stats = { ...stats, tenants: stats.tenants - 1 };
deletingId = null;
toast.success('Tenant deleted');
}
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function autoSlug() {
newSlug = newName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
</script>
<svelte:head>
<title>Site Admin - Pounce</title>
</svelte:head>
<div class="mx-auto max-w-6xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Site Administration</h1>
<!-- Stats Cards -->
<div class="grid grid-cols-2 gap-4 mb-8 sm:grid-cols-4">
{#each [
{ label: 'Tenants', value: stats.tenants },
{ label: 'Users', value: stats.users },
{ label: 'Boards', value: stats.boards },
{ label: 'Cards', value: stats.cards }
] as stat}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-5 text-center">
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{stat.value.toLocaleString()}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">{stat.label}</div>
</div>
{/each}
</div>
<!-- Tenants -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Tenants</h2>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90"
>
{showCreate ? 'Cancel' : 'New Tenant'}
</button>
</div>
<!-- Create Form -->
{#if showCreate}
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4 mb-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label for="tenant-name" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input
id="tenant-name"
type="text"
bind:value={newName}
oninput={autoSlug}
placeholder="Acme Corp"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="tenant-slug" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Slug</label>
<input
id="tenant-slug"
type="text"
bind:value={newSlug}
placeholder="acme-corp"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="tenant-hostname" class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hostname (optional)</label>
<input
id="tenant-hostname"
type="text"
bind:value={newHostname}
placeholder="acme.example.com"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
</div>
<div class="mt-3 flex justify-end">
<button
onclick={createTenant}
disabled={creating || !newName.trim() || !newSlug.trim()}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Tenant'}
</button>
</div>
</div>
{/if}
<!-- Tenants Table -->
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 dark:border-gray-700 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Slug</th>
<th class="px-4 py-3">Hostnames</th>
<th class="px-4 py-3 text-center">Users</th>
<th class="px-4 py-3 text-center">Boards</th>
<th class="px-4 py-3">Created</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-800">
{#each tenants as tenant (tenant.id)}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-gray-100">{tenant.name}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">{tenant.slug}</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 text-xs">
{#each tenant.hostnames as h}
<span class="inline-block rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 mr-1">{h.hostname}</span>
{/each}
</td>
<td class="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{tenant._count.users}</td>
<td class="px-4 py-3 text-center text-gray-600 dark:text-gray-400">{tenant._count.boards}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{formatDate(tenant.createdAt)}</td>
<td class="px-4 py-3 text-right">
{#if deletingId === tenant.id}
<span class="text-xs text-gray-500 dark:text-gray-400 mr-2">Are you sure?</span>
<button
onclick={() => deleteTenant(tenant.id)}
class="text-red-600 hover:text-red-700 text-xs font-medium mr-2"
>
Yes, delete
</button>
<button
onclick={() => (deletingId = null)}
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 text-xs"
>
Cancel
</button>
{:else}
<button
onclick={() => (deletingId = tenant.id)}
class="text-red-500 hover:text-red-600 text-xs"
>
Delete
</button>
{/if}
</td>
</tr>
{/each}
{#if tenants.length === 0}
<tr>
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">No tenants</td>
</tr>
{/if}
</tbody>
</table>
</div>
</section>
</div>
@@ -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 });
};
@@ -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 });
};
+7 -1
View File
@@ -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) {
+7 -1
View File
@@ -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) {
+17
View File
@@ -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 });
};