feat(service-integrations): phase 1 — integration architecture foundation

- Add Integration interfaces, registry, cache, encryption, and base helpers
- Add integrationType, integrationConfig, integrationEnabled to App model
- Add integration widget type to constants and validators
- Add integration fields to AppRecord, CreateAppInput, UpdateAppInput
- Update appService with encryption/decryption for integration config
- Add API routes: list integrations, test connection, fetch endpoint data
This commit is contained in:
2026-03-25 22:02:34 +03:00
parent c62ca79adb
commit 114dee57a8
12 changed files with 445 additions and 6 deletions
+3
View File
@@ -80,6 +80,9 @@ model App {
healthcheckMethod String @default("GET") healthcheckMethod String @default("GET")
healthcheckExpectedStatus Int @default(200) healthcheckExpectedStatus Int @default(200)
healthcheckTimeout Int @default(5000) // milliseconds healthcheckTimeout Int @default(5000) // milliseconds
integrationType String?
integrationConfig String?
integrationEnabled Boolean @default(false)
createdById String? createdById String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+34
View File
@@ -0,0 +1,34 @@
const DEFAULT_TIMEOUT_MS = 10_000;
export async function fetchWithTimeout(
url: string,
options?: RequestInit & { timeout?: number }
): Promise<Response> {
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}`);
}
+35
View File
@@ -0,0 +1,35 @@
interface CacheEntry<T> {
readonly data: T;
readonly expiresAt: number;
}
const store = new Map<string, CacheEntry<unknown>>();
export function get<T>(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<T>(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);
}
}
}
+47
View File
@@ -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;
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { Integration, IntegrationInfo, IntegrationFieldDescriptor } from './types.js';
import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod';
const integrations = new Map<string, Integration>();
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<ZodRawShape>)._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)
}));
}
+129
View File
@@ -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<string, unknown>
): Promise<IntegrationTestResult>;
fetchData(
appUrl: string,
config: Record<string, unknown>,
endpointId: string
): Promise<IntegrationData>;
}
+47 -2
View File
@@ -1,5 +1,13 @@
import { prisma } from '../prisma.js'; import { prisma } from '../prisma.js';
import type { CreateAppInput, UpdateAppInput } from '$lib/types/app.js'; import type { CreateAppInput, UpdateAppInput } from '$lib/types/app.js';
import { encrypt, tryDecrypt } from '../integrations/encryption.js';
function decryptAppIntegration<T extends { integrationConfig: string | null }>(app: T): T {
return {
...app,
integrationConfig: tryDecrypt(app.integrationConfig)
};
}
export async function findAll(options?: { category?: string; search?: string }) { export async function findAll(options?: { category?: string; search?: string }) {
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
@@ -16,7 +24,7 @@ export async function findAll(options?: { category?: string; search?: string })
]; ];
} }
return prisma.app.findMany({ const apps = await prisma.app.findMany({
where, where,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
include: { include: {
@@ -29,6 +37,7 @@ export async function findAll(options?: { category?: string; search?: string })
} }
} }
}); });
return apps.map(decryptAppIntegration);
} }
export async function findById(id: string) { export async function findById(id: string) {
@@ -50,7 +59,7 @@ export async function findById(id: string) {
if (!app) { if (!app) {
throw new Error(`App not found: ${id}`); throw new Error(`App not found: ${id}`);
} }
return app; return decryptAppIntegration(app);
} }
export async function create(input: CreateAppInput) { export async function create(input: CreateAppInput) {
@@ -68,6 +77,9 @@ export async function create(input: CreateAppInput) {
healthcheckMethod: input.healthcheckMethod ?? 'GET', healthcheckMethod: input.healthcheckMethod ?? 'GET',
healthcheckExpectedStatus: input.healthcheckExpectedStatus ?? 200, healthcheckExpectedStatus: input.healthcheckExpectedStatus ?? 200,
healthcheckTimeout: input.healthcheckTimeout ?? 5000, healthcheckTimeout: input.healthcheckTimeout ?? 5000,
integrationType: input.integrationType ?? null,
integrationConfig: input.integrationConfig ? encrypt(input.integrationConfig) : null,
integrationEnabled: input.integrationEnabled ?? false,
createdById: input.createdById ?? null createdById: input.createdById ?? null
} }
}); });
@@ -90,6 +102,11 @@ export async function update(id: string, input: UpdateAppInput) {
if (input.healthcheckExpectedStatus !== undefined) if (input.healthcheckExpectedStatus !== undefined)
data.healthcheckExpectedStatus = input.healthcheckExpectedStatus; data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout; 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({ return prisma.app.update({
where: { id }, 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() { export async function getCategories() {
const apps = await prisma.app.findMany({ const apps = await prisma.app.findMany({
where: { category: { not: null } }, where: { category: { not: null } },
+9
View File
@@ -14,6 +14,9 @@ export interface AppRecord {
readonly healthcheckMethod: string; readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number; readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number; readonly healthcheckTimeout: number;
readonly integrationType: string | null;
readonly integrationConfig: string | null;
readonly integrationEnabled: boolean;
readonly createdById: string | null; readonly createdById: string | null;
readonly createdAt: Date; readonly createdAt: Date;
readonly updatedAt: Date; readonly updatedAt: Date;
@@ -32,6 +35,9 @@ export interface CreateAppInput {
readonly healthcheckMethod?: HealthcheckMethod; readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number; readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number; readonly healthcheckTimeout?: number;
readonly integrationType?: string;
readonly integrationConfig?: string;
readonly integrationEnabled?: boolean;
readonly createdById?: string; readonly createdById?: string;
} }
@@ -48,6 +54,9 @@ export interface UpdateAppInput {
readonly healthcheckMethod?: HealthcheckMethod; readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number; readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number; readonly healthcheckTimeout?: number;
readonly integrationType?: string | null;
readonly integrationConfig?: string | null;
readonly integrationEnabled?: boolean;
} }
export interface AppStatusRecord { export interface AppStatusRecord {
+2 -1
View File
@@ -43,7 +43,8 @@ export const WidgetType = {
MARKDOWN: 'markdown', MARKDOWN: 'markdown',
METRIC: 'metric', METRIC: 'metric',
LINK_GROUP: 'link_group', LINK_GROUP: 'link_group',
CAMERA: 'camera' CAMERA: 'camera',
INTEGRATION: 'integration'
} as const; } as const;
export type WidgetType = (typeof WidgetType)[keyof typeof WidgetType]; export type WidgetType = (typeof WidgetType)[keyof typeof WidgetType];
+16 -3
View File
@@ -74,7 +74,10 @@ export const createAppSchema = z.object({
healthcheckInterval: z.number().int().min(30).max(86400).optional(), healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(), healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).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({ export const updateAppSchema = z.object({
@@ -89,7 +92,10 @@ export const updateAppSchema = z.object({
healthcheckInterval: z.number().int().min(30).max(86400).optional(), healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(), healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).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 --- // --- Board ---
@@ -185,7 +191,8 @@ const allWidgetTypes = [
WidgetType.MARKDOWN, WidgetType.MARKDOWN,
WidgetType.METRIC, WidgetType.METRIC,
WidgetType.LINK_GROUP, WidgetType.LINK_GROUP,
WidgetType.CAMERA WidgetType.CAMERA,
WidgetType.INTEGRATION
] as const; ] as const;
export const createWidgetSchema = z.object({ export const createWidgetSchema = z.object({
@@ -371,6 +378,12 @@ export const cameraWidgetConfigSchema = z.object({
aspectRatio: z.string().max(20).optional() 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 --- // --- New entity schemas for Phases 4-7 ---
export const createTagSchema = z.object({ export const createTagSchema = z.object({
+15
View File
@@ -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));
};
@@ -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 });
}
};