feat: production hardening + password reset, metrics, signed webhooks
Lint & Test / lint-and-check (push) Failing after 5m5s
Lint & Test / test (push) Has been skipped
Lint & Test / build (push) Has been skipped
Lint & Test / docker-build (push) Has been skipped
Lint & Test / audit (push) Has been skipped

Security hardening (CRITICAL/HIGH from production-readiness audit):
- Require strong JWT_SECRET + separate INTEGRATION_ENCRYPTION_KEY at boot;
  refuse placeholder defaults. Integration key now derived via HKDF.
- SSRF guard (src/lib/server/utils/safeFetch.ts): DNS-resolves and rejects
  RFC1918/loopback/link-local/IPv4-mapped IPv6/decimal-IP/cloud-metadata.
  Manual redirect handling re-validates each 3xx Location hop. Applied to
  healthcheck, RSS, calendar, metric, system-stats, camera, notifications,
  discovery, apps/preview, and all integration clients.
- API tokens, session refresh tokens, invite tokens, password-reset tokens
  switched from bcrypt to sha256 with @unique indexed lookup (O(1) instead
  of O(N) bcrypt-compares; eliminates a trivial DoS).
- Refresh-token reuse detection via Session.previousTokenHash.
- Permission checks on App PATCH/DELETE and Widget/Section endpoints.
- /api/integrations/alerts now requires auth.
- SVG uploads sanitized through DOMPurify (svg profile, scheme allow-list).
- Custom CSS sanitizer + selector scoping (decodes CSS unicode escapes
  before pattern match, drops forbidden at-rules incl. @import without
  whitespace, strips dangerous url() args). Scoped to .custom-css-scope.
- Backup restore validates SQLite magic header, takes a safety snapshot,
  uses atomic rename, re-applies pragmas.
- SQLite WAL + busy_timeout + foreign_keys + synchronous=NORMAL at startup.
- Healthcheck scheduler was dead code; wired in hooks.server.ts with
  HMR-safe singleton, concurrency cap, overlap prevention, retention jobs
  for AppClick/Notification/AuditLog. Composite indexes added on hot paths.
- Security headers (CSP, HSTS-on-https, X-Frame-Options, Permissions-Policy)
  emitted on every response.
- Account-enumeration mitigation on login (dummy bcrypt on no-user/oauth
  branches) + rate limiting on login/register/onboarding/refresh/invite/
  password-reset.
- OAuth callback sanitizes IdP error_description before echoing.

New features:
- Custom +error.svelte pages (root + boards + admin) via shared
  ErrorState component. Inverted hierarchy (status as label, title as hero).
- /forgot-password + /reset-password + admin-mediated /admin/password-resets
  page. SHA256 tokens, 24h TTL, all sessions revoked on apply.
- /invite page for manual invite-token redemption.
- /api/metrics Prometheus exposition with optional METRICS_TOKEN bearer
  auth. Counters for login/healthcheck/notification/integration; gauges
  for users/boards/apps + per-status app counts.
- Webhook HMAC-SHA256 signing for HTTP notification channels (optional
  shared secret + configurable signature header, default X-Signature-256).
- PATCH /api/users/me/password for self-service password change.
- Persistent uploads at /app/data/uploads with served-from-volume handler
  at /uploads/[...path]. SVGs served with CSP: sandbox.
- /api/health does a DB ping; returns 503 on disconnect.
- Public /status filtered to guest-accessible-board apps when unauthenticated.
- Audit log coverage: LOGIN_SUCCESS/FAILED, LOGOUT, OAUTH_LOGIN,
  OAUTH_USER_PROVISIONED, SESSION_REVOKED, API_TOKEN_*, INVITE_*,
  APP_UPDATED, PASSWORD_CHANGED, PASSWORD_RESET_*.

Performance:
- Board page: removed double findAll() over-fetch; include links + appTags
  in board query; widgets lazy-loaded via dynamic imports (marked,
  DOMPurify, hls.js, integration renderers).
- uptimeService.getAllAppsUptime: single batched query instead of N+1.
- 30s in-memory user-locals cache; invalidated on user mutation.
- pruneOldStatuses: single window-function DELETE instead of N+1.

Code quality:
- Typed error classes (NotFoundError, PermissionError, RateLimitError,
  IntegrationError) with toHttpError mapper.
- Locals.user shape exposes avatarUrl and narrows role via guard.
- App input types derived from Zod schemas via z.infer.
- 274 tests passing (up from 212); 62 new tests covering SSRF guard,
  CSS sanitizer, SVG sanitizer, rate limiter.

CI / Docker / config:
- Test workflow adds build, docker-build, audit jobs. Release workflow
  uses buildx multi-arch (amd64+arm64) with provenance + SBOM.
- Dockerfile uses tini, multi-stage prune, persistent uploads dir, single
  prisma migrate deploy (no destructive db push fallback).
- docker-compose: JWT_SECRET + INTEGRATION_ENCRYPTION_KEY required at
  startup, log rotation, resource limits.
- README documents breaking-change upgrade path.

Bug fixes from UI/UX review:
- ~55 missing i18n keys added to en/ru (auth flows, error pages, admin
  nav, register invite banner, settings.card_style).
- Hardcoded English on login replaced with $t('auth.remember_me').
- Admin nav uses i18n keys; mobile horizontal-scroll layout.
- Page <title> tags standardized.
- Password-resets: separated error/info/success surfaces, ConfirmDialog
  replaces window.confirm.
- Auth pages have matching lucide icon badges.
- Webhook secret has eye toggle and monospace input.
- text-green-500 → text-emerald-500 to match codebase convention.

Pre-existing CI lint failures cleaned up (31 errors → 0): each-key
attributes added, unused-svelte-ignore comments removed, two any casts
typed, dead skeleton components removed, /boards/[id]/edit redirect to
inline edit mode.

Tests: 274 / 274 passing
Type check: 0 errors / 0 warnings
Build: green
This commit is contained in:
2026-05-26 19:51:21 +03:00
parent 38335e925b
commit f1cfb61d13
144 changed files with 5586 additions and 2284 deletions
@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as passwordResetService from '$lib/server/services/passwordResetService.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const resets = await passwordResetService.listPendingResets();
return {
resets: resets.map((r) => ({
id: r.id,
userId: r.userId,
userEmail: r.user.email,
userDisplayName: r.user.displayName,
expiresAt: r.expiresAt.toISOString(),
createdAt: r.createdAt.toISOString(),
createdById: r.createdById
}))
};
};
@@ -0,0 +1,233 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { t } from 'svelte-i18n';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
let email = $state('');
let issuing = $state(false);
let issuedLink = $state<{ url: string; expiresAt: string } | null>(null);
let infoMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let copyToast = $state(false);
let pendingRevokeId = $state<string | null>(null);
async function issueReset(e: Event) {
e.preventDefault();
if (issuing || !email.trim()) return;
issuing = true;
issuedLink = null;
errorMessage = null;
infoMessage = null;
try {
const res = await fetch('/api/admin/password-resets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim() })
});
const json = await res.json();
if (!res.ok || !json.success) {
errorMessage = json.error ?? 'Failed to issue reset link.';
return;
}
if (json.data?.resetUrl) {
issuedLink = { url: json.data.resetUrl, expiresAt: json.data.expiresAt };
email = '';
} else {
// Account doesn't exist or is OAuth-only — we don't tell the admin which.
infoMessage =
'Request processed. If a matching local account exists, a reset link is now active.';
email = '';
}
await invalidateAll();
} catch {
errorMessage = 'Network error. Please try again.';
} finally {
issuing = false;
}
}
async function confirmRevoke() {
if (!pendingRevokeId) return;
const id = pendingRevokeId;
pendingRevokeId = null;
const res = await fetch(`/api/admin/password-resets/${id}`, { method: 'DELETE' });
if (res.ok) await invalidateAll();
}
async function copyLink(url: string) {
try {
await navigator.clipboard.writeText(url);
copyToast = true;
setTimeout(() => (copyToast = false), 1500);
} catch {
window.prompt('Copy reset link:', url);
}
}
function formatRelative(iso: string): string {
const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'expired';
const hours = Math.floor(ms / (60 * 60 * 1000));
if (hours > 0) return `expires in ${hours}h`;
const mins = Math.max(1, Math.floor(ms / (60 * 1000)));
return `expires in ${mins}m`;
}
</script>
<svelte:head>
<title>{$t('admin.password_resets') ?? 'Password Resets'}{$t('admin.panel') ?? 'Admin'}</title>
</svelte:head>
<div class="space-y-6">
<header>
<h1 class="text-xl font-semibold">{$t('admin.password_resets') ?? 'Password resets'}</h1>
<p class="mt-1 text-sm text-muted-foreground">
Issue a reset link for a user, then share it with them through your preferred channel.
Links expire after 24 hours and become single-use once the user sets a new password.
</p>
</header>
<!-- Issue new reset -->
<section class="rounded-xl border border-border bg-card p-5">
<h2 class="mb-3 text-sm font-semibold">Issue a reset link</h2>
<form onsubmit={issueReset} class="flex flex-col gap-3 sm:flex-row sm:items-end">
<label class="flex-1">
<span class="mb-1 block text-xs font-medium text-muted-foreground">User email</span>
<input
type="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
disabled={issuing}
class="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 sm:w-auto"
>
{issuing ? 'Issuing…' : 'Issue link'}
</button>
</form>
<!-- Success: a real link to copy -->
{#if issuedLink}
<div class="mt-4 rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm">
<p class="mb-2 font-medium">Share this link with the user (shown once)</p>
<div class="flex items-center gap-2">
<input
type="text"
readonly
value={issuedLink.url}
class="flex-1 rounded-md border border-input bg-background px-2 py-1.5 font-mono text-xs"
onclick={(e) => (e.currentTarget as HTMLInputElement).select()}
/>
<button
type="button"
onclick={() => issuedLink && copyLink(issuedLink.url)}
class="rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{copyToast ? 'Copied!' : 'Copy'}
</button>
</div>
<p class="mt-2 text-xs text-muted-foreground">
Expires {new Date(issuedLink.expiresAt).toLocaleString()} ({formatRelative(
issuedLink.expiresAt
)}).
</p>
</div>
{/if}
<!-- Silent success (account didn't exist or is OAuth-only) — info, not error -->
{#if infoMessage}
<div class="mt-4 rounded-lg border border-primary/20 bg-primary/5 p-3 text-sm text-foreground">
{infoMessage}
</div>
{/if}
<!-- Real failure -->
{#if errorMessage}
<div
class="mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
>
{errorMessage}
</div>
{/if}
</section>
<!-- Pending resets table -->
<section class="overflow-hidden rounded-xl border border-border bg-card">
<header class="border-b border-border p-4">
<h2 class="text-sm font-semibold">Pending reset links ({data.resets.length})</h2>
<p class="mt-1 text-xs text-muted-foreground">
The raw token is only shown once — at the moment of issue. Re-issue if you lost the link.
</p>
</header>
{#if data.resets.length === 0}
<p class="p-6 text-center text-sm text-muted-foreground">No pending resets.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Issued</th>
<th class="px-4 py-3 font-medium">Expires</th>
<th class="px-4 py-3 text-right font-medium">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{#each data.resets as r (r.id)}
<tr class="border-t border-border">
<td class="px-4 py-3">
<div class="font-medium">{r.userDisplayName}</div>
<div class="text-xs text-muted-foreground">{r.userEmail}</div>
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{new Date(r.createdAt).toLocaleString()}
{#if r.createdById}
<div class="text-[10px] uppercase tracking-wide">by admin</div>
{:else}
<div class="text-[10px] uppercase tracking-wide">self-service</div>
{/if}
</td>
<td class="px-4 py-3 text-xs text-muted-foreground">
{new Date(r.expiresAt).toLocaleString()}
<div class="text-[10px] text-muted-foreground/80">
{formatRelative(r.expiresAt)}
</div>
</td>
<td class="px-4 py-3 text-right">
<button
type="button"
onclick={() => (pendingRevokeId = r.id)}
class="rounded-md border border-destructive/30 px-3 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
>
Revoke
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</div>
{#if pendingRevokeId}
<ConfirmDialog
title="Revoke reset link?"
message="The user will no longer be able to use this link to reset their password. They can request a new one if needed."
confirmLabel="Revoke"
onConfirm={confirmRevoke}
onCancel={() => (pendingRevokeId = null)}
/>
{/if}