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
5.6 KiB
Web App Launcher
A self-hosted dashboard for organizing, monitoring, and launching web applications. Built with SvelteKit, Prisma (SQLite), and Tailwind CSS.
Features
- App registry — add apps with icons, tags, and categories; automatic healthcheck monitoring with sparkline history
- Boards & widgets — customizable dashboards with drag-and-drop, resizable widget columns, and inline WYSIWYG editing
- Service integrations — connect to media services, Planka, and more to display live data in widgets
- Authentication — local accounts + OAuth/Authentik; per-board access control; API tokens
- Localization — English and Russian
- PWA — installable, multi-tab sync, auto-discovery bookmarklet
- SQLite backup/restore — full database backup from the admin panel
Quick Start
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/web-app-launcher.git
cd web-app-launcher
# Generate two strong secrets
export JWT_SECRET=$(openssl rand -hex 32)
export INTEGRATION_ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d
The app is available at http://localhost:3000. On first launch, create an admin account at the setup page.
The launcher refuses to start if JWT_SECRET or INTEGRATION_ENCRYPTION_KEY is missing, shorter than 32 characters, or set to a known placeholder. This is intentional — running with the old change-me-… defaults would let anyone mint admin tokens.
Configuration
Environment variables (set in docker-compose.yml or .env):
| Variable | Default | Description |
|---|---|---|
APP_PORT |
3000 |
Port to expose |
JWT_SECRET |
required | Strong secret for JWT signing. Generate with openssl rand -hex 32. |
INTEGRATION_ENCRYPTION_KEY |
required | Strong secret for encrypting stored integration credentials. Must differ from JWT_SECRET. Generate with openssl rand -hex 32. |
ORIGIN |
http://localhost:$APP_PORT |
Public URL users visit. When set to https://..., session cookies are issued with the Secure flag. Set this to your public https URL when behind a reverse proxy. |
GUEST_MODE |
true |
Allow unauthenticated access to guest-flagged boards |
HEALTHCHECK_CRON |
*/5 * * * * |
App healthcheck interval |
HEALTHCHECK_TIMEOUT_MS |
5000 |
Healthcheck request timeout |
ALLOW_PRIVATE_NETWORK_FETCH |
false (true in dev) |
Allow outbound fetches to RFC1918/loopback/link-local. Self-hosted users monitoring LAN services usually want true. Off by default in prod to mitigate SSRF. |
RUN_SCHEDULERS |
true |
Run background jobs (healthcheck, backup) in this process. Set false on extra horizontal replicas. |
OAUTH_CLIENT_ID |
— | OAuth provider client ID |
OAUTH_CLIENT_SECRET |
— | OAuth provider client secret |
OAUTH_DISCOVERY_URL |
— | OpenID Connect discovery URL |
METRICS_TOKEN |
— | Optional bearer token for /api/metrics. Unset = open (private-network setups) |
Production deployment
Reverse proxy (Traefik / Caddy / Nginx)
The launcher must know its public URL to issue secure cookies. Set ORIGIN=https://launcher.example.com and terminate TLS at the proxy. Example Traefik labels:
services:
web-app-launcher:
# remove `ports:` mapping
networks: [traefik, launcher-net]
labels:
- traefik.enable=true
- traefik.http.routers.launcher.rule=Host(`launcher.example.com`)
- traefik.http.routers.launcher.entrypoints=websecure
- traefik.http.routers.launcher.tls.certresolver=letsencrypt
- traefik.http.services.launcher.loadbalancer.server.port=3000
environment:
- ORIGIN=https://launcher.example.com
Volume backup
docker run --rm \
-v web-app-launcher_launcher-data:/data \
-v "$PWD":/backup \
alpine tar czf /backup/launcher-backup.tar.gz -C /data .
Upgrade
docker compose pull && docker compose up -d
Database migrations run automatically on container start via prisma migrate deploy. The previous db push fallback was removed because it can silently drop columns on schema drift.
Breaking changes when upgrading from versions ≤ 0.0.x
The 0.1.0 hardening release is a one-way upgrade with three breaking changes:
-
INTEGRATION_ENCRYPTION_KEYis required and must differ fromJWT_SECRET. The launcher will refuse to start without it. Previously the integration key was derived fromJWT_SECRET; all stored integration credentials (Planka, Authentik, Pi-hole, Portainer, Gitea, Immich, etc.) will be undecryptable after the upgrade and must be re-entered through the admin UI. -
All users will be logged out and all API tokens / invites will be revoked. The hardening migration drops the
Session,Invite, andApiTokentables to switch from bcrypt-hashed storage to sha256 (so token validation is O(1) instead of O(N) bcrypt comparisons). Users will need to log in once; admins need to reissue API tokens and pending invites. -
Uploaded icons / wallpapers move from
static/uploads/to/app/data/uploads/. This makes them persist across container rebuilds. On upgrade, copy any existing files from your previousstatic/uploads/mount into thelauncher-datavolume:# if you previously mounted `./static/uploads:/app/static/uploads` docker run --rm \ -v "$PWD/static/uploads:/src:ro" \ -v web-app-launcher_launcher-data:/dst \ alpine sh -c "mkdir -p /dst/uploads && cp -r /src/. /dst/uploads/"
Take a backup before upgrading.
Development
npm install
npx prisma generate
# strong dev secrets are already in .env (gitignored)
npm run dev
License
MIT