From ca6a6cacf3783b4d69320395590eb3e5024603ea Mon Sep 17 00:00:00 2001 From: Waseem Ilyas <1478353+Waseemilyas@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:24:58 +0000 Subject: [PATCH 1/3] fix(server): 404 on unresolvable project refs instead of feeding them to the uuid column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The projects router accepts an id-or-shortname reference via router.param("id") and resolves shortnames through resolveByReference when a company scope is available > - When no company context exists, or the shortname matches nothing, the raw ref fell through to getById — a uuid column — and Postgres rejected it as an unhandled 500 > - This pull request turns both fall-throughs into the same 404 the route already returns for a missing uuid ## Linked Issues or Issue Description Refs #11660 — the reported repro (shortname + companyId) already resolves on master; this closes the residual 500 where the ref cannot be resolved at all. ## What Changed - server/src/routes/projects.ts: normalizeProjectReference now returns 404 "Project not found" when a non-uuid ref has no company context or matches no project, instead of passing the raw ref on to the uuid query. - server/src/__tests__/project-get-reference-routes.test.ts: route-level coverage — shortname resolves with companyId, unknown shortname 404s, a non-uuid ref without company context 404s, and a missing uuid still 404s. ## Verification - pnpm --filter @paperclipai/server vitest run src/__tests__/project-get-reference-routes.test.ts — 4 tests pass (embedded Postgres). ## Risks - Low risk. A non-uuid ref can never match the uuid primary key, so the only behavior change is 500 -> 404 on a request that could not succeed. ## Model Used - Anthropic Claude — SWE-2 Max agent via Devin CLI, tool use and code execution. Co-authored-by: Paperclip --- .../project-get-reference-routes.test.ts | 119 ++++++++++++++++++ server/src/routes/projects.ts | 7 +- 2 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 server/src/__tests__/project-get-reference-routes.test.ts diff --git a/server/src/__tests__/project-get-reference-routes.test.ts b/server/src/__tests__/project-get-reference-routes.test.ts new file mode 100644 index 0000000000..80179d0058 --- /dev/null +++ b/server/src/__tests__/project-get-reference-routes.test.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb, projects } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { projectRoutes } from "../routes/projects.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres project reference tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +function boardActor(companyId: string): Express.Request["actor"] { + return { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }; +} + +function createApp(db: ReturnType, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", projectRoutes(db)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("GET /projects/:id reference resolution", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-project-get-reference-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(); + const projectId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Demo Project", + status: "in_progress", + }); + return { companyId, projectId }; + } + + it("resolves a project shortname to the project when companyId is given", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/demo-project?companyId=${companyId}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(projectId); + }); + + it("returns 404 instead of 500 for an unknown shortname", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/no-such-project?companyId=${companyId}`); + + expect(res.status).toBe(404); + }); + + it("returns 404 instead of 500 for a non-uuid ref without company context", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get("/api/projects/demo-project"); + + expect(res.status).toBe(404); + }); + + it("still returns 404 for an unknown uuid", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/${randomUUID()}?companyId=${companyId}`); + + expect(res.status).toBe(404); + }); +}); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index f3226c7d5c..9d20e4d882 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -17,7 +17,7 @@ import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } fr import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; -import { conflict, forbidden, unprocessable } from "../errors.js"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; @@ -122,12 +122,13 @@ export function projectRoutes(db: Db) { async function normalizeProjectReference(req: Request, rawId: string) { if (isUuidLike(rawId)) return rawId; const companyId = await resolveCompanyIdForProjectReference(req); - if (!companyId) return rawId; + if (!companyId) throw notFound("Project not found"); const resolved = await svc.resolveByReference(companyId, rawId); if (resolved.ambiguous) { throw conflict("Project shortname is ambiguous in this company. Use the project ID."); } - return resolved.project?.id ?? rawId; + if (!resolved.project) throw notFound("Project not found"); + return resolved.project.id; } async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) { From 5d66168ba6f9a4fb5c123ea9228e1c19ad0cce8f Mon Sep 17 00:00:00 2001 From: Waseem Ilyas <1478353+Waseemilyas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:23:56 +0000 Subject: [PATCH 2/3] fix(server): resolve company context for single-company actors on project refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board actor with exactly one membership can resolve non-UUID project references without a ?companyId= query — shortnames only resolve inside a company anyway. Keeps the 404 for refs that still cannot resolve, and updates route-test mocks to model resolveByReference's real behavior instead of relying on the unreachable getById fall-through. Co-authored-by: Paperclip --- .../__tests__/environment-selection-route-guards.test.ts | 5 +++++ server/src/__tests__/project-routes-env.test.ts | 2 +- .../project-workspace-managed-sandbox-routes.test.ts | 2 +- server/src/routes/projects.ts | 6 +++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 0f36d4e512..7a9fb2c39e 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -117,6 +117,7 @@ function buildApp(routerFactory: (app: express.Express) => void) { (req as any).actor = { type: "board", userId: "user-1", + companyIds: ["company-1"], source: "local_implicit", }; next(); @@ -170,6 +171,10 @@ describe.sequential("execution environment route guards", () => { mockProjectService.createWorkspace.mockReset(); mockProjectService.remove.mockReset(); mockProjectService.resolveByReference.mockReset(); + mockProjectService.resolveByReference.mockResolvedValue({ + ambiguous: false, + project: { id: "project-1" }, + }); mockProjectService.listWorkspaces.mockReset(); mockIssueService.create.mockReset(); mockIssueService.getById.mockReset(); diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index d68576a73f..4cb93dc2e8 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -158,7 +158,7 @@ describe("project env routes", () => { explanation: "Allowed by test mock.", }); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); - mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: buildProject() }); mockProjectService.createWorkspace.mockResolvedValue(null); mockProjectService.listWorkspaces.mockResolvedValue([]); mockEnvironmentService.getById.mockReset(); diff --git a/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts b/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts index 5623fc6b07..b6324d4e26 100644 --- a/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts +++ b/server/src/__tests__/project-workspace-managed-sandbox-routes.test.ts @@ -203,7 +203,7 @@ describe("project workspace host-path floor", () => { explanation: "Allowed by test mock.", }); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); - mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: buildProject() }); mockProjectService.getById.mockResolvedValue(buildProject()); mockProjectService.create.mockResolvedValue(buildProject()); mockProjectService.createWorkspace.mockResolvedValue(buildWorkspace()); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 9d20e4d882..7a5d814935 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -116,7 +116,11 @@ export function projectRoutes(db: Db) { if (req.actor.type === "agent" && req.actor.companyId) { return req.actor.companyId; } - return null; + // A single-company actor (the common self-hosted case) has an unambiguous + // company context without a `?companyId=` query — shortnames only resolve + // inside one company anyway, so require exactly one. + const actorCompanyIds = req.actor.companyIds ?? []; + return actorCompanyIds.length === 1 ? actorCompanyIds[0] : null; } async function normalizeProjectReference(req: Request, rawId: string) { From ba82810150064c170a1e8946dbe41b32f06738eb Mon Sep 17 00:00:00 2001 From: Waseem Ilyas <1478353+Waseemilyas@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:29:12 +0000 Subject: [PATCH 3/3] test(server): cover single-company shortname resolution; fix the no-context case Co-authored-by: Paperclip --- .../project-get-reference-routes.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/project-get-reference-routes.test.ts b/server/src/__tests__/project-get-reference-routes.test.ts index 80179d0058..99a8362563 100644 --- a/server/src/__tests__/project-get-reference-routes.test.ts +++ b/server/src/__tests__/project-get-reference-routes.test.ts @@ -100,14 +100,31 @@ describeEmbeddedPostgres("GET /projects/:id reference resolution", () => { }); it("returns 404 instead of 500 for a non-uuid ref without company context", async () => { - const { companyId } = await seed(); - const app = createApp(db, boardActor(companyId)); + await seed(); + const app = createApp(db, { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: true, + companyIds: [], + memberships: [], + }); const res = await request(app).get("/api/projects/demo-project"); expect(res.status).toBe(404); }); + it("resolves a shortname for a single-company actor without ?companyId=", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get("/api/projects/demo-project"); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(projectId); + }); + it("still returns 404 for an unknown uuid", async () => { const { companyId } = await seed(); const app = createApp(db, boardActor(companyId));