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] 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 }) {