File size: 5,340 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
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<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;
  }
}