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,96 @@
-- Production-readiness hardening migration:
-- * Session: switch tokenHash from bcrypt-hashed to sha256-hashed (deterministic
-- lookup), add previousTokenHash for refresh-token reuse detection, add
-- unique constraint on tokenHash.
-- * AppStatus, AppClick, Notification, AuditLog: composite indexes that match
-- the actual query shapes (entity + time range).
--
-- Existing Session rows store bcrypt hashes that are no longer compatible with
-- the new sha256 lookup. We invalidate ALL sessions on upgrade — users will be
-- prompted to log in once. This is the safer option than keeping incompatible
-- rows that would silently fail validation.
PRAGMA foreign_keys=OFF;
-- ---- Session: rebuild with new shape and clear contents ---------------------
CREATE TABLE "new_Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"previousTokenHash" TEXT,
"label" TEXT,
"userAgent" TEXT,
"ipAddress" TEXT,
"rememberMe" BOOLEAN NOT NULL DEFAULT false,
"lastUsedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- intentionally do NOT copy old rows — bcrypt hashes won't validate against
-- sha256 lookups and users would silently fail to refresh.
DROP TABLE "Session";
ALTER TABLE "new_Session" RENAME TO "Session";
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- ---- Invite: same bcrypt -> sha256 migration for the same reason ------------
-- Invites are short-lived (default 7 days). Existing unused invite rows would
-- be unreachable after the switch; clear them so admins re-issue if needed.
CREATE TABLE "new_Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"email" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"usedByUserId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
DROP TABLE "Invite";
ALTER TABLE "new_Invite" RENAME TO "Invite";
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById");
-- ---- ApiToken: same bcrypt -> sha256 migration ------------------------------
-- Existing API tokens stop working at upgrade; users must regenerate. This is
-- preferable to keeping broken-but-present rows.
CREATE TABLE "new_ApiToken" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"expiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
DROP TABLE "ApiToken";
ALTER TABLE "new_ApiToken" RENAME TO "ApiToken";
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
CREATE INDEX "ApiToken_tokenHash_idx" ON "ApiToken"("tokenHash");
PRAGMA foreign_keys=ON;
-- ---- Composite indexes for hot query paths ----------------------------------
DROP INDEX IF EXISTS "AppStatus_appId_idx";
CREATE INDEX "AppStatus_appId_checkedAt_idx" ON "AppStatus"("appId", "checkedAt");
DROP INDEX IF EXISTS "AppClick_userId_idx";
CREATE INDEX "AppClick_userId_clickedAt_idx" ON "AppClick"("userId", "clickedAt");
DROP INDEX IF EXISTS "Notification_userId_idx";
DROP INDEX IF EXISTS "Notification_sentAt_idx";
CREATE INDEX "Notification_userId_sentAt_idx" ON "Notification"("userId", "sentAt");
DROP INDEX IF EXISTS "AuditLog_userId_idx";
DROP INDEX IF EXISTS "AuditLog_entityType_entityId_idx";
CREATE INDEX "AuditLog_userId_createdAt_idx" ON "AuditLog"("userId", "createdAt");
CREATE INDEX "AuditLog_entityType_entityId_createdAt_idx" ON "AuditLog"("entityType", "entityId", "createdAt");
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "PasswordReset" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PasswordReset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PasswordReset_tokenHash_key" ON "PasswordReset"("tokenHash");
CREATE INDEX "PasswordReset_userId_idx" ON "PasswordReset"("userId");
CREATE INDEX "PasswordReset_expiresAt_idx" ON "PasswordReset"("expiresAt");
+33 -16
View File
@@ -37,10 +37,26 @@ model User {
apiTokens ApiToken[]
auditLogs AuditLog[]
boardTemplates BoardTemplate[]
passwordResets PasswordReset[]
@@index([email])
}
model PasswordReset {
id String @id @default(cuid())
userId String
tokenHash String @unique // sha256 of the raw reset token
expiresAt DateTime
usedAt DateTime?
createdById String? // admin who issued (if admin-mediated), null if self-service
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
}
model Invite {
id String @id @default(cuid())
tokenHash String @unique
@@ -57,19 +73,21 @@ model Invite {
}
model Session {
id String @id @default(cuid())
userId String
tokenHash String // bcrypt hash of the refresh token
label String? // user-friendly, e.g. "Chrome on Windows"
userAgent String?
ipAddress String?
rememberMe Boolean @default(false)
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
id String @id @default(cuid())
userId String
tokenHash String // sha256 hash of current refresh token
previousTokenHash String? // sha256 hash of the immediately-previous refresh token (reuse detection)
label String?
userAgent String?
ipAddress String?
rememberMe Boolean @default(false)
lastUsedAt DateTime @default(now())
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([tokenHash])
@@index([userId])
@@index([expiresAt])
}
@@ -142,7 +160,7 @@ model AppStatus {
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([appId])
@@index([appId, checkedAt])
@@index([checkedAt])
}
@@ -302,7 +320,7 @@ model AppClick {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, clickedAt])
@@index([appId])
@@index([clickedAt])
}
@@ -332,9 +350,8 @@ model Notification {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, sentAt])
@@index([appId])
@@index([sentAt])
}
model ApiToken {
@@ -364,9 +381,9 @@ model AuditLog {
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([userId, createdAt])
@@index([action])
@@index([entityType, entityId])
@@index([entityType, entityId, createdAt])
@@index([createdAt])
}