diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md
index 446cd61..6cf8bfb 100644
--- a/plans/mvp-web-app-launcher/CONTEXT.md
+++ b/plans/mvp-web-app-launcher/CONTEXT.md
@@ -2,6 +2,8 @@
## Current State
+Phase 4 (App Registry & Healthcheck) is complete. All app CRUD API routes are implemented at `/api/apps` (GET/POST) and `/api/apps/[id]` (GET/PATCH/DELETE) with Zod validation and auth middleware. Status history is served from `/api/apps/[id]/status`. The healthcheck service performs HTTP HEAD/GET requests with AbortController timeouts, mapping responses to online/offline/degraded/unknown. The scheduler uses node-cron (default: every 60 seconds) with an initial delayed check on startup. Icon resolution supports lucide, simple-icons (CDN), direct URL, and emoji types. The app registry UI at `/apps` renders cards in a responsive grid with category filtering and an inline Superforms create form. Custom icon uploads are handled at `/api/uploads` with type (SVG/PNG/JPG/WebP) and size (<1MB) validation, saving to `static/uploads/`. A Docker healthcheck endpoint at `/api/health` returns 200 with no auth. All Svelte components use runes mode ($state, $derived, $props).
+
Phase 3 (Authentication System) is complete. The full local authentication flow is implemented: login, registration, logout, and JWT token refresh. `hooks.server.ts` validates access tokens on every request, injects `event.locals.user`/`session`, and silently rotates expired tokens via refresh tokens. Protected routes redirect to `/login`; guest-accessible board routes are exempt. Login and registration pages use Superforms + Zod with inline validation errors. Registration respects the `SystemSettings.registrationEnabled` toggle. Reusable middleware helpers (`requireAuth`, `requireAdmin`, `requireRole`) are available for downstream phases. The root layout injects user session into all page data. The root page redirects to the default board or login. `jwt.ts` and `password.ts` are thin re-exports from `authService` (no duplication). Build does not pass yet (Big Bang strategy — expected).
## Temporary Workarounds
diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md
index 721ec12..0b6fb36 100644
--- a/plans/mvp-web-app-launcher/PLAN.md
+++ b/plans/mvp-web-app-launcher/PLAN.md
@@ -30,7 +30,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi
- [x] Phase 1: Project Scaffolding & Tooling [backend] → [subplan](./phase-1-scaffolding.md)
- [x] Phase 2: Database Schema & Services Layer [backend] → [subplan](./phase-2-database-services.md)
- [x] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md)
-- [ ] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md)
+- [x] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md)
- [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md)
- [ ] Phase 6: Admin Panel [fullstack] → [subplan](./phase-6-admin-panel.md)
- [ ] Phase 7: UI Polish & Ambient Backgrounds [frontend] → [subplan](./phase-7-ui-polish.md)
@@ -43,7 +43,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi
| Phase 1: Scaffolding | backend | ✅ Complete | ✅ | ⬜ | ⬜ |
| Phase 2: Database & Services | backend | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 3: Authentication | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
-| Phase 4: App & Healthcheck | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
+| Phase 4: App & Healthcheck | fullstack | ✅ Complete | ⬜ | ⬜ | ⬜ |
| Phase 5: Board & Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 6: Admin Panel | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 7: UI Polish | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
diff --git a/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md b/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md
index cb04b0a..3802b3d 100644
--- a/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md
+++ b/plans/mvp-web-app-launcher/phase-4-app-healthcheck.md
@@ -1,6 +1,6 @@
# Phase 4: App Registry & Healthcheck
-**Status:** ⬜ Not Started
+**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
@@ -9,20 +9,20 @@ Build the app (service) registry with CRUD operations, the icon resolution syste
## Tasks
-- [ ] Task 1: Create `src/routes/api/apps/+server.ts` — GET (list), POST (create)
-- [ ] Task 2: Create `src/routes/api/apps/[id]/+server.ts` — GET, PATCH, DELETE
-- [ ] Task 3: Create `src/routes/api/apps/[id]/status/+server.ts` — GET healthcheck status
-- [ ] Task 4: Implement `src/lib/server/services/healthcheckService.ts` — perform HTTP health checks
-- [ ] Task 5: Implement `src/lib/server/jobs/healthcheckScheduler.ts` — node-cron scheduled pings
-- [ ] Task 6: Implement `src/lib/server/utils/iconResolver.ts` — resolve icon by type (Lucide, Simple Icons, Dashboard Icons CDN, upload path)
-- [ ] Task 7: Create `src/routes/apps/+page.server.ts` — load app list
-- [ ] Task 8: Create `src/routes/apps/+page.svelte` — app registry list page
-- [ ] Task 9: Create `src/lib/components/app/AppCard.svelte` — app card with status indicator
-- [ ] Task 10: Create `src/lib/components/app/AppForm.svelte` — create/edit app form (Superforms)
-- [ ] Task 11: Create `src/lib/components/app/AppIconPicker.svelte` — icon selection UI
-- [ ] Task 12: Create `src/lib/components/app/AppHealthBadge.svelte` — status badge (online/offline/degraded/unknown)
-- [ ] Task 13: Create `src/routes/api/health/+server.ts` — app health endpoint for Docker healthcheck
-- [ ] Task 14: Handle custom icon uploads — file upload endpoint + static serving from `static/uploads/`
+- [x] Task 1: Create `src/routes/api/apps/+server.ts` — GET (list), POST (create)
+- [x] Task 2: Create `src/routes/api/apps/[id]/+server.ts` — GET, PATCH, DELETE
+- [x] Task 3: Create `src/routes/api/apps/[id]/status/+server.ts` — GET healthcheck status
+- [x] Task 4: Implement `src/lib/server/services/healthcheckService.ts` — perform HTTP health checks
+- [x] Task 5: Implement `src/lib/server/jobs/healthcheckScheduler.ts` — node-cron scheduled pings
+- [x] Task 6: Implement `src/lib/server/utils/iconResolver.ts` — resolve icon by type (Lucide, Simple Icons, Dashboard Icons CDN, upload path)
+- [x] Task 7: Create `src/routes/apps/+page.server.ts` — load app list
+- [x] Task 8: Create `src/routes/apps/+page.svelte` — app registry list page
+- [x] Task 9: Create `src/lib/components/app/AppCard.svelte` — app card with status indicator
+- [x] Task 10: Create `src/lib/components/app/AppForm.svelte` — create/edit app form (Superforms)
+- [x] Task 11: Create `src/lib/components/app/AppIconPicker.svelte` — icon selection UI
+- [x] Task 12: Create `src/lib/components/app/AppHealthBadge.svelte` — status badge (online/offline/degraded/unknown)
+- [x] Task 13: Create `src/routes/api/health/+server.ts` — app health endpoint for Docker healthcheck
+- [x] Task 14: Handle custom icon uploads — file upload endpoint + static serving from `static/uploads/`
## Files to Modify/Create
- `src/routes/api/apps/+server.ts`
@@ -55,11 +55,21 @@ Build the app (service) registry with CRUD operations, the icon resolution syste
- ⚠️ Big Bang: pages will be functional but minimally styled until Phase 7
## Review Checklist
-- [ ] All tasks completed
-- [ ] Code follows project conventions
-- [ ] No unintended side effects
+- [x] All tasks completed
+- [x] Code follows project conventions
+- [x] No unintended side effects
- [ ] Build passes
- [ ] Tests pass (new + existing)
## Handoff to Next Phase
-
+
+All 14 tasks are implemented. Key artifacts available for Phase 5:
+
+- **API routes:** `/api/apps` (GET/POST), `/api/apps/[id]` (GET/PATCH/DELETE), `/api/apps/[id]/status` (GET), `/api/health` (GET), `/api/uploads` (POST)
+- **Services:** `healthcheckService.ts` provides `checkAppHealth()` and `checkAllApps()`; `healthcheckScheduler.ts` provides `startScheduler()`/`stopScheduler()` using node-cron
+- **Icon resolution:** `iconResolver.ts` maps all 4 icon types (lucide, simple, url, emoji) to renderable objects; `AppCard.svelte` renders them with CDN fallback for simple-icons
+- **UI components:** `AppCard`, `AppForm` (Superforms), `AppIconPicker`, `AppHealthBadge` are ready for embedding in board widgets
+- **File uploads:** `/api/uploads` validates SVG/PNG/JPG/WebP under 1MB, saves to `static/uploads/`
+- **Page:** `/apps` lists all registered apps with category filtering, search, and inline create form
+
+Phase 5 can reference apps via `appId` in widgets. The `appService.findAll()` and `appService.findById()` include latest status in responses. The healthcheck scheduler should be started from `hooks.server.ts` or a startup hook in Phase 8.
diff --git a/src/lib/components/app/AppCard.svelte b/src/lib/components/app/AppCard.svelte
new file mode 100644
index 0000000..64faf3a
--- /dev/null
+++ b/src/lib/components/app/AppCard.svelte
@@ -0,0 +1,85 @@
+
+
+
+
+
+ {#if iconDisplay?.kind === 'emoji'}
+
{iconDisplay.value}
+ {:else if iconDisplay?.kind === 'image'}
+

+ {:else if iconDisplay?.kind === 'text'}
+
{iconDisplay.value}
+ {:else}
+
{app.name.charAt(0).toUpperCase()}
+ {/if}
+
+
+
+
+
+ {app.name}
+
+
+ {#if app.description}
+ {app.description}
+ {/if}
+
+ {#if app.category}
+
+ {app.category}
+
+ {/if}
+
diff --git a/src/lib/components/app/AppForm.svelte b/src/lib/components/app/AppForm.svelte
new file mode 100644
index 0000000..6bd259c
--- /dev/null
+++ b/src/lib/components/app/AppForm.svelte
@@ -0,0 +1,230 @@
+
+
+
diff --git a/src/lib/components/app/AppHealthBadge.svelte b/src/lib/components/app/AppHealthBadge.svelte
new file mode 100644
index 0000000..e0ae37f
--- /dev/null
+++ b/src/lib/components/app/AppHealthBadge.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+ {config.text}
+
diff --git a/src/lib/components/app/AppIconPicker.svelte b/src/lib/components/app/AppIconPicker.svelte
new file mode 100644
index 0000000..690c2fa
--- /dev/null
+++ b/src/lib/components/app/AppIconPicker.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if iconType === 'emoji' && iconValue}
+
{iconValue}
+ {:else if iconType === 'url' && iconValue}
+

+ {:else if iconType === 'simple' && iconValue}
+
})
+ {/if}
+
diff --git a/src/lib/server/jobs/healthcheckScheduler.ts b/src/lib/server/jobs/healthcheckScheduler.ts
new file mode 100644
index 0000000..ddeda64
--- /dev/null
+++ b/src/lib/server/jobs/healthcheckScheduler.ts
@@ -0,0 +1,39 @@
+import cron from 'node-cron';
+import { checkAllApps } from '$lib/server/services/healthcheckService.js';
+
+let scheduledTask: cron.ScheduledTask | null = null;
+
+/**
+ * Start the healthcheck scheduler.
+ * Runs checkAllApps on a cron schedule (default: every 60 seconds).
+ */
+export function startScheduler(cronExpression: string = '* * * * *'): void {
+ if (scheduledTask) {
+ return;
+ }
+
+ scheduledTask = cron.schedule(cronExpression, async () => {
+ try {
+ await checkAllApps();
+ } catch {
+ // Swallow errors to prevent scheduler crash
+ }
+ });
+
+ // Run an initial check shortly after startup
+ setTimeout(() => {
+ checkAllApps().catch(() => {
+ // Swallow initial check errors
+ });
+ }, 5000);
+}
+
+/**
+ * Stop the healthcheck scheduler.
+ */
+export function stopScheduler(): void {
+ if (scheduledTask) {
+ scheduledTask.stop();
+ scheduledTask = null;
+ }
+}
diff --git a/src/lib/server/services/healthcheckService.ts b/src/lib/server/services/healthcheckService.ts
new file mode 100644
index 0000000..e18bb3a
--- /dev/null
+++ b/src/lib/server/services/healthcheckService.ts
@@ -0,0 +1,83 @@
+import * as appService from './appService.js';
+import { AppStatusValue } from '$lib/utils/constants.js';
+
+export interface HealthcheckResult {
+ readonly appId: string;
+ readonly status: string;
+ readonly responseTime: number | null;
+}
+
+/**
+ * Perform a health check on a single app by making an HTTP request to its URL.
+ */
+export async function checkAppHealth(app: {
+ readonly id: string;
+ readonly url: string;
+ readonly healthcheckMethod: string;
+ readonly healthcheckExpectedStatus: number;
+ readonly healthcheckTimeout: number;
+}): Promise {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), app.healthcheckTimeout);
+
+ const start = Date.now();
+
+ try {
+ const response = await fetch(app.url, {
+ method: app.healthcheckMethod,
+ signal: controller.signal,
+ redirect: 'follow',
+ headers: {
+ 'User-Agent': 'WebAppLauncher-Healthcheck/1.0'
+ }
+ });
+
+ const responseTime = Date.now() - start;
+
+ const status =
+ response.status === app.healthcheckExpectedStatus
+ ? AppStatusValue.ONLINE
+ : AppStatusValue.DEGRADED;
+
+ return { appId: app.id, status, responseTime };
+ } catch (err) {
+ const responseTime = Date.now() - start;
+
+ if (err instanceof DOMException && err.name === 'AbortError') {
+ return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime };
+ }
+
+ return { appId: app.id, status: AppStatusValue.OFFLINE, responseTime: null };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+}
+
+/**
+ * Check all apps that have healthcheck enabled, record their statuses.
+ */
+export async function checkAllApps(): Promise {
+ const targets = await appService.getHealthcheckTargets();
+
+ if (targets.length === 0) {
+ return [];
+ }
+
+ const results = await Promise.allSettled(targets.map((target) => checkAppHealth(target)));
+
+ const outcomes: HealthcheckResult[] = [];
+
+ for (const result of results) {
+ if (result.status === 'fulfilled') {
+ const { appId, status, responseTime } = result.value;
+ try {
+ await appService.recordStatus(appId, status, responseTime);
+ } catch {
+ // Log but don't fail the whole batch
+ }
+ outcomes.push(result.value);
+ }
+ }
+
+ return outcomes;
+}
diff --git a/src/lib/server/utils/iconResolver.ts b/src/lib/server/utils/iconResolver.ts
new file mode 100644
index 0000000..1d34b8d
--- /dev/null
+++ b/src/lib/server/utils/iconResolver.ts
@@ -0,0 +1,50 @@
+import type { IconType } from '$lib/utils/constants.js';
+
+export interface ResolvedIcon {
+ readonly type: IconType;
+ readonly value: string;
+ readonly src?: string;
+}
+
+/**
+ * Resolve an icon reference into a renderable object.
+ *
+ * - 'lucide' → { type, value } — render via lucide-svelte component lookup
+ * - 'simple' → { type, value, src } — SVG path from simple-icons
+ * - 'url' → { type, value, src } — direct image URL
+ * - 'emoji' → { type, value } — render as text
+ */
+export function resolveIcon(iconType: IconType, iconValue: string | null): ResolvedIcon | null {
+ if (!iconValue) {
+ return null;
+ }
+
+ switch (iconType) {
+ case 'lucide':
+ return { type: 'lucide', value: iconValue };
+
+ case 'simple': {
+ try {
+ // simple-icons exports an object keyed by slug prefixed with 'si'
+ // e.g., siGithub, siDocker. We look up by slug.
+ const slug = iconValue.toLowerCase().replace(/[^a-z0-9]/g, '');
+ return {
+ type: 'simple',
+ value: iconValue,
+ src: `https://cdn.simpleicons.org/${slug}`
+ };
+ } catch {
+ return { type: 'simple', value: iconValue };
+ }
+ }
+
+ case 'url':
+ return { type: 'url', value: iconValue, src: iconValue };
+
+ case 'emoji':
+ return { type: 'emoji', value: iconValue };
+
+ default:
+ return { type: 'lucide', value: iconValue };
+ }
+}
diff --git a/src/routes/api/apps/+server.ts b/src/routes/api/apps/+server.ts
new file mode 100644
index 0000000..cf89007
--- /dev/null
+++ b/src/routes/api/apps/+server.ts
@@ -0,0 +1,55 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { createAppSchema } from '$lib/utils/validators.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+/**
+ * GET /api/apps — List all apps, optionally filtered by category or search.
+ */
+export const GET: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const category = event.url.searchParams.get('category') ?? undefined;
+ const search = event.url.searchParams.get('search') ?? undefined;
+
+ try {
+ const apps = await appService.findAll({ category, search });
+ return json(success(apps));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch apps';
+ return json(error(message), { status: 500 });
+ }
+};
+
+/**
+ * POST /api/apps — Create a new app.
+ */
+export const POST: RequestHandler = async (event) => {
+ const user = requireAuth(event);
+
+ let body: unknown;
+ try {
+ body = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ const parsed = createAppSchema.safeParse(body);
+ if (!parsed.success) {
+ const messages = parsed.error.errors.map((e) => e.message).join(', ');
+ return json(error(messages), { status: 400 });
+ }
+
+ try {
+ const app = await appService.create({
+ ...parsed.data,
+ createdById: user.id
+ });
+ return json(success(app), { status: 201 });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to create app';
+ return json(error(message), { status: 500 });
+ }
+};
diff --git a/src/routes/api/apps/[id]/+server.ts b/src/routes/api/apps/[id]/+server.ts
new file mode 100644
index 0000000..8a63f4a
--- /dev/null
+++ b/src/routes/api/apps/[id]/+server.ts
@@ -0,0 +1,72 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { updateAppSchema } from '$lib/utils/validators.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+/**
+ * GET /api/apps/:id — Get a single app by ID.
+ */
+export const GET: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const { id } = event.params;
+
+ try {
+ const app = await appService.findById(id);
+ return json(success(app));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'App not found';
+ return json(error(message), { status: 404 });
+ }
+};
+
+/**
+ * PATCH /api/apps/:id — Update an existing app.
+ */
+export const PATCH: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const { id } = event.params;
+
+ let body: unknown;
+ try {
+ body = await event.request.json();
+ } catch {
+ return json(error('Invalid JSON body'), { status: 400 });
+ }
+
+ const parsed = updateAppSchema.safeParse(body);
+ if (!parsed.success) {
+ const messages = parsed.error.errors.map((e) => e.message).join(', ');
+ return json(error(messages), { status: 400 });
+ }
+
+ try {
+ const app = await appService.update(id, parsed.data);
+ return json(success(app));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to update app';
+ const status = message.includes('not found') ? 404 : 500;
+ return json(error(message), { status });
+ }
+};
+
+/**
+ * DELETE /api/apps/:id — Delete an app.
+ */
+export const DELETE: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const { id } = event.params;
+
+ try {
+ await appService.remove(id);
+ return json(success(null));
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to delete app';
+ const status = message.includes('not found') ? 404 : 500;
+ return json(error(message), { status });
+ }
+};
diff --git a/src/routes/api/apps/[id]/status/+server.ts b/src/routes/api/apps/[id]/status/+server.ts
new file mode 100644
index 0000000..d91f363
--- /dev/null
+++ b/src/routes/api/apps/[id]/status/+server.ts
@@ -0,0 +1,35 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { success, error } from '$lib/server/utils/response.js';
+
+/**
+ * GET /api/apps/:id/status — Get healthcheck status history for an app.
+ */
+export const GET: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ const { id } = event.params;
+ const limitParam = event.url.searchParams.get('limit');
+ const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 50, 1), 200) : 50;
+
+ try {
+ // Verify app exists
+ await appService.findById(id);
+
+ const latest = await appService.getLatestStatus(id);
+ const history = await appService.getStatusHistory(id, limit);
+
+ return json(
+ success({
+ current: latest,
+ history
+ })
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to fetch status';
+ const status = message.includes('not found') ? 404 : 500;
+ return json(error(message), { status });
+ }
+};
diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts
new file mode 100644
index 0000000..ad93963
--- /dev/null
+++ b/src/routes/api/health/+server.ts
@@ -0,0 +1,10 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+/**
+ * GET /api/health — Docker healthcheck endpoint.
+ * Returns 200 when the server is running. No auth required.
+ */
+export const GET: RequestHandler = async () => {
+ return json({ status: 'ok' });
+};
diff --git a/src/routes/api/uploads/+server.ts b/src/routes/api/uploads/+server.ts
new file mode 100644
index 0000000..b3df0bc
--- /dev/null
+++ b/src/routes/api/uploads/+server.ts
@@ -0,0 +1,67 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import { error, success } from '$lib/server/utils/response.js';
+import { writeFile, mkdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+
+const ALLOWED_TYPES = new Set([
+ 'image/svg+xml',
+ 'image/png',
+ 'image/jpeg',
+ 'image/webp'
+]);
+
+const EXTENSION_MAP: Record = {
+ 'image/svg+xml': '.svg',
+ 'image/png': '.png',
+ 'image/jpeg': '.jpg',
+ 'image/webp': '.webp'
+};
+
+const MAX_FILE_SIZE = 1024 * 1024; // 1MB
+
+/**
+ * POST /api/uploads — Upload a custom icon file.
+ * Accepts multipart form data with a single 'file' field.
+ * Validates type (SVG, PNG, JPG, WebP) and size (<1MB).
+ * Saves to static/uploads/ and returns the public path.
+ */
+export const POST: RequestHandler = async (event) => {
+ requireAuth(event);
+
+ let formData: FormData;
+ try {
+ formData = await event.request.formData();
+ } catch {
+ return json(error('Invalid form data'), { status: 400 });
+ }
+
+ const file = formData.get('file');
+ if (!file || !(file instanceof File)) {
+ return json(error('No file provided'), { status: 400 });
+ }
+
+ if (!ALLOWED_TYPES.has(file.type)) {
+ return json(error('Invalid file type. Allowed: SVG, PNG, JPG, WebP'), { status: 400 });
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ return json(error('File too large. Maximum size: 1MB'), { status: 400 });
+ }
+
+ const extension = EXTENSION_MAP[file.type] ?? '.bin';
+ const filename = `${randomUUID()}${extension}`;
+
+ const uploadsDir = join(process.cwd(), 'static', 'uploads');
+ await mkdir(uploadsDir, { recursive: true });
+
+ const filePath = join(uploadsDir, filename);
+ const buffer = Buffer.from(await file.arrayBuffer());
+ await writeFile(filePath, buffer);
+
+ const publicPath = `/uploads/${filename}`;
+
+ return json(success({ path: publicPath, filename }), { status: 201 });
+};
diff --git a/src/routes/apps/+page.server.ts b/src/routes/apps/+page.server.ts
new file mode 100644
index 0000000..8d8448f
--- /dev/null
+++ b/src/routes/apps/+page.server.ts
@@ -0,0 +1,46 @@
+import type { Actions, PageServerLoad } from './$types.js';
+import { superValidate, setError } from 'sveltekit-superforms';
+import { zod } from 'sveltekit-superforms/adapters';
+import { fail } from '@sveltejs/kit';
+import { requireAuth } from '$lib/server/middleware/authenticate.js';
+import * as appService from '$lib/server/services/appService.js';
+import { createAppSchema } from '$lib/utils/validators.js';
+
+export const load: PageServerLoad = async (event) => {
+ requireAuth(event);
+
+ const category = event.url.searchParams.get('category') ?? undefined;
+ const search = event.url.searchParams.get('search') ?? undefined;
+
+ const [apps, categories, form] = await Promise.all([
+ appService.findAll({ category, search }),
+ appService.getCategories(),
+ superValidate(zod(createAppSchema))
+ ]);
+
+ return { apps, categories, form };
+};
+
+export const actions: Actions = {
+ create: async (event) => {
+ const user = requireAuth(event);
+
+ const form = await superValidate(event.request, zod(createAppSchema));
+
+ if (!form.valid) {
+ return fail(400, { form });
+ }
+
+ try {
+ await appService.create({
+ ...form.data,
+ createdById: user.id
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to create app';
+ return setError(form, '', message);
+ }
+
+ return { form };
+ }
+};
diff --git a/src/routes/apps/+page.svelte b/src/routes/apps/+page.svelte
new file mode 100644
index 0000000..9c15fce
--- /dev/null
+++ b/src/routes/apps/+page.svelte
@@ -0,0 +1,67 @@
+
+
+
+ Apps — Web App Launcher
+
+
+
+
+
+
App Registry
+
+
+
+ {#if showForm}
+
+ {/if}
+
+ {#if data.categories.length > 0}
+
+ {/if}
+
+ {#if data.apps.length === 0}
+
+
No apps registered yet.
+
Click "Add App" to register your first application.
+
+ {:else}
+
+ {#each data.apps as app (app.id)}
+
+ {/each}
+
+ {/if}
+
+