Spaces:
Runtime error
Runtime error
| 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'; | |
| } | |
| } | |
| () | |
| export class PluginInstanceService { | |
| constructor((PluginInstance, 'data') private readonly repo: Repository<PluginInstance>) {} | |
| async mint( | |
| pluginId: string, | |
| instanceId: string, | |
| opts: { sessionScope?: string; verifyToken?: string; secret?: string; config?: Record<string, unknown> }, | |
| ): Promise<PluginInstance> { | |
| 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<PluginInstance | null> { | |
| 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<string, unknown> }, | |
| ): Promise<PluginInstance> { | |
| 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<PluginInstance[]> { | |
| return this.repo.find({ where: { pluginId } }); | |
| } | |
| /** Every persisted instance across all plugins — used by the boot-time scope-binding reconciliation. */ | |
| listAll(): Promise<PluginInstance[]> { | |
| return this.repo.find(); | |
| } | |
| async regenerateSecret(pluginId: string, instanceId: string): Promise<PluginInstance> { | |
| 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<PluginInstance | null> { | |
| 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<string, unknown> }, | |
| schema?: PluginConfigSchema, | |
| ): Promise<PluginInstance | null> { | |
| 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<boolean> { | |
| const result = await this.repo.delete({ id: `${pluginId}:${instanceId}` }); | |
| return (result.affected ?? 0) > 0; | |
| } | |
| } | |