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.
- package/package.json +5 -5
- package/src/app.ts +4 -2
- package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
- package/src/assets/starter-packs/devrel-growth.zip +0 -0
- package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
- package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
- package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
- package/src/lib/agent-issue-permissions.ts +56 -0
- package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +23 -1
- package/src/routes/assistant.ts +40 -1
- package/src/routes/companies.ts +227 -15
- package/src/routes/issues.ts +82 -3
- package/src/routes/observability.ts +222 -0
- package/src/routes/plugins.ts +393 -103
- package/src/routes/{loops.ts → routines.ts} +72 -76
- package/src/scripts/onboard-seed.ts +2 -0
- package/src/server.ts +3 -1
- package/src/services/company-assistant-context-snapshot.ts +4 -2
- package/src/services/company-assistant-service.ts +17 -15
- package/src/services/company-file-archive-service.ts +81 -6
- package/src/services/company-file-import-service.ts +221 -31
- package/src/services/company-knowledge-file-service.ts +361 -0
- package/src/services/company-skill-file-service.ts +151 -2
- package/src/services/governance-service.ts +58 -3
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
- package/src/services/plugin-artifact-installer.ts +115 -0
- package/src/services/plugin-artifact-store.ts +28 -0
- package/src/services/plugin-capability-policy.ts +31 -0
- package/src/services/plugin-jobs-service.ts +74 -0
- package/src/services/plugin-manifest-loader.ts +78 -3
- package/src/services/plugin-rpc.ts +102 -0
- package/src/services/plugin-runtime.ts +240 -209
- package/src/services/plugin-worker-host.ts +167 -0
- package/src/services/starter-pack-registry.ts +68 -0
- package/src/services/template-apply-service.ts +3 -1
- package/src/services/template-catalog.ts +29 -0
- package/src/services/work-loop-service/work-loop-service.ts +18 -18
- package/src/shutdown/graceful-shutdown.ts +3 -1
- package/src/validation/issue-routes.ts +19 -2
- package/src/worker/scheduler.ts +21 -1
- package/src/services/company-export-service.ts +0 -63
package/src/routes/plugins.ts
CHANGED
|
@@ -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 {
|
|
5
|
+
import { PluginManifestV2Schema } from "bopodev-contracts";
|
|
4
6
|
import {
|
|
7
|
+
appendPluginInstall,
|
|
8
|
+
countPluginInstallRevisionsByCompany,
|
|
5
9
|
createApprovalRequest,
|
|
6
10
|
deletePluginById,
|
|
7
|
-
|
|
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 {
|
|
19
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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([
|
|
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
|
|
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
|
|
151
|
+
router.post("/install", async (req, res) => {
|
|
123
152
|
if (!enforcePermission(req, res, "plugins:write")) return;
|
|
124
|
-
const parsed =
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|