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:
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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<T extends { integrationConfig: string | null }>(app: T): T {
|
||||
return {
|
||||
...app,
|
||||
integrationConfig: tryDecrypt(app.integrationConfig)
|
||||
};
|
||||
}
|
||||
|
||||
export async function findAll(options?: { category?: string; search?: string }) {
|
||||
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,
|
||||
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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user