@agentprojectcontext/apx 1.58.0 → 1.60.0

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/skills/index.js +9 -0
  3. package/src/core/agent/skills/inspector.js +7 -2
  4. package/src/core/agent/skills/policy.js +128 -0
  5. package/src/core/agent/super-agent.js +8 -1
  6. package/src/core/agent/tools/handlers/_asana.js +34 -0
  7. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  8. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  9. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  10. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  11. package/src/core/agent/tools/handlers/list-skills.js +6 -3
  12. package/src/core/agent/tools/handlers/load-skill.js +9 -2
  13. package/src/core/agent/tools/names.js +10 -0
  14. package/src/core/agent/tools/registry.js +11 -0
  15. package/src/core/integrations/catalog.js +66 -0
  16. package/src/core/integrations/index.js +10 -0
  17. package/src/core/integrations/plugins/asana.js +231 -0
  18. package/src/core/integrations/sources.js +56 -0
  19. package/src/core/integrations/store.js +118 -0
  20. package/src/host/daemon/api/integrations.js +191 -0
  21. package/src/host/daemon/api/skills.js +301 -17
  22. package/src/host/daemon/api.js +2 -0
  23. package/src/interfaces/cli/commands/skills.js +3 -2
  24. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +761 -0
  25. package/src/interfaces/web/dist/assets/{index-DPAuXATr.js.map → index-DFNV6BWh.js.map} +1 -1
  26. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  27. package/src/interfaces/web/dist/index.html +2 -2
  28. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  29. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  30. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  31. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  32. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
  33. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
  34. package/src/interfaces/web/src/i18n/en.ts +67 -0
  35. package/src/interfaces/web/src/i18n/es.ts +67 -0
  36. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  37. package/src/interfaces/web/src/lib/api/skills.ts +79 -8
  38. package/src/interfaces/web/src/lib/api.ts +1 -0
  39. package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -4
  40. package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
  41. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  42. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
  43. package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +0 -1
  44. package/src/interfaces/web/dist/assets/index-DPAuXATr.js +0 -705
@@ -0,0 +1,231 @@
1
+ // Asana integration plugin — ported from PandaProject's asana_service.py.
2
+ //
3
+ // Two halves:
4
+ // 1. A thin Asana REST client (Personal Access Token auth) — pure HTTP, no
5
+ // knowledge of storage. Used by both the lifecycle below and the agent
6
+ // tools in core/agent/tools/handlers/asana.js.
7
+ // 2. A plugin descriptor implementing the shared lifecycle contract
8
+ // (configure / validate / status / deactivate / actions) that the daemon
9
+ // API dispatches to. Lifecycle methods take a stored record and return a
10
+ // patch to persist — they never touch the filesystem themselves (SRP).
11
+ //
12
+ // Contract shared by every plugin (see catalog.js):
13
+ // configure(record, body) -> { patch } (sync)
14
+ // validate(record) -> Promise<{ patch, result }> (async, hits API)
15
+ // status(record) -> statusObject (sync, pure)
16
+ // deactivate(record) -> { patch } (sync)
17
+ // actions: { <name>(record) -> Promise<any> } (async reads)
18
+
19
+ const ASANA_API_BASE = "https://app.asana.com/api/1.0";
20
+ const REQUEST_TIMEOUT_MS = 15_000;
21
+
22
+ // ─── HTTP helpers ────────────────────────────────────────────────────────────
23
+
24
+ function headers(token) {
25
+ return {
26
+ Authorization: `Bearer ${token}`,
27
+ Accept: "application/json",
28
+ "Content-Type": "application/json",
29
+ };
30
+ }
31
+
32
+ async function request(token, method, apiPath, { params, payload } = {}) {
33
+ let url = `${ASANA_API_BASE}${apiPath}`;
34
+ if (params) {
35
+ const qs = new URLSearchParams();
36
+ for (const [k, v] of Object.entries(params)) {
37
+ if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
38
+ }
39
+ const s = qs.toString();
40
+ if (s) url += `?${s}`;
41
+ }
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
44
+ let res;
45
+ try {
46
+ res = await fetch(url, {
47
+ method,
48
+ headers: headers(token),
49
+ body: payload !== undefined ? JSON.stringify({ data: payload }) : undefined,
50
+ signal: controller.signal,
51
+ });
52
+ } catch (e) {
53
+ clearTimeout(timer);
54
+ if (e.name === "AbortError") throw new Error("Asana request timed out");
55
+ throw new Error(`Asana request failed: ${e.message}`);
56
+ }
57
+ clearTimeout(timer);
58
+ if (!res.ok) {
59
+ let detail = `${res.status}`;
60
+ try {
61
+ const body = await res.json();
62
+ const first = Array.isArray(body?.errors) ? body.errors[0]?.message : null;
63
+ detail = first || JSON.stringify(body);
64
+ } catch {
65
+ /* non-JSON error body */
66
+ }
67
+ throw new Error(`Asana API ${res.status}: ${detail}`);
68
+ }
69
+ return res.json();
70
+ }
71
+
72
+ // ─── REST client (mirrors asana_service.py) ───────────────────────────────────
73
+
74
+ export async function validateToken(token) {
75
+ const result = await request(token, "GET", "/users/me");
76
+ const user = result?.data || {};
77
+ if (!user.gid) throw new Error("Asana returned an empty user response");
78
+ return user;
79
+ }
80
+
81
+ export async function getWorkspaces(token) {
82
+ const result = await request(token, "GET", "/workspaces");
83
+ return result?.data || [];
84
+ }
85
+
86
+ export async function listProjects(token, workspaceGid) {
87
+ const result = await request(token, "GET", "/projects", {
88
+ params: { workspace: workspaceGid, opt_fields: "gid,name,color,archived,permalink_url" },
89
+ });
90
+ return result?.data || [];
91
+ }
92
+
93
+ export async function listTasks(token, projectGid, completed = false) {
94
+ const result = await request(token, "GET", `/projects/${projectGid}/tasks`, {
95
+ params: {
96
+ opt_fields: "gid,name,completed,due_on,assignee.name,notes,permalink_url",
97
+ completed_since: completed ? "" : "now",
98
+ },
99
+ });
100
+ return result?.data || [];
101
+ }
102
+
103
+ export async function createTask(token, { workspaceGid, name, notes = "", projectGid, assignee, dueOn } = {}) {
104
+ const payload = { name, workspace: workspaceGid };
105
+ if (notes) payload.notes = notes;
106
+ if (projectGid) payload.projects = [projectGid];
107
+ if (assignee) payload.assignee = assignee;
108
+ if (dueOn) payload.due_on = dueOn;
109
+ const result = await request(token, "POST", "/tasks", { payload });
110
+ return result?.data || {};
111
+ }
112
+
113
+ export async function updateTask(token, taskGid, { name, notes, completed, dueOn, assignee } = {}) {
114
+ const payload = {};
115
+ if (name !== undefined) payload.name = name;
116
+ if (notes !== undefined) payload.notes = notes;
117
+ if (completed !== undefined) payload.completed = completed;
118
+ if (dueOn !== undefined) payload.due_on = dueOn;
119
+ if (assignee !== undefined) payload.assignee = assignee;
120
+ const result = await request(token, "PUT", `/tasks/${taskGid}`, { payload });
121
+ return result?.data || {};
122
+ }
123
+
124
+ // ─── Plugin descriptor + lifecycle ────────────────────────────────────────────
125
+
126
+ function safeToken(record) {
127
+ const token = record?.config?.personal_access_token || "";
128
+ if (!token) throw new Error("Asana token not configured");
129
+ return token;
130
+ }
131
+
132
+ export const asanaPlugin = {
133
+ slug: "asana",
134
+ name: "Asana",
135
+ type: "project_management",
136
+ description: "Conectá tu workspace de Asana para que los agentes creen, actualicen y consulten tareas",
137
+ auth: "token",
138
+ tools: [
139
+ { slug: "asana_list_projects", desc: "Listar proyectos del workspace" },
140
+ { slug: "asana_list_tasks", desc: "Listar tareas de un proyecto" },
141
+ { slug: "asana_create_task", desc: "Crear una tarea" },
142
+ { slug: "asana_update_task", desc: "Actualizar estado o campos de una tarea" },
143
+ ],
144
+
145
+ // Save the PAT and/or the target workspace. Returns a patch to persist.
146
+ configure(record, body = {}) {
147
+ const pat = (body.personal_access_token || "").trim();
148
+ const workspaceGid = (body.workspace_gid || "").trim();
149
+ if (!pat && !workspaceGid && !record) {
150
+ throw new Error("Provide personal_access_token or workspace_gid");
151
+ }
152
+ const config = {};
153
+ if (pat) config.personal_access_token = pat;
154
+ if (workspaceGid) config.workspace_gid = workspaceGid;
155
+ const patch = {
156
+ name: "Asana",
157
+ type: this.type,
158
+ description: this.description,
159
+ config,
160
+ };
161
+ // Saving a fresh token means the connection is not yet verified.
162
+ if (pat) patch.status = "pending_validation";
163
+ return { patch };
164
+ },
165
+
166
+ // Verify the PAT against Asana and resolve user + workspace metadata.
167
+ async validate(record) {
168
+ const token = safeToken(record);
169
+ let user;
170
+ try {
171
+ user = await validateToken(token);
172
+ } catch (e) {
173
+ return {
174
+ patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
175
+ result: { ok: false, error: String(e.message || e) },
176
+ };
177
+ }
178
+ const config = { user_name: user.name || null, user_email: user.email || null, last_error: null };
179
+ let workspaceGid = record?.config?.workspace_gid || null;
180
+ try {
181
+ const workspaces = await getWorkspaces(token);
182
+ if (workspaceGid) {
183
+ const match = workspaces.find((w) => w.gid === workspaceGid);
184
+ if (match) config.workspace_name = match.name;
185
+ } else if (workspaces.length === 1) {
186
+ // Auto-select when the token owner has exactly one workspace.
187
+ config.workspace_gid = workspaces[0].gid;
188
+ config.workspace_name = workspaces[0].name;
189
+ workspaceGid = workspaces[0].gid;
190
+ }
191
+ } catch {
192
+ /* workspace resolution is best-effort */
193
+ }
194
+ return {
195
+ patch: { status: "active", is_enabled: true, config },
196
+ result: {
197
+ ok: true,
198
+ user_name: config.user_name,
199
+ user_email: config.user_email,
200
+ workspace_gid: config.workspace_gid || workspaceGid,
201
+ workspace_name: config.workspace_name || null,
202
+ },
203
+ };
204
+ },
205
+
206
+ status(record) {
207
+ const config = record?.config || {};
208
+ return {
209
+ slug: this.slug,
210
+ status: record?.status || "disconnected",
211
+ is_enabled: !!record?.is_enabled,
212
+ user_name: config.user_name || null,
213
+ user_email: config.user_email || null,
214
+ workspace_gid: config.workspace_gid || null,
215
+ workspace_name: config.workspace_name || null,
216
+ };
217
+ },
218
+
219
+ deactivate() {
220
+ return { patch: { status: "inactive", is_enabled: false } };
221
+ },
222
+
223
+ actions: {
224
+ async workspaces(record) {
225
+ const token = safeToken(record);
226
+ return { workspaces: await getWorkspaces(token) };
227
+ },
228
+ },
229
+ };
230
+
231
+ export default asanaPlugin;
@@ -0,0 +1,56 @@
1
+ // Integration storage. Integrations are per-project records that hold plugin
2
+ // credentials + resolved metadata (Asana PAT, workspace gid, connected user…).
3
+ //
4
+ // Scoping model (see IntegrationStore + resolveIntegration in ./store.js):
5
+ // - Every integration lives in ONE project's integrations.json.
6
+ // - "Global" integrations are simply the ones stored under the DEFAULT project
7
+ // (~/.apx/projects/default/integrations.json). There is no separate global
8
+ // file: this keeps "which Asana does project X use?" unambiguous — a project
9
+ // uses its own record if present, otherwise it falls back to the default's.
10
+ //
11
+ // The file may contain tokens, so it is written chmod 0600 exactly like the
12
+ // runtime MCP + vars stores.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { DEFAULT_PROJECT_STORE } from "#core/config/index.js";
16
+
17
+ const INTEGRATIONS_FILENAME = "integrations.json";
18
+
19
+ // Absolute path to a project's integrations.json given its storagePath
20
+ // (~/.apx/projects/<apxId>/). Returns null when no storagePath is available.
21
+ export function integrationsPath(storagePath) {
22
+ if (!storagePath) return null;
23
+ return path.join(storagePath, INTEGRATIONS_FILENAME);
24
+ }
25
+
26
+ // The storagePath of the DEFAULT project — the home of "global" integrations.
27
+ export function defaultIntegrationsStorage() {
28
+ return DEFAULT_PROJECT_STORE;
29
+ }
30
+
31
+ // Read the raw array of integration records for a project. Always returns an
32
+ // array (missing/corrupt file → []).
33
+ export function readIntegrations(storagePath) {
34
+ const p = integrationsPath(storagePath);
35
+ if (!p || !fs.existsSync(p)) return [];
36
+ try {
37
+ const json = JSON.parse(fs.readFileSync(p, "utf8"));
38
+ return Array.isArray(json) ? json : [];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ // Persist the array of integration records for a project. Creates the storage
45
+ // directory if needed and locks the file to 0600 (tokens live here).
46
+ export function writeIntegrations(storagePath, entries) {
47
+ const p = integrationsPath(storagePath);
48
+ if (!p) throw new Error("writeIntegrations: storagePath required");
49
+ fs.mkdirSync(path.dirname(p), { recursive: true });
50
+ fs.writeFileSync(p, JSON.stringify(entries, null, 2) + "\n");
51
+ try {
52
+ fs.chmodSync(p, 0o600);
53
+ } catch {
54
+ // best-effort on non-POSIX filesystems (Windows) — ignore.
55
+ }
56
+ }
@@ -0,0 +1,118 @@
1
+ // IntegrationStore: CRUD over one project's integrations.json, plus the
2
+ // cross-scope resolution used by agent tools. Pure filesystem + data logic —
3
+ // no Express, no plugin HTTP calls (those live in ./plugins/*). This keeps the
4
+ // storage layer testable in isolation (see tests/integrations.test.js).
5
+ import {
6
+ readIntegrations,
7
+ writeIntegrations,
8
+ defaultIntegrationsStorage,
9
+ } from "./sources.js";
10
+
11
+ // Keys inside `config` that hold secrets — never sent to the web UI in clear.
12
+ const SECRET_KEYS = new Set([
13
+ "personal_access_token",
14
+ "token",
15
+ "api_key",
16
+ "client_secret",
17
+ ]);
18
+
19
+ // Return a shallow copy of a record with secret config values masked, safe to
20
+ // send to the browser. A present secret becomes `true` under `config.<key>_set`
21
+ // so the UI can show "configured" without leaking the value.
22
+ export function redactRecord(record) {
23
+ if (!record) return record;
24
+ const config = { ...(record.config || {}) };
25
+ for (const key of Object.keys(config)) {
26
+ if (SECRET_KEYS.has(key)) {
27
+ const hasValue = typeof config[key] === "string" && config[key].length > 0;
28
+ delete config[key];
29
+ config[`${key}_set`] = hasValue;
30
+ }
31
+ }
32
+ return { ...record, config };
33
+ }
34
+
35
+ export class IntegrationStore {
36
+ // `storagePath` is a project's ~/.apx/projects/<apxId>/ directory.
37
+ constructor(storagePath) {
38
+ if (!storagePath) throw new Error("IntegrationStore: storagePath required");
39
+ this.storagePath = storagePath;
40
+ }
41
+
42
+ list() {
43
+ return readIntegrations(this.storagePath);
44
+ }
45
+
46
+ get(slug) {
47
+ return this.list().find((r) => r.slug === slug) || null;
48
+ }
49
+
50
+ // Insert or merge a record by slug. `patch` is shallow-merged; `patch.config`
51
+ // is deep-merged one level so callers can update a single config key without
52
+ // clobbering the token. Returns the persisted record.
53
+ upsert(slug, patch = {}) {
54
+ const entries = this.list();
55
+ const idx = entries.findIndex((r) => r.slug === slug);
56
+ const now = new Date().toISOString();
57
+ if (idx === -1) {
58
+ const record = {
59
+ slug,
60
+ name: patch.name || slug,
61
+ type: patch.type || "custom",
62
+ description: patch.description || "",
63
+ source: patch.source || "builtin",
64
+ status: patch.status || "disconnected",
65
+ is_enabled: patch.is_enabled ?? false,
66
+ config: patch.config || {},
67
+ created_at: now,
68
+ updated_at: now,
69
+ };
70
+ entries.push(record);
71
+ writeIntegrations(this.storagePath, entries);
72
+ return record;
73
+ }
74
+ const prev = entries[idx];
75
+ const merged = {
76
+ ...prev,
77
+ ...patch,
78
+ config: { ...(prev.config || {}), ...(patch.config || {}) },
79
+ updated_at: now,
80
+ };
81
+ entries[idx] = merged;
82
+ writeIntegrations(this.storagePath, entries);
83
+ return merged;
84
+ }
85
+
86
+ remove(slug) {
87
+ const entries = this.list();
88
+ const next = entries.filter((r) => r.slug !== slug);
89
+ if (next.length === entries.length) return false;
90
+ writeIntegrations(this.storagePath, next);
91
+ return true;
92
+ }
93
+ }
94
+
95
+ // Resolve the effective integration for `slug` in a project, applying the
96
+ // project→default precedence: a project's OWN enabled record wins; otherwise
97
+ // the default project's enabled record is used. Returns { record, scope } or
98
+ // null when neither is usable. `defaultStorage` defaults to the default
99
+ // project's storage so callers only need the current project's storagePath.
100
+ export function resolveIntegration({
101
+ projectStorage,
102
+ slug,
103
+ defaultStorage = defaultIntegrationsStorage(),
104
+ requireEnabled = true,
105
+ }) {
106
+ const usable = (record) =>
107
+ !!record && (!requireEnabled || (record.is_enabled && record.status === "active"));
108
+
109
+ if (projectStorage) {
110
+ const own = new IntegrationStore(projectStorage).get(slug);
111
+ if (usable(own)) return { record: own, scope: "project", storagePath: projectStorage };
112
+ }
113
+ if (defaultStorage && defaultStorage !== projectStorage) {
114
+ const fallback = new IntegrationStore(defaultStorage).get(slug);
115
+ if (usable(fallback)) return { record: fallback, scope: "global", storagePath: defaultStorage };
116
+ }
117
+ return null;
118
+ }
@@ -0,0 +1,191 @@
1
+ // Integration plugins per project. Companion to api/mcps.js: MCP servers are
2
+ // raw tool endpoints; integrations are higher-level plugins (Asana today) that
3
+ // own a credential + lifecycle and expose named tools to agents.
4
+ //
5
+ // Scoping (see core/integrations/store.js): every record lives in one project's
6
+ // integrations.json. `?scope=global` targets the DEFAULT project's store, so
7
+ // "global" integrations are literally the default project's — a project without
8
+ // its own record falls back to that one. This keeps "which Asana runs here?"
9
+ // unambiguous when you have both a base Asana and a per-project Asana.
10
+ //
11
+ // GET /projects/:pid/integrations?scope=project|global list stored (redacted)
12
+ // GET /projects/:pid/integrations/catalog roster + resolved status
13
+ // GET /projects/:pid/integrations/:slug?scope=… one plugin status
14
+ // POST /projects/:pid/integrations/:slug/configure?scope=… save credentials
15
+ // POST /projects/:pid/integrations/:slug/validate?scope=… verify against provider
16
+ // POST /projects/:pid/integrations/:slug/deactivate?scope=… disable
17
+ // POST /projects/:pid/integrations/:slug/action/:action?scope=… plugin read action
18
+ // DELETE /projects/:pid/integrations/:slug?scope=… remove
19
+ import {
20
+ IntegrationStore,
21
+ resolveIntegration,
22
+ redactRecord,
23
+ defaultIntegrationsStorage,
24
+ listCatalog,
25
+ getPluginService,
26
+ } from "#core/integrations/index.js";
27
+
28
+ function normalizeScope(raw) {
29
+ if (!raw) return "project";
30
+ const s = String(raw).toLowerCase();
31
+ if (s === "global" || s === "default") return "global";
32
+ if (s === "project" || s === "shared" || s === "runtime") return "project";
33
+ return null;
34
+ }
35
+
36
+ // Resolve the storagePath for the requested scope. `global` → default project
37
+ // store; `project` → the current project's store.
38
+ function storagePathForScope(scope, p, projects) {
39
+ if (scope === "global") {
40
+ const base = projects.get(0);
41
+ return base?.storagePath || defaultIntegrationsStorage();
42
+ }
43
+ return p.storagePath || null;
44
+ }
45
+
46
+ export function register(app, { projects, project }) {
47
+ // List stored integrations in the chosen scope (secrets redacted).
48
+ app.get("/projects/:pid/integrations", (req, res) => {
49
+ const p = project(req, res);
50
+ if (!p) return;
51
+ const scope = normalizeScope(req.query?.scope);
52
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
53
+ const storagePath = storagePathForScope(scope, p, projects);
54
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
55
+ const records = new IntegrationStore(storagePath).list().map(redactRecord);
56
+ res.json(records);
57
+ });
58
+
59
+ // The full plugin roster with each plugin's resolved status for this project
60
+ // (project record wins over the default/global one).
61
+ app.get("/projects/:pid/integrations/catalog", (req, res) => {
62
+ const p = project(req, res);
63
+ if (!p) return;
64
+ const catalog = listCatalog().map((entry) => {
65
+ const svc = getPluginService(entry.slug);
66
+ let status = { slug: entry.slug, status: "disconnected", is_enabled: false };
67
+ let scope = null;
68
+ if (svc) {
69
+ const resolved = resolveIntegration({
70
+ projectStorage: p.storagePath,
71
+ slug: entry.slug,
72
+ requireEnabled: false,
73
+ });
74
+ status = svc.status(resolved?.record || null);
75
+ scope = resolved?.scope || null;
76
+ }
77
+ return { ...entry, status, resolved_scope: scope };
78
+ });
79
+ res.json(catalog);
80
+ });
81
+
82
+ // Status for a single plugin in the chosen scope.
83
+ app.get("/projects/:pid/integrations/:slug", (req, res) => {
84
+ const p = project(req, res);
85
+ if (!p) return;
86
+ const svc = getPluginService(req.params.slug);
87
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
88
+ const scope = normalizeScope(req.query?.scope);
89
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
90
+ const storagePath = storagePathForScope(scope, p, projects);
91
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
92
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
93
+ res.json(svc.status(record));
94
+ });
95
+
96
+ // Save credentials / config. Creates the record if missing.
97
+ app.post("/projects/:pid/integrations/:slug/configure", (req, res) => {
98
+ const p = project(req, res);
99
+ if (!p) return;
100
+ const svc = getPluginService(req.params.slug);
101
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
102
+ const scope = normalizeScope(req.query?.scope);
103
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
104
+ const storagePath = storagePathForScope(scope, p, projects);
105
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
106
+ const store = new IntegrationStore(storagePath);
107
+ try {
108
+ const { patch } = svc.configure(store.get(req.params.slug), req.body || {});
109
+ const record = store.upsert(req.params.slug, patch);
110
+ res.status(201).json(redactRecord(record));
111
+ } catch (e) {
112
+ res.status(400).json({ error: e.message });
113
+ }
114
+ });
115
+
116
+ // Verify the stored credentials against the provider, then persist the result.
117
+ app.post("/projects/:pid/integrations/:slug/validate", async (req, res) => {
118
+ const p = project(req, res);
119
+ if (!p) return;
120
+ const svc = getPluginService(req.params.slug);
121
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
122
+ const scope = normalizeScope(req.query?.scope);
123
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
124
+ const storagePath = storagePathForScope(scope, p, projects);
125
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
126
+ const store = new IntegrationStore(storagePath);
127
+ const record = store.get(req.params.slug);
128
+ if (!record) return res.status(404).json({ error: "integration not configured" });
129
+ try {
130
+ const { patch, result } = await svc.validate(record);
131
+ store.upsert(req.params.slug, patch);
132
+ if (result && result.ok === false) return res.status(400).json(result);
133
+ res.json(result);
134
+ } catch (e) {
135
+ res.status(400).json({ error: e.message });
136
+ }
137
+ });
138
+
139
+ // Disable a plugin without deleting its stored credentials.
140
+ app.post("/projects/:pid/integrations/:slug/deactivate", (req, res) => {
141
+ const p = project(req, res);
142
+ if (!p) return;
143
+ const svc = getPluginService(req.params.slug);
144
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
145
+ const scope = normalizeScope(req.query?.scope);
146
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
147
+ const storagePath = storagePathForScope(scope, p, projects);
148
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
149
+ const store = new IntegrationStore(storagePath);
150
+ if (!store.get(req.params.slug)) return res.status(404).json({ error: "integration not configured" });
151
+ const { patch } = svc.deactivate(store.get(req.params.slug));
152
+ const record = store.upsert(req.params.slug, patch);
153
+ res.json(svc.status(record));
154
+ });
155
+
156
+ // Plugin-specific read action (e.g. Asana → list workspaces for the token).
157
+ app.post("/projects/:pid/integrations/:slug/action/:action", async (req, res) => {
158
+ const p = project(req, res);
159
+ if (!p) return;
160
+ const svc = getPluginService(req.params.slug);
161
+ if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
162
+ const fn = svc.actions?.[req.params.action];
163
+ if (typeof fn !== "function") {
164
+ return res.status(404).json({ error: `unknown action "${req.params.action}"` });
165
+ }
166
+ const scope = normalizeScope(req.query?.scope);
167
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
168
+ const storagePath = storagePathForScope(scope, p, projects);
169
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
170
+ const record = new IntegrationStore(storagePath).get(req.params.slug);
171
+ if (!record) return res.status(404).json({ error: "integration not configured" });
172
+ try {
173
+ res.json(await fn.call(svc.actions, record));
174
+ } catch (e) {
175
+ res.status(400).json({ error: e.message });
176
+ }
177
+ });
178
+
179
+ // Remove a stored integration entirely.
180
+ app.delete("/projects/:pid/integrations/:slug", (req, res) => {
181
+ const p = project(req, res);
182
+ if (!p) return;
183
+ const scope = normalizeScope(req.query?.scope);
184
+ if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
185
+ const storagePath = storagePathForScope(scope, p, projects);
186
+ if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
187
+ const removed = new IntegrationStore(storagePath).remove(req.params.slug);
188
+ if (!removed) return res.status(404).end();
189
+ res.status(204).end();
190
+ });
191
+ }