bopodev-api 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/package.json +5 -5
  2. package/src/app.ts +4 -2
  3. package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
  4. package/src/assets/starter-packs/devrel-growth.zip +0 -0
  5. package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
  6. package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
  7. package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
  8. package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
  9. package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
  10. package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
  11. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
  12. package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
  13. package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
  14. package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
  15. package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
  16. package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
  17. package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
  18. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
  19. package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
  20. package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
  21. package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
  22. package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
  23. package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
  24. package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
  25. package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
  26. package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
  27. package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
  28. package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
  29. package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
  30. package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
  31. package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
  32. package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
  33. package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
  34. package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
  35. package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
  36. package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
  37. package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
  38. package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
  39. package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
  40. package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
  41. package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
  42. package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
  43. package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
  44. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
  45. package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
  46. package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
  47. package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
  48. package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
  49. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
  50. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
  51. package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
  52. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
  53. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
  54. package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
  55. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
  56. package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
  57. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
  58. package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
  59. package/src/lib/agent-issue-permissions.ts +56 -0
  60. package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
  61. package/src/lib/instance-paths.ts +5 -0
  62. package/src/realtime/office-space.ts +7 -0
  63. package/src/routes/agents.ts +23 -1
  64. package/src/routes/assistant.ts +40 -1
  65. package/src/routes/companies.ts +227 -15
  66. package/src/routes/issues.ts +82 -3
  67. package/src/routes/observability.ts +222 -0
  68. package/src/routes/plugins.ts +393 -103
  69. package/src/routes/{loops.ts → routines.ts} +72 -76
  70. package/src/scripts/onboard-seed.ts +2 -0
  71. package/src/server.ts +3 -1
  72. package/src/services/company-assistant-context-snapshot.ts +4 -2
  73. package/src/services/company-assistant-service.ts +17 -15
  74. package/src/services/company-file-archive-service.ts +81 -6
  75. package/src/services/company-file-import-service.ts +221 -31
  76. package/src/services/company-knowledge-file-service.ts +361 -0
  77. package/src/services/company-skill-file-service.ts +151 -2
  78. package/src/services/governance-service.ts +58 -3
  79. package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
  80. package/src/services/plugin-artifact-installer.ts +115 -0
  81. package/src/services/plugin-artifact-store.ts +28 -0
  82. package/src/services/plugin-capability-policy.ts +31 -0
  83. package/src/services/plugin-jobs-service.ts +74 -0
  84. package/src/services/plugin-manifest-loader.ts +78 -3
  85. package/src/services/plugin-rpc.ts +102 -0
  86. package/src/services/plugin-runtime.ts +240 -209
  87. package/src/services/plugin-worker-host.ts +167 -0
  88. package/src/services/starter-pack-registry.ts +68 -0
  89. package/src/services/template-apply-service.ts +3 -1
  90. package/src/services/template-catalog.ts +29 -0
  91. package/src/services/work-loop-service/work-loop-service.ts +18 -18
  92. package/src/shutdown/graceful-shutdown.ts +3 -1
  93. package/src/validation/issue-routes.ts +19 -2
  94. package/src/worker/scheduler.ts +21 -1
  95. package/src/services/company-export-service.ts +0 -63
@@ -1,22 +1,40 @@
1
1
  import { Router } from "express";
2
+ import { access } from "node:fs/promises";
3
+ import { resolve } from "node:path";
2
4
  import { z } from "zod";
3
- import { PluginManifestSchema } from "bopodev-contracts";
5
+ import { PluginManifestV2Schema } from "bopodev-contracts";
4
6
  import {
7
+ appendPluginInstall,
8
+ countPluginInstallRevisionsByCompany,
5
9
  createApprovalRequest,
6
10
  deletePluginById,
7
- deletePluginConfig,
11
+ getPluginInstallById,
8
12
  listCompanyPluginConfigs,
9
13
  listCompanies,
14
+ listPluginInstalls,
10
15
  listPluginRuns,
11
16
  listPlugins,
17
+ markPluginInstallStatus,
18
+ markPluginInstallsSuperseded,
12
19
  updatePluginConfig
13
20
  } from "bopodev-db";
14
21
  import type { AppContext } from "../context";
15
22
  import { sendError, sendOk } from "../http";
16
23
  import { requireCompanyScope } from "../middleware/company-scope";
17
24
  import { enforcePermission, requireBoardRole } from "../middleware/request-actor";
18
- import { deletePluginManifestFromFilesystem, writePluginManifestToFilesystem } from "../services/plugin-manifest-loader";
19
- import { registerPluginManifest } from "../services/plugin-runtime";
25
+ import {
26
+ deletePluginManifestFromFilesystem,
27
+ writePackagedPluginManifestToFilesystem
28
+ } from "../services/plugin-manifest-loader";
29
+ import {
30
+ invokePluginWorkerEndpoint,
31
+ invokePluginWorkerHealth,
32
+ invokePluginWorkerWebhook,
33
+ resolvePluginUiEntrypoint,
34
+ registerPluginManifest
35
+ } from "../services/plugin-runtime";
36
+ import { namespacedCapabilitiesRequireApproval } from "../services/plugin-capability-policy";
37
+ import { installPluginArtifactFromNpm } from "../services/plugin-artifact-installer";
20
38
 
21
39
  const pluginConfigSchema = z.object({
22
40
  enabled: z.boolean().optional(),
@@ -25,19 +43,30 @@ const pluginConfigSchema = z.object({
25
43
  grantedCapabilities: z.array(z.string().min(1)).default([]),
26
44
  requestApproval: z.boolean().default(true)
27
45
  });
28
- const pluginManifestCreateSchema = z.object({
29
- manifestJson: z.string().min(2),
30
- install: z.boolean().default(true)
46
+ const pluginRegistryInstallSchema = z.object({
47
+ packageName: z.string().min(1),
48
+ version: z.string().min(1).optional(),
49
+ install: z.boolean().default(true),
50
+ requestApproval: z.boolean().default(true)
51
+ });
52
+ const pluginRollbackSchema = z.object({
53
+ installId: z.string().min(1)
54
+ });
55
+ const pluginUpgradeSchema = z.object({
56
+ packageName: z.string().min(1),
57
+ version: z.string().min(1).optional()
31
58
  });
32
-
33
- const HIGH_RISK_CAPABILITIES = new Set(["network", "queue_publish", "issue_write", "write_memory"]);
34
59
 
35
60
  export function createPluginsRouter(ctx: AppContext) {
36
61
  const router = Router();
37
62
  router.use(requireCompanyScope);
38
63
 
39
64
  router.get("/", async (req, res) => {
40
- const [catalog, configs] = await Promise.all([listPlugins(ctx.db), listCompanyPluginConfigs(ctx.db, req.companyId!)]);
65
+ const [catalog, configs, installRevisionCounts] = await Promise.all([
66
+ listPlugins(ctx.db),
67
+ listCompanyPluginConfigs(ctx.db, req.companyId!),
68
+ countPluginInstallRevisionsByCompany(ctx.db, req.companyId!)
69
+ ]);
41
70
  const configByPluginId = new Map(configs.map((row) => [row.pluginId, row]));
42
71
  return sendOk(
43
72
  res,
@@ -58,6 +87,21 @@ export function createPluginsRouter(ctx: AppContext) {
58
87
  kind: plugin.kind,
59
88
  runtimeType: plugin.runtimeType,
60
89
  runtimeEntrypoint: plugin.runtimeEntrypoint,
90
+ apiVersion: typeof manifest.apiVersion === "string" ? manifest.apiVersion : "2",
91
+ entrypoints:
92
+ typeof manifest.entrypoints === "object" && manifest.entrypoints !== null
93
+ ? manifest.entrypoints
94
+ : null,
95
+ uiSlots:
96
+ typeof manifest.ui === "object" &&
97
+ manifest.ui !== null &&
98
+ Array.isArray((manifest.ui as Record<string, unknown>).slots)
99
+ ? (manifest.ui as { slots: unknown[] }).slots
100
+ : [],
101
+ install:
102
+ typeof manifest.install === "object" && manifest.install !== null
103
+ ? (manifest.install as Record<string, unknown>)
104
+ : null,
61
105
  hooks: safeParseStringArray(plugin.hooksJson),
62
106
  capabilities: safeParseStringArray(plugin.capabilitiesJson),
63
107
  companyConfig: config
@@ -67,7 +111,8 @@ export function createPluginsRouter(ctx: AppContext) {
67
111
  config: safeParseJsonObject(config.configJson),
68
112
  grantedCapabilities: safeParseStringArray(config.grantedCapabilitiesJson)
69
113
  }
70
- : null
114
+ : null,
115
+ installRevisionCount: installRevisionCounts.get(plugin.id) ?? 0
71
116
  };
72
117
  })
73
118
  );
@@ -86,28 +131,12 @@ export function createPluginsRouter(ctx: AppContext) {
86
131
  const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
87
132
  const pluginExists = catalog.some((plugin) => plugin.id === pluginId);
88
133
  if (!pluginExists) {
89
- return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
134
+ return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
90
135
  }
91
136
  const companyExists = companies.some((company) => company.id === req.companyId);
92
137
  if (!companyExists) {
93
138
  return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
94
139
  }
95
- const riskyCaps = parsed.data.grantedCapabilities.filter((cap) => HIGH_RISK_CAPABILITIES.has(cap));
96
- if (riskyCaps.length > 0 && parsed.data.requestApproval) {
97
- const approvalId = await createApprovalRequest(ctx.db, {
98
- companyId: req.companyId!,
99
- requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
100
- action: "grant_plugin_capabilities",
101
- payload: {
102
- pluginId,
103
- enabled: parsed.data.enabled,
104
- priority: parsed.data.priority,
105
- grantedCapabilities: parsed.data.grantedCapabilities,
106
- config: parsed.data.config
107
- }
108
- });
109
- return sendOk(res, { approvalId, status: "pending" });
110
- }
111
140
  await updatePluginConfig(ctx.db, {
112
141
  companyId: req.companyId!,
113
142
  pluginId,
@@ -119,90 +148,86 @@ export function createPluginsRouter(ctx: AppContext) {
119
148
  return sendOk(res, { ok: true });
120
149
  });
121
150
 
122
- router.post("/install-from-json", async (req, res) => {
151
+ router.post("/install", async (req, res) => {
123
152
  if (!enforcePermission(req, res, "plugins:write")) return;
124
- const parsed = pluginManifestCreateSchema.safeParse(req.body);
153
+ const parsed = pluginRegistryInstallSchema.safeParse(req.body);
125
154
  if (!parsed.success) {
126
155
  return sendError(res, parsed.error.message, 422);
127
156
  }
128
- let rawManifest: unknown;
129
- try {
130
- rawManifest = JSON.parse(parsed.data.manifestJson);
131
- } catch {
132
- return sendError(res, "manifestJson must be valid JSON.", 422);
133
- }
134
- const manifestParsed = PluginManifestSchema.safeParse(rawManifest);
135
- if (!manifestParsed.success) {
136
- return sendError(res, manifestParsed.error.message, 422);
137
- }
138
- const manifest = manifestParsed.data;
139
157
  const [companies] = await Promise.all([listCompanies(ctx.db)]);
140
158
  const companyExists = companies.some((company) => company.id === req.companyId);
141
159
  if (!companyExists) {
142
160
  return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
143
161
  }
144
-
145
- const manifestPath = await writePluginManifestToFilesystem(manifest);
146
- await registerPluginManifest(ctx.db, manifest);
147
- if (parsed.data.install) {
148
- await updatePluginConfig(ctx.db, {
162
+ try {
163
+ const installed = await installPluginArtifactFromNpm({
164
+ packageName: parsed.data.packageName,
165
+ version: parsed.data.version
166
+ });
167
+ const requestedNamespaces = installed.manifest.capabilityNamespaces ?? [];
168
+ if (requestedNamespaces.length > 0 && namespacedCapabilitiesRequireApproval(requestedNamespaces) && parsed.data.requestApproval) {
169
+ const approvalId = await createApprovalRequest(ctx.db, {
170
+ companyId: req.companyId!,
171
+ requestedByAgentId: req.actor?.type === "agent" ? req.actor.id : null,
172
+ action: "grant_plugin_capabilities",
173
+ payload: {
174
+ pluginId: installed.manifest.id,
175
+ capabilityNamespaces: requestedNamespaces,
176
+ sourceType: "registry",
177
+ sourceRef: installed.packageRef,
178
+ integrity: installed.integrity ?? null,
179
+ buildHash: installed.buildHash,
180
+ manifestJson: JSON.stringify(installed.manifest),
181
+ install: parsed.data.install
182
+ }
183
+ });
184
+ return sendOk(res, { ok: true, pluginId: installed.manifest.id, approvalId, status: "pending" });
185
+ }
186
+ const manifestPath = await writePackagedPluginManifestToFilesystem(installed.manifest, {
187
+ sourceType: "registry",
188
+ sourceRef: installed.packageRef,
189
+ integrity: installed.integrity,
190
+ buildHash: installed.buildHash
191
+ });
192
+ await registerPluginManifest(ctx.db, installed.manifest);
193
+ await markPluginInstallsSuperseded(ctx.db, {
149
194
  companyId: req.companyId!,
150
- pluginId: manifest.id,
151
- enabled: false,
152
- priority: 100,
153
- configJson: "{}",
154
- grantedCapabilitiesJson: "[]"
195
+ pluginId: installed.manifest.id
155
196
  });
197
+ const installId = await appendPluginInstall(ctx.db, {
198
+ companyId: req.companyId!,
199
+ pluginId: installed.manifest.id,
200
+ pluginVersion: installed.manifest.version,
201
+ sourceType: "registry",
202
+ sourceRef: installed.packageRef,
203
+ integrity: installed.integrity ?? null,
204
+ buildHash: installed.buildHash,
205
+ artifactPath: installed.manifest.install?.artifactPath ?? null,
206
+ manifestJson: JSON.stringify(installed.manifest),
207
+ status: "active"
208
+ });
209
+ if (parsed.data.install) {
210
+ await updatePluginConfig(ctx.db, {
211
+ companyId: req.companyId!,
212
+ pluginId: installed.manifest.id,
213
+ enabled: false,
214
+ priority: 100,
215
+ configJson: "{}",
216
+ grantedCapabilitiesJson: "[]"
217
+ });
218
+ }
219
+ return sendOk(res, {
220
+ ok: true,
221
+ pluginId: installed.manifest.id,
222
+ installId,
223
+ installed: parsed.data.install,
224
+ manifestPath,
225
+ sourceType: "registry",
226
+ sourceRef: installed.packageRef
227
+ });
228
+ } catch (error) {
229
+ return sendError(res, `Failed to install package plugin: ${String(error)}`, 422);
156
230
  }
157
- return sendOk(res, { ok: true, pluginId: manifest.id, manifestPath, installed: parsed.data.install });
158
- });
159
-
160
- router.post("/:pluginId/install", async (req, res) => {
161
- if (!enforcePermission(req, res, "plugins:write")) return;
162
- const pluginId = readPluginIdParam(req.params.pluginId);
163
- if (!pluginId) {
164
- return sendError(res, "Missing plugin id.", 422);
165
- }
166
- const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
167
- const plugin = catalog.find((item) => item.id === pluginId);
168
- if (!plugin) {
169
- return sendError(res, `Plugin '${pluginId}' was not found. Restart API to refresh built-in plugins.`, 404);
170
- }
171
- const companyExists = companies.some((company) => company.id === req.companyId);
172
- if (!companyExists) {
173
- return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
174
- }
175
- await updatePluginConfig(ctx.db, {
176
- companyId: req.companyId!,
177
- pluginId,
178
- enabled: false,
179
- priority: 100,
180
- configJson: "{}",
181
- grantedCapabilitiesJson: "[]"
182
- });
183
- return sendOk(res, { ok: true, pluginId, installed: true, enabled: false });
184
- });
185
-
186
- router.delete("/:pluginId/install", async (req, res) => {
187
- if (!enforcePermission(req, res, "plugins:write")) return;
188
- const pluginId = readPluginIdParam(req.params.pluginId);
189
- if (!pluginId) {
190
- return sendError(res, "Missing plugin id.", 422);
191
- }
192
- const [catalog, companies] = await Promise.all([listPlugins(ctx.db), listCompanies(ctx.db)]);
193
- const plugin = catalog.find((item) => item.id === pluginId);
194
- if (!plugin) {
195
- return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
196
- }
197
- const companyExists = companies.some((company) => company.id === req.companyId);
198
- if (!companyExists) {
199
- return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
200
- }
201
- await deletePluginConfig(ctx.db, {
202
- companyId: req.companyId!,
203
- pluginId
204
- });
205
- return sendOk(res, { ok: true, pluginId, installed: false });
206
231
  });
207
232
 
208
233
  router.delete("/:pluginId", requireBoardRole, async (req, res) => {
@@ -215,9 +240,6 @@ export function createPluginsRouter(ctx: AppContext) {
215
240
  if (!plugin) {
216
241
  return sendError(res, `Plugin '${pluginId}' was not found.`, 404);
217
242
  }
218
- if (plugin.runtimeEntrypoint.startsWith("builtin:")) {
219
- return sendError(res, `Plugin '${pluginId}' is built-in and cannot be deleted.`, 400);
220
- }
221
243
  const companyExists = companies.some((company) => company.id === req.companyId);
222
244
  if (!companyExists) {
223
245
  return sendError(res, `Company '${req.companyId}' does not exist.`, 404);
@@ -246,6 +268,248 @@ export function createPluginsRouter(ctx: AppContext) {
246
268
  );
247
269
  });
248
270
 
271
+ router.get("/:pluginId/installs", async (req, res) => {
272
+ const pluginId = readPluginIdParam(req.params.pluginId);
273
+ if (!pluginId) {
274
+ return sendError(res, "Missing plugin id.", 422);
275
+ }
276
+ let rows: Awaited<ReturnType<typeof listPluginInstalls>>;
277
+ try {
278
+ rows = await listPluginInstalls(ctx.db, {
279
+ companyId: req.companyId!,
280
+ pluginId,
281
+ limit: typeof req.query.limit === "string" ? Number(req.query.limit) : undefined
282
+ });
283
+ } catch (error) {
284
+ if (isMissingPluginInstallsTableError(error)) {
285
+ return sendError(res, "Plugin version history is unavailable. Run database migrations.", 422);
286
+ }
287
+ throw error;
288
+ }
289
+ return sendOk(
290
+ res,
291
+ rows.map((row) => ({
292
+ ...row,
293
+ manifest: safeParseJsonObject(row.manifestJson)
294
+ }))
295
+ );
296
+ });
297
+
298
+ router.post("/:pluginId/rollback", async (req, res) => {
299
+ if (!enforcePermission(req, res, "plugins:write")) return;
300
+ const pluginId = readPluginIdParam(req.params.pluginId);
301
+ if (!pluginId) {
302
+ return sendError(res, "Missing plugin id.", 422);
303
+ }
304
+ const parsed = pluginRollbackSchema.safeParse(req.body);
305
+ if (!parsed.success) {
306
+ return sendError(res, parsed.error.message, 422);
307
+ }
308
+ let target: Awaited<ReturnType<typeof getPluginInstallById>>;
309
+ try {
310
+ target = await getPluginInstallById(ctx.db, {
311
+ companyId: req.companyId!,
312
+ pluginId,
313
+ installId: parsed.data.installId
314
+ });
315
+ } catch (error) {
316
+ if (isMissingPluginInstallsTableError(error)) {
317
+ return sendError(res, "Plugin rollback is unavailable. Run database migrations.", 422);
318
+ }
319
+ throw error;
320
+ }
321
+ if (!target) {
322
+ return sendError(res, `Plugin install '${parsed.data.installId}' was not found.`, 404);
323
+ }
324
+ const manifestParsed = PluginManifestV2Schema.safeParse(safeParseJsonObject(target.manifestJson));
325
+ if (!manifestParsed.success) {
326
+ return sendError(res, "Stored plugin install manifest is invalid.", 422);
327
+ }
328
+ await registerPluginManifest(ctx.db, manifestParsed.data);
329
+ try {
330
+ await markPluginInstallsSuperseded(ctx.db, { companyId: req.companyId!, pluginId });
331
+ await markPluginInstallStatus(ctx.db, {
332
+ companyId: req.companyId!,
333
+ pluginId,
334
+ installId: parsed.data.installId,
335
+ status: "active"
336
+ });
337
+ } catch (error) {
338
+ if (isMissingPluginInstallsTableError(error)) {
339
+ return sendError(res, "Plugin rollback is unavailable. Run database migrations.", 422);
340
+ }
341
+ throw error;
342
+ }
343
+ return sendOk(res, {
344
+ ok: true,
345
+ pluginId,
346
+ rollbackToInstallId: parsed.data.installId
347
+ });
348
+ });
349
+
350
+ router.post("/:pluginId/upgrade", async (req, res) => {
351
+ if (!enforcePermission(req, res, "plugins:write")) return;
352
+ const pluginId = readPluginIdParam(req.params.pluginId);
353
+ if (!pluginId) {
354
+ return sendError(res, "Missing plugin id.", 422);
355
+ }
356
+ const parsed = pluginUpgradeSchema.safeParse(req.body);
357
+ if (!parsed.success) {
358
+ return sendError(res, parsed.error.message, 422);
359
+ }
360
+ try {
361
+ const installed = await installPluginArtifactFromNpm({
362
+ packageName: parsed.data.packageName,
363
+ version: parsed.data.version
364
+ });
365
+ if (installed.manifest.id !== pluginId) {
366
+ return sendError(
367
+ res,
368
+ `Installed package manifest id '${installed.manifest.id}' does not match route plugin id '${pluginId}'.`,
369
+ 422
370
+ );
371
+ }
372
+ await writePackagedPluginManifestToFilesystem(installed.manifest, {
373
+ sourceType: "registry",
374
+ sourceRef: installed.packageRef,
375
+ integrity: installed.integrity,
376
+ buildHash: installed.buildHash
377
+ });
378
+ await registerPluginManifest(ctx.db, installed.manifest);
379
+ await markPluginInstallsSuperseded(ctx.db, {
380
+ companyId: req.companyId!,
381
+ pluginId
382
+ });
383
+ const installId = await appendPluginInstall(ctx.db, {
384
+ companyId: req.companyId!,
385
+ pluginId,
386
+ pluginVersion: installed.manifest.version,
387
+ sourceType: "registry",
388
+ sourceRef: installed.packageRef,
389
+ integrity: installed.integrity ?? null,
390
+ buildHash: installed.buildHash,
391
+ artifactPath: installed.manifest.install?.artifactPath ?? null,
392
+ manifestJson: JSON.stringify(installed.manifest),
393
+ status: "active"
394
+ });
395
+ return sendOk(res, {
396
+ ok: true,
397
+ pluginId,
398
+ installId,
399
+ upgradedToVersion: installed.manifest.version
400
+ });
401
+ } catch (error) {
402
+ return sendError(res, `Failed to upgrade plugin: ${String(error)}`, 422);
403
+ }
404
+ });
405
+
406
+ router.post("/:pluginId/actions/:actionKey", async (req, res) => {
407
+ if (!enforcePermission(req, res, "plugins:write")) return;
408
+ const pluginId = readPluginIdParam(req.params.pluginId);
409
+ const actionKey = readPluginIdParam(req.params.actionKey);
410
+ if (!pluginId || !actionKey) {
411
+ return sendError(res, "Missing plugin id or action key.", 422);
412
+ }
413
+ try {
414
+ const payload = typeof req.body === "object" && req.body !== null ? (req.body as Record<string, unknown>) : {};
415
+ const result = await invokePluginWorkerEndpoint(ctx.db, {
416
+ companyId: req.companyId!,
417
+ pluginId,
418
+ endpointType: "action",
419
+ endpointKey: actionKey,
420
+ payload
421
+ });
422
+ return sendOk(res, { ok: true, data: result });
423
+ } catch (error) {
424
+ return sendError(res, String(error), 422);
425
+ }
426
+ });
427
+
428
+ router.get("/:pluginId/health", async (req, res) => {
429
+ const pluginId = readPluginIdParam(req.params.pluginId);
430
+ if (!pluginId) {
431
+ return sendError(res, "Missing plugin id.", 422);
432
+ }
433
+ try {
434
+ const data = await invokePluginWorkerHealth(ctx.db, {
435
+ companyId: req.companyId!,
436
+ pluginId
437
+ });
438
+ return sendOk(res, { ok: true, data });
439
+ } catch (error) {
440
+ return sendError(res, String(error), 422);
441
+ }
442
+ });
443
+
444
+ router.post("/:pluginId/data/:dataKey", async (req, res) => {
445
+ const pluginId = readPluginIdParam(req.params.pluginId);
446
+ const dataKey = readPluginIdParam(req.params.dataKey);
447
+ if (!pluginId || !dataKey) {
448
+ return sendError(res, "Missing plugin id or data key.", 422);
449
+ }
450
+ try {
451
+ const payload = typeof req.body === "object" && req.body !== null ? (req.body as Record<string, unknown>) : {};
452
+ const result = await invokePluginWorkerEndpoint(ctx.db, {
453
+ companyId: req.companyId!,
454
+ pluginId,
455
+ endpointType: "data",
456
+ endpointKey: dataKey,
457
+ payload
458
+ });
459
+ return sendOk(res, { ok: true, data: result });
460
+ } catch (error) {
461
+ return sendError(res, String(error), 422);
462
+ }
463
+ });
464
+
465
+ router.post("/:pluginId/webhooks/:endpointKey", async (req, res) => {
466
+ const pluginId = readPluginIdParam(req.params.pluginId);
467
+ const endpointKey = readPluginIdParam(req.params.endpointKey);
468
+ if (!pluginId || !endpointKey) {
469
+ return sendError(res, "Missing plugin id or endpoint key.", 422);
470
+ }
471
+ try {
472
+ const payload = typeof req.body === "object" && req.body !== null ? (req.body as Record<string, unknown>) : {};
473
+ const headers: Record<string, string> = {};
474
+ for (const [key, value] of Object.entries(req.headers)) {
475
+ if (typeof value === "string") {
476
+ headers[key] = value;
477
+ }
478
+ }
479
+ const data = await invokePluginWorkerWebhook(ctx.db, {
480
+ companyId: req.companyId!,
481
+ pluginId,
482
+ endpointKey,
483
+ payload,
484
+ headers
485
+ });
486
+ return sendOk(res, { ok: true, data });
487
+ } catch (error) {
488
+ return sendError(res, String(error), 422);
489
+ }
490
+ });
491
+
492
+ router.get("/:pluginId/ui", async (req, res) => {
493
+ const pluginId = readPluginIdParam(req.params.pluginId);
494
+ if (!pluginId) {
495
+ return sendError(res, "Missing plugin id.", 422);
496
+ }
497
+ try {
498
+ const uiEntrypoint = await resolvePluginUiEntrypoint(ctx.db, {
499
+ companyId: req.companyId!,
500
+ pluginId
501
+ });
502
+ if (!uiEntrypoint) {
503
+ return sendError(res, `Plugin '${pluginId}' does not declare a UI entrypoint.`, 404);
504
+ }
505
+ const indexPath = uiEntrypoint.endsWith(".html") ? uiEntrypoint : resolve(uiEntrypoint, "index.html");
506
+ await access(indexPath);
507
+ return res.sendFile(indexPath);
508
+ } catch (error) {
509
+ return sendError(res, String(error), 422);
510
+ }
511
+ });
512
+
249
513
  return router;
250
514
  }
251
515
 
@@ -276,3 +540,29 @@ function safeParseJsonObject(value: string | null | undefined) {
276
540
  return {};
277
541
  }
278
542
  }
543
+
544
+ function isMissingPluginInstallsTableError(error: unknown) {
545
+ const visited = new Set<unknown>();
546
+ const queue: unknown[] = [error];
547
+ while (queue.length > 0) {
548
+ const current = queue.shift();
549
+ if (current === undefined || current === null || visited.has(current)) {
550
+ continue;
551
+ }
552
+ visited.add(current);
553
+ const message = String(current);
554
+ if (message.includes('relation "plugin_installs" does not exist')) {
555
+ return true;
556
+ }
557
+ if (typeof current === "object") {
558
+ const record = current as Record<string, unknown>;
559
+ if ("cause" in record) {
560
+ queue.push(record.cause);
561
+ }
562
+ if ("message" in record) {
563
+ queue.push(record.message);
564
+ }
565
+ }
566
+ }
567
+ return false;
568
+ }