{"record":{"id":"84fdde3de98f5af7","repo":"paperclipai/paperclip","slug":"unable-to-resolve-company-for-plugin-api-route","errorCode":null,"errorMessage":"Unable to resolve company for plugin API route","messagePattern":"Unable to resolve company for plugin API route","errorType":"http","errorClass":null,"httpStatus":400,"severity":"error","filePath":"server/src/routes/plugins.ts","lineNumber":1875,"sourceCode":"      res.status(404).json({ error: \"Plugin does not expose scoped API routes\" });\n      return;\n    }\n\n    const requestPath = req.path || \"/\";\n    const routes = plugin.manifestJson.apiRoutes ?? [];\n    const match = routes\n      .map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) }))\n      .find((candidate) => candidate.params !== null);\n    if (!match || !match.params) {\n      res.status(404).json({ error: \"Plugin API route not found\" });\n      return;\n    }\n\n    try {\n      assertScopedApiAuth(req, match.route);\n      const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);\n      if (!companyId) {\n        res.status(400).json({ error: \"Unable to resolve company for plugin API route\" });\n        return;\n      }\n      assertCompanyAccess(req, companyId);\n      await enforceScopedApiCheckout(req, match.route, match.params, companyId);\n      if (req.method !== \"GET\" && req.headers[\"content-type\"] && !req.is(\"application/json\")) {\n        res.status(415).json({ error: \"Plugin API routes accept JSON requests only\" });\n        return;\n      }\n      const requestBody = req.body ?? null;\n      const bodySize = Buffer.byteLength(JSON.stringify(requestBody));\n      if (bodySize > PLUGIN_API_BODY_LIMIT_BYTES) {\n        res.status(413).json({ error: \"Plugin API request body is too large\" });\n        return;\n      }\n\n      const actor = getActorInfo(req);\n      const input: PluginScopedApiRequest = {\n        routeKey: match.route.routeKey,","sourceCodeStart":1857,"sourceCodeEnd":1893,"githubUrl":"https://github.com/paperclipai/paperclip/blob/a7e689b3c35347b529cb9f54c9b9a8575a3dcab6/server/src/routes/plugins.ts#L1857-L1893","documentation":"Returned as HTTP 400 when resolveScopedApiCompanyId cannot determine which company the scoped API call belongs to. The company is derived from the route's companyResolution declaration (from \"body\", \"query\", or a path param resolved through an issue lookup), or — when the route declares no companyResolution — from the authenticated agent actor's companyId. A board actor calling a route with no resolution always fails here, since board context has no single company.","triggerScenarios":"Route without companyResolution called by a board user; companyResolution {from:\"body\"} but the JSON body lacks the key (e.g. companyId) or it is not a string; {from:\"query\"} without the query param; path-param resolution where the issueId does not exist or belongs to a deleted issue (issue?.companyId is null).","commonSituations":"Operator testing an agent-oriented route from the board UI without passing companyId; client dropping the companyId field during a refactor; the target issue deleted between page load and the API call; agent token missing its companyId claim.","solutions":["Inspect manifestJson.apiRoutes[].companyResolution for the route to learn where companyId must come from.","Include the required value: companyId in the JSON body or query string, or use an issue-scoped path param whose issue actually exists.","For board actors on routes without a companyResolution there is no fallback — either call with an agent API key or extend the plugin manifest to declare body/query resolution."],"exampleFix":"// before — route declares companyResolution { from: \"body\", key: \"companyId\" }\nawait fetch(`/api/plugins/${id}/api/issues`, {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({ title: \"New issue\" }),\n});\n\n// after\nawait fetch(`/api/plugins/${id}/api/issues`, {\n  method: \"POST\",\n  headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({ companyId, title: \"New issue\" }),\n});","handlingStrategy":"validation","validationCode":"function buildScopedApiBody(\n  route: { companyResolution?: { from: string; key?: string } | null },\n  body: Record<string, unknown>,\n  agentCompanyId: string | undefined,\n): Record<string, unknown> {\n  const res = route.companyResolution;\n  if (!res) {\n    if (!agentCompanyId) throw new Error(\"Route has no companyResolution; caller must be an agent with a companyId\");\n    return body;\n  }\n  if (res.from === \"body\" && res.key && typeof body[res.key] !== \"string\") {\n    throw new Error(`Missing '${res.key}' in body for company resolution`);\n  }\n  return body;\n}","typeGuard":"interface CompanyResolution { from: \"body\" | \"query\" | \"issue\"; key?: string; param?: string }\nfunction hasCompanyResolution(route: { companyResolution?: CompanyResolution | null }): boolean {\n  return route.companyResolution != null;\n}","tryCatchPattern":null,"preventionTips":["Read the route's companyResolution from the manifest and always supply the declared source field.","When testing board-facing flows, verify the route declares body/query resolution before assuming board access works.","For issue-scoped routes, confirm the issue still exists before dispatching (it carries the company)."],"tags":["plugin","scoped-api","company-scope","http-400"],"backgroundTag":"company-scope-unresolved","analyzedSha":"a7e689b3c35347b529cb9f54c9b9a8575a3dcab6","analyzedAt":"2026-08-18T22:49:45.177Z","schemaVersion":2},"datasetVersion":"2026-08-22T04:17:13.399Z"}