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")
|
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
|
||||||
|
|||||||
@@ -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 { 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 } },
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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