fix(server): validate project goal ids exist and belong to the company (#12779)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Projects can link to goals, through the `goalIds` list or the legacy
`goalId` field. The project service writes those links on create and
update.
> - The service never checked the goal ids. A nonexistent id died at the
`projects.goal_id` foreign key as an opaque 500, and the caller got no
actionable feedback — observed live on 2026-09-03, where one caller
retried the same bad id four times.
> - The foreign key also only proves a goal exists, not who owns it. A
goal id from another company linked silently on a multi-company
instance.
> - This pull request asserts every resolved goal id exists under the
caller's company before any write, and rejects with a 422 that names the
unknown ids.
> - The benefit is a clear, actionable client error instead of a 500,
and no cross-company goal links.

## Linked Issues or Issue Description

**What happened?**

`POST /companies/:companyId/projects` with a `goalIds` entry that does
not exist fails with an internal error: `insert or update on table
"projects" violates foreign key constraint
"projects_goal_id_goals_id_fk"`. The caller sees a 500 and retries. A
goal id that exists but belongs to a different company is accepted and
linked.

**Expected behavior**

The request fails fast with a 422 that names the unknown goal id(s).
Goals from other companies are rejected the same way. Valid links behave
exactly as before.

**Steps to reproduce**

1. Create a company and no goals.
2. `POST /companies/:companyId/projects` with `{ "name": "Rocket",
"goalIds": ["<any-uuid>"] }`.
3. Before this change: 500 from the foreign key. After: 422 naming the
id.

**Deployment mode**

Any; observed on an authenticated public deployment.

## What Changed

- `assertGoalsBelongToCompany` in the project service: one query for the
resolved ids scoped to the company; unknown ids produce `unprocessable`
(422) with the ids in the message and details
- called on create (before the project row insert, so no partial writes)
and on update (scoped to the existing project's company); both `goalIds`
and the legacy `goalId` field flow through the same resolution
- new embedded-Postgres test file: valid link, nonexistent id on create
with no partial insert, legacy field, another company's goal on create,
and a foreign-goal update that leaves existing links unchanged

## Verification

- `pnpm vitest run src/__tests__/project-goal-validation.test.ts` — 5
passed
- adjacent suites (`project-icon-persistence`,
`project-shortname-resolution`, `issue-goal-fallback`,
`project-goal-telemetry-routes`, `heartbeat-referenced-projects`,
`projects-list-archived-routes`) — 35 passed

## Risks

- Low risk. One extra indexed select per create/update that carries goal
ids. Requests that previously 500ed now 422; requests that silently
linked a foreign goal now fail — both are corrections, not regressions.
- Existing rows with foreign links (written before this check) are
untouched; only new writes validate.

## Model Used

Claude Fable 5 (claude-fable-5) via Claude Code, extended thinking with
tool use.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found for goal-id validation)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (doc
comments; no user-facing docs affected)
- [x] I have considered and documented any risks above
This commit is contained in:
Devin Foley authored and GitHub committed 2026-09-03 12:17:27 -07:00
1 parent 2177b85eb5
commit db4eeb1688
2 files changed
+197 -1

No files matched your search

+35 -1
View File
@@ -28,6 +28,7 @@ import {
type PluginManagedProjectDeclaration,
type PluginManagedProjectResolution,
} from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js";
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
@@ -401,6 +402,31 @@ async function attachListMetrics(
}
/** Sync the project_goals join table for a single project. */
/**
* Every goal a project links to must exist and belong to the same company.
* Without this check a nonexistent id only dies at the projects.goal_id
* foreign key — an opaque 500 the caller retries (observed live
* 2026-09-03: four identical retries of one bad id) — and a goal from
* another company would link silently, because the foreign key proves
* existence, not ownership.
*/
async function assertGoalsBelongToCompany(db: Db, companyId: string, goalIds: string[]): Promise<void> {
if (goalIds.length === 0) return;
const unique = [...new Set(goalIds)];
const found = await db
.select({ id: goals.id })
.from(goals)
.where(and(eq(goals.companyId, companyId), inArray(goals.id, unique)));
const foundIds = new Set(found.map((row) => row.id));
const unknown = unique.filter((goalId) => !foundIds.has(goalId));
if (unknown.length > 0) {
throw unprocessable(
`Unknown goal id(s) for this company: ${unknown.join(", ")}`,
{ unknownGoalIds: unknown },
);
}
}
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
// Delete existing links
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
@@ -547,6 +573,7 @@ export function projectService(db: Db) {
): Promise<ProjectWithGoals> => {
const { goalIds: inputGoalIds, ...projectData } = data;
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
if (ids && ids.length > 0) await assertGoalsBelongToCompany(db, companyId, ids);
// Note: color is intentionally NOT auto-assigned. New projects default to
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
@@ -558,7 +585,11 @@ export function projectService(db: Db) {
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
// Also write goalId to the legacy column (first goal or null)
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
// The resolved set is canonical for persistence as well as validation:
// falling back to the raw legacy field here would write an id that
// skipped validation whenever `goalIds: []` and `goalId` arrive
// together (goalIds wins resolution, mirroring the update path).
const legacyGoalId = ids?.[0] ?? null;
const row = await db
.insert(projects)
@@ -794,6 +825,9 @@ export function projectService(db: Db) {
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null);
if (!existingProject) return null;
if (ids && ids.length > 0) {
await assertGoalsBelongToCompany(db, existingProject.companyId, ids);
}
if (projectData.name !== undefined) {
const existingShortname = normalizeProjectUrlKey(existingProject.name);