import { randomBytes } from 'node:crypto'; import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PluginInstance } from './entities/plugin-instance.entity'; import type { PluginConfigSchema } from '../../core/plugins/plugin.interfaces'; import { redactSecretConfig, restoreSecretConfig, SECRET_SENTINEL } from '../plugins/redact-config'; // A supplied ingress secret must be a real, guessing-resistant value; an empty/short one would make the // public HMAC forgeable. Absent => auto-generate. Trimmed so pasted whitespace can't slip a weak secret in. function normalizeSecret(supplied?: string): string { if (supplied === undefined) return randomBytes(32).toString('hex'); const s = supplied.trim(); if (s.length < 16) { throw new BadRequestException('instance secret must be a non-empty string of at least 16 characters'); } return s; } export class InstanceExistsError extends Error { constructor(pluginId: string, instanceId: string) { super(`instance ${instanceId} already exists for plugin ${pluginId}`); this.name = 'InstanceExistsError'; } } @Injectable() export class PluginInstanceService { constructor(@InjectRepository(PluginInstance, 'data') private readonly repo: Repository) {} async mint( pluginId: string, instanceId: string, opts: { sessionScope?: string; verifyToken?: string; secret?: string; config?: Record }, ): Promise { const id = `${pluginId}:${instanceId}`; const existing = await this.repo.findOne({ where: { id } }); if (existing) return existing; const inst = this.repo.create({ id, pluginId, instanceId, sessionScope: opts.sessionScope || null, secret: normalizeSecret(opts.secret), verifyToken: opts.verifyToken ?? null, config: opts.config ?? null, enabled: true, }); return this.repo.save(inst); } resolve(pluginId: string, instanceId: string): Promise { return this.repo.findOne({ where: { id: `${pluginId}:${instanceId}` } }); } // Operator-facing view: never leak the raw secret, and mask any `secret:true` config field (e.g. a // provider apiToken) per the plugin's configSchema — recursively, at any depth, and fail-closed when // the schema is unavailable — by reusing the shared redactSecretConfig (single source of truth). maskedView(instance: PluginInstance, schema?: PluginConfigSchema): PluginInstance { return { ...instance, secret: SECRET_SENTINEL, config: instance.config == null ? instance.config : redactSecretConfig(instance.config, schema), }; } async create( pluginId: string, instanceId: string, opts: { sessionScope?: string; verifyToken?: string; secret?: string; config?: Record }, ): Promise { const id = `${pluginId}:${instanceId}`; if (await this.repo.findOne({ where: { id } })) throw new InstanceExistsError(pluginId, instanceId); const inst = this.repo.create({ id, pluginId, instanceId, sessionScope: opts.sessionScope || null, secret: normalizeSecret(opts.secret), verifyToken: opts.verifyToken ?? null, config: opts.config ?? null, enabled: true, }); return this.repo.save(inst); } list(pluginId: string): Promise { return this.repo.find({ where: { pluginId } }); } /** Every persisted instance across all plugins — used by the boot-time scope-binding reconciliation. */ listAll(): Promise { return this.repo.find(); } async regenerateSecret(pluginId: string, instanceId: string): Promise { const inst = await this.resolve(pluginId, instanceId); if (!inst) throw new Error(`instance ${instanceId} not found for plugin ${pluginId}`); inst.secret = randomBytes(32).toString('hex'); return this.repo.save(inst); } async setEnabled(pluginId: string, instanceId: string, enabled: boolean): Promise { const inst = await this.resolve(pluginId, instanceId); if (!inst) return null; inst.enabled = enabled; return this.repo.save(inst); } async update( pluginId: string, instanceId: string, patch: { sessionScope?: string; config?: Record }, schema?: PluginConfigSchema, ): Promise { const inst = await this.resolve(pluginId, instanceId); if (!inst) return null; if (patch.sessionScope !== undefined) inst.sessionScope = patch.sessionScope || null; if (patch.config !== undefined) { // The operator view masks secrets as the sentinel, so a round-tripped config carries '***' for // unchanged secrets. Restore the stored values instead of persisting the mask (which would corrupt // the credential); genuinely-new values are written as provided. inst.config = restoreSecretConfig(patch.config, inst.config ?? undefined, schema); } return this.repo.save(inst); } async remove(pluginId: string, instanceId: string): Promise { const result = await this.repo.delete({ id: `${pluginId}:${instanceId}` }); return (result.affected ?? 0) > 0; } }