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:
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user