File size: 6,461 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
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { AuditAction } from '../audit/entities/audit-log.entity';
import { AuditService } from '../audit/audit.service';
import { PluginLoaderService } from '../../core/plugins/plugin-loader.service';
import { PluginInstanceService } from './plugin-instance.service';
import { PluginInstance } from './entities/plugin-instance.entity';
import { createLogger } from '../../common/services/logger.service';

/**
 * Owns the provisioning bridge that makes a persisted plugin instance's config reach the ingress
 * worker: it mirrors the instance config into the plugin's per-session config and toggles the bound
 * session in activeSessions, so `dispatchWebhookForInstance` resolves it as `ctx.config`. Extracted
 * from IntegrationInstanceController so the SAME binding runs both on provisioning (create/patch/remove)
 * and as a boot-time reconciliation over the authoritative `plugin_instances` rows.
 */
@Injectable()
export class ScopeBindingService implements OnApplicationBootstrap {
  private readonly logger = createLogger('ScopeBindingService');

  constructor(
    private readonly instances: PluginInstanceService,
    private readonly loader: PluginLoaderService,
    private readonly audit: AuditService,
  ) {}

  /**
   * Re-derive every ENABLED instance's runtime scope binding from the persisted `plugin_instances`
   * rows, so a binding lost at provisioning time (the plugin was momentarily unloaded, so
   * applyScopeBinding was swallowed as an INFO audit) is restored on the next boot without an operator
   * re-PATCH β€” otherwise the row shows `enabled` but the ingress handler resolves base config only.
   * Runs after every module's onModuleInit (so PluginLoaderService has loaded all plugins).
   *
   * Only enabled rows are (re)activated: a disabled instance must never be force-activated, and issuing
   * its deactivate here could clear a scope a sibling ENABLED instance still binds. applyScopeBinding
   * already no-ops/logs for an unloaded plugin, and its internal try/catch means one instance's failure
   * cannot abort the rest.
   */
  async onApplicationBootstrap(): Promise<void> {
    let rows: PluginInstance[];
    try {
      rows = await this.instances.listAll();
    } catch (err) {
      this.logger.error('Scope-binding reconciliation skipped (failed to list instances)', String(err));
      return;
    }

    // Order-independent reconciliation: listAll() is repo.find() with no ORDER BY, so its row order
    // is DB/restart-dependent (differs between SQLite and Postgres). applyScopeBinding mutates the
    // SAME in-memory plugin.activeSessions each iteration β€” the concrete-scope path strips '*' while
    // the wildcard path overwrites with ['*'] β€” so for a plugin with BOTH a wildcard and a concrete
    // instance the final set would otherwise depend on row order (a concrete processed after a
    // wildcard drops the '*'). Sort concrete scopes before wildcard/null so the wildcard's ['*'] is
    // the last write (correct: '*' subsumes every concrete scope), making the result stable across
    // DBs and restarts. instanceId is the deterministic tiebreak among same-rank rows.
    rows.sort((a, b) => {
      const rank = (i: PluginInstance) => (!i.sessionScope || i.sessionScope === '*' ? 1 : 0);
      if (rank(a) !== rank(b)) return rank(a) - rank(b);
      return String(a.instanceId).localeCompare(String(b.instanceId));
    });

    let count = 0;
    for (const inst of rows) {
      if (!inst.enabled) continue;
      // Skip instances whose plugin isn't loaded β€” applyScopeBinding would only no-op/log for them.
      if (!this.loader.getPlugin(inst.pluginId)) continue;
      await this.applyScopeBinding(inst.pluginId, inst.sessionScope, inst.config ?? {}, true);
      count++;
    }

    if (count > 0) {
      this.logger.log(`Reconciled scope bindings for ${count} enabled plugin instance(s)`, {
        action: 'scope_bindings_reconciled',
        count,
      });
    }
  }

  /**
   * Bind an instance's config to the plugin's runtime so an ingress handler resolves it as ctx.config
   * (see PluginLoaderService.dispatchWebhookForInstance) and activate the session β€” iff `activate` (a
   * disabled or removed instance must not keep firing). A concrete scope writes sessionConfig[scope] and
   * toggles that session in activeSessions; a null/'*' scope binds the base config + all sessions ('*').
   * Best-effort: provisioning must not fail because the plugin is momentarily unloaded.
   */
  async applyScopeBinding(
    pluginId: string,
    scope: string | null,
    config: Record<string, unknown>,
    activate: boolean,
  ): Promise<void> {
    try {
      if (!scope || scope === '*') {
        // 'all sessions' β†’ base config + activate ['*']. The merged base config cannot be cleanly torn
        // down (updatePluginConfig merges, so one instance's keys aren't separable), but the '*'
        // activation CAN be retired: on deactivate, drop '*' from activeSessions ONLY when no OTHER
        // enabled instance still binds a wildcard/null scope β€” otherwise disabling/deleting a wildcard
        // instance would leave the plugin firing on every session with stale config.
        if (activate) {
          this.loader.updatePluginConfig(pluginId, config);
          this.loader.setPluginSessions(pluginId, ['*']);
          return;
        }
        const anyWildcardLeft = (await this.instances.list(pluginId)).some(
          i => i.enabled && (!i.sessionScope || i.sessionScope === '*'),
        );
        if (!anyWildcardLeft) {
          const current = this.loader.getPlugin(pluginId)?.activeSessions ?? [];
          this.loader.setPluginSessions(
            pluginId,
            current.filter(s => s !== '*'),
          );
        }
        return;
      }
      this.loader.setPluginSessionConfig(pluginId, scope, activate ? config : {});
      const current = this.loader.getPlugin(pluginId)?.activeSessions ?? [];
      const set = new Set(current.filter(s => s !== '*'));
      if (activate) set.add(scope);
      else set.delete(scope);
      this.loader.setPluginSessions(pluginId, [...set]);
    } catch (err) {
      // Best-effort: don't fail provisioning if the plugin is momentarily unloaded.
      void this.audit.logInfo(AuditAction.INTEGRATION_INSTANCE_UPDATED, {
        metadata: { pluginId, scope, bridgeError: String(err) },
      });
    }
  }
}