diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d462935..85b9b3e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -80,6 +80,9 @@ model App { healthcheckMethod String @default("GET") healthcheckExpectedStatus Int @default(200) healthcheckTimeout Int @default(5000) // milliseconds + integrationType String? + integrationConfig String? + integrationEnabled Boolean @default(false) createdById String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/lib/server/integrations/base.ts b/src/lib/server/integrations/base.ts new file mode 100644 index 0000000..ce86b9b --- /dev/null +++ b/src/lib/server/integrations/base.ts @@ -0,0 +1,34 @@ +const DEFAULT_TIMEOUT_MS = 10_000; + +export async function fetchWithTimeout( + url: string, + options?: RequestInit & { timeout?: number } +): Promise { + const timeout = options?.timeout ?? DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + 'User-Agent': 'WebAppLauncher/1.0', + ...options?.headers + } + }); + return response; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error(`Integration request timed out after ${timeout}ms`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +export function wrapError(integration: string, endpoint: string, error: unknown): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(`[${integration}/${endpoint}] ${message}`); +} diff --git a/src/lib/server/integrations/cache.ts b/src/lib/server/integrations/cache.ts new file mode 100644 index 0000000..8b30a0f --- /dev/null +++ b/src/lib/server/integrations/cache.ts @@ -0,0 +1,35 @@ +interface CacheEntry { + readonly data: T; + readonly expiresAt: number; +} + +const store = new Map>(); + +export function get(key: string): T | null { + const entry = store.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + store.delete(key); + return null; + } + return entry.data as T; +} + +export function set(key: string, data: T, ttlSeconds: number): void { + store.set(key, { + data, + expiresAt: Date.now() + ttlSeconds * 1000 + }); +} + +export function clear(): void { + store.clear(); +} + +export function clearForApp(appId: string): void { + for (const key of store.keys()) { + if (key.startsWith(`${appId}:`)) { + store.delete(key); + } + } +} diff --git a/src/lib/server/integrations/encryption.ts b/src/lib/server/integrations/encryption.ts new file mode 100644 index 0000000..0428e87 --- /dev/null +++ b/src/lib/server/integrations/encryption.ts @@ -0,0 +1,47 @@ +import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto'; +import { env } from '$env/dynamic/private'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +function getKey(): Buffer { + const keySource = env.INTEGRATION_ENCRYPTION_KEY ?? env.JWT_SECRET ?? ''; + if (!keySource) { + throw new Error( + 'No encryption key available. Set INTEGRATION_ENCRYPTION_KEY or JWT_SECRET.' + ); + } + return createHash('sha256').update(keySource).digest(); +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`; +} + +export function decrypt(encryptedText: string): string { + const key = getKey(); + const parts = encryptedText.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted text format'); + } + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = Buffer.from(parts[2], 'hex'); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export function tryDecrypt(encryptedText: string | null | undefined): string | null { + if (!encryptedText) return null; + try { + return decrypt(encryptedText); + } catch { + return null; + } +} diff --git a/src/lib/server/integrations/registry.ts b/src/lib/server/integrations/registry.ts new file mode 100644 index 0000000..07becc7 --- /dev/null +++ b/src/lib/server/integrations/registry.ts @@ -0,0 +1,62 @@ +import type { Integration, IntegrationInfo, IntegrationFieldDescriptor } from './types.js'; +import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod'; + +const integrations = new Map(); + +export function register(integration: Integration): void { + integrations.set(integration.id, integration); +} + +export function get(id: string): Integration | undefined { + return integrations.get(id); +} + +export function list(): readonly Integration[] { + return [...integrations.values()]; +} + +export function getEndpoints( + integrationId: string +): readonly import('./types.js').IntegrationEndpoint[] { + const integration = integrations.get(integrationId); + return integration?.endpoints ?? []; +} + +function zodSchemaToFields( + schema: ZodTypeAny | undefined +): readonly IntegrationFieldDescriptor[] { + if (!schema) return []; + // Extract shape from ZodObject + const shape = (schema as ZodObject)._def?.shape?.() ?? {}; + return Object.entries(shape).map(([name, fieldSchema]) => { + const zodField = fieldSchema as ZodTypeAny; + const isOptional = zodField.isOptional?.() ?? false; + // Detect underlying type + let type: 'string' | 'number' | 'boolean' = 'string'; + const typeName = zodField._def?.typeName ?? ''; + const innerTypeName = zodField._def?.innerType?._def?.typeName ?? ''; + if (typeName === 'ZodNumber' || innerTypeName === 'ZodNumber') type = 'number'; + if (typeName === 'ZodBoolean' || innerTypeName === 'ZodBoolean') type = 'boolean'; + return { + name, + type, + required: !isOptional, + label: name + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (s) => s.toUpperCase()) + .trim() + }; + }); +} + +export function listInfo(): readonly IntegrationInfo[] { + return [...integrations.values()].map((i) => ({ + id: i.id, + name: i.name, + icon: i.icon, + description: i.description, + endpoints: i.endpoints, + authConfigFields: zodSchemaToFields(i.authConfigSchema), + extraConfigFields: zodSchemaToFields(i.extraConfigSchema) + })); +} diff --git a/src/lib/server/integrations/types.ts b/src/lib/server/integrations/types.ts new file mode 100644 index 0000000..604153e --- /dev/null +++ b/src/lib/server/integrations/types.ts @@ -0,0 +1,129 @@ +import type { ZodSchema } from 'zod'; + +// Renderer-specific data types +export interface StatCardData { + readonly label: string; + readonly value: string | number; + readonly unit?: string; + readonly trend?: 'up' | 'down' | 'flat'; + readonly previousValue?: string | number; + readonly subtitle?: string; +} + +export interface ListData { + readonly items: readonly { + readonly id: string; + readonly title: string; + readonly subtitle?: string; + readonly icon?: string; + readonly badge?: { readonly text: string; readonly color: string }; + readonly url?: string; + }[]; +} + +export interface GaugeData { + readonly value: number; + readonly max: number; + readonly label: string; + readonly unit: string; + readonly thresholds?: { readonly warning: number; readonly critical: number }; +} + +export interface ChartData { + readonly labels: readonly string[]; + readonly datasets: readonly { + readonly label: string; + readonly values: readonly number[]; + readonly color?: string; + }[]; +} + +export interface ProgressData { + readonly items: readonly { + readonly id: string; + readonly label: string; + readonly progress: number; + readonly subtitle?: string; + readonly speed?: string; + }[]; +} + +export interface AlertBannerData { + readonly severity: 'info' | 'warning' | 'critical'; + readonly title: string; + readonly message: string; + readonly icon?: string; +} + +export type IntegrationRendererType = + | 'stat-card' + | 'list' + | 'gauge' + | 'chart' + | 'progress' + | 'alert-banner'; + +export type IntegrationDataPayload = + | StatCardData + | ListData + | GaugeData + | ChartData + | ProgressData + | AlertBannerData; + +export interface IntegrationEndpoint { + readonly id: string; + readonly name: string; + readonly description: string; + readonly renderer: IntegrationRendererType; + readonly refreshInterval: number; +} + +export interface IntegrationData { + readonly endpointId: string; + readonly renderer: IntegrationRendererType; + readonly data: IntegrationDataPayload; + readonly fetchedAt: string; +} + +export interface IntegrationTestResult { + readonly success: boolean; + readonly message: string; +} + +export interface IntegrationFieldDescriptor { + readonly name: string; + readonly type: 'string' | 'number' | 'boolean'; + readonly required: boolean; + readonly label: string; + readonly description?: string; +} + +export interface IntegrationInfo { + readonly id: string; + readonly name: string; + readonly icon: string; + readonly description: string; + readonly endpoints: readonly IntegrationEndpoint[]; + readonly authConfigFields: readonly IntegrationFieldDescriptor[]; + readonly extraConfigFields: readonly IntegrationFieldDescriptor[]; +} + +export interface Integration { + readonly id: string; + readonly name: string; + readonly icon: string; + readonly description: string; + readonly authConfigSchema: ZodSchema; + readonly extraConfigSchema?: ZodSchema; + readonly endpoints: readonly IntegrationEndpoint[]; + testConnection( + appUrl: string, + config: Record + ): Promise; + fetchData( + appUrl: string, + config: Record, + endpointId: string + ): Promise; +} diff --git a/src/lib/server/services/appService.ts b/src/lib/server/services/appService.ts index a831881..6defeeb 100644 --- a/src/lib/server/services/appService.ts +++ b/src/lib/server/services/appService.ts @@ -1,5 +1,13 @@ import { prisma } from '../prisma.js'; import type { CreateAppInput, UpdateAppInput } from '$lib/types/app.js'; +import { encrypt, tryDecrypt } from '../integrations/encryption.js'; + +function decryptAppIntegration(app: T): T { + return { + ...app, + integrationConfig: tryDecrypt(app.integrationConfig) + }; +} export async function findAll(options?: { category?: string; search?: string }) { const where: Record = {}; @@ -16,7 +24,7 @@ export async function findAll(options?: { category?: string; search?: string }) ]; } - return prisma.app.findMany({ + const apps = await prisma.app.findMany({ where, orderBy: { name: 'asc' }, include: { @@ -29,6 +37,7 @@ export async function findAll(options?: { category?: string; search?: string }) } } }); + return apps.map(decryptAppIntegration); } export async function findById(id: string) { @@ -50,7 +59,7 @@ export async function findById(id: string) { if (!app) { throw new Error(`App not found: ${id}`); } - return app; + return decryptAppIntegration(app); } export async function create(input: CreateAppInput) { @@ -68,6 +77,9 @@ export async function create(input: CreateAppInput) { healthcheckMethod: input.healthcheckMethod ?? 'GET', healthcheckExpectedStatus: input.healthcheckExpectedStatus ?? 200, healthcheckTimeout: input.healthcheckTimeout ?? 5000, + integrationType: input.integrationType ?? null, + integrationConfig: input.integrationConfig ? encrypt(input.integrationConfig) : null, + integrationEnabled: input.integrationEnabled ?? false, createdById: input.createdById ?? null } }); @@ -90,6 +102,11 @@ export async function update(id: string, input: UpdateAppInput) { if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus; if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout; + if (input.integrationType !== undefined) data.integrationType = input.integrationType; + if (input.integrationConfig !== undefined) { + data.integrationConfig = input.integrationConfig ? encrypt(input.integrationConfig) : null; + } + if (input.integrationEnabled !== undefined) data.integrationEnabled = input.integrationEnabled; return prisma.app.update({ where: { id }, @@ -252,6 +269,34 @@ export async function getAppLinks(appId: string) { }); } +export async function findWithIntegration(id: string) { + const app = await prisma.app.findUnique({ + where: { id }, + select: { + id: true, + url: true, + integrationType: true, + integrationConfig: true, + integrationEnabled: true + } + }); + if (!app) { + throw new Error(`App not found: ${id}`); + } + if (!app.integrationType || !app.integrationEnabled) { + throw new Error(`Integration not configured or not enabled for app: ${id}`); + } + const decryptedConfig = tryDecrypt(app.integrationConfig); + if (!decryptedConfig) { + throw new Error(`Failed to decrypt integration config for app: ${id}`); + } + return { + ...app, + integrationType: app.integrationType, + integrationConfig: decryptedConfig + }; +} + export async function getCategories() { const apps = await prisma.app.findMany({ where: { category: { not: null } }, diff --git a/src/lib/types/app.ts b/src/lib/types/app.ts index 7a7be97..b66c85e 100644 --- a/src/lib/types/app.ts +++ b/src/lib/types/app.ts @@ -14,6 +14,9 @@ export interface AppRecord { readonly healthcheckMethod: string; readonly healthcheckExpectedStatus: number; readonly healthcheckTimeout: number; + readonly integrationType: string | null; + readonly integrationConfig: string | null; + readonly integrationEnabled: boolean; readonly createdById: string | null; readonly createdAt: Date; readonly updatedAt: Date; @@ -32,6 +35,9 @@ export interface CreateAppInput { readonly healthcheckMethod?: HealthcheckMethod; readonly healthcheckExpectedStatus?: number; readonly healthcheckTimeout?: number; + readonly integrationType?: string; + readonly integrationConfig?: string; + readonly integrationEnabled?: boolean; readonly createdById?: string; } @@ -48,6 +54,9 @@ export interface UpdateAppInput { readonly healthcheckMethod?: HealthcheckMethod; readonly healthcheckExpectedStatus?: number; readonly healthcheckTimeout?: number; + readonly integrationType?: string | null; + readonly integrationConfig?: string | null; + readonly integrationEnabled?: boolean; } export interface AppStatusRecord { diff --git a/src/lib/utils/constants.ts b/src/lib/utils/constants.ts index 32e286f..52a9101 100644 --- a/src/lib/utils/constants.ts +++ b/src/lib/utils/constants.ts @@ -43,7 +43,8 @@ export const WidgetType = { MARKDOWN: 'markdown', METRIC: 'metric', LINK_GROUP: 'link_group', - CAMERA: 'camera' + CAMERA: 'camera', + INTEGRATION: 'integration' } as const; export type WidgetType = (typeof WidgetType)[keyof typeof WidgetType]; diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts index 8685b80..fbbf5f1 100644 --- a/src/lib/utils/validators.ts +++ b/src/lib/utils/validators.ts @@ -74,7 +74,10 @@ export const createAppSchema = z.object({ healthcheckInterval: z.number().int().min(30).max(86400).optional(), healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(), healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(), - healthcheckTimeout: z.number().int().min(1000).max(30000).optional() + healthcheckTimeout: z.number().int().min(1000).max(30000).optional(), + integrationType: z.string().max(50).nullable().optional(), + integrationConfig: z.string().max(10000).nullable().optional(), + integrationEnabled: z.boolean().optional() }); export const updateAppSchema = z.object({ @@ -89,7 +92,10 @@ export const updateAppSchema = z.object({ healthcheckInterval: z.number().int().min(30).max(86400).optional(), healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(), healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(), - healthcheckTimeout: z.number().int().min(1000).max(30000).optional() + healthcheckTimeout: z.number().int().min(1000).max(30000).optional(), + integrationType: z.string().max(50).nullable().optional(), + integrationConfig: z.string().max(10000).nullable().optional(), + integrationEnabled: z.boolean().optional() }); // --- Board --- @@ -185,7 +191,8 @@ const allWidgetTypes = [ WidgetType.MARKDOWN, WidgetType.METRIC, WidgetType.LINK_GROUP, - WidgetType.CAMERA + WidgetType.CAMERA, + WidgetType.INTEGRATION ] as const; export const createWidgetSchema = z.object({ @@ -371,6 +378,12 @@ export const cameraWidgetConfigSchema = z.object({ aspectRatio: z.string().max(20).optional() }); +export const integrationWidgetConfigSchema = z.object({ + appId: z.string().min(1, 'App ID is required'), + endpointId: z.string().min(1, 'Endpoint ID is required'), + refreshInterval: z.number().int().min(5).max(3600).optional() +}); + // --- New entity schemas for Phases 4-7 --- export const createTagSchema = z.object({ diff --git a/src/routes/api/integrations/+server.ts b/src/routes/api/integrations/+server.ts new file mode 100644 index 0000000..d6fb6db --- /dev/null +++ b/src/routes/api/integrations/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import { listInfo } from '$lib/server/integrations/registry.js'; +import { success } from '$lib/server/utils/response.js'; + +/** + * GET /api/integrations — List available integration types. + */ +export const GET: RequestHandler = async (event) => { + requireAuth(event); + + const integrations = listInfo(); + return json(success(integrations)); +}; diff --git a/src/routes/api/integrations/test/+server.ts b/src/routes/api/integrations/test/+server.ts new file mode 100644 index 0000000..fbca686 --- /dev/null +++ b/src/routes/api/integrations/test/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { z } from 'zod'; +import { requireAuth } from '$lib/server/middleware/authenticate.js'; +import * as registry from '$lib/server/integrations/registry.js'; +import { success, error } from '$lib/server/utils/response.js'; + +const testConnectionSchema = z.object({ + integrationType: z.string().min(1, 'Integration type is required'), + appUrl: z.string().url('Invalid app URL'), + config: z.record(z.unknown()) +}); + +/** + * POST /api/integrations/test — Test an integration connection. + */ +export const POST: RequestHandler = async (event) => { + requireAuth(event); + + let body: unknown; + try { + body = await event.request.json(); + } catch { + return json(error('Invalid JSON body'), { status: 400 }); + } + + const parsed = testConnectionSchema.safeParse(body); + if (!parsed.success) { + return json(error(parsed.error.errors[0]?.message ?? 'Invalid input'), { status: 400 }); + } + + const { integrationType, appUrl, config } = parsed.data; + + const integration = registry.get(integrationType); + if (!integration) { + return json(error(`Unknown integration type: ${integrationType}`), { status: 404 }); + } + + try { + const result = await integration.testConnection(appUrl, config); + return json(success(result)); + } catch (err) { + const message = err instanceof Error ? err.message : 'Connection test failed'; + return json(error(message), { status: 500 }); + } +};